feat(rfa-ai): Complete RFA Approval Refactor and AI Model Revision
This commit is contained in:
@@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Readable } from 'stream';
|
||||
import { AiIngestService } from './ai-ingest.service';
|
||||
import { AiQueueService } from './ai-queue.service';
|
||||
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
||||
@@ -35,7 +36,7 @@ function makeFile(
|
||||
mimetype: 'application/pdf',
|
||||
buffer: Buffer.from('pdf-content'),
|
||||
size: 1024,
|
||||
stream: null as unknown as NodeJS.ReadableStream,
|
||||
stream: new Readable(),
|
||||
destination: '',
|
||||
filename: 'test.pdf',
|
||||
path: '',
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AiQueueService } from './ai-queue.service';
|
||||
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
|
||||
import { ExtractDocumentDto } from './dto/extract-document.dto';
|
||||
import { AiCallbackDto } from './dto/ai-callback.dto';
|
||||
import { CreateAiJobDto } from './dto/create-ai-job.dto';
|
||||
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
||||
import { MigrationQueryDto } from './dto/migration-query.dto';
|
||||
import {
|
||||
@@ -71,6 +72,49 @@ export class AiController {
|
||||
|
||||
// --- Real-time Extraction (User Upload) ---
|
||||
|
||||
@Post('suggest')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.suggest')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({
|
||||
summary: 'AI Suggest — enqueue metadata suggestion job',
|
||||
description:
|
||||
'รับ documentPublicId/projectPublicId แล้วส่งงานเข้า ai-realtime queue เพื่อให้ frontend polling สถานะ',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key เพื่อป้องกัน duplicate AI Suggest job',
|
||||
required: true,
|
||||
})
|
||||
async suggestDocumentMetadata(
|
||||
@Body() dto: CreateAiJobDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
): Promise<{ success: boolean; jobId?: string; status: string }> {
|
||||
const result = await this.aiService.queueSuggestJob({
|
||||
...dto,
|
||||
jobType: 'ai-suggest',
|
||||
idempotencyKey: idempotencyKey || dto.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
success: result.success,
|
||||
jobId: result.jobId,
|
||||
status: result.success ? 'queued' : 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('jobs/:jobId/status')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.suggest')
|
||||
@ApiOperation({
|
||||
summary: 'AI Job Status — polling endpoint สำหรับ AI Suggest',
|
||||
})
|
||||
@ApiParam({ name: 'jobId', description: 'BullMQ job id' })
|
||||
async getAiJobStatus(@Param('jobId') jobId: string) {
|
||||
return this.aiService.getAiJobStatus(jobId);
|
||||
}
|
||||
|
||||
@Post('extract')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@@ -202,6 +246,43 @@ export class AiController {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Phase 6: AI Analytics & Single Audit Log Delete (T036, T037) ────────
|
||||
|
||||
@Get('analytics/summary')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.read_analytics')
|
||||
@ApiOperation({
|
||||
summary: 'AI Analytics Summary — สรุปสถิติ AI Audit Logs (T036)',
|
||||
description:
|
||||
'คำนวณ avgConfidence, overrideRate, rejectedRate แยกตาม document type และ overall',
|
||||
})
|
||||
async getAnalyticsSummary() {
|
||||
return this.aiService.getAnalyticsSummary();
|
||||
}
|
||||
|
||||
@Delete('audit-logs/:publicId')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.delete_audit')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'AI Audit Log Single Delete — ลบ log เดี่ยวโดย publicId (SYSTEM_ADMIN เท่านั้น) (T037)',
|
||||
description:
|
||||
'ลบ AiAuditLog เดี่ยวและบันทึกใน audit_logs (action: AI_AUDIT_LOG_DELETED)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'publicId',
|
||||
description: 'UUID ของ AiAuditLog (ADR-019)',
|
||||
})
|
||||
async deleteAuditLogByPublicId(
|
||||
@Param('publicId', ParseUuidPipe) publicId: string,
|
||||
@CurrentUser() user: User
|
||||
): Promise<{ deleted: boolean; publicId: string }> {
|
||||
return this.aiService.deleteAuditLogByPublicId(publicId, user.user_id);
|
||||
}
|
||||
|
||||
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
|
||||
|
||||
@Post('rag/query')
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// File: src/modules/ai/ai.module.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม BullMQ/Qdrant/Service Account foundation สำหรับ ADR-023.
|
||||
// - 2026-05-15: เพิ่ม ai-realtime/ai-batch foundation และ stale paused recovery ตาม ADR-023A.
|
||||
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { BullModule, InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiIngestService } from './ai-ingest.service';
|
||||
@@ -16,20 +18,30 @@ import { AiQdrantService } from './qdrant.service';
|
||||
import { AiValidationService } from './ai-validation.service';
|
||||
import { AiRagService } from './ai-rag.service';
|
||||
import { AiRagProcessor } from './processors/rag.processor';
|
||||
import { AiRealtimeProcessor } from './processors/ai-realtime.processor';
|
||||
import { AiBatchProcessor } from './processors/ai-batch.processor';
|
||||
import { AiVectorDeletionProcessor } from './processors/vector-deletion.processor';
|
||||
import { OllamaService } from './services/ollama.service';
|
||||
import { OcrService } from './services/ocr.service';
|
||||
import { EmbeddingService } from './services/embedding.service';
|
||||
import { MigrationLog } from './entities/migration-log.entity';
|
||||
import { AiAuditLog } from './entities/ai-audit-log.entity';
|
||||
import { MigrationReviewRecord } from './entities/migration-review.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { MigrationModule } from '../migration/migration.module';
|
||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||
import { AuditLogModule } from '../audit-log/audit-log.module';
|
||||
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_INGEST,
|
||||
QUEUE_AI_RAG,
|
||||
QUEUE_AI_REALTIME,
|
||||
QUEUE_AI_VECTOR_DELETION,
|
||||
} from '../common/constants/queue.constants';
|
||||
|
||||
@@ -39,7 +51,9 @@ import {
|
||||
TypeOrmModule.forFeature([
|
||||
MigrationLog,
|
||||
AiAuditLog,
|
||||
AuditLog,
|
||||
MigrationReviewRecord,
|
||||
Attachment,
|
||||
Project,
|
||||
Organization,
|
||||
CorrespondenceType,
|
||||
@@ -47,6 +61,24 @@ import {
|
||||
|
||||
BullModule.registerQueue(
|
||||
{ name: QUEUE_AI_INGEST },
|
||||
{
|
||||
name: QUEUE_AI_REALTIME,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: QUEUE_AI_BATCH,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
},
|
||||
{ name: QUEUE_AI_RAG },
|
||||
{ name: QUEUE_AI_VECTOR_DELETION }
|
||||
),
|
||||
@@ -64,6 +96,7 @@ import {
|
||||
UserModule,
|
||||
MigrationModule,
|
||||
FileStorageModule,
|
||||
AuditLogModule,
|
||||
],
|
||||
controllers: [AiController],
|
||||
providers: [
|
||||
@@ -72,6 +105,11 @@ import {
|
||||
AiQueueService,
|
||||
AiQdrantService,
|
||||
AiValidationService,
|
||||
OllamaService,
|
||||
OcrService,
|
||||
EmbeddingService,
|
||||
AiRealtimeProcessor,
|
||||
AiBatchProcessor,
|
||||
// Phase 4: RAG BullMQ pipeline (ADR-023)
|
||||
AiRagService,
|
||||
AiRagProcessor,
|
||||
@@ -86,7 +124,28 @@ import {
|
||||
AiQueueService,
|
||||
AiQdrantService,
|
||||
AiValidationService,
|
||||
OllamaService,
|
||||
OcrService,
|
||||
AiRagService,
|
||||
],
|
||||
})
|
||||
export class AiModule {}
|
||||
export class AiModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiModule.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_AI_REALTIME)
|
||||
private readonly aiRealtimeQueue: Queue,
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
private readonly aiBatchQueue: Queue
|
||||
) {}
|
||||
|
||||
/** ป้องกัน ai-batch ค้าง paused หลัง service restart ระหว่าง ai-realtime job */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const isPaused = await this.aiBatchQueue.isPaused();
|
||||
const activeCount = await this.aiRealtimeQueue.getActiveCount();
|
||||
if (isPaused && activeCount === 0) {
|
||||
await this.aiBatchQueue.resume();
|
||||
this.logger.warn('ai-batch auto-resumed on startup (stale paused state)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AiService } from './ai.service';
|
||||
@@ -15,6 +16,11 @@ import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity';
|
||||
import { AiCallbackDto } from './dto/ai-callback.dto';
|
||||
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
||||
import { NotFoundException, BusinessException } from '../../common/exceptions';
|
||||
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../common/constants/queue.constants';
|
||||
|
||||
describe('AiService', () => {
|
||||
let service: AiService;
|
||||
@@ -38,6 +44,19 @@ describe('AiService', () => {
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMainAuditLogRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueue = {
|
||||
add: jest.fn(),
|
||||
isPaused: jest.fn().mockResolvedValue(false),
|
||||
getActiveCount: jest.fn().mockResolvedValue(0),
|
||||
resume: jest.fn(),
|
||||
getState: jest.fn().mockResolvedValue('completed'),
|
||||
};
|
||||
|
||||
// Mock ConfigService — คืนค่า Config ตาม Key
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
@@ -80,6 +99,8 @@ describe('AiService', () => {
|
||||
);
|
||||
mockAuditLogRepo.create.mockReturnValue({});
|
||||
mockAuditLogRepo.save.mockResolvedValue({});
|
||||
mockMainAuditLogRepo.create.mockReturnValue({});
|
||||
mockMainAuditLogRepo.save.mockResolvedValue({});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -89,6 +110,12 @@ describe('AiService', () => {
|
||||
useValue: mockMigrationLogRepo,
|
||||
},
|
||||
{ provide: getRepositoryToken(AiAuditLog), useValue: mockAuditLogRepo },
|
||||
{
|
||||
provide: getRepositoryToken(AuditLog),
|
||||
useValue: mockMainAuditLogRepo,
|
||||
},
|
||||
{ provide: getQueueToken(QUEUE_AI_REALTIME), useValue: mockQueue },
|
||||
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockQueue },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: HttpService, useValue: mockHttpService },
|
||||
{ provide: AiValidationService, useValue: mockValidationService },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// File: src/modules/ai/ai.service.ts
|
||||
// Service หลักของ AI Gateway — เชื่อมต่อระหว่าง DMS กับ n8n/Ollama Pipeline (ADR-018, ADR-020)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { firstValueFrom, timeout, catchError } from 'rxjs';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
@@ -25,6 +27,14 @@ import { ExtractDocumentDto } from './dto/extract-document.dto';
|
||||
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
||||
import { MigrationQueryDto } from './dto/migration-query.dto';
|
||||
import { AiValidationService } from './ai-validation.service';
|
||||
import { CreateAiJobDto } from './dto/create-ai-job.dto';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../common/constants/queue.constants';
|
||||
import { AiRealtimeJobData } from './processors/ai-realtime.processor';
|
||||
import { AiBatchJobData } from './processors/ai-batch.processor';
|
||||
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||
|
||||
// ผลลัพธ์ของ Real-time Extraction
|
||||
export interface ExtractionResult {
|
||||
@@ -45,6 +55,14 @@ export interface PaginatedResult<T> {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface AnalyticsQueryResult {
|
||||
documentType: string | null;
|
||||
avgConfidence: string | number;
|
||||
total: string | number;
|
||||
overrides: string | number;
|
||||
rejections: string | number;
|
||||
}
|
||||
|
||||
// Context สำหรับส่งไปยัง n8n
|
||||
interface N8nWebhookPayload {
|
||||
migrationLogPublicId: string;
|
||||
@@ -65,6 +83,20 @@ interface N8nWebhookResponse {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AiQueueResult {
|
||||
success: boolean;
|
||||
jobId?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface AiJobStatusResult {
|
||||
jobId: string;
|
||||
queue: 'ai-realtime' | 'ai-batch';
|
||||
status: string;
|
||||
result?: unknown;
|
||||
failedReason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
@@ -82,7 +114,15 @@ export class AiService {
|
||||
@InjectRepository(MigrationLog)
|
||||
private readonly migrationLogRepo: Repository<MigrationLog>,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly aiAuditLogRepo: Repository<AiAuditLog>
|
||||
private readonly aiAuditLogRepo: Repository<AiAuditLog>,
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditLogRepo: Repository<AuditLog>,
|
||||
@Optional()
|
||||
@InjectQueue(QUEUE_AI_REALTIME)
|
||||
private readonly aiRealtimeQueue?: Queue<AiRealtimeJobData>,
|
||||
@Optional()
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
private readonly aiBatchQueue?: Queue<AiBatchJobData>
|
||||
) {
|
||||
this.n8nWebhookUrl =
|
||||
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
|
||||
@@ -95,6 +135,87 @@ export class AiService {
|
||||
this.configService.get<string>('APP_BASE_URL') ?? 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// --- ADR-023A BullMQ Job Queueing ---
|
||||
|
||||
/** ส่งงาน AI Suggest เข้า ai-realtime queue แบบไม่ block request thread */
|
||||
async queueSuggestJob(dto: CreateAiJobDto): Promise<AiQueueResult> {
|
||||
if (!this.aiRealtimeQueue) {
|
||||
const error = new Error('AI realtime queue is not registered');
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await this.aiRealtimeQueue.add(
|
||||
'ai-suggest',
|
||||
{
|
||||
jobType: 'ai-suggest',
|
||||
documentPublicId: dto.documentPublicId,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
payload: dto.payload ?? {},
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
},
|
||||
{ jobId: dto.idempotencyKey }
|
||||
);
|
||||
return { success: true, jobId: String(job.id) };
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** ส่งงาน embedding เข้า ai-batch queue แบบ best-effort */
|
||||
async queueEmbedJob(dto: CreateAiJobDto): Promise<AiQueueResult> {
|
||||
if (!this.aiBatchQueue) {
|
||||
const error = new Error('AI batch queue is not registered');
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await this.aiBatchQueue.add(
|
||||
'embed-document',
|
||||
{
|
||||
jobType: 'embed-document',
|
||||
documentPublicId: dto.documentPublicId,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
payload: dto.payload ?? {},
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
},
|
||||
{ jobId: dto.idempotencyKey }
|
||||
);
|
||||
return { success: true, jobId: String(job.id) };
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** อ่านสถานะ job จาก ai-realtime หรือ ai-batch เพื่อให้ frontend polling ได้ */
|
||||
async getAiJobStatus(jobId: string): Promise<AiJobStatusResult> {
|
||||
const realtimeJob = await this.aiRealtimeQueue?.getJob(jobId);
|
||||
if (realtimeJob) return this.toJobStatus(jobId, 'ai-realtime', realtimeJob);
|
||||
|
||||
const batchJob = await this.aiBatchQueue?.getJob(jobId);
|
||||
if (batchJob) return this.toJobStatus(jobId, 'ai-batch', batchJob);
|
||||
|
||||
return { jobId, queue: 'ai-realtime', status: 'not_found' };
|
||||
}
|
||||
|
||||
// --- Real-time Extraction (สำหรับ User Upload ใหม่) ---
|
||||
|
||||
async extractRealtime(
|
||||
@@ -438,4 +559,136 @@ export class AiService {
|
||||
this.logger.error(`Failed to save AI audit log: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 6: AI Analytics Summary (T036) ---
|
||||
|
||||
/**
|
||||
* สรุปสถิติ AI Audit Logs แยกตาม document type และ status
|
||||
* @returns ข้อมูลสรุป avgConfidence, overrideRate, rejectedRate แยกตาม type
|
||||
*/
|
||||
async getAnalyticsSummary(): Promise<{
|
||||
byDocumentType: Array<{
|
||||
documentType: string;
|
||||
avgConfidence: number;
|
||||
overrideRate: number;
|
||||
rejectedRate: number;
|
||||
total: number;
|
||||
}>;
|
||||
overall: {
|
||||
avgConfidence: number;
|
||||
overrideRate: number;
|
||||
rejectedRate: number;
|
||||
total: number;
|
||||
};
|
||||
}> {
|
||||
// Query ai_audit_logs GROUP BY document type จาก ai_suggestion_json
|
||||
const qb = this.aiAuditLogRepo.createQueryBuilder('log');
|
||||
|
||||
// ดึง document type จาก JSON field
|
||||
const results = await qb
|
||||
.select([
|
||||
"JSON_UNQUOTE(JSON_EXTRACT(log.aiSuggestionJson, '$.documentType')) as documentType",
|
||||
'AVG(log.confidenceScore) as avgConfidence',
|
||||
'COUNT(*) as total',
|
||||
'SUM(CASE WHEN log.humanOverrideJson IS NOT NULL THEN 1 ELSE 0 END) as overrides',
|
||||
'SUM(CASE WHEN log.status = :rejectedStatus THEN 1 ELSE 0 END) as rejections',
|
||||
])
|
||||
.where('log.aiSuggestionJson IS NOT NULL')
|
||||
.andWhere('log.confidenceScore IS NOT NULL')
|
||||
.setParameter('rejectedStatus', AiAuditStatus.FAILED)
|
||||
.groupBy('documentType')
|
||||
.getRawMany<AnalyticsQueryResult>();
|
||||
|
||||
const byDocumentType = results.map((row) => ({
|
||||
documentType: row.documentType || 'UNKNOWN',
|
||||
avgConfidence: Number(row.avgConfidence) || 0,
|
||||
overrideRate:
|
||||
Number(row.total) > 0
|
||||
? (Number(row.overrides) / Number(row.total)) * 100
|
||||
: 0,
|
||||
rejectedRate:
|
||||
Number(row.total) > 0
|
||||
? (Number(row.rejections) / Number(row.total)) * 100
|
||||
: 0,
|
||||
total: Number(row.total),
|
||||
}));
|
||||
|
||||
// คำนวณ overall stats จาก raw results เพื่อความแม่นยำ
|
||||
const totalDocs = results.reduce((sum, row) => sum + Number(row.total), 0);
|
||||
const totalOverrides = results.reduce(
|
||||
(sum, row) => sum + Number(row.overrides),
|
||||
0
|
||||
);
|
||||
const totalRejections = results.reduce(
|
||||
(sum, row) => sum + Number(row.rejections),
|
||||
0
|
||||
);
|
||||
const totalConfidence = results.reduce(
|
||||
(sum, row) => sum + Number(row.avgConfidence) * Number(row.total),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
byDocumentType,
|
||||
overall: {
|
||||
avgConfidence: totalDocs > 0 ? totalConfidence / totalDocs : 0,
|
||||
overrideRate: totalDocs > 0 ? (totalOverrides / totalDocs) * 100 : 0,
|
||||
rejectedRate: totalDocs > 0 ? (totalRejections / totalDocs) * 100 : 0,
|
||||
total: totalDocs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Phase 6: Single Audit Log Delete (T037) ---
|
||||
|
||||
/**
|
||||
* ลบ AiAuditLog แบบ single record โดย publicId
|
||||
* @param publicId UUID ของ audit log ที่ต้องการลบ
|
||||
* @param userId ID ของผู้ทำการลบ (สำหรับ audit trail)
|
||||
*/
|
||||
async deleteAuditLogByPublicId(
|
||||
publicId: string,
|
||||
userId: number
|
||||
): Promise<{ deleted: boolean; publicId: string }> {
|
||||
const auditLog = await this.aiAuditLogRepo.findOne({
|
||||
where: { publicId },
|
||||
});
|
||||
|
||||
if (!auditLog) {
|
||||
throw new NotFoundException('AiAuditLog', publicId);
|
||||
}
|
||||
|
||||
await this.aiAuditLogRepo.remove(auditLog);
|
||||
|
||||
// บันทึกใน audit_logs table (T037 requirement)
|
||||
const auditEntry = this.auditLogRepo.create({
|
||||
userId,
|
||||
action: 'AI_AUDIT_LOG_DELETED',
|
||||
entityType: 'AiAuditLog',
|
||||
entityId: publicId,
|
||||
severity: 'INFO',
|
||||
detailsJson: { deletedAuditLogPublicId: publicId },
|
||||
});
|
||||
await this.auditLogRepo.save(auditEntry);
|
||||
|
||||
this.logger.log(
|
||||
`AI audit log deleted — publicId=${publicId}, deletedBy=${userId}`
|
||||
);
|
||||
|
||||
return { deleted: true, publicId };
|
||||
}
|
||||
|
||||
private async toJobStatus(
|
||||
jobId: string,
|
||||
queue: 'ai-realtime' | 'ai-batch',
|
||||
job: Job<AiRealtimeJobData | AiBatchJobData>
|
||||
): Promise<AiJobStatusResult> {
|
||||
return {
|
||||
jobId,
|
||||
queue,
|
||||
status: await job.getState(),
|
||||
result: job.returnvalue,
|
||||
failedReason: job.failedReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// File: src/modules/ai/dto/create-ai-job.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม DTO สำหรับ enqueue AI jobs ตาม ADR-023A US1.
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export const AI_JOB_TYPES = [
|
||||
'ai-suggest',
|
||||
'rag-query',
|
||||
'ocr',
|
||||
'extract-metadata',
|
||||
'embed-document',
|
||||
] as const;
|
||||
|
||||
export type CreateAiJobType = (typeof AI_JOB_TYPES)[number];
|
||||
|
||||
/** DTO สำหรับส่งงาน AI เข้า BullMQ โดยใช้ publicId เท่านั้นตาม ADR-019 */
|
||||
export class CreateAiJobDto {
|
||||
@ApiProperty({ description: 'Attachment/document publicId สำหรับงาน AI' })
|
||||
@IsUUID()
|
||||
documentPublicId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Project publicId สำหรับ project isolation' })
|
||||
@IsUUID()
|
||||
projectPublicId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: AI_JOB_TYPES,
|
||||
description: 'ชนิดงาน AI ที่ต้อง enqueue',
|
||||
})
|
||||
@IsIn(AI_JOB_TYPES)
|
||||
jobType!: CreateAiJobType;
|
||||
|
||||
@ApiProperty({ description: 'Idempotency key จาก request header/body' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
idempotencyKey!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Payload เพิ่มเติม เช่น pdfPath, extractedText, question',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// File: backend/src/modules/ai/dto/migration-queue-item.dto.ts
|
||||
// บันทึกการแก้ไข: สร้าง DTO สำหรับ Legacy Migration (T029) ตาม ADR-023A
|
||||
|
||||
import { IsString, IsNotEmpty, IsUUID, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MigrationQueueItemDto {
|
||||
@ApiProperty({
|
||||
description: 'n8n batch identifier',
|
||||
example: 'batch-2026-05-15',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
batchId!: string;
|
||||
|
||||
@ApiProperty({ description: 'ชื่อไฟล์ต้นฉบับ', example: 'INV-2026-001.pdf' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
filename!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'เส้นทางไฟล์ชั่วคราวใน storage',
|
||||
example: 'temp/migration/batch-1/INV-001.pdf',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tempPath!: string;
|
||||
|
||||
@ApiProperty({ description: 'UUID ของโครงการ (ถ้าทราบ)', required: false })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectPublicId?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// File: src/modules/ai/entities/migration-review-queue.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม re-export สำหรับชื่อ entity ตาม ADR-023A tasks.md โดยไม่สร้าง metadata ซ้ำ.
|
||||
|
||||
export {
|
||||
MigrationReviewRecord as MigrationReviewQueueEntity,
|
||||
MigrationReviewRecord,
|
||||
MigrationReviewRecordStatus,
|
||||
} from './migration-review.entity';
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: src/modules/ai/entities/migration-review.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม entity staging queue สำหรับ Unified AI Architecture.
|
||||
// - 2026-05-15: เพิ่ม column สำหรับ ADR-023A migration_review_queue schema.
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -28,9 +29,34 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
@Column({ name: 'batch_id', type: 'varchar', length: 100 })
|
||||
batchId!: string;
|
||||
|
||||
@Index('uq_migration_review_idempotency', { unique: true })
|
||||
@Column({
|
||||
name: 'idempotency_key',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
})
|
||||
idempotencyKey?: string;
|
||||
|
||||
@Column({ name: 'original_file_name', type: 'varchar', length: 255 })
|
||||
originalFileName!: string;
|
||||
|
||||
@Column({
|
||||
name: 'original_filename',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
originalFilename?: string;
|
||||
|
||||
@Column({
|
||||
name: 'storage_temp_path',
|
||||
type: 'varchar',
|
||||
length: 1000,
|
||||
nullable: true,
|
||||
})
|
||||
storageTempPath?: string;
|
||||
|
||||
@Column({ name: 'source_attachment_public_id', type: 'uuid', nullable: true })
|
||||
sourceAttachmentPublicId?: string;
|
||||
|
||||
@@ -40,6 +66,9 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
@Column({ name: 'extracted_metadata', type: 'json', nullable: true })
|
||||
extractedMetadata?: Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'ai_metadata_json', type: 'json', nullable: true })
|
||||
aiMetadataJson?: Record<string, unknown>;
|
||||
|
||||
@Column({
|
||||
name: 'confidence_score',
|
||||
type: 'decimal',
|
||||
@@ -49,6 +78,9 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
})
|
||||
confidenceScore?: number;
|
||||
|
||||
@Column({ name: 'ocr_used', type: 'boolean', default: false })
|
||||
ocrUsed!: boolean;
|
||||
|
||||
@Index('idx_migration_review_status')
|
||||
@Column({
|
||||
type: 'enum',
|
||||
@@ -60,6 +92,20 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
@Column({ name: 'error_reason', type: 'text', nullable: true })
|
||||
errorReason?: string;
|
||||
|
||||
@Column({ name: 'reviewed_by', type: 'int', nullable: true })
|
||||
reviewedBy?: number;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'datetime', nullable: true })
|
||||
reviewedAt?: Date;
|
||||
|
||||
@Column({
|
||||
name: 'rejection_reason',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
rejectionReason?: string;
|
||||
|
||||
@VersionColumn({ name: 'version' })
|
||||
version!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// File: src/modules/ai/processors/ai-batch.processor.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
||||
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||
import { QUEUE_AI_BATCH } from '../../common/constants/queue.constants';
|
||||
import { EmbeddingService } from '../services/embedding.service';
|
||||
|
||||
export type AiBatchJobType = 'ocr' | 'extract-metadata' | 'embed-document';
|
||||
|
||||
export interface AiBatchJobData {
|
||||
jobType: AiBatchJobType;
|
||||
documentPublicId: string;
|
||||
projectPublicId: string;
|
||||
payload: Record<string, unknown>;
|
||||
batchId?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM */
|
||||
@Processor(QUEUE_AI_BATCH, { concurrency: 1 })
|
||||
export class AiBatchProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(AiBatchProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>,
|
||||
private readonly embeddingService: EmbeddingService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** Dispatch งาน batch ตาม jobType */
|
||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
|
||||
try {
|
||||
switch (job.data.jobType) {
|
||||
case 'ocr':
|
||||
this.logger.log(`OCR batch job processing — jobId=${String(job.id)}`);
|
||||
// OCR logic handled by OcrService in ai-realtime processor
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
return;
|
||||
case 'extract-metadata':
|
||||
this.logger.log(
|
||||
`Metadata extraction job processing — jobId=${String(job.id)}`
|
||||
);
|
||||
// Metadata extraction handled in ai-realtime processor
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
return;
|
||||
case 'embed-document':
|
||||
this.logger.log(`Embedding job processing — jobId=${String(job.id)}`);
|
||||
await this.processEmbedDocument(job.data);
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
return;
|
||||
default: {
|
||||
const unreachable: never = job.data.jobType;
|
||||
throw new Error(
|
||||
`Unsupported ai-batch jobType: ${String(unreachable)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Batch job failed — jobType=${job.data.jobType}, documentPublicId=${job.data.documentPublicId}`,
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** ประมวลผล embed-document job ด้วย EmbeddingService (T022) */
|
||||
private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
|
||||
const { documentPublicId, projectPublicId, payload } = data;
|
||||
const pdfPath = payload.pdfPath as string;
|
||||
const extractedText = payload.extractedText as string | undefined;
|
||||
|
||||
if (!pdfPath) {
|
||||
throw new Error('pdfPath is required for embed-document job');
|
||||
}
|
||||
|
||||
const result = await this.embeddingService.embedDocument(
|
||||
pdfPath,
|
||||
documentPublicId,
|
||||
projectPublicId,
|
||||
extractedText
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Embedding completed for document ${documentPublicId} — ${result.chunksEmbedded} chunks embedded`
|
||||
);
|
||||
}
|
||||
|
||||
private async setAiProcessingStatus(
|
||||
documentPublicId: string,
|
||||
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED'
|
||||
): Promise<void> {
|
||||
await this.attachmentRepo.update(
|
||||
{ publicId: documentPublicId },
|
||||
{ aiProcessingStatus: status }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// File: src/modules/ai/processors/ai-realtime.processor.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-realtime queue และ pause/resume ai-batch ตาม ADR-023A.
|
||||
|
||||
import {
|
||||
Processor,
|
||||
WorkerHost,
|
||||
OnWorkerEvent,
|
||||
InjectQueue,
|
||||
} from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../../common/constants/queue.constants';
|
||||
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
|
||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||
import { OcrService } from '../services/ocr.service';
|
||||
import { OllamaService } from '../services/ollama.service';
|
||||
|
||||
export type AiRealtimeJobType = 'ai-suggest' | 'rag-query';
|
||||
|
||||
export interface AiRealtimeJobData {
|
||||
jobType: AiRealtimeJobType;
|
||||
documentPublicId?: string;
|
||||
projectPublicId: string;
|
||||
userId?: number;
|
||||
payload: Record<string, unknown>;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** Processor สำหรับงาน AI interactive ที่ต้องกัน batch job ระหว่างใช้ GPU */
|
||||
@Processor(QUEUE_AI_REALTIME, { concurrency: 1 })
|
||||
export class AiRealtimeProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(AiRealtimeProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
private readonly aiBatchQueue: Queue,
|
||||
private readonly ocrService: OcrService,
|
||||
private readonly ollamaService: OllamaService,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly aiAuditLogRepo: Repository<AiAuditLog>,
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** Dispatch งาน ai-realtime ตาม jobType */
|
||||
async process(job: Job<AiRealtimeJobData>): Promise<unknown> {
|
||||
switch (job.data.jobType) {
|
||||
case 'ai-suggest':
|
||||
return this.processSuggest(job);
|
||||
case 'rag-query':
|
||||
this.logger.log(`RAG query queued — jobId=${String(job.id)}`);
|
||||
return;
|
||||
default: {
|
||||
const unreachable: never = job.data.jobType;
|
||||
throw new Error(
|
||||
`Unsupported ai-realtime jobType: ${String(unreachable)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processSuggest(
|
||||
job: Job<AiRealtimeJobData>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
if (job.data.documentPublicId) {
|
||||
await this.setAiProcessingStatus(
|
||||
job.data.documentPublicId,
|
||||
'PROCESSING'
|
||||
);
|
||||
}
|
||||
const extractedText =
|
||||
typeof job.data.payload['extractedText'] === 'string'
|
||||
? job.data.payload['extractedText']
|
||||
: '';
|
||||
const pdfPath =
|
||||
typeof job.data.payload['pdfPath'] === 'string'
|
||||
? job.data.payload['pdfPath']
|
||||
: undefined;
|
||||
const extractedChars =
|
||||
typeof job.data.payload['extractedChars'] === 'number'
|
||||
? job.data.payload['extractedChars']
|
||||
: extractedText.length;
|
||||
|
||||
const textResult = await this.ocrService.detectAndExtract({
|
||||
extractedText,
|
||||
extractedChars,
|
||||
pdfPath,
|
||||
});
|
||||
|
||||
const prompt = [
|
||||
'Extract concise DMS metadata from this engineering document.',
|
||||
'Return only JSON with fields: title, documentType, category, confidenceScore.',
|
||||
textResult.text.slice(0, 6000),
|
||||
].join('\n');
|
||||
|
||||
const rawOutput = await this.ollamaService.generate(prompt);
|
||||
const suggestion = this.parseSuggestion(rawOutput);
|
||||
const normalizedSuggestion = this.flagUnknownCategories(
|
||||
suggestion,
|
||||
job.data.payload['masterDataCategories']
|
||||
);
|
||||
|
||||
await this.aiAuditLogRepo.save(
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
aiSuggestionJson: normalizedSuggestion,
|
||||
confidenceScore: this.extractConfidence(normalizedSuggestion),
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
})
|
||||
);
|
||||
if (job.data.documentPublicId) {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
}
|
||||
return {
|
||||
suggestion: normalizedSuggestion,
|
||||
ocrUsed: textResult.ocrUsed,
|
||||
};
|
||||
} catch (err) {
|
||||
if (job.data.documentPublicId) {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
|
||||
}
|
||||
await this.aiAuditLogRepo.save(
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private parseSuggestion(rawOutput: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(rawOutput) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('AI suggestion output was not valid JSON');
|
||||
}
|
||||
return {
|
||||
title: rawOutput.slice(0, 250),
|
||||
confidenceScore: 0,
|
||||
is_unknown: true,
|
||||
};
|
||||
}
|
||||
|
||||
private flagUnknownCategories(
|
||||
suggestion: Record<string, unknown>,
|
||||
masterDataCategories: unknown
|
||||
): Record<string, unknown> {
|
||||
if (!Array.isArray(masterDataCategories)) return suggestion;
|
||||
const knownValues = new Set(
|
||||
masterDataCategories
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => value.toLowerCase())
|
||||
);
|
||||
const category = suggestion['category'];
|
||||
if (
|
||||
typeof category === 'string' &&
|
||||
!knownValues.has(category.toLowerCase())
|
||||
) {
|
||||
return { ...suggestion, is_unknown: true };
|
||||
}
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
private extractConfidence(
|
||||
suggestion: Record<string, unknown>
|
||||
): number | undefined {
|
||||
const confidence = suggestion['confidenceScore'];
|
||||
return typeof confidence === 'number' ? confidence : undefined;
|
||||
}
|
||||
|
||||
private async setAiProcessingStatus(
|
||||
documentPublicId: string,
|
||||
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED'
|
||||
): Promise<void> {
|
||||
await this.attachmentRepo.update(
|
||||
{ publicId: documentPublicId },
|
||||
{ aiProcessingStatus: status }
|
||||
);
|
||||
}
|
||||
|
||||
/** เมื่อ interactive job เริ่ม ให้ pause batch queue เพื่อกัน GPU contention */
|
||||
@OnWorkerEvent('active')
|
||||
async onActive(job: Job<AiRealtimeJobData>): Promise<void> {
|
||||
await this.aiBatchQueue.pause();
|
||||
this.logger.warn(
|
||||
`ai-batch paused while ai-realtime job is active — jobId=${String(job.id)}`
|
||||
);
|
||||
}
|
||||
|
||||
/** เมื่อ interactive job เสร็จ ให้ resume batch queue */
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job<AiRealtimeJobData>): Promise<void> {
|
||||
await this.aiBatchQueue.resume();
|
||||
this.logger.log(
|
||||
`ai-batch resumed after ai-realtime completion — jobId=${String(job.id)}`
|
||||
);
|
||||
}
|
||||
|
||||
/** เมื่อ interactive job fail ให้ resume batch queue เช่นกัน */
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job<AiRealtimeJobData> | undefined): Promise<void> {
|
||||
await this.aiBatchQueue.resume();
|
||||
this.logger.warn(
|
||||
`ai-batch resumed after ai-realtime failure — jobId=${String(job?.id ?? 'unknown')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,11 +66,11 @@ export class AiQdrantService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** ค้นหา vector โดยบังคับ projectPublicId เพื่อป้องกันข้อมูลข้ามโครงการ */
|
||||
async searchByProject(
|
||||
vector: number[],
|
||||
/** ค้นหา vector โดยบังคับ projectPublicId เป็น parameter แรกตาม ADR-023A */
|
||||
async search(
|
||||
projectPublicId: string,
|
||||
limit: number
|
||||
vector: number[],
|
||||
topK = 5
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
@@ -78,7 +78,7 @@ export class AiQdrantService implements OnModuleInit {
|
||||
|
||||
const results = await this.client.search(AI_COLLECTION_NAME, {
|
||||
vector,
|
||||
limit,
|
||||
limit: topK,
|
||||
filter: {
|
||||
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
||||
},
|
||||
@@ -92,6 +92,15 @@ export class AiQdrantService implements OnModuleInit {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */
|
||||
async searchByProject(
|
||||
vector: number[],
|
||||
projectPublicId: string,
|
||||
limit: number
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
return this.search(projectPublicId, vector, limit);
|
||||
}
|
||||
|
||||
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */
|
||||
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
|
||||
await this.client.delete(AI_COLLECTION_NAME, {
|
||||
@@ -101,4 +110,32 @@ export class AiQdrantService implements OnModuleInit {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Upsert vectors ไป Qdrant พร้อม project isolation (T021) */
|
||||
async upsert(
|
||||
projectPublicId: string,
|
||||
points: Array<{
|
||||
id: string;
|
||||
vector: number[];
|
||||
payload: Record<string, unknown>;
|
||||
}>
|
||||
): Promise<void> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
// เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation
|
||||
const pointsWithProject = points.map((point) => ({
|
||||
...point,
|
||||
payload: {
|
||||
...point.payload,
|
||||
project_public_id: projectPublicId,
|
||||
},
|
||||
}));
|
||||
|
||||
await this.client.upsert(AI_COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: pointsWithProject,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// File: src/modules/ai/services/embedding.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ full-document chunked embedding ตาม ADR-023A T021.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OllamaService } from './ollama.service';
|
||||
import { AiQdrantService } from '../qdrant.service';
|
||||
import { OcrService } from './ocr.service';
|
||||
|
||||
export interface EmbeddingChunk {
|
||||
chunkIndex: number;
|
||||
text: string;
|
||||
pageNumber?: number;
|
||||
}
|
||||
|
||||
export interface EmbeddingResult {
|
||||
success: boolean;
|
||||
chunksEmbedded: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** บริการสร้าง embedding สำหรับ full-document RAG (ADR-023A) */
|
||||
@Injectable()
|
||||
export class EmbeddingService {
|
||||
private readonly logger = new Logger(EmbeddingService.name);
|
||||
private readonly chunkSize: number;
|
||||
private readonly overlap: number;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly ollamaService: OllamaService,
|
||||
private readonly qdrantService: AiQdrantService,
|
||||
private readonly ocrService: OcrService
|
||||
) {
|
||||
this.chunkSize = this.configService.get<number>(
|
||||
'EMBEDDING_CHUNK_SIZE',
|
||||
512
|
||||
);
|
||||
this.overlap = this.configService.get<number>(
|
||||
'EMBEDDING_CHUNK_OVERLAP',
|
||||
64
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง embedding สำหรับเอกสารทั้งฉบับ:
|
||||
* 1. ดึงข้อความ full-doc (ใช้ extractedText หรือ OCR)
|
||||
* 2. Chunk text 512 tokens / 64 overlap
|
||||
* 3. Generate embedding ต่อ chunk ด้วย nomic-embed-text
|
||||
* 4. Upsert ไป Qdrant พร้อม project isolation
|
||||
*/
|
||||
async embedDocument(
|
||||
pdfPath: string,
|
||||
documentPublicId: string,
|
||||
projectPublicId: string,
|
||||
extractedText?: string
|
||||
): Promise<EmbeddingResult> {
|
||||
try {
|
||||
// 1. ดึงข้อความจาก PDF (ใช้ extractedText ถ้ามี หรือเรียก OCR)
|
||||
let fullText = extractedText;
|
||||
if (!fullText) {
|
||||
const ocrResult = await this.ocrService.detectAndExtract({
|
||||
pdfPath,
|
||||
extractedText: '',
|
||||
extractedChars: 0,
|
||||
});
|
||||
fullText = ocrResult.text;
|
||||
}
|
||||
|
||||
if (!fullText || fullText.trim().length === 0) {
|
||||
this.logger.warn(`No text extracted from document ${documentPublicId}`);
|
||||
return {
|
||||
success: false,
|
||||
chunksEmbedded: 0,
|
||||
error: 'No text extracted',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Chunk text
|
||||
const chunks = this.chunkText(fullText);
|
||||
this.logger.log(
|
||||
`Document ${documentPublicId} split into ${chunks.length} chunks`
|
||||
);
|
||||
|
||||
// 3. Generate embedding และ upsert ไป Qdrant
|
||||
const points = [];
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
const embedding = await this.ollamaService.generateEmbedding(
|
||||
chunk.text
|
||||
);
|
||||
points.push({
|
||||
id: `${documentPublicId}-${chunk.chunkIndex}`,
|
||||
vector: embedding,
|
||||
payload: {
|
||||
document_public_id: documentPublicId,
|
||||
chunk_index: chunk.chunkIndex,
|
||||
page_number: chunk.pageNumber,
|
||||
chunk_text: chunk.text,
|
||||
embedded_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to embed chunk ${chunk.chunkIndex} for document ${documentPublicId}`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
chunksEmbedded: 0,
|
||||
error: 'All chunks failed to embed',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Upsert ไป Qdrant พร้อม project isolation
|
||||
await this.qdrantService.upsert(projectPublicId, points);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully embedded ${points.length} chunks for document ${documentPublicId} in project ${projectPublicId}`
|
||||
);
|
||||
|
||||
return { success: true, chunksEmbedded: points.length };
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`Embedding failed for document ${documentPublicId}: ${errorMsg}`
|
||||
);
|
||||
return { success: false, chunksEmbedded: 0, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text ด้วย overlap
|
||||
* - chunkSize: 512 characters (approximate token equivalent)
|
||||
* - overlap: 64 characters
|
||||
*/
|
||||
private chunkText(text: string): EmbeddingChunk[] {
|
||||
const chunks: EmbeddingChunk[] = [];
|
||||
const cleanText = text.replace(/\s+/g, ' ').trim();
|
||||
const textLength = cleanText.length;
|
||||
|
||||
let startIndex = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
while (startIndex < textLength) {
|
||||
const endIndex = Math.min(startIndex + this.chunkSize, textLength);
|
||||
const chunkText = cleanText.substring(startIndex, endIndex);
|
||||
|
||||
chunks.push({
|
||||
chunkIndex,
|
||||
text: chunkText,
|
||||
pageNumber: undefined, // TODO: Extract page numbers if available
|
||||
});
|
||||
|
||||
startIndex += this.chunkSize - this.overlap;
|
||||
chunkIndex += 1;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// File: backend/src/modules/ai/services/migration.service.ts
|
||||
// บันทึกการแก้ไข: สร้าง MigrationService สำหรับ Legacy Migration (T030) ตาม ADR-023A
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import {
|
||||
MigrationReviewRecord,
|
||||
MigrationReviewRecordStatus,
|
||||
} from '../entities/migration-review.entity';
|
||||
import { MigrationQueueItemDto } from '../dto/migration-queue-item.dto';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MigrationService {
|
||||
private readonly logger = new Logger(MigrationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(MigrationReviewRecord)
|
||||
private readonly migrationRepo: Repository<MigrationReviewRecord>,
|
||||
@InjectQueue('ai-batch')
|
||||
private readonly aiBatchQueue: Queue,
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue a legacy document for human review and AI extraction
|
||||
*/
|
||||
async queueForReview(dto: MigrationQueueItemDto, idempotencyKey: string) {
|
||||
this.logger.log(
|
||||
`📥 Queuing legacy document for review: ${dto.filename} (Batch: ${dto.batchId})`
|
||||
);
|
||||
|
||||
// 1. Check idempotency
|
||||
const existing = await this.migrationRepo.findOne({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 2. Create pending record
|
||||
const record = this.migrationRepo.create({
|
||||
batchId: dto.batchId,
|
||||
idempotencyKey: idempotencyKey,
|
||||
originalFilename: dto.filename,
|
||||
storageTempPath: dto.tempPath,
|
||||
status: MigrationReviewRecordStatus.PENDING,
|
||||
aiMetadataJson: {}, // Will be updated by AI processor
|
||||
confidenceScore: 0,
|
||||
});
|
||||
|
||||
const saved = await this.migrationRepo.save(record);
|
||||
|
||||
// 3. Queue AI processing (OCR + Metadata Extraction)
|
||||
await this.aiBatchQueue.add('extract-metadata', {
|
||||
migrationQueuePublicId: saved.publicId,
|
||||
tempPath: dto.tempPath,
|
||||
filename: dto.filename,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all migration queue items with pagination
|
||||
*/
|
||||
async findAll(page = 1, limit = 20, status?: string) {
|
||||
const query = this.migrationRepo
|
||||
.createQueryBuilder('q')
|
||||
.orderBy('q.createdAt', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
if (status) {
|
||||
query.andWhere('q.status = :status', { status });
|
||||
}
|
||||
|
||||
const [items, total] = await query.getManyAndCount();
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a migration item and import it as a real document
|
||||
*/
|
||||
async approve(publicId: string, user: User) {
|
||||
const item = await this.migrationRepo.findOne({ where: { publicId } });
|
||||
if (!item) throw new NotFoundException('Migration item not found');
|
||||
if (item.status !== MigrationReviewRecordStatus.PENDING)
|
||||
throw new BadRequestException(
|
||||
`Cannot approve item in status ${item.status}`
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`✅ Approving migration item: ${item.originalFilename} (uuid: ${publicId})`
|
||||
);
|
||||
|
||||
// TODO: Implement actual document import logic here in US3 Phase 5
|
||||
// This will involve calling FileStorageService, CorrespondenceService, etc.
|
||||
|
||||
item.status = MigrationReviewRecordStatus.IMPORTED;
|
||||
item.reviewedBy = user.user_id;
|
||||
item.reviewedAt = new Date();
|
||||
|
||||
return this.migrationRepo.save(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a migration item
|
||||
*/
|
||||
async reject(publicId: string, user: User, reason: string) {
|
||||
const item = await this.migrationRepo.findOne({ where: { publicId } });
|
||||
if (!item) throw new NotFoundException('Migration item not found');
|
||||
|
||||
item.status = MigrationReviewRecordStatus.REJECTED;
|
||||
item.reviewedBy = user.user_id;
|
||||
item.reviewedAt = new Date();
|
||||
item.rejectionReason = reason;
|
||||
|
||||
return this.migrationRepo.save(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// File: src/modules/ai/services/ocr.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม OCR auto-detection service สำหรับ ADR-023A.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface OcrDetectionInput {
|
||||
extractedText?: string;
|
||||
extractedChars?: number;
|
||||
pdfPath?: string;
|
||||
}
|
||||
|
||||
export interface OcrDetectionResult {
|
||||
text: string;
|
||||
ocrUsed: boolean;
|
||||
}
|
||||
|
||||
interface PaddleOcrResponse {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** บริการเลือก fast path หรือ PaddleOCR sidecar ตามจำนวนตัวอักษรที่ extract ได้ */
|
||||
@Injectable()
|
||||
export class OcrService {
|
||||
private readonly logger = new Logger(OcrService.name);
|
||||
private readonly threshold: number;
|
||||
private readonly ocrApiUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.threshold = this.configService.get<number>('OCR_CHAR_THRESHOLD', 100);
|
||||
this.ocrApiUrl = this.configService.get<string>(
|
||||
'OCR_API_URL',
|
||||
'http://localhost:8765'
|
||||
);
|
||||
}
|
||||
|
||||
/** ตรวจสอบ text layer ก่อนเลือก OCR slow path */
|
||||
async detectAndExtract(
|
||||
input: OcrDetectionInput
|
||||
): Promise<OcrDetectionResult> {
|
||||
const extractedText = input.extractedText ?? '';
|
||||
const extractedChars = input.extractedChars ?? extractedText.length;
|
||||
|
||||
if (extractedChars > this.threshold) {
|
||||
return { text: extractedText, ocrUsed: false };
|
||||
}
|
||||
|
||||
if (!input.pdfPath) {
|
||||
this.logger.warn('OCR slow path skipped because pdfPath is missing');
|
||||
return { text: extractedText, ocrUsed: false };
|
||||
}
|
||||
|
||||
const response = await axios.post<PaddleOcrResponse>(
|
||||
`${this.ocrApiUrl}/ocr`,
|
||||
{ pdfPath: input.pdfPath },
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
|
||||
return {
|
||||
text: response.data.text ?? '',
|
||||
ocrUsed: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// File: src/modules/ai/services/ollama.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface OllamaGenerateOptions {
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
|
||||
@Injectable()
|
||||
export class OllamaService {
|
||||
private readonly logger = new Logger(OllamaService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly mainModel: string;
|
||||
private readonly embedModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
||||
);
|
||||
this.mainModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
'gemma4:e4b'
|
||||
);
|
||||
this.embedModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_EMBED',
|
||||
this.configService.get<string>('OLLAMA_EMBED_MODEL', 'nomic-embed-text')
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
/** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */
|
||||
async generate(
|
||||
prompt: string,
|
||||
options: OllamaGenerateOptions = {}
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
{
|
||||
model: this.mainModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
timeout: options.timeoutMs ?? this.timeoutMs,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
return response.data.response ?? '';
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Ollama generate failed',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** สร้าง embedding ด้วย nomic-embed-text หรือค่า ENV ที่กำหนด */
|
||||
async generateEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const response = await axios.post<{ embedding: number[] }>(
|
||||
`${this.ollamaUrl}/api/embeddings`,
|
||||
{ model: this.embedModel, prompt: text },
|
||||
{ timeout: this.timeoutMs }
|
||||
);
|
||||
return response.data.embedding;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Ollama embedding failed',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** คืนชื่อ main model สำหรับ audit log */
|
||||
getMainModelName(): string {
|
||||
return this.mainModel;
|
||||
}
|
||||
|
||||
/** คืนชื่อ embedding model สำหรับ audit log */
|
||||
getEmbeddingModelName(): string {
|
||||
return this.embedModel;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user