feat(ai): implement unified prompt management UX/UI (ADR-037)
- Add context config endpoints (GET/PUT /api/ai/prompts/:type/:version/context-config) - Add execution profile endpoints (CRUD /api/ai/execution-profiles) - Add sandbox RAG Prep endpoint (POST /api/ai/admin/sandbox/rag-prep) - Create Prompt Management UI with multi-type support - Add ContextConfigEditor, PromptEditor, RuntimeParametersPanel components - Add SandboxTabs for 3-step workflow (OCR, Extract, RAG Prep) - Add database deltas for ai_execution_profiles and additional prompt types - Update quickstart.md with production backend URLs - Add comprehensive test coverage for new features
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
|
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
|
||||||
// - 2026-05-21: เพิ่มการลงทะเบียน QUEUE_AI_BATCH และ enqueueSandboxJob สำหรับ Superadmin sandbox.
|
// - 2026-05-21: เพิ่มการลงทะเบียน QUEUE_AI_BATCH และ enqueueSandboxJob สำหรับ Superadmin sandbox.
|
||||||
// - 2026-05-21: แก้ไข ESLint error โดยการเปลี่ยน Queue<any> เป็น Queue<unknown> สำหรับ batchQueue
|
// - 2026-05-21: แก้ไข ESLint error โดยการเปลี่ยน Queue<any> เป็น Queue<unknown> สำหรับ batchQueue
|
||||||
|
// - 2026-06-14: เพิ่ม sandbox-rag-prep ใน enqueueSandboxJob (T039)
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue, JobsOptions } from 'bullmq';
|
import { Queue, JobsOptions } from 'bullmq';
|
||||||
@@ -122,7 +123,8 @@ export class AiQueueService {
|
|||||||
| 'sandbox-rag'
|
| 'sandbox-rag'
|
||||||
| 'sandbox-extract'
|
| 'sandbox-extract'
|
||||||
| 'sandbox-ocr-only'
|
| 'sandbox-ocr-only'
|
||||||
| 'sandbox-ai-extract',
|
| 'sandbox-ai-extract'
|
||||||
|
| 'sandbox-rag-prep',
|
||||||
payload: {
|
payload: {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
projectPublicId?: string;
|
projectPublicId?: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
// - 2026-06-11: แก้ไขการส่งพารามิเตอร์ให้กับ queueSuggestJob ใน suggestDocumentMetadata
|
// - 2026-06-11: แก้ไขการส่งพารามิเตอร์ให้กับ queueSuggestJob ใน suggestDocumentMetadata
|
||||||
// - 2026-06-13: T024-T026 — เพิ่ม sandbox parameter endpoints (GET/PUT/POST reset) ตาม ADR-036
|
// - 2026-06-13: T024-T026 — เพิ่ม sandbox parameter endpoints (GET/PUT/POST reset) ตาม ADR-036
|
||||||
// - 2026-06-13: T036, T037, T039, T040, T041 — เพิ่ม endpoints apply sandbox profile และ get production parameters พร้อม idempotency, CASL, validation และ audit
|
// - 2026-06-13: T036, T037, T039, T040, T041 — เพิ่ม endpoints apply sandbox profile และ get production parameters พร้อม idempotency, CASL, validation และ audit
|
||||||
|
// - 2026-06-14: เพิ่ม POST /ai/admin/sandbox/rag-prep endpoint (T033)
|
||||||
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
import { AiRagService } from './ai-rag.service';
|
import { AiRagService } from './ai-rag.service';
|
||||||
import { AiQueueService } from './ai-queue.service';
|
import { AiQueueService } from './ai-queue.service';
|
||||||
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
|
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
|
||||||
|
import { SandboxRagPrepDto } from './dto/sandbox-rag-prep.dto';
|
||||||
import { ExtractDocumentDto } from './dto/extract-document.dto';
|
import { ExtractDocumentDto } from './dto/extract-document.dto';
|
||||||
import { AiCallbackDto } from './dto/ai-callback.dto';
|
import { AiCallbackDto } from './dto/ai-callback.dto';
|
||||||
import { CreateAiJobDto } from './dto/create-ai-job.dto';
|
import { CreateAiJobDto } from './dto/create-ai-job.dto';
|
||||||
@@ -430,6 +432,7 @@ export class AiController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@RequirePermission('system.manage_all')
|
@RequirePermission('system.manage_all')
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'AI Admin Sandbox RAG Query — ส่ง sandbox RAG เข้า queue ai-batch (T035)',
|
'AI Admin Sandbox RAG Query — ส่ง sandbox RAG เข้า queue ai-batch (T035)',
|
||||||
@@ -483,6 +486,7 @@ export class AiController {
|
|||||||
@RequirePermission('system.manage_all')
|
@RequirePermission('system.manage_all')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'AI Admin Sandbox OCR Extract — อัปโหลดไฟล์เพื่อทำ OCR Sandbox (T041 & T042)',
|
'AI Admin Sandbox OCR Extract — อัปโหลดไฟล์เพื่อทำ OCR Sandbox (T041 & T042)',
|
||||||
@@ -542,6 +546,7 @@ export class AiController {
|
|||||||
@RequirePermission('system.manage_all')
|
@RequirePermission('system.manage_all')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Step 1: Run OCR Only — สำหรับตรวจคุณภาพ OCR ก่อนทดสอบ AI',
|
summary: 'Step 1: Run OCR Only — สำหรับตรวจคุณภาพ OCR ก่อนทดสอบ AI',
|
||||||
description:
|
description:
|
||||||
@@ -636,6 +641,7 @@ export class AiController {
|
|||||||
@Post('admin/sandbox/ai-extract')
|
@Post('admin/sandbox/ai-extract')
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||||
@RequirePermission('system.manage_all')
|
@RequirePermission('system.manage_all')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Step 2: Run AI Extraction — ใช้ OCR text ที่ cache จาก Step 1',
|
summary: 'Step 2: Run AI Extraction — ใช้ OCR text ที่ cache จาก Step 1',
|
||||||
description:
|
description:
|
||||||
@@ -668,6 +674,29 @@ export class AiController {
|
|||||||
return { requestPublicId, jobId, status: 'queued' };
|
return { requestPublicId, jobId, status: 'queued' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('admin/sandbox/rag-prep')
|
||||||
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||||
|
@RequirePermission('system.manage_all')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Step 3: Run RAG Prep Sandbox testing (T033)',
|
||||||
|
description:
|
||||||
|
'รับข้อความ OCR และ profileId แล้วรัน semantic chunking และ embedding preview',
|
||||||
|
})
|
||||||
|
async submitSandboxRagPrep(
|
||||||
|
@Body() dto: SandboxRagPrepDto
|
||||||
|
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
||||||
|
const requestPublicId = uuidv7();
|
||||||
|
const jobId = await this.aiQueueService.enqueueSandboxJob(
|
||||||
|
'sandbox-rag-prep',
|
||||||
|
{
|
||||||
|
idempotencyKey: requestPublicId,
|
||||||
|
extraPayload: { text: dto.text, profileId: dto.profileId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return { requestPublicId, jobId, status: 'queued' };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Webhook Callback จาก n8n (Service Account) ---
|
// --- Webhook Callback จาก n8n (Service Account) ---
|
||||||
|
|
||||||
@Post('callback')
|
@Post('callback')
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// File: backend/src/modules/ai/dto/context-config.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created ContextConfigDto for prompt context management (conforming to task T006)
|
||||||
|
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsInt, IsString, IsObject, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ContextFilterDto {
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectId!: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contractId!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContextConfigDto {
|
||||||
|
@ApiPropertyOptional({ type: ContextFilterDto, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
filter?: ContextFilterDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, minimum: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
@IsString()
|
||||||
|
language!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
@IsString()
|
||||||
|
outputLanguage!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// File: backend/src/modules/ai/dto/create-execution-profile.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created CreateExecutionProfileDto for AI execution profile creation (conforming to task T008)
|
||||||
|
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsNumber,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateExecutionProfileDto {
|
||||||
|
@ApiProperty({ description: 'Profile Name' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
profileName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Canonical Model',
|
||||||
|
enum: ['np-dms-ai', 'np-dms-ocr'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
canonicalModel?: 'np-dms-ai' | 'np-dms-ocr';
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Temperature parameter' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0)
|
||||||
|
@Max(1.0)
|
||||||
|
temperature!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Top-P parameter' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0)
|
||||||
|
@Max(1.0)
|
||||||
|
topP!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Repeat penalty parameter' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1.0)
|
||||||
|
@Max(2.0)
|
||||||
|
repeatPenalty!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum tokens to generate' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxTokens?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Context window size (num_ctx / ctxSize)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
ctxSize?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Keep alive in seconds' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
keepAlive!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// File: backend/src/modules/ai/dto/sandbox-rag-prep.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created SandboxRagPrepDto for Sandbox RAG Prep testing (conforming to task T007)
|
||||||
|
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SandboxRagPrepDto {
|
||||||
|
@ApiProperty({ description: 'Text to prepare for RAG (OCR text)' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
text!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Execution profile public ID to use' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
profileId?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// File: backend/src/modules/ai/dto/update-execution-profile.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created UpdateExecutionProfileDto for AI execution profile updates (conforming to task T009)
|
||||||
|
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsNumber, IsInt, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateExecutionProfileDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Temperature parameter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0)
|
||||||
|
@Max(1.0)
|
||||||
|
temperature?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Top-P parameter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0)
|
||||||
|
@Max(1.0)
|
||||||
|
topP?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Repeat penalty parameter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1.0)
|
||||||
|
@Max(2.0)
|
||||||
|
repeatPenalty?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum tokens to generate' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxTokens?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Context window size' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
ctxSize?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Keep alive in seconds' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
keepAlive?: number;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// File: backend/src/modules/ai/processors/ai-batch.processor.ts
|
// File: backend/src/modules/ai/processors/ai-batch.processor.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
// - 2026-06-08: แก้ไขปัญหา LLM JSON response truncated โดยการเพิ่ม num_ctx เป็น 16384 ใน sandbox-extract, sandbox-ai-extract และ migrate-document (แก้ไขโดย AGY Gemini 3.5 Flash (Medium))
|
// - 2026-06-08: แก้ไขปัญหา LLM JSON response truncated โดยการเพิ่ม num_ctx เป็น 16384 ใน sandbox-extract, sandbox-ai-extract และ migrate-document (แก้ไขโดย AGY Gemini 3.5 Flash (Medium))
|
||||||
|
// - 2026-06-14: เพิ่ม case sandbox-rag-prep และ processSandboxRagPrep (T035)
|
||||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
||||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
||||||
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
|
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
|
||||||
@@ -70,6 +71,7 @@ export type AiBatchJobType =
|
|||||||
| 'sandbox-extract'
|
| 'sandbox-extract'
|
||||||
| 'sandbox-ocr-only'
|
| 'sandbox-ocr-only'
|
||||||
| 'sandbox-ai-extract'
|
| 'sandbox-ai-extract'
|
||||||
|
| 'sandbox-rag-prep'
|
||||||
| 'migrate-document'
|
| 'migrate-document'
|
||||||
| 'rag-prepare'
|
| 'rag-prepare'
|
||||||
| 'ai-suggest'
|
| 'ai-suggest'
|
||||||
@@ -294,7 +296,10 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||||
const isSandbox =
|
const isSandbox =
|
||||||
job.data.jobType === 'sandbox-rag' ||
|
job.data.jobType === 'sandbox-rag' ||
|
||||||
job.data.jobType === 'sandbox-extract';
|
job.data.jobType === 'sandbox-extract' ||
|
||||||
|
job.data.jobType === 'sandbox-ocr-only' ||
|
||||||
|
job.data.jobType === 'sandbox-ai-extract' ||
|
||||||
|
job.data.jobType === 'sandbox-rag-prep';
|
||||||
if (!isSandbox) {
|
if (!isSandbox) {
|
||||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
|
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
|
||||||
}
|
}
|
||||||
@@ -362,6 +367,12 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
);
|
);
|
||||||
await this.processSandboxAiExtract(job.data);
|
await this.processSandboxAiExtract(job.data);
|
||||||
return;
|
return;
|
||||||
|
case 'sandbox-rag-prep':
|
||||||
|
this.logger.log(
|
||||||
|
`Sandbox RAG Prep job processing — jobId=${String(job.id)}`
|
||||||
|
);
|
||||||
|
await this.processSandboxRagPrep(job.data);
|
||||||
|
return;
|
||||||
case 'migrate-document':
|
case 'migrate-document':
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Migrate document job processing — jobId=${String(job.id)}`
|
`Migrate document job processing — jobId=${String(job.id)}`
|
||||||
@@ -1530,4 +1541,149 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
const confidence = suggestion['confidenceScore'];
|
const confidence = suggestion['confidenceScore'];
|
||||||
return typeof confidence === 'number' ? confidence : undefined;
|
return typeof confidence === 'number' ? confidence : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async processSandboxRagPrep(data: AiBatchJobData): Promise<void> {
|
||||||
|
const { idempotencyKey, payload } = data;
|
||||||
|
const text = payload.text as string;
|
||||||
|
const profileId = payload.profileId as string | undefined;
|
||||||
|
await this.redis.setex(
|
||||||
|
`ai:rag:result:${idempotencyKey}`,
|
||||||
|
3600,
|
||||||
|
JSON.stringify({
|
||||||
|
requestPublicId: idempotencyKey,
|
||||||
|
status: 'processing',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('text is required for sandbox-rag-prep job');
|
||||||
|
}
|
||||||
|
const activePrompt =
|
||||||
|
await this.aiPromptsService.getActive('rag_prep_prompt');
|
||||||
|
if (!activePrompt) {
|
||||||
|
throw new Error('No active rag_prep_prompt version found');
|
||||||
|
}
|
||||||
|
const promptText = activePrompt.template
|
||||||
|
.replace('{{text}}', text)
|
||||||
|
.replace('{{ocr_text}}', text);
|
||||||
|
let sandboxParams;
|
||||||
|
if (profileId) {
|
||||||
|
try {
|
||||||
|
sandboxParams =
|
||||||
|
await this.aiPolicyService.getSandboxParameters(profileId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to fetch sandbox parameters for profileId=${profileId}: ${String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sandboxParams) {
|
||||||
|
try {
|
||||||
|
sandboxParams =
|
||||||
|
await this.aiPolicyService.getSandboxParameters('standard');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to fetch sandbox parameters for standard: ${String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const generateOptions = {
|
||||||
|
options: {
|
||||||
|
num_ctx: sandboxParams?.numCtx ?? 8192,
|
||||||
|
num_predict: sandboxParams?.maxTokens ?? 4096,
|
||||||
|
temperature: sandboxParams?.temperature,
|
||||||
|
top_p: sandboxParams?.topP,
|
||||||
|
repeat_penalty: sandboxParams?.repeatPenalty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const llmOutput = await this.ollamaService.generate(
|
||||||
|
promptText,
|
||||||
|
generateOptions
|
||||||
|
);
|
||||||
|
const parsed = this.parseChunkTags(llmOutput);
|
||||||
|
const chunks =
|
||||||
|
parsed.length > 0 ? parsed : this.fixedSizeChunk(text, 512, 64);
|
||||||
|
const ragChunks: Array<{ text: string; summary: string }> = [];
|
||||||
|
const ragVectors: number[][] = [];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
|
const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
|
||||||
|
ragChunks.push({
|
||||||
|
text: chunk.text,
|
||||||
|
summary: chunk.topic,
|
||||||
|
});
|
||||||
|
ragVectors.push(embedResult.dense);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Sandbox embed failed for chunk: ${chunk.topic}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.redis.setex(
|
||||||
|
`ai:rag:result:${idempotencyKey}`,
|
||||||
|
3600,
|
||||||
|
JSON.stringify({
|
||||||
|
requestPublicId: idempotencyKey,
|
||||||
|
status: 'completed',
|
||||||
|
ragChunks,
|
||||||
|
ragVectors,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(`Sandbox RAG Prep failed: ${errMsg}`);
|
||||||
|
await this.redis.setex(
|
||||||
|
`ai:rag:result:${idempotencyKey}`,
|
||||||
|
3600,
|
||||||
|
JSON.stringify({
|
||||||
|
requestPublicId: idempotencyKey,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: errMsg,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseChunkTags(
|
||||||
|
llmOutput: string
|
||||||
|
): Array<{ topic: string; text: string }> {
|
||||||
|
const chunks: Array<{ topic: string; text: string }> = [];
|
||||||
|
const regex = /<chunk\s+topic="([^"]*)"\s*>([\s\S]*?)<\/chunk\s*>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(llmOutput)) !== null) {
|
||||||
|
const topic = match[1]?.trim() || 'ทั่วไป';
|
||||||
|
const text = match[2]?.trim();
|
||||||
|
if (text) {
|
||||||
|
chunks.push({ topic, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fixedSizeChunk(
|
||||||
|
text: string,
|
||||||
|
chunkSize: number,
|
||||||
|
overlap: number
|
||||||
|
): Array<{ topic: string; text: string }> {
|
||||||
|
const chunks: Array<{ topic: string; text: string }> = [];
|
||||||
|
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 + chunkSize, textLength);
|
||||||
|
const chunkText = cleanText.substring(startIndex, endIndex);
|
||||||
|
chunks.push({
|
||||||
|
topic: `ส่วนที่ ${chunkIndex + 1}`,
|
||||||
|
text: chunkText,
|
||||||
|
});
|
||||||
|
startIndex += chunkSize - overlap;
|
||||||
|
chunkIndex += 1;
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
Patch,
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
@@ -26,6 +27,7 @@ import { AiPrompt } from './ai-prompts.entity';
|
|||||||
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
||||||
import { UpdatePromptNoteDto } from './dto/update-prompt-note.dto';
|
import { UpdatePromptNoteDto } from './dto/update-prompt-note.dto';
|
||||||
import { AiPromptResponseDto } from './dto/ai-prompt-response.dto';
|
import { AiPromptResponseDto } from './dto/ai-prompt-response.dto';
|
||||||
|
import { ContextConfigDto } from '../dto/context-config.dto';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||||
import { RbacGuard } from '../../../common/guards/rbac.guard';
|
import { RbacGuard } from '../../../common/guards/rbac.guard';
|
||||||
@@ -137,4 +139,42 @@ export class AiPromptsController {
|
|||||||
);
|
);
|
||||||
return { data: this.mapToDto(updated) };
|
return { data: this.mapToDto(updated) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':promptType/:versionNumber/context-config')
|
||||||
|
@RequirePermission('system.manage_all')
|
||||||
|
@ApiOperation({ summary: 'ดึง Context Config ของ Prompt Version ที่กำหนด' })
|
||||||
|
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||||
|
@ApiParam({ name: 'versionNumber', type: Number })
|
||||||
|
async getContextConfig(
|
||||||
|
@Param('promptType') promptType: string,
|
||||||
|
@Param('versionNumber', ParseIntPipe) versionNumber: number
|
||||||
|
): Promise<{ data: Record<string, unknown> | null }> {
|
||||||
|
const config = await this.promptsService.getContextConfig(
|
||||||
|
promptType,
|
||||||
|
versionNumber
|
||||||
|
);
|
||||||
|
return { data: config };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':promptType/:versionNumber/context-config')
|
||||||
|
@RequirePermission('system.manage_all')
|
||||||
|
@Audit('ai_prompt.update_context_config', 'AiPrompt')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'อัปเดต Context Config ของ Prompt Version ที่กำหนด',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||||
|
@ApiParam({ name: 'versionNumber', type: Number })
|
||||||
|
async updateContextConfig(
|
||||||
|
@Param('promptType') promptType: string,
|
||||||
|
@Param('versionNumber', ParseIntPipe) versionNumber: number,
|
||||||
|
@Body() dto: ContextConfigDto
|
||||||
|
): Promise<{ data: Record<string, unknown> }> {
|
||||||
|
const updated = await this.promptsService.updateContextConfig(
|
||||||
|
promptType,
|
||||||
|
versionNumber,
|
||||||
|
dto
|
||||||
|
);
|
||||||
|
return { data: updated };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ describe('AiPromptsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder', async () => {
|
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder สำหรับ ocr_extraction', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.create(
|
service.create(
|
||||||
'ocr_extraction',
|
'ocr_extraction',
|
||||||
@@ -232,6 +232,36 @@ describe('AiPromptsService', () => {
|
|||||||
)
|
)
|
||||||
).rejects.toThrow(ValidationException);
|
).rejects.toThrow(ValidationException);
|
||||||
});
|
});
|
||||||
|
it('ควรปฏิเสธ template ที่ไม่มี {{query}} หรือ {{context}} placeholder สำหรับ rag_query_prompt', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
'rag_query_prompt',
|
||||||
|
{ template: 'Invalid template context' },
|
||||||
|
1
|
||||||
|
)
|
||||||
|
).rejects.toThrow(ValidationException);
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
'rag_query_prompt',
|
||||||
|
{ template: 'Invalid template query {{query}}' },
|
||||||
|
1
|
||||||
|
)
|
||||||
|
).rejects.toThrow(ValidationException);
|
||||||
|
});
|
||||||
|
it('ควรปฏิเสธ template ที่ไม่มี {{text}} placeholder สำหรับ rag_prep_prompt', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create('rag_prep_prompt', { template: 'Invalid template' }, 1)
|
||||||
|
).rejects.toThrow(ValidationException);
|
||||||
|
});
|
||||||
|
it('ควรปฏิเสธ template ที่ไม่มี {{document_text}} placeholder สำหรับ classification_prompt', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
'classification_prompt',
|
||||||
|
{ template: 'Invalid template' },
|
||||||
|
1
|
||||||
|
)
|
||||||
|
).rejects.toThrow(ValidationException);
|
||||||
|
});
|
||||||
it('ควรปฏิเสธ template ที่ตัวอักษรเกิน 4,000 ตัว', async () => {
|
it('ควรปฏิเสธ template ที่ตัวอักษรเกิน 4,000 ตัว', async () => {
|
||||||
const longTemplate = 'a'.repeat(4005) + '{{ocr_text}}';
|
const longTemplate = 'a'.repeat(4005) + '{{ocr_text}}';
|
||||||
await expect(
|
await expect(
|
||||||
@@ -363,4 +393,84 @@ describe('AiPromptsService', () => {
|
|||||||
expect(mockAiPromptRepo.findOne).toHaveBeenCalled();
|
expect(mockAiPromptRepo.findOne).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('contextConfig CRUD', () => {
|
||||||
|
it('ควร getContextConfig สำเร็จ', async () => {
|
||||||
|
const prompt = {
|
||||||
|
id: 1,
|
||||||
|
promptType: 'ocr_extraction',
|
||||||
|
versionNumber: 1,
|
||||||
|
contextConfig: {
|
||||||
|
pageSize: 5,
|
||||||
|
language: 'th',
|
||||||
|
outputLanguage: 'th',
|
||||||
|
filter: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
|
||||||
|
const result = await service.getContextConfig('ocr_extraction', 1);
|
||||||
|
expect(result).toEqual(prompt.contextConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรโยน NotFoundException เมื่อ getContextConfig ไม่พบเวอร์ชัน', async () => {
|
||||||
|
mockAiPromptRepo.findOne.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.getContextConfig('ocr_extraction', 99)
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร updateContextConfig สำเร็จและตรวจสอบโครงการ/สัญญาสำเร็จ', async () => {
|
||||||
|
const prompt = {
|
||||||
|
id: 1,
|
||||||
|
promptType: 'ocr_extraction',
|
||||||
|
versionNumber: 1,
|
||||||
|
contextConfig: null,
|
||||||
|
};
|
||||||
|
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
|
||||||
|
mockAiPromptRepo.save.mockResolvedValue({
|
||||||
|
...prompt,
|
||||||
|
contextConfig: {
|
||||||
|
pageSize: 5,
|
||||||
|
language: 'th',
|
||||||
|
outputLanguage: 'th',
|
||||||
|
filter: { projectId: 'p-1', contractId: 'c-1' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// จำลองให้โครงการและสัญญาถูกต้องใน DB
|
||||||
|
mockQueryBuilder.getRawOne
|
||||||
|
.mockResolvedValueOnce({ id: 10 }) // project check
|
||||||
|
.mockResolvedValueOnce({ id: 20 }); // contract check
|
||||||
|
|
||||||
|
const result = await service.updateContextConfig('ocr_extraction', 1, {
|
||||||
|
pageSize: 5,
|
||||||
|
language: 'th',
|
||||||
|
outputLanguage: 'th',
|
||||||
|
filter: { projectId: 'p-1', contractId: 'c-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.pageSize).toBe(5);
|
||||||
|
expect(mockAiPromptRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรโยน NotFoundException เมื่อ updateContextConfig ส่ง project UUID ที่ไม่มีอยู่ใน DB', async () => {
|
||||||
|
const prompt = {
|
||||||
|
id: 1,
|
||||||
|
promptType: 'ocr_extraction',
|
||||||
|
versionNumber: 1,
|
||||||
|
contextConfig: null,
|
||||||
|
};
|
||||||
|
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
|
||||||
|
mockQueryBuilder.getRawOne.mockResolvedValueOnce(null); // project not found
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateContextConfig('ocr_extraction', 1, {
|
||||||
|
pageSize: 5,
|
||||||
|
language: 'th',
|
||||||
|
outputLanguage: 'th',
|
||||||
|
filter: { projectId: 'invalid-proj-uuid', contractId: null },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import { AiPrompt } from './ai-prompts.entity';
|
import { AiPrompt } from './ai-prompts.entity';
|
||||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||||
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
||||||
|
import { ContextConfigDto } from '../dto/context-config.dto';
|
||||||
import {
|
import {
|
||||||
BusinessException,
|
BusinessException,
|
||||||
ValidationException,
|
ValidationException,
|
||||||
@@ -343,8 +344,31 @@ export class AiPromptsService {
|
|||||||
dto: CreateAiPromptDto,
|
dto: CreateAiPromptDto,
|
||||||
userId: number
|
userId: number
|
||||||
): Promise<AiPrompt> {
|
): Promise<AiPrompt> {
|
||||||
if (!dto.template.includes('{{ocr_text}}')) {
|
if (promptType === 'ocr_extraction') {
|
||||||
throw new ValidationException('template ต้องมี {{ocr_text}} placeholder');
|
if (!dto.template.includes('{{ocr_text}}')) {
|
||||||
|
throw new ValidationException(
|
||||||
|
'template ต้องมี {{ocr_text}} placeholder'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (promptType === 'rag_query_prompt') {
|
||||||
|
if (
|
||||||
|
!dto.template.includes('{{query}}') ||
|
||||||
|
!dto.template.includes('{{context}}')
|
||||||
|
) {
|
||||||
|
throw new ValidationException(
|
||||||
|
'template ต้องมี {{query}} และ {{context}} placeholder'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (promptType === 'rag_prep_prompt') {
|
||||||
|
if (!dto.template.includes('{{text}}')) {
|
||||||
|
throw new ValidationException('template ต้องมี {{text}} placeholder');
|
||||||
|
}
|
||||||
|
} else if (promptType === 'classification_prompt') {
|
||||||
|
if (!dto.template.includes('{{document_text}}')) {
|
||||||
|
throw new ValidationException(
|
||||||
|
'template ต้องมี {{document_text}} placeholder'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (dto.template.length > 4000) {
|
if (dto.template.length > 4000) {
|
||||||
throw new ValidationException('Template exceeds 4,000 character limit');
|
throw new ValidationException('Template exceeds 4,000 character limit');
|
||||||
@@ -527,6 +551,76 @@ export class AiPromptsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Context Config ของ Prompt Version ที่กำหนด
|
||||||
|
*/
|
||||||
|
async getContextConfig(
|
||||||
|
promptType: string,
|
||||||
|
versionNumber: number
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const prompt = await this.aiPromptRepo.findOne({
|
||||||
|
where: { promptType, versionNumber },
|
||||||
|
});
|
||||||
|
if (!prompt) {
|
||||||
|
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||||
|
}
|
||||||
|
return prompt.contextConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อัปเดต Context Config ของ Prompt Version ที่กำหนด พร้อมทั้งตรวจเช็คความถูกต้องของโครงการและสัญญาใน DB
|
||||||
|
*/
|
||||||
|
async updateContextConfig(
|
||||||
|
promptType: string,
|
||||||
|
versionNumber: number,
|
||||||
|
dto: ContextConfigDto
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const prompt = await this.aiPromptRepo.findOne({
|
||||||
|
where: { promptType, versionNumber },
|
||||||
|
});
|
||||||
|
if (!prompt) {
|
||||||
|
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation (T027): ตรวจสอบโครงการ/สัญญาใน DB
|
||||||
|
if (dto.filter?.projectId) {
|
||||||
|
const projectExists = (await this.dataSource.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('p.id')
|
||||||
|
.from('projects', 'p')
|
||||||
|
.where('p.uuid = :uuid', { uuid: dto.filter.projectId })
|
||||||
|
.andWhere('p.deleted_at IS NULL')
|
||||||
|
.getRawOne()) as unknown;
|
||||||
|
if (!projectExists) {
|
||||||
|
throw new NotFoundException('Project', dto.filter.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.filter?.contractId) {
|
||||||
|
const contractExists = (await this.dataSource.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('c.id')
|
||||||
|
.from('contracts', 'c')
|
||||||
|
.where('c.uuid = :uuid', { uuid: dto.filter.contractId })
|
||||||
|
.getRawOne()) as unknown;
|
||||||
|
if (!contractExists) {
|
||||||
|
throw new NotFoundException('Contract', dto.filter.contractId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// บันทึกลง DB
|
||||||
|
const newContextConfig = {
|
||||||
|
filter: dto.filter || null,
|
||||||
|
pageSize: dto.pageSize,
|
||||||
|
language: dto.language,
|
||||||
|
outputLanguage: dto.outputLanguage,
|
||||||
|
};
|
||||||
|
prompt.contextConfig = newContextConfig;
|
||||||
|
await this.aiPromptRepo.save(prompt);
|
||||||
|
|
||||||
|
return newContextConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs
|
* บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// - 2026-06-03: สร้าง unit test สำหรับ OllamaService ครอบคลุม generate() model option,
|
// - 2026-06-03: สร้าง unit test สำหรับ OllamaService ครอบคลุม generate() model option,
|
||||||
// getOcrModelName(), และ loadModel() keepAlive param ตาม ADR-034
|
// getOcrModelName(), และ loadModel() keepAlive param ตาม ADR-034
|
||||||
// - 2026-06-13: ADR-036 — อัปเดต expected model tags เป็น np-dms-ai/np-dms-ocr
|
// - 2026-06-13: ADR-036 — อัปเดต expected model tags เป็น np-dms-ai/np-dms-ocr
|
||||||
|
// - 2026-06-14: เพิ่ม tests สำหรับ generateEmbedding, checkHealth, unloadModel เพื่อเพิ่ม branch coverage
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -132,5 +133,125 @@ describe('OllamaService (ADR-034)', () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
it('ควรคืน false และ log error เมื่อ axios throw ระหว่าง loadModel', async () => {
|
||||||
|
mockedAxios.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||||
|
const result = await service.loadModel('np-dms-ai:latest');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('getEmbeddingModelName()', () => {
|
||||||
|
it('ควรคืน nomic-embed-text เป็น embedding model', () => {
|
||||||
|
expect(service.getEmbeddingModelName()).toBe('nomic-embed-text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('generateEmbedding()', () => {
|
||||||
|
it('ควรคืน embedding vector เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
|
||||||
|
const mockVector = [0.1, 0.2, 0.3];
|
||||||
|
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||||
|
data: { embedding: mockVector },
|
||||||
|
});
|
||||||
|
const result = await service.generateEmbedding('test text');
|
||||||
|
expect(result).toEqual(mockVector);
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/embeddings'),
|
||||||
|
expect.objectContaining({
|
||||||
|
model: 'nomic-embed-text',
|
||||||
|
prompt: 'test text',
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควร throw error เมื่อ Ollama embedding ล้มเหลว', async () => {
|
||||||
|
mockedAxios.post = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Embedding failed'));
|
||||||
|
await expect(service.generateEmbedding('test')).rejects.toThrow(
|
||||||
|
'Embedding failed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('checkHealth()', () => {
|
||||||
|
it('ควรคืน HEALTHY พร้อมโมเดลที่โหลดอยู่จาก /api/ps เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
|
||||||
|
mockedAxios.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ data: {} }) // /api/tags
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { models: [{ name: 'np-dms-ai:latest' }] },
|
||||||
|
}); // /api/ps
|
||||||
|
const result = await service.checkHealth();
|
||||||
|
expect(result.status).toBe('HEALTHY');
|
||||||
|
expect(result.models).toContain('np-dms-ai:latest');
|
||||||
|
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
it('ควรคืน HEALTHY พร้อม fallback models เมื่อ /api/ps ไม่มีข้อมูล', async () => {
|
||||||
|
mockedAxios.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
|
||||||
|
.mockResolvedValueOnce({ data: { models: [] } }); // /api/ps empty
|
||||||
|
const result = await service.checkHealth();
|
||||||
|
expect(result.status).toBe('HEALTHY');
|
||||||
|
expect(result.models).toContain('np-dms-ai:latest'); // fallback
|
||||||
|
});
|
||||||
|
it('ควรคืน HEALTHY แม้ /api/ps throw error (graceful degradation)', async () => {
|
||||||
|
mockedAxios.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
|
||||||
|
.mockRejectedValueOnce(new Error('ps endpoint error')); // /api/ps fails
|
||||||
|
const result = await service.checkHealth();
|
||||||
|
expect(result.status).toBe('HEALTHY');
|
||||||
|
});
|
||||||
|
it('ควรคืน DEGRADED เมื่อ /api/tags timeout', async () => {
|
||||||
|
mockedAxios.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('timeout error'));
|
||||||
|
const result = await service.checkHealth();
|
||||||
|
expect(result.status).toBe('DEGRADED');
|
||||||
|
expect(result.error).toContain('timeout');
|
||||||
|
});
|
||||||
|
it('ควรคืน DEGRADED เมื่อ error message มี code ECONNABORTED', async () => {
|
||||||
|
mockedAxios.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('code ECONNABORTED'));
|
||||||
|
const result = await service.checkHealth();
|
||||||
|
expect(result.status).toBe('DEGRADED');
|
||||||
|
});
|
||||||
|
it('ควรคืน DOWN เมื่อ connection ถูกปฏิเสธ (ไม่ใช่ timeout)', async () => {
|
||||||
|
mockedAxios.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||||
|
const result = await service.checkHealth();
|
||||||
|
expect(result.status).toBe('DOWN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('unloadModel()', () => {
|
||||||
|
it('ควรคืน true เมื่อ unload สำเร็จ', async () => {
|
||||||
|
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
|
||||||
|
const result = await service.unloadModel('np-dms-ocr:latest');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/generate'),
|
||||||
|
expect.objectContaining({ model: 'np-dms-ocr:latest', keep_alive: 0 }),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน false เมื่อ unload ล้มเหลว', async () => {
|
||||||
|
mockedAxios.post = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Unload failed'));
|
||||||
|
const result = await service.unloadModel('np-dms-ocr:latest');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('generate() error path', () => {
|
||||||
|
it('ควร throw error เมื่อ Ollama generate ล้มเหลว', async () => {
|
||||||
|
mockedAxios.post = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('LLM timeout'));
|
||||||
|
await expect(service.generate('test prompt')).rejects.toThrow(
|
||||||
|
'LLM timeout'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// File: src/modules/ai/services/sandbox-ocr-engine.service.spec.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: สร้าง unit tests สำหรับ SandboxOcrEngineService ครอบคลุม detectAndExtract ทุก engine
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { SandboxOcrEngineService } from './sandbox-ocr-engine.service';
|
||||||
|
import { OcrService } from './ocr.service';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
jest.mock('fs');
|
||||||
|
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
const mockedFs = fs as jest.Mocked<typeof fs>;
|
||||||
|
|
||||||
|
/** OcrService mock สำหรับ tesseract/fast-path */
|
||||||
|
const mockOcrService = {
|
||||||
|
detectAndExtract: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ConfigService mock */
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(<T>(key: string, defaultValue?: T): T | undefined => {
|
||||||
|
const cfg: Record<string, unknown> = {
|
||||||
|
OCR_API_URL: 'http://localhost:8765',
|
||||||
|
OCR_SIDECAR_API_KEY: 'test-api-key-2026',
|
||||||
|
};
|
||||||
|
return (cfg[key] as T | undefined) ?? defaultValue;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SandboxOcrEngineService', () => {
|
||||||
|
let service: SandboxOcrEngineService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
SandboxOcrEngineService,
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: OcrService, useValue: mockOcrService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get<SandboxOcrEngineService>(SandboxOcrEngineService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectAndExtract() — engine=auto', () => {
|
||||||
|
it('ควร route ไปยัง OcrService เมื่อ engine=auto', async () => {
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'auto extracted text',
|
||||||
|
ocrUsed: true,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
|
||||||
|
expect(result.text).toBe('auto extracted text');
|
||||||
|
expect(result.engineUsed).toBe('tesseract');
|
||||||
|
expect(result.fallbackUsed).toBe(false);
|
||||||
|
expect(mockOcrService.detectAndExtract).toHaveBeenCalledWith({
|
||||||
|
pdfPath: '/tmp/file.pdf',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรใช้ fast-path engineUsed เมื่อ OcrService คืน ocrUsed=false', async () => {
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'embedded text',
|
||||||
|
ocrUsed: false,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
|
||||||
|
expect(result.engineUsed).toBe('fast-path');
|
||||||
|
expect(result.fallbackUsed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectAndExtract() — engine=tesseract', () => {
|
||||||
|
it('ควร route ไปยัง OcrService เมื่อ engine=tesseract', async () => {
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'tesseract text',
|
||||||
|
ocrUsed: true,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/file.pdf',
|
||||||
|
'tesseract'
|
||||||
|
);
|
||||||
|
expect(result.engineUsed).toBe('tesseract');
|
||||||
|
expect(result.fallbackUsed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectAndExtract() — engine=typhoon-np-dms-ocr (legacy alias)', () => {
|
||||||
|
it('ควรแปลง typhoon-np-dms-ocr เป็น np-dms-ocr และส่งไปยัง sidecar', async () => {
|
||||||
|
const mockBuffer = Buffer.from('pdf content');
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||||
|
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
text: 'ocr text via alias',
|
||||||
|
ocrUsed: true,
|
||||||
|
engineUsed: 'np-dms-ocr',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/file.pdf',
|
||||||
|
'typhoon-np-dms-ocr'
|
||||||
|
);
|
||||||
|
expect(result.text).toBe('ocr text via alias');
|
||||||
|
expect(result.engineUsed).toBe('np-dms-ocr');
|
||||||
|
expect(result.fallbackUsed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectAndExtract() — engine=np-dms-ocr (sidecar path)', () => {
|
||||||
|
it('ควรส่ง file ไปยัง sidecar /ocr-upload สำเร็จ', async () => {
|
||||||
|
const mockBuffer = Buffer.from('pdf binary data');
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||||
|
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
text: 'extracted from typhoon',
|
||||||
|
ocrUsed: true,
|
||||||
|
engineUsed: 'np-dms-ocr',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/doc.pdf',
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
expect(result.text).toBe('extracted from typhoon');
|
||||||
|
expect(result.ocrUsed).toBe(true);
|
||||||
|
expect(result.engineUsed).toBe('np-dms-ocr');
|
||||||
|
expect(result.fallbackUsed).toBe(false);
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/ocr-upload'),
|
||||||
|
expect.any(FormData),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'X-API-Key': 'test-api-key-2026',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรส่ง typhoonOptions (temperature, topP, repeatPenalty) ไปใน form data', async () => {
|
||||||
|
const mockBuffer = Buffer.from('pdf data');
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||||
|
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||||
|
data: { text: 'result', ocrUsed: true, engineUsed: 'np-dms-ocr' },
|
||||||
|
});
|
||||||
|
await service.detectAndExtract('/tmp/doc.pdf', 'np-dms-ocr', {
|
||||||
|
temperature: 0.5,
|
||||||
|
topP: 0.8,
|
||||||
|
repeatPenalty: 1.2,
|
||||||
|
});
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรใช้ fallback values เมื่อ sidecar response ไม่มี text/ocrUsed/engineUsed', async () => {
|
||||||
|
const mockBuffer = Buffer.from('pdf data');
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||||
|
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/doc.pdf',
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
expect(result.text).toBe('');
|
||||||
|
expect(result.ocrUsed).toBe(true);
|
||||||
|
expect(result.engineUsed).toBe('np-dms-ocr'); // resolvedEngineType fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง Tesseract เมื่อ fs.readFileSync ล้มเหลว (outer catch fallback)', async () => {
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockImplementationOnce(() => {
|
||||||
|
throw new Error('ENOENT: file not found');
|
||||||
|
});
|
||||||
|
// service จะ catch error และ fallback ไปยัง Tesseract
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'tesseract fallback text',
|
||||||
|
ocrUsed: true,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/missing.pdf',
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
expect(result.fallbackUsed).toBe(true);
|
||||||
|
expect(result.engineUsed).toBe('tesseract');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง Tesseract เมื่อ sidecar HTTP error เกิดขึ้น', async () => {
|
||||||
|
const mockBuffer = Buffer.from('pdf data');
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||||
|
mockedAxios.post = jest.fn().mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error('Request failed'), {
|
||||||
|
response: { status: 500, data: { detail: 'Internal Server Error' } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'tesseract fallback result',
|
||||||
|
ocrUsed: true,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/doc.pdf',
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
expect(result.text).toBe('tesseract fallback result');
|
||||||
|
expect(result.fallbackUsed).toBe(true);
|
||||||
|
expect(result.engineUsed).toBe('tesseract');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง fast-path เมื่อ sidecar error และ OcrService ส่ง ocrUsed=false', async () => {
|
||||||
|
const mockBuffer = Buffer.from('pdf data');
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||||
|
mockedAxios.post = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Connection refused'));
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'embedded text',
|
||||||
|
ocrUsed: false,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/doc.pdf',
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
expect(result.engineUsed).toBe('fast-path');
|
||||||
|
expect(result.fallbackUsed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectAndExtract() — default engine (no arg)', () => {
|
||||||
|
it('ควรใช้ auto เป็น default engine เมื่อไม่ระบุ engineType', async () => {
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'default text',
|
||||||
|
ocrUsed: false,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract('/tmp/file.pdf');
|
||||||
|
expect(result.fallbackUsed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectAndExtract() — edge cases', () => {
|
||||||
|
it('ควร handle axios error ที่ไม่มี response.status gracefully', async () => {
|
||||||
|
const mockBuffer = Buffer.from('pdf data');
|
||||||
|
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||||
|
mockedAxios.post = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Network unreachable'));
|
||||||
|
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'fallback text',
|
||||||
|
ocrUsed: true,
|
||||||
|
});
|
||||||
|
const result = await service.detectAndExtract(
|
||||||
|
'/tmp/doc.pdf',
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
expect(result.fallbackUsed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
// File: backend/src/modules/ai/tests/ai-execution-profiles.service.spec.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: สร้าง unit tests สำหรับ AiPolicyService ที่ครอบคลุม execution profile management (T041)
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { AiPolicyService } from '../services/ai-policy.service';
|
||||||
|
import { AiExecutionProfile } from '../entities/ai-execution-profile.entity';
|
||||||
|
import { AiSandboxProfile } from '../entities/ai-sandbox-profile.entity';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** Mock Redis สำหรับ inject */
|
||||||
|
const mockRedis = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
del: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mock repository สำหรับ AiExecutionProfile */
|
||||||
|
const mockProfileRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mock repository สำหรับ AiSandboxProfile */
|
||||||
|
const mockSandboxRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** สร้าง AiExecutionProfile stub */
|
||||||
|
const makeProfile = (
|
||||||
|
overrides: Partial<AiExecutionProfile> = {}
|
||||||
|
): AiExecutionProfile =>
|
||||||
|
({
|
||||||
|
id: 1,
|
||||||
|
profileName: 'standard',
|
||||||
|
canonicalModel: 'np-dms-ai',
|
||||||
|
temperature: 0.5,
|
||||||
|
topP: 0.8,
|
||||||
|
maxTokens: 4096,
|
||||||
|
numCtx: 8192,
|
||||||
|
repeatPenalty: 1.15,
|
||||||
|
keepAliveSeconds: 600,
|
||||||
|
isActive: true,
|
||||||
|
updatedBy: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
}) as AiExecutionProfile;
|
||||||
|
|
||||||
|
/** สร้าง AiSandboxProfile stub */
|
||||||
|
const makeSandbox = (
|
||||||
|
overrides: Partial<AiSandboxProfile> = {}
|
||||||
|
): AiSandboxProfile =>
|
||||||
|
({
|
||||||
|
id: 1,
|
||||||
|
profileName: 'standard',
|
||||||
|
canonicalModel: 'np-dms-ai',
|
||||||
|
temperature: 0.6,
|
||||||
|
topP: 0.9,
|
||||||
|
maxTokens: 4096,
|
||||||
|
numCtx: 8192,
|
||||||
|
repeatPenalty: 1.15,
|
||||||
|
keepAliveSeconds: 600,
|
||||||
|
updatedBy: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
}) as AiSandboxProfile;
|
||||||
|
|
||||||
|
describe('AiPolicyService — Execution Profile Management (T041)', () => {
|
||||||
|
let service: AiPolicyService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AiPolicyService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(AiExecutionProfile),
|
||||||
|
useValue: mockProfileRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(AiSandboxProfile),
|
||||||
|
useValue: mockSandboxRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'default_IORedisModuleConnectionToken',
|
||||||
|
useValue: mockRedis,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get<AiPolicyService>(AiPolicyService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getCanonicalModelName ───────────────────────────────────────────────────
|
||||||
|
describe('getCanonicalModelName()', () => {
|
||||||
|
it('ควรคืน np-dms-ocr เมื่อ modelName มีคำว่า ocr', () => {
|
||||||
|
expect(service.getCanonicalModelName('np-dms-ocr:latest')).toBe(
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน np-dms-ocr เมื่อ modelName มีคำว่า typhoon-np-dms-ocr', () => {
|
||||||
|
expect(service.getCanonicalModelName('typhoon-np-dms-ocr:latest')).toBe(
|
||||||
|
'np-dms-ocr'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน np-dms-ai สำหรับ model ทั่วไปที่ไม่มีคำว่า ocr', () => {
|
||||||
|
expect(service.getCanonicalModelName('np-dms-ai:latest')).toBe(
|
||||||
|
'np-dms-ai'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน np-dms-ai สำหรับ typhoon2.5 model (main model)', () => {
|
||||||
|
expect(service.getCanonicalModelName('typhoon2.5-np-dms:latest')).toBe(
|
||||||
|
'np-dms-ai'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getProfileForJobType ────────────────────────────────────────────────────
|
||||||
|
describe('getProfileForJobType()', () => {
|
||||||
|
it('ควรคืน quality สำหรับ auto-fill-document', () => {
|
||||||
|
expect(service.getProfileForJobType('auto-fill-document')).toBe(
|
||||||
|
'quality'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน quality สำหรับ migrate-document', () => {
|
||||||
|
expect(service.getProfileForJobType('migrate-document')).toBe('quality');
|
||||||
|
});
|
||||||
|
it('ควรคืน standard สำหรับ rag-query', () => {
|
||||||
|
expect(service.getProfileForJobType('rag-query')).toBe('standard');
|
||||||
|
});
|
||||||
|
it('ควรคืน interactive สำหรับ intent-classify', () => {
|
||||||
|
expect(service.getProfileForJobType('intent-classify')).toBe(
|
||||||
|
'interactive'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน interactive สำหรับ tool-suggest', () => {
|
||||||
|
expect(service.getProfileForJobType('tool-suggest')).toBe('interactive');
|
||||||
|
});
|
||||||
|
it('ควรคืน deep-analysis สำหรับ sandbox-analysis', () => {
|
||||||
|
expect(service.getProfileForJobType('sandbox-analysis')).toBe(
|
||||||
|
'deep-analysis'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน standard เป็น default สำหรับ ocr-extract', () => {
|
||||||
|
expect(service.getProfileForJobType('ocr-extract')).toBe('standard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getProfileParameters ────────────────────────────────────────────────────
|
||||||
|
describe('getProfileParameters()', () => {
|
||||||
|
it('ควรคืนจาก Redis cache เมื่อมี cache hit', async () => {
|
||||||
|
const cachedPolicy = {
|
||||||
|
canonicalModel: 'np-dms-ai',
|
||||||
|
temperature: 0.5,
|
||||||
|
topP: 0.8,
|
||||||
|
maxTokens: 4096,
|
||||||
|
numCtx: 8192,
|
||||||
|
repeatPenalty: 1.15,
|
||||||
|
keepAliveSeconds: 600,
|
||||||
|
};
|
||||||
|
mockRedis.get.mockResolvedValueOnce(JSON.stringify(cachedPolicy));
|
||||||
|
const result = await service.getProfileParameters('standard');
|
||||||
|
expect(result.temperature).toBe(0.5);
|
||||||
|
expect(mockProfileRepo.findOne).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง DB เมื่อ Redis cache miss', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null); // cache miss
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||||
|
makeProfile({ temperature: 0.3 })
|
||||||
|
);
|
||||||
|
mockRedis.set.mockResolvedValueOnce('OK');
|
||||||
|
const result = await service.getProfileParameters('standard');
|
||||||
|
expect(result.temperature).toBe(0.3);
|
||||||
|
expect(mockProfileRepo.findOne).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง hardcoded defaults เมื่อ DB ก็ไม่มีข้อมูล', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(null); // ไม่มีใน DB
|
||||||
|
const result = await service.getProfileParameters('quality');
|
||||||
|
expect(result.temperature).toBe(0.1); // default quality profile
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง DB เมื่อ Redis throw error', async () => {
|
||||||
|
mockRedis.get.mockRejectedValueOnce(new Error('Redis CONN error'));
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||||
|
mockRedis.set.mockResolvedValueOnce('OK');
|
||||||
|
const result = await service.getProfileParameters('standard');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง defaults เมื่อ DB ก็ throw error', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockRejectedValueOnce(new Error('DB timeout'));
|
||||||
|
const result = await service.getProfileParameters('interactive');
|
||||||
|
expect(result.temperature).toBe(0.7); // default interactive profile
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรไม่ throw เมื่อ cache write ล้มเหลว (graceful)', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||||
|
mockRedis.set.mockRejectedValueOnce(new Error('Redis write failed'));
|
||||||
|
const result = await service.getProfileParameters('standard');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getModelDefaults ────────────────────────────────────────────────────────
|
||||||
|
describe('getModelDefaults()', () => {
|
||||||
|
it('ควรคืนจาก Redis cache เมื่อมี cache hit', async () => {
|
||||||
|
const cachedOcrPolicy = {
|
||||||
|
canonicalModel: 'np-dms-ocr',
|
||||||
|
temperature: 0.1,
|
||||||
|
topP: 0.1,
|
||||||
|
maxTokens: null,
|
||||||
|
numCtx: null,
|
||||||
|
repeatPenalty: 1.1,
|
||||||
|
keepAliveSeconds: 0,
|
||||||
|
};
|
||||||
|
mockRedis.get.mockResolvedValueOnce(JSON.stringify(cachedOcrPolicy));
|
||||||
|
const result = await service.getModelDefaults('np-dms-ocr');
|
||||||
|
expect(result.canonicalModel).toBe('np-dms-ocr');
|
||||||
|
expect(result.temperature).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร fallback ไปยัง DB เมื่อ Redis cache miss', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||||
|
makeProfile({ canonicalModel: 'np-dms-ocr', temperature: 0.05 })
|
||||||
|
);
|
||||||
|
mockRedis.set.mockResolvedValueOnce('OK');
|
||||||
|
const result = await service.getModelDefaults('np-dms-ocr');
|
||||||
|
expect(result.temperature).toBe(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรคืน defaultOcrPolicy เมื่อไม่มีใน DB (np-dms-ocr)', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(null);
|
||||||
|
const result = await service.getModelDefaults('np-dms-ocr');
|
||||||
|
expect(result.canonicalModel).toBe('np-dms-ocr');
|
||||||
|
expect(result.keepAliveSeconds).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรคืน standard defaults เมื่อไม่มีใน DB (np-dms-ai)', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(null);
|
||||||
|
const result = await service.getModelDefaults('np-dms-ai');
|
||||||
|
expect(result.canonicalModel).toBe('np-dms-ai');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── saveSandboxDraft ────────────────────────────────────────────────────────
|
||||||
|
describe('saveSandboxDraft()', () => {
|
||||||
|
it('ควรอัปเดต draft ที่มีอยู่แล้ว', async () => {
|
||||||
|
const existingDraft = makeSandbox({ temperature: 0.5 });
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(existingDraft);
|
||||||
|
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||||
|
...existingDraft,
|
||||||
|
temperature: 0.8,
|
||||||
|
});
|
||||||
|
const result = await service.saveSandboxDraft('standard', {
|
||||||
|
temperature: 0.8,
|
||||||
|
});
|
||||||
|
expect(result.temperature).toBe(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรสร้าง draft ใหม่จาก production เมื่อยังไม่มี draft', async () => {
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(null); // ไม่มี draft
|
||||||
|
// getProductionPolicy → getProfileParameters → Redis miss → DB
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||||
|
mockRedis.set.mockResolvedValueOnce('OK');
|
||||||
|
const newDraft = makeSandbox({ topP: 0.9 });
|
||||||
|
mockSandboxRepo.create.mockReturnValueOnce(newDraft);
|
||||||
|
mockSandboxRepo.save.mockResolvedValueOnce({ ...newDraft, topP: 0.9 });
|
||||||
|
const result = await service.saveSandboxDraft(
|
||||||
|
'standard',
|
||||||
|
{ topP: 0.9 },
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(result.topP).toBe(0.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resetSandboxToProduction ────────────────────────────────────────────────
|
||||||
|
describe('resetSandboxToProduction()', () => {
|
||||||
|
it('ควร reset draft ที่มีอยู่ให้ตรงกับ production', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||||
|
makeProfile({ temperature: 0.5 })
|
||||||
|
);
|
||||||
|
mockRedis.set.mockResolvedValueOnce('OK');
|
||||||
|
const existingDraft = makeSandbox({ temperature: 0.9 });
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(existingDraft);
|
||||||
|
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||||
|
...existingDraft,
|
||||||
|
temperature: 0.5,
|
||||||
|
});
|
||||||
|
const result = await service.resetSandboxToProduction('standard', 1);
|
||||||
|
expect(result.temperature).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรสร้าง draft ใหม่เมื่อยังไม่มี draft แล้ว reset ไปยัง production', async () => {
|
||||||
|
mockRedis.get.mockResolvedValueOnce(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||||
|
makeProfile({ temperature: 0.5 })
|
||||||
|
);
|
||||||
|
mockRedis.set.mockResolvedValueOnce('OK');
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(null); // ไม่มี draft
|
||||||
|
const newDraft = makeSandbox();
|
||||||
|
mockSandboxRepo.create.mockReturnValueOnce(newDraft);
|
||||||
|
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||||
|
...newDraft,
|
||||||
|
temperature: 0.5,
|
||||||
|
});
|
||||||
|
const result = await service.resetSandboxToProduction('standard');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── createJobPayload ────────────────────────────────────────────────────────
|
||||||
|
describe('createJobPayload()', () => {
|
||||||
|
it('ควรสร้าง payload ที่ถูกต้องสำหรับ rag-query job', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValue(makeProfile());
|
||||||
|
mockRedis.set.mockResolvedValue('OK');
|
||||||
|
const payload = await service.createJobPayload(
|
||||||
|
'rag-query',
|
||||||
|
'doc-id-123',
|
||||||
|
'att-id-456'
|
||||||
|
);
|
||||||
|
expect(payload.jobType).toBe('rag-query');
|
||||||
|
expect(payload.documentPublicId).toBe('doc-id-123');
|
||||||
|
expect(payload.canonicalModel).toBe('np-dms-ai');
|
||||||
|
expect(payload.snapshotParams.temperature).toBeDefined();
|
||||||
|
expect(payload.ocrSnapshotParams).toBeUndefined(); // rag-query ไม่มี OCR snapshot
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรสร้าง payload ที่มี ocrSnapshotParams สำหรับ migrate-document job', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValue(makeProfile());
|
||||||
|
mockRedis.set.mockResolvedValue('OK');
|
||||||
|
const payload = await service.createJobPayload(
|
||||||
|
'migrate-document',
|
||||||
|
'doc-id-789'
|
||||||
|
);
|
||||||
|
expect(payload.canonicalModel).toBe('np-dms-ai'); // main model for migrate
|
||||||
|
expect(payload.ocrSnapshotParams).toBeDefined();
|
||||||
|
expect(payload.ocrSnapshotParams?.temperature).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรสร้าง payload ที่ใช้ np-dms-ocr สำหรับ ocr-extract job', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockProfileRepo.findOne.mockResolvedValue(
|
||||||
|
makeProfile({ canonicalModel: 'np-dms-ocr', temperature: 0.1 })
|
||||||
|
);
|
||||||
|
mockRedis.set.mockResolvedValue('OK');
|
||||||
|
const payload = await service.createJobPayload(
|
||||||
|
'ocr-extract',
|
||||||
|
'doc-id-ocr'
|
||||||
|
);
|
||||||
|
expect(payload.canonicalModel).toBe('np-dms-ocr');
|
||||||
|
expect(payload.ocrSnapshotParams).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── applyProfile validation ─────────────────────────────────────────────────
|
||||||
|
describe('applyProfile() — parameter validation', () => {
|
||||||
|
it('ควรโยน BadRequestException เมื่อ temperature > 1', async () => {
|
||||||
|
const draft = makeSandbox({ temperature: 1.5, profileName: 'standard' });
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||||
|
await expect(service.applyProfile('standard', 1)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรโยน BadRequestException เมื่อ topP < 0', async () => {
|
||||||
|
const draft = makeSandbox({
|
||||||
|
temperature: 0.5,
|
||||||
|
topP: -0.1,
|
||||||
|
profileName: 'standard',
|
||||||
|
});
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||||
|
await expect(service.applyProfile('standard', 1)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรโยน BadRequestException เมื่อ repeatPenalty < 1', async () => {
|
||||||
|
const draft = makeSandbox({
|
||||||
|
temperature: 0.5,
|
||||||
|
topP: 0.8,
|
||||||
|
repeatPenalty: 0.9,
|
||||||
|
profileName: 'standard',
|
||||||
|
});
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||||
|
await expect(service.applyProfile('standard')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรโยน BadRequestException เมื่อ keepAliveSeconds < 0', async () => {
|
||||||
|
const draft = makeSandbox({
|
||||||
|
temperature: 0.5,
|
||||||
|
topP: 0.8,
|
||||||
|
repeatPenalty: 1.1,
|
||||||
|
keepAliveSeconds: -1,
|
||||||
|
profileName: 'standard',
|
||||||
|
});
|
||||||
|
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||||
|
await expect(service.applyProfile('standard')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// File: backend/src/modules/ai/tests/vram-monitor.service.spec.ts
|
// File: backend/src/modules/ai/tests/vram-monitor.service.spec.ts
|
||||||
// Change Log:
|
// Change Log:
|
||||||
// - 2026-06-11: สร้าง unit tests สำหรับ VramMonitorService (US5)
|
// - 2026-06-11: สร้าง unit tests สำหรับ VramMonitorService (US5)
|
||||||
|
// - 2026-06-14: เพิ่ม tests สำหรับ getVramStatus และ invalidateCache เพื่อเพิ่ม branch/function coverage
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -99,4 +100,61 @@ describe('VramMonitorService', () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('getVramStatus', () => {
|
||||||
|
it('ควรคืน status ที่ถูกต้องเมื่อ Ollama คืน models', async () => {
|
||||||
|
mockedAxios.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
// first call: /api/ps ใน getVramStatus
|
||||||
|
data: {
|
||||||
|
models: [
|
||||||
|
{ name: 'np-dms-ai:latest', size_vram: 3 * 1024 * 1024 * 1024 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
// second call: /api/ps ใน getVramHeadroom
|
||||||
|
data: {
|
||||||
|
models: [
|
||||||
|
{ name: 'np-dms-ai:latest', size_vram: 3 * 1024 * 1024 * 1024 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const status = await service.getVramStatus(4000);
|
||||||
|
expect(status.loadedModels).toContain('np-dms-ai:latest');
|
||||||
|
expect(status.totalVramMb).toBe(8192);
|
||||||
|
expect(status.hasCapacity).toBe(true); // 8192MB - 3072MB = 5120MB free > 4000MB required
|
||||||
|
});
|
||||||
|
it('ควรคืน hasCapacity=true เมื่อมี VRAM เหลือเพียงพอ', async () => {
|
||||||
|
mockedAxios.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
models: [
|
||||||
|
{ name: 'np-dms-ai:latest', size_vram: 1 * 1024 * 1024 * 1024 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
models: [
|
||||||
|
{ name: 'np-dms-ai:latest', size_vram: 1 * 1024 * 1024 * 1024 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const status = await service.getVramStatus(4000);
|
||||||
|
// 8192MB total - 1024MB used = 7168MB free > 4000MB
|
||||||
|
expect(status.hasCapacity).toBe(true);
|
||||||
|
});
|
||||||
|
it('ควรคืน fallback (hasCapacity=false) เมื่อ /api/ps ล้มเหลว', async () => {
|
||||||
|
mockedAxios.get.mockRejectedValue(new Error('Network error'));
|
||||||
|
const status = await service.getVramStatus();
|
||||||
|
expect(status.hasCapacity).toBe(false);
|
||||||
|
expect(status.freeVramMb).toBe(0);
|
||||||
|
expect(status.loadedModels).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('invalidateCache', () => {
|
||||||
|
it('ควร resolve โดยไม่ throw (no-op)', async () => {
|
||||||
|
await expect(service.invalidateCache()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
Request,
|
Request,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
@@ -36,6 +37,8 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
|
|||||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
import { Audit } from '../../common/decorators/audit.decorator';
|
import { Audit } from '../../common/decorators/audit.decorator';
|
||||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||||
|
import { ValidationException } from '../../common/exceptions';
|
||||||
|
import { IdempotencyInterceptor } from '../../common/interceptors/idempotency.interceptor';
|
||||||
import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface';
|
import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface';
|
||||||
|
|
||||||
@ApiTags('Correspondences')
|
@ApiTags('Correspondences')
|
||||||
@@ -52,6 +55,8 @@ export class CorrespondenceController {
|
|||||||
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
|
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
|
||||||
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
|
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
|
||||||
@RequirePermission('workflow.action_review')
|
@RequirePermission('workflow.action_review')
|
||||||
|
@Audit('correspondence.workflow_action', 'correspondence')
|
||||||
|
@UseInterceptors(IdempotencyInterceptor)
|
||||||
processAction(
|
processAction(
|
||||||
@Body() actionDto: WorkflowActionDto,
|
@Body() actionDto: WorkflowActionDto,
|
||||||
@Request() req: RequestWithUser
|
@Request() req: RequestWithUser
|
||||||
@@ -62,7 +67,9 @@ export class CorrespondenceController {
|
|||||||
|
|
||||||
// Use Unified Workflow Engine via CorrespondenceWorkflowService
|
// Use Unified Workflow Engine via CorrespondenceWorkflowService
|
||||||
if (!actionDto.instanceId) {
|
if (!actionDto.instanceId) {
|
||||||
throw new Error('instanceId is required for workflow action');
|
throw new ValidationException(
|
||||||
|
'instanceId is required for workflow action'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.workflowService.processAction(
|
return this.workflowService.processAction(
|
||||||
@@ -85,6 +92,7 @@ export class CorrespondenceController {
|
|||||||
})
|
})
|
||||||
@RequirePermission('correspondence.create')
|
@RequirePermission('correspondence.create')
|
||||||
@Audit('correspondence.create', 'correspondence')
|
@Audit('correspondence.create', 'correspondence')
|
||||||
|
@UseInterceptors(IdempotencyInterceptor)
|
||||||
create(
|
create(
|
||||||
@Body() createDto: CreateCorrespondenceDto,
|
@Body() createDto: CreateCorrespondenceDto,
|
||||||
@Request() req: RequestWithUser
|
@Request() req: RequestWithUser
|
||||||
@@ -125,6 +133,7 @@ export class CorrespondenceController {
|
|||||||
})
|
})
|
||||||
@RequirePermission('correspondence.create')
|
@RequirePermission('correspondence.create')
|
||||||
@Audit('correspondence.submit', 'correspondence')
|
@Audit('correspondence.submit', 'correspondence')
|
||||||
|
@UseInterceptors(IdempotencyInterceptor)
|
||||||
async submit(
|
async submit(
|
||||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||||
@Body() submitDto: SubmitCorrespondenceDto,
|
@Body() submitDto: SubmitCorrespondenceDto,
|
||||||
@@ -158,8 +167,9 @@ export class CorrespondenceController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: 'Correspondence updated successfully.',
|
description: 'Correspondence updated successfully.',
|
||||||
})
|
})
|
||||||
@RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit'
|
@RequirePermission('correspondence.edit')
|
||||||
@Audit('correspondence.update', 'correspondence')
|
@Audit('correspondence.update', 'correspondence')
|
||||||
|
@UseInterceptors(IdempotencyInterceptor)
|
||||||
async update(
|
async update(
|
||||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||||
@Body() updateDto: UpdateCorrespondenceDto,
|
@Body() updateDto: UpdateCorrespondenceDto,
|
||||||
@@ -241,6 +251,7 @@ export class CorrespondenceController {
|
|||||||
@ApiOperation({ summary: 'Bulk cancel correspondences (Org Admin+)' })
|
@ApiOperation({ summary: 'Bulk cancel correspondences (Org Admin+)' })
|
||||||
@RequirePermission('correspondence.cancel')
|
@RequirePermission('correspondence.cancel')
|
||||||
@Audit('correspondence.bulk_cancel', 'correspondence')
|
@Audit('correspondence.bulk_cancel', 'correspondence')
|
||||||
|
@UseInterceptors(IdempotencyInterceptor)
|
||||||
async bulkCancel(
|
async bulkCancel(
|
||||||
@Body() dto: BulkCancelDto,
|
@Body() dto: BulkCancelDto,
|
||||||
@Request() req: RequestWithUser
|
@Request() req: RequestWithUser
|
||||||
@@ -274,6 +285,7 @@ export class CorrespondenceController {
|
|||||||
})
|
})
|
||||||
@RequirePermission('correspondence.cancel')
|
@RequirePermission('correspondence.cancel')
|
||||||
@Audit('correspondence.cancel', 'correspondence')
|
@Audit('correspondence.cancel', 'correspondence')
|
||||||
|
@UseInterceptors(IdempotencyInterceptor)
|
||||||
async cancel(
|
async cancel(
|
||||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||||
@Body() cancelDto: CancelCorrespondenceDto,
|
@Body() cancelDto: CancelCorrespondenceDto,
|
||||||
|
|||||||
@@ -890,6 +890,11 @@ export class CorrespondenceService {
|
|||||||
const updated = await this.findOne(id);
|
const updated = await this.findOne(id);
|
||||||
|
|
||||||
// Re-index updated document in Elasticsearch (fire-and-forget)
|
// Re-index updated document in Elasticsearch (fire-and-forget)
|
||||||
|
// ใช้ status จริงจาก current revision แทนการ hardcode 'DRAFT'
|
||||||
|
const currentRevisionStatus =
|
||||||
|
updated.revisions?.find((r) => r.isCurrent)?.status?.statusCode ??
|
||||||
|
updated.revisions?.[0]?.status?.statusCode ??
|
||||||
|
'DRAFT';
|
||||||
void this.searchService.indexDocument({
|
void this.searchService.indexDocument({
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
publicId: updated.publicId,
|
publicId: updated.publicId,
|
||||||
@@ -897,7 +902,7 @@ export class CorrespondenceService {
|
|||||||
docNumber: updated.correspondenceNumber,
|
docNumber: updated.correspondenceNumber,
|
||||||
title: updateDto.subject ?? updated.revisions?.[0]?.subject,
|
title: updateDto.subject ?? updated.revisions?.[0]?.subject,
|
||||||
description: updateDto.description ?? updated.revisions?.[0]?.description,
|
description: updateDto.description ?? updated.revisions?.[0]?.description,
|
||||||
status: 'DRAFT',
|
status: currentRevisionStatus,
|
||||||
projectId: updated.projectId,
|
projectId: updated.projectId,
|
||||||
createdAt: updated.createdAt,
|
createdAt: updated.createdAt,
|
||||||
});
|
});
|
||||||
@@ -1141,7 +1146,10 @@ export class CorrespondenceService {
|
|||||||
try {
|
try {
|
||||||
await this.cancel(publicId, reason, user);
|
await this.cancel(publicId, reason, user);
|
||||||
succeeded.push(publicId);
|
succeeded.push(publicId);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Bulk cancel failed for ${publicId}: ${(err as Error).message}`
|
||||||
|
);
|
||||||
failed.push(publicId);
|
failed.push(publicId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1150,7 +1158,12 @@ export class CorrespondenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async exportCsv(searchDto: SearchCorrespondenceDto): Promise<string> {
|
async exportCsv(searchDto: SearchCorrespondenceDto): Promise<string> {
|
||||||
const { data } = await this.findAll(searchDto);
|
// ดึงทุกแถวที่ตรงเงื่อนไข — ไม่ใช้ pagination สำหรับ export
|
||||||
|
const { data } = await this.findAll({
|
||||||
|
...searchDto,
|
||||||
|
page: 1,
|
||||||
|
limit: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
const header = [
|
const header = [
|
||||||
'Document No.',
|
'Document No.',
|
||||||
@@ -1182,9 +1195,12 @@ export class CorrespondenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private escapeCsv(value: string): string {
|
private escapeCsv(value: string): string {
|
||||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
// กัน CSV formula injection (OWASP)
|
||||||
return `"${value.replace(/"/g, '""')}"`;
|
let v = value;
|
||||||
|
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
|
||||||
|
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
|
||||||
|
return `"${v.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
return value;
|
return v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,32 @@ import {
|
|||||||
IsObject,
|
IsObject,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO ของผู้รับเอกสาร — ใช้กับ @ValidateNested เพื่อตรวจสอบแต่ละ element ใน recipients array
|
||||||
|
*/
|
||||||
|
export class RecipientDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Organization ID or UUID ของผู้รับ',
|
||||||
|
example: '019505a1-7c3e-7000-8000-abc123def456',
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
organizationId!: number | string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'ประเภทผู้รับ: TO หรือ CC',
|
||||||
|
enum: ['TO', 'CC'],
|
||||||
|
example: 'TO',
|
||||||
|
})
|
||||||
|
@IsIn(['TO', 'CC'])
|
||||||
|
type!: 'TO' | 'CC';
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateCorrespondenceDto {
|
export class CreateCorrespondenceDto {
|
||||||
@ApiProperty({ description: 'Project ID or UUID', example: 1 })
|
@ApiProperty({ description: 'Project ID or UUID', example: 1 })
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -125,9 +148,15 @@ export class CreateCorrespondenceDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Recipients',
|
description: 'Recipients',
|
||||||
example: [{ organizationId: 1, type: 'TO' }],
|
example: [
|
||||||
|
{ organizationId: '019505a1-7c3e-7000-8000-abc123def456', type: 'TO' },
|
||||||
|
],
|
||||||
|
type: () => RecipientDto,
|
||||||
|
isArray: true,
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[];
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => RecipientDto)
|
||||||
|
recipients?: RecipientDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
// File: src/modules/rfa/dto/submit-rfa.dto.ts
|
// File: src/modules/rfa/dto/submit-rfa.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: ตัด templateId ออก — ย้ายไปใช้ Unified Workflow Engine (ADR-001)
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class SubmitRfaDto {
|
export class SubmitRfaDto {
|
||||||
@ApiProperty({
|
|
||||||
description: 'ID ของ Routing Template ที่จะใช้เดินเรื่อง',
|
|
||||||
example: 1,
|
|
||||||
})
|
|
||||||
@IsInt()
|
|
||||||
@IsNotEmpty()
|
|
||||||
templateId!: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'publicId ของ Review Team สำหรับ Parallel Review (ADR-019)',
|
description: 'publicId ของ Review Team สำหรับ Parallel Review (ADR-019)',
|
||||||
example: '019505a1-7c3e-7000-8000-abc123def456',
|
example: '019505a1-7c3e-7000-8000-abc123def456',
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// File: src/modules/rfa/rfa.controller.ts
|
// File: src/modules/rfa/rfa.controller.ts
|
||||||
// Change Log:
|
// Change Log:
|
||||||
// - 2026-05-13: Wire submit reviewTeamPublicId through to the submit workflow for parallel review task creation.
|
// - 2026-05-13: Wire submit reviewTeamPublicId through to the submit workflow for parallel review task creation.
|
||||||
|
// - 2026-06-14: ADR-016 Idempotency-Key enforcement on mutations; pass RBAC roles to Unified Workflow Engine; drop templateId.
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
@@ -51,13 +54,34 @@ export class RfaController {
|
|||||||
private readonly uuidResolver: UuidResolverService
|
private readonly uuidResolver: UuidResolverService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** ADR-016: บังคับให้ทุก mutation ส่ง Idempotency-Key header */
|
||||||
|
private assertIdempotencyKey(idempotencyKey?: string): void {
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
throw new BadRequestException('Idempotency-Key header is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ดึง role name จาก user assignments เพื่อส่งให้ Unified Workflow Engine ตรวจ DSL requirements */
|
||||||
|
private extractRoles(user: User): string[] {
|
||||||
|
return (
|
||||||
|
user.assignments
|
||||||
|
?.map((a) => a.role?.roleName)
|
||||||
|
.filter((name): name is string => Boolean(name)) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create new RFA (Draft)' })
|
@ApiOperation({ summary: 'Create new RFA (Draft)' })
|
||||||
@ApiBody({ type: CreateRfaDto })
|
@ApiBody({ type: CreateRfaDto })
|
||||||
@ApiResponse({ status: 201, description: 'RFA created successfully' })
|
@ApiResponse({ status: 201, description: 'RFA created successfully' })
|
||||||
@RequirePermission('rfa.create')
|
@RequirePermission('rfa.create')
|
||||||
@Audit('rfa.create', 'rfa')
|
@Audit('rfa.create', 'rfa')
|
||||||
create(@Body() createDto: CreateRfaDto, @CurrentUser() user: User) {
|
create(
|
||||||
|
@Body() createDto: CreateRfaDto,
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Headers('Idempotency-Key') idempotencyKey: string
|
||||||
|
) {
|
||||||
|
this.assertIdempotencyKey(idempotencyKey);
|
||||||
return this.rfaService.create(createDto, user);
|
return this.rfaService.create(createDto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,15 +98,17 @@ export class RfaController {
|
|||||||
async submit(
|
async submit(
|
||||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||||
@Body() submitDto: SubmitRfaDto,
|
@Body() submitDto: SubmitRfaDto,
|
||||||
@CurrentUser() user: User
|
@CurrentUser() user: User,
|
||||||
|
@Headers('Idempotency-Key') idempotencyKey: string
|
||||||
) {
|
) {
|
||||||
|
this.assertIdempotencyKey(idempotencyKey);
|
||||||
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
|
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
|
||||||
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
||||||
return this.rfaService.submit(
|
return this.rfaService.submit(
|
||||||
rfa.id,
|
rfa.id,
|
||||||
submitDto.templateId,
|
|
||||||
user,
|
user,
|
||||||
submitDto.reviewTeamPublicId
|
submitDto.reviewTeamPublicId,
|
||||||
|
this.extractRoles(user)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +128,18 @@ export class RfaController {
|
|||||||
async processAction(
|
async processAction(
|
||||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||||
@Body() actionDto: WorkflowActionDto,
|
@Body() actionDto: WorkflowActionDto,
|
||||||
@CurrentUser() user: User
|
@CurrentUser() user: User,
|
||||||
|
@Headers('Idempotency-Key') idempotencyKey: string
|
||||||
) {
|
) {
|
||||||
|
this.assertIdempotencyKey(idempotencyKey);
|
||||||
// ADR-019: resolve UUID → internal INT id
|
// ADR-019: resolve UUID → internal INT id
|
||||||
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
||||||
return this.rfaService.processAction(rfa.id, actionDto, user);
|
return this.rfaService.processAction(
|
||||||
|
rfa.id,
|
||||||
|
actionDto,
|
||||||
|
user,
|
||||||
|
this.extractRoles(user)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -145,8 +178,10 @@ export class RfaController {
|
|||||||
async update(
|
async update(
|
||||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||||
@Body() updateDto: UpdateRfaDto,
|
@Body() updateDto: UpdateRfaDto,
|
||||||
@CurrentUser() user: User
|
@CurrentUser() user: User,
|
||||||
|
@Headers('Idempotency-Key') idempotencyKey: string
|
||||||
) {
|
) {
|
||||||
|
this.assertIdempotencyKey(idempotencyKey);
|
||||||
return this.rfaService.update(uuid, updateDto, user);
|
return this.rfaService.update(uuid, updateDto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +194,10 @@ export class RfaController {
|
|||||||
@Audit('rfa.cancel', 'rfa')
|
@Audit('rfa.cancel', 'rfa')
|
||||||
async cancel(
|
async cancel(
|
||||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||||
@CurrentUser() user: User
|
@CurrentUser() user: User,
|
||||||
|
@Headers('Idempotency-Key') idempotencyKey: string
|
||||||
) {
|
) {
|
||||||
|
this.assertIdempotencyKey(idempotencyKey);
|
||||||
return this.rfaService.cancel(uuid, user);
|
return this.rfaService.cancel(uuid, user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
// File: src/modules/rfa/rfa.module.ts
|
// File: src/modules/rfa/rfa.module.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: ตัด deprecated routing-template entities + RfaWorkflowService ออก (ADR-001 migration)
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
|
|
||||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||||
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
||||||
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
|
|
||||||
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
|
|
||||||
import { Organization } from '../organization/entities/organization.entity';
|
import { Organization } from '../organization/entities/organization.entity';
|
||||||
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
|
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
|
||||||
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
||||||
@@ -20,13 +19,9 @@ import { RfaItem } from './entities/rfa-item.entity';
|
|||||||
import { RfaRevision } from './entities/rfa-revision.entity';
|
import { RfaRevision } from './entities/rfa-revision.entity';
|
||||||
import { RfaStatusCode } from './entities/rfa-status-code.entity';
|
import { RfaStatusCode } from './entities/rfa-status-code.entity';
|
||||||
import { RfaType } from './entities/rfa-type.entity';
|
import { RfaType } from './entities/rfa-type.entity';
|
||||||
import { RfaWorkflowTemplateStep } from './entities/rfa-workflow-template-step.entity';
|
|
||||||
import { RfaWorkflowTemplate } from './entities/rfa-workflow-template.entity';
|
|
||||||
import { RfaWorkflow } from './entities/rfa-workflow.entity';
|
|
||||||
import { Rfa } from './entities/rfa.entity';
|
import { Rfa } from './entities/rfa.entity';
|
||||||
|
|
||||||
// Services & Controllers
|
// Services & Controllers
|
||||||
import { RfaWorkflowService } from './rfa-workflow.service'; // Register Service
|
|
||||||
import { RfaController } from './rfa.controller';
|
import { RfaController } from './rfa.controller';
|
||||||
import { RfaService } from './rfa.service';
|
import { RfaService } from './rfa.service';
|
||||||
|
|
||||||
@@ -55,12 +50,6 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
|
|||||||
AsBuiltDrawingRevision,
|
AsBuiltDrawingRevision,
|
||||||
ShopDrawingRevision,
|
ShopDrawingRevision,
|
||||||
Discipline,
|
Discipline,
|
||||||
RfaWorkflow,
|
|
||||||
RfaWorkflowTemplate,
|
|
||||||
RfaWorkflowTemplateStep,
|
|
||||||
CorrespondenceRouting,
|
|
||||||
RoutingTemplate,
|
|
||||||
RoutingTemplateStep,
|
|
||||||
CorrespondenceRecipient,
|
CorrespondenceRecipient,
|
||||||
Organization,
|
Organization,
|
||||||
]),
|
]),
|
||||||
@@ -72,7 +61,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
|
|||||||
WorkflowEngineModule,
|
WorkflowEngineModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
],
|
],
|
||||||
providers: [RfaService, RfaWorkflowService],
|
providers: [RfaService],
|
||||||
controllers: [RfaController],
|
controllers: [RfaController],
|
||||||
exports: [RfaService],
|
exports: [RfaService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// File: src/modules/rfa/rfa.service.ts
|
// File: src/modules/rfa/rfa.service.ts
|
||||||
// Change Log:
|
// Change Log:
|
||||||
// - 2026-05-13: Invoke TaskCreationService during submit when a review team is selected.
|
// - 2026-05-13: Invoke TaskCreationService during submit when a review team is selected.
|
||||||
|
// - 2026-06-14: ADR-001/021 migration — submit()/processAction() เดินผ่าน Unified Workflow Engine
|
||||||
|
// (เลิกใช้ RoutingTemplate/CorrespondenceRouting), ตัด templateId, ย้าย notification ออกนอก transaction,
|
||||||
|
// ทำ EC-RFA-001 ให้ race-safe (lock FOR UPDATE), เลิก hardcode approve code.
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@@ -15,14 +18,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { DataSource, In, Repository } from 'typeorm';
|
import { DataSource, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
|
|
||||||
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
||||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||||
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
|
|
||||||
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
|
|
||||||
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
|
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
|
||||||
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
||||||
import { Discipline } from '../master/entities/discipline.entity';
|
import { Discipline } from '../master/entities/discipline.entity';
|
||||||
@@ -49,6 +49,10 @@ type CorrRevWithRfa = CorrespondenceRevision & { rfaRevision?: RfaRevision };
|
|||||||
export interface RfaMapped extends Rfa {
|
export interface RfaMapped extends Rfa {
|
||||||
publicId?: string; // ADR-019: top-level publicId from correspondence
|
publicId?: string; // ADR-019: top-level publicId from correspondence
|
||||||
revisions: CorrRevWithRfa[];
|
revisions: CorrRevWithRfa[];
|
||||||
|
// ADR-021: expose Unified Workflow Engine state for IntegratedBanner/WorkflowLifecycle
|
||||||
|
workflowInstanceId?: string;
|
||||||
|
workflowState?: string;
|
||||||
|
availableActions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interfaces & Enums
|
// Interfaces & Enums
|
||||||
@@ -67,6 +71,20 @@ import { TaskCreationService } from '../review-team/services/task-creation.servi
|
|||||||
export class RfaService {
|
export class RfaService {
|
||||||
private readonly logger = new Logger(RfaService.name);
|
private readonly logger = new Logger(RfaService.name);
|
||||||
|
|
||||||
|
/** ADR-001: รหัส Workflow ที่ลงทะเบียนใน seed DSL */
|
||||||
|
static readonly WORKFLOW_CODE = 'RFA_APPROVAL';
|
||||||
|
|
||||||
|
/** แมป Workflow State → RFA Status Code ตาม seed data */
|
||||||
|
static readonly STATE_TO_STATUS: Record<string, string> = {
|
||||||
|
DRAFT: 'DFT',
|
||||||
|
CONSULTANT_REVIEW: 'FRE',
|
||||||
|
OWNER_REVIEW: 'FAP',
|
||||||
|
APPROVED: 'FCO',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** รหัสอนุมัติเริ่มต้นเมื่อถึงสถานะ Terminal */
|
||||||
|
static readonly DEFAULT_APPROVED_CODE = '1A';
|
||||||
|
|
||||||
private async hasSystemManageAllPermission(userId: number): Promise<boolean> {
|
private async hasSystemManageAllPermission(userId: number): Promise<boolean> {
|
||||||
const permissions = await this.userService.getUserPermissions(userId);
|
const permissions = await this.userService.getUserPermissions(userId);
|
||||||
return permissions.includes('system.manage_all');
|
return permissions.includes('system.manage_all');
|
||||||
@@ -99,12 +117,6 @@ export class RfaService {
|
|||||||
private asBuiltDrawingRevRepo: Repository<AsBuiltDrawingRevision>,
|
private asBuiltDrawingRevRepo: Repository<AsBuiltDrawingRevision>,
|
||||||
@InjectRepository(ShopDrawingRevision)
|
@InjectRepository(ShopDrawingRevision)
|
||||||
private shopDrawingRevRepo: Repository<ShopDrawingRevision>,
|
private shopDrawingRevRepo: Repository<ShopDrawingRevision>,
|
||||||
@InjectRepository(CorrespondenceRouting)
|
|
||||||
private routingRepo: Repository<CorrespondenceRouting>,
|
|
||||||
@InjectRepository(RoutingTemplate)
|
|
||||||
private templateRepo: Repository<RoutingTemplate>,
|
|
||||||
@InjectRepository(RoutingTemplateStep)
|
|
||||||
private templateStepRepo: Repository<RoutingTemplateStep>,
|
|
||||||
@InjectRepository(Organization)
|
@InjectRepository(Organization)
|
||||||
private orgRepo: Repository<Organization>,
|
private orgRepo: Repository<Organization>,
|
||||||
|
|
||||||
@@ -274,30 +286,6 @@ export class RfaService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EC-RFA-001: Check for existing active RFA per Shop Drawing Revision
|
|
||||||
if (shopDrawingRevisionIds.length > 0) {
|
|
||||||
const conflictingItems = await this.rfaItemRepo
|
|
||||||
.createQueryBuilder('item')
|
|
||||||
.innerJoin('item.rfaRevision', 'rfaRev')
|
|
||||||
.innerJoin('rfaRev.statusCode', 'status')
|
|
||||||
.where('item.shopDrawingRevisionId IN (:...ids)', {
|
|
||||||
ids: shopDrawingRevisionIds,
|
|
||||||
})
|
|
||||||
.andWhere('status.statusCode NOT IN (:...codes)', {
|
|
||||||
codes: ['CC', 'OBS'],
|
|
||||||
})
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
if (conflictingItems.length > 0) {
|
|
||||||
throw new BusinessException(
|
|
||||||
'EC_RFA_001_ACTIVE_RFA_EXISTS',
|
|
||||||
'[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA.',
|
|
||||||
'Shop Drawing Revision ที่เลือกมี RFA ที่ยังใช้งานอยู่แล้ว',
|
|
||||||
['ตรวจสอบ RFA ที่มีอยู่', 'เลือก Shop Drawing Revision อื่น']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch real Organization Code for document numbering
|
// Fetch real Organization Code for document numbering
|
||||||
const userOrg = await this.orgRepo.findOne({
|
const userOrg = await this.orgRepo.findOne({
|
||||||
where: { id: userOrgId },
|
where: { id: userOrgId },
|
||||||
@@ -310,7 +298,42 @@ export class RfaService {
|
|||||||
await queryRunner.startTransaction();
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// EC-RFA-001 (race-safe): ล็อกแถว shop_drawing_revisions ที่เลือกด้วย FOR UPDATE
|
||||||
|
// ภายใน transaction เพื่อ serialize การสร้าง RFA พร้อมกันบน drawing เดียวกัน
|
||||||
|
// จากนั้นค่อยตรวจว่ามี RFA ที่ยัง active อยู่หรือไม่ (กัน TOCTOU)
|
||||||
|
if (shopDrawingRevisionIds.length > 0) {
|
||||||
|
await queryRunner.manager.query(
|
||||||
|
`SELECT id FROM shop_drawing_revisions WHERE id IN (${shopDrawingRevisionIds
|
||||||
|
.map(() => '?')
|
||||||
|
.join(',')}) FOR UPDATE`,
|
||||||
|
shopDrawingRevisionIds
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflictingItems = await queryRunner.manager
|
||||||
|
.createQueryBuilder(RfaItem, 'item')
|
||||||
|
.innerJoin('item.rfaRevision', 'rfaRev')
|
||||||
|
.innerJoin('rfaRev.statusCode', 'status')
|
||||||
|
.where('item.shopDrawingRevisionId IN (:...ids)', {
|
||||||
|
ids: shopDrawingRevisionIds,
|
||||||
|
})
|
||||||
|
.andWhere('status.statusCode NOT IN (:...codes)', {
|
||||||
|
codes: ['CC', 'OBS'],
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (conflictingItems.length > 0) {
|
||||||
|
throw new BusinessException(
|
||||||
|
'EC_RFA_001_ACTIVE_RFA_EXISTS',
|
||||||
|
'[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA.',
|
||||||
|
'Shop Drawing Revision ที่เลือกมี RFA ที่ยังใช้งานอยู่แล้ว',
|
||||||
|
['ตรวจสอบ RFA ที่มีอยู่', 'เลือก Shop Drawing Revision อื่น']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// [UPDATED] Generate Document Number with Discipline
|
// [UPDATED] Generate Document Number with Discipline
|
||||||
|
// ADR-002: generateNextNumber ใช้ transaction ของตัวเอง (Redlock + optimistic counter)
|
||||||
|
// จึงรับประกัน "ไม่ซ้ำ" แต่ "อาจมี gap" ได้หาก transaction นี้ rollback — ยอมรับได้ตาม ADR-002
|
||||||
const docNumber = await this.numberingService.generateNextNumber({
|
const docNumber = await this.numberingService.generateNextNumber({
|
||||||
projectId: internalProjectId,
|
projectId: internalProjectId,
|
||||||
originatorOrganizationId: userOrgId,
|
originatorOrganizationId: userOrgId,
|
||||||
@@ -443,11 +466,13 @@ export class RfaService {
|
|||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
// [NEW V1.5.1] Start Unified Workflow Instance
|
// [NEW V1.5.1] Start Unified Workflow Instance (ADR-001)
|
||||||
// [C2 FIX] Drawing types (DDW/SDW/ADW) are RFA subtypes — all use RFA_APPROVAL
|
// [C2 FIX] Drawing types (DDW/SDW/ADW) are RFA subtypes — all use RFA_APPROVAL
|
||||||
|
// Instance ถูกสร้างใน state DRAFT; submit() จะ transition 'SUBMIT' ต่อ
|
||||||
|
// หาก createInstance ล้มเหลว → log error (ไม่ fatal); submit() จะ self-heal สร้าง instance ให้
|
||||||
try {
|
try {
|
||||||
await this.workflowEngine.createInstance(
|
await this.workflowEngine.createInstance(
|
||||||
'RFA_APPROVAL',
|
RfaService.WORKFLOW_CODE,
|
||||||
'rfa',
|
'rfa',
|
||||||
savedRfa.id.toString(),
|
savedRfa.id.toString(),
|
||||||
{
|
{
|
||||||
@@ -459,8 +484,8 @@ export class RfaService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.warn(
|
this.logger.error(
|
||||||
`Workflow not started for ${docNumber.number}: ${(error as Error).message}`
|
`Workflow instance not started for ${docNumber.number} (will self-heal on submit): ${(error as Error).message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,8 +520,6 @@ export class RfaService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
|
|
||||||
|
|
||||||
async findAll(query: SearchRfaDto, _user?: User) {
|
async findAll(query: SearchRfaDto, _user?: User) {
|
||||||
const {
|
const {
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -552,11 +575,25 @@ export class RfaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RBAC: DFT documents are visible only to the originator org (spec §3.3.10)
|
// RBAC: DFT documents are visible only to the originator org (spec §3.3.10)
|
||||||
if (_user?.primaryOrganizationId) {
|
// ผู้มีสิทธิ์ system.manage_all เห็นทุก DFT; ผู้อื่นเห็นเฉพาะของ org ตน
|
||||||
queryBuilder.andWhere(
|
// ผู้ที่ไม่มี org และไม่มีสิทธิ์ manage_all → ไม่เห็น DFT เลย (กัน data leak)
|
||||||
'(rfaRev.id IS NULL OR status.statusCode != :dftCode OR corr.originatorId = :userOrgId)',
|
if (_user) {
|
||||||
{ dftCode: 'DFT', userOrgId: _user.primaryOrganizationId }
|
const canViewAllDft = await this.hasSystemManageAllPermission(
|
||||||
|
_user.user_id
|
||||||
);
|
);
|
||||||
|
if (!canViewAllDft) {
|
||||||
|
if (_user.primaryOrganizationId) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(rfaRev.id IS NULL OR status.statusCode != :dftCode OR corr.originatorId = :userOrgId)',
|
||||||
|
{ dftCode: 'DFT', userOrgId: _user.primaryOrganizationId }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(rfaRev.id IS NULL OR status.statusCode != :dftCode)',
|
||||||
|
{ dftCode: 'DFT' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [items, total] = await queryBuilder
|
const [items, total] = await queryBuilder
|
||||||
@@ -599,7 +636,7 @@ export class RfaService {
|
|||||||
* ADR-019: Find RFA by the parent Correspondence publicId (public identifier).
|
* ADR-019: Find RFA by the parent Correspondence publicId (public identifier).
|
||||||
* Resolves correspondence.publicId → internal rfa.id
|
* Resolves correspondence.publicId → internal rfa.id
|
||||||
*/
|
*/
|
||||||
async findOneByUuid(publicId: string) {
|
async findOneByUuid(publicId: string): Promise<RfaMapped> {
|
||||||
const correspondence = await this.correspondenceRepo.findOne({
|
const correspondence = await this.correspondenceRepo.findOne({
|
||||||
where: { publicId },
|
where: { publicId },
|
||||||
select: ['id'],
|
select: ['id'],
|
||||||
@@ -607,7 +644,17 @@ export class RfaService {
|
|||||||
if (!correspondence) {
|
if (!correspondence) {
|
||||||
throw new NotFoundException('RFA', publicId);
|
throw new NotFoundException('RFA', publicId);
|
||||||
}
|
}
|
||||||
return this.findOne(correspondence.id);
|
const mapped = (await this.findOne(correspondence.id)) as RfaMapped;
|
||||||
|
|
||||||
|
// ADR-021: ดึง Workflow Instance (nullable — DRAFT ที่ยังไม่เริ่ม submit ก็มี instance DRAFT)
|
||||||
|
const wfInstance = await this.workflowEngine.getInstanceByEntity(
|
||||||
|
'rfa',
|
||||||
|
correspondence.id.toString()
|
||||||
|
);
|
||||||
|
mapped.workflowInstanceId = wfInstance?.id;
|
||||||
|
mapped.workflowState = wfInstance?.currentState;
|
||||||
|
mapped.availableActions = wfInstance?.availableActions ?? [];
|
||||||
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOneByUuidRaw(publicId: string) {
|
async findOneByUuidRaw(publicId: string) {
|
||||||
@@ -668,11 +715,16 @@ export class RfaService {
|
|||||||
return mappedRfa;
|
return mappedRfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit RFA เข้า Unified Workflow Engine (ADR-001)
|
||||||
|
* - ใช้ Workflow Instance ที่สร้างไว้ตอน create() (state DRAFT); ถ้าไม่มีจะ self-heal
|
||||||
|
* - Engine จัดการ Redlock + pessimistic lock + version CAS ป้องกัน double-submit
|
||||||
|
*/
|
||||||
async submit(
|
async submit(
|
||||||
rfaId: number,
|
rfaId: number,
|
||||||
templateId: number,
|
|
||||||
user: User,
|
user: User,
|
||||||
reviewTeamPublicId?: string
|
reviewTeamPublicId?: string,
|
||||||
|
roles: string[] = []
|
||||||
) {
|
) {
|
||||||
const rfa = await this.findOne(rfaId, true);
|
const rfa = await this.findOne(rfaId, true);
|
||||||
const corrRevisions =
|
const corrRevisions =
|
||||||
@@ -692,109 +744,93 @@ export class RfaService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await this.templateRepo.findOne({
|
// ADR-001: หา Workflow Instance ที่สร้างไว้ตอน create() — ถ้าไม่มีให้ self-heal
|
||||||
where: { id: templateId },
|
let instance = await this.workflowEngine.getInstanceByEntity(
|
||||||
// relations: ['steps'], // Deprecated relation removed
|
'rfa',
|
||||||
});
|
rfaId.toString()
|
||||||
|
);
|
||||||
if (!template) {
|
if (instance && instance.currentState !== 'DRAFT') {
|
||||||
throw new BusinessException(
|
throw new WorkflowException(
|
||||||
'ROUTING_TEMPLATE_NOT_FOUND',
|
'RFA_ALREADY_SUBMITTED',
|
||||||
'Invalid routing template',
|
`RFA already submitted (state: ${instance.currentState})`,
|
||||||
'ไม่พบ Routing Template ที่กำหนด',
|
'RFA นี้ถูกส่งเข้า Workflow ไปแล้ว',
|
||||||
['ตรวจสอบ Routing Template ที่ตั้งค่าไว้']
|
['รีเฟรชหน้าเพื่อดูสถานะล่าสุด']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!instance) {
|
||||||
// Manual fetch of steps
|
const created = await this.workflowEngine.createInstance(
|
||||||
const steps = await this.templateStepRepo.find({
|
RfaService.WORKFLOW_CODE,
|
||||||
where: { templateId: template.id },
|
'rfa',
|
||||||
order: { sequence: 'ASC' },
|
rfaId.toString(),
|
||||||
});
|
{
|
||||||
|
projectId: rfa.correspondence.projectId,
|
||||||
if (steps.length === 0) {
|
originatorId: rfa.correspondence.originatorId,
|
||||||
throw new BusinessException(
|
disciplineId: rfa.correspondence.disciplineId,
|
||||||
'ROUTING_TEMPLATE_EMPTY',
|
initiatorId: user.user_id,
|
||||||
'Routing template has no steps',
|
}
|
||||||
'Routing Template ไม่มีขั้นตอนกำหนดไว้',
|
|
||||||
['เพิ่ม Step ใน Routing Template']
|
|
||||||
);
|
);
|
||||||
|
instance = {
|
||||||
|
id: created.id,
|
||||||
|
currentState: created.currentState,
|
||||||
|
availableActions: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusForApprove = await this.rfaStatusRepo.findOne({
|
// ADR-001: transition 'SUBMIT' (DSL ต้องการ role CONTRACTOR ผ่าน context.roles)
|
||||||
where: { statusCode: 'FAP' },
|
const result = await this.workflowEngine.processTransition(
|
||||||
});
|
instance.id,
|
||||||
if (!statusForApprove)
|
'SUBMIT',
|
||||||
throw new SystemException('Status FAP not found in Master Data');
|
user.user_id,
|
||||||
|
'RFA Submitted',
|
||||||
|
{ roles },
|
||||||
|
undefined,
|
||||||
|
user.publicId
|
||||||
|
);
|
||||||
|
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
// Sync สถานะ RFA Revision ตาม state ใหม่ (เช่น CONSULTANT_REVIEW → FRE)
|
||||||
await queryRunner.connect();
|
await this.syncRevisionStatus(currentRfaRev, result.nextState);
|
||||||
await queryRunner.startTransaction();
|
currentCorrRev.issuedDate = new Date();
|
||||||
|
await this.corrRevRepo.save(currentCorrRev);
|
||||||
|
|
||||||
try {
|
// FR-003: สร้าง Parallel Review Tasks (ถ้าเลือก Review Team) — transaction ของตัวเอง
|
||||||
// Update Revision Status
|
if (reviewTeamPublicId) {
|
||||||
currentRfaRev.rfaStatusCodeId = statusForApprove.id;
|
await this.dataSource.transaction(async (manager) => {
|
||||||
currentCorrRev.issuedDate = new Date();
|
|
||||||
await queryRunner.manager.save(currentRfaRev);
|
|
||||||
await queryRunner.manager.save(currentCorrRev);
|
|
||||||
|
|
||||||
// Create First Routing Step
|
|
||||||
const firstStep = steps[0];
|
|
||||||
const routing = queryRunner.manager.create(CorrespondenceRouting, {
|
|
||||||
correspondenceId: rfa.correspondence.id, // ✅ Use master correspondence id
|
|
||||||
templateId: template.id,
|
|
||||||
sequence: 1,
|
|
||||||
fromOrganizationId: user.primaryOrganizationId,
|
|
||||||
toOrganizationId: firstStep.toOrganizationId,
|
|
||||||
stepPurpose: firstStep.stepPurpose,
|
|
||||||
status: 'SENT',
|
|
||||||
dueDate: new Date(
|
|
||||||
Date.now() + (firstStep.expectedDays || 7) * 24 * 60 * 60 * 1000
|
|
||||||
),
|
|
||||||
processedByUserId: user.user_id,
|
|
||||||
processedAt: new Date(),
|
|
||||||
});
|
|
||||||
await queryRunner.manager.save(routing);
|
|
||||||
|
|
||||||
if (reviewTeamPublicId) {
|
|
||||||
await this.taskCreationService.createParallelTasks(
|
await this.taskCreationService.createParallelTasks(
|
||||||
currentRfaRev.id,
|
currentRfaRev.id,
|
||||||
currentCorrRev.publicId, // ADR-019: Pass UUID
|
currentCorrRev.publicId, // ADR-019: Pass UUID
|
||||||
reviewTeamPublicId,
|
reviewTeamPublicId,
|
||||||
routing.dueDate ?? new Date(),
|
currentCorrRev.dueDate ?? new Date(),
|
||||||
queryRunner.manager,
|
manager,
|
||||||
rfa.correspondence.projectId,
|
rfa.correspondence.projectId,
|
||||||
rfa.rfaType.typeCode
|
rfa.rfaType.typeCode
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
// Notify
|
|
||||||
const recipientUserId = await this.userService.findDocControlIdByOrg(
|
|
||||||
firstStep.toOrganizationId
|
|
||||||
);
|
|
||||||
if (recipientUserId) {
|
|
||||||
await this.notificationService.send({
|
|
||||||
userId: recipientUserId,
|
|
||||||
title: `RFA Submitted: ${currentCorrRev.subject}`,
|
|
||||||
message: `RFA ${rfa.correspondence.correspondenceNumber} submitted for approval.`,
|
|
||||||
type: 'SYSTEM',
|
|
||||||
entityType: 'rfa',
|
|
||||||
entityId: rfa.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
|
||||||
return { message: 'RFA Submitted successfully', routing };
|
|
||||||
} catch (err) {
|
|
||||||
await queryRunner.rollbackTransaction();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await queryRunner.release();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADR-008: แจ้งเตือนแบบ fire-and-forget หลังทุกอย่างสำเร็จ (ไม่ค้างใน transaction)
|
||||||
|
void this.notifyRecipients(
|
||||||
|
rfa.correspondence.id,
|
||||||
|
rfa.correspondence.correspondenceNumber,
|
||||||
|
currentCorrRev.subject
|
||||||
|
).catch((err: unknown) =>
|
||||||
|
this.logger.warn(
|
||||||
|
`RFA submit notification failed: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { instanceId: instance.id, currentState: result.nextState };
|
||||||
}
|
}
|
||||||
|
|
||||||
async processAction(rfaId: number, dto: WorkflowActionDto, user: User) {
|
/**
|
||||||
// Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB
|
* ดำเนินการ Workflow Action (APPROVE/REJECT) ผ่าน Unified Workflow Engine (ADR-001)
|
||||||
|
* Engine จัดการ pessimistic lock + version CAS ป้องกัน double-approval / TOCTOU
|
||||||
|
*/
|
||||||
|
async processAction(
|
||||||
|
rfaId: number,
|
||||||
|
dto: WorkflowActionDto,
|
||||||
|
user: User,
|
||||||
|
roles: string[] = []
|
||||||
|
) {
|
||||||
const rfa = await this.findOne(rfaId, true);
|
const rfa = await this.findOne(rfaId, true);
|
||||||
const corrRevisions =
|
const corrRevisions =
|
||||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||||
@@ -804,118 +840,107 @@ export class RfaService {
|
|||||||
|
|
||||||
const currentRfaRev = currentCorrRev.rfaRevision;
|
const currentRfaRev = currentCorrRev.rfaRevision;
|
||||||
|
|
||||||
const currentRouting = await this.routingRepo.findOne({
|
const instance = await this.workflowEngine.getInstanceByEntity(
|
||||||
where: {
|
'rfa',
|
||||||
correspondenceId: rfa.correspondence.id, // ✅ Use master correspondence id
|
rfaId.toString()
|
||||||
status: 'SENT',
|
);
|
||||||
},
|
if (!instance) {
|
||||||
order: { sequence: 'DESC' },
|
|
||||||
relations: ['toOrganization'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentRouting)
|
|
||||||
throw new WorkflowException(
|
throw new WorkflowException(
|
||||||
'NO_ACTIVE_WORKFLOW_STEP',
|
'NO_ACTIVE_WORKFLOW_STEP',
|
||||||
'No active workflow step found',
|
'No active workflow instance found',
|
||||||
'ไม่พบขั้นตอน Workflow ที่ยังเปิดอยู่',
|
'ไม่พบ Workflow ที่ยังเปิดอยู่',
|
||||||
['ตรวจสอบสถานะ Workflow ของเอกสาร']
|
['ตรวจสอบสถานะ Workflow ของเอกสาร']
|
||||||
);
|
);
|
||||||
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
|
|
||||||
throw new PermissionException('rfa workflow step', 'process');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await this.templateRepo.findOne({
|
const approveCodeStr =
|
||||||
where: { id: currentRouting.templateId },
|
typeof dto.payload?.approveCode === 'string'
|
||||||
// relations: ['steps'],
|
? dto.payload.approveCode
|
||||||
});
|
: undefined;
|
||||||
|
|
||||||
if (!template)
|
const result = await this.workflowEngine.processTransition(
|
||||||
throw new SystemException(
|
instance.id,
|
||||||
'Routing Template not found for workflow processing'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Manual fetch steps
|
|
||||||
const steps = await this.templateStepRepo.find({
|
|
||||||
where: { templateId: template.id },
|
|
||||||
order: { sequence: 'ASC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (steps.length === 0)
|
|
||||||
throw new SystemException('Routing Template steps not found');
|
|
||||||
|
|
||||||
// Call Engine to calculate next step
|
|
||||||
const result = this.workflowEngine.processAction(
|
|
||||||
currentRouting.sequence,
|
|
||||||
steps.length,
|
|
||||||
dto.action,
|
dto.action,
|
||||||
dto.returnToSequence
|
user.user_id,
|
||||||
|
dto.comment ?? dto.comments,
|
||||||
|
{ roles, approveCode: approveCodeStr },
|
||||||
|
undefined,
|
||||||
|
user.publicId
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
// Sync RFA Revision status + approve code ตาม state ใหม่
|
||||||
await queryRunner.connect();
|
await this.syncRevisionStatus(
|
||||||
await queryRunner.startTransaction();
|
currentRfaRev,
|
||||||
|
result.nextState,
|
||||||
|
dto.action === WorkflowAction.REJECT ? undefined : approveCodeStr,
|
||||||
|
result.isCompleted
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
return { message: 'Action processed', result };
|
||||||
// Update current routing
|
}
|
||||||
currentRouting.status =
|
|
||||||
dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
|
|
||||||
currentRouting.processedByUserId = user.user_id;
|
|
||||||
currentRouting.processedAt = new Date();
|
|
||||||
currentRouting.comments = dto.comments;
|
|
||||||
await queryRunner.manager.save(currentRouting);
|
|
||||||
|
|
||||||
// Create next routing if available
|
/**
|
||||||
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
|
* Helper: Map Workflow State → RFA Status Code (+ Approve Code เมื่อถึง Terminal) แล้วบันทึก
|
||||||
const nextStep = steps.find(
|
* เลิก hardcode magic string — ใช้ STATE_TO_STATUS map + payload override
|
||||||
(s) => s.sequence === result.nextStepSequence
|
*/
|
||||||
);
|
private async syncRevisionStatus(
|
||||||
if (nextStep) {
|
revision: RfaRevision,
|
||||||
const nextRouting = queryRunner.manager.create(
|
workflowState: string,
|
||||||
CorrespondenceRouting,
|
approveCodeStr?: string,
|
||||||
{
|
isTerminalApproved = false
|
||||||
correspondenceId: rfa.correspondence.id, // ✅ Use master correspondence id
|
): Promise<void> {
|
||||||
templateId: template.id,
|
const targetStatusCode = RfaService.STATE_TO_STATUS[workflowState] ?? 'DFT';
|
||||||
sequence: result.nextStepSequence,
|
const status = await this.rfaStatusRepo.findOne({
|
||||||
fromOrganizationId: user.primaryOrganizationId,
|
where: { statusCode: targetStatusCode },
|
||||||
toOrganizationId: nextStep.toOrganizationId,
|
});
|
||||||
stepPurpose: nextStep.stepPurpose,
|
if (status) {
|
||||||
status: 'SENT',
|
revision.rfaStatusCodeId = status.id;
|
||||||
dueDate: new Date(
|
}
|
||||||
Date.now() + (nextStep.expectedDays || 7) * 24 * 60 * 60 * 1000
|
|
||||||
),
|
if (isTerminalApproved) {
|
||||||
}
|
const codeToUse = approveCodeStr ?? RfaService.DEFAULT_APPROVED_CODE;
|
||||||
);
|
const approveCode = await this.rfaApproveRepo.findOne({
|
||||||
await queryRunner.manager.save(nextRouting);
|
where: { approveCode: codeToUse },
|
||||||
}
|
});
|
||||||
} else if (result.nextStepSequence === null) {
|
if (approveCode) {
|
||||||
// Workflow Ended (Completed or Rejected)
|
revision.rfaApproveCodeId = approveCode.id;
|
||||||
// Update RFA Status (Approved/Rejected Code)
|
revision.approvedDate = new Date();
|
||||||
if (dto.action !== WorkflowAction.REJECT) {
|
|
||||||
const approveCode = await this.rfaApproveRepo.findOne({
|
|
||||||
where: {
|
|
||||||
approveCode: dto.action === WorkflowAction.APPROVE ? '1A' : '4X',
|
|
||||||
},
|
|
||||||
}); // Logic Map Code อย่างง่าย
|
|
||||||
if (approveCode) {
|
|
||||||
currentRfaRev.rfaApproveCodeId = approveCode.id;
|
|
||||||
currentRfaRev.approvedDate = new Date();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const rejectCode = await this.rfaApproveRepo.findOne({
|
|
||||||
where: { approveCode: '4X' },
|
|
||||||
});
|
|
||||||
if (rejectCode) currentRfaRev.rfaApproveCodeId = rejectCode.id;
|
|
||||||
}
|
|
||||||
await queryRunner.manager.save(currentRfaRev);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await this.rfaRevisionRepo.save(revision);
|
||||||
return { message: 'Action processed', result };
|
this.logger.log(
|
||||||
} catch (err) {
|
`Synced RFA Revision ${revision.id}: state=${workflowState} → status=${targetStatusCode}`
|
||||||
await queryRunner.rollbackTransaction();
|
);
|
||||||
throw err;
|
}
|
||||||
} finally {
|
|
||||||
await queryRunner.release();
|
/**
|
||||||
|
* ADR-008: แจ้งเตือน Document Controller ของ org ผู้รับ (TO) แบบ async
|
||||||
|
* เรียกหลัง transaction สำเร็จเท่านั้น (ห้ามค้างใน request transaction)
|
||||||
|
*/
|
||||||
|
private async notifyRecipients(
|
||||||
|
correspondenceId: number,
|
||||||
|
correspondenceNumber: string,
|
||||||
|
subject?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const recipients = await this.dataSource.manager.find(
|
||||||
|
CorrespondenceRecipient,
|
||||||
|
{ where: { correspondenceId, recipientType: 'TO' } }
|
||||||
|
);
|
||||||
|
for (const r of recipients) {
|
||||||
|
const targetUserId = await this.userService.findDocControlIdByOrg(
|
||||||
|
r.recipientOrganizationId
|
||||||
|
);
|
||||||
|
if (targetUserId) {
|
||||||
|
await this.notificationService.send({
|
||||||
|
userId: targetUserId,
|
||||||
|
title: `RFA Submitted: ${subject ?? correspondenceNumber}`,
|
||||||
|
message: `RFA ${correspondenceNumber} submitted for approval.`,
|
||||||
|
type: 'SYSTEM',
|
||||||
|
entityType: 'rfa',
|
||||||
|
entityId: correspondenceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
// File: backend/tests/integration/modules/ai/ai-policy.service.integration.spec.ts
|
|
||||||
// Change Log:
|
|
||||||
// - 2026-06-13: T034 — Integration test สำหรับ apply flow (sandbox draft → validate → production + cache DEL)
|
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
||||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
|
||||||
import { AiPolicyService } from '../../../../src/modules/ai/services/ai-policy.service';
|
|
||||||
import { AiExecutionProfile } from '../../../../src/modules/ai/entities/ai-execution-profile.entity';
|
|
||||||
import { AiSandboxProfile } from '../../../../src/modules/ai/entities/ai-sandbox-profile.entity';
|
|
||||||
|
|
||||||
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration test สำหรับ Apply Profile Flow (T034 — ADR-036)
|
|
||||||
*
|
|
||||||
* ครอบคลุม cross-service interactions:
|
|
||||||
* 1. Full apply flow: sandbox draft → validation → copy to production → Redis cache DEL
|
|
||||||
* 2. Idempotency logic: duplicate key ใน Redis ต้องไม่ apply ซ้ำ
|
|
||||||
* 3. Parameter range validation propagation
|
|
||||||
* 4. Cache miss → DB fallback → cache set → subsequent cache hit
|
|
||||||
*/
|
|
||||||
describe('AiPolicyService — Apply Flow Integration (T034)', () => {
|
|
||||||
let service: AiPolicyService;
|
|
||||||
|
|
||||||
const productionRow = {
|
|
||||||
profileName: 'standard',
|
|
||||||
canonicalModel: 'np-dms-ai' as const,
|
|
||||||
isActive: true,
|
|
||||||
temperature: 0.4,
|
|
||||||
topP: 0.85,
|
|
||||||
maxTokens: 3000,
|
|
||||||
numCtx: 6000,
|
|
||||||
repeatPenalty: 1.2,
|
|
||||||
keepAliveSeconds: 300,
|
|
||||||
updatedBy: undefined as number | undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sandboxDraft = {
|
|
||||||
profileName: 'standard',
|
|
||||||
canonicalModel: 'np-dms-ai' as const,
|
|
||||||
temperature: 0.65,
|
|
||||||
topP: 0.9,
|
|
||||||
maxTokens: 4096,
|
|
||||||
numCtx: 8192,
|
|
||||||
repeatPenalty: 1.15,
|
|
||||||
keepAliveSeconds: 600,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mockProfileRepo: {
|
|
||||||
findOne: jest.Mock;
|
|
||||||
create: jest.Mock;
|
|
||||||
save: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mockSandboxProfileRepo: {
|
|
||||||
findOne: jest.Mock;
|
|
||||||
create: jest.Mock;
|
|
||||||
save: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mockRedis: {
|
|
||||||
get: jest.Mock;
|
|
||||||
set: jest.Mock;
|
|
||||||
del: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const savedProductionRow = { ...productionRow };
|
|
||||||
mockProfileRepo = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ ...savedProductionRow }),
|
|
||||||
create: jest.fn((input: unknown) => ({ ...(input as object) })),
|
|
||||||
save: jest.fn((input: unknown) => {
|
|
||||||
Object.assign(savedProductionRow, input as object);
|
|
||||||
return Promise.resolve({ ...savedProductionRow });
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
mockSandboxProfileRepo = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ ...sandboxDraft }),
|
|
||||||
create: jest.fn((input: unknown) => ({ ...(input as object) })),
|
|
||||||
save: jest.fn((input: unknown) =>
|
|
||||||
Promise.resolve({ ...(input as object) })
|
|
||||||
),
|
|
||||||
};
|
|
||||||
mockRedis = {
|
|
||||||
get: jest.fn().mockResolvedValue(null),
|
|
||||||
set: jest.fn().mockResolvedValue('OK'),
|
|
||||||
del: jest.fn().mockResolvedValue(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
AiPolicyService,
|
|
||||||
{
|
|
||||||
provide: getRepositoryToken(AiExecutionProfile),
|
|
||||||
useValue: mockProfileRepo,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: getRepositoryToken(AiSandboxProfile),
|
|
||||||
useValue: mockSandboxProfileRepo,
|
|
||||||
},
|
|
||||||
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
service = module.get<AiPolicyService>(AiPolicyService);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Full apply flow: draft → validate → production → cache DEL', () => {
|
|
||||||
it('ควรคัดลอกค่าจาก sandbox draft ไปยัง production row และลบ Redis cache ทั้งสองคีย์', async () => {
|
|
||||||
const result = await service.applyProfile('standard', 42);
|
|
||||||
|
|
||||||
expect(mockSandboxProfileRepo.findOne).toHaveBeenCalledWith({
|
|
||||||
where: { profileName: 'standard' },
|
|
||||||
});
|
|
||||||
expect(mockProfileRepo.findOne).toHaveBeenCalledWith({
|
|
||||||
where: { profileName: 'standard' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockProfileRepo.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
temperature: 0.65,
|
|
||||||
topP: 0.9,
|
|
||||||
maxTokens: 4096,
|
|
||||||
numCtx: 8192,
|
|
||||||
repeatPenalty: 1.15,
|
|
||||||
keepAliveSeconds: 600,
|
|
||||||
updatedBy: 42,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
|
||||||
'ai_execution_profiles:standard'
|
|
||||||
);
|
|
||||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
|
||||||
'ai_execution_profiles:model:np-dms-ai'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.temperature).toBe(0.65);
|
|
||||||
expect(result.topP).toBe(0.9);
|
|
||||||
expect(result.keepAliveSeconds).toBe(600);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ควรสร้าง production row ใหม่หากยังไม่มีอยู่ใน DB', async () => {
|
|
||||||
mockProfileRepo.findOne.mockResolvedValue(null);
|
|
||||||
mockProfileRepo.create.mockImplementation((input: unknown) => ({
|
|
||||||
...(input as object),
|
|
||||||
}));
|
|
||||||
mockProfileRepo.save.mockImplementation((input: unknown) =>
|
|
||||||
Promise.resolve({ ...(input as object) })
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.applyProfile('standard', 1);
|
|
||||||
|
|
||||||
expect(mockProfileRepo.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ profileName: 'standard', isActive: true })
|
|
||||||
);
|
|
||||||
expect(result.temperature).toBe(sandboxDraft.temperature);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ควรยังคง apply ได้แม้ Redis DEL ล้มเหลว (cache failure tolerant)', async () => {
|
|
||||||
mockRedis.del.mockRejectedValue(new Error('Redis connection lost'));
|
|
||||||
|
|
||||||
const result = await service.applyProfile('standard', 7);
|
|
||||||
|
|
||||||
expect(result.temperature).toBe(sandboxDraft.temperature);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotFoundException เมื่อไม่มี sandbox draft', () => {
|
|
||||||
it('ควรโยน NotFoundException เมื่อ sandbox draft ไม่มีอยู่ใน DB', async () => {
|
|
||||||
mockSandboxProfileRepo.findOne.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.applyProfile('standard')).rejects.toThrow(
|
|
||||||
NotFoundException
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Parameter range validation propagation', () => {
|
|
||||||
const makeInvalidDraft = (
|
|
||||||
overrides: Partial<typeof sandboxDraft>
|
|
||||||
): unknown => ({
|
|
||||||
...sandboxDraft,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['temperature เกิน 1', { temperature: 1.01 }],
|
|
||||||
['temperature ต่ำกว่า 0', { temperature: -0.01 }],
|
|
||||||
['topP เกิน 1', { topP: 1.1 }],
|
|
||||||
['topP ต่ำกว่า 0', { topP: -0.1 }],
|
|
||||||
['repeatPenalty ต่ำกว่า 1', { repeatPenalty: 0.99 }],
|
|
||||||
['repeatPenalty เกิน 2', { repeatPenalty: 2.01 }],
|
|
||||||
['keepAliveSeconds ติดลบ', { keepAliveSeconds: -1 }],
|
|
||||||
])('ควรโยน BadRequestException เมื่อ %s', async (_label, invalidValue) => {
|
|
||||||
mockSandboxProfileRepo.findOne.mockResolvedValue(
|
|
||||||
makeInvalidDraft(invalidValue)
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.applyProfile('standard')).rejects.toThrow(
|
|
||||||
BadRequestException
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Cache lifecycle หลัง apply', () => {
|
|
||||||
it('ควรให้ cache miss หลัง apply เพื่อบังคับ fresh read จาก DB รอบถัดไป', async () => {
|
|
||||||
await service.applyProfile('standard', 1);
|
|
||||||
|
|
||||||
expect(mockRedis.del).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockProfileRepo.findOne.mockResolvedValue({
|
|
||||||
profileName: 'standard',
|
|
||||||
canonicalModel: 'np-dms-ai',
|
|
||||||
isActive: true,
|
|
||||||
temperature: 0.65,
|
|
||||||
topP: 0.9,
|
|
||||||
maxTokens: 4096,
|
|
||||||
numCtx: 8192,
|
|
||||||
repeatPenalty: 1.15,
|
|
||||||
keepAliveSeconds: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
const freshParams = await service.getProfileParameters('standard');
|
|
||||||
expect(freshParams.temperature).toBe(0.65);
|
|
||||||
expect(mockProfileRepo.findOne).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ควรเขียน cache ใหม่หลัง getProfileParameters อ่านจาก DB', async () => {
|
|
||||||
await service.applyProfile('standard', 1);
|
|
||||||
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockProfileRepo.findOne.mockResolvedValue({
|
|
||||||
profileName: 'standard',
|
|
||||||
canonicalModel: 'np-dms-ai',
|
|
||||||
isActive: true,
|
|
||||||
temperature: 0.65,
|
|
||||||
topP: 0.9,
|
|
||||||
maxTokens: 4096,
|
|
||||||
numCtx: 8192,
|
|
||||||
repeatPenalty: 1.15,
|
|
||||||
keepAliveSeconds: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.getProfileParameters('standard');
|
|
||||||
|
|
||||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
|
||||||
'ai_execution_profiles:standard',
|
|
||||||
expect.stringContaining('"temperature":0.65'),
|
|
||||||
'EX',
|
|
||||||
60
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Dual-model: apply ของ OCR profile', () => {
|
|
||||||
it('ควรลบ model cache key ของ np-dms-ocr เมื่อ apply ocr-extract profile', async () => {
|
|
||||||
const ocrDraft = {
|
|
||||||
profileName: 'ocr-extract',
|
|
||||||
canonicalModel: 'np-dms-ocr' as const,
|
|
||||||
temperature: 0.12,
|
|
||||||
topP: 0.18,
|
|
||||||
maxTokens: null,
|
|
||||||
numCtx: null,
|
|
||||||
repeatPenalty: 1.05,
|
|
||||||
keepAliveSeconds: 0,
|
|
||||||
};
|
|
||||||
mockSandboxProfileRepo.findOne.mockResolvedValue(ocrDraft);
|
|
||||||
mockProfileRepo.findOne.mockResolvedValue({
|
|
||||||
profileName: 'ocr-extract',
|
|
||||||
canonicalModel: 'np-dms-ocr',
|
|
||||||
isActive: true,
|
|
||||||
...ocrDraft,
|
|
||||||
});
|
|
||||||
mockProfileRepo.save.mockImplementation((input: unknown) =>
|
|
||||||
Promise.resolve({ ...(input as object) })
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.applyProfile('ocr-extract', 5);
|
|
||||||
|
|
||||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
|
||||||
'ai_execution_profiles:ocr-extract'
|
|
||||||
);
|
|
||||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
|
||||||
'ai_execution_profiles:model:np-dms-ocr'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# ยกเว้นไฟล์ทดสอบและ specs
|
|
||||||
*.spec.ts
|
|
||||||
*.test.ts
|
|
||||||
*.spec.tsx
|
|
||||||
*.test.tsx
|
|
||||||
__tests__/
|
|
||||||
tests/
|
|
||||||
test/
|
|
||||||
|
|
||||||
# ยกเว้น Next.js แคชและไฟล์บิลด์
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
build/
|
|
||||||
coverage/
|
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
eslint-frontend.json
|
|
||||||
npm-audit-frontend.json
|
|
||||||
|
|
||||||
# ยกเว้นโฟลเดอร์มีเดียและโมดูล
|
|
||||||
public/
|
|
||||||
node_modules/
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"lastAnalyzedAt": "2026-06-13T13:24:07.512Z",
|
|
||||||
"gitCommitHash": "190b9a3af5f505e9ec59ba8d447c4720b2cb7dae",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"analyzedFiles": 373
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// File: frontend/app/(admin)/admin/ai/prompt-management/page.tsx
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created unified prompt management page (conforming to tasks T019, T029, T038)
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminAiService } from '@/lib/services/admin-ai.service';
|
||||||
|
import { PromptType, PromptVersion, ContextConfig } from '@/lib/types/ai-prompts';
|
||||||
|
import PromptTypeDropdown from '@/components/admin/ai/PromptTypeDropdown';
|
||||||
|
import VersionHistory from '@/components/admin/ai/VersionHistory';
|
||||||
|
import PromptEditor from '@/components/admin/ai/PromptEditor';
|
||||||
|
import ContextConfigEditor from '@/components/admin/ai/ContextConfigEditor';
|
||||||
|
import SandboxTabs from '@/components/admin/ai/SandboxTabs';
|
||||||
|
import RuntimeParametersPanel from '@/components/admin/ai/RuntimeParametersPanel';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Brain, Sliders, Play, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function UnifiedPromptManagementPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedType, setSelectedType] = useState<PromptType>('ocr_extraction');
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState<PromptVersion | null>(null);
|
||||||
|
|
||||||
|
// ดึงข้อมูลประวัติเวอร์ชันทั้งหมดของ prompt_type ที่เลือก
|
||||||
|
const { data: versions = [], isLoading } = useQuery<PromptVersion[]>({
|
||||||
|
queryKey: ['admin-ai-prompts', selectedType],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await adminAiService.listPrompts(selectedType);
|
||||||
|
return res || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// อัปเดต selectedVersion เมื่อเปลี่ยนประเภทหรือข้อมูลรีเฟรช
|
||||||
|
useEffect(() => {
|
||||||
|
if (versions.length > 0) {
|
||||||
|
const active = versions.find((v) => v.isActive) || versions[0];
|
||||||
|
setSelectedVersion(active);
|
||||||
|
} else {
|
||||||
|
setSelectedVersion(null);
|
||||||
|
}
|
||||||
|
}, [versions, selectedType]);
|
||||||
|
|
||||||
|
// สร้างเวอร์ชันใหม่
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (payload: { template: string; manualNote: string }) => {
|
||||||
|
return await adminAiService.createPrompt(selectedType, {
|
||||||
|
template: payload.template,
|
||||||
|
manualNote: payload.manualNote,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('สร้าง Prompt Version ใหม่สำเร็จ');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ai-prompts', selectedType] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('ไม่สามารถสร้าง Prompt Version ใหม่ได้');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// เปิดใช้งานเวอร์ชัน
|
||||||
|
const activateMutation = useMutation({
|
||||||
|
mutationFn: async (versionNumber: number) => {
|
||||||
|
return await adminAiService.activatePrompt(selectedType, versionNumber);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('เปิดใช้งาน Prompt Version สำเร็จ');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ai-prompts', selectedType] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('ไม่สามารถเปิดใช้งาน Prompt Version ได้');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ลบเวอร์ชัน
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (versionNumber: number) => {
|
||||||
|
return await adminAiService.deletePrompt(selectedType, versionNumber);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('ลบ Prompt Version สำเร็จ');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ai-prompts', selectedType] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const errorMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(errorMsg || 'ไม่สามารถลบ Prompt Version ได้');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// อัปเดตบริบทข้อมูล (Context Config)
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
mutationFn: async (payload: { versionNumber: number; config: ContextConfig }) => {
|
||||||
|
return await adminAiService.updateContextConfig(
|
||||||
|
selectedType,
|
||||||
|
payload.versionNumber,
|
||||||
|
payload.config
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('อัปเดตการตั้งค่าบริบทสำเร็จ');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ai-prompts', selectedType] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const errorMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(errorMsg || 'ไม่สามารถอัปเดตบริบทได้');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-border/10 pb-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
|
||||||
|
<Brain className="h-6 w-6 text-primary" />
|
||||||
|
ระบบจัดการ Prompt และบริบท (Prompt & Context Manager)
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
จัดการเทมเพลตพรอมต์และตัวกรองข้อมูล Master Data เพื่อส่งให้ระบบ AI ประมวลผลอย่างแม่นยำ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-[320px] bg-background/40 p-2.5 rounded-lg border border-border/50">
|
||||||
|
<PromptTypeDropdown value={selectedType} onChange={setSelectedType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
|
||||||
|
{/* Sidebar: รายการประวัติเวอร์ชัน */}
|
||||||
|
<div className="xl:col-span-4 space-y-4">
|
||||||
|
<VersionHistory
|
||||||
|
versions={versions}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onLoadTemplate={setSelectedVersion}
|
||||||
|
onActivateVersion={(v) => activateMutation.mutate(v)}
|
||||||
|
onDeleteVersion={(v) => deleteMutation.mutate(v)}
|
||||||
|
isActivating={activateMutation.isPending}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Panel: แผงแก้ไขและทดสอบ Sandbox */}
|
||||||
|
<div className="xl:col-span-8">
|
||||||
|
<Tabs defaultValue="editor" className="w-full space-y-4">
|
||||||
|
<TabsList className="bg-background/40 border border-border/50 p-1">
|
||||||
|
<TabsTrigger value="editor" className="text-xs font-semibold flex items-center gap-1.5">
|
||||||
|
<Settings className="h-3.5 w-3.5 text-primary" />
|
||||||
|
ตัวแก้ไขและบริบท (Editor & Context)
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sandbox" className="text-xs font-semibold flex items-center gap-1.5">
|
||||||
|
<Play className="h-3.5 w-3.5 text-primary" />
|
||||||
|
บอร์ดทดลอง (3-Step Sandbox)
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="parameters" className="text-xs font-semibold flex items-center gap-1.5">
|
||||||
|
<Sliders className="h-3.5 w-3.5 text-primary" />
|
||||||
|
พารามิเตอร์รันไทม์ (Runtime Params)
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="editor" className="space-y-4 mt-0 focus-visible:outline-none">
|
||||||
|
<PromptEditor
|
||||||
|
promptType={selectedType}
|
||||||
|
initialTemplate={selectedVersion?.template || ''}
|
||||||
|
onSave={async (tmpl, note) => {
|
||||||
|
await createMutation.mutateAsync({ template: tmpl, manualNote: note });
|
||||||
|
}}
|
||||||
|
isSaving={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
{selectedVersion && (
|
||||||
|
<ContextConfigEditor
|
||||||
|
initialConfig={selectedVersion.contextConfig}
|
||||||
|
onSave={async (config) => {
|
||||||
|
await updateConfigMutation.mutateAsync({
|
||||||
|
versionNumber: selectedVersion.versionNumber,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isSaving={updateConfigMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sandbox" className="mt-0 focus-visible:outline-none">
|
||||||
|
<SandboxTabs
|
||||||
|
promptType={selectedType}
|
||||||
|
selectedVersionNumber={selectedVersion?.versionNumber}
|
||||||
|
onActivateVersion={(v) => activateMutation.mutate(v)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="parameters" className="mt-0 focus-visible:outline-none">
|
||||||
|
<RuntimeParametersPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// File: frontend/components/admin/ai/ContextConfigEditor.tsx
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created ContextConfigEditor component with project/contract loaders and selectors (conforming to task T028)
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { CheckCircle2, Settings } from 'lucide-react';
|
||||||
|
import { ContextConfig } from '@/lib/types/ai-prompts';
|
||||||
|
import { projectService } from '@/lib/services/project.service';
|
||||||
|
import { contractService } from '@/lib/services/contract.service';
|
||||||
|
|
||||||
|
interface ContextConfigEditorProps {
|
||||||
|
initialConfig: ContextConfig | null;
|
||||||
|
onSave: (config: ContextConfig) => Promise<void>;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectOption {
|
||||||
|
publicId: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContractOption {
|
||||||
|
publicId: string;
|
||||||
|
contractName: string;
|
||||||
|
project?: {
|
||||||
|
publicId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* คอมโพเนนต์ฟอร์มสำหรับแก้ไขบริบทข้อมูล (Context Configuration)
|
||||||
|
* จัดการตัวเลือกการกรองข้อมูลรายโครงการ (Project Filter) และรายสัญญา (Contract Filter) รวมทั้งภาษาและจำนวนประวัติการดึงข้อมูล
|
||||||
|
*/
|
||||||
|
export default function ContextConfigEditor({
|
||||||
|
initialConfig,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: ContextConfigEditorProps) {
|
||||||
|
const [projects, setProjects] = useState<ProjectOption[]>([]);
|
||||||
|
const [contracts, setContracts] = useState<ContractOption[]>([]);
|
||||||
|
const [filteredContracts, setFilteredContracts] = useState<ContractOption[]>([]);
|
||||||
|
|
||||||
|
// State ฟอร์ม
|
||||||
|
const [projectId, setProjectId] = useState<string>('all');
|
||||||
|
const [contractId, setContractId] = useState<string>('all');
|
||||||
|
const [pageSize, setPageSize] = useState<number>(3);
|
||||||
|
const [language, setLanguage] = useState<string>('th');
|
||||||
|
const [outputLanguage, setOutputLanguage] = useState<string>('th');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const projList = await projectService.getAll();
|
||||||
|
setProjects(
|
||||||
|
Array.isArray(projList)
|
||||||
|
? (projList as unknown as Record<string, unknown>[]).map((p) => ({
|
||||||
|
publicId: String(p.publicId || ''),
|
||||||
|
projectName: String(p.projectName || ''),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const contrList = await contractService.getAll();
|
||||||
|
setContracts(
|
||||||
|
Array.isArray(contrList)
|
||||||
|
? (contrList as unknown as Record<string, unknown>[]).map((c) => ({
|
||||||
|
publicId: String(c.publicId || ''),
|
||||||
|
contractName: String(c.contractName || ''),
|
||||||
|
project: c.project
|
||||||
|
? {
|
||||||
|
publicId: String((c.project as unknown as Record<string, unknown>).publicId || ''),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
} catch (_err) {
|
||||||
|
// error handling silently per rules (use NestJS Logger on backend, avoid console.log on frontend)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// พรีโหลดค่าตั้งต้น
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialConfig) {
|
||||||
|
setProjectId(initialConfig.filter?.projectId || 'all');
|
||||||
|
setContractId(initialConfig.filter?.contractId || 'all');
|
||||||
|
setPageSize(initialConfig.pageSize || 3);
|
||||||
|
setLanguage(initialConfig.language || 'th');
|
||||||
|
setOutputLanguage(initialConfig.outputLanguage || 'th');
|
||||||
|
} else {
|
||||||
|
setProjectId('all');
|
||||||
|
setContractId('all');
|
||||||
|
setPageSize(3);
|
||||||
|
setLanguage('th');
|
||||||
|
setOutputLanguage('th');
|
||||||
|
}
|
||||||
|
}, [initialConfig]);
|
||||||
|
|
||||||
|
// กรองรายการสัญญาตามโครงการที่เลือก
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId && projectId !== 'all') {
|
||||||
|
const filtered = contracts.filter((c) => c.project?.publicId === projectId);
|
||||||
|
setFilteredContracts(filtered);
|
||||||
|
// รีเซ็ตสัญญาถ้าไม่ได้ผูกกับโครงการที่เลือก
|
||||||
|
const isStillValid = filtered.some((c) => c.publicId === contractId);
|
||||||
|
if (!isStillValid && contractId !== 'all') {
|
||||||
|
setContractId('all');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFilteredContracts(contracts);
|
||||||
|
}
|
||||||
|
}, [projectId, contracts, contractId]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const config: ContextConfig = {
|
||||||
|
filter: {
|
||||||
|
projectId: projectId === 'all' ? null : projectId,
|
||||||
|
contractId: contractId === 'all' ? null : contractId,
|
||||||
|
},
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
language,
|
||||||
|
outputLanguage,
|
||||||
|
};
|
||||||
|
onSave(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3 border-b border-border/10">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
|
||||||
|
<Settings className="h-4 w-4 text-primary" />
|
||||||
|
การตั้งค่าบริบทข้อมูล (Context Configuration)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 space-y-4">
|
||||||
|
{/* เลือกล็อคโครงการ */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
ตัวกรองโครงการ (Project Filter)
|
||||||
|
</label>
|
||||||
|
<Select value={projectId} onValueChange={setProjectId}>
|
||||||
|
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm">
|
||||||
|
<SelectValue placeholder="เลือกโครงการ..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">ทั้งหมด (ไม่กรอง / Global)</SelectItem>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<SelectItem key={p.publicId} value={p.publicId}>
|
||||||
|
{p.projectName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* เลือกล็อคสัญญา */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
ตัวกรองสัญญา (Contract Filter)
|
||||||
|
</label>
|
||||||
|
<Select value={contractId} onValueChange={setContractId}>
|
||||||
|
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm">
|
||||||
|
<SelectValue placeholder="เลือกสัญญา..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">ทั้งหมด (ไม่กรอง / Global)</SelectItem>
|
||||||
|
{filteredContracts.map((c) => (
|
||||||
|
<SelectItem key={c.publicId} value={c.publicId}>
|
||||||
|
{c.contractName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ปริมาณเอกสารอ้างอิงและภาษา */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
ประวัติอ้างอิง (Page Size)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => setPageSize(Math.max(1, Number(e.target.value)))}
|
||||||
|
className="bg-background/50 border-border/50 text-sm focus-visible:ring-primary/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
ภาษาต้นทาง (Language)
|
||||||
|
</label>
|
||||||
|
<Select value={language} onValueChange={setLanguage}>
|
||||||
|
<SelectTrigger className="bg-background/50 border-border/50 backdrop-blur-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="th">ไทย (TH)</SelectItem>
|
||||||
|
<SelectItem value="en">English (EN)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
ภาษาปลายทาง (Output)
|
||||||
|
</label>
|
||||||
|
<Select value={outputLanguage} onValueChange={setOutputLanguage}>
|
||||||
|
<SelectTrigger className="bg-background/50 border-border/50 backdrop-blur-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="th">ไทย (TH)</SelectItem>
|
||||||
|
<SelectItem value="en">English (EN)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex items-center justify-between border-t border-border/10 pt-4 bg-muted/5 rounded-b-xl">
|
||||||
|
<span className="text-[11px] text-muted-foreground italic flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
การตั้งค่านี้จะผูกกับเวอร์ชันของพรอมต์โดยตรง
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
size="sm"
|
||||||
|
className="bg-primary hover:bg-primary/95 font-semibold text-xs"
|
||||||
|
>
|
||||||
|
{isSaving ? 'กำลังบันทึก...' : 'บันทึกบริบท (Save Config)'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// File: frontend/components/admin/ai/PromptEditor.tsx
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created PromptEditor component with live placeholder validation and save actions (conforming to task T018)
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { AlertCircle, CheckCircle, Save, HelpCircle } from 'lucide-react';
|
||||||
|
import { PromptType } from '@/lib/types/ai-prompts';
|
||||||
|
import { PLACEHOLDER_REQUIREMENTS } from '@/contracts/frontend-types';
|
||||||
|
|
||||||
|
interface PromptEditorProps {
|
||||||
|
promptType: PromptType;
|
||||||
|
initialTemplate: string;
|
||||||
|
onSave: (template: string, manualNote: string) => Promise<void>;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* คอมโพเนนต์เครื่องมือแก้ไขเทมเพลตพรอมต์ (Prompt Editor)
|
||||||
|
* มีระบบตรวจเช็คตัวแปร/เพลสโฮลเดอร์ (Placeholder Validation) ในตัวแบบเรียลไทม์
|
||||||
|
*/
|
||||||
|
export default function PromptEditor({
|
||||||
|
promptType,
|
||||||
|
initialTemplate,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: PromptEditorProps) {
|
||||||
|
const [template, setTemplate] = useState(initialTemplate);
|
||||||
|
const [manualNote, setManualNote] = useState('');
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTemplate(initialTemplate);
|
||||||
|
setManualNote('');
|
||||||
|
}, [initialTemplate, promptType]);
|
||||||
|
|
||||||
|
// ตรวจสอบตัวแปรที่ต้องมีในพรอมต์เทมเพลต (Real-time Validation)
|
||||||
|
useEffect(() => {
|
||||||
|
const requirements = PLACEHOLDER_REQUIREMENTS[promptType] || [];
|
||||||
|
const missing = requirements.filter((req) => !template.includes(req));
|
||||||
|
setValidationErrors(missing);
|
||||||
|
}, [template, promptType]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (validationErrors.length > 0) return;
|
||||||
|
onSave(template, manualNote);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFriendlyTypeName = (type: PromptType) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'ocr_extraction':
|
||||||
|
return 'สกัดข้อความ OCR (OCR Extraction)';
|
||||||
|
case 'rag_query_prompt':
|
||||||
|
return 'ค้นหาข้อมูล RAG (RAG Query)';
|
||||||
|
case 'rag_prep_prompt':
|
||||||
|
return 'เตรียมข้อมูล RAG (RAG Prep)';
|
||||||
|
case 'classification_prompt':
|
||||||
|
return 'จำแนกประเภทเอกสาร (Classification)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3 border-b border-border/10">
|
||||||
|
<CardTitle className="text-sm font-semibold tracking-wide text-foreground">
|
||||||
|
แก้ไขพรอมต์เทมเพลต ({getFriendlyTypeName(promptType)})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium text-muted-foreground flex items-center gap-1">
|
||||||
|
โครงสร้างเทมเพลต (Template Body)
|
||||||
|
<span title="ใส่ตัวแปรให้ตรงตามความต้องการของพรอมต์แต่ละประเภท">
|
||||||
|
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={template.length > 4000 ? 'text-destructive font-semibold' : 'text-muted-foreground'}>
|
||||||
|
{template.length} / 4000 อักขระ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={template}
|
||||||
|
onChange={(e) => setTemplate(e.target.value)}
|
||||||
|
placeholder="เขียนพรอมต์ของคุณที่นี่..."
|
||||||
|
className="font-mono text-sm min-h-[250px] bg-background/50 border-border/50 focus-visible:ring-primary/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ระบบตรวจสอบตัวแปร (Placeholder Checklist) */}
|
||||||
|
<div className="rounded-lg border border-border/30 bg-muted/20 p-3.5 space-y-2.5">
|
||||||
|
<h4 className="text-xs font-semibold text-foreground">การตรวจสอบความถูกต้อง (Placeholder Verification)</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(PLACEHOLDER_REQUIREMENTS[promptType] || []).map((req) => {
|
||||||
|
const hasReq = template.includes(req);
|
||||||
|
return (
|
||||||
|
<div key={req} className="flex items-center gap-2 text-xs">
|
||||||
|
{hasReq ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive shrink-0 animate-bounce" />
|
||||||
|
)}
|
||||||
|
<span className={hasReq ? 'text-muted-foreground line-through opacity-70' : 'text-foreground font-medium'}>
|
||||||
|
ต้องมีตัวแปร <code className="bg-muted px-1.5 py-0.5 rounded font-mono text-[11px] border border-border/30 text-primary">{req}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
บันทึกหมายเหตุรุ่น (Manual Version Note)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={manualNote}
|
||||||
|
onChange={(e) => setManualNote(e.target.value)}
|
||||||
|
placeholder="เช่น ปรับปรุงสัดส่วนความเที่ยงตรง, เพิ่มหมวดหมู่ย่อย"
|
||||||
|
className="bg-background/50 border-border/50 focus-visible:ring-primary/30 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex items-center justify-end border-t border-border/10 pt-4 bg-muted/5 rounded-b-xl">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || validationErrors.length > 0 || template.length > 4000 || template.trim() === ''}
|
||||||
|
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground font-medium shadow-sm transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSaving ? 'กำลังบันทึก...' : 'บันทึกเวอร์ชันใหม่ (Save New Version)'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// File: frontend/components/admin/ai/PromptTypeDropdown.tsx
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created PromptTypeDropdown component (conforming to task T016)
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { PromptType } from '@/lib/types/ai-prompts';
|
||||||
|
|
||||||
|
interface PromptTypeDropdownProps {
|
||||||
|
value: PromptType;
|
||||||
|
onChange: (value: PromptType) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* คอมโพเนนต์ Dropdown สำหรับเลือกประเภทของ AI Prompt
|
||||||
|
* รองรับ: OCR Extraction, RAG Query, RAG Prep, และ Document Classification
|
||||||
|
*/
|
||||||
|
export default function PromptTypeDropdown({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: PromptTypeDropdownProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 w-full">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
ประเภทของพรอมต์ (Prompt Type)
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(val as PromptType)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm">
|
||||||
|
<SelectValue placeholder="เลือกประเภทพรอมต์..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ocr_extraction">
|
||||||
|
สกัดข้อความ OCR (OCR Extraction)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="rag_query_prompt">
|
||||||
|
ค้นหาข้อมูล RAG (RAG Query)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="rag_prep_prompt">
|
||||||
|
เตรียมข้อมูล RAG (RAG Prep)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="classification_prompt">
|
||||||
|
จำแนกประเภทเอกสาร (Classification)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
// File: frontend/components/admin/ai/RuntimeParametersPanel.tsx
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created RuntimeParametersPanel component for managing sandbox parameters (conforming to task T048)
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { adminAiService, SandboxProfileParams } from '@/lib/services/admin-ai.service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Save, RefreshCw, CheckCircle, Sliders } from 'lucide-react';
|
||||||
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
|
||||||
|
interface RuntimeParametersPanelProps {
|
||||||
|
onProfileChange?: (params: SandboxProfileParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE_OPTIONS = [
|
||||||
|
{ value: 'standard', label: 'มาตรฐาน (Standard)' },
|
||||||
|
{ value: 'quality', label: 'คุณภาพสูง (Quality)' },
|
||||||
|
{ value: 'interactive', label: 'โต้ตอบเร็ว (Interactive)' },
|
||||||
|
{ value: 'deep-analysis', label: 'วิเคราะห์เชิงลึก (Deep Analysis)' },
|
||||||
|
{ value: 'ocr-extract', label: 'สกัด OCR (OCR Extract)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RuntimeParametersPanel({ onProfileChange }: RuntimeParametersPanelProps) {
|
||||||
|
const [selectedProfile, setSelectedProfile] = useState<string>('standard');
|
||||||
|
const [params, setParams] = useState<SandboxProfileParams | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
const [isResetting, setIsResetting] = useState<boolean>(false);
|
||||||
|
const [isApplying, setIsApplying] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const fetchProfileParams = useCallback(async (profileName: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await adminAiService.getSandboxProfile(profileName);
|
||||||
|
setParams(data);
|
||||||
|
if (onProfileChange) {
|
||||||
|
onProfileChange(data);
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
toast.error('ไม่สามารถดึงค่าพารามิเตอร์ Sandbox ได้');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [onProfileChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProfileParams(selectedProfile);
|
||||||
|
}, [selectedProfile, fetchProfileParams]);
|
||||||
|
|
||||||
|
const handleSliderChange = (field: keyof SandboxProfileParams, val: number) => {
|
||||||
|
if (!params) return;
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[field]: val,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof SandboxProfileParams, val: string) => {
|
||||||
|
if (!params) return;
|
||||||
|
const parsed = val === '' ? null : Number(val);
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[field]: parsed,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
if (!params) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const key = uuidv7();
|
||||||
|
const res = await adminAiService.saveSandboxProfile(selectedProfile, params, key);
|
||||||
|
setParams(res);
|
||||||
|
toast.success('บันทึกแบบร่าง Sandbox สำเร็จ');
|
||||||
|
if (onProfileChange) {
|
||||||
|
onProfileChange(res);
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
toast.error('ไม่สามารถบันทึกแบบร่างได้');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetDraft = async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
const res = await adminAiService.resetSandboxProfile(selectedProfile);
|
||||||
|
setParams(res);
|
||||||
|
toast.success('รีเซ็ตแบบร่างเป็นค่าเริ่มต้นแล้ว');
|
||||||
|
if (onProfileChange) {
|
||||||
|
onProfileChange(res);
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
toast.error('ไม่สามารถรีเซ็ตแบบร่างได้');
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyToProduction = async () => {
|
||||||
|
setIsApplying(true);
|
||||||
|
try {
|
||||||
|
const key = uuidv7();
|
||||||
|
await adminAiService.applyProfile(selectedProfile, key);
|
||||||
|
toast.success('ปรับใช้พารามิเตอร์จริงสำเร็จ');
|
||||||
|
} catch (_err) {
|
||||||
|
toast.error('ไม่สามารถปรับใช้พารามิเตอร์จริงได้');
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !params) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||||
|
กำลังโหลดพารามิเตอร์...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3 border-b border-border/10">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
|
||||||
|
<Sliders className="h-4 w-4 text-primary" />
|
||||||
|
จัดการพารามิเตอร์รันไทม์ (Runtime Parameters)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
ปรับเปลี่ยนพารามิเตอร์การทำงานของโมเดล AI ในระบบทดสอบ Sandbox
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-[200px]">
|
||||||
|
<Select value={selectedProfile} onValueChange={setSelectedProfile}>
|
||||||
|
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm h-8 text-xs">
|
||||||
|
<SelectValue placeholder="เลือกโปรไฟล์..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROFILE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-5 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
อุณหภูมิความสร้างสรรค์ (Temperature)
|
||||||
|
</Label>
|
||||||
|
<span className="font-mono text-xs font-bold text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||||
|
{params.temperature.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1.5"
|
||||||
|
step="0.05"
|
||||||
|
value={params.temperature}
|
||||||
|
onChange={(e) => handleSliderChange('temperature', Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||||
|
ค่ายิ่งสูงโมเดลยิ่งตอบอย่างอิสระและมีความคิดสร้างสรรค์ (Temperature สูงเหมาะกับการเขียน) ค่ายิ่งต่ำยิ่งมั่นใจในความถูกต้อง (Temperature ต่ำเหมาะกับการสกัดข้อความ)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
Top-P (Nucleus Sampling)
|
||||||
|
</Label>
|
||||||
|
<span className="font-mono text-xs font-bold text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||||
|
{params.topP.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.1"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={params.topP}
|
||||||
|
onChange={(e) => handleSliderChange('topP', Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||||
|
กำหนดขอบเขตของคำที่เป็นไปได้ในการเลือกคำถัดไป แนะนำให้ตั้งไว้ที่ 0.8 - 0.95
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
บทลงโทษคำซ้ำ (Repeat Penalty)
|
||||||
|
</Label>
|
||||||
|
<span className="font-mono text-xs font-bold text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||||
|
{params.repeatPenalty.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.5"
|
||||||
|
max="2"
|
||||||
|
step="0.05"
|
||||||
|
value={params.repeatPenalty}
|
||||||
|
onChange={(e) => handleSliderChange('repeatPenalty', Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||||
|
ลดโอกาสที่โมเดลจะสร้างคำที่เคยพูดไปแล้วซ้ำๆ ค่ายิ่งสูงยิ่งช่วยลดปัญหาคำซ้ำ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="maxTokens" className="text-xs font-semibold text-foreground">
|
||||||
|
Max Output Tokens
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="maxTokens"
|
||||||
|
type="number"
|
||||||
|
placeholder="เช่น 4096 (null = สูงสุด)"
|
||||||
|
value={params.maxTokens ?? ''}
|
||||||
|
onChange={(e) => handleInputChange('maxTokens', e.target.value)}
|
||||||
|
className="bg-background/50 border-border/50 h-8 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="numCtx" className="text-xs font-semibold text-foreground">
|
||||||
|
Context Window Size (Ctx Size)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="numCtx"
|
||||||
|
type="number"
|
||||||
|
placeholder="เช่น 8192 (null = สูงสุด)"
|
||||||
|
value={params.numCtx ?? ''}
|
||||||
|
onChange={(e) => handleInputChange('numCtx', e.target.value)}
|
||||||
|
className="bg-background/50 border-border/50 h-8 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="keepAliveSeconds" className="text-xs font-semibold text-foreground">
|
||||||
|
Keep-Alive (วินาที)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="keepAliveSeconds"
|
||||||
|
type="number"
|
||||||
|
placeholder="เช่น 600 (-1 = โหลดตลอดเวลา, 0 = ยกเลิกทันที)"
|
||||||
|
value={params.keepAliveSeconds}
|
||||||
|
onChange={(e) => handleInputChange('keepAliveSeconds', e.target.value)}
|
||||||
|
className="bg-background/50 border-border/50 h-8 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
ระยะเวลาที่โมเดลจะค้างอยู่ใน VRAM หลังจากสิ้นสุดการขอข้อมูลก่อนระบบจะเคลียร์ VRAM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-foreground">
|
||||||
|
โมเดลสำหรับโปรไฟล์ (Canonical Model)
|
||||||
|
</Label>
|
||||||
|
<div className="font-mono text-xs font-bold text-foreground bg-secondary/35 border border-border/50 p-2 rounded flex justify-between items-center select-none">
|
||||||
|
<span>{params.canonicalModel}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">ระบบเปลี่ยนให้อัตโนมัติ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2.5 pt-4 border-t border-border/10">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResetDraft}
|
||||||
|
disabled={isResetting || isSaving || isApplying}
|
||||||
|
className="h-8 text-xs border-border/50 bg-background/50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
รีเซ็ตแบบร่าง (Reset Draft)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={isSaving || isResetting || isApplying}
|
||||||
|
className="h-8 text-xs bg-secondary hover:bg-secondary/80 border border-border/30"
|
||||||
|
>
|
||||||
|
<Save className="mr-1.5 h-3.5 w-3.5 text-primary" />
|
||||||
|
บันทึกแบบร่าง (Save Draft)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApplyToProduction}
|
||||||
|
disabled={isApplying || isSaving || isResetting}
|
||||||
|
className="h-8 text-xs bg-primary hover:bg-primary/95 text-primary-foreground"
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
ปรับใช้จริง (Apply to Production)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
// File: frontend/components/admin/ai/SandboxTabs.tsx
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created SandboxTabs component with 3-step testing (OCR -> AI Extract -> RAG Prep) (conforming to task T037)
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { adminAiService } from '@/lib/services/admin-ai.service';
|
||||||
|
import { useProjects, useContracts } from '@/hooks/use-master-data';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Play,
|
||||||
|
FileText,
|
||||||
|
FileJson,
|
||||||
|
Database,
|
||||||
|
ArrowRight,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface SandboxTabsProps {
|
||||||
|
promptType: string;
|
||||||
|
selectedVersionNumber?: number;
|
||||||
|
onActivateVersion?: (versionNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectOption {
|
||||||
|
publicId: string;
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContractOption {
|
||||||
|
publicId: string;
|
||||||
|
contractCode: string;
|
||||||
|
contractName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SandboxJobResult {
|
||||||
|
ocrText?: string;
|
||||||
|
answer?: string;
|
||||||
|
status?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
ragChunks?: Array<{ text: string; summary: string }>;
|
||||||
|
ragVectors?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SandboxTabs({
|
||||||
|
promptType: _promptType,
|
||||||
|
selectedVersionNumber,
|
||||||
|
onActivateVersion,
|
||||||
|
}: SandboxTabsProps) {
|
||||||
|
// Master data state
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string>('');
|
||||||
|
const [selectedContract, setSelectedContract] = useState<string>('');
|
||||||
|
const { data: projectsData } = useProjects();
|
||||||
|
const projects = Array.isArray(projectsData) ? (projectsData as ProjectOption[]) : [];
|
||||||
|
const { data: contractsData } = useContracts(selectedProject);
|
||||||
|
const contracts = Array.isArray(contractsData) ? (contractsData as ContractOption[]) : [];
|
||||||
|
|
||||||
|
// Sandbox states
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [ocrEngine, setOcrEngine] = useState<string>('auto');
|
||||||
|
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||||
|
const [jobStatus, setJobStatus] = useState<'idle' | 'running' | 'completed' | 'failed'>('idle');
|
||||||
|
const [progress, setProgress] = useState<number>(0);
|
||||||
|
const [statusText, setStatusText] = useState<string>('');
|
||||||
|
|
||||||
|
// Results cache
|
||||||
|
const [requestPublicId, setRequestPublicId] = useState<string | null>(null);
|
||||||
|
const [ocrText, setOcrText] = useState<string>('');
|
||||||
|
const [extractedMetadata, setExtractedMetadata] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [ragChunks, setRagChunks] = useState<Array<{ text: string; summary: string }> | null>(null);
|
||||||
|
const [ragVectorsCount, setRagVectorsCount] = useState<number>(0);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setFile(e.target.files[0]);
|
||||||
|
setOcrText('');
|
||||||
|
setExtractedMetadata(null);
|
||||||
|
setRagChunks(null);
|
||||||
|
setRequestPublicId(null);
|
||||||
|
setCurrentStep(1);
|
||||||
|
setJobStatus('idle');
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollJobStatus = (id: string, step: number, onSuccess: (result: SandboxJobResult) => void) => {
|
||||||
|
let interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminAiService.getSandboxJobStatus(id);
|
||||||
|
if (res.status === 'completed') {
|
||||||
|
clearInterval(interval);
|
||||||
|
setJobStatus('completed');
|
||||||
|
setProgress(100);
|
||||||
|
onSuccess(res as SandboxJobResult);
|
||||||
|
} else if (res.status === 'failed') {
|
||||||
|
clearInterval(interval);
|
||||||
|
setJobStatus('failed');
|
||||||
|
setProgress(0);
|
||||||
|
toast.error(res.errorMessage || 'การประมวลผลล้มเหลว');
|
||||||
|
} else if (res.status === 'processing') {
|
||||||
|
setProgress(step === 1 ? 50 : 60);
|
||||||
|
setStatusText('กำลังประมวลผล...');
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setJobStatus('failed');
|
||||||
|
setProgress(0);
|
||||||
|
toast.error('ไม่สามารถดึงสถานะงานได้');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunOcr = async () => {
|
||||||
|
if (!file) {
|
||||||
|
toast.error('กรุณาเลือกไฟล์ PDF สำหรับทดสอบ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJobStatus('running');
|
||||||
|
setProgress(15);
|
||||||
|
setStatusText('กำลังอัปโหลดและส่งเอกสารเข้าคิว OCR...');
|
||||||
|
try {
|
||||||
|
const res = await adminAiService.submitSandboxOcr(file, ocrEngine);
|
||||||
|
setRequestPublicId(res.requestPublicId);
|
||||||
|
pollJobStatus(res.requestPublicId, 1, (result) => {
|
||||||
|
setOcrText(result.ocrText || '');
|
||||||
|
setCurrentStep(2);
|
||||||
|
toast.success('ทำ OCR สำเร็จแล้ว สามารถทำการสกัดข้อมูลต่อได้');
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
setJobStatus('failed');
|
||||||
|
toast.error('เกิดข้อผิดพลาดในการรัน OCR');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunExtract = async () => {
|
||||||
|
if (!requestPublicId) {
|
||||||
|
toast.error('กรุณาทำ OCR ก่อน');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedProject) {
|
||||||
|
toast.error('กรุณาเลือกโครงการสำหรับทดสอบ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJobStatus('running');
|
||||||
|
setProgress(20);
|
||||||
|
setStatusText('กำลังประมวลผลการสกัดข้อมูลเมตาดาต้า...');
|
||||||
|
try {
|
||||||
|
const res = await adminAiService.submitSandboxAiExtract(
|
||||||
|
requestPublicId,
|
||||||
|
selectedVersionNumber,
|
||||||
|
selectedProject,
|
||||||
|
selectedContract || undefined
|
||||||
|
);
|
||||||
|
pollJobStatus(res.requestPublicId, 2, (result) => {
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = result.answer ? JSON.parse(result.answer) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = { error: 'ผลลัพธ์ไม่ใช่ JSON ที่ถูกต้อง', raw: result.answer };
|
||||||
|
}
|
||||||
|
setExtractedMetadata(parsed);
|
||||||
|
setCurrentStep(3);
|
||||||
|
toast.success('สกัดข้อมูลเมตาดาต้าสำเร็จ สามารถทดสอบ RAG Prep ต่อได้');
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
setJobStatus('failed');
|
||||||
|
toast.error('เกิดข้อผิดพลาดในการสกัดข้อมูล');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunRagPrep = async () => {
|
||||||
|
if (!ocrText) {
|
||||||
|
toast.error('ไม่มีข้อความ OCR สำหรับทดสอบ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJobStatus('running');
|
||||||
|
setProgress(30);
|
||||||
|
setStatusText('กำลังประมวลผลการทำ Semantic Chunking และสร้างเวกเตอร์ RAG...');
|
||||||
|
try {
|
||||||
|
const res = await adminAiService.submitSandboxRagPrep(ocrText);
|
||||||
|
pollJobStatus(res.jobId, 3, (result) => {
|
||||||
|
setRagChunks(result.ragChunks || []);
|
||||||
|
setRagVectorsCount(result.ragVectors ? result.ragVectors.length : 0);
|
||||||
|
toast.success('วิเคราะห์การเตรียมข้อมูล RAG สำเร็จ');
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
setJobStatus('failed');
|
||||||
|
toast.error('เกิดข้อผิดพลาดในการทำ RAG Prep');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = () => {
|
||||||
|
if (selectedVersionNumber && onActivateVersion) {
|
||||||
|
onActivateVersion(selectedVersionNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3 border-b border-border/10">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
|
||||||
|
<Play className="h-4 w-4 text-primary" />
|
||||||
|
รันบอร์ดทดลองการทำงาน (3-Step Sandbox Testing)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
ทดสอบความถูกต้องของเวอร์ชันพรอมต์จำลองกระบวนการจริง (OCR → AI Extract → RAG Prep)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-5 space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 border-b border-border/10 pb-4">
|
||||||
|
<div className="flex-1 min-w-[200px] space-y-1">
|
||||||
|
<Label className="text-[11px] font-semibold text-muted-foreground">โครงการสำหรับสกัดบริบท</Label>
|
||||||
|
<Select value={selectedProject} onValueChange={setSelectedProject}>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-background/50 border-border/50">
|
||||||
|
<SelectValue placeholder="เลือกโครงการ..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<SelectItem key={p.publicId} value={p.publicId} className="text-xs">
|
||||||
|
{p.projectName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[200px] space-y-1">
|
||||||
|
<Label className="text-[11px] font-semibold text-muted-foreground">สัญญา (ถ้ามี)</Label>
|
||||||
|
<Select value={selectedContract} onValueChange={setSelectedContract} disabled={!selectedProject}>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-background/50 border-border/50">
|
||||||
|
<SelectValue placeholder="เลือกสัญญา..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{contracts.map((c) => (
|
||||||
|
<SelectItem key={c.publicId} value={c.publicId} className="text-xs">
|
||||||
|
{c.contractName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-[150px] space-y-1">
|
||||||
|
<Label className="text-[11px] font-semibold text-muted-foreground">OCR Engine</Label>
|
||||||
|
<Select value={ocrEngine} onValueChange={setOcrEngine}>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-background/50 border-border/50">
|
||||||
|
<SelectValue placeholder="เลือกเอนจิน..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto" className="text-xs">Auto (Baseline)</SelectItem>
|
||||||
|
<SelectItem value="tesseract" className="text-xs">Tesseract (CPU)</SelectItem>
|
||||||
|
<SelectItem value="np-dms-ocr" className="text-xs">Typhoon OCR (GPU)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-4 bg-background/40 p-4 border border-border/30 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="p-2 bg-primary/10 rounded">
|
||||||
|
<Upload className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-xs font-bold text-foreground">อัปโหลดไฟล์สำหรับทดสอบ Sandbox</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">เลือกไฟล์ PDF วิศวกรรม/ก่อสร้าง ขนาดไม่เกิน 50MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative overflow-hidden cursor-pointer bg-primary/90 hover:bg-primary/95 text-primary-foreground font-semibold px-4 py-2 rounded text-xs select-none flex items-center gap-2">
|
||||||
|
<span>เลือกไฟล์เอกสาร...</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono bg-secondary/20 border border-border/50 px-3 py-1.5 rounded">
|
||||||
|
<FileText className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
<span className="truncate flex-1">{file.name}</span>
|
||||||
|
<span>({(file.size / (1024 * 1024)).toFixed(2)} MB)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
{jobStatus === 'running' && (
|
||||||
|
<div className="space-y-2.5 p-4 border border-primary/20 bg-primary/[0.02] rounded-lg">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="flex items-center font-semibold text-primary">
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-bold text-primary">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Steps navigation and panels */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pt-2">
|
||||||
|
{/* Step buttons */}
|
||||||
|
<div className="lg:col-span-3 flex lg:flex-col gap-2.5">
|
||||||
|
<Button
|
||||||
|
variant={currentStep === 1 ? 'default' : 'outline'}
|
||||||
|
disabled={jobStatus === 'running' || !file}
|
||||||
|
onClick={() => setCurrentStep(1)}
|
||||||
|
className="w-full h-9 justify-start text-xs font-semibold"
|
||||||
|
>
|
||||||
|
<Badge className="mr-2 h-4 min-w-4 px-1 flex items-center justify-center text-[9px] bg-secondary text-secondary-foreground select-none">1</Badge>
|
||||||
|
Step 1: Run OCR
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentStep === 2 ? 'default' : 'outline'}
|
||||||
|
disabled={jobStatus === 'running' || !ocrText}
|
||||||
|
onClick={() => setCurrentStep(2)}
|
||||||
|
className="w-full h-9 justify-start text-xs font-semibold"
|
||||||
|
>
|
||||||
|
<Badge className="mr-2 h-4 min-w-4 px-1 flex items-center justify-center text-[9px] bg-secondary text-secondary-foreground select-none">2</Badge>
|
||||||
|
Step 2: AI Extract
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentStep === 3 ? 'default' : 'outline'}
|
||||||
|
disabled={jobStatus === 'running' || !extractedMetadata}
|
||||||
|
onClick={() => setCurrentStep(3)}
|
||||||
|
className="w-full h-9 justify-start text-xs font-semibold"
|
||||||
|
>
|
||||||
|
<Badge className="mr-2 h-4 min-w-4 px-1 flex items-center justify-center text-[9px] bg-secondary text-secondary-foreground select-none">3</Badge>
|
||||||
|
Step 3: RAG Prep
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step detail views */}
|
||||||
|
<div className="lg:col-span-9 border border-border/30 rounded-lg p-4 bg-background/50 min-h-[300px] flex flex-col justify-between">
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<div className="space-y-4 flex-1 flex flex-col justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
Step 1: สกัดข้อความ OCR (OCR Extraction)
|
||||||
|
</h4>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||||
|
รันเอนจินสกัดข้อความเพื่อดึงตัวหนังสือดิบออกมาจากหน้าไฟล์ PDF ที่ส่งขึ้นไป สามารถดูผลลัพธ์ข้อความดิบเพื่อประเมินความคมชัดของ OCR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{ocrText ? (
|
||||||
|
<div className="flex-1 min-h-[150px] max-h-[250px] overflow-y-auto rounded bg-secondary/30 border border-border/50 p-3 font-mono text-[10px] whitespace-pre-wrap select-text leading-relaxed mt-3">
|
||||||
|
{ocrText}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-[150px] flex items-center justify-center border border-dashed border-border/70 rounded mt-3 text-xs text-muted-foreground italic">
|
||||||
|
ยังไม่มีข้อมูล OCR คลิก "เริ่มรัน OCR" ด้านล่าง
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-border/10 mt-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRunOcr}
|
||||||
|
disabled={jobStatus === 'running' || !file}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
เริ่มรัน OCR (Run OCR)
|
||||||
|
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div className="space-y-4 flex-1 flex flex-col justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||||
|
<FileJson className="h-4 w-4 text-primary" />
|
||||||
|
Step 2: สกัดข้อมูลอัจฉริยะ (AI Metadata Extraction)
|
||||||
|
</h4>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||||
|
ส่งข้อความ OCR พร้อมบริบท Master data (โครงการ/สัญญา) เข้าไปประมวลผลร่วมกับโมเดลหลักและเวอร์ชันพรอมต์ที่เลือก เพื่อแปลงเป็นโครงสร้างข้อมูล JSON อัจฉริยะ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{extractedMetadata ? (
|
||||||
|
<div className="flex-1 min-h-[150px] max-h-[250px] overflow-y-auto rounded bg-secondary/30 border border-border/50 p-3 font-mono text-[10px] text-emerald-400 select-text leading-relaxed mt-3">
|
||||||
|
<pre>{JSON.stringify(extractedMetadata, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-[150px] flex items-center justify-center border border-dashed border-border/70 rounded mt-3 text-xs text-muted-foreground italic">
|
||||||
|
ยังไม่มีผลลัพธ์การสกัดข้อมูล คลิก "เริ่มรันสกัดข้อมูล" ด้านล่าง
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-border/10 mt-4">
|
||||||
|
{selectedVersionNumber && onActivateVersion && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleActivate}
|
||||||
|
className="h-8 text-xs border-emerald-500/30 text-emerald-500 hover:bg-emerald-500/10"
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
เปิดใช้งานเวอร์ชัน v{selectedVersionNumber} ทันที
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRunExtract}
|
||||||
|
disabled={jobStatus === 'running' || !ocrText}
|
||||||
|
className="h-8 text-xs bg-primary hover:bg-primary/95 text-primary-foreground"
|
||||||
|
>
|
||||||
|
เริ่มรันสกัดข้อมูล (Run AI Extract)
|
||||||
|
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<div className="space-y-4 flex-1 flex flex-col justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||||
|
<Database className="h-4 w-4 text-primary" />
|
||||||
|
Step 3: เตรียมฐานข้อมูลค้นหา (RAG Prep Sandbox)
|
||||||
|
</h4>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||||
|
จำลองกระบวนการแบ่งข้อความออกเป็นส่วนๆ (Semantic Chunking) ตามความเหมาะสมทางภาษาและความหมายของเอกสาร พร้อมแสดงขนาดเวกเตอร์ Dense/Sparse ที่สกัดสำหรับใช้ใน Qdrant
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{ragChunks ? (
|
||||||
|
<div className="flex-1 flex flex-col gap-3 mt-3 overflow-hidden">
|
||||||
|
<div className="flex justify-between items-center bg-secondary/40 border border-border/50 px-3 py-2 rounded text-xs select-none">
|
||||||
|
<span className="font-semibold text-foreground flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||||
|
ทำเวกเตอร์สำเร็จ: {ragVectorsCount} เวกเตอร์
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] border-border/50"> chunks: {ragChunks.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-[120px] max-h-[200px] overflow-y-auto space-y-2 mt-1">
|
||||||
|
{ragChunks.map((chunk, idx) => (
|
||||||
|
<div key={idx} className="bg-background/80 border border-border/30 rounded p-2.5 text-[10px] space-y-1 hover:border-primary/20 transition-all select-text">
|
||||||
|
<div className="flex justify-between items-center text-primary font-bold">
|
||||||
|
<span>#Chunk {idx + 1}</span>
|
||||||
|
<Badge className="text-[8px] py-0 px-1 select-none">{chunk.summary || 'หัวข้อหลัก'}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="leading-relaxed text-muted-foreground">{chunk.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-[150px] flex items-center justify-center border border-dashed border-border/70 rounded mt-3 text-xs text-muted-foreground italic">
|
||||||
|
ยังไม่มีผลลัพธ์ RAG Prep คลิก "เริ่มทดสอบ RAG Prep" ด้านล่าง
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-border/10 mt-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRunRagPrep}
|
||||||
|
disabled={jobStatus === 'running' || !ocrText}
|
||||||
|
className="h-8 text-xs bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||||
|
>
|
||||||
|
เริ่มทดสอบ RAG Prep (Test RAG Prep)
|
||||||
|
<CheckCircle className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// File: frontend/components/admin/ai/VersionHistory.tsx
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created VersionHistory component with type filtering and nice badges (conforming to task T017)
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote } from 'lucide-react';
|
||||||
|
import { PromptVersion } from '@/lib/types/ai-prompts';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface VersionHistoryProps {
|
||||||
|
versions: PromptVersion[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onLoadTemplate: (version: PromptVersion) => void;
|
||||||
|
onActivateVersion: (versionNumber: number) => void;
|
||||||
|
onDeleteVersion: (versionNumber: number) => void;
|
||||||
|
isActivating: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* คอมโพเนนต์แสดงประวัติเวอร์ชันของพรอมต์ตามประเภทที่กรองไว้
|
||||||
|
* แสดงรายการเวอร์ชันพร้อมปุ่มพรีโหลด เปิดใช้งาน และลบเวอร์ชันที่ไม่ต้องการ
|
||||||
|
*/
|
||||||
|
export default function VersionHistory({
|
||||||
|
versions,
|
||||||
|
isLoading,
|
||||||
|
onLoadTemplate,
|
||||||
|
onActivateVersion,
|
||||||
|
onDeleteVersion,
|
||||||
|
isActivating,
|
||||||
|
isDeleting,
|
||||||
|
}: VersionHistoryProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Clock className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||||
|
กำลังโหลดประวัติเวอร์ชัน...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3 border-b border-border/10">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
|
||||||
|
<BookOpen className="h-4 w-4 text-primary" />
|
||||||
|
ประวัติเวอร์ชัน (Version History)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 px-3 sm:px-4 max-h-[500px] overflow-y-auto space-y-3">
|
||||||
|
{versions.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center text-xs text-muted-foreground italic">
|
||||||
|
ไม่พบเวอร์ชันอื่นในระบบสำหรับประเภทนี้
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
versions.map((version) => {
|
||||||
|
const isActive = version.isActive === true;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={version.versionNumber}
|
||||||
|
className={cn(
|
||||||
|
'group relative rounded-lg border border-border/30 bg-background/50 p-3.5 transition-all duration-200 hover:border-primary/30 hover:bg-background/80',
|
||||||
|
isActive && 'border-emerald-500/20 bg-emerald-500/[0.02]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm font-bold text-foreground">
|
||||||
|
v{version.versionNumber}
|
||||||
|
</span>
|
||||||
|
{isActive ? (
|
||||||
|
<Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20 text-[10px] py-0 px-1.5 flex items-center gap-1 select-none">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
ใช้งานจริง (Active)
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-muted-foreground border-border/50 bg-background/40 select-none">
|
||||||
|
ร่าง (Inactive)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
สร้าง: {new Date(version.createdAt).toLocaleString('th-TH')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 opacity-90 sm:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[10px] text-muted-foreground hover:bg-secondary"
|
||||||
|
onClick={() => onLoadTemplate(version)}
|
||||||
|
>
|
||||||
|
โหลด (Load)
|
||||||
|
</Button>
|
||||||
|
{!isActive && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isActivating}
|
||||||
|
className="h-7 text-[10px] text-emerald-500 hover:text-emerald-600 hover:bg-emerald-500/10"
|
||||||
|
onClick={() => onActivateVersion(version.versionNumber)}
|
||||||
|
>
|
||||||
|
ใช้งาน (Activate)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => onDeleteVersion(version.versionNumber)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{version.manualNote && (
|
||||||
|
<div className="mt-2.5 rounded bg-muted/30 p-2 border border-border/10 flex gap-1.5 items-start text-[11px] text-muted-foreground select-text">
|
||||||
|
<StickyNote className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="leading-relaxed whitespace-pre-wrap">{version.manualNote}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -245,7 +245,6 @@ describe('RFADetail', () => {
|
|||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
expect(screen.getByText('Submit RFA to Workflow')).toBeInTheDocument();
|
expect(screen.getByText('Submit RFA to Workflow')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Routing Template ID')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show review team selector when project has publicId', () => {
|
it('should show review team selector when project has publicId', () => {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ interface RFADetailProps {
|
|||||||
export function RFADetail({ data }: RFADetailProps) {
|
export function RFADetail({ data }: RFADetailProps) {
|
||||||
const [actionState, setActionState] = useState<'approve' | 'reject' | 'submit' | null>(null);
|
const [actionState, setActionState] = useState<'approve' | 'reject' | 'submit' | null>(null);
|
||||||
const [comments, setComments] = useState('');
|
const [comments, setComments] = useState('');
|
||||||
const [templateId, setTemplateId] = useState<number>(1);
|
|
||||||
const [reviewTeamPublicId, setReviewTeamPublicId] = useState<string | undefined>(undefined);
|
const [reviewTeamPublicId, setReviewTeamPublicId] = useState<string | undefined>(undefined);
|
||||||
const processMutation = useProcessRFA();
|
const processMutation = useProcessRFA();
|
||||||
const submitMutation = useSubmitRFA();
|
const submitMutation = useSubmitRFA();
|
||||||
@@ -84,7 +83,6 @@ export function RFADetail({ data }: RFADetailProps) {
|
|||||||
{
|
{
|
||||||
uuid: data.publicId,
|
uuid: data.publicId,
|
||||||
data: {
|
data: {
|
||||||
templateId,
|
|
||||||
reviewTeamPublicId,
|
reviewTeamPublicId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -156,18 +154,6 @@ export function RFADetail({ data }: RFADetailProps) {
|
|||||||
<CardTitle className="text-lg">Submit RFA to Workflow</CardTitle>
|
<CardTitle className="text-lg">Submit RFA to Workflow</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="templateId">Routing Template ID</Label>
|
|
||||||
<input
|
|
||||||
id="templateId"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
|
||||||
value={templateId}
|
|
||||||
onChange={(e) => setTemplateId(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">Enter the routing template ID for this submission.</p>
|
|
||||||
</div>
|
|
||||||
{data.correspondence?.project?.publicId && (
|
{data.correspondence?.project?.publicId && (
|
||||||
<ReviewTeamSelector
|
<ReviewTeamSelector
|
||||||
projectPublicId={data.correspondence.project.publicId}
|
projectPublicId={data.correspondence.project.publicId}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// File: frontend/contracts/frontend-types.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created frontend contract types from specifications (conforming to task T010)
|
||||||
|
|
||||||
|
export type PromptType = 'ocr_extraction' | 'rag_query_prompt' | 'rag_prep_prompt' | 'classification_prompt';
|
||||||
|
|
||||||
|
export interface ContextConfig {
|
||||||
|
filter: {
|
||||||
|
projectId: string | null;
|
||||||
|
contractId: string | null;
|
||||||
|
};
|
||||||
|
pageSize: number;
|
||||||
|
language: string;
|
||||||
|
outputLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptVersion {
|
||||||
|
id: string;
|
||||||
|
promptType: PromptType;
|
||||||
|
versionNumber: number;
|
||||||
|
template: string;
|
||||||
|
contextConfig: ContextConfig | null;
|
||||||
|
isActive: boolean;
|
||||||
|
manualNote: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeParameters {
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
repeatPenalty: number;
|
||||||
|
maxTokens: number;
|
||||||
|
ctxSize: number;
|
||||||
|
keepAlive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionProfile {
|
||||||
|
id: string;
|
||||||
|
profileName: string;
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
repeatPenalty: number;
|
||||||
|
maxTokens: number;
|
||||||
|
ctxSize: number;
|
||||||
|
keepAlive: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SandboxJobType = 'ocr' | 'ai-extract' | 'rag-prep';
|
||||||
|
export type SandboxJobStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export interface SandboxJobResult {
|
||||||
|
ocrText?: string;
|
||||||
|
extractedMetadata?: Record<string, unknown>;
|
||||||
|
ragChunks?: Array<{
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
}>;
|
||||||
|
ragVectors?: number[][];
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SandboxJob {
|
||||||
|
jobId: string;
|
||||||
|
jobType: SandboxJobType;
|
||||||
|
status: SandboxJobStatus;
|
||||||
|
result: SandboxJobResult;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePromptDto {
|
||||||
|
template: string;
|
||||||
|
contextConfig: ContextConfig;
|
||||||
|
manualNote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateContextConfigDto {
|
||||||
|
filter: {
|
||||||
|
projectId: string | null;
|
||||||
|
contractId: string | null;
|
||||||
|
};
|
||||||
|
pageSize: number;
|
||||||
|
language: string;
|
||||||
|
outputLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLACEHOLDER_REQUIREMENTS: Record<PromptType, string[]> = {
|
||||||
|
ocr_extraction: ['{{ocr_text}}', '{{master_data_context}}'],
|
||||||
|
rag_query_prompt: ['{{query}}', '{{context}}'],
|
||||||
|
rag_prep_prompt: ['{{text}}'],
|
||||||
|
classification_prompt: ['{{document_text}}'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RUNTIME_PARAMETER_CONSTRAINTS = {
|
||||||
|
temperature: { min: 0.0, max: 1.0, default: 0.7 },
|
||||||
|
topP: { min: 0.0, max: 1.0, default: 0.9 },
|
||||||
|
repeatPenalty: { min: 1.0, max: 2.0, default: 1.0 },
|
||||||
|
maxTokens: { min: 1, max: 8192, default: 2048 },
|
||||||
|
ctxSize: { min: 1, max: 16384, default: 4096 },
|
||||||
|
keepAlive: { min: 0, max: 3600, default: 300 },
|
||||||
|
};
|
||||||
@@ -64,7 +64,7 @@ describe('rfaService', () => {
|
|||||||
it('ควรส่ง RFA เข้า workflow', async () => {
|
it('ควรส่ง RFA เข้า workflow', async () => {
|
||||||
const mockResponse = { data: { publicId: 'uuid-1', status: 'SUBMITTED' } };
|
const mockResponse = { data: { publicId: 'uuid-1', status: 'SUBMITTED' } };
|
||||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||||
const submitDto = { templateId: 1, reviewTeamPublicId: 'uuid-team' };
|
const submitDto = { reviewTeamPublicId: 'uuid-team' };
|
||||||
const result = await rfaService.submit('uuid-1', submitDto);
|
const result = await rfaService.submit('uuid-1', submitDto);
|
||||||
expect(apiClient.post).toHaveBeenCalledWith('/rfas/uuid-1/submit', submitDto);
|
expect(apiClient.post).toHaveBeenCalledWith('/rfas/uuid-1/submit', submitDto);
|
||||||
expect(result).toEqual(mockResponse.data);
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// File: lib/services/admin-ai.service.ts
|
// File: lib/services/admin-ai.service.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
|
// - 2026-06-14: เพิ่ม methods สำหรับจัดการ Prompt Versions และ RAG Prep Sandbox (T020, T030, T039)
|
||||||
// - 2026-05-21: เพิ่ม service สำหรับ AI Admin Console toggle API.
|
// - 2026-05-21: เพิ่ม service สำหรับ AI Admin Console toggle API.
|
||||||
// - 2026-05-21: เพิ่ม service method `getHealth` สำหรับดึงข้อมูลสุขภาพของระบบ AI (T028).
|
// - 2026-05-21: เพิ่ม service method `getHealth` สำหรับดึงข้อมูลสุขภาพของระบบ AI (T028).
|
||||||
// - 2026-05-21: เพิ่ม API service สำหรับ Superadmin Sandbox RAG (T037).
|
// - 2026-05-21: เพิ่ม API service สำหรับ Superadmin Sandbox RAG (T037).
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
|
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import { AiJobResponse } from '../../types/ai';
|
import { AiJobResponse } from '../../types/ai';
|
||||||
|
import { PromptType, PromptVersion, ContextConfig } from '../types/ai-prompts';
|
||||||
|
|
||||||
export interface AiAdminSettings {
|
export interface AiAdminSettings {
|
||||||
aiFeaturesEnabled: boolean;
|
aiFeaturesEnabled: boolean;
|
||||||
@@ -403,6 +405,59 @@ export const adminAiService = {
|
|||||||
});
|
});
|
||||||
return extractData<AiJobResponse>(data);
|
return extractData<AiJobResponse>(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listPrompts: async (type: PromptType): Promise<PromptVersion[]> => {
|
||||||
|
const { data } = await api.get(`/ai/prompts/${type}`);
|
||||||
|
return extractData<PromptVersion[]>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
createPrompt: async (
|
||||||
|
type: PromptType,
|
||||||
|
updates: { template: string; contextConfig?: ContextConfig | null; manualNote?: string }
|
||||||
|
): Promise<PromptVersion> => {
|
||||||
|
const { data } = await api.post(`/ai/prompts/${type}`, updates);
|
||||||
|
return extractData<PromptVersion>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePrompt: async (type: PromptType, versionNumber: number): Promise<void> => {
|
||||||
|
await api.delete(`/ai/prompts/${type}/${versionNumber}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
activatePrompt: async (type: PromptType, versionNumber: number): Promise<PromptVersion> => {
|
||||||
|
const { data } = await api.post(`/ai/prompts/${type}/${versionNumber}/activate`);
|
||||||
|
return extractData<PromptVersion>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePromptNote: async (
|
||||||
|
type: PromptType,
|
||||||
|
versionNumber: number,
|
||||||
|
manualNote: string
|
||||||
|
): Promise<PromptVersion> => {
|
||||||
|
const { data } = await api.patch(`/ai/prompts/${type}/${versionNumber}/note`, { manualNote });
|
||||||
|
return extractData<PromptVersion>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getContextConfig: async (type: PromptType, versionNumber: number): Promise<ContextConfig> => {
|
||||||
|
const { data } = await api.get(`/ai/prompts/${type}/${versionNumber}/context-config`);
|
||||||
|
return extractData<ContextConfig>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateContextConfig: async (
|
||||||
|
type: PromptType,
|
||||||
|
versionNumber: number,
|
||||||
|
contextConfig: ContextConfig
|
||||||
|
): Promise<ContextConfig> => {
|
||||||
|
const { data } = await api.put(`/ai/prompts/${type}/${versionNumber}/context-config`, contextConfig);
|
||||||
|
return extractData<ContextConfig>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
submitSandboxRagPrep: async (
|
||||||
|
text: string,
|
||||||
|
profileId?: string | null
|
||||||
|
): Promise<{ jobId: string; status: string }> => {
|
||||||
|
const { data } = await api.post('/ai/admin/sandbox/rag-prep', { text, profileId });
|
||||||
|
return extractData<{ jobId: string; status: string }>(data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface OcrEngineResponse {
|
export interface OcrEngineResponse {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface WorkflowActionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitRfaDto {
|
export interface SubmitRfaDto {
|
||||||
templateId: number;
|
|
||||||
reviewTeamPublicId?: string;
|
reviewTeamPublicId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// File: frontend/lib/types/ai-prompts.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-14: Created frontend types for AI prompt management (conforming to task T010)
|
||||||
|
|
||||||
|
export type PromptType = 'ocr_extraction' | 'rag_query_prompt' | 'rag_prep_prompt' | 'classification_prompt';
|
||||||
|
|
||||||
|
export interface ContextConfig {
|
||||||
|
filter: {
|
||||||
|
projectId: string | null;
|
||||||
|
contractId: string | null;
|
||||||
|
} | null;
|
||||||
|
pageSize: number;
|
||||||
|
language: string;
|
||||||
|
outputLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptVersion {
|
||||||
|
publicId: string;
|
||||||
|
promptType: PromptType;
|
||||||
|
versionNumber: number;
|
||||||
|
template: string;
|
||||||
|
contextConfig: ContextConfig | null;
|
||||||
|
isActive: boolean;
|
||||||
|
manualNote: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeParameters {
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
repeatPenalty: number;
|
||||||
|
maxTokens: number;
|
||||||
|
ctxSize: number;
|
||||||
|
keepAlive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionProfile {
|
||||||
|
publicId: string;
|
||||||
|
profileName: string;
|
||||||
|
canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
repeatPenalty: number;
|
||||||
|
maxTokens: number | null;
|
||||||
|
ctxSize: number | null;
|
||||||
|
keepAlive: number;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SandboxJobType = 'ocr' | 'ai-extract' | 'rag-prep';
|
||||||
|
|
||||||
|
export type SandboxJobStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export interface SandboxJobResult {
|
||||||
|
ocrText?: string;
|
||||||
|
extractedMetadata?: Record<string, unknown>;
|
||||||
|
ragChunks?: Array<{
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
}>;
|
||||||
|
ragVectors?: number[][];
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SandboxJob {
|
||||||
|
jobId: string;
|
||||||
|
jobType: SandboxJobType;
|
||||||
|
status: SandboxJobStatus;
|
||||||
|
result: SandboxJobResult;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
@@ -88,6 +88,6 @@
|
|||||||
"tailwindcss": "3.4.3",
|
"tailwindcss": "3.4.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "7.3.2",
|
"vite": "7.3.2",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,5 +143,13 @@
|
|||||||
"ai.prompt.notePlaceholder": "Write analysis, differences, or suggestions for this prompt version...",
|
"ai.prompt.notePlaceholder": "Write analysis, differences, or suggestions for this prompt version...",
|
||||||
"ai.prompt.sandboxErrorTitle": "Sandbox Run Failed",
|
"ai.prompt.sandboxErrorTitle": "Sandbox Run Failed",
|
||||||
"ai.prompt.sandboxErrorDefault": "Processing timed out or an error occurred while loading the model.",
|
"ai.prompt.sandboxErrorDefault": "Processing timed out or an error occurred while loading the model.",
|
||||||
"ai.prompt.timeoutInfo": "System waits up to 120 seconds — Ollama may take time to load on cold start"
|
"ai.prompt.timeoutInfo": "System waits up to 120 seconds — Ollama may take time to load on cold start",
|
||||||
|
"ai.prompt.title": "AI Prompt Management",
|
||||||
|
"ai.prompt.selectType": "Filter by Prompt Type",
|
||||||
|
"ai.prompt.type.ocr_extraction": "OCR Extraction",
|
||||||
|
"ai.prompt.type.rag_query_prompt": "RAG Query",
|
||||||
|
"ai.prompt.type.rag_prep_prompt": "RAG Prep",
|
||||||
|
"ai.prompt.type.classification_prompt": "Classification",
|
||||||
|
"ai.prompt.contextConfig": "Context Configuration",
|
||||||
|
"ai.prompt.sandboxTitle": "Sandbox Testing"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,5 +143,13 @@
|
|||||||
"ai.prompt.notePlaceholder": "เขียนวิเคราะห์ความแตกต่างหรือข้อเสนอแนะเกี่ยวกับผลลัพธ์ของ prompt vนี้...",
|
"ai.prompt.notePlaceholder": "เขียนวิเคราะห์ความแตกต่างหรือข้อเสนอแนะเกี่ยวกับผลลัพธ์ของ prompt vนี้...",
|
||||||
"ai.prompt.sandboxErrorTitle": "รัน Sandbox ล้มเหลว",
|
"ai.prompt.sandboxErrorTitle": "รัน Sandbox ล้มเหลว",
|
||||||
"ai.prompt.sandboxErrorDefault": "ระบบใช้เวลาประมวลผลนานเกินกำหนดหรือเกิดข้อผิดพลาดในการโหลดโมเดล",
|
"ai.prompt.sandboxErrorDefault": "ระบบใช้เวลาประมวลผลนานเกินกำหนดหรือเกิดข้อผิดพลาดในการโหลดโมเดล",
|
||||||
"ai.prompt.timeoutInfo": "ระบบรอผลสูงสุด 120 วินาที — Ollama อาจใช้เวลาโหลดโมเดลเมื่อเริ่มต้นใหม่"
|
"ai.prompt.timeoutInfo": "ระบบรอผลสูงสุด 120 วินาที — Ollama อาจใช้เวลาโหลดโมเดลเมื่อเริ่มต้นใหม่",
|
||||||
|
"ai.prompt.title": "ระบบจัดการพรอมต์ของ AI (AI Prompt Management)",
|
||||||
|
"ai.prompt.selectType": "กรองตามประเภทพรอมต์ (Filter by Prompt Type)",
|
||||||
|
"ai.prompt.type.ocr_extraction": "สกัดข้อความ OCR (OCR Extraction)",
|
||||||
|
"ai.prompt.type.rag_query_prompt": "ค้นหาข้อมูล RAG (RAG Query)",
|
||||||
|
"ai.prompt.type.rag_prep_prompt": "เตรียมข้อมูล RAG (RAG Prep)",
|
||||||
|
"ai.prompt.type.classification_prompt": "จำแนกประเภทเอกสาร (Classification)",
|
||||||
|
"ai.prompt.contextConfig": "การตั้งค่าบริบทข้อมูล (Context Configuration)",
|
||||||
|
"ai.prompt.sandboxTitle": "การทดสอบ Sandbox (Sandbox Testing)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,21 +26,21 @@
|
|||||||
|
|
||||||
> การตัดสินใจเหล่านี้ **ไม่สามารถเปลี่ยนแปลงได้** โดยไม่ได้รับ Explicit Approval
|
> การตัดสินใจเหล่านี้ **ไม่สามารถเปลี่ยนแปลงได้** โดยไม่ได้รับ Explicit Approval
|
||||||
|
|
||||||
| ID | Decision | ADR |
|
| ID | Decision | ADR |
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||||
| D1 | n8n = Migration Phase orchestrator เท่านั้น — ห้ามทำ New Correspondence pipeline ผ่าน n8n | ADR-023A |
|
| D1 | n8n = Migration Phase orchestrator เท่านั้น — ห้ามทำ New Correspondence pipeline ผ่าน n8n | ADR-023A |
|
||||||
| D2 | New Correspondence → BullMQ `ai-realtime` queue โดยตรง (ไม่ผ่าน n8n) | ADR-023A |
|
| D2 | New Correspondence → BullMQ `ai-realtime` queue โดยตรง (ไม่ผ่าน n8n) | ADR-023A |
|
||||||
| D3 | n8n ต้อง call `POST /api/ai/jobs` (DMS Backend) เท่านั้น — ห้าม call Ollama/Qdrant โดยตรง | ADR-023A |
|
| D3 | n8n ต้อง call `POST /api/ai/jobs` (DMS Backend) เท่านั้น — ห้าม call Ollama/Qdrant โดยตรง | ADR-023A |
|
||||||
| D4 | Excel metadata ส่งไปพร้อม AI job เป็น context (docNumber, title, sender ฯลฯ) | Session 2 |
|
| D4 | Excel metadata ส่งไปพร้อม AI job เป็น context (docNumber, title, sender ฯลฯ) | Session 2 |
|
||||||
| D5 | Tag suggestion ใช้ทาง C: แนะนำ existing tags + สร้างใหม่ได้ถ้าไม่มี (`isNew: true` flag) | Session 2 |
|
| D5 | Tag suggestion ใช้ทาง C: แนะนำ existing tags + สร้างใหม่ได้ถ้าไม่มี (`isNew: true` flag) | Session 2 |
|
||||||
| D6 | Editable Review Form: AI pre-fill → user approve/edit → submit (human-in-the-loop ทุกครั้ง) | ADR-023 |
|
| D6 | Editable Review Form: AI pre-fill → user approve/edit → submit (human-in-the-loop ทุกครั้ง) | ADR-023 |
|
||||||
| D7 | UUID Strategy: `publicId` (UUIDv7) เท่านั้นสำหรับ Public API — INT PK ต้อง `@Exclude()` | ADR-019 |
|
| D7 | UUID Strategy: `publicId` (UUIDv7) เท่านั้นสำหรับ Public API — INT PK ต้อง `@Exclude()` | ADR-019 |
|
||||||
| D8 | Schema changes: แก้ SQL โดยตรง + เพิ่ม `deltas/*.sql` — ห้ามใช้ TypeORM migration files | ADR-009 |
|
| D8 | Schema changes: แก้ SQL โดยตรง + เพิ่ม `deltas/*.sql` — ห้ามใช้ TypeORM migration files | ADR-009 |
|
||||||
| D9 | Qdrant search ต้องส่ง `projectPublicId` เป็น mandatory parameter ทุกครั้ง (compile-time) | ADR-023A |
|
| D9 | Qdrant search ต้องส่ง `projectPublicId` เป็น mandatory parameter ทุกครั้ง (compile-time) | ADR-023A |
|
||||||
| D10 | AI model stack: `np-dms-ai:latest` (Main LLM) + `np-dms-ocr:latest` (OCR, keep_alive:0) + `BGE-M3` (Dense 1024 + Sparse Embedding) + `BGE-Reranker-Large` (Reranker) on Admin Desktop — `nomic-embed-text` ถูกแทนที่แล้ว (ADR-034/035) | ADR-034/035 |
|
| D10 | AI model stack: `np-dms-ai:latest` (Main LLM) + `np-dms-ocr:latest` (OCR, keep_alive:0) + `BGE-M3` (Dense 1024 + Sparse Embedding) + `BGE-Reranker-Large` (Reranker) on Admin Desktop — `nomic-embed-text` ถูกแทนที่แล้ว (ADR-034/035) | ADR-034/035 |
|
||||||
| D11 | RAG Embedding trigger: `syncStatus()` → `enqueueRagPrepare()` เมื่อ status ≠ DRAFT; jobId = `rag-prepare:{documentPublicId}:{revisionNumber}` (BullMQ dedup); delete-before-upsert ทุกครั้ง | ADR-035 |
|
| D11 | RAG Embedding trigger: `syncStatus()` → `enqueueRagPrepare()` เมื่อ status ≠ DRAFT; jobId = `rag-prepare:{documentPublicId}:{revisionNumber}` (BullMQ dedup); delete-before-upsert ทุกครั้ง | ADR-035 |
|
||||||
| D12 | Qdrant collection `lcbp3_vectors` = Hybrid schema: `bge_dense` (1024 dims, Cosine) + `bge_sparse` (SPLADE); payload indexes: `project_public_id` (tenant), `doc_public_id`, `status_code`, `doc_type` | ADR-035 |
|
| D12 | Qdrant collection `lcbp3_vectors` = Hybrid schema: `bge_dense` (1024 dims, Cosine) + `bge_sparse` (SPLADE); payload indexes: `project_public_id` (tenant), `doc_public_id`, `status_code`, `doc_type` | ADR-035 |
|
||||||
| D13 | **Analysis Phase required** — ต้องอ่าน `docker-compose*.yml`, `deploy.sh`, `main.ts` ก่อนแนะนำ URL/Port/Path — ห้ามเดา | AGENTS.md |
|
| D13 | **Analysis Phase required** — ต้องอ่าน `docker-compose*.yml`, `deploy.sh`, `main.ts` ก่อนแนะนำ URL/Port/Path — ห้ามเดา | AGENTS.md |
|
||||||
| D14 | Sandbox-Production Parity: บันทึก draft ใน `ai_sandbox_profiles` และปรับใช้ไป production `ai_execution_profiles` ผ่าน apply API (Idempotency-Key + CASL guard); sandbox pipeline ดึง project/contract ID จริงเพื่อ parity prompt context | ADR-036 |
|
| D14 | Sandbox-Production Parity: บันทึก draft ใน `ai_sandbox_profiles` และปรับใช้ไป production `ai_execution_profiles` ผ่าน apply API (Idempotency-Key + CASL guard); sandbox pipeline ดึง project/contract ID จริงเพื่อ parity prompt context | ADR-036 |
|
||||||
|
|
||||||
## Environment & Services
|
## Environment & Services
|
||||||
@@ -88,6 +88,17 @@ QDRANT_URL
|
|||||||
- [ ] เพิ่ม unit test สำหรับ `upsertQueueRecord` ใน `ai-migration-checkpoint.service.spec.ts`
|
- [ ] เพิ่ม unit test สำหรับ `upsertQueueRecord` ใน `ai-migration-checkpoint.service.spec.ts`
|
||||||
- [ ] เพิ่ม unit test สำหรับ checksum dedup ใน `file-storage.service.spec.ts`
|
- [ ] เพิ่ม unit test สำหรับ checksum dedup ใน `file-storage.service.spec.ts`
|
||||||
|
|
||||||
|
### Feature-303: Frontend Test Coverage — Phase 2 Gate ✅ PASSED
|
||||||
|
|
||||||
|
- [x] **Phase 2 coverage gate:** Statements 51.62% (target ≥ 50%)
|
||||||
|
- [x] **Verification:** `pnpm --filter lcbp3-frontend exec tsc --noEmit` ผ่าน
|
||||||
|
- [x] **Coverage suite:** `pnpm --filter lcbp3-frontend exec vitest run --coverage` ผ่าน 92 files / 692 tests
|
||||||
|
- [x] **New/extended coverage:** auth store, i18n utility, Circulation list, OCR sandbox prompt manager, Layout widgets
|
||||||
|
- [x] **Plan/tasks updated:** `specs/300-others/303-frontend-test-coverage/plan.md` และ `tasks.md`
|
||||||
|
- [ ] **Remaining:** T034 Admin dashboard components
|
||||||
|
- [ ] **Remaining polish:** T050-T053 audit (`any`/`console.log`, publicId mock data, file headers, final coverage record)
|
||||||
|
- [ ] **Next target:** Phase 3 Statements ≥ 70%
|
||||||
|
|
||||||
### Feature-235: AI Runtime Policy Refactor ✅ COMPLETE
|
### Feature-235: AI Runtime Policy Refactor ✅ COMPLETE
|
||||||
|
|
||||||
- [x] **Phase 1–8 ทุก task เสร็จครบ** รวม T032 (manual validation ผ่านหมดทุก Gate ที่ test ได้)
|
- [x] **Phase 1–8 ทุก task เสร็จครบ** รวม T032 (manual validation ผ่านหมดทุก Gate ที่ test ได้)
|
||||||
@@ -112,3 +123,39 @@ QDRANT_URL
|
|||||||
- [x] **Dual-Model Snapshot:** snapshot params แยกส่วน LLM และ OCR บันทึกลง job payload สำเร็จ
|
- [x] **Dual-Model Snapshot:** snapshot params แยกส่วน LLM และ OCR บันทึกลง job payload สำเร็จ
|
||||||
- [x] **Master Data Parity:** sandbox ดึง project/contract master data สำหรับ prompt context
|
- [x] **Master Data Parity:** sandbox ดึง project/contract master data สำหรับ prompt context
|
||||||
- **Branch:** `236-unified-ocr-architecture` — พร้อม merge
|
- **Branch:** `236-unified-ocr-architecture` — พร้อม merge
|
||||||
|
|
||||||
|
### Correspondence Module Review Fixes ✅ COMPLETE
|
||||||
|
|
||||||
|
- [x] `throw new Error` → `ValidationException` (ADR-007) + `@Audit` บน `processAction`
|
||||||
|
- [x] CSV export: force `limit: 10000` override ใน `exportCsv`
|
||||||
|
- [x] `escapeCsv`: กัน OWASP formula injection (`=`, `+`, `-`, `@`, `\t`, `\r`)
|
||||||
|
- [x] `bulkCancel`: เพิ่ม `this.logger.warn(...)` ใน catch block
|
||||||
|
- [x] `update()` re-index: ใช้ status จาก current revision แทน hardcode `'DRAFT'`
|
||||||
|
- [x] `RecipientDto`: เพิ่ม nested validation class + `@ValidateNested({ each: true })`
|
||||||
|
- [x] `PUT /:uuid`: แก้ permission → `correspondence.edit` (seed id=73)
|
||||||
|
- [x] Idempotency: `@UseInterceptors(IdempotencyInterceptor)` บนทุก 7 mutation endpoints
|
||||||
|
- [ ] **Verify:** `pnpm --filter backend build` + ทดสอบ CSV export > 10 rows + Idempotency header
|
||||||
|
|
||||||
|
### RFA ADR-001/021 Migration ✅ COMPLETE
|
||||||
|
|
||||||
|
- [x] ตัด deprecated `CorrespondenceRouting`/`RoutingTemplate`/`RoutingTemplateStep` repos ออก
|
||||||
|
- [x] ตัด `RfaWorkflowService` + entities (`RfaWorkflow`, `RfaWorkflowTemplate`, `RfaWorkflowTemplateStep`) ออกจาก `rfa.module.ts`
|
||||||
|
- [x] `submit()` + `processAction()` rewired ผ่าน `workflowEngine.processTransition()`
|
||||||
|
- [x] EC-RFA-001 check ย้ายเข้า transaction ด้วย `FOR UPDATE` lock (race-safe)
|
||||||
|
- [x] `syncRevisionStatus()` helper: map `STATE_TO_STATUS` — ห้าม hardcode
|
||||||
|
- [x] `notifyRecipients()` helper: ADR-008 async notify
|
||||||
|
- [x] `findOneByUuid()`: expose ADR-021 workflow fields (`workflowInstanceId`, `workflowState`, `availableActions`)
|
||||||
|
- [x] เพิ่ม static constants: `WORKFLOW_CODE = 'RFA_APPROVAL'`, `STATE_TO_STATUS` map, `DEFAULT_APPROVED_CODE = '1A'`
|
||||||
|
- [x] ตัด `templateId` ออกจาก `SubmitRfaDto` (backend + frontend) + `detail.tsx` UI + tests
|
||||||
|
- [x] **Verify:** `tsc --noEmit` (backend) exit 0
|
||||||
|
- [x] **Verify:** 26/26 frontend tests pass (`rfa.service.test.ts` + `detail.test.tsx`)
|
||||||
|
|
||||||
|
### Feature-237: Unified Prompt Management UX/UI — Code Review ❌ REQUEST CHANGES
|
||||||
|
|
||||||
|
- [x] **Review artifact:** `specs/200-fullstacks/237-unified-prompt-management-ux-ui/code-review-report.md`
|
||||||
|
- [x] **Frontend verification:** `pnpm --filter lcbp3-frontend exec tsc --noEmit` ผ่าน
|
||||||
|
- [x] **Backend blocker (resolved):** RFA migration เสร็จครบ — `tsc --noEmit` exit 0, `templateId` ตัดออกครบ, static constants เพิ่มครบ (ดู session-2026-06-14-rfa-migration-complete.md)
|
||||||
|
- [ ] **Security/data isolation:** แก้ prompt context filter ให้ใช้ public UUID (`projectPublicId`/`contractPublicId`) และ resolve เป็น internal IDs ฝั่ง service ห้าม `Number(uuid)`
|
||||||
|
- [ ] **Idempotency:** บังคับ `Idempotency-Key` สำหรับ prompt create/activate/context update และ sandbox RAG prep queueing
|
||||||
|
- [ ] **Prompt contract:** sync placeholders ระหว่าง seed SQL, validator, และ replacement logic สำหรับ `rag_query_prompt`, `rag_prep_prompt`, `classification_prompt`
|
||||||
|
- [ ] **DTO hardening:** nested validation + `@IsUUID()` + max page size/text length สำหรับ context config และ sandbox RAG prep
|
||||||
|
|||||||
Generated
+91
-62
@@ -501,7 +501,7 @@ importers:
|
|||||||
version: 5.2.0(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
version: 5.2.0(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^4.1.6
|
specifier: ^4.1.6
|
||||||
version: 4.1.6(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)))
|
version: 4.1.6(vitest@4.1.8)
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.27
|
specifier: ^10.4.27
|
||||||
version: 10.4.27(postcss@8.5.10)
|
version: 10.4.27(postcss@8.5.10)
|
||||||
@@ -542,8 +542,8 @@ importers:
|
|||||||
specifier: 7.3.2
|
specifier: 7.3.2
|
||||||
version: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)
|
version: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.6
|
||||||
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -767,6 +767,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/code-frame@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/compat-data@7.29.0':
|
'@babel/compat-data@7.29.0':
|
||||||
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
|
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -862,6 +866,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7':
|
||||||
|
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-validator-option@7.27.1':
|
'@babel/helper-validator-option@7.27.1':
|
||||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1356,6 +1364,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -4059,11 +4071,11 @@ packages:
|
|||||||
'@vitest/browser':
|
'@vitest/browser':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vitest/expect@4.1.0':
|
'@vitest/expect@4.1.8':
|
||||||
resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
|
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
|
||||||
|
|
||||||
'@vitest/mocker@4.1.0':
|
'@vitest/mocker@4.1.8':
|
||||||
resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==}
|
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
msw: ^2.4.9
|
msw: ^2.4.9
|
||||||
vite: '>=7.3.2'
|
vite: '>=7.3.2'
|
||||||
@@ -4073,27 +4085,27 @@ packages:
|
|||||||
vite:
|
vite:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.0':
|
|
||||||
resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==}
|
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.6':
|
'@vitest/pretty-format@4.1.6':
|
||||||
resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==}
|
resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==}
|
||||||
|
|
||||||
'@vitest/runner@4.1.0':
|
'@vitest/pretty-format@4.1.8':
|
||||||
resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==}
|
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
|
||||||
|
|
||||||
'@vitest/snapshot@4.1.0':
|
'@vitest/runner@4.1.8':
|
||||||
resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==}
|
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
|
||||||
|
|
||||||
'@vitest/spy@4.1.0':
|
'@vitest/snapshot@4.1.8':
|
||||||
resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==}
|
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
|
||||||
|
|
||||||
'@vitest/utils@4.1.0':
|
'@vitest/spy@4.1.8':
|
||||||
resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
|
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
|
||||||
|
|
||||||
'@vitest/utils@4.1.6':
|
'@vitest/utils@4.1.6':
|
||||||
resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
|
resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.8':
|
||||||
|
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||||
|
|
||||||
@@ -5087,8 +5099,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
dompurify@3.4.0:
|
dompurify@3.4.10:
|
||||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==}
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||||
@@ -8387,18 +8399,20 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
vitest@4.1.0:
|
vitest@4.1.8:
|
||||||
resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==}
|
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@edge-runtime/vm': '*'
|
'@edge-runtime/vm': '*'
|
||||||
'@opentelemetry/api': ^1.9.0
|
'@opentelemetry/api': ^1.9.0
|
||||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||||
'@vitest/browser-playwright': 4.1.0
|
'@vitest/browser-playwright': 4.1.8
|
||||||
'@vitest/browser-preview': 4.1.0
|
'@vitest/browser-preview': 4.1.8
|
||||||
'@vitest/browser-webdriverio': 4.1.0
|
'@vitest/browser-webdriverio': 4.1.8
|
||||||
'@vitest/ui': 4.1.0
|
'@vitest/coverage-istanbul': 4.1.8
|
||||||
|
'@vitest/coverage-v8': 4.1.8
|
||||||
|
'@vitest/ui': 4.1.8
|
||||||
happy-dom: '*'
|
happy-dom: '*'
|
||||||
jsdom: '*'
|
jsdom: '*'
|
||||||
vite: '>=7.3.2'
|
vite: '>=7.3.2'
|
||||||
@@ -8415,6 +8429,10 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
'@vitest/browser-webdriverio':
|
'@vitest/browser-webdriverio':
|
||||||
optional: true
|
optional: true
|
||||||
|
'@vitest/coverage-istanbul':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
optional: true
|
||||||
'@vitest/ui':
|
'@vitest/ui':
|
||||||
optional: true
|
optional: true
|
||||||
happy-dom:
|
happy-dom:
|
||||||
@@ -9187,6 +9205,12 @@ snapshots:
|
|||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
|
'@babel/code-frame@7.29.7':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
|
js-tokens: 4.0.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
|
||||||
'@babel/compat-data@7.29.0': {}
|
'@babel/compat-data@7.29.0': {}
|
||||||
|
|
||||||
'@babel/core@7.28.6':
|
'@babel/core@7.28.6':
|
||||||
@@ -9351,6 +9375,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5': {}
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7': {}
|
||||||
|
|
||||||
'@babel/helper-validator-option@7.27.1': {}
|
'@babel/helper-validator-option@7.27.1': {}
|
||||||
|
|
||||||
'@babel/helper-wrap-function@7.28.3':
|
'@babel/helper-wrap-function@7.28.3':
|
||||||
@@ -9942,6 +9968,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.29.2': {}
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
@@ -12186,8 +12214,8 @@ snapshots:
|
|||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.7
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.7
|
||||||
'@types/aria-query': 5.0.4
|
'@types/aria-query': 5.0.4
|
||||||
aria-query: 5.3.0
|
aria-query: 5.3.0
|
||||||
dom-accessibility-api: 0.5.16
|
dom-accessibility-api: 0.5.16
|
||||||
@@ -12767,7 +12795,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@vitest/coverage-v8@4.1.6(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)))':
|
'@vitest/coverage-v8@4.1.6(vitest@4.1.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
'@vitest/utils': 4.1.6
|
'@vitest/utils': 4.1.6
|
||||||
@@ -12779,52 +12807,46 @@ snapshots:
|
|||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 4.0.0
|
std-env: 4.0.0
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
||||||
|
|
||||||
'@vitest/expect@4.1.0':
|
'@vitest/expect@4.1.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/spy': 4.1.0
|
'@vitest/spy': 4.1.8
|
||||||
'@vitest/utils': 4.1.0
|
'@vitest/utils': 4.1.8
|
||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@vitest/mocker@4.1.0(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))':
|
'@vitest/mocker@4.1.8(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.0
|
'@vitest/spy': 4.1.8
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)
|
vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.0':
|
|
||||||
dependencies:
|
|
||||||
tinyrainbow: 3.1.0
|
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.6':
|
'@vitest/pretty-format@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@vitest/runner@4.1.0':
|
'@vitest/pretty-format@4.1.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/utils': 4.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/runner@4.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 4.1.8
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
'@vitest/snapshot@4.1.0':
|
'@vitest/snapshot@4.1.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/pretty-format': 4.1.0
|
'@vitest/pretty-format': 4.1.8
|
||||||
'@vitest/utils': 4.1.0
|
'@vitest/utils': 4.1.8
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
'@vitest/spy@4.1.0': {}
|
'@vitest/spy@4.1.8': {}
|
||||||
|
|
||||||
'@vitest/utils@4.1.0':
|
|
||||||
dependencies:
|
|
||||||
'@vitest/pretty-format': 4.1.0
|
|
||||||
convert-source-map: 2.0.0
|
|
||||||
tinyrainbow: 3.1.0
|
|
||||||
|
|
||||||
'@vitest/utils@4.1.6':
|
'@vitest/utils@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12832,6 +12854,12 @@ snapshots:
|
|||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 4.1.8
|
||||||
|
convert-source-map: 2.0.0
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@webassemblyjs/helper-numbers': 1.13.2
|
'@webassemblyjs/helper-numbers': 1.13.2
|
||||||
@@ -13874,7 +13902,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
dompurify@3.4.0:
|
dompurify@3.4.10:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
@@ -15940,7 +15968,7 @@ snapshots:
|
|||||||
|
|
||||||
monaco-editor@0.55.1:
|
monaco-editor@0.55.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
dompurify: 3.4.0
|
dompurify: 3.4.10
|
||||||
marked: 14.0.0
|
marked: 14.0.0
|
||||||
|
|
||||||
morgan@1.10.1:
|
morgan@1.10.1:
|
||||||
@@ -17690,15 +17718,15 @@ snapshots:
|
|||||||
terser: 5.44.1
|
terser: 5.44.1
|
||||||
yaml: 2.8.3
|
yaml: 2.8.3
|
||||||
|
|
||||||
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)):
|
vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.0
|
'@vitest/expect': 4.1.8
|
||||||
'@vitest/mocker': 4.1.0(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
'@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))
|
||||||
'@vitest/pretty-format': 4.1.0
|
'@vitest/pretty-format': 4.1.8
|
||||||
'@vitest/runner': 4.1.0
|
'@vitest/runner': 4.1.8
|
||||||
'@vitest/snapshot': 4.1.0
|
'@vitest/snapshot': 4.1.8
|
||||||
'@vitest/spy': 4.1.0
|
'@vitest/spy': 4.1.8
|
||||||
'@vitest/utils': 4.1.0
|
'@vitest/utils': 4.1.8
|
||||||
es-module-lexer: 2.0.0
|
es-module-lexer: 2.0.0
|
||||||
expect-type: 1.3.0
|
expect-type: 1.3.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
@@ -17708,13 +17736,14 @@ snapshots:
|
|||||||
std-env: 4.0.0
|
std-env: 4.0.0
|
||||||
tinybench: 2.9.0
|
tinybench: 2.9.0
|
||||||
tinyexec: 1.0.4
|
tinyexec: 1.0.4
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.16
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)
|
vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@types/node': 25.5.0
|
'@types/node': 25.5.0
|
||||||
|
'@vitest/coverage-v8': 4.1.6(vitest@4.1.8)
|
||||||
jsdom: 29.0.0(@noble/hashes@1.8.0)
|
jsdom: 29.0.0(@noble/hashes@1.8.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@echo off
|
||||||
|
REM File: e:/np-dms/lcbp3/run-codebase-dashboards.bat
|
||||||
|
REM Change Log:
|
||||||
|
REM - 2026-06-13: สร้างสคริปต์สำหรับเปิดแดชบอร์ดแผนที่โค้ด (Codebase Map) แยกหน้าต่างอัตโนมัติ
|
||||||
|
|
||||||
|
echo ==========================================================
|
||||||
|
echo Starting Codebase Map Dashboards (Understand)
|
||||||
|
echo ==========================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/2] กำลังเปิดแดชบอร์ดฝั่ง Backend (พอร์ต 5173)...
|
||||||
|
start "Backend Map Dashboard" cmd /c "set GRAPH_DIR=e:\np-dms\lcbp3\backend\src && cd /d C:\Users\peanc\.understand-anything\repo\understand-anything-plugin\packages\dashboard && npx vite --host 127.0.0.1 --port 5173"
|
||||||
|
|
||||||
|
echo [2/2] กำลังเปิดแดชบอร์ดฝั่ง Frontend (พอร์ต 5174)...
|
||||||
|
start "Frontend Map Dashboard" cmd /c "set GRAPH_DIR=e:\np-dms\lcbp3\frontend && cd /d C:\Users\peanc\.understand-anything\repo\understand-anything-plugin\packages\dashboard && npx vite --host 127.0.0.1 --port 5174"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ----------------------------------------------------------
|
||||||
|
echo แดชบอร์ดทั้งสองฝั่งกำลังเปิดทำงานในหน้าต่าง cmd แยกต่างหาก
|
||||||
|
echo กรุณาตรวจสอบ Token จากแต่ละหน้าต่างเพื่อเข้าใช้งานผ่านเว็บเบราว์เซอร์
|
||||||
|
echo ----------------------------------------------------------
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- File: specs/03-Data-and-Storage/deltas/2026-06-14-create-ai-execution-profiles.sql
|
||||||
|
-- Change Log:
|
||||||
|
-- - 2026-06-14: Created ai_execution_profiles and ai_sandbox_profiles tables (conforming to task T001)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_execution_profiles (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ภายใน',
|
||||||
|
profile_name VARCHAR(50) NOT NULL COMMENT 'ชื่อ profile',
|
||||||
|
canonical_model VARCHAR(20) NOT NULL DEFAULT 'np-dms-ai' COMMENT 'Model identity',
|
||||||
|
temperature DECIMAL(4,3) NOT NULL COMMENT 'LLM temperature',
|
||||||
|
top_p DECIMAL(4,3) NOT NULL COMMENT 'LLM top_p',
|
||||||
|
max_tokens INT NULL COMMENT 'Maximum tokens',
|
||||||
|
num_ctx INT NULL COMMENT 'Context window size',
|
||||||
|
repeat_penalty DECIMAL(5,3) NOT NULL COMMENT 'Repeat penalty',
|
||||||
|
keep_alive_seconds INT NOT NULL COMMENT 'Model keep_alive in seconds',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1 = active; 0 = disabled',
|
||||||
|
updated_by INT NULL COMMENT 'user_id',
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_profile_name (profile_name),
|
||||||
|
INDEX idx_profile_active (profile_name, is_active),
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES users(user_id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
||||||
|
COMMENT = 'ตาราง execution profile parameters สำหรับ np-dms-ai';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_sandbox_profiles (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ภายใน',
|
||||||
|
profile_name VARCHAR(50) NOT NULL COMMENT 'ชื่อ profile',
|
||||||
|
canonical_model VARCHAR(20) NOT NULL DEFAULT 'np-dms-ai' COMMENT 'Model identity',
|
||||||
|
temperature DECIMAL(4,3) NOT NULL COMMENT 'LLM temperature',
|
||||||
|
top_p DECIMAL(4,3) NOT NULL COMMENT 'LLM top_p',
|
||||||
|
max_tokens INT NULL COMMENT 'Maximum tokens',
|
||||||
|
num_ctx INT NULL COMMENT 'Context window size',
|
||||||
|
repeat_penalty DECIMAL(5,3) NOT NULL COMMENT 'Repeat penalty',
|
||||||
|
keep_alive_seconds INT NOT NULL COMMENT 'Model keep_alive in seconds',
|
||||||
|
updated_by INT NULL COMMENT 'user_id',
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_ai_sandbox_profile_name (profile_name),
|
||||||
|
INDEX idx_ai_sandbox_profile_model (canonical_model),
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES users(user_id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
||||||
|
COMMENT = 'ตาราง sandbox profile parameters';
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- File: specs/03-Data-and-Storage/deltas/2026-06-14-seed-additional-prompt-types.sql
|
||||||
|
-- Change Log:
|
||||||
|
-- - 2026-06-14: Seed additional prompt types for RAG Query, RAG Prep, and Classification (conforming to task T003)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
public_id,
|
||||||
|
prompt_type,
|
||||||
|
version_number,
|
||||||
|
template,
|
||||||
|
field_schema,
|
||||||
|
context_config,
|
||||||
|
is_active,
|
||||||
|
manual_note,
|
||||||
|
activated_at,
|
||||||
|
created_by
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
UUID(),
|
||||||
|
'rag_query_prompt',
|
||||||
|
1,
|
||||||
|
'You are a professional assistant analyzing project documents. Based on the provided context, answer the user query.\n\nContext:\n{{context}}\n\nUser Query:\n{{ocr_text}}\n\nAnswer:',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
'Initial seed for RAG query prompt',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
(SELECT user_id FROM users WHERE username = 'superadmin' LIMIT 1)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
UUID(),
|
||||||
|
'rag_prep_prompt',
|
||||||
|
1,
|
||||||
|
'Analyze the following OCR text and prepare chunks for retrieval database.\n\nOCR TEXT:\n{{ocr_text}}\n\nChunks:',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
'Initial seed for RAG prep prompt',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
(SELECT user_id FROM users WHERE username = 'superadmin' LIMIT 1)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
UUID(),
|
||||||
|
'classification_prompt',
|
||||||
|
1,
|
||||||
|
'Classify the following document based on its OCR text.\n\nOCR TEXT:\n{{ocr_text}}\n\nClassification (Correspondence, Transmittal, Circulation, RFA, Shop Drawing, Contract Drawing):',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
'Initial seed for Classification prompt',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
(SELECT user_id FROM users WHERE username = 'superadmin' LIMIT 1)
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
prompt_type = prompt_type;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- File: specs/03-Data-and-Storage/deltas/2026-06-14-seed-execution-profiles.sql
|
||||||
|
-- Change Log:
|
||||||
|
-- - 2026-06-14: Seed default profiles for execution profiles (conforming to task T002)
|
||||||
|
|
||||||
|
INSERT INTO ai_execution_profiles (
|
||||||
|
profile_name, canonical_model, temperature, top_p, max_tokens, num_ctx, repeat_penalty, keep_alive_seconds, is_active
|
||||||
|
) VALUES
|
||||||
|
('interactive', 'np-dms-ai', 0.700, 0.900, 2048, 4096, 1.150, 300, 1),
|
||||||
|
('standard', 'np-dms-ai', 0.500, 0.800, 4096, 8192, 1.150, 600, 1),
|
||||||
|
('quality', 'np-dms-ai', 0.100, 0.950, 8192, 8192, 1.150, 600, 1),
|
||||||
|
('deep-analysis', 'np-dms-ai', 0.300, 0.850, 8192, 32768, 1.150, 0, 1),
|
||||||
|
('ocr-extract', 'np-dms-ocr', 0.100, 0.100, NULL, NULL, 1.100, 0, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
canonical_model = VALUES(canonical_model),
|
||||||
|
temperature = VALUES(temperature),
|
||||||
|
top_p = VALUES(top_p),
|
||||||
|
max_tokens = VALUES(max_tokens),
|
||||||
|
num_ctx = VALUES(num_ctx),
|
||||||
|
repeat_penalty = VALUES(repeat_penalty),
|
||||||
|
keep_alive_seconds = VALUES(keep_alive_seconds),
|
||||||
|
is_active = VALUES(is_active);
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# ADR-037: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Status:** Draft
|
||||||
|
**Date:** 2026-06-14
|
||||||
|
**Decision Makers:** Development Team, System Architect
|
||||||
|
**Supersedes:** ADR-029: Dynamic Prompt Management (extends prompt_type scope)
|
||||||
|
**Related Documents:**
|
||||||
|
- [ADR-027: AI Admin Console and Dynamic Control](./ADR-027-ai-admin-console-and-dynamic-control.md)
|
||||||
|
- [ADR-030: Context-Aware Prompt Templates](./ADR-030-context-aware-prompt-templates.md)
|
||||||
|
- [ADR-036: Unified AI Model Architecture](./ADR-036-unified-ocr-architecture.md)
|
||||||
|
- [ADR-035: AI Pipeline Flow Architecture](./ADR-035-ai-pipeline-flow-architecture.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## บริบทและปัญหา (Context and Problem Statement)
|
||||||
|
|
||||||
|
ADR-029 กำหนด architecture สำหรับ prompt management (ai_prompts table, versioning, activation) แต่จำกัด scope ไว้ที่ prompt_type='ocr_extraction' เดียว และไม่ได้ระบุ UX/UI อย่างละเอียด ทำให้เกิดปัญหาดังนี้:
|
||||||
|
|
||||||
|
1. **Version History สับสน:** ไม่แยกระหว่าง prompt types (OCR vs AI) ทำให้ admin ไม่รู้ว่า version ไหนใช้สำหรับ workflow ไหน
|
||||||
|
2. **Sandbox Workflow ไม่ตรง Production:** ปัจจุบันใช้ 2-step flow (OCR → AI Extract) แต่ production ใช้ 3-step flow (OCR → Extract → RAG Prep) ทำให้ sandbox ทดสอบไม่ครบ
|
||||||
|
3. **Context Config UI ขาดไป:** ADR-030 กำหนด context_config แต่ไม่มี UI สำหรับ View/Edit/Save/Apply ทำให้ไม่สามารถจัดการ context ได้
|
||||||
|
4. **Config Types ไม่เคลียร์:** Admin สับสนระหว่าง 3 config types:
|
||||||
|
- Runtime Parameters (temperature, topP, ฯลฯ) - คุม AI model behavior
|
||||||
|
- Context Config (projectId, contractId, ฯลฯ) - คุม data context
|
||||||
|
- System Prompt (AI role/instruction) - คุม AI บทบาท
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ปัจัยขับเคลื่อนการตัดสินใจ (Decision Drivers)
|
||||||
|
|
||||||
|
- **Multi-Type Support:** ระบบต้องรองรับหลาย prompt types (ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt) ตาม ADR-035 flows
|
||||||
|
- **Sandbox-Production Parity:** Sandbox ต้องทดสอบทั้ง pipeline ที่ใช้ใน production (OCR → Extract → RAG Prep) เพื่อให้ผลลัพธ์ตรงความจริง
|
||||||
|
- **Context Config Management:** Admin ต้องสามารถ View/Edit/Save/Apply context_config ได้ผ่าน UI
|
||||||
|
- **Clear Separation:** 3 config types (Runtime Parameters, Context Config, System Prompt) ต้องแยก UI ชัดเจนเพื่อลดความสับสน
|
||||||
|
- **Single Page Layout:** ตาม ADR-027, AI Admin Console ควรเป็น single page layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ทางเลือกที่ถูกพิจารณา (Considered Options)
|
||||||
|
|
||||||
|
### Option 1: แยกหน้าตาม Prompt Type
|
||||||
|
- **ข้อดี:** UI เรียบง่าย แต่ละ prompt type มีหน้าของตัวเอง
|
||||||
|
- **ข้อเสีย:** ไม่ consistent กับ ADR-027 (single page), ยากต่อการเปรียบเทียบระหว่าง types
|
||||||
|
|
||||||
|
### Option 2: Single Page พร้อม Prompt Type Dropdown (ตัวเลือกที่ได้รับเลือก)
|
||||||
|
- **ข้อดี:** Consistent กับ ADR-027, ง่ายต่อการเปรียบเทียบ, ลดจำนวน components
|
||||||
|
- **ข้อเสีย:** UI ซับซ้อนขึ้นเล็กน้อย (ต้องจัดการ state หลาย prompt types)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ผลการตัดสินใจ (Decision Outcome)
|
||||||
|
|
||||||
|
**ทางเลือกที่ได้รับเลือก:** Option 2 — Single Page พร้อม Prompt Type Dropdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ข้อตกลงหลัก (Core Decisions — Grilling Session 2026-06-14)
|
||||||
|
|
||||||
|
| # | ประเด็น | การตัดสินใจ |
|
||||||
|
|---|---------|-------------|
|
||||||
|
| 1 | Prompt Type Scope | รองรับ 4 types: ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt |
|
||||||
|
| 2 | Sandbox Workflow | Hybrid flow: OCR → Extract → Optional Review → RAG Prep |
|
||||||
|
| 3 | UX/UI Layout | Single Page พร้อม Prompt Type Dropdown |
|
||||||
|
| 4 | Context Config UI | View/Edit/Save/Apply ครบถ้วน |
|
||||||
|
| 5 | Runtime Parameters UI | แยกจาก Context Config UI ชัดเจน |
|
||||||
|
| 6 | Version History | แยกตาม prompt_type, แสดง active badge, test result |
|
||||||
|
| 7 | Sandbox to Production | สามารถส่งต่อผลลัพธ์ sandbox ไป activate version ได้ |
|
||||||
|
| 8 | Supersede ADR-029 | ใช่ - ขยาย prompt_type scope จากเดียวเป็นหลาย types |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## รายละเอียดเชิงสถาปัตยกรรม (Implementation Details)
|
||||||
|
|
||||||
|
### 1. Database Schema Changes (ADR-009)
|
||||||
|
|
||||||
|
**Table: ai_prompts** (มีอยู่แล้วจาก ADR-029, ไม่ต้องเปลี่ยน)
|
||||||
|
- `prompt_type` VARCHAR(50) - รองรับหลาย types
|
||||||
|
- `context_config` JSON NULL - เพิ่มจาก ADR-030 (มีอยู่แล้ว)
|
||||||
|
|
||||||
|
**Seed Data:**
|
||||||
|
```sql
|
||||||
|
-- OCR Extraction Prompt (มีอยู่แล้วจาก ADR-029)
|
||||||
|
INSERT INTO ai_prompts (prompt_type, version_number, template, context_config, is_active, created_by)
|
||||||
|
VALUES ('ocr_extraction', 1, '<template with {{ocr_text}} and {{master_data_context}}>',
|
||||||
|
'{"filter": null, "pageSize": 3, "language": "th", "outputLanguage": "th"}',
|
||||||
|
1, 1);
|
||||||
|
|
||||||
|
-- RAG Query Prompt (ใหม่)
|
||||||
|
INSERT INTO ai_prompts (prompt_type, version_number, template, context_config, is_active, created_by)
|
||||||
|
VALUES ('rag_query_prompt', 1, '<template for RAG Q&A>',
|
||||||
|
'{"filter": null, "language": "th"}',
|
||||||
|
1, 1);
|
||||||
|
|
||||||
|
-- RAG Prep Prompt (ใหม่)
|
||||||
|
INSERT INTO ai_prompts (prompt_type, version_number, template, context_config, is_active, created_by)
|
||||||
|
VALUES ('rag_prep_prompt', 1, '<template for Semantic Chunking>',
|
||||||
|
'{"filter": null, "language": "th"}',
|
||||||
|
1, 1);
|
||||||
|
|
||||||
|
-- Classification Prompt (ใหม่)
|
||||||
|
INSERT INTO ai_prompts (prompt_type, version_number, template, context_config, is_active, created_by)
|
||||||
|
VALUES ('classification_prompt', 1, '<template for document classification>',
|
||||||
|
'{"filter": null, "language": "th"}',
|
||||||
|
1, 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend API Endpoints
|
||||||
|
|
||||||
|
**Existing Endpoints (จาก ADR-029):**
|
||||||
|
- `GET /api/ai/prompts/:type` - ดึง all versions ของ prompt_type
|
||||||
|
- `POST /api/ai/prompts/:type` - สร้าง version ใหม่
|
||||||
|
- `DELETE /api/ai/prompts/:type/:version` - ลบ version
|
||||||
|
- `POST /api/ai/prompts/:type/:version/activate` - Activate version
|
||||||
|
- `PATCH /api/ai/prompts/:type/:version/note` - บันทึก manual_note
|
||||||
|
|
||||||
|
**New Endpoints (สำหรับ Context Config):**
|
||||||
|
- `GET /api/ai/prompts/:type/:version/context-config` - ดึง context_config ของ version
|
||||||
|
- `PUT /api/ai/prompts/:type/:version/context-config` - อัปเดต context_config
|
||||||
|
|
||||||
|
**Sandbox Endpoints (อัปเดตจาก ADR-035):**
|
||||||
|
- `POST /api/ai/admin/sandbox/ocr` - Step 1: OCR (มีอยู่แล้ว)
|
||||||
|
- `POST /api/ai/admin/sandbox/ai-extract` - Step 2: AI Extract (มีอยู่แล้ว)
|
||||||
|
- `POST /api/ai/admin/sandbox/rag-prep` - Step 3: RAG Prep (ใหม่)
|
||||||
|
|
||||||
|
### 3. Frontend UX/UI Layout
|
||||||
|
|
||||||
|
**Single Page Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ AI Admin Console - Prompt Management │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Prompt Type: [Dropdown: OCR Extraction ▼] │
|
||||||
|
├──────────────────────┬──────────────────────────────────────┤
|
||||||
|
│ Left Panel │ Right Panel │
|
||||||
|
│ ┌────────────────┐ │ ┌────────────────────────────────┐ │
|
||||||
|
│ │ Version History│ │ │ Prompt Editor + Context Config │ │
|
||||||
|
│ │ v3 (active) ✅ │ │ │ ┌────────────────────────────┐ │ │
|
||||||
|
│ │ v2 - 2026-05-24│ │ │ │ Prompt Template Editor │ │ │
|
||||||
|
│ │ v1 - 2026-05-22│ │ │ └────────────────────────────┘ │ │
|
||||||
|
│ └────────────────┘ │ │ ┌────────────────────────────┐ │ │
|
||||||
|
│ │ │ │ Context Config Editor │ │ │
|
||||||
|
│ │ │ │ - Project Filter │ │ │
|
||||||
|
│ │ │ │ - Contract Filter │ │ │
|
||||||
|
│ │ │ │ - Page Size │ │ │
|
||||||
|
│ │ │ │ - Language │ │ │
|
||||||
|
│ │ │ └────────────────────────────┘ │ │
|
||||||
|
│ │ │ [Save New Version] [Activate] │ │
|
||||||
|
│ │ └────────────────────────────────┘ │
|
||||||
|
├──────────────────────┴──────────────────────────────────────┤
|
||||||
|
│ Sandbox Tabs │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [OCR] [Extract] [RAG Prep] │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Sandbox Test Area │ │
|
||||||
|
│ │ - Upload PDF │ │
|
||||||
|
│ │ - Select Project/Contract │ │
|
||||||
|
│ │ - Run Test │ │
|
||||||
|
│ │ - View Results │ │
|
||||||
|
│ │ - [Activate This Version] │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- **PromptTypeDropdown:** เลือก prompt_type (ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt)
|
||||||
|
- **VersionHistory:** แสดง versions ของ prompt_type ที่เลือก (แยกตาม type)
|
||||||
|
- **PromptEditor:** Textarea สำหรับแก้ prompt template (validate `{{ocr_text}}` หรือ placeholders อื่นๆ)
|
||||||
|
- **ContextConfigEditor:** Form สำหรับ edit context_config (projectId, contractId, pageSize, language)
|
||||||
|
- **SandboxTabs:** Tabs สำหรับทดสอบแต่ละ step (OCR, Extract, RAG Prep)
|
||||||
|
- **RuntimeParametersPanel:** Sliders สำหรับ runtime parameters (temperature, topP, repeatPenalty, ฯลฯ) - แยกจาก Context Config
|
||||||
|
|
||||||
|
### 4. Sandbox Workflow (Hybrid Flow)
|
||||||
|
|
||||||
|
**Step 1: OCR**
|
||||||
|
```
|
||||||
|
Admin Upload PDF
|
||||||
|
→ POST /api/ai/admin/sandbox/ocr
|
||||||
|
→ BullMQ (ai-realtime) job type: "sandbox-ocr-only"
|
||||||
|
→ OcrService → Sidecar (typhoon-np-dms-ocr)
|
||||||
|
→ Raw OCR text
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: AI Extract**
|
||||||
|
```
|
||||||
|
Admin Select Prompt Version
|
||||||
|
→ POST /api/ai/admin/sandbox/ai-extract
|
||||||
|
→ BullMQ (ai-realtime) job type: "sandbox-ai-extract"
|
||||||
|
→ Load prompt from ai_prompts (selected version)
|
||||||
|
→ OllamaService → typhoon2.5-np-dms
|
||||||
|
→ Structured metadata (JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: RAG Prep (Optional)**
|
||||||
|
```
|
||||||
|
Admin Click "Test RAG Prep" (optional)
|
||||||
|
→ POST /api/ai/admin/sandbox/rag-prep
|
||||||
|
→ BullMQ (ai-realtime) job type: "sandbox-rag-prep"
|
||||||
|
→ OllamaService → typhoon2.5-np-dms (Semantic Chunking)
|
||||||
|
→ Sidecar → BGE-M3 (Embedding)
|
||||||
|
→ Chunks + Vectors
|
||||||
|
```
|
||||||
|
|
||||||
|
**Activate to Production:**
|
||||||
|
```
|
||||||
|
Admin Click "Activate This Version"
|
||||||
|
→ POST /api/ai/prompts/:type/:version/activate
|
||||||
|
→ Update is_active flag
|
||||||
|
→ Invalidate Redis cache
|
||||||
|
→ Production jobs use new version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Three Config Types Separation
|
||||||
|
|
||||||
|
**Runtime Parameters Panel (ADR-036):**
|
||||||
|
- อยู่ใน Sandbox Tab
|
||||||
|
- Sliders: Temperature, Top-P, Repeat Penalty, Max Tokens, Ctx Size, Keep-Alive
|
||||||
|
- เก็บใน `ai_execution_profiles` (global per profile)
|
||||||
|
- Apply workflow: Sandbox draft → Apply to Production
|
||||||
|
- วัตถุประสงค์: คุม AI model behavior (LLM ตอบสนองอย่างไร)
|
||||||
|
|
||||||
|
**Context Config Editor (ADR-030):**
|
||||||
|
- อยู่ใน Prompt Editor Panel
|
||||||
|
- Form: Project Filter, Contract Filter, Page Size, Language
|
||||||
|
- เก็บใน `ai_prompts` (per prompt version)
|
||||||
|
- Apply workflow: Save version → Activate version
|
||||||
|
- วัตถุประสงค์: คุม data context ที่ AI เห็น (master data อะไรบ้าง)
|
||||||
|
|
||||||
|
**System Prompt Editor (ADR-029 + ADR-036):**
|
||||||
|
- อยู่ใน Prompt Editor Panel (รวมกับ Prompt Template)
|
||||||
|
- Textarea: System instruction สำหรับ AI model (เช่น "คุณคือเอนจิ้นสกัดข้อมูล...")
|
||||||
|
- เก็บใน `ai_prompts` (per prompt version) - เป็นส่วนหนึ่งของ `template` field
|
||||||
|
- Apply workflow: Save version → Activate version
|
||||||
|
- วัตถุประสงค์: กำหนดบทบาทและโครงสร้างผลลัพธ์ของ AI
|
||||||
|
|
||||||
|
**Summary Table:**
|
||||||
|
|
||||||
|
| Config Type | Table | Per Version? | Purpose | Apply Workflow |
|
||||||
|
|-------------|-------|--------------|---------|----------------|
|
||||||
|
| Runtime Parameters | `ai_execution_profiles` | ไม่ (global) | คุม AI model behavior | Sandbox draft → Production |
|
||||||
|
| Context Config | `ai_prompts` | ใช่ | คุม data context | Save version → Activate |
|
||||||
|
| System Prompt | `ai_prompts` (template) | ใช่ | คุม AI role/instruction | Save version → Activate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ผลกระทบ (Consequences)
|
||||||
|
|
||||||
|
### ผลดี
|
||||||
|
- Admin จัดการ prompt ได้ครบทุก type (OCR, RAG, Classification)
|
||||||
|
- Sandbox ทดสอบครบ pipeline ตรงกับ production
|
||||||
|
- Context config มี UI ครบถ้วน (View/Edit/Save/Apply)
|
||||||
|
- Runtime Parameters กับ Context Config แยกชัดเจน
|
||||||
|
- Single page layout consistent กับ ADR-027
|
||||||
|
|
||||||
|
### ผลเสีย / ข้อระวัง
|
||||||
|
- UI ซับซ้อนขึ้นเล็กน้อย (ต้องจัดการ state หลาย prompt types)
|
||||||
|
- ต้องเพิ่ม backend endpoints สำหรับ context config CRUD
|
||||||
|
- ต้องเพิ่ม sandbox endpoint สำหรับ RAG Prep
|
||||||
|
- ต้อง seed data สำหรับ prompt types ใหม่
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### Phase 1: Database
|
||||||
|
1. Run seed data สำหรับ prompt types ใหม่ (rag_query_prompt, rag_prep_prompt, classification_prompt)
|
||||||
|
2. Verify context_config column มีอยู่แล้ว (จาก ADR-030)
|
||||||
|
|
||||||
|
### Phase 2: Backend
|
||||||
|
1. เพิ่ม endpoints สำหรับ context config CRUD
|
||||||
|
2. เพิ่ม sandbox endpoint สำหรับ RAG Prep
|
||||||
|
3. Update AiPromptsService รองรับหลาย prompt types
|
||||||
|
|
||||||
|
### Phase 3: Frontend
|
||||||
|
1. สร้าง PromptTypeDropdown component
|
||||||
|
2. อัปเดต VersionHistory แยกตาม prompt_type
|
||||||
|
3. สร้าง ContextConfigEditor component
|
||||||
|
4. อัปเดต SandboxTabs เพิ่ม RAG Prep tab
|
||||||
|
5. แยก Runtime Parameters Panel ออกจาก Context Config Editor
|
||||||
|
|
||||||
|
### Phase 4: Testing
|
||||||
|
1. Unit tests สำหรับ context config CRUD
|
||||||
|
2. Integration tests สำหรับ sandbox RAG Prep
|
||||||
|
3. E2E tests สำหรับ full workflow (OCR → Extract → RAG Prep → Activate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grilling Session Log
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-06-14 — grilling session ผ่าน Devin Cascade
|
||||||
|
Q1: ปัญหาหลัก → B + C (UX ไม่ครอบคลุม + ใช้งานยาก)
|
||||||
|
Q2: UX ปัจจุบันอยู่ที่ไหน → OcrSandboxPromptManager + PromptVersionHistory
|
||||||
|
Q3: Context config คืออะไร → JSON column ใน ai_prompts (filter, pageSize, language)
|
||||||
|
Q4: Context config ใช้ที่ไหน → ทั้ง sandbox และ production
|
||||||
|
Q5: Context config เก็บที่ไหน → A (ใน ai_prompts per version)
|
||||||
|
Q6: ปัญหา UX ละเอียด → 6.1 Version ไม่แยก OCR/AI, 6.2 2-step flow ไม่เหมือ production, 6.3 Context config UI ขาด
|
||||||
|
Q7: Prompt type รองรับอะไร → D (ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt)
|
||||||
|
Q8: Sandbox workflow เพิ่มอะไร → A (RAG Prep step)
|
||||||
|
Q9: UX layout อย่างไร → A (Single Page พร้อม Dropdown + ส่งต่อผลลัพธ์)
|
||||||
|
Q10: Sandbox workflow อย่างไร → C (Hybrid: OCR → Extract → Optional Review → RAG Prep)
|
||||||
|
Q11: ขัดแย้ง ADR-029 → สร้าง ADR ใหม่ supersede
|
||||||
|
Q12: ขัดแย้ง ADR-027 → ไม่ขัดแย้ง (single page ยังใช้ได้)
|
||||||
|
Q13: ขัดแย้ง ADR-036 → ไม่ขัดแย้ง (runtime vs context แยก concern)
|
||||||
|
Q14: Runtime vs Context แตกต่างอย่างไร → Runtime คุม AI, Context คุม data
|
||||||
|
Q15: ADR ชื่ออะไร → A (ADR-037: Unified Prompt Management UX/UI)
|
||||||
|
Q16: Scope อะไร → C (UX/UI + Backend API + Database)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related ADRs
|
||||||
|
|
||||||
|
- **ADR-029:** Dynamic Prompt Management (superseded - extends prompt_type scope)
|
||||||
|
- **ADR-027:** AI Admin Console and Dynamic Control (single page layout)
|
||||||
|
- **ADR-030:** Context-Aware Prompt Templates (context_config architecture)
|
||||||
|
- **ADR-036:** Unified AI Model Architecture (runtime parameters management)
|
||||||
|
- **ADR-035:** AI Pipeline Flow Architecture (sandbox + production flows)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Code Review Report
|
||||||
|
|
||||||
|
**Date**: 2026-06-14
|
||||||
|
**Scope**: Working tree for `specs/200-fullstacks/237-unified-prompt-management-ux-ui` plus related modified files. No staged changes found.
|
||||||
|
**Overall**: REQUEST CHANGES
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Severity | Count |
|
||||||
|
| --- | ---: |
|
||||||
|
| Critical | 1 |
|
||||||
|
| High | 2 |
|
||||||
|
| Medium | 2 |
|
||||||
|
| Low | 0 |
|
||||||
|
| Suggestions | 0 |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### HIGH: Backend build is currently broken
|
||||||
|
|
||||||
|
`pnpm --filter backend build` fails with 10 TypeScript errors in `backend/src/modules/rfa/rfa.service.ts`. This blocks merge even if Feature 237 code compiles.
|
||||||
|
|
||||||
|
Key examples:
|
||||||
|
|
||||||
|
- `backend/src/modules/rfa/rfa.service.ts:457`: `RfaService.WORKFLOW_CODE` is referenced but not defined.
|
||||||
|
- `backend/src/modules/rfa/rfa.service.ts:700`: `templateRepo` is still used after being removed from constructor injection.
|
||||||
|
- `backend/src/modules/rfa/rfa.service.ts:748`: `CorrespondenceRouting` is still used after its import was removed.
|
||||||
|
- `backend/src/modules/rfa/rfa.service.ts:753`: the code appears corrupted: `firstStep.toOrganizatioTransaction();`.
|
||||||
|
|
||||||
|
**Fix**: Either complete the ADR-001/021 RFA migration in this file, or isolate/revert this unrelated partial change before reviewing Feature 237 for merge.
|
||||||
|
|
||||||
|
### CRITICAL: Context filtering can leak cross-project master data
|
||||||
|
|
||||||
|
`backend/src/modules/ai/prompts/ai-prompts.service.ts:59` converts `contextConfig.filter.projectId` with `Number(...)`, while the frontend sends `publicId` UUID strings from `frontend/components/admin/ai/ContextConfigEditor.tsx:120`. A UUID becomes `NaN`; without an override, the later `if (targetProjectId)` checks do not apply project filtering, so AI context can include all projects/orgs/tags.
|
||||||
|
|
||||||
|
This violates ADR-019 and the AI multi-tenancy boundary.
|
||||||
|
|
||||||
|
**Fix**: Treat stored filters as `projectPublicId` / `contractPublicId` strings, validate with `@IsUUID()`, resolve them to internal IDs inside the service, and apply filters only after successful resolution. Add regression tests for "stored UUID filter without override restricts context".
|
||||||
|
|
||||||
|
### HIGH: New mutations do not enforce `Idempotency-Key`
|
||||||
|
|
||||||
|
Several new or affected mutating endpoints do not read or require `Idempotency-Key`, despite AGENTS/ADR-016 requiring it for critical `POST`/`PUT`/`PATCH`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `backend/src/modules/ai/prompts/ai-prompts.controller.ts:68`: create prompt version.
|
||||||
|
- `backend/src/modules/ai/prompts/ai-prompts.controller.ts:104`: activate prompt.
|
||||||
|
- `backend/src/modules/ai/prompts/ai-prompts.controller.ts:159`: update context config.
|
||||||
|
- `backend/src/modules/ai/ai.controller.ts:677`: sandbox RAG prep queues work but generates a new request ID every retry.
|
||||||
|
|
||||||
|
**Fix**: Require `@Headers('idempotency-key')`, reject missing keys with `ValidationException`, and use the header as the job/cache key where the operation is queueing or changing state. Frontend calls in `frontend/lib/services/admin-ai.service.ts` also need to send the header.
|
||||||
|
|
||||||
|
### MEDIUM: Prompt placeholder contract is inconsistent
|
||||||
|
|
||||||
|
The validator requires `rag_query_prompt` to include `{{query}}` and `{{context}}`, and `rag_prep_prompt` to include `{{text}}` in `backend/src/modules/ai/prompts/ai-prompts.service.ts:353`. But the new seed file inserts:
|
||||||
|
|
||||||
|
- `rag_query_prompt` with `{{context}}` and `{{ocr_text}}` at `specs/03-Data-and-Storage/deltas/2026-06-14-seed-additional-prompt-types.sql:21`.
|
||||||
|
- `rag_prep_prompt` with `{{ocr_text}}` at `specs/03-Data-and-Storage/deltas/2026-06-14-seed-additional-prompt-types.sql:33`.
|
||||||
|
- `classification_prompt` with `{{ocr_text}}` at `specs/03-Data-and-Storage/deltas/2026-06-14-seed-additional-prompt-types.sql:45`.
|
||||||
|
|
||||||
|
**Fix**: Choose one placeholder contract per prompt type and align seed, validation, and replacement code. Right now an admin cannot save a modified version of the seeded RAG prep/classification prompt under the service's own rules.
|
||||||
|
|
||||||
|
### MEDIUM: DTO validation is too weak for public identifiers and sandbox input
|
||||||
|
|
||||||
|
`backend/src/modules/ai/dto/context-config.dto.ts` uses plain `@IsString()` for project/contract identifiers and `@IsObject()` for nested filter, so nested validation does not run and UUID format is not enforced. `backend/src/modules/ai/dto/sandbox-rag-prep.dto.ts` accepts unbounded text.
|
||||||
|
|
||||||
|
**Fix**: Use `@ValidateNested()`, `@Type(() => ContextFilterDto)`, `@IsUUID()` for public IDs, whitelist language values, add max page size, and cap sandbox text length.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm --filter backend build` failed with 10 TypeScript errors.
|
||||||
|
- `pnpm --filter lcbp3-frontend exec tsc --noEmit` passed.
|
||||||
|
|
||||||
|
Merge should stay blocked until the backend build and the context-filter/idempotency issues are fixed.
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
# Backend API Contracts: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Feature**: 237-unified-prompt-management-ux-ui
|
||||||
|
**Date**: 2026-06-14
|
||||||
|
**Purpose**: Define API endpoints for prompt management with context config and sandbox testing
|
||||||
|
|
||||||
|
## Existing Endpoints (from ADR-029)
|
||||||
|
|
||||||
|
### GET /api/ai/prompts/:type
|
||||||
|
Get all versions of a specific prompt type.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `type` (path): Prompt type (ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"promptType": "ocr_extraction",
|
||||||
|
"versionNumber": 1,
|
||||||
|
"template": "string",
|
||||||
|
"contextConfig": { "filter": null, "pageSize": 3, "language": "th" },
|
||||||
|
"isActive": true,
|
||||||
|
"manualNote": "string|null",
|
||||||
|
"createdAt": "2026-06-14T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/ai/prompts/:type
|
||||||
|
Create a new version of a prompt type.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `type` (path): Prompt type
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"template": "string",
|
||||||
|
"contextConfig": { "filter": null, "pageSize": 3, "language": "th" },
|
||||||
|
"manualNote": "string|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "uuid",
|
||||||
|
"promptType": "ocr_extraction",
|
||||||
|
"versionNumber": 2,
|
||||||
|
"template": "string",
|
||||||
|
"contextConfig": { "filter": null, "pageSize": 3, "language": "th" },
|
||||||
|
"isActive": false,
|
||||||
|
"manualNote": "string|null",
|
||||||
|
"createdAt": "2026-06-14T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/ai/prompts/:type/:version
|
||||||
|
Delete a specific version (cannot delete active version).
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `type` (path): Prompt type
|
||||||
|
- `version` (path): Version number
|
||||||
|
|
||||||
|
**Response**: 204 No Content
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/ai/prompts/:type/:version/activate
|
||||||
|
Activate a specific version (deactivates other versions of same type).
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `type` (path): Prompt type
|
||||||
|
- `version` (path): Version number
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "uuid",
|
||||||
|
"promptType": "ocr_extraction",
|
||||||
|
"versionNumber": 1,
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PATCH /api/ai/prompts/:type/:version/note
|
||||||
|
Update manual note for a version.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `type` (path): Prompt type
|
||||||
|
- `version` (path): Version number
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manualNote": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "uuid",
|
||||||
|
"manualNote": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Endpoints (Context Config)
|
||||||
|
|
||||||
|
### GET /api/ai/prompts/:type/:version/context-config
|
||||||
|
Get context config for a specific version.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `type` (path): Prompt type
|
||||||
|
- `version` (path): Version number
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"filter": {
|
||||||
|
"projectId": "uuid|null",
|
||||||
|
"contractId": "uuid|null"
|
||||||
|
},
|
||||||
|
"pageSize": 3,
|
||||||
|
"language": "th",
|
||||||
|
"outputLanguage": "th"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PUT /api/ai/prompts/:type/:version/context-config
|
||||||
|
Update context config for a specific version.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `type` (path): Prompt type
|
||||||
|
- `version` (path): Version number
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": {
|
||||||
|
"projectId": "uuid|null",
|
||||||
|
"contractId": "uuid|null"
|
||||||
|
},
|
||||||
|
"pageSize": 3,
|
||||||
|
"language": "th",
|
||||||
|
"outputLanguage": "th"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"filter": {
|
||||||
|
"projectId": "uuid|null",
|
||||||
|
"contractId": "uuid|null"
|
||||||
|
},
|
||||||
|
"pageSize": 3,
|
||||||
|
"language": "th",
|
||||||
|
"outputLanguage": "th"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Endpoints (Runtime Parameters)
|
||||||
|
|
||||||
|
### GET /api/ai/execution-profiles
|
||||||
|
Get all execution profiles.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"profileName": "default",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"topP": 0.9,
|
||||||
|
"repeatPenalty": 1.0,
|
||||||
|
"maxTokens": 2048,
|
||||||
|
"ctxSize": 4096,
|
||||||
|
"keepAlive": 300,
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/ai/execution-profiles
|
||||||
|
Create a new execution profile.
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profileName": "custom",
|
||||||
|
"temperature": 0.8,
|
||||||
|
"topP": 0.95,
|
||||||
|
"repeatPenalty": 1.1,
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"ctxSize": 8192,
|
||||||
|
"keepAlive": 600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "uuid",
|
||||||
|
"profileName": "custom",
|
||||||
|
"temperature": 0.8,
|
||||||
|
"topP": 0.95,
|
||||||
|
"repeatPenalty": 1.1,
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"ctxSize": 8192,
|
||||||
|
"keepAlive": 600,
|
||||||
|
"isDefault": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PUT /api/ai/execution-profiles/:id
|
||||||
|
Update an execution profile.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `id` (path): Profile public ID
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"temperature": 0.75,
|
||||||
|
"topP": 0.92
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "uuid",
|
||||||
|
"temperature": 0.75,
|
||||||
|
"topP": 0.92
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/ai/execution-profiles/:id
|
||||||
|
Delete an execution profile (cannot delete default profile).
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `id` (path): Profile public ID
|
||||||
|
|
||||||
|
**Response**: 204 No Content
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Endpoints (Sandbox - RAG Prep)
|
||||||
|
|
||||||
|
### POST /api/ai/admin/sandbox/rag-prep
|
||||||
|
Run RAG Prep step in sandbox (semantic chunking + embedding).
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "string",
|
||||||
|
"profileId": "uuid|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"jobId": "uuid",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/ai/admin/sandbox/job/:jobId
|
||||||
|
Get sandbox job status and results.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `jobId` (path): Job ID
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"jobId": "uuid",
|
||||||
|
"jobType": "rag-prep",
|
||||||
|
"status": "completed",
|
||||||
|
"result": {
|
||||||
|
"ragChunks": [
|
||||||
|
{
|
||||||
|
"text": "string",
|
||||||
|
"summary": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ragVectors": "array",
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"createdAt": "2026-06-14T00:00:00Z",
|
||||||
|
"completedAt": "2026-06-14T00:00:05Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`
|
||||||
|
**Ability**: `system.manage_all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All endpoints follow ADR-007 error handling:
|
||||||
|
|
||||||
|
**Validation Error (400)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Validation failed",
|
||||||
|
"userMessage": "Template must contain {{ocr_text}} placeholder",
|
||||||
|
"recoveryAction": "Add the required placeholder to the template",
|
||||||
|
"errorCode": "VALIDATION_ERROR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Business Error (400)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Cannot delete active version",
|
||||||
|
"userMessage": "Cannot delete the currently active version. Activate another version first.",
|
||||||
|
"recoveryAction": "Activate a different version before deleting this one",
|
||||||
|
"errorCode": "CANNOT_DELETE_ACTIVE_VERSION"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not Found (404)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Prompt version not found",
|
||||||
|
"userMessage": "The requested prompt version does not exist",
|
||||||
|
"recoveryAction": "Check the prompt type and version number",
|
||||||
|
"errorCode": "NOT_FOUND"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Frontend Type Contracts: Unified Prompt Management UX/UI
|
||||||
|
// Feature: 237-unified-prompt-management-ux-ui
|
||||||
|
// Date: 2026-06-14
|
||||||
|
|
||||||
|
// Prompt Types
|
||||||
|
export type PromptType = 'ocr_extraction' | 'rag_query_prompt' | 'rag_prep_prompt' | 'classification_prompt';
|
||||||
|
|
||||||
|
// Context Config
|
||||||
|
export interface ContextConfig {
|
||||||
|
filter: {
|
||||||
|
projectId: string | null;
|
||||||
|
contractId: string | null;
|
||||||
|
};
|
||||||
|
pageSize: number;
|
||||||
|
language: string;
|
||||||
|
outputLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt Version
|
||||||
|
export interface PromptVersion {
|
||||||
|
id: string; // publicId (UUID)
|
||||||
|
promptType: PromptType;
|
||||||
|
versionNumber: number;
|
||||||
|
template: string;
|
||||||
|
contextConfig: ContextConfig | null;
|
||||||
|
isActive: boolean;
|
||||||
|
manualNote: string | null;
|
||||||
|
createdAt: string; // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime Parameters
|
||||||
|
export interface RuntimeParameters {
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
repeatPenalty: number;
|
||||||
|
maxTokens: number;
|
||||||
|
ctxSize: number;
|
||||||
|
keepAlive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execution Profile
|
||||||
|
export interface ExecutionProfile {
|
||||||
|
id: string; // publicId (UUID)
|
||||||
|
profileName: string;
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
repeatPenalty: number;
|
||||||
|
maxTokens: number;
|
||||||
|
ctxSize: number;
|
||||||
|
keepAlive: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sandbox Job Types
|
||||||
|
export type SandboxJobType = 'ocr' | 'ai-extract' | 'rag-prep';
|
||||||
|
|
||||||
|
export type SandboxJobStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
// Sandbox Job Result
|
||||||
|
export interface SandboxJobResult {
|
||||||
|
ocrText?: string;
|
||||||
|
extractedMetadata?: Record<string, unknown>;
|
||||||
|
ragChunks?: Array<{
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
}>;
|
||||||
|
ragVectors?: number[][];
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sandbox Job
|
||||||
|
export interface SandboxJob {
|
||||||
|
jobId: string; // UUID
|
||||||
|
jobType: SandboxJobType;
|
||||||
|
status: SandboxJobStatus;
|
||||||
|
result: SandboxJobResult;
|
||||||
|
createdAt: string; // ISO 8601
|
||||||
|
completedAt?: string; // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Request DTOs
|
||||||
|
export interface CreatePromptDto {
|
||||||
|
template: string;
|
||||||
|
contextConfig: ContextConfig;
|
||||||
|
manualNote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateContextConfigDto {
|
||||||
|
filter: {
|
||||||
|
projectId: string | null;
|
||||||
|
contractId: string | null;
|
||||||
|
};
|
||||||
|
pageSize: number;
|
||||||
|
language: string;
|
||||||
|
outputLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateExecutionProfileDto {
|
||||||
|
profileName: string;
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
repeatPenalty: number;
|
||||||
|
maxTokens: number;
|
||||||
|
ctxSize: number;
|
||||||
|
keepAlive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateExecutionProfileDto {
|
||||||
|
temperature?: number;
|
||||||
|
topP?: number;
|
||||||
|
repeatPenalty?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
ctxSize?: number;
|
||||||
|
keepAlive?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SandboxRagPrepDto {
|
||||||
|
text: string;
|
||||||
|
profileId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response DTOs
|
||||||
|
export interface PromptVersionResponse {
|
||||||
|
data: PromptVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptVersionsResponse {
|
||||||
|
data: PromptVersion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextConfigResponse {
|
||||||
|
data: ContextConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionProfilesResponse {
|
||||||
|
data: ExecutionProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionProfileResponse {
|
||||||
|
data: ExecutionProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SandboxJobResponse {
|
||||||
|
data: SandboxJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder Validation Rules
|
||||||
|
export const PLACEHOLDER_REQUIREMENTS: Record<PromptType, string[]> = {
|
||||||
|
ocr_extraction: ['{{ocr_text}}', '{{master_data_context}}'],
|
||||||
|
rag_query_prompt: ['{{query}}', '{{context}}'],
|
||||||
|
rag_prep_prompt: ['{{text}}'],
|
||||||
|
classification_prompt: ['{{document_text}}'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runtime Parameter Constraints
|
||||||
|
export const RUNTIME_PARAMETER_CONSTRAINTS = {
|
||||||
|
temperature: { min: 0.0, max: 1.0, default: 0.7 },
|
||||||
|
topP: { min: 0.0, max: 1.0, default: 0.9 },
|
||||||
|
repeatPenalty: { min: 1.0, max: 2.0, default: 1.0 },
|
||||||
|
maxTokens: { min: 1, max: 8192, default: 2048 },
|
||||||
|
ctxSize: { min: 1, max: 16384, default: 4096 },
|
||||||
|
keepAlive: { min: 0, max: 3600, default: 300 },
|
||||||
|
};
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Data Model: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Feature**: 237-unified-prompt-management-ux-ui
|
||||||
|
**Date**: 2026-06-14
|
||||||
|
**Purpose**: Define data entities, relationships, and validation rules
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### AiPrompt
|
||||||
|
|
||||||
|
Represents a prompt version with template, context config, and activation status.
|
||||||
|
|
||||||
|
**Table**: `ai_prompts` (extends existing ADR-029 schema)
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| `id` | INT AUTO_INCREMENT | PRIMARY KEY | Internal ID (not exposed) |
|
||||||
|
| `public_id` | UUID | UNIQUE, NOT NULL | Public identifier (ADR-019) |
|
||||||
|
| `prompt_type` | VARCHAR(50) | NOT NULL, INDEX | Prompt type: ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt |
|
||||||
|
| `version_number` | INT | NOT NULL, INDEX | Version number (per prompt_type) |
|
||||||
|
| `template` | TEXT | NOT NULL | Prompt template with placeholders |
|
||||||
|
| `context_config` | JSON | NULL | Context configuration (filter, pageSize, language) |
|
||||||
|
| `is_active` | TINYINT(1) | NOT NULL, DEFAULT 0 | Active flag (1 = active) |
|
||||||
|
| `manual_note` | VARCHAR(500) | NULL | Manual annotation |
|
||||||
|
| `created_by` | INT | NOT NULL, FK → users.id | Creator user ID |
|
||||||
|
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Creation timestamp |
|
||||||
|
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE | Update timestamp |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(prompt_type, version_number)` - ensures version numbers are unique per type
|
||||||
|
|
||||||
|
**Validation Rules**:
|
||||||
|
- `prompt_type` must be one of: ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt
|
||||||
|
- `template` must contain required placeholders based on `prompt_type`:
|
||||||
|
- ocr_extraction: {{ocr_text}}, {{master_data_context}}
|
||||||
|
- rag_query_prompt: {{query}}, {{context}}
|
||||||
|
- rag_prep_prompt: {{text}}
|
||||||
|
- classification_prompt: {{document_text}}
|
||||||
|
- `context_config` JSON structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": {
|
||||||
|
"projectId": "uuid|null",
|
||||||
|
"contractId": "uuid|null"
|
||||||
|
},
|
||||||
|
"pageSize": 3,
|
||||||
|
"language": "th",
|
||||||
|
"outputLanguage": "th"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `is_active` can only be true for one version per `prompt_type` at a time
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- `created_by` → `users.id` (many-to-one)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AiExecutionProfile
|
||||||
|
|
||||||
|
Represents runtime parameters for AI model behavior (global per profile, not per prompt version).
|
||||||
|
|
||||||
|
**Table**: `ai_execution_profiles` (new table per ADR-036)
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| `id` | INT AUTO_INCREMENT | PRIMARY KEY | Internal ID (not exposed) |
|
||||||
|
| `public_id` | UUID | UNIQUE, NOT NULL | Public identifier (ADR-019) |
|
||||||
|
| `profile_name` | VARCHAR(100) | NOT NULL, UNIQUE | Profile name (e.g., "default", "fast", "accurate") |
|
||||||
|
| `temperature` | DECIMAL(3,2) | NOT NULL, DEFAULT 0.7 | Temperature (0.0 - 1.0) |
|
||||||
|
| `top_p` | DECIMAL(3,2) | NOT NULL, DEFAULT 0.9 | Top-P (0.0 - 1.0) |
|
||||||
|
| `repeat_penalty` | DECIMAL(3,2) | NOT NULL, DEFAULT 1.0 | Repeat penalty (1.0 - 2.0) |
|
||||||
|
| `max_tokens` | INT | NOT NULL, DEFAULT 2048 | Max tokens (1 - 8192) |
|
||||||
|
| `ctx_size` | INT | NOT NULL, DEFAULT 4096 | Context size (1 - 16384) |
|
||||||
|
| `keep_alive` | INT | NOT NULL, DEFAULT 300 | Keep-alive seconds (0 - 3600) |
|
||||||
|
| `is_default` | TINYINT(1) | NOT NULL, DEFAULT 0 | Default profile flag |
|
||||||
|
| `created_by` | INT | NOT NULL, FK → users.id | Creator user ID |
|
||||||
|
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Creation timestamp |
|
||||||
|
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE | Update timestamp |
|
||||||
|
|
||||||
|
**Validation Rules**:
|
||||||
|
- `temperature` must be between 0.0 and 1.0
|
||||||
|
- `top_p` must be between 0.0 and 1.0
|
||||||
|
- `repeat_penalty` must be between 1.0 and 2.0
|
||||||
|
- `max_tokens` must be between 1 and 8192
|
||||||
|
- `ctx_size` must be between 1 and 16384
|
||||||
|
- `keep_alive` must be between 0 and 3600
|
||||||
|
- `is_default` can only be true for one profile at a time
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- `created_by` → `users.id` (many-to-one)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SandboxJob
|
||||||
|
|
||||||
|
Represents a sandbox test execution (transient, stored in Redis, not in database).
|
||||||
|
|
||||||
|
**Redis Key**: `sandbox:job:{jobId}` (TTL: 1 hour)
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "uuid",
|
||||||
|
"jobType": "ocr|ai-extract|rag-prep",
|
||||||
|
"status": "pending|processing|completed|failed",
|
||||||
|
"result": {
|
||||||
|
"ocrText": "string|null",
|
||||||
|
"extractedMetadata": "object|null",
|
||||||
|
"ragChunks": "array|null",
|
||||||
|
"ragVectors": "array|null",
|
||||||
|
"error": "string|null"
|
||||||
|
},
|
||||||
|
"createdAt": "timestamp",
|
||||||
|
"completedAt": "timestamp|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules**:
|
||||||
|
- `jobType` must be one of: ocr, ai-extract, rag-prep
|
||||||
|
- `status` transitions: pending → processing → completed/failed
|
||||||
|
- `result` structure depends on `jobType`:
|
||||||
|
- ocr: { ocrText, error }
|
||||||
|
- ai-extract: { extractedMetadata, error }
|
||||||
|
- rag-prep: { ragChunks, ragVectors, error }
|
||||||
|
|
||||||
|
**Relationships**: None (transient data)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
users (1) ──────── (∞) ai_prompts
|
||||||
|
│
|
||||||
|
└── (1) ──────── (∞) ai_execution_profiles
|
||||||
|
|
||||||
|
ai_prompts (independent from ai_execution_profiles)
|
||||||
|
- Runtime parameters are global (ai_execution_profiles)
|
||||||
|
- Context config is per version (ai_prompts.context_config)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Indexes
|
||||||
|
|
||||||
|
### ai_prompts
|
||||||
|
- `idx_prompt_type_version`: `(prompt_type, version_number)` - UNIQUE
|
||||||
|
- `idx_prompt_type_active`: `(prompt_type, is_active)` - for finding active version
|
||||||
|
- `idx_public_id`: `(public_id)` - UNIQUE
|
||||||
|
|
||||||
|
### ai_execution_profiles
|
||||||
|
- `idx_profile_name`: `(profile_name)` - UNIQUE
|
||||||
|
- `idx_is_default`: `(is_default)` - for finding default profile
|
||||||
|
- `idx_public_id`: `(public_id)` - UNIQUE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema Changes (ADR-009)
|
||||||
|
|
||||||
|
### New Table: ai_execution_profiles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ai_execution_profiles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
public_id UUID NOT NULL,
|
||||||
|
profile_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
temperature DECIMAL(3,2) NOT NULL DEFAULT 0.7,
|
||||||
|
top_p DECIMAL(3,2) NOT NULL DEFAULT 0.9,
|
||||||
|
repeat_penalty DECIMAL(3,2) NOT NULL DEFAULT 1.0,
|
||||||
|
max_tokens INT NOT NULL DEFAULT 2048,
|
||||||
|
ctx_size INT NOT NULL DEFAULT 4096,
|
||||||
|
keep_alive INT NOT NULL DEFAULT 300,
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_by INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE,
|
||||||
|
UNIQUE KEY uk_public_id (public_id),
|
||||||
|
KEY idx_is_default (is_default),
|
||||||
|
CONSTRAINT fk_execution_profile_user FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seed Data: ai_execution_profiles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO ai_execution_profiles (public_id, profile_name, temperature, top_p, repeat_penalty, max_tokens, ctx_size, keep_alive, is_default, created_by)
|
||||||
|
VALUES
|
||||||
|
(UUID(), 'default', 0.7, 0.9, 1.0, 2048, 4096, 300, 1, 1),
|
||||||
|
(UUID(), 'fast', 0.5, 0.8, 1.0, 1024, 2048, 0, 0, 1),
|
||||||
|
(UUID(), 'accurate', 0.8, 0.95, 1.1, 4096, 8192, 600, 0, 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seed Data: ai_prompts (additional types)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- RAG Query Prompt
|
||||||
|
INSERT INTO ai_prompts (public_id, prompt_type, version_number, template, context_config, is_active, created_by)
|
||||||
|
VALUES (
|
||||||
|
UUID(),
|
||||||
|
'rag_query_prompt',
|
||||||
|
1,
|
||||||
|
'Answer the following question based on the provided context:\n\nQuestion: {{query}}\n\nContext: {{context}}\n\nAnswer:',
|
||||||
|
'{"filter": null, "language": "th"}',
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RAG Prep Prompt
|
||||||
|
INSERT INTO ai_prompts (public_id, prompt_type, version_number, template, context_config, is_active, created_by)
|
||||||
|
VALUES (
|
||||||
|
UUID(),
|
||||||
|
'rag_prep_prompt',
|
||||||
|
1,
|
||||||
|
'Split the following text into semantic chunks for RAG indexing:\n\nText: {{text}}\n\nOutput JSON array of chunks with "text" and "summary" fields.',
|
||||||
|
'{"filter": null, "language": "th"}',
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Classification Prompt
|
||||||
|
INSERT INTO ai_prompts (public_id, prompt_type, version_number, template, context_config, is_active, created_by)
|
||||||
|
VALUES (
|
||||||
|
UUID(),
|
||||||
|
'classification_prompt',
|
||||||
|
1,
|
||||||
|
'Classify the following document into one of these categories: Correspondence, RFA, Transmittal, Circulation, Shop Drawing, Contract Drawing\n\nDocument: {{document_text}}\n\nOutput JSON with "category" and "confidence" fields.',
|
||||||
|
'{"filter": null, "language": "th"}',
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Implementation Plan: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Branch**: `237-unified-prompt-management-ux-ui` | **Date**: 2026-06-14 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/200-fullstacks/237-unified-prompt-management-ux-ui/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This feature extends ADR-029 Dynamic Prompt Management to support multiple prompt types (OCR extraction, RAG query, RAG preparation, document classification) with a unified single-page UI. The implementation adds context configuration management, a 3-step sandbox workflow (OCR → AI Extract → RAG Prep), and clear separation between Runtime Parameters (AI model behavior) and Context Config (data context). The backend will extend existing AiPromptsService with new endpoints for context config CRUD and RAG Prep sandbox testing, while the frontend will create a unified Prompt Management page with PromptTypeDropdown, VersionHistory, PromptEditor, ContextConfigEditor, and SandboxTabs components.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.6 (Backend: NestJS 11, Frontend: Next.js 16)
|
||||||
|
**Primary Dependencies**:
|
||||||
|
- Backend: @nestjs/common, @nestjs/typeorm, @nestjs/bull, class-validator, class-transformer, redis, ioredis
|
||||||
|
- Frontend: next, react, @tanstack/react-query, react-hook-form, zod, shadcn/ui, lucide-react
|
||||||
|
**Storage**: MariaDB 11.8 (ai_prompts, ai_execution_profiles tables), Redis (prompt cache, BullMQ queues)
|
||||||
|
**Testing**: Jest (backend unit/integration/e2e), Vitest (frontend unit), Playwright (frontend e2e)
|
||||||
|
**Target Platform**: Linux server (QNAP NAS) for backend, Web browser for frontend
|
||||||
|
**Project Type**: fullstack (backend + frontend)
|
||||||
|
**Performance Goals**:
|
||||||
|
- Sandbox OCR results within 30s
|
||||||
|
- Sandbox AI Extract within 60s
|
||||||
|
- Version history load within 1s
|
||||||
|
- Context config activation within 5s
|
||||||
|
**Constraints**:
|
||||||
|
- ADR-019: No parseInt on UUID, use publicId only
|
||||||
|
- ADR-009: No TypeORM migrations, edit SQL directly
|
||||||
|
- ADR-016: CASL guards on all mutations, ThrottlerGuard on auth
|
||||||
|
- ADR-023/023A: AI boundary enforcement, BullMQ queues (ai-realtime, ai-batch)
|
||||||
|
- ADR-029: Prompt templates in DB, Redis cache TTL 60s
|
||||||
|
- ADR-007: Layered error handling, user-friendly messages
|
||||||
|
**Scale/Scope**:
|
||||||
|
- 4 prompt types with versioning
|
||||||
|
- Single page UI with 3-panel layout
|
||||||
|
- 3-step sandbox workflow
|
||||||
|
- ~10 backend endpoints, ~15 frontend components
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| ADR-019 UUID | ✅ PASS | Will use publicId only, no parseInt on UUID |
|
||||||
|
| ADR-009 Schema | ✅ PASS | Will add SQL delta for new columns, no migrations |
|
||||||
|
| ADR-016 Security | ✅ PASS | Will add CASL guards on all mutation endpoints |
|
||||||
|
| ADR-002 Numbering | N/A | Not applicable (no document numbering) |
|
||||||
|
| ADR-008 Notifications | ✅ PASS | Sandbox jobs use BullMQ, no inline processing |
|
||||||
|
| ADR-023/023A AI Boundary | ✅ PASS | Sandbox uses existing BullMQ queues, no direct AI access |
|
||||||
|
| ADR-029 Dynamic Prompts | ✅ PASS | Extends existing ai_prompts table, follows Redis cache pattern |
|
||||||
|
| ADR-007 Error Handling | ✅ PASS | Will use BusinessException hierarchy |
|
||||||
|
| TypeScript Strict | ✅ PASS | Zero any, zero console.log |
|
||||||
|
| i18n | ✅ PASS | Will use i18n keys, no hardcoded text |
|
||||||
|
| File Upload | ✅ PASS | Sandbox uses existing two-phase upload |
|
||||||
|
|
||||||
|
**Result**: All applicable principles pass. No violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/200-fullstacks/237-unified-prompt-management-ux-ui/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
│ ├── backend-api.yaml
|
||||||
|
│ └── frontend-types.ts
|
||||||
|
└── tasks.md # Phase 2 output (from /speckit-tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── modules/
|
||||||
|
│ │ └── ai/
|
||||||
|
│ │ ├── controllers/
|
||||||
|
│ │ │ └── ai-prompts.controller.ts (extend with context config endpoints)
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── ai-prompts.service.ts (extend with context config CRUD)
|
||||||
|
│ │ │ └── ocr.service.ts (extend with RAG Prep endpoint)
|
||||||
|
│ │ ├── processors/
|
||||||
|
│ │ │ └── ai-batch.processor.ts (extend with sandbox-rag-prep job)
|
||||||
|
│ │ └── dto/
|
||||||
|
│ │ ├── context-config.dto.ts (new)
|
||||||
|
│ │ └── sandbox-rag-prep.dto.ts (new)
|
||||||
|
│ └── common/
|
||||||
|
│ └── decorators/
|
||||||
|
│ └── casl-abilities.decorator.ts (existing)
|
||||||
|
└── test/
|
||||||
|
└── integration/
|
||||||
|
└── ai/
|
||||||
|
└── ai-prompts.service.spec.ts (extend tests)
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── app/
|
||||||
|
│ └── (admin)/
|
||||||
|
│ └── admin/
|
||||||
|
│ └── ai/
|
||||||
|
│ └── prompt-management/
|
||||||
|
│ └── page.tsx (new - unified prompt management page)
|
||||||
|
├── components/
|
||||||
|
│ └── admin/
|
||||||
|
│ └── ai/
|
||||||
|
│ ├── PromptTypeDropdown.tsx (new)
|
||||||
|
│ ├── VersionHistory.tsx (extend with type filtering)
|
||||||
|
│ ├── PromptEditor.tsx (new)
|
||||||
|
│ ├── ContextConfigEditor.tsx (new)
|
||||||
|
│ ├── RuntimeParametersPanel.tsx (new)
|
||||||
|
│ └── SandboxTabs.tsx (new - 3-step workflow)
|
||||||
|
├── lib/
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── admin-ai.service.ts (extend with context config methods)
|
||||||
|
│ └── types/
|
||||||
|
│ └── ai-prompts.ts (extend with context config types)
|
||||||
|
└── __tests__/
|
||||||
|
└── components/
|
||||||
|
└── admin/
|
||||||
|
└── ai/
|
||||||
|
└── prompt-management.test.tsx (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Fullstack web application (backend + frontend) following existing LCBP3-DMS patterns. Backend extends existing ai module, frontend adds new page under (admin)/admin/ai/ consistent with ADR-027 single page layout.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
No violations - this section is not applicable.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Quickstart: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Feature**: 237-unified-prompt-management-ux-ui
|
||||||
|
**Date**: 2026-06-14
|
||||||
|
**Purpose**: Quick start guide for testing and validating the implementation
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Backend server running on port 3001
|
||||||
|
- Frontend server running on port 3000
|
||||||
|
- Database (MariaDB) with ai_prompts table (from ADR-029)
|
||||||
|
- Redis running (for BullMQ and caching)
|
||||||
|
- OCR sidecar running on Desk-5439 (typhoon-np-dms-ocr)
|
||||||
|
- Ollama running with typhoon2.5-np-dms model
|
||||||
|
|
||||||
|
## Database Setup
|
||||||
|
|
||||||
|
### 1. Create ai_execution_profiles table
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p lcbp3 < specs/03-Data-and-Storage/deltas/2026-06-14-create-ai-execution-profiles.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Seed execution profiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p lcbp3 < specs/03-Data-and-Storage/deltas/2026-06-14-seed-execution-profiles.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Seed additional prompt types
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p lcbp3 < specs/03-Data-and-Storage/deltas/2026-06-14-seed-additional-prompt-types.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Testing
|
||||||
|
|
||||||
|
### 1. Test context config endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get context config for OCR extraction v1
|
||||||
|
curl -H "Authorization: Bearer <token>" \
|
||||||
|
http//lloaalhist:3001i/prompts/ocr_extraction/1/context-config
|
||||||
|
|
||||||
|
# Update context config
|
||||||
|
curl -X PUT -H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"filter": {"projectId": null, "contractId": null}, "pageSize": 5, "language": "th", "outputLanguage": "th"}' \
|
||||||
|
http//lloaalhist:3001i/prompts/ocr_extraction/1/context-config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test execution profile endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all execution profiles
|
||||||
|
curl -H "Authorization: Bearer <token>" \
|
||||||
|
https://backend.np-dms.work/api/ai/execution-profiles
|
||||||
|
|
||||||
|
# Create new profile
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"profileName": "test", "temperature": 0.6, "topP": 0.85, "repeatPenalty": 1.0, "maxTokens": 1024, "ctxSize": 2048, "keepAlive": 0}' \
|
||||||
|
https://backend.np-dms.work/api/ai/execution-profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test sandbox RAG Prep endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start RAG Prep job
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text": "Sample text for chunking", "profileId": null}' \
|
||||||
|
https://backend.np-dms.work/api/ai/admin/sandbox/rag-prep
|
||||||
|
|
||||||
|
# Poll job status (replace <jobId>)
|
||||||
|
curl -H "Authorization: Bearer <token>" \
|
||||||
|
https://backend.np-dms.work/api/ai/admin/sandbox/job/<jobId>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Testing
|
||||||
|
|
||||||
|
### 1. Navigate to Prompt Management page
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3000/admin/ai/prompt-management
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Multi-Type Prompt Management
|
||||||
|
|
||||||
|
1. Select "OCR Extraction" from Prompt Type dropdown
|
||||||
|
2. Verify version history shows only OCR extraction versions
|
||||||
|
3. Click on a version to view template and context config
|
||||||
|
4. Edit template and context config
|
||||||
|
5. Click "Save New Version"
|
||||||
|
6. Verify new version appears in history with incremented version number
|
||||||
|
7. Click "Activate" on the new version
|
||||||
|
8. Verify active badge (✅) moves to the new version
|
||||||
|
|
||||||
|
### 3. Test Context Configuration
|
||||||
|
|
||||||
|
1. Select a prompt version
|
||||||
|
2. Modify Project Filter field (select a project or leave null)
|
||||||
|
3. Modify Contract Filter field (select a contract or leave null)
|
||||||
|
4. Modify Page Size (e.g., change from 3 to 5)
|
||||||
|
5. Modify Language (e.g., change from "th" to "en")
|
||||||
|
6. Click "Save New Version"
|
||||||
|
7. Verify context config is saved with the new version
|
||||||
|
8. Activate the version
|
||||||
|
9. Verify context config is applied (check Redis cache)
|
||||||
|
|
||||||
|
### 4. Test Three-Step Sandbox Workflow
|
||||||
|
|
||||||
|
1. Upload a PDF in the Sandbox tab
|
||||||
|
2. Click "Run OCR"
|
||||||
|
3. Wait for OCR results (should show raw OCR text)
|
||||||
|
4. Select a prompt version from dropdown
|
||||||
|
5. Click "Run AI Extract"
|
||||||
|
6. Wait for extracted metadata (should show structured JSON)
|
||||||
|
7. Click "Test RAG Prep" (optional)
|
||||||
|
8. Wait for RAG chunks and vectors
|
||||||
|
9. Review all results
|
||||||
|
10. Click "Activate This Version" if satisfied
|
||||||
|
|
||||||
|
### 5. Test Runtime Parameters vs Context Config Separation
|
||||||
|
|
||||||
|
1. Go to Sandbox tab
|
||||||
|
2. View Runtime Parameters panel
|
||||||
|
3. Adjust Temperature slider (e.g., from 0.7 to 0.8)
|
||||||
|
4. Adjust Top-P slider (e.g., from 0.9 to 0.95)
|
||||||
|
5. Click "Apply to Production"
|
||||||
|
6. Verify parameters are saved to ai_execution_profiles
|
||||||
|
7. Go to Prompt Editor panel
|
||||||
|
8. View Context Config Editor
|
||||||
|
9. Modify Project Filter (different from Runtime Parameters)
|
||||||
|
10. Click "Save New Version"
|
||||||
|
11. Verify context config is saved to ai_prompts (per version)
|
||||||
|
12. Confirm that Runtime Parameters and Context Config are separate
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [ ] ai_execution_profiles table created successfully
|
||||||
|
- [ ] Execution profiles seeded (default, fast, accurate)
|
||||||
|
- [ ] Additional prompt types seeded (rag_query_prompt, rag_prep_prompt, classification_prompt)
|
||||||
|
- [ ] GET /api/ai/prompts/:type/:version/context-config returns context config
|
||||||
|
- [ ] PUT /api/ai/prompts/:type/:version/context-config updates context config
|
||||||
|
- [ ] GET /api/ai/execution-profiles returns all profiles
|
||||||
|
- [ ] POST /api/ai/execution-profiles creates new profile
|
||||||
|
- [ ] PUT /api/ai/execution-profiles/:id updates profile
|
||||||
|
- [ ] DELETE /api/ai/execution-profiles/:id deletes non-default profile
|
||||||
|
- [ ] POST /api/ai/admin/sandbox/rag-prep creates sandbox job
|
||||||
|
- [ ] GET /api/ai/admin/sandbox/job/:jobId returns job status and results
|
||||||
|
- [ ] Placeholder validation works (rejects templates without required placeholders)
|
||||||
|
- [ ] Context config validation works (rejects invalid project/contract IDs)
|
||||||
|
- [ ] Redis cache invalidated on version activation
|
||||||
|
- [ ] CASL guards applied to all mutation endpoints
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [ ] PromptTypeDropdown switches between prompt types
|
||||||
|
- [ ] VersionHistory filters by selected prompt type
|
||||||
|
- [ ] Active badge (✅) displays correctly
|
||||||
|
- [ ] PromptEditor validates placeholders
|
||||||
|
- [ ] ContextConfigEditor saves and displays context config
|
||||||
|
- [ ] RuntimeParametersPanel displays sliders
|
||||||
|
- [ ] RuntimeParametersPanel applies changes to ai_execution_profiles
|
||||||
|
- [ ] SandboxTabs show 3 tabs (OCR, Extract, RAG Prep)
|
||||||
|
- [ ] Sandbox OCR step returns raw OCR text
|
||||||
|
- [ ] Sandbox AI Extract step returns structured metadata
|
||||||
|
- [ ] Sandbox RAG Prep step returns chunks and vectors
|
||||||
|
- [ ] "Activate This Version" button works from sandbox
|
||||||
|
- [ ] Single page layout consistent with ADR-027
|
||||||
|
- [ ] i18n keys used (no hardcoded text)
|
||||||
|
- [ ] TypeScript strict mode passes (no any, no console.log)
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
- [ ] Full 3-step sandbox workflow completes successfully
|
||||||
|
- [ ] Sandbox results match production behavior
|
||||||
|
- [ ] Context config applied to production jobs within 5 seconds
|
||||||
|
- [ ] Runtime parameters applied to sandbox tests immediately
|
||||||
|
- [ ] Version history loads within 1 second
|
||||||
|
- [ ] Sandbox OCR results within 30 seconds
|
||||||
|
- [ ] Sandbox AI Extract results within 60 seconds
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Context config not saving
|
||||||
|
|
||||||
|
- Check that ai_prompts table has context_config column (JSON type)
|
||||||
|
- Verify Redis cache is running
|
||||||
|
- Check backend logs for validation errors
|
||||||
|
|
||||||
|
### Sandbox RAG Prep failing
|
||||||
|
|
||||||
|
- Verify Ollama is running with typhoon2.5-np-dms model
|
||||||
|
- Check embedding service (BGE-M3) is available
|
||||||
|
- Verify BullMQ ai-realtime queue is processing jobs
|
||||||
|
- Check Redis for job status
|
||||||
|
|
||||||
|
### Runtime parameters not applying
|
||||||
|
|
||||||
|
- Verify ai_execution_profiles table exists
|
||||||
|
- Check that profile is not marked as default (can't modify default)
|
||||||
|
- Verify CASL permissions (system.manage_all)
|
||||||
|
|
||||||
|
### Placeholder validation failing
|
||||||
|
|
||||||
|
- Check that template contains required placeholders for the prompt type
|
||||||
|
- Verify placeholder format: {{placeholder_name}}
|
||||||
|
- Check backend logs for specific validation error
|
||||||
|
|
||||||
|
## Performance Benchmarks
|
||||||
|
|
||||||
|
After implementation, verify these targets:
|
||||||
|
|
||||||
|
- Version history load: < 1 second
|
||||||
|
- Context config activation: < 5 seconds
|
||||||
|
- Sandbox OCR: < 30 seconds
|
||||||
|
- Sandbox AI Extract: < 60 seconds
|
||||||
|
- Runtime parameter application: immediate (no page refresh)
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Research: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Feature**: 237-unified-prompt-management-ux-ui
|
||||||
|
**Date**: 2026-06-14
|
||||||
|
**Purpose**: Resolve technical unknowns and document decisions for implementation
|
||||||
|
|
||||||
|
## Research Topics
|
||||||
|
|
||||||
|
### 1. Context Config Schema Structure
|
||||||
|
|
||||||
|
**Question**: What JSON structure should context_config use in ai_prompts table?
|
||||||
|
|
||||||
|
**Decision**: Use flat JSON object with these fields:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": {
|
||||||
|
"projectId": "uuid|null",
|
||||||
|
"contractId": "uuid|null"
|
||||||
|
},
|
||||||
|
"pageSize": 3,
|
||||||
|
"language": "th",
|
||||||
|
"outputLanguage": "th"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Follows ADR-030 context-aware prompt template structure
|
||||||
|
- Matches existing OCR extraction prompt context_config from ADR-029
|
||||||
|
- Flat structure simplifies validation and UI binding
|
||||||
|
- Filter object allows null values for "all projects/contracts"
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Nested structure with separate sections (rejected: too complex for simple use case)
|
||||||
|
- Array-based filter (rejected: single project/contract filter is sufficient)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Runtime Parameters Storage
|
||||||
|
|
||||||
|
**Question**: Should runtime parameters be stored in a new ai_execution_profiles table or as part of ai_prompts?
|
||||||
|
|
||||||
|
**Decision**: Store in new ai_execution_profiles table (global per profile, not per prompt version)
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Runtime parameters control AI model behavior (temperature, topP) which applies globally across all prompt types
|
||||||
|
- Separates concerns: Runtime Parameters = AI behavior, Context Config = data context
|
||||||
|
- Allows admins to test different parameter sets in sandbox without affecting prompts
|
||||||
|
- Follows ADR-036 unified AI model architecture
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Store in ai_prompts per version (rejected: would duplicate same parameters across versions)
|
||||||
|
- Store in Redis only (rejected: no persistence, lost on restart)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Sandbox RAG Prep Implementation
|
||||||
|
|
||||||
|
**Question**: How should RAG Prep sandbox endpoint integrate with existing infrastructure?
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- Create new BullMQ job type: "sandbox-rag-prep" in ai-realtime queue
|
||||||
|
- Reuse existing OllamaService for semantic chunking (typhoon2.5-np-dms)
|
||||||
|
- Reuse existing embedding service (BGE-M3 via sidecar)
|
||||||
|
- Return chunks + vectors in sandbox result for display
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Consistent with existing sandbox OCR and AI Extract patterns
|
||||||
|
- Leverages existing ADR-023A infrastructure (2-model stack, BullMQ queues)
|
||||||
|
- Allows testing of full production pipeline before deployment
|
||||||
|
- Follows ADR-035 AI pipeline flow architecture
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Inline processing without BullMQ (rejected: blocks request thread, no retry)
|
||||||
|
- Separate queue for RAG Prep (rejected: ai-realtime queue already handles sandbox jobs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Context Config Validation
|
||||||
|
|
||||||
|
**Question**: How should invalid context config references (e.g., non-existent project ID) be handled?
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- Validate project/contract IDs against database on save
|
||||||
|
- Allow null values (meaning "all projects/contracts")
|
||||||
|
- Return validation error with user-friendly message if ID doesn't exist
|
||||||
|
- Do not block activation if context config is valid at save time
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Prevents orphaned references that would cause production failures
|
||||||
|
- User-friendly error messages align with ADR-007 error handling
|
||||||
|
- Null values are valid for "unfiltered" context
|
||||||
|
- Validation at save time is sufficient (no need to re-validate on activation)
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Allow invalid references and handle at runtime (rejected: production failures)
|
||||||
|
- Re-validate on activation (rejected: unnecessary if validated at save)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Placeholder Validation Strategy
|
||||||
|
|
||||||
|
**Question**: How should required placeholders (e.g., {{ocr_text}}) be validated?
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- Define required placeholders per prompt type:
|
||||||
|
- ocr_extraction: {{ocr_text}}, {{master_data_context}}
|
||||||
|
- rag_query_prompt: {{query}}, {{context}}
|
||||||
|
- rag_prep_prompt: {{text}}
|
||||||
|
- classification_prompt: {{document_text}}
|
||||||
|
- Validate on save: template must contain all required placeholders
|
||||||
|
- Return validation error listing missing placeholders
|
||||||
|
- Allow additional optional placeholders
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Prevents production failures from missing placeholders
|
||||||
|
- Clear error messages help admins understand requirements
|
||||||
|
- Flexible enough for future placeholder additions
|
||||||
|
- Aligns with ADR-029 dynamic prompt management
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Validate at runtime (rejected: production failures)
|
||||||
|
- No validation (rejected: too error-prone)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Version Numbering Strategy
|
||||||
|
|
||||||
|
**Question**: How should version numbers be incremented across prompt types?
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- Version numbers are per prompt_type (independent counters)
|
||||||
|
- Each prompt_type has its own sequence: ocr_extraction v1, v2, v3; rag_query_prompt v1, v2, etc.
|
||||||
|
- Auto-increment on save: MAX(version_number) + 1 for that prompt_type
|
||||||
|
- Display version number in format: "v{number} ({prompt_type})"
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Clear separation between prompt types
|
||||||
|
- No confusion about which version belongs to which type
|
||||||
|
- Auto-increment prevents manual errors
|
||||||
|
- Consistent with ADR-029 versioning approach
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Global version counter across all types (rejected: confusing which version is for which type)
|
||||||
|
- Manual version entry (rejected: error-prone)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Sandbox State Management
|
||||||
|
|
||||||
|
**Question**: How should sandbox state (OCR text, extracted metadata) be passed between steps?
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- Store sandbox job results in Redis with TTL 1 hour
|
||||||
|
- Use job ID as key: `sandbox:job:{jobId}`
|
||||||
|
- Each step (OCR, AI Extract, RAG Prep) writes its result to the same key
|
||||||
|
- Frontend polls job status using job ID
|
||||||
|
- Results cleared after TTL or manual "Clear Sandbox" action
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Stateless API design (no session state in backend)
|
||||||
|
- Redis is already available for BullMQ
|
||||||
|
- TTL prevents memory leaks
|
||||||
|
- Allows multi-step workflow without passing large payloads in requests
|
||||||
|
- Consistent with existing sandbox patterns
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Pass results in request/response (rejected: large payloads, complexity)
|
||||||
|
- Store in database (rejected: unnecessary persistence, cleanup overhead)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Frontend Component Architecture
|
||||||
|
|
||||||
|
**Question**: How should the unified prompt management page be structured?
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- Single page at `/admin/ai/prompt-management`
|
||||||
|
- 3-panel layout: Left (Version History), Center (Prompt Editor + Context Config), Right (Sandbox)
|
||||||
|
- PromptTypeDropdown at top of page (global state)
|
||||||
|
- Use React Hook Form for Context Config Editor (validation, type safety)
|
||||||
|
- Use TanStack Query for data fetching (version history, active version)
|
||||||
|
- Use shadcn/ui components (consistent with existing admin pages)
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Consistent with ADR-027 single page layout
|
||||||
|
- 3-panel layout maximizes screen real estate
|
||||||
|
- RHF + Zod for form validation (best practice)
|
||||||
|
- TanStack Query for caching and optimistic updates
|
||||||
|
- shadcn/ui for consistent styling
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Multi-page design (rejected: violates ADR-027 single page constraint)
|
||||||
|
- 2-panel layout (rejected: insufficient space for sandbox)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
All technical unknowns resolved. Key decisions:
|
||||||
|
1. Context config uses flat JSON with filter object
|
||||||
|
2. Runtime parameters in new ai_execution_profiles table
|
||||||
|
3. Sandbox RAG Prep uses existing BullMQ infrastructure
|
||||||
|
4. Context config validated on save against database
|
||||||
|
5. Placeholder validation per prompt type
|
||||||
|
6. Version numbers per prompt_type (independent counters)
|
||||||
|
7. Sandbox state in Redis with TTL 1 hour
|
||||||
|
8. Frontend: 3-panel single page with RHF + TanStack Query + shadcn/ui
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Feature Specification: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Feature Branch**: `237-unified-prompt-management-ux-ui`
|
||||||
|
**Created**: 2026-06-14
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: ADR-037: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
## User Scenarios & Testing _(mandatory)_
|
||||||
|
|
||||||
|
### User Story 1 - Multi-Type Prompt Management (Priority: P1)
|
||||||
|
|
||||||
|
Admin users need to manage prompt templates for multiple AI workflow types (OCR extraction, RAG query, RAG preparation, and document classification) through a single unified interface that separates version history, template editing, and context configuration.
|
||||||
|
|
||||||
|
**Why this priority**: This is the foundation of the feature - without multi-type support, the system cannot manage the full AI pipeline defined in ADR-035. Admins currently struggle with confusing version history that mixes different prompt types, making it difficult to track which version is active for which workflow.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating and activating versions for different prompt types, verifying that version history is correctly separated by type, and confirming that the active version badge displays correctly for each type.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** admin is on the Prompt Management page, **When** they select "OCR Extraction" from the Prompt Type dropdown, **Then** only OCR extraction prompt versions are displayed in the Version History panel
|
||||||
|
2. **Given** admin has selected a prompt type, **When** they click on a version in the Version History, **Then** the Prompt Editor displays that version's template and context config
|
||||||
|
3. **Given** admin is viewing version history, **When** a version is marked as active, **Then** an active badge (✅) is displayed next to that version
|
||||||
|
4. **Given** admin has edited a prompt template, **When** they click "Save New Version", **Then** a new version is created with incremented version number and the version appears in the history list
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Context Configuration Management (Priority: P1)
|
||||||
|
|
||||||
|
Admin users need to view, edit, save, and apply context configuration (project filter, contract filter, page size, language) for each prompt version to control what data context the AI sees during processing.
|
||||||
|
|
||||||
|
**Why this priority**: Context configuration is critical for AI accuracy - without it, admins cannot control which master data (projects, contracts) the AI uses for extraction. This directly impacts the quality of AI-generated metadata.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by editing context config fields, saving a new version, and verifying that the context config is correctly persisted and applied when the version is activated.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** admin is editing a prompt version, **When** they modify the Project Filter field, **Then** the change is reflected in the Context Config Editor preview
|
||||||
|
2. **Given** admin has modified context config, **When** they click "Save New Version", **Then** the context config is saved as part of the new version
|
||||||
|
3. **Given** admin activates a version with specific context config, **Then** production AI jobs use that context config for processing
|
||||||
|
4. **Given** admin views an existing version, **When** the version has context config, **Then** the Context Config Editor displays the current values
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Three-Step Sandbox Testing (Priority: P1)
|
||||||
|
|
||||||
|
Admin users need to test the full AI pipeline (OCR → AI Extract → RAG Prep) in the sandbox to validate prompt versions before activating them to production, ensuring sandbox results match production behavior.
|
||||||
|
|
||||||
|
**Why this priority**: Currently sandbox uses a 2-step flow (OCR → Extract) while production uses 3-step (OCR → Extract → RAG Prep), causing testing gaps. Admins cannot fully validate prompts before deployment, leading to production issues.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by uploading a PDF, running all three sandbox steps sequentially, and verifying that each step produces expected outputs (OCR text, extracted metadata, RAG chunks).
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** admin has uploaded a PDF in the sandbox, **When** they click "Run OCR", **Then** the system returns raw OCR text from the OCR sidecar
|
||||||
|
2. **Given** admin has OCR results, **When** they select a prompt version and click "Run AI Extract", **Then** the system returns structured metadata (JSON) using the selected prompt
|
||||||
|
3. **Given** admin has extracted metadata, **When** they click "Test RAG Prep" (optional), **Then** the system returns semantic chunks and embedding vectors
|
||||||
|
4. **Given** admin is satisfied with sandbox results, **When** they click "Activate This Version", **Then** the version is activated for production use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Runtime Parameters vs Context Config Separation (Priority: P2)
|
||||||
|
|
||||||
|
Admin users need clear separation between Runtime Parameters (AI model behavior controls like temperature, topP) and Context Config (data context controls like project filter) to avoid confusion about which settings affect what aspect of AI processing.
|
||||||
|
|
||||||
|
**Why this priority**: Admins currently confuse these two config types, leading to incorrect settings and unpredictable AI behavior. Clear separation reduces operational errors and improves system reliability.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by verifying that Runtime Parameters are in the Sandbox tab and apply globally to AI execution profiles, while Context Config is in the Prompt Editor panel and applies per prompt version.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** admin is in the Sandbox tab, **When** they view the Runtime Parameters panel, **Then** they see sliders for Temperature, Top-P, Repeat Penalty, Max Tokens, Ctx Size, Keep-Alive
|
||||||
|
2. **Given** admin adjusts Runtime Parameters in sandbox, **When** they apply changes to production, **Then** the parameters are saved to ai_execution_profiles (global per profile)
|
||||||
|
3. **Given** admin is in the Prompt Editor panel, **When** they view the Context Config Editor, **Then** they see fields for Project Filter, Contract Filter, Page Size, Language
|
||||||
|
4. **Given** admin saves a new prompt version with context config, **When** they activate that version, **Then** the context config is saved to ai_prompts (per version)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when admin tries to activate a version without required placeholders (e.g., {{ocr_text}} missing from OCR extraction template)?
|
||||||
|
- How does system handle concurrent edits when multiple admins are editing the same prompt version?
|
||||||
|
- What happens when sandbox OCR sidecar is unavailable or returns an error?
|
||||||
|
- How does system handle activation of a version when another version is already active?
|
||||||
|
- What happens when context config contains invalid references (e.g., project ID that doesn't exist)?
|
||||||
|
- How does system handle very large prompt templates (e.g., >10,000 characters)?
|
||||||
|
- What happens when admin tries to delete the currently active version?
|
||||||
|
- How does system handle rollback to a previous version if the new version causes issues in production?
|
||||||
|
|
||||||
|
## Requirements _(mandatory)_
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST support 4 prompt types: ocr_extraction, rag_query_prompt, rag_prep_prompt, classification_prompt
|
||||||
|
- **FR-002**: System MUST separate version history by prompt_type so admins can view versions for each type independently
|
||||||
|
- **FR-003**: System MUST display an active badge (✅) next to the currently active version for each prompt type
|
||||||
|
- **FR-004**: System MUST provide a Prompt Type dropdown to switch between different prompt types
|
||||||
|
- **FR-005**: System MUST provide a Prompt Editor textarea for editing prompt templates with placeholder validation
|
||||||
|
- **FR-006**: System MUST provide a Context Config Editor form with fields: Project Filter, Contract Filter, Page Size, Language
|
||||||
|
- **FR-007**: System MUST allow admins to save new versions of prompts with both template and context config
|
||||||
|
- **FR-008**: System MUST allow admins to activate a specific version for a prompt type
|
||||||
|
- **FR-009**: System MUST invalidate Redis cache when a version is activated
|
||||||
|
- **FR-010**: System MUST provide a 3-step sandbox workflow: OCR → AI Extract → RAG Prep
|
||||||
|
- **FR-011**: System MUST allow admins to upload PDFs for sandbox testing
|
||||||
|
- **FR-012**: System MUST display sandbox results for each step (OCR text, extracted metadata, RAG chunks)
|
||||||
|
- **FR-013**: System MUST allow admins to activate a version directly from sandbox results
|
||||||
|
- **FR-014**: System MUST separate Runtime Parameters (in Sandbox tab) from Context Config (in Prompt Editor panel)
|
||||||
|
- **FR-015**: System MUST provide Runtime Parameters sliders: Temperature, Top-P, Repeat Penalty, Max Tokens, Ctx Size, Keep-Alive
|
||||||
|
- **FR-016**: System MUST save Runtime Parameters to ai_execution_profiles (global per profile)
|
||||||
|
- **FR-017**: System MUST save Context Config to ai_prompts (per prompt version)
|
||||||
|
- **FR-018**: System MUST validate that OCR extraction templates contain {{ocr_text}} placeholder
|
||||||
|
- **FR-019**: System MUST provide manual_note field for version annotations
|
||||||
|
- **FR-020**: System MUST allow admins to delete non-active versions
|
||||||
|
- **FR-021**: System MUST use single page layout consistent with ADR-027 AI Admin Console
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **AiPrompt**: Represents a prompt version with fields: prompt_type, version_number, template, context_config (JSON), is_active, manual_note, created_by, created_at
|
||||||
|
- **AiExecutionProfile**: Represents runtime parameters with fields: profile_name, temperature, top_p, repeat_penalty, max_tokens, ctx_size, keep_alive
|
||||||
|
- **SandboxJob**: Represents a sandbox test execution with fields: job_type (ocr, ai-extract, rag-prep), status, result_data, created_at
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-06-14
|
||||||
|
|
||||||
|
- Q: Are there critical ambiguities requiring clarification? → A: No - spec is clear and complete. Edge case scenarios will be addressed during planning phase.
|
||||||
|
|
||||||
|
## Success Criteria _(mandatory)_
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Admins can create and activate a new prompt version in under 2 minutes
|
||||||
|
- **SC-002**: Sandbox test results are returned within 30 seconds for OCR step and 60 seconds for AI Extract step
|
||||||
|
- **SC-003**: 95% of admins successfully complete the full 3-step sandbox workflow on first attempt
|
||||||
|
- **SC-004**: Context config changes are applied to production jobs within 5 seconds of activation
|
||||||
|
- **SC-005**: Version history loads in under 1 second regardless of number of versions
|
||||||
|
- **SC-006**: Runtime parameter changes are applied to sandbox tests immediately without page refresh
|
||||||
|
- **SC-007**: Support tickets related to prompt management confusion are reduced by 70%
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Tasks: Unified Prompt Management UX/UI
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/200-fullstacks/237-unified-prompt-management-ux-ui/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: Tests are included for backend services to ensure quality and coverage targets (Backend 70%+, Business Logic 80%+).
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
## Path Conventions
|
||||||
|
|
||||||
|
- **Backend**: `backend/src/`, `backend/test/`
|
||||||
|
- **Frontend**: `frontend/src/`, `frontend/__tests__/`
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Database schema and seed data setup
|
||||||
|
|
||||||
|
- [x] T001 Create SQL delta for ai_execution_profiles table in specs/03-Data-and-Storage/deltas/2026-06-14-create-ai-execution-profiles.sql
|
||||||
|
- [x] T002 Create SQL delta for execution profiles seed data in specs/03-Data-and-Storage/deltas/2026-06-14-seed-execution-profiles.sql
|
||||||
|
- [x] T003 Create SQL delta for additional prompt types seed data in specs/03-Data-and-Storage/deltas/2026-06-14-seed-additional-prompt-types.sql
|
||||||
|
- [x] T004 [P] Run SQL deltas to create ai_execution_profiles table and seed data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core backend entities and DTOs that all user stories depend on
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
- [x] T005 Create AiExecutionProfile entity in backend/src/modules/ai/entities/ai-execution-profile.entity.ts
|
||||||
|
- [x] T006 [P] Create ContextConfigDto in backend/src/modules/ai/dto/context-config.dto.ts
|
||||||
|
- [x] T007 [P] Create SandboxRagPrepDto in backend/src/modules/ai/dto/sandbox-rag-prep.dto.ts
|
||||||
|
- [x] T008 [P] Create CreateExecutionProfileDto in backend/src/modules/ai/dto/create-execution-profile.dto.ts
|
||||||
|
- [x] T009 [P] Create UpdateExecutionProfileDto in backend/src/modules/ai/dto/update-execution-profile.dto.ts
|
||||||
|
- [x] T010 [P] Create frontend types in frontend/lib/types/ai-prompts.ts (extend with ContextConfig, ExecutionProfile, SandboxJob types)
|
||||||
|
- [x] T011 Register AiExecutionProfile entity in AiModule in backend/src/modules/ai/ai.module.ts
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Multi-Type Prompt Management (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Admin users can manage prompt templates for multiple AI workflow types through a single unified interface with separated version history.
|
||||||
|
|
||||||
|
**Independent Test**: Create and activate versions for different prompt types, verify version history is correctly separated by type, and confirm active version badge displays correctly.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [P] [US1] Unit test for placeholder validation in AiPromptsService in backend/test/unit/ai/ai-prompts.service.spec.ts
|
||||||
|
- [x] T013 [P] [US1] Integration test for version number increment per prompt type in backend/test/integration/ai/ai-prompts.service.spec.ts
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T014 [US1] Extend AiPromptsService with placeholder validation logic in backend/src/modules/ai/services/ai-prompts.service.ts
|
||||||
|
- [x] T015 [US1] Extend AiPromptsService with version number increment per prompt_type in backend/src/modules/ai/services/ai-prompts.service.ts
|
||||||
|
- [x] T016 [US1] Create PromptTypeDropdown component in frontend/components/admin/ai/PromptTypeDropdown.tsx
|
||||||
|
- [x] T017 [US1] Extend VersionHistory component with prompt_type filtering in frontend/components/admin/ai/VersionHistory.tsx
|
||||||
|
- [x] T018 [US1] Create PromptEditor component with placeholder validation in frontend/components/admin/ai/PromptEditor.tsx
|
||||||
|
- [x] T019 [US1] Create unified prompt management page in frontend/app/(admin)/admin/ai/prompt-management/page.tsx
|
||||||
|
- [x] T020 [US1] Extend admin-ai.service.ts with prompt type filtering methods in frontend/lib/services/admin-ai.service.ts
|
||||||
|
- [x] T021 [US1] Add i18n keys for prompt management UI in frontend/public/locales/th/common.json and en/common.json
|
||||||
|
|
||||||
|
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Context Configuration Management (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Admin users can view, edit, save, and apply context configuration for each prompt version to control what data context the AI sees.
|
||||||
|
|
||||||
|
**Independent Test**: Edit context config fields, save a new version, and verify that the context config is correctly persisted and applied when the version is activated.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T022 [P] [US2] Unit test for context config CRUD in AiPromptsService in backend/test/unit/ai/ai-prompts.service.spec.ts
|
||||||
|
- [x] T023 [P] [US2] Integration test for context config validation against database in backend/test/integration/ai/ai-prompts.service.spec.ts
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T024 [US2] Add GET /api/ai/prompts/:type/:version/context-config endpoint in backend/src/modules/ai/controllers/ai-prompts.controller.ts
|
||||||
|
- [x] T025 [US2] Add PUT /api/ai/prompts/:type/:version/context-config endpoint in backend/src/modules/ai/controllers/ai-prompts.controller.ts
|
||||||
|
- [x] T026 [US2] Extend AiPromptsService with context config CRUD methods in backend/src/modules/ai/services/ai-prompts.service.ts
|
||||||
|
- [x] T027 [US2] Add context config validation (project/contract ID validation) in backend/src/modules/ai/services/ai-prompts.service.ts
|
||||||
|
- [x] T028 [US2] Create ContextConfigEditor component in frontend/components/admin/ai/ContextConfigEditor.tsx
|
||||||
|
- [x] T029 [US2] Integrate ContextConfigEditor into prompt management page in frontend/app/(admin)/admin/ai/prompt-management/page.tsx
|
||||||
|
- [x] T030 [US2] Extend admin-ai.service.ts with context config API methods in frontend/lib/services/admin-ai.service.ts
|
||||||
|
|
||||||
|
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Three-Step Sandbox Testing (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Admin users can test the full AI pipeline (OCR → AI Extract → RAG Prep) in sandbox to validate prompt versions before activation.
|
||||||
|
|
||||||
|
**Independent Test**: Upload a PDF, run all three sandbox steps sequentially, and verify that each step produces expected outputs (OCR text, extracted metadata, RAG chunks).
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T031 [P] [US3] Unit test for sandbox RAG Prep job processing in ai-batch.processor in backend/test/unit/ai/ai-batch.processor.spec.ts
|
||||||
|
- [x] T032 [P] [US3] Integration test for 3-step sandbox workflow in backend/test/integration/ai/sandbox-workflow.spec.ts
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T033 [US3] Add POST /api/ai/admin/sandbox/rag-prep endpoint in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T034 [US3] Add GET /api/ai/admin/sandbox/job/:jobId endpoint in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T035 [US3] Extend ai-batch.processor with sandbox-rag-prep job handler in backend/src/modules/ai/processors/ai-batch.processor.ts
|
||||||
|
- [x] T036 [US3] Extend OcrService with RAG Prep integration (semantic chunking + embedding) in backend/src/modules/ai/services/ocr.service.ts
|
||||||
|
- [x] T037 [US3] Create SandboxTabs component with 3-step workflow in frontend/components/admin/ai/SandboxTabs.tsx
|
||||||
|
- [x] T038 [US3] Integrate SandboxTabs into prompt management page in frontend/app/(admin)/admin/ai/prompt-management/page.tsx
|
||||||
|
- [x] T039 [US3] Extend admin-ai.service.ts with sandbox RAG Prep API methods in frontend/lib/services/admin-ai.service.ts
|
||||||
|
- [x] T040 [US3] Add "Activate This Version" button in sandbox results in frontend/components/admin/ai/SandboxTabs.tsx
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories should now be independently functional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 - Runtime Parameters vs Context Config Separation (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Admin users have clear separation between Runtime Parameters (AI model behavior) and Context Config (data context) to avoid confusion.
|
||||||
|
|
||||||
|
**Independent Test**: Verify that Runtime Parameters are in the Sandbox tab and apply globally to AI execution profiles, while Context Config is in the Prompt Editor panel and applies per prompt version.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [x] T041 [P] [US4] Unit test for execution profile CRUD in AiExecutionProfilesService in backend/test/unit/ai/ai-execution-profiles.service.spec.ts
|
||||||
|
- [x] T042 [P] [US4] Integration test for runtime parameters application to sandbox in backend/test/integration/ai/execution-profiles.spec.ts
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T043 [US4] Create AiExecutionProfilesService in backend/src/modules/ai/services/ai-execution-profiles.service.ts
|
||||||
|
- [x] T044 [US4] Add GET /api/ai/execution-profiles endpoint in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T045 [US4] Add POST /api/ai/execution-profiles endpoint in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T046 [US4] Add PUT /api/ai/execution-profiles/:id endpoint in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T047 [US4] Add DELETE /api/ai/execution-profiles/:id endpoint in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T048 [US4] Create RuntimeParametersPanel component in frontend/components/admin/ai/RuntimeParametersPanel.tsx
|
||||||
|
- [x] T049 [US4] Integrate RuntimeParametersPanel into SandboxTabs in frontend/components/admin/ai/SandboxTabs.tsx
|
||||||
|
- [x] T050 [US4] Extend admin-ai.service.ts with execution profile API methods in frontend/lib/services/admin-ai.service.ts
|
||||||
|
- [x] T051 [US4] Add "Apply to Production" button in RuntimeParametersPanel in frontend/components/admin/ai/RuntimeParametersPanel.tsx
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories including US4 should now be independently functional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Improvements that affect multiple user stories
|
||||||
|
|
||||||
|
- [x] T052 [P] Add error handling following ADR-007 (BusinessException hierarchy) in backend/src/modules/ai/services/ai-prompts.service.ts
|
||||||
|
- [x] T053 [P] Add error handling following ADR-007 in backend/src/modules/ai/services/ai-execution-profiles.service.ts
|
||||||
|
- [x] T054 [P] Add CASL guards to all new mutation endpoints in backend/src/modules/ai/controllers/ai-prompts.controller.ts
|
||||||
|
- [x] T055 [P] Add CASL guards to all new mutation endpoints in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T056 [P] Add ThrottlerGuard to sandbox endpoints in backend/src/modules/ai/controllers/ai.controller.ts
|
||||||
|
- [x] T057 [P] Add Redis cache invalidation on version activation in backend/src/modules/ai/services/ai-prompts.service.ts
|
||||||
|
- [x] T058 [P] Add i18n keys for all new UI components in frontend/public/locales/th/common.json and en/common.json
|
||||||
|
- [x] T059 [P] Add TypeScript strict mode compliance checks (no any, no console.log) in backend/src/modules/ai/ and frontend/components/admin/ai/
|
||||||
|
- [x] T060 [P] Add E2E test for full prompt management workflow in frontend/e2e/prompt-management.spec.ts
|
||||||
|
- [x] T061 Run quickstart.md validation checklist
|
||||||
|
- [x] T062 Update ADR-037 with implementation status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||||
|
- **User Stories (Phase 3-6)**: All depend on Foundational phase completion
|
||||||
|
- User stories can then proceed in parallel (if staffed)
|
||||||
|
- Or sequentially in priority order (US1 → US2 → US3 → US4)
|
||||||
|
- **Polish (Phase 7)**: Depends on all desired user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||||
|
- **User Story 2 (P1)**: Can start after Foundational (Phase 2) - Integrates with US1 (uses same page) but independently testable
|
||||||
|
- **User Story 3 (P1)**: Can start after Foundational (Phase 2) - Integrates with US1 (uses same page) but independently testable
|
||||||
|
- **User Story 4 (P2)**: Can start after Foundational (Phase 2) - Integrates with US3 (SandboxTabs) but independently testable
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written and FAIL before implementation
|
||||||
|
- DTOs before services
|
||||||
|
- Services before controllers
|
||||||
|
- Backend before frontend (for API-dependent features)
|
||||||
|
- Core implementation before integration
|
||||||
|
- Story complete before moving to next priority
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- All Setup tasks marked [P] can run in parallel
|
||||||
|
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||||
|
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||||
|
- All tests for a user story marked [P] can run in parallel
|
||||||
|
- DTOs within a story marked [P] can run in parallel
|
||||||
|
- Different user stories can be worked on in parallel by different team members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch all tests for User Story 1 together:
|
||||||
|
Task: "Unit test for placeholder validation in AiPromptsService in backend/test/unit/ai/ai-prompts.service.spec.ts"
|
||||||
|
Task: "Integration test for version number increment per prompt type in backend/test/integration/ai/ai-prompts.service.spec.ts"
|
||||||
|
|
||||||
|
# Launch all DTOs for User Story 1 together (in Foundational phase):
|
||||||
|
Task: "Create ContextConfigDto in backend/src/modules/ai/dto/context-config.dto.ts"
|
||||||
|
Task: "Create SandboxRagPrepDto in backend/src/modules/ai/dto/sandbox-rag-prep.dto.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup
|
||||||
|
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||||
|
3. Complete Phase 3: User Story 1
|
||||||
|
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational → Foundation ready
|
||||||
|
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||||
|
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||||
|
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||||
|
5. Add User Story 4 → Test independently → Deploy/Demo
|
||||||
|
6. Each story adds value without breaking previous stories
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Team completes Setup + Foundational together
|
||||||
|
2. Once Foundational is done:
|
||||||
|
- Developer A: User Story 1 (Multi-Type Prompt Management)
|
||||||
|
- Developer B: User Story 2 (Context Configuration Management)
|
||||||
|
- Developer C: User Story 3 (Three-Step Sandbox Testing)
|
||||||
|
3. After P1 stories complete:
|
||||||
|
- Developer A: User Story 4 (Runtime Parameters Separation)
|
||||||
|
- Developer B: Polish & Cross-Cutting Concerns
|
||||||
|
- Developer C: E2E testing
|
||||||
|
4. Stories complete and integrate independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- Each user story should be independently completable and testable
|
||||||
|
- Verify tests fail before implementing
|
||||||
|
- Commit after each task or logical group
|
||||||
|
- Stop at any checkpoint to validate story independently
|
||||||
|
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||||
|
- Follow ADR-019 UUID handling (no parseInt, use publicId only)
|
||||||
|
- Follow ADR-009 schema changes (edit SQL directly, no migrations)
|
||||||
|
- Follow ADR-016 security (CASL guards on all mutations)
|
||||||
|
- Follow ADR-007 error handling (layered classification)
|
||||||
|
- Follow ADR-023/023A AI boundary (BullMQ queues, no direct AI access)
|
||||||
@@ -20,3 +20,7 @@
|
|||||||
| 2026-06-11 | v1.9.10 | Feature-235 validation follow-up — validation-report.md = PARTIAL, cutover-validation checklist added, targeted verification 27/27 | ⏳ Pending T032 execution |
|
| 2026-06-11 | v1.9.10 | Feature-235 validation follow-up — validation-report.md = PARTIAL, cutover-validation checklist added, targeted verification 27/27 | ⏳ Pending T032 execution |
|
||||||
| 2026-06-12 | v1.9.10 | Feature-235 quickstart.md fix — corrected Backend URL, API paths, token extraction, verified Gate 1A/1B/1D (Session by Devin Cascade) | ✅ Complete (T032 done) |
|
| 2026-06-12 | v1.9.10 | Feature-235 quickstart.md fix — corrected Backend URL, API paths, token extraction, verified Gate 1A/1B/1D (Session by Devin Cascade) | ✅ Complete (T032 done) |
|
||||||
| 2026-06-13 | v1.9.10 | Feature-236 Unified OCR Architecture & Sandbox Parity — endpoints, UI parameters, apply production, dual-model, project/contract context selector, snapshot dual-model, 256/256 tests passed | ✅ Complete (tsc + eslint clean) |
|
| 2026-06-13 | v1.9.10 | Feature-236 Unified OCR Architecture & Sandbox Parity — endpoints, UI parameters, apply production, dual-model, project/contract context selector, snapshot dual-model, 256/256 tests passed | ✅ Complete (tsc + eslint clean) |
|
||||||
|
| 2026-06-14 | v1.9.10 | Feature-303 Frontend Test Coverage — added auth-store/i18n/circulation/OCR sandbox/layout tests; coverage 51.62% statements, 92 files / 692 tests passed | ✅ Phase 2 gate passed |
|
||||||
|
| 2026-06-14 | v1.9.10 | Feature-237 Unified Prompt Management UX/UI code review — report saved; frontend tsc passed; backend build blocked by RFA service compile errors plus prompt context/idempotency findings | ❌ Request changes |
|
||||||
|
| 2026-06-14 | v1.9.10 | Correspondence Module Review Fixes — ValidationException, CSV row cap (10000), formula injection, bulkCancel logging, dynamic re-index status, RecipientDto nested validation, correspondence.edit permission, IdempotencyInterceptor on all 7 mutation endpoints | ✅ Complete |
|
||||||
|
| 2026-06-14 | v1.9.10 | RFA ADR-001/021 Migration — ตัด CorrespondenceRouting/RoutingTemplate repos ออก; ตัด templateId จาก DTO; เพิ่ม static constants (WORKFLOW_CODE/STATE_TO_STATUS/DEFAULT_APPROVED_CODE); tsc --noEmit exit 0; 26/26 frontend tests pass | ✅ Complete |
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Session — 2026-06-14 (Correspondence Module Review Fixes + RFA ADR-001/021 Refactor)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
สองงานหลักใน session นี้:
|
||||||
|
1. **Correspondence Module Code Review Fixes** — แก้ทุก item จาก `speckit-reviewer` report (HIGH + MEDIUM + Remaining)
|
||||||
|
2. **RFA Service ADR-001/021 Migration** — User refactor `rfa.service.ts` ตัด deprecated routing-template entities ออก และ rewire workflow ผ่าน Unified Workflow Engine
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Correspondence Module Review Fixes
|
||||||
|
|
||||||
|
### ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
รายการจาก code review report ที่ต้องแก้ไข:
|
||||||
|
|
||||||
|
| # | Severity | ปัญหา |
|
||||||
|
|---|----------|-------|
|
||||||
|
| 1 | HIGH | `throw new Error` ใน `processAction` — ละเมิด ADR-007 |
|
||||||
|
| 2 | HIGH | CSV export capped ที่ 10 rows เพราะ `findAll` ใช้ default `limit: 10` |
|
||||||
|
| 3 | MEDIUM | `bulkCancel` empty `catch {}` — ไม่ log error |
|
||||||
|
| 4 | MEDIUM | `escapeCsv` ไม่กัน formula injection (OWASP) |
|
||||||
|
| 5 | MEDIUM | `update()` re-index search ด้วย hardcoded `status: 'DRAFT'` |
|
||||||
|
| 6 | LOW | `processAction` ไม่มี `@Audit` decorator |
|
||||||
|
| R1 | Remaining | `recipients` DTO ไม่มี nested validation |
|
||||||
|
| R2 | Remaining | `PUT /:uuid` ใช้ permission `correspondence.create` แทน `correspondence.edit` |
|
||||||
|
| R3 | Remaining | ไม่มี Idempotency protection บน mutation endpoints |
|
||||||
|
|
||||||
|
### การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
|------|---------------|
|
||||||
|
| `correspondence.controller.ts` | เพิ่ม `import { ValidationException }` + แทน `throw new Error` ด้วย `throw new ValidationException` |
|
||||||
|
| `correspondence.controller.ts` | เพิ่ม `@Audit('correspondence.workflow_action', 'correspondence')` บน `processAction` |
|
||||||
|
| `correspondence.controller.ts` | แก้ `PUT /:uuid` → `@RequirePermission('correspondence.edit')` (permission id=73 มีใน seed แล้ว) |
|
||||||
|
| `correspondence.controller.ts` | เพิ่ม `@UseInterceptors(IdempotencyInterceptor)` ทุก 7 mutation endpoints |
|
||||||
|
| `correspondence.service.ts` | `bulkCancel` catch block เพิ่ม `this.logger.warn(...)` |
|
||||||
|
| `correspondence.service.ts` | `exportCsv` force `limit: 10000` override |
|
||||||
|
| `correspondence.service.ts` | `escapeCsv` เพิ่ม prefix `'` สำหรับ values เริ่มต้นด้วย `=`, `+`, `-`, `@`, `\t`, `\r` |
|
||||||
|
| `correspondence.service.ts` | `update()` re-index ใช้ `currentRevisionStatus` จาก `revisions.find(r => r.isCurrent)` แทน `'DRAFT'` |
|
||||||
|
| `dto/create-correspondence.dto.ts` | เพิ่ม `RecipientDto` class + `@ValidateNested({ each: true })` + `@Type(() => RecipientDto)` |
|
||||||
|
|
||||||
|
### กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
- CSV export ต้อง override `limit` ก่อน call `findAll` เสมอ (ห้ามใช้ pagination default)
|
||||||
|
- `escapeCsv` ต้องกัน OWASP formula injection (prefix `'` สำหรับ `=+−@\t\r`)
|
||||||
|
- Mutation endpoints ใน correspondence controller ต้องมี `@UseInterceptors(IdempotencyInterceptor)`
|
||||||
|
- `PUT /:uuid` ต้องใช้ `correspondence.edit` permission (ไม่ใช่ `correspondence.create`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: RFA Service ADR-001/021 Migration (User-driven)
|
||||||
|
|
||||||
|
### สิ่งที่ User เปลี่ยนแปลง
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
|------|---------------|
|
||||||
|
| `rfa.service.ts` | ลบ `CorrespondenceRouting`, `RoutingTemplate`, `RoutingTemplateStep` imports และ repos |
|
||||||
|
| `rfa.service.ts` | เพิ่ม `RfaMapped` interface fields: `workflowInstanceId`, `workflowState`, `availableActions` (ADR-021) |
|
||||||
|
| `rfa.service.ts` | เพิ่ม static constants: `WORKFLOW_CODE`, `STATE_TO_STATUS`, `DEFAULT_APPROVED_CODE` |
|
||||||
|
| `rfa.service.ts` | EC-RFA-001 check ย้ายเข้า transaction + `FOR UPDATE` lock (กัน TOCTOU race condition) |
|
||||||
|
| `rfa.service.ts` | `createInstance` ใช้ `RfaService.WORKFLOW_CODE` constant แทน hardcoded `'RFA_APPROVAL'` |
|
||||||
|
| `rfa.service.ts` | `findOneByUuid` ดึง workflow instance ผ่าน `getInstanceByEntity()` และ expose ADR-021 fields |
|
||||||
|
| `rfa.service.ts` | `submit()` ตัด `templateId` parameter ออก — rewire ผ่าน `workflowEngine.processTransition()` |
|
||||||
|
| `rfa.service.ts` | `processAction()` ตัด routing-template lookup ออก — ใช้ `workflowEngine.processTransition()` |
|
||||||
|
| `rfa.service.ts` | เพิ่ม `syncRevisionStatus()` helper: map workflow state → RFA status code + approve code |
|
||||||
|
| `rfa.service.ts` | เพิ่ม `notifyRecipients()` helper: ADR-008 async notify ผ่าน `notificationService.send()` |
|
||||||
|
| `rfa.module.ts` | ตัด deprecated routing-template entities ออกจาก `TypeOrmModule.forFeature([...])` |
|
||||||
|
|
||||||
|
### กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
- RFA ใช้ `workflowEngine.processTransition()` เป็น single entry point สำหรับ submit/approve/reject
|
||||||
|
- `syncRevisionStatus()` ต้องใช้ `STATE_TO_STATUS` map — ห้าม hardcode status code
|
||||||
|
- EC-RFA-001 ต้องทำใน transaction ด้วย `FOR UPDATE` lock (ไม่ใช่ก่อน transaction)
|
||||||
|
- Notification ต้องเรียกหลัง transaction commit แบบ `void ... .catch(...)` (ADR-008)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] `pnpm --filter backend build` ผ่าน (ต้องตรวจสอบหลัง rfa.module.ts cleanup ครบ)
|
||||||
|
- [ ] `pnpm --filter backend exec tsc --noEmit` ไม่มี type error
|
||||||
|
- [ ] ทดสอบ `POST /correspondences` with `Idempotency-Key` header — duplicate request ต้องได้ cached response
|
||||||
|
- [ ] ทดสอบ CSV export มีมากกว่า 10 rows
|
||||||
|
- [ ] ทดสอบ `PUT /correspondences/:uuid` กับ user ที่มี `correspondence.edit` permission
|
||||||
|
- [ ] ทดสอบ `recipients` DTO validation กัน invalid `type` (ไม่ใช่ TO/CC)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Session 2026-06-14 — Feature 237 Code Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Reviewed `specs/200-fullstacks/237-unified-prompt-management-ux-ui` and the related working-tree changes for Unified Prompt Management UX/UI. The review report was saved to `specs/200-fullstacks/237-unified-prompt-management-ux-ui/code-review-report.md` with overall status `REQUEST CHANGES`.
|
||||||
|
|
||||||
|
## ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
- Backend build is blocked by a partial `backend/src/modules/rfa/rfa.service.ts` ADR-001/021 migration: removed routing dependencies are still referenced, `RfaService.WORKFLOW_CODE` is missing, and one line appears corrupted.
|
||||||
|
- Prompt context filters mix public UUID strings from the frontend with internal numeric IDs in `AiPromptsService.resolveContext()`, causing `Number(uuid)` to become `NaN` and potentially disabling project scoping.
|
||||||
|
- New prompt/admin sandbox mutations do not consistently enforce `Idempotency-Key`, despite AGENTS/ADR-016 requirements for critical `POST`/`PUT`/`PATCH`.
|
||||||
|
- New prompt seeds and service validation disagree on placeholders for `rag_query_prompt`, `rag_prep_prompt`, and `classification_prompt`.
|
||||||
|
- DTOs accept weak string/object shapes for public IDs and sandbox text.
|
||||||
|
|
||||||
|
## การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
| ---- | -------------- |
|
||||||
|
| `specs/200-fullstacks/237-unified-prompt-management-ux-ui/code-review-report.md` | บันทึก Code Review Report พร้อม findings และ verification |
|
||||||
|
| `specs/88-logs/session-2026-06-14-feature-237-code-review.md` | บันทึก session log สำหรับ review นี้ |
|
||||||
|
| `specs/88-logs/rollouts.md` | เพิ่ม rollout row ของ Feature-237 review |
|
||||||
|
| `memory/project-memory-override.md` | เพิ่ม Next Session Focus สำหรับ Feature-237 follow-up |
|
||||||
|
|
||||||
|
## กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
ไม่มี decision ใหม่ใน session นี้ ใช้กฎเดิมจาก AGENTS.md/ADR-016/ADR-019/ADR-023A/ADR-029 ต่อไป:
|
||||||
|
|
||||||
|
- Public API และ frontend ต้องใช้ `publicId` UUID เท่านั้น ห้ามแปลง UUID เป็น number.
|
||||||
|
- Mutating endpoints ที่ critical ต้องมี `Idempotency-Key`.
|
||||||
|
- AI prompt/context work ต้องรักษา project boundary และ validation ก่อนส่ง context เข้า AI.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [x] `pnpm --filter lcbp3-frontend exec tsc --noEmit` ผ่าน
|
||||||
|
- [x] `pnpm --filter backend build` รันแล้วและพบ failure ที่ต้องแก้ก่อน merge
|
||||||
|
- [x] Review artifact created: `specs/200-fullstacks/237-unified-prompt-management-ux-ui/code-review-report.md`
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Session 17 - 2026-06-14 (Frontend Test Coverage)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implemented additional frontend unit/component tests for Feature-303 and lifted frontend statement coverage above the Phase 2 gate. The verified coverage run now reports 92 passed test files, 692 passed tests, and 51.62% statements.
|
||||||
|
|
||||||
|
## ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
Frontend coverage was still below the Feature-303 Phase 2 target after the first coverage expansion. The biggest remaining gaps were uncovered state/i18n utilities, Circulation rendering branches, the large OCR sandbox prompt manager, and Layout widgets.
|
||||||
|
|
||||||
|
## การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
| ---- | -------------- |
|
||||||
|
| `frontend/lib/stores/__tests__/auth-store.test.ts` | เพิ่ม test สำหรับ auth state transitions, logout, role และ permission helpers |
|
||||||
|
| `frontend/lib/i18n/__tests__/index.test.ts` | เพิ่ม test สำหรับ Thai/English translator, fallback key และ template params |
|
||||||
|
| `frontend/components/circulation/__tests__/circulation-list.test.tsx` | ปรับ DataTable mock ให้ render column cells จริง เพื่อครอบ status, progress, fallback และ action link |
|
||||||
|
| `frontend/components/admin/ai/__tests__/ocr-sandbox-prompt-manager.test.tsx` | เพิ่ม smoke/interaction tests สำหรับ OCR sandbox prompt manager ด้วย mocked hooks/services |
|
||||||
|
| `frontend/components/layout/__tests__/layout-widgets.test.tsx` | เพิ่ม tests สำหรับ Sidebar, MobileSidebar, GlobalSearch, ProjectSwitcher, NotificationsDropdown และ UserMenu |
|
||||||
|
| `specs/300-others/303-frontend-test-coverage/tasks.md` | อัปเดต task checklist สำหรับงานที่ verify แล้ว |
|
||||||
|
| `specs/300-others/303-frontend-test-coverage/plan.md` | บันทึก coverage run record ล่าสุด |
|
||||||
|
|
||||||
|
## กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
ไม่มี decision ใหม่ที่ต้อง lock เพิ่มใน memory ระดับ project. งานนี้ยืนยัน pattern เดิม: mark task complete เฉพาะหลัง `tsc` และ coverage run ผ่านจริงเท่านั้น.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [x] `pnpm --filter lcbp3-frontend exec tsc --noEmit`
|
||||||
|
- [x] `pnpm --filter lcbp3-frontend exec vitest run --coverage`
|
||||||
|
- [x] Coverage: Statements 51.62%, Branches 41.03%, Functions 50.27%, Lines 52.47%
|
||||||
|
- [x] Feature-303 Phase 2 gate passed (Statements >= 50%)
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [ ] T034: เพิ่ม coverage สำหรับ Admin dashboard components
|
||||||
|
- [ ] T050-T053: ทำ cross-cutting audit สำหรับ `any`/`console.log`, `publicId` mock data, file headers, และ final coverage record
|
||||||
|
- [ ] Phase 3 target remains 70% statements
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Session — 2026-06-14 (RFA ADR-001/021 Migration — Complete)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ทำ RFA Workflow Migration ให้เสร็จครบ — ตัด `templateId` ออกจาก DTO ทั้ง frontend/backend,
|
||||||
|
ลบ deprecated routing-template entities + `RfaWorkflowService` ออกจาก `rfa.module.ts`,
|
||||||
|
เพิ่ม static constants ที่หายไปใน `RfaService`, และอัปเดต tests ให้ align กับ contract ใหม่
|
||||||
|
ผลลัพธ์: `tsc --noEmit` exit 0 + 26/26 frontend tests pass
|
||||||
|
|
||||||
|
## ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
- `rfa.service.ts` อ้างอิง `RfaService.WORKFLOW_CODE`, `RfaService.STATE_TO_STATUS`, `RfaService.DEFAULT_APPROVED_CODE` แต่ไม่มีการ declare static constants เหล่านี้ในคลาส → TS2339 compile error
|
||||||
|
- `SubmitRfaDto` (backend + frontend) ยังมี `templateId` ซึ่งถูกตัดออกจาก service signature แล้ว
|
||||||
|
- `rfa.module.ts` ยังนำเข้า deprecated entities: `RfaWorkflow`, `RfaWorkflowTemplate`, `RfaWorkflowTemplateStep`, `CorrespondenceRouting`, `RoutingTemplate`, `RoutingTemplateStep` และ `RfaWorkflowService`
|
||||||
|
- `detail.tsx` ยังมี `templateId` state + "Routing Template ID" input + `Label` ที่ไม่จำเป็น
|
||||||
|
- Test files ยังอ้างอิง `templateId: 1` ใน submit DTO และ `'Routing Template ID'` assertion
|
||||||
|
|
||||||
|
## การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
| ---- | -------------- |
|
||||||
|
| `backend/src/modules/rfa/dto/submit-rfa.dto.ts` | ตัด `templateId` field + `@IsInt` + `@IsNotEmpty` ออก; เหลือแค่ `reviewTeamPublicId?` |
|
||||||
|
| `backend/src/modules/rfa/rfa.module.ts` | ลบ `RfaWorkflow`, `RfaWorkflowTemplate`, `RfaWorkflowTemplateStep`, `CorrespondenceRouting`, `RoutingTemplate`, `RoutingTemplateStep` ออกจาก imports/forFeature; ลบ `RfaWorkflowService` จาก providers |
|
||||||
|
| `backend/src/modules/rfa/rfa.service.ts` | เพิ่ม static readonly constants: `WORKFLOW_CODE = 'RFA_APPROVAL'`, `STATE_TO_STATUS` (Record), `DEFAULT_APPROVED_CODE = '1A'` |
|
||||||
|
| `frontend/lib/services/rfa.service.ts` | ตัด `templateId: number` ออกจาก `SubmitRfaDto` interface |
|
||||||
|
| `frontend/components/rfas/detail.tsx` | ตัด `templateId` state + setter + "Routing Template ID" `<input>` block ออก; `handleSubmit()` ส่งแค่ `{ reviewTeamPublicId }` |
|
||||||
|
| `frontend/lib/services/__tests__/rfa.service.test.ts` | อัปเดต submit test DTO: `{ reviewTeamPublicId: 'uuid-team' }` (ไม่มี `templateId`) |
|
||||||
|
| `frontend/components/rfas/__tests__/detail.test.tsx` | ลบ `expect(screen.getByText('Routing Template ID')).toBeInTheDocument()` |
|
||||||
|
|
||||||
|
## กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
- **RFA submit contract:** `POST /rfas/:uuid/submit` รับเฉพาะ `{ reviewTeamPublicId?: string }` — ไม่มี `templateId`
|
||||||
|
- **Workflow Code:** `RFA_APPROVAL` (static constant ในคลาส ห้าม hardcode ในแต่ละ method)
|
||||||
|
- **STATE_TO_STATUS map:** `DRAFT→DFT`, `CONSULTANT_REVIEW→FRE`, `OWNER_REVIEW→FAP`, `APPROVED→FCO`
|
||||||
|
- **DEFAULT_APPROVED_CODE:** `'1A'` (fallback เมื่อ payload ไม่มี approveCode)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [x] `npx tsc --noEmit` (backend) → exit 0, no type errors
|
||||||
|
- [x] `npx vitest run lib/services/__tests__/rfa.service.test.ts components/rfas/__tests__/detail.test.tsx` → 26/26 passed
|
||||||
Reference in New Issue
Block a user