diff --git a/.agents/rules/00-project-context.md b/.agents/rules/00-project-context.md index fce0cefd..a598b1ed 100644 --- a/.agents/rules/00-project-context.md +++ b/.agents/rules/00-project-context.md @@ -126,25 +126,25 @@ Best practice — follow when possible: ## 🚫 Forbidden Actions -| ❌ Forbidden | ✅ Correct Approach | -| ----------------------------------------------- | ------------------------------------------------------- | -| SQL Triggers for business logic | NestJS Service methods | -| `.env` files in production | `docker-compose.yml` environment section | -| TypeORM migration files | Edit schema SQL directly (ADR-009) | -| Inventing table/column names | Verify against `lcbp3-v1.9.0-schema-02-tables.sql` | -| `any` TypeScript type | Proper types / generics | -| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | -| `req: any` in controllers | `RequestWithUser` typed interface | -| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | -| Exposing INT PK in API responses | UUIDv7 (ADR-019) | -| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023) | -| Direct file operations bypassing StorageService | `StorageService` for all file moves | -| Inline email/notification sending | BullMQ queue job | -| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | -| AI direct cloud API calls | On-premises Ollama only (ADR-023) | -| AI outputs without human validation | Human-in-the-loop validation required (ADR-023) | -| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama/Qdrant (ADR-023A) | -| Qdrant query without `projectPublicId` filter | `QdrantService.search(projectPublicId, ...)` (ADR-023A) | +| ❌ Forbidden | ✅ Correct Approach | ⚠️ Why | +| ----------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | +| SQL Triggers for business logic | NestJS Service methods | Untestable; bypasses audit log | +| `.env` files in production | `docker-compose.yml` environment section | Secrets exposed in version control | +| TypeORM migration files | Edit schema SQL directly (ADR-009) | Migration drift risk; schema managed via SQL delta | +| Inventing table/column names | Verify against `schema-02-tables.sql` | Schema mismatch causes silent runtime errors | +| `any` TypeScript type | Proper types / generics | Defeats strict mode; hides runtime type errors | +| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | Log flooding in production; risk of data leakage | +| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable | +| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"0195…"` parsed to integer `19` — silently wrong | +| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks | +| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023/023A) | Bypasses RBAC, audit trail, and validation layer | +| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail | +| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure | +| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production | +| AI direct cloud API calls | On-premises Ollama only (ADR-023/023A) | Data privacy violation; no audit control | +| AI outputs without human validation | Human-in-the-loop validation required (ADR-023/023A) | Unvalidated AI metadata corrupts document records | +| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama (ADR-023A) | Bypasses audit log, RBAC, and error handling layer | +| Qdrant query without `projectPublicId` filter | `QdrantService.search(projectPublicId, ...)` (ADR-023A) | Cross-project data leak via vector search | --- diff --git a/.agents/rules/04-domain-terminology.md b/.agents/rules/04-domain-terminology.md index 14524d8f..54cf9e15 100644 --- a/.agents/rules/04-domain-terminology.md +++ b/.agents/rules/04-domain-terminology.md @@ -2,17 +2,17 @@ ## DMS Glossary -| ✅ Use | ❌ Don't Use | -| ------------------ | ------------------------------------- | -| Correspondence | Letter, Communication, Document | -| RFA | Approval Request, Submit for Approval | -| Transmittal | Delivery Note, Cover Letter | -| Circulation | Distribution, Routing | -| Shop Drawing | Construction Drawing | -| Contract Drawing | Design Drawing, Blueprint | -| Workflow Engine | Approval Flow, Process Engine | -| Document Numbering | Document ID, Auto Number | -| RBAC | Permission System (generic) | +| ✅ Use | ❌ Don't Use | คำอธิบายเพิ่มเติม | +| ------------------ | ------------------------------------- | ------------------------------------------------ | +| Correspondence | Letter, Communication, Document | ครอบคลุมทุกประเภท: Letter, RFA, Memo, ฯลฯ | +| RFA | Approval Request, Submit for Approval | เอกสารขออนุมัติ (ชนิดหนึ่งของ Correspondence) | +| Transmittal | Delivery Note, Cover Letter | เอกสารนำส่ง (ชนิดหนึ่งของ Correspondence) | +| Circulation | Distribution, Routing | ใบเวียนเอกสารภายใน (ชนิดหนึ่งของ Correspondence) | +| Shop Drawing | Construction Drawing | แบบก่อสร้าง | +| Contract Drawing | Design Drawing, Blueprint | แบบคู่สัญญา | +| Workflow Engine | Approval Flow, Process Engine | เครื่องมือจัดการลำดับงาน | +| Document Numbering | Document ID, Auto Number | ระบบจัดการเลขที่เอกสาร | +| RBAC | Permission System (generic) | การควบคุมสิทธิ์ตามบทบาท | ## Full Glossary diff --git a/.agents/rules/05-forbidden-actions.md b/.agents/rules/05-forbidden-actions.md index d59a9f40..1f4f607a 100644 --- a/.agents/rules/05-forbidden-actions.md +++ b/.agents/rules/05-forbidden-actions.md @@ -2,25 +2,25 @@ ## ❌ Never Do This -| ❌ Forbidden | ✅ Correct Approach | -| ----------------------------------------------- | ----------------------------------------------------------------- | -| SQL Triggers for business logic | NestJS Service methods | -| `.env` files in production | `docker-compose.yml` environment section | -| TypeORM migration files | Edit schema SQL directly (ADR-009) | -| Inventing table/column names | Verify against `lcbp3-v1.9.0-schema-02-tables.sql` | -| `any` TypeScript type | Proper types / generics | -| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | -| `req: any` in controllers | `RequestWithUser` typed interface | -| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | -| Exposing INT PK in API responses | UUIDv7 (ADR-019) | -| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023) | -| Direct file operations bypassing StorageService | `StorageService` for all file moves | -| Inline email/notification sending | BullMQ queue job | -| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | -| AI direct cloud API calls | On-premises Ollama only (ADR-023) | -| AI outputs without human validation | Human-in-the-loop validation required (ADR-023) | -| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama/Qdrant (ADR-023A) | -| Qdrant query without projectPublicId filter | QdrantService.search(projectPublicId: string) required (ADR-023A) | +| ❌ Forbidden | ✅ Correct Approach | ⚠️ Why | +| ----------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | +| SQL Triggers for business logic | NestJS Service methods | Untestable; bypasses audit log | +| `.env` files in production | `docker-compose.yml` environment section | Secrets exposed in version control | +| TypeORM migration files | Edit schema SQL directly (ADR-009) | Migration drift risk; schema managed via SQL delta | +| Inventing table/column names | Verify against `schema-02-tables.sql` | Schema mismatch causes silent runtime errors | +| `any` TypeScript type | Proper types / generics | Defeats strict mode; hides runtime type errors | +| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | Log flooding in production; risk of data leakage | +| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable | +| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"0195…"` parsed to integer `19` — silently wrong | +| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks | +| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023/023A) | Bypasses RBAC, audit trail, and validation layer | +| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail | +| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure | +| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production | +| AI direct cloud API calls | On-premises Ollama only (ADR-023/023A) | Data privacy violation; no audit control | +| AI outputs without human validation | Human-in-the-loop validation required (ADR-023/023A) | Unvalidated AI metadata corrupts document records | +| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama (ADR-023A) | Bypasses audit log, RBAC, and error handling layer | +| Qdrant query without `projectPublicId` filter | `QdrantService.search(projectPublicId, ...)` (ADR-023A) | Cross-project data leak via vector search | ## Schema Changes (ADR-009) diff --git a/.agents/rules/07-frontend-patterns.md b/.agents/rules/07-frontend-patterns.md index 9d590a9d..28bed1aa 100644 --- a/.agents/rules/07-frontend-patterns.md +++ b/.agents/rules/07-frontend-patterns.md @@ -1,4 +1,3 @@ - # Frontend Patterns (Next.js) ## Form Handling @@ -17,7 +16,7 @@ interface ProjectOption { } // Select options -const options = contracts.map(c => ({ +const options = contracts.map((c) => ({ label: `${c.contractName} (${c.contractCode})`, value: c.publicId!, // Use publicId, no fallback to id })); diff --git a/.agents/rules/08-development-flow.md b/.agents/rules/08-development-flow.md index 7fe194f9..c1bef0bc 100644 --- a/.agents/rules/08-development-flow.md +++ b/.agents/rules/08-development-flow.md @@ -9,7 +9,7 @@ 3. **Check schema** — verify table/column in `lcbp3-v1.9.0-schema-02-tables.sql` 4. **Check data dictionary** — confirm field meanings + business rules 5. **Scan edge cases** — `01-06-edge-cases-and-rules.md` -6. **Check ADRs** — verify decisions align (ADR-009, ADR-018, ADR-019) +6. **Check ADRs** — verify decisions align (ADR-009, ADR-019, ADR-023) 7. **Write code** — TypeScript strict, no `any`, no `console.log` ## 🟡 Normal Work — UI / Feature / Integration @@ -80,30 +80,35 @@ ## Context-Aware Triggers -| Request | Files to Check | Expected Response | -| ----------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard | -| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases.md` | RHF+Zod + TanStack Query + Thai comments | -| "เพิ่ม field ใหม่" | `ADR-009`, `data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | -| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor | -| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow | -| "ตรวจสอบ permission" | `seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix | -| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy | -| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns | -| "AI integration" | ✅ | `ADR-023`, `ADR-023A`, `ADR-024`, `ADR-025` | AI boundary + 2-model stack + BullMQ queue policy + Intent/Tool Layer | -| "Error handling" | ✅ | `ADR-007` | Layered error classification + recovery | -| "File upload" | ✅ | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist | -| "Notifications / Queue" | ✅ | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter | -| "Add i18n / translate" | ✅ | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text | -| "Workflow / DSL" | ✅ | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService | -| "Document numbering" | ✅ | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) | -| "ตรวจสอบ Workflow" | ✅ | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร | -| "Transmittal submit" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | submit() with EC-RFA-004 validation | -| "Circulation reassign" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | reassignRouting() with EC-CIRC-001 | -| "สร้าง workflow ใหม่" | 📋 | `ADR-001`, `ADR-021`, `specs/200-fullstacks/203-unified-workflow-engine/` | DSL workflow definition + WorkflowEngineService setup | -| "ตรวจสอบ AI boundary" | ✅ | `ADR-023`, `ADR-023A` | Verify Ollama isolation + BullMQ queues + Qdrant projectPublicId filter | -| "Intent classification" | ✅ | `ADR-024`, `specs/200-fullstacks/224-intent-classification/` | Pattern Layer → LLM Fallback; ai_intent_patterns; Redis cache 5 min | -| "AI Tool Layer" | ✅ | `ADR-025`, `specs/200-fullstacks/225-ai-tool-layer-architecture/` | Tool Registry; CASL-guarded dispatch; ToolResult publicId only | -| "Document Chat UI" | ✅ | `ADR-026`, `specs/200-fullstacks/226-document-chat-ui-pattern/` | Side-panel; useAiChat() hook; streaming SSE; TanStack Query cache | -| "AI Admin Console" | ✅ | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints | -| "Migration refactor" | ✅ | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates | +| Request | Files to Check | Expected Response | +| --------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard | +| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases-and-rules.md` | RHF+Zod + TanStack Query + Thai comments | +| "เพิ่ม field ใหม่" | `ADR-009`, `03-01-data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | +| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor | +| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow | +| "ตรวจสอบ permission" | `lcbp3-v1.9.0-seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix | +| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy | +| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns | +| "AI integration" | `ADR-023`, `ADR-023A`, `ADR-024`, `ADR-025` | AI boundary + 2-model stack + BullMQ queue policy + Intent/Tool Layer | +| "Error handling" | `ADR-007` | Layered error classification + recovery | +| "File upload" | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist | +| "Notifications / Queue" | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter | +| "Add i18n / translate" | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text | +| "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService | +| "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) | +| "ตรวจสอบ Workflow" | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร | +| "Transmittal submit" | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | submit() with EC-RFA-004 validation | +| "Circulation reassign" | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | reassignRouting() with EC-CIRC-001 | +| "สร้าง workflow ใหม่" | `ADR-001`, `ADR-021`, `specs/200-fullstacks/203-unified-workflow-engine/` | DSL workflow definition + WorkflowEngineService setup | +| "ตรวจสอบ AI boundary" | `ADR-023`, `ADR-023A` | Verify Ollama isolation + BullMQ queues + Qdrant projectPublicId filter | +| "Intent classification" | `ADR-024`, `specs/200-fullstacks/224-intent-classification/` | Pattern Layer → LLM Fallback; ai_intent_patterns; Redis cache 5 min | +| "AI Tool Layer" | `ADR-025`, `specs/200-fullstacks/225-ai-tool-layer-architecture/` | Tool Registry; CASL-guarded dispatch; ToolResult publicId only | +| "Document Chat UI" | `ADR-026`, `specs/200-fullstacks/226-document-chat-ui-pattern/` | Side-panel; useAiChat() hook; streaming SSE; TanStack Query cache | +| "AI Admin Console" | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints | +| "Migration refactor" | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates | +| "จัดการ document numbering" | `ADR-002`, `specs/03-Data-and-Storage/03-04-document-numbering.md` | Redis Redlock + template system + preview/override workflows | +| "Audit ความปลอดภัย" | `ADR-016`, `ADR-019`, `ADR-023`, `ADR-023A` | ตรวจสอบ UUID pattern, CASL Guard, AI Boundary และ Qdrant multi-tenancy | +| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน | +| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence | +| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ | diff --git a/.agents/rules/09-commit-checklist.md b/.agents/rules/09-commit-checklist.md index 1b81f5b2..0ebf27d6 100644 --- a/.agents/rules/09-commit-checklist.md +++ b/.agents/rules/09-commit-checklist.md @@ -1,4 +1,3 @@ - # Commit Checklist ## Pre-Commit Verification @@ -28,6 +27,7 @@ type(scope): description Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` Examples: + - `feat(correspondence): add originator organization validation` - `fix(uuid): correct parseInt usage to string comparison` - `spec(agents): bump to v1.8.5 - refactor structure` diff --git a/.agents/rules/10-error-handling.md b/.agents/rules/10-error-handling.md index ec2a5d0e..76edf180 100644 --- a/.agents/rules/10-error-handling.md +++ b/.agents/rules/10-error-handling.md @@ -1,4 +1,3 @@ - # ADR-007 Error Handling Strategy ## CRITICAL RULES @@ -11,23 +10,18 @@ ## Error Classification -| Error Type | Description | User Message | Technical Log | -|------------|-------------|--------------|---------------| -| **Validation** | Input validation failures | Clear field-level errors | Full validation details | -| **Business** | Business rule violations | Actionable guidance | Business context + user ID | -| **System** | Infrastructure failures | Generic "try again" | Full stack trace + metrics | +| Error Type | Description | User Message | Technical Log | +| -------------- | ------------------------- | ------------------------ | -------------------------- | +| **Validation** | Input validation failures | Clear field-level errors | Full validation details | +| **Business** | Business rule violations | Actionable guidance | Business context + user ID | +| **System** | Infrastructure failures | Generic "try again" | Full stack trace + metrics | ## Backend Pattern (NestJS) ```typescript // Custom Exception Hierarchy export class BusinessException extends HttpException { - constructor( - message: string, - userMessage: string, - recoveryAction?: string, - errorCode?: string - ) { + constructor(message: string, userMessage: string, recoveryAction?: string, errorCode?: string) { super({ message, userMessage, recoveryAction, errorCode }, 400); } } diff --git a/.agents/rules/11-ai-integration.md b/.agents/rules/11-ai-integration.md index 038b2170..864849ad 100644 --- a/.agents/rules/11-ai-integration.md +++ b/.agents/rules/11-ai-integration.md @@ -3,7 +3,7 @@ ## CRITICAL RULES - **ALWAYS** follow ADR-023 AI boundary policy (isolation on Admin Desktop) -- **ALWAYS** use ADR-023A 2-model stack (gemma4:e4b Q8_0 + nomic-embed-text) +- **ALWAYS** use ADR-023A 2-model stack (gemma4:e2b + nomic-embed-text) - **ALWAYS** use BullMQ 2-queue (ai-realtime + ai-batch) for GPU overload prevention - **NEVER** allow AI direct database/storage access - **ALWAYS** implement human-in-the-loop validation @@ -26,7 +26,7 @@ n8n (Migration) → DMS API → BullMQ → Admin Desktop (Ollama) → Backend Va | ----------------- | ------------------------- | ------------------------------------------------------------------------ | | **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging | | **BullMQ Queues** | Backend (NestJS) | ai-realtime (RAG/Suggest), ai-batch (OCR/Extract/Embed) | -| **Ollama Engine** | Admin Desktop (Desk-5439) | gemma4:e4b Q8_0 (LLM) + nomic-embed-text (Embedding) | +| **Ollama Engine** | Admin Desktop (Desk-5439) | gemma4:e2b (LLM) + nomic-embed-text (Embedding) | | **OCR Engine** | Admin Desktop (Desk-5439) | PaddleOCR + PyThaiNLP (Thai/English text extraction) | | **Orchestrator** | QNAP NAS (n8n) | Migration Phase orchestrator only (calls DMS API, never Ollama directly) | @@ -76,7 +76,7 @@ export class AiService { async extractMetadata(documentId: string): Promise { // 1. Validate permissions // 2. Queue job to BullMQ (ai-batch or ai-realtime) - // 3. Worker sends to Admin Desktop AI (gemma4:e4b Q8_0) + // 3. Worker sends to Admin Desktop AI (gemma4:e2b) // 4. Validate AI response // 5. Log audit trail to ai_audit_logs // 6. Return validated results @@ -115,7 +115,7 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => { ## ADR-023A Specific Rules -- **2-Model Stack:** gemma4:e4b Q8_0 (~4.0GB) + nomic-embed-text (~0.3GB) = ~4.3GB VRAM peak +- **2-Model Stack:** gemma4:e2b + nomic-embed-text - **PDF 3-Page Limit:** Classification/Tagging uses first 3 pages only (NOT RAG embedding) - **RAG Embedding:** Full document chunked at 512 tokens/64 tokens overlap - **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else PaddleOCR @@ -129,7 +129,7 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => { - [ ] BullMQ 2-queue setup (ai-realtime + ai-batch) - [ ] QdrantService with projectPublicId enforcement - [ ] DocumentReviewForm reusable component -- [ ] Admin Desktop Ollama (gemma4:e4b Q8_0 + nomic-embed-text) + PaddleOCR setup +- [ ] Admin Desktop Ollama (gemma4:e2b + nomic-embed-text) + PaddleOCR setup - [ ] n8n workflow orchestration (Migration Phase only) - [ ] AI audit logging and monitoring (ai_audit_logs) - [ ] Human-in-the-loop validation workflows @@ -138,3 +138,8 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => { - `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` (Base architecture) - `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` (Model revision - current) +- `specs/06-Decision-Records/ADR-024-intent-classification-strategy.md` (Pattern→LLM Fallback) +- `specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md` (Tool Registry) +- `specs/06-Decision-Records/ADR-026-document-chat-ui-pattern.md` (Chat UI) +- `specs/06-Decision-Records/ADR-027-ai-admin-console-and-dynamic-control.md` (Admin Console) +- `specs/06-Decision-Records/ADR-028-migration-architecture-refactor.md` (Migration Pipeline) diff --git a/.agents/tests/skill-integration.test.js b/.agents/tests/skill-integration.test.js index 71c93e15..d8fb07e2 100644 --- a/.agents/tests/skill-integration.test.js +++ b/.agents/tests/skill-integration.test.js @@ -15,220 +15,249 @@ const WORKFLOWS_DIR = path.join(BASE_DIR, '.windsurf', 'workflows'); // Test utilities class SkillTestSuite { - constructor() { - this.results = { - passed: 0, - failed: 0, - errors: [] - }; + constructor() { + this.results = { + passed: 0, + failed: 0, + errors: [], + }; + } + + log(message, type = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + reset: '\x1b[0m', + }; + + const color = colors[type] || colors.info; + console.log(`${color}${message}${colors.reset}`); + } + + assert(condition, message) { + if (condition) { + this.log(` PASS: ${message}`, 'pass'); + this.results.passed++; + return true; + } else { + this.log(` FAIL: ${message}`, 'fail'); + this.results.failed++; + this.results.errors.push(message); + return false; + } + } + + testDirectoryExists(dirPath, description) { + const exists = fs.existsSync(dirPath); + this.assert(exists, `${description} exists at ${dirPath}`); + return exists; + } + + testFileExists(filePath, description) { + const exists = fs.existsSync(filePath); + this.assert(exists, `${description} exists at ${filePath}`); + return exists; + } + + testFileContent(filePath, pattern, description) { + if (!fs.existsSync(filePath)) { + this.assert(false, `${description} - file not found: ${filePath}`); + return false; } - log(message, type = 'info') { - const colors = { - info: '\x1b[36m', // Cyan - pass: '\x1b[32m', // Green - fail: '\x1b[31m', // Red - warn: '\x1b[33m', // Yellow - reset: '\x1b[0m' - }; - - const color = colors[type] || colors.info; - console.log(`${color}${message}${colors.reset}`); + try { + const content = fs.readFileSync(filePath, 'utf8'); + const matches = content.match(pattern); + this.assert(matches !== null, `${description} - pattern found in ${filePath}`); + return matches !== null; + } catch (error) { + this.assert(false, `${description} - error reading file: ${error.message}`); + return false; } + } - assert(condition, message) { - if (condition) { - this.log(` PASS: ${message}`, 'pass'); - this.results.passed++; - return true; - } else { - this.log(` FAIL: ${message}`, 'fail'); - this.results.failed++; - this.results.errors.push(message); - return false; - } - } - - testDirectoryExists(dirPath, description) { - const exists = fs.existsSync(dirPath); - this.assert(exists, `${description} exists at ${dirPath}`); - return exists; - } - - testFileExists(filePath, description) { - const exists = fs.existsSync(filePath); - this.assert(exists, `${description} exists at ${filePath}`); - return exists; - } - - testFileContent(filePath, pattern, description) { - if (!fs.existsSync(filePath)) { - this.assert(false, `${description} - file not found: ${filePath}`); - return false; - } - - try { - const content = fs.readFileSync(filePath, 'utf8'); - const matches = content.match(pattern); - this.assert(matches !== null, `${description} - pattern found in ${filePath}`); - return matches !== null; - } catch (error) { - this.assert(false, `${description} - error reading file: ${error.message}`); - return false; - } - } - - runScript(scriptPath, description) { - try { - const output = execSync(scriptPath, { encoding: 'utf8', cwd: BASE_DIR }); - this.log(` SCRIPT: ${description} executed successfully`, 'pass'); - return { success: true, output }; - } catch (error) { - this.log(` SCRIPT: ${description} failed - ${error.message}`, 'fail'); - this.results.failed++; - this.results.errors.push(`${description}: ${error.message}`); - return { success: false, error: error.message }; - } + runScript(scriptPath, description) { + try { + const output = execSync(scriptPath, { encoding: 'utf8', cwd: BASE_DIR }); + this.log(` SCRIPT: ${description} executed successfully`, 'pass'); + return { success: true, output }; + } catch (error) { + this.log(` SCRIPT: ${description} failed - ${error.message}`, 'fail'); + this.results.failed++; + this.results.errors.push(`${description}: ${error.message}`); + return { success: false, error: error.message }; } + } } // Test suite implementation const testSuite = new SkillTestSuite(); function runAllTests() { - testSuite.log('=== .agents Integration Test Suite ===', 'info'); - testSuite.log(`Base directory: ${BASE_DIR}`, 'info'); - testSuite.log(`Started: ${new Date().toISOString()}`, 'info'); - testSuite.log(''); + testSuite.log('=== .agents Integration Test Suite ===', 'info'); + testSuite.log(`Base directory: ${BASE_DIR}`, 'info'); + testSuite.log(`Started: ${new Date().toISOString()}`, 'info'); + testSuite.log(''); - // Test 1: Directory Structure - testSuite.log('Test 1: Directory Structure', 'info'); - testSuite.testDirectoryExists(AGENTS_DIR, '.agents directory'); - testSuite.testDirectoryExists(SKILLS_DIR, 'skills directory'); - testSuite.testDirectoryExists(WORKFLOWS_DIR, 'workflows directory'); - testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'scripts'), 'scripts directory'); - testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'rules'), 'rules directory'); - testSuite.log(''); + // Test 1: Directory Structure + testSuite.log('Test 1: Directory Structure', 'info'); + testSuite.testDirectoryExists(AGENTS_DIR, '.agents directory'); + testSuite.testDirectoryExists(SKILLS_DIR, 'skills directory'); + testSuite.testDirectoryExists(WORKFLOWS_DIR, 'workflows directory'); + testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'scripts'), 'scripts directory'); + testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'rules'), 'rules directory'); + testSuite.log(''); - // Test 2: Core Files - testSuite.log('Test 2: Core Files', 'info'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'README.md'), 'README.md'); - testSuite.testFileExists(path.join(SKILLS_DIR, 'VERSION'), 'skills VERSION file'); - testSuite.testFileExists(path.join(SKILLS_DIR, 'skills.md'), 'skills.md documentation'); - testSuite.log(''); + // Test 2: Core Files + testSuite.log('Test 2: Core Files', 'info'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'README.md'), 'README.md'); + testSuite.testFileExists(path.join(SKILLS_DIR, 'VERSION'), 'skills VERSION file'); + testSuite.testFileExists(path.join(SKILLS_DIR, 'skills.md'), 'skills.md documentation'); + testSuite.log(''); - // Test 3: Script Files - testSuite.log('Test 3: Validation Scripts', 'info'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'), 'bash validate-versions.sh'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'audit-skills.sh'), 'bash audit-skills.sh'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'sync-workflows.sh'), 'bash sync-workflows.sh'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'validate-versions.ps1'), 'powershell validate-versions.ps1'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'audit-skills.ps1'), 'powershell audit-skills.ps1'); - testSuite.log(''); + // Test 3: Script Files + testSuite.log('Test 3: Validation Scripts', 'info'); + testSuite.testFileExists( + path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'), + 'bash validate-versions.sh' + ); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'audit-skills.sh'), 'bash audit-skills.sh'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'sync-workflows.sh'), 'bash sync-workflows.sh'); + testSuite.testFileExists( + path.join(AGENTS_DIR, 'scripts', 'powershell', 'validate-versions.ps1'), + 'powershell validate-versions.ps1' + ); + testSuite.testFileExists( + path.join(AGENTS_DIR, 'scripts', 'powershell', 'audit-skills.ps1'), + 'powershell audit-skills.ps1' + ); + testSuite.log(''); - // Test 4: Version Consistency - testSuite.log('Test 4: Version Consistency', 'info'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /v1\.8\.6/, 'README.md version'); - testSuite.testFileContent(path.join(SKILLS_DIR, 'VERSION'), /version: 1\.8\.6/, 'skills VERSION file'); - testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /v1\.8\.6/, 'skills.md version'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'rules', '00-project-context.md'), /v1\.8\.6/, 'project context version'); - testSuite.log(''); + // Test 4: Version Consistency + testSuite.log('Test 4: Version Consistency', 'info'); + testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /v1\.8\.6/, 'README.md version'); + testSuite.testFileContent(path.join(SKILLS_DIR, 'VERSION'), /version: 1\.8\.6/, 'skills VERSION file'); + testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /v1\.8\.6/, 'skills.md version'); + testSuite.testFileContent( + path.join(AGENTS_DIR, 'rules', '00-project-context.md'), + /v1\.8\.6/, + 'project context version' + ); + testSuite.log(''); - // Test 5: Skills Structure - testSuite.log('Test 5: Skills Structure', 'info'); - const skillDirs = fs.readdirSync(SKILLS_DIR).filter(item => { - const itemPath = path.join(SKILLS_DIR, item); - return fs.statSync(itemPath).isDirectory() && item.startsWith('speckit-') || item === 'nestjs-best-practices' || item === 'next-best-practices'; - }); + // Test 5: Skills Structure + testSuite.log('Test 5: Skills Structure', 'info'); + const skillDirs = fs.readdirSync(SKILLS_DIR).filter((item) => { + const itemPath = path.join(SKILLS_DIR, item); + return ( + (fs.statSync(itemPath).isDirectory() && item.startsWith('speckit-')) || + item === 'nestjs-best-practices' || + item === 'next-best-practices' + ); + }); - testSuite.assert(skillDirs.length >= 20, `Found at least 20 skill directories (found ${skillDirs.length})`); - - // Test a few key skills - const keySkills = ['speckit-plan', 'speckit-implement', 'speckit-specify', 'speckit-validate']; - keySkills.forEach(skill => { - const skillPath = path.join(SKILLS_DIR, skill); - const skillMdPath = path.join(skillPath, 'SKILL.md'); - testSuite.testDirectoryExists(skillPath, `${skill} directory`); - testSuite.testFileExists(skillMdPath, `${skill} SKILL.md`); - - if (fs.existsSync(skillMdPath)) { - testSuite.testFileContent(skillMdPath, /^name:/, `${skill} has name field`); - testSuite.testFileContent(skillMdPath, /^description:/, `${skill} has description field`); - testSuite.testFileContent(skillMdPath, /^version:/, `${skill} has version field`); - testSuite.testFileContent(skillMdPath, /^## Role$/, `${skill} has Role section`); - testSuite.testFileContent(skillMdPath, /^## Task$/, `${skill} has Task section`); - } - }); - testSuite.log(''); + testSuite.assert(skillDirs.length >= 20, `Found at least 20 skill directories (found ${skillDirs.length})`); - // Test 6: Workflows Structure - testSuite.log('Test 6: Workflows Structure', 'info'); - const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter(item => item.endsWith('.md')); - testSuite.assert(workflowFiles.length >= 20, `Found at least 20 workflow files (found ${workflowFiles.length})`); - - // Test key workflows - const keyWorkflows = ['00-speckit.all.md', '02-speckit.specify.md', '04-speckit.plan.md', '07-speckit.implement.md']; - keyWorkflows.forEach(workflow => { - const workflowPath = path.join(WORKFLOWS_DIR, workflow); - testSuite.testFileExists(workflowPath, `${workflow} file`); - }); - testSuite.log(''); + // Test a few key skills + const keySkills = ['speckit-plan', 'speckit-implement', 'speckit-specify', 'speckit-validate']; + keySkills.forEach((skill) => { + const skillPath = path.join(SKILLS_DIR, skill); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + testSuite.testDirectoryExists(skillPath, `${skill} directory`); + testSuite.testFileExists(skillMdPath, `${skill} SKILL.md`); - // Test 7: Rules Structure - testSuite.log('Test 7: Rules Structure', 'info'); - const rulesDir = path.join(AGENTS_DIR, 'rules'); - const ruleFiles = fs.readdirSync(rulesDir).filter(item => item.endsWith('.md')); - testSuite.assert(ruleFiles.length >= 10, `Found at least 10 rule files (found ${ruleFiles.length})`); - - // Test key rules - const keyRules = ['00-project-context.md', '01-adr-019-uuid.md', '02-security.md']; - keyRules.forEach(rule => { - const rulePath = path.join(rulesDir, rule); - testSuite.testFileExists(rulePath, `${rule} file`); - }); - testSuite.log(''); + if (fs.existsSync(skillMdPath)) { + testSuite.testFileContent(skillMdPath, /^name:/, `${skill} has name field`); + testSuite.testFileContent(skillMdPath, /^description:/, `${skill} has description field`); + testSuite.testFileContent(skillMdPath, /^version:/, `${skill} has version field`); + testSuite.testFileContent(skillMdPath, /^## Role$/, `${skill} has Role section`); + testSuite.testFileContent(skillMdPath, /^## Task$/, `${skill} has Task section`); + } + }); + testSuite.log(''); - // Test 8: Script Execution (if on Unix-like system) - if (process.platform !== 'win32') { - testSuite.log('Test 8: Script Execution', 'info'); - - // Test version validation script - const versionScript = path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'); - if (fs.existsSync(versionScript)) { - try { - // Make executable - fs.chmodSync(versionScript, '755'); - testSuite.runScript(versionScript, 'Version validation script'); - } catch (error) { - testSuite.log(` SKIP: Cannot execute version script - ${error.message}`, 'warn'); - } - } - - testSuite.log(''); + // Test 6: Workflows Structure + testSuite.log('Test 6: Workflows Structure', 'info'); + const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter((item) => item.endsWith('.md')); + testSuite.assert(workflowFiles.length >= 20, `Found at least 20 workflow files (found ${workflowFiles.length})`); + + // Test key workflows + const keyWorkflows = ['00-speckit.all.md', '02-speckit.specify.md', '04-speckit.plan.md', '07-speckit.implement.md']; + keyWorkflows.forEach((workflow) => { + const workflowPath = path.join(WORKFLOWS_DIR, workflow); + testSuite.testFileExists(workflowPath, `${workflow} file`); + }); + testSuite.log(''); + + // Test 7: Rules Structure + testSuite.log('Test 7: Rules Structure', 'info'); + const rulesDir = path.join(AGENTS_DIR, 'rules'); + const ruleFiles = fs.readdirSync(rulesDir).filter((item) => item.endsWith('.md')); + testSuite.assert(ruleFiles.length >= 10, `Found at least 10 rule files (found ${ruleFiles.length})`); + + // Test key rules + const keyRules = ['00-project-context.md', '01-adr-019-uuid.md', '02-security.md']; + keyRules.forEach((rule) => { + const rulePath = path.join(rulesDir, rule); + testSuite.testFileExists(rulePath, `${rule} file`); + }); + testSuite.log(''); + + // Test 8: Script Execution (if on Unix-like system) + if (process.platform !== 'win32') { + testSuite.log('Test 8: Script Execution', 'info'); + + // Test version validation script + const versionScript = path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'); + if (fs.existsSync(versionScript)) { + try { + // Make executable + fs.chmodSync(versionScript, '755'); + testSuite.runScript(versionScript, 'Version validation script'); + } catch (error) { + testSuite.log(` SKIP: Cannot execute version script - ${error.message}`, 'warn'); + } } - // Test 9: Documentation Quality - testSuite.log('Test 9: Documentation Quality', 'info'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /## Troubleshooting/, 'README.md has troubleshooting section'); - testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /## Skill Dependency Matrix/, 'skills.md has dependency matrix'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /## Architecture/, 'README.md has architecture section'); testSuite.log(''); + } - // Results Summary - testSuite.log('=== Test Results Summary ===', 'info'); - testSuite.log(`Passed: ${testSuite.results.passed}`, 'pass'); - testSuite.log(`Failed: ${testSuite.results.failed}`, testSuite.results.failed > 0 ? 'fail' : 'pass'); - - if (testSuite.results.errors.length > 0) { - testSuite.log('Errors:', 'fail'); - testSuite.results.errors.forEach(error => { - testSuite.log(` - ${error}`, 'fail'); - }); - } - - testSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); - - return testSuite.results.failed === 0; + // Test 9: Documentation Quality + testSuite.log('Test 9: Documentation Quality', 'info'); + testSuite.testFileContent( + path.join(AGENTS_DIR, 'README.md'), + /## Troubleshooting/, + 'README.md has troubleshooting section' + ); + testSuite.testFileContent( + path.join(SKILLS_DIR, 'skills.md'), + /## Skill Dependency Matrix/, + 'skills.md has dependency matrix' + ); + testSuite.testFileContent( + path.join(AGENTS_DIR, 'README.md'), + /## Architecture/, + 'README.md has architecture section' + ); + testSuite.log(''); + + // Results Summary + testSuite.log('=== Test Results Summary ===', 'info'); + testSuite.log(`Passed: ${testSuite.results.passed}`, 'pass'); + testSuite.log(`Failed: ${testSuite.results.failed}`, testSuite.results.failed > 0 ? 'fail' : 'pass'); + + if (testSuite.results.errors.length > 0) { + testSuite.log('Errors:', 'fail'); + testSuite.results.errors.forEach((error) => { + testSuite.log(` - ${error}`, 'fail'); + }); + } + + testSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); + + return testSuite.results.failed === 0; } // Export for use in other modules @@ -236,6 +265,6 @@ module.exports = { SkillTestSuite, runAllTests }; // Run tests if called directly if (require.main === module) { - const success = runAllTests(); - process.exit(success ? 0 : 1); + const success = runAllTests(); + process.exit(success ? 0 : 1); } diff --git a/.agents/tests/workflow-validation.test.js b/.agents/tests/workflow-validation.test.js index 368911d5..f2ff09f6 100644 --- a/.agents/tests/workflow-validation.test.js +++ b/.agents/tests/workflow-validation.test.js @@ -13,216 +13,218 @@ const AGENTS_DIR = path.join(BASE_DIR, '.agents'); // Test utilities class WorkflowTestSuite { - constructor() { - this.results = { - passed: 0, - failed: 0, - errors: [] - }; + constructor() { + this.results = { + passed: 0, + failed: 0, + errors: [], + }; + } + + log(message, type = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + reset: '\x1b[0m', + }; + + const color = colors[type] || colors.info; + console.log(`${color}${message}${colors.reset}`); + } + + assert(condition, message) { + if (condition) { + this.log(` PASS: ${message}`, 'pass'); + this.results.passed++; + return true; + } else { + this.log(` FAIL: ${message}`, 'fail'); + this.results.failed++; + this.results.errors.push(message); + return false; + } + } + + testWorkflowFile(filePath, expectedName) { + if (!fs.existsSync(filePath)) { + this.assert(false, `Workflow file exists: ${expectedName}`); + return false; } - log(message, type = 'info') { - const colors = { - info: '\x1b[36m', // Cyan - pass: '\x1b[32m', // Green - fail: '\x1b[31m', // Red - warn: '\x1b[33m', // Yellow - reset: '\x1b[0m' - }; - - const color = colors[type] || colors.info; - console.log(`${color}${message}${colors.reset}`); + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Basic structure checks + this.assert(content.length > 0, `${expectedName} has content`); + this.assert(content.includes('#'), `${expectedName} has markdown headers`); + + // Check for workflow-specific patterns + if (expectedName.includes('speckit-')) { + this.assert(content.includes('speckit-'), `${expectedName} contains speckit reference`); + } + + // Check for proper markdown formatting + const lines = content.split('\n'); + const nonEmptyLines = lines.filter((line) => line.trim().length > 0); + this.assert(nonEmptyLines.length >= 5, `${expectedName} has sufficient content`); + + return true; + } catch (error) { + this.assert(false, `${expectedName} - error reading file: ${error.message}`); + return false; + } + } + + validateWorkflowDependency(workflowName, workflowContent) { + // Check if workflow references existing skills + const skillReferences = workflowContent.match(/@speckit-\w+/g) || []; + const skillsDir = path.join(AGENTS_DIR, 'skills'); + + for (const skillRef of skillReferences) { + const skillName = skillRef.replace('@', ''); + const skillPath = path.join(skillsDir, skillName); + + if (!fs.existsSync(skillPath)) { + this.assert(false, `${workflowName} references non-existent skill: ${skillRef}`); + return false; + } } - assert(condition, message) { - if (condition) { - this.log(` PASS: ${message}`, 'pass'); - this.results.passed++; - return true; - } else { - this.log(` FAIL: ${message}`, 'fail'); - this.results.failed++; - this.results.errors.push(message); - return false; - } - } - - testWorkflowFile(filePath, expectedName) { - if (!fs.existsSync(filePath)) { - this.assert(false, `Workflow file exists: ${expectedName}`); - return false; - } - - try { - const content = fs.readFileSync(filePath, 'utf8'); - - // Basic structure checks - this.assert(content.length > 0, `${expectedName} has content`); - this.assert(content.includes('#'), `${expectedName} has markdown headers`); - - // Check for workflow-specific patterns - if (expectedName.includes('speckit-')) { - this.assert(content.includes('speckit-'), `${expectedName} contains speckit reference`); - } - - // Check for proper markdown formatting - const lines = content.split('\n'); - const nonEmptyLines = lines.filter(line => line.trim().length > 0); - this.assert(nonEmptyLines.length >= 5, `${expectedName} has sufficient content`); - - return true; - } catch (error) { - this.assert(false, `${expectedName} - error reading file: ${error.message}`); - return false; - } - } - - validateWorkflowDependency(workflowName, workflowContent) { - // Check if workflow references existing skills - const skillReferences = workflowContent.match(/@speckit-\w+/g) || []; - const skillsDir = path.join(AGENTS_DIR, 'skills'); - - for (const skillRef of skillReferences) { - const skillName = skillRef.replace('@', ''); - const skillPath = path.join(skillsDir, skillName); - - if (!fs.existsSync(skillPath)) { - this.assert(false, `${workflowName} references non-existent skill: ${skillRef}`); - return false; - } - } - - return true; - } + return true; + } } // Expected workflows mapping const expectedWorkflows = { - '00-speckit.all.md': 'Full pipeline workflow', - '01-speckit.constitution.md': 'Constitution workflow', - '02-speckit.specify.md': 'Specification workflow', - '03-speckit.clarify.md': 'Clarification workflow', - '04-speckit.plan.md': 'Planning workflow', - '05-speckit.tasks.md': 'Task breakdown workflow', - '06-speckit.analyze.md': 'Analysis workflow', - '07-speckit.implement.md': 'Implementation workflow', - '08-speckit.checker.md': 'Static analysis workflow', - '09-speckit.tester.md': 'Testing workflow', - '10-speckit.reviewer.md': 'Code review workflow', - '11-speckit.validate.md': 'Validation workflow', - 'speckit.prepare.md': 'Preparation workflow', - 'schema-change.md': 'Schema change workflow', - 'create-backend-module.md': 'Backend module creation', - 'create-frontend-page.md': 'Frontend page creation', - 'deploy.md': 'Deployment workflow', - 'review.md': 'Code review workflow', - 'util-speckit.checklist.md': 'Checklist utility', - 'util-speckit.diff.md': 'Diff utility', - 'util-speckit.migrate.md': 'Migration utility', - 'util-speckit.quizme.md': 'Quiz utility', - 'util-speckit.status.md': 'Status utility', - 'util-speckit.taskstoissues.md': 'Task to issues utility' + '00-speckit.all.md': 'Full pipeline workflow', + '01-speckit.constitution.md': 'Constitution workflow', + '02-speckit.specify.md': 'Specification workflow', + '03-speckit.clarify.md': 'Clarification workflow', + '04-speckit.plan.md': 'Planning workflow', + '05-speckit.tasks.md': 'Task breakdown workflow', + '06-speckit.analyze.md': 'Analysis workflow', + '07-speckit.implement.md': 'Implementation workflow', + '08-speckit.checker.md': 'Static analysis workflow', + '09-speckit.tester.md': 'Testing workflow', + '10-speckit.reviewer.md': 'Code review workflow', + '11-speckit.validate.md': 'Validation workflow', + 'speckit.prepare.md': 'Preparation workflow', + 'schema-change.md': 'Schema change workflow', + 'create-backend-module.md': 'Backend module creation', + 'create-frontend-page.md': 'Frontend page creation', + 'deploy.md': 'Deployment workflow', + 'review.md': 'Code review workflow', + 'util-speckit.checklist.md': 'Checklist utility', + 'util-speckit.diff.md': 'Diff utility', + 'util-speckit.migrate.md': 'Migration utility', + 'util-speckit.quizme.md': 'Quiz utility', + 'util-speckit.status.md': 'Status utility', + 'util-speckit.taskstoissues.md': 'Task to issues utility', }; // Test suite implementation const workflowTestSuite = new WorkflowTestSuite(); function runWorkflowTests() { - workflowTestSuite.log('=== Workflow Validation Test Suite ===', 'info'); - workflowTestSuite.log(`Workflows directory: ${WORKFLOWS_DIR}`, 'info'); - workflowTestSuite.log(`Started: ${new Date().toISOString()}`, 'info'); - workflowTestSuite.log(''); + workflowTestSuite.log('=== Workflow Validation Test Suite ===', 'info'); + workflowTestSuite.log(`Workflows directory: ${WORKFLOWS_DIR}`, 'info'); + workflowTestSuite.log(`Started: ${new Date().toISOString()}`, 'info'); + workflowTestSuite.log(''); - // Test 1: Workflows directory exists - workflowTestSuite.log('Test 1: Directory Structure', 'info'); - workflowTestSuite.assert(fs.existsSync(WORKFLOWS_DIR), 'Workflows directory exists'); - workflowTestSuite.log(''); + // Test 1: Workflows directory exists + workflowTestSuite.log('Test 1: Directory Structure', 'info'); + workflowTestSuite.assert(fs.existsSync(WORKFLOWS_DIR), 'Workflows directory exists'); + workflowTestSuite.log(''); - // Test 2: Expected workflow files exist - workflowTestSuite.log('Test 2: Expected Workflow Files', 'info'); - let foundWorkflows = 0; - - for (const [filename, description] of Object.entries(expectedWorkflows)) { - const filePath = path.join(WORKFLOWS_DIR, filename); - workflowTestSuite.testWorkflowFile(filePath, description); - if (fs.existsSync(filePath)) { - foundWorkflows++; - } - } - - workflowTestSuite.assert(foundWorkflows >= 20, `Found at least 20 workflows (found ${foundWorkflows})`); - workflowTestSuite.log(''); + // Test 2: Expected workflow files exist + workflowTestSuite.log('Test 2: Expected Workflow Files', 'info'); + let foundWorkflows = 0; - // Test 3: Workflow content validation - workflowTestSuite.log('Test 3: Content Validation', 'info'); - - for (const [filename, description] of Object.entries(expectedWorkflows)) { - const filePath = path.join(WORKFLOWS_DIR, filename); - - if (fs.existsSync(filePath)) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - - // Check for proper workflow structure - workflowTestSuite.assert(content.includes('#'), `${filename} has markdown headers`); - workflowTestSuite.assert(content.length > 100, `${filename} has substantial content`); - - // Validate skill dependencies - workflowTestSuite.validateWorkflowDependency(filename, content); - - } catch (error) { - workflowTestSuite.assert(false, `${filename} - content validation error: ${error.message}`); - } - } + for (const [filename, description] of Object.entries(expectedWorkflows)) { + const filePath = path.join(WORKFLOWS_DIR, filename); + workflowTestSuite.testWorkflowFile(filePath, description); + if (fs.existsSync(filePath)) { + foundWorkflows++; } - workflowTestSuite.log(''); + } - // Test 4: Workflow naming consistency - workflowTestSuite.log('Test 4: Naming Consistency', 'info'); - const actualFiles = fs.readdirSync(WORKFLOWS_DIR).filter(file => file.endsWith('.md')); - - for (const actualFile of actualFiles) { - if (!expectedWorkflows[actualFile]) { - workflowTestSuite.log(` UNEXPECTED: ${actualFile} not in expected list`, 'warn'); - } - } - - for (const expectedFile of Object.keys(expectedWorkflows)) { - if (!actualFiles.includes(expectedFile)) { - workflowTestSuite.assert(false, `Missing expected workflow: ${expectedFile}`); - } - } - workflowTestSuite.log(''); + workflowTestSuite.assert(foundWorkflows >= 20, `Found at least 20 workflows (found ${foundWorkflows})`); + workflowTestSuite.log(''); - // Test 5: Cross-reference validation - workflowTestSuite.log('Test 5: Cross-Reference Validation', 'info'); - - // Check if README.md references workflows correctly - const readmePath = path.join(AGENTS_DIR, 'README.md'); - if (fs.existsSync(readmePath)) { - const readmeContent = fs.readFileSync(readmePath, 'utf8'); - workflowTestSuite.assert( - readmeContent.includes('.windsurf/workflows'), - 'README.md references correct workflows path' - ); - } - workflowTestSuite.log(''); + // Test 3: Workflow content validation + workflowTestSuite.log('Test 3: Content Validation', 'info'); - // Results Summary - workflowTestSuite.log('=== Workflow Test Results Summary ===', 'info'); - workflowTestSuite.log(`Passed: ${workflowTestSuite.results.passed}`, 'pass'); - workflowTestSuite.log(`Failed: ${workflowTestSuite.results.failed}`, workflowTestSuite.results.failed > 0 ? 'fail' : 'pass'); - - if (workflowTestSuite.results.errors.length > 0) { - workflowTestSuite.log('Errors:', 'fail'); - workflowTestSuite.results.errors.forEach(error => { - workflowTestSuite.log(` - ${error}`, 'fail'); - }); + for (const [filename, description] of Object.entries(expectedWorkflows)) { + const filePath = path.join(WORKFLOWS_DIR, filename); + + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Check for proper workflow structure + workflowTestSuite.assert(content.includes('#'), `${filename} has markdown headers`); + workflowTestSuite.assert(content.length > 100, `${filename} has substantial content`); + + // Validate skill dependencies + workflowTestSuite.validateWorkflowDependency(filename, content); + } catch (error) { + workflowTestSuite.assert(false, `${filename} - content validation error: ${error.message}`); + } } - - workflowTestSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); - - return workflowTestSuite.results.failed === 0; + } + workflowTestSuite.log(''); + + // Test 4: Workflow naming consistency + workflowTestSuite.log('Test 4: Naming Consistency', 'info'); + const actualFiles = fs.readdirSync(WORKFLOWS_DIR).filter((file) => file.endsWith('.md')); + + for (const actualFile of actualFiles) { + if (!expectedWorkflows[actualFile]) { + workflowTestSuite.log(` UNEXPECTED: ${actualFile} not in expected list`, 'warn'); + } + } + + for (const expectedFile of Object.keys(expectedWorkflows)) { + if (!actualFiles.includes(expectedFile)) { + workflowTestSuite.assert(false, `Missing expected workflow: ${expectedFile}`); + } + } + workflowTestSuite.log(''); + + // Test 5: Cross-reference validation + workflowTestSuite.log('Test 5: Cross-Reference Validation', 'info'); + + // Check if README.md references workflows correctly + const readmePath = path.join(AGENTS_DIR, 'README.md'); + if (fs.existsSync(readmePath)) { + const readmeContent = fs.readFileSync(readmePath, 'utf8'); + workflowTestSuite.assert( + readmeContent.includes('.windsurf/workflows'), + 'README.md references correct workflows path' + ); + } + workflowTestSuite.log(''); + + // Results Summary + workflowTestSuite.log('=== Workflow Test Results Summary ===', 'info'); + workflowTestSuite.log(`Passed: ${workflowTestSuite.results.passed}`, 'pass'); + workflowTestSuite.log( + `Failed: ${workflowTestSuite.results.failed}`, + workflowTestSuite.results.failed > 0 ? 'fail' : 'pass' + ); + + if (workflowTestSuite.results.errors.length > 0) { + workflowTestSuite.log('Errors:', 'fail'); + workflowTestSuite.results.errors.forEach((error) => { + workflowTestSuite.log(` - ${error}`, 'fail'); + }); + } + + workflowTestSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); + + return workflowTestSuite.results.failed === 0; } // Export for use in other modules @@ -230,6 +232,6 @@ module.exports = { WorkflowTestSuite, runWorkflowTests }; // Run tests if called directly if (require.main === module) { - const success = runWorkflowTests(); - process.exit(success ? 0 : 1); + const success = runWorkflowTests(); + process.exit(success ? 0 : 1); } diff --git a/.windsurf/rules/00-project-context.md b/.windsurf/rules/00-project-context.md index fa7c2b73..3e965e84 100644 --- a/.windsurf/rules/00-project-context.md +++ b/.windsurf/rules/00-project-context.md @@ -130,25 +130,25 @@ Best practice — follow when possible: ## 🚫 Forbidden Actions -| ❌ Forbidden | ✅ Correct Approach | -| ----------------------------------------------- | ------------------------------------------------------- | -| SQL Triggers for business logic | NestJS Service methods | -| `.env` files in production | `docker-compose.yml` environment section | -| TypeORM migration files | Edit schema SQL directly (ADR-009) | -| Inventing table/column names | Verify against `lcbp3-v1.9.0-schema-02-tables.sql` | -| `any` TypeScript type | Proper types / generics | -| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | -| `req: any` in controllers | `RequestWithUser` typed interface | -| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | -| Exposing INT PK in API responses | UUIDv7 (ADR-019) | -| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023) | -| Direct file operations bypassing StorageService | `StorageService` for all file moves | -| Inline email/notification sending | BullMQ queue job | -| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | -| AI direct cloud API calls | On-premises Ollama only (ADR-023) | -| AI outputs without human validation | Human-in-the-loop validation required (ADR-023) | -| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama/Qdrant (ADR-023A) | -| Qdrant query without `projectPublicId` filter | `QdrantService.search(projectPublicId, ...)` (ADR-023A) | +| ❌ Forbidden | ✅ Correct Approach | ⚠️ Why | +| ----------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | +| SQL Triggers for business logic | NestJS Service methods | Untestable; bypasses audit log | +| `.env` files in production | `docker-compose.yml` environment section | Secrets exposed in version control | +| TypeORM migration files | Edit schema SQL directly (ADR-009) | Migration drift risk; schema managed via SQL delta | +| Inventing table/column names | Verify against `schema-02-tables.sql` | Schema mismatch causes silent runtime errors | +| `any` TypeScript type | Proper types / generics | Defeats strict mode; hides runtime type errors | +| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | Log flooding in production; risk of data leakage | +| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable | +| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"0195…"` parsed to integer `19` — silently wrong | +| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks | +| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023/023A) | Bypasses RBAC, audit trail, and validation layer | +| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail | +| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure | +| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production | +| AI direct cloud API calls | On-premises Ollama only (ADR-023/023A) | Data privacy violation; no audit control | +| AI outputs without human validation | Human-in-the-loop validation required (ADR-023/023A) | Unvalidated AI metadata corrupts document records | +| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama (ADR-023A) | Bypasses audit log, RBAC, and error handling layer | +| Qdrant query without `projectPublicId` filter | `QdrantService.search(projectPublicId, ...)` (ADR-023A) | Cross-project data leak via vector search | --- diff --git a/.windsurf/rules/04-domain-terminology.md b/.windsurf/rules/04-domain-terminology.md index cf3005f8..4db3276d 100644 --- a/.windsurf/rules/04-domain-terminology.md +++ b/.windsurf/rules/04-domain-terminology.md @@ -6,17 +6,17 @@ trigger: always_on ## DMS Glossary -| ✅ Use | ❌ Don't Use | -| ------------------ | ------------------------------------- | -| Correspondence | Letter, Communication, Document | -| RFA | Approval Request, Submit for Approval | -| Transmittal | Delivery Note, Cover Letter | -| Circulation | Distribution, Routing | -| Shop Drawing | Construction Drawing | -| Contract Drawing | Design Drawing, Blueprint | -| Workflow Engine | Approval Flow, Process Engine | -| Document Numbering | Document ID, Auto Number | -| RBAC | Permission System (generic) | +| ✅ Use | ❌ Don't Use | คำอธิบายเพิ่มเติม | +| ------------------ | ------------------------------------- | ------------------------------------------------ | +| Correspondence | Letter, Communication, Document | ครอบคลุมทุกประเภท: Letter, RFA, Memo, ฯลฯ | +| RFA | Approval Request, Submit for Approval | เอกสารขออนุมัติ (ชนิดหนึ่งของ Correspondence) | +| Transmittal | Delivery Note, Cover Letter | เอกสารนำส่ง (ชนิดหนึ่งของ Correspondence) | +| Circulation | Distribution, Routing | ใบเวียนเอกสารภายใน (ชนิดหนึ่งของ Correspondence) | +| Shop Drawing | Construction Drawing | แบบก่อสร้าง | +| Contract Drawing | Design Drawing, Blueprint | แบบคู่สัญญา | +| Workflow Engine | Approval Flow, Process Engine | เครื่องมือจัดการลำดับงาน | +| Document Numbering | Document ID, Auto Number | ระบบจัดการเลขที่เอกสาร | +| RBAC | Permission System (generic) | การควบคุมสิทธิ์ตามบทบาท | ## Full Glossary diff --git a/.windsurf/rules/05-forbidden-actions.md b/.windsurf/rules/05-forbidden-actions.md index 9017c34d..4f024cb4 100644 --- a/.windsurf/rules/05-forbidden-actions.md +++ b/.windsurf/rules/05-forbidden-actions.md @@ -6,25 +6,25 @@ trigger: always_on ## ❌ Never Do This -| ❌ Forbidden | ✅ Correct Approach | -| ----------------------------------------------- | ----------------------------------------------------------------- | -| SQL Triggers for business logic | NestJS Service methods | -| `.env` files in production | `docker-compose.yml` environment section | -| TypeORM migration files | Edit schema SQL directly (ADR-009) | -| Inventing table/column names | Verify against `lcbp3-v1.9.0-schema-02-tables.sql` | -| `any` TypeScript type | Proper types / generics | -| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | -| `req: any` in controllers | `RequestWithUser` typed interface | -| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | -| Exposing INT PK in API responses | UUIDv7 (ADR-019) | -| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023) | -| Direct file operations bypassing StorageService | `StorageService` for all file moves | -| Inline email/notification sending | BullMQ queue job | -| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | -| AI direct cloud API calls | On-premises Ollama only (ADR-023) | -| AI outputs without human validation | Human-in-the-loop validation required (ADR-023) | -| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama/Qdrant (ADR-023A) | -| Qdrant query without projectPublicId filter | QdrantService.search(projectPublicId: string) required (ADR-023A) | +| ❌ Forbidden | ✅ Correct Approach | ⚠️ Why | +| ----------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | +| SQL Triggers for business logic | NestJS Service methods | Untestable; bypasses audit log | +| `.env` files in production | `docker-compose.yml` environment section | Secrets exposed in version control | +| TypeORM migration files | Edit schema SQL directly (ADR-009) | Migration drift risk; schema managed via SQL delta | +| Inventing table/column names | Verify against `schema-02-tables.sql` | Schema mismatch causes silent runtime errors | +| `any` TypeScript type | Proper types / generics | Defeats strict mode; hides runtime type errors | +| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | Log flooding in production; risk of data leakage | +| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable | +| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"0195…"` parsed to integer `19` — silently wrong | +| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks | +| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023/023A) | Bypasses RBAC, audit trail, and validation layer | +| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail | +| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure | +| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production | +| AI direct cloud API calls | On-premises Ollama only (ADR-023/023A) | Data privacy violation; no audit control | +| AI outputs without human validation | Human-in-the-loop validation required (ADR-023/023A) | Unvalidated AI metadata corrupts document records | +| n8n calling Ollama/Qdrant directly | n8n → DMS API → BullMQ → Ollama (ADR-023A) | Bypasses audit log, RBAC, and error handling layer | +| Qdrant query without `projectPublicId` filter | `QdrantService.search(projectPublicId, ...)` (ADR-023A) | Cross-project data leak via vector search | ## Schema Changes (ADR-009) diff --git a/.windsurf/rules/08-development-flow.md b/.windsurf/rules/08-development-flow.md index 19c15cdc..832e8b87 100644 --- a/.windsurf/rules/08-development-flow.md +++ b/.windsurf/rules/08-development-flow.md @@ -13,7 +13,7 @@ trigger: always_on 3. **Check schema** — verify table/column in `lcbp3-v1.9.0-schema-02-tables.sql` 4. **Check data dictionary** — confirm field meanings + business rules 5. **Scan edge cases** — `01-06-edge-cases-and-rules.md` -6. **Check ADRs** — verify decisions align (ADR-009, ADR-018, ADR-019) +6. **Check ADRs** — verify decisions align (ADR-009, ADR-019, ADR-023) 7. **Write code** — TypeScript strict, no `any`, no `console.log` ## 🟡 Normal Work — UI / Feature / Integration @@ -84,30 +84,35 @@ trigger: always_on ## Context-Aware Triggers -| Request | Files to Check | Expected Response | -| ----------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard | -| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases.md` | RHF+Zod + TanStack Query + Thai comments | -| "เพิ่ม field ใหม่" | `ADR-009`, `data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | -| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor | -| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow | -| "ตรวจสอบ permission" | `seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix | -| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy | -| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns | -| "AI integration" | ✅ | `ADR-023`, `ADR-023A`, `ADR-024`, `ADR-025` | AI boundary + 2-model stack + BullMQ queue policy + Intent/Tool Layer | -| "Error handling" | ✅ | `ADR-007` | Layered error classification + recovery | -| "File upload" | ✅ | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist | -| "Notifications / Queue" | ✅ | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter | -| "Add i18n / translate" | ✅ | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text | -| "Workflow / DSL" | ✅ | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService | -| "Document numbering" | ✅ | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) | -| "ตรวจสอบ Workflow" | ✅ | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร | -| "Transmittal submit" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | submit() with EC-RFA-004 validation | -| "Circulation reassign" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | reassignRouting() with EC-CIRC-001 | -| "สร้าง workflow ใหม่" | 📋 | `ADR-001`, `ADR-021`, `specs/200-fullstacks/203-unified-workflow-engine/` | DSL workflow definition + WorkflowEngineService setup | -| "ตรวจสอบ AI boundary" | ✅ | `ADR-023`, `ADR-023A` | Verify Ollama isolation + BullMQ queues + Qdrant projectPublicId filter | -| "Intent classification" | ✅ | `ADR-024`, `specs/200-fullstacks/224-intent-classification/` | Pattern Layer → LLM Fallback; ai_intent_patterns; Redis cache 5 min | -| "AI Tool Layer" | ✅ | `ADR-025`, `specs/200-fullstacks/225-ai-tool-layer-architecture/` | Tool Registry; CASL-guarded dispatch; ToolResult publicId only | -| "Document Chat UI" | ✅ | `ADR-026`, `specs/200-fullstacks/226-document-chat-ui-pattern/` | Side-panel; useAiChat() hook; streaming SSE; TanStack Query cache | -| "AI Admin Console" | ✅ | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints | -| "Migration refactor" | ✅ | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates | +| Request | Files to Check | Expected Response | +| --------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard | +| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases-and-rules.md` | RHF+Zod + TanStack Query + Thai comments | +| "เพิ่ม field ใหม่" | `ADR-009`, `03-01-data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | +| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor | +| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow | +| "ตรวจสอบ permission" | `lcbp3-v1.9.0-seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix | +| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy | +| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns | +| "AI integration" | `ADR-023`, `ADR-023A`, `ADR-024`, `ADR-025` | AI boundary + 2-model stack + BullMQ queue policy + Intent/Tool Layer | +| "Error handling" | `ADR-007` | Layered error classification + recovery | +| "File upload" | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist | +| "Notifications / Queue" | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter | +| "Add i18n / translate" | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text | +| "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService | +| "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) | +| "ตรวจสอบ Workflow" | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร | +| "Transmittal submit" | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | submit() with EC-RFA-004 validation | +| "Circulation reassign" | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | reassignRouting() with EC-CIRC-001 | +| "สร้าง workflow ใหม่" | `ADR-001`, `ADR-021`, `specs/200-fullstacks/203-unified-workflow-engine/` | DSL workflow definition + WorkflowEngineService setup | +| "ตรวจสอบ AI boundary" | `ADR-023`, `ADR-023A` | Verify Ollama isolation + BullMQ queues + Qdrant projectPublicId filter | +| "Intent classification" | `ADR-024`, `specs/200-fullstacks/224-intent-classification/` | Pattern Layer → LLM Fallback; ai_intent_patterns; Redis cache 5 min | +| "AI Tool Layer" | `ADR-025`, `specs/200-fullstacks/225-ai-tool-layer-architecture/` | Tool Registry; CASL-guarded dispatch; ToolResult publicId only | +| "Document Chat UI" | `ADR-026`, `specs/200-fullstacks/226-document-chat-ui-pattern/` | Side-panel; useAiChat() hook; streaming SSE; TanStack Query cache | +| "AI Admin Console" | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints | +| "Migration refactor" | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates | +| "จัดการ document numbering" | `ADR-002`, `specs/03-Data-and-Storage/03-04-document-numbering.md` | Redis Redlock + template system + preview/override workflows | +| "Audit ความปลอดภัย" | `ADR-016`, `ADR-019`, `ADR-023`, `ADR-023A` | ตรวจสอบ UUID pattern, CASL Guard, AI Boundary และ Qdrant multi-tenancy | +| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน | +| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence | +| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ | diff --git a/.windsurf/rules/11-ai-integration.md b/.windsurf/rules/11-ai-integration.md index 65476700..6147c599 100644 --- a/.windsurf/rules/11-ai-integration.md +++ b/.windsurf/rules/11-ai-integration.md @@ -7,7 +7,7 @@ trigger: always_on ## CRITICAL RULES - **ALWAYS** follow ADR-023 AI boundary policy (isolation on Admin Desktop) -- **ALWAYS** use ADR-023A 2-model stack (gemma4:e4b Q8_0 + nomic-embed-text) +- **ALWAYS** use ADR-023A 2-model stack (gemma4:e2b + nomic-embed-text) - **ALWAYS** use BullMQ 2-queue (ai-realtime + ai-batch) for GPU overload prevention - **NEVER** allow AI direct database/storage access - **ALWAYS** implement human-in-the-loop validation @@ -30,7 +30,7 @@ n8n (Migration) → DMS API → BullMQ → Admin Desktop (Ollama) → Backend Va | ----------------- | ------------------------- | ------------------------------------------------------------------------ | | **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging | | **BullMQ Queues** | Backend (NestJS) | ai-realtime (RAG/Suggest), ai-batch (OCR/Extract/Embed) | -| **Ollama Engine** | Admin Desktop (Desk-5439) | gemma4:e4b Q8_0 (LLM) + nomic-embed-text (Embedding) | +| **Ollama Engine** | Admin Desktop (Desk-5439) | gemma4:e2b (LLM) + nomic-embed-text (Embedding) | | **OCR Engine** | Admin Desktop (Desk-5439) | PaddleOCR + PyThaiNLP (Thai/English text extraction) | | **Orchestrator** | QNAP NAS (n8n) | Migration Phase orchestrator only (calls DMS API, never Ollama directly) | @@ -80,7 +80,7 @@ export class AiService { async extractMetadata(documentId: string): Promise { // 1. Validate permissions // 2. Queue job to BullMQ (ai-batch or ai-realtime) - // 3. Worker sends to Admin Desktop AI (gemma4:e4b Q8_0) + // 3. Worker sends to Admin Desktop AI (gemma4:e2b) // 4. Validate AI response // 5. Log audit trail to ai_audit_logs // 6. Return validated results @@ -119,7 +119,7 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => { ## ADR-023A Specific Rules -- **2-Model Stack:** gemma4:e4b Q8_0 (~4.0GB) + nomic-embed-text (~0.3GB) = ~4.3GB VRAM peak +- **2-Model Stack:** gemma4:e2b + nomic-embed-text - **PDF 3-Page Limit:** Classification/Tagging uses first 3 pages only (NOT RAG embedding) - **RAG Embedding:** Full document chunked at 512 tokens/64 tokens overlap - **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else PaddleOCR @@ -133,7 +133,7 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => { - [ ] BullMQ 2-queue setup (ai-realtime + ai-batch) - [ ] QdrantService with projectPublicId enforcement - [ ] DocumentReviewForm reusable component -- [ ] Admin Desktop Ollama (gemma4:e4b Q8_0 + nomic-embed-text) + PaddleOCR setup +- [ ] Admin Desktop Ollama (gemma4:e2b + nomic-embed-text) + PaddleOCR setup - [ ] n8n workflow orchestration (Migration Phase only) - [ ] AI audit logging and monitoring (ai_audit_logs) - [ ] Human-in-the-loop validation workflows @@ -142,3 +142,8 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => { - `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` (Base architecture) - `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` (Model revision - current) +- `specs/06-Decision-Records/ADR-024-intent-classification-strategy.md` (Pattern→LLM Fallback) +- `specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md` (Tool Registry) +- `specs/06-Decision-Records/ADR-026-document-chat-ui-pattern.md` (Chat UI) +- `specs/06-Decision-Records/ADR-027-ai-admin-console-and-dynamic-control.md` (Admin Console) +- `specs/06-Decision-Records/ADR-028-migration-architecture-refactor.md` (Migration Pipeline) diff --git a/AGENTS.md b/AGENTS.md index f765dbbc..014f5204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # NAP-DMS Project Context & Rules - For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools) -- Version: 1.9.6 | Last synced from repo: 2026-05-22 +- Version: 1.9.7 | Last synced from repo: 2026-05-25 - Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) - Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md) @@ -120,37 +120,38 @@ Best practice — follow when possible: Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > others -| Document | Path | Status | Use When | -| ------------------------------ | --------------------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------- | -| **Glossary** | `specs/00-overview/00-02-glossary.md` | — | Verify domain terminology | -| **Schema Tables** | `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql` | — | Before writing any query | -| **Data Dictionary** | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | — | Field meanings + business rules | -| **RBAC Matrix** | `specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md` | — | Permission levels + roles | -| **Edge Cases** | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | — | Prevent bugs in flows | -| **ADR-001 Workflow Engine** | `specs/06-Decision-Records/ADR-001-unified-workflow-engine.md` | ✅ Active | DSL-based workflow implementation | -| **ADR-002 Doc Numbering** | `specs/06-Decision-Records/ADR-002-document-numbering-strategy.md` | ✅ Active | Document number generation + locking | -| **ADR-007 Error Handling** | `specs/06-Decision-Records/ADR-007-error-handling-strategy.md` | ✅ Active | Error patterns & recovery | -| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification | -| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly | -| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security | -| **ADR-015 Release Strategy** | `specs/06-Decision-Records/ADR-015-deployment-infrastructure.md` | ✅ Active | Blue-Green deployment + release gates | -| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | -| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments | -| **ADR-023 AI Architecture** | `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` | ✅ Active | Unified AI boundaries and pipeline (base architecture) | -| **ADR-023A AI Model Rev.** | `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` | ✅ Active | 2-Model stack (gemma4:e2b), BullMQ 2-queue, RAG embed scope, OCR auto-detect | -| **ADR-024 Intent Class.** | `specs/06-Decision-Records/ADR-024-intent-classification-strategy.md` | ✅ Active | Hybrid Pattern→LLM Fallback; ai_intent_patterns DB; Redis cache 5 min | -| **ADR-025 AI Tool Layer** | `specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md` | ✅ Active | Server-side Tool dispatch; CASL-guarded bridge; ToolResult uses publicId only | -| **ADR-026 Chat UI** | `specs/06-Decision-Records/ADR-026-document-chat-ui-pattern.md` | ✅ Active | Side-panel Document Chat UI; useAiChat() hook; streaming response support | -| **ADR-027 AI Admin Console** | `specs/06-Decision-Records/ADR-027-ai-admin-console-and-dynamic-control.md` | ✅ Active | Admin Panel + dynamic model/prompt/intent control without redeploy | -| **ADR-028 Migration Refactor** | `specs/06-Decision-Records/ADR-028-migration-architecture-refactor.md` | ✅ Active | Staging Queue & post-migration cleanup | -| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns | -| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns | -| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals | -| **Git Conventions** | `specs/05-Engineering-Guidelines/05-05-git-conventions.md` | — | Commit/branch naming | -| **Code Snippets** | `specs/05-Engineering-Guidelines/05-06-code-snippets.md` | — | Reusable patterns | -| **i18n Guidelines** | `specs/05-Engineering-Guidelines/05-08-i18n-guidelines.md` | — | Localization rules | -| **Release Policy** | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` | — | Before deploy/hotfix | -| **UAT Criteria** | `specs/01-Requirements/01-05-acceptance-criteria.md` | — | Feature completeness | +| Document | Path | Status | Use When | +| ------------------------------ | --------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------- | +| **Glossary** | `specs/00-overview/00-02-glossary.md` | — | Verify domain terminology | +| **Schema Tables** | `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql` | — | Before writing any query | +| **Data Dictionary** | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | — | Field meanings + business rules | +| **RBAC Matrix** | `specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md` | — | Permission levels + roles | +| **Edge Cases** | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | — | Prevent bugs in flows | +| **ADR-001 Workflow Engine** | `specs/06-Decision-Records/ADR-001-unified-workflow-engine.md` | ✅ Active | DSL-based workflow implementation | +| **ADR-002 Doc Numbering** | `specs/06-Decision-Records/ADR-002-document-numbering-strategy.md` | ✅ Active | Document number generation + locking | +| **ADR-007 Error Handling** | `specs/06-Decision-Records/ADR-007-error-handling-strategy.md` | ✅ Active | Error patterns & recovery | +| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification | +| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly | +| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security | +| **ADR-015 Release Strategy** | `specs/06-Decision-Records/ADR-015-deployment-infrastructure.md` | ✅ Active | Blue-Green deployment + release gates | +| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | +| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments | +| **ADR-023 AI Architecture** | `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` | ✅ Active | Unified AI boundaries and pipeline (base architecture) | +| **ADR-023A AI Model Rev.** | `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` | ✅ Active | 2-Model stack (gemma4:e4b Q8_0), BullMQ 2-queue, RAG embed scope, OCR auto-detect | +| **ADR-024 Intent Class.** | `specs/06-Decision-Records/ADR-024-intent-classification-strategy.md` | ✅ Active | Hybrid Pattern→LLM Fallback; ai_intent_patterns DB; Redis cache 5 min | +| **ADR-025 AI Tool Layer** | `specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md` | ✅ Active | Server-side Tool dispatch; CASL-guarded bridge; ToolResult uses publicId only | +| **ADR-026 Chat UI** | `specs/06-Decision-Records/ADR-026-document-chat-ui-pattern.md` | ✅ Active | Side-panel Document Chat UI; useAiChat() hook; streaming response support | +| **ADR-027 AI Admin Console** | `specs/06-Decision-Records/ADR-027-ai-admin-console-and-dynamic-control.md` | ✅ Active | Admin Panel + dynamic model/prompt/intent control without redeploy | +| **ADR-028 Migration Refactor** | `specs/06-Decision-Records/ADR-028-migration-architecture-refactor.md` | ✅ Active | Staging Queue & post-migration cleanup | +| **ADR-029 Dynamic Prompts** | `specs/06-Decision-Records/ADR-029-dynamic-prompt-management.md` | ✅ Active | Prompt templates in DB (`ai_prompts`); Redis cache TTL 60s; versioned | +| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns | +| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns | +| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals | +| **Git Conventions** | `specs/05-Engineering-Guidelines/05-05-git-conventions.md` | — | Commit/branch naming | +| **Code Snippets** | `specs/05-Engineering-Guidelines/05-06-code-snippets.md` | — | Reusable patterns | +| **i18n Guidelines** | `specs/05-Engineering-Guidelines/05-08-i18n-guidelines.md` | — | Localization rules | +| **Release Policy** | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` | — | Before deploy/hotfix | +| **UAT Criteria** | `specs/01-Requirements/01-05-acceptance-criteria.md` | — | Feature completeness | --- @@ -265,7 +266,7 @@ Read `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md` 5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days 6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints 7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan -8. **AI Isolation (ADR-023/023A):** Ollama on Admin Desktop ONLY — NO direct DB/storage access; 2-model stack `gemma4:e2b` + `nomic-embed-text`; all inference via BullMQ (`ai-realtime` / `ai-batch`) +8. **AI Isolation (ADR-023/023A):** Ollama on Admin Desktop ONLY — NO direct DB/storage access; 2-model stack `gemma4:e4b Q8_0` + `nomic-embed-text`; all inference via BullMQ (`ai-realtime` / `ai-batch`) 9. **Error Handling (ADR-007):** Use layered error classification with user-friendly messages 10. **AI Integration (ADR-023/023A):** RFA-First approach; n8n orchestrates Migration Phase only via DMS API — never calls Ollama directly; `QdrantService.search()` requires `projectPublicId` as mandatory param @@ -427,7 +428,7 @@ Full glossary: `specs/00-overview/00-02-glossary.md` **For AI Runtime Layer (ADR-024/025/026/027):** -- ADR-024: Pattern Layer first (ai_intent_patterns DB + Redis cache 5 min) → LLM Fallback (gemma4:e2b, semaphore max=3) +- ADR-024: Pattern Layer first (ai_intent_patterns DB + Redis cache 5 min) → LLM Fallback (gemma4:e4b Q8_0, semaphore max=3) - ADR-025: Tool Registry dispatch — AI Gateway → Tool → Business Service; ToolResult DTO must use publicId only - ADR-026: useAiChat() hook + side-panel UI; streaming response via SSE; TanStack Query cache - ADR-027: Admin Console — dynamic model/prompt/intent control; CASL-guarded admin-only endpoints @@ -479,6 +480,7 @@ When user asks about... check these files: | "Document Chat UI" | ✅ | `ADR-026`, `specs/200-fullstacks/226-document-chat-ui-pattern/` | Side-panel; useAiChat() hook; streaming SSE; TanStack Query cache | | "AI Admin Console" | ✅ | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints | | "Migration refactor" | ✅ | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates | +| "Dynamic Prompt / Prompt" | ✅ | `ADR-029`, `specs/06-Decision-Records/ADR-029-dynamic-prompt-management.md` | ai_prompts table; Redis cache `ai:prompt:active:{type}` TTL 60s | | "จัดการ document numbering" | ✅ | `ADR-002`, `specs/03-Data-and-Storage/03-04-document-numbering.md` | Redis Redlock + template system + preview/override workflows | | "Audit ความปลอดภัย" | ✅ | `ADR-016`, `ADR-019`, `ADR-023`, `ADR-023A` | ตรวจสอบ UUID pattern, CASL Guard, AI Boundary และ Qdrant multi-tenancy | | "แก้ bug / bugfix" | ✅ | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน | @@ -549,7 +551,8 @@ When user asks about... check these files: - [ ] **Qdrant Multi-tenancy:** `projectPublicId` filter enforced - [ ] **Human-in-the-loop:** AI outputs validated before use - [ ] **Audit Logging:** All AI interactions logged to `ai_audit_logs` -- [ ] **2-Model Stack:** gemma4:e2b + nomic-embed-text verified +- [ ] **2-Model Stack:** gemma4:e4b Q8_0 + nomic-embed-text verified +- [ ] **Dynamic Prompts (ADR-029):** Prompt templates loaded from `ai_prompts` DB, not hardcoded **Performance & Complex Logic:** @@ -603,6 +606,7 @@ This file is a **quick reference**. For detailed information: | Version | Date | Changes | Updated By | | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | +| 1.9.7 | 2026-05-25 | Added ADR-029 Dynamic Prompt Management to Key Spec Files table; fixed gemma4 model name e2b→e4b Q8_0; added Dynamic Prompt context trigger; added ADR-029 to Tier 3 AI checklist; bumped last synced date | Windsurf AI | | 1.9.6 | 2026-05-22 | Added ADR-024/025/026/027/028 to Key Spec Files table; Tier 3 expanded with AI Runtime Layer + Migration Pipeline tiers; Specialized Work section updated with ADR-024~028 patterns; 6 new Context-Aware Triggers; bumped Last synced date | Windsurf AI | | 1.9.5 | 2026-05-18 | **Grill-with-Docs Session:** Domain terminology clarified (Correspondence = all doc types), Tier 3: SPECIALIZED WORK added, Context-Aware Triggers with Status column, Tier-specific Final Checklists | Windsurf AI | | 1.9.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe0846cb..0f833dfa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -3,10 +3,10 @@ --- **title:** 'LCBP3-DMS Architecture Documentation' -**version:** 1.9.5 +**version:** 1.9.7 **status:** active **owner:** Nattanin Peancharoen -**last_updated:** 2026-05-22 +**last_updated:** 2026-05-25 **related:** - specs/02-Architecture/02-01-system-context.md @@ -519,23 +519,24 @@ graph TB ### 6.1 Key ADRs Implemented -| ADR | Title | Status | Description | -| ------------ | ------------------------------- | --------- | ---------------------------------------------- | -| **ADR-001** | Unified Workflow Engine | ✅ Active | DSL-based workflow implementation | -| **ADR-002** | Document Numbering Strategy | ✅ Active | Document number generation + locking | -| **ADR-007** | Error Handling Strategy | ✅ Active | Layered error classification | -| **ADR-008** | Email Notification Strategy | ✅ Active | BullMQ + multi-channel notification | -| **ADR-009** | Database Migration Strategy | ✅ Active | Schema changes — edit SQL directly | -| **ADR-016** | Security Authentication | ✅ Active | Auth, RBAC, file upload security | -| **ADR-019** | Hybrid Identifier Strategy | ✅ Active | INT PK + UUIDv7 Public API | -| **ADR-021** | Workflow Context | ✅ Active | Integrated workflow & step attachments | -| **ADR-023** | Unified AI Architecture | ✅ Active | AI boundaries and pipeline | -| **ADR-023A** | AI Model Revision | ✅ Active | 2-Model stack with BullMQ queues | -| **ADR-024** | Intent Classification Strategy | ✅ Active | Hybrid Pattern → LLM Fallback intent routing | -| **ADR-025** | AI Tool Layer Architecture | ✅ Active | Server-side Tool dispatch, CASL-guarded bridge | -| **ADR-026** | Document Chat UI Pattern | ✅ Active | Side-panel document chat UI | -| **ADR-027** | AI Admin Console & Dynamic Ctrl | ✅ Active | AI Admin Panel + dynamic model/prompt control | -| **ADR-028** | Migration Architecture Refactor | ✅ Active | Staging Queue & post-migration cleanup | +| ADR | Title | Status | Description | +| ------------ | ------------------------------- | --------- | --------------------------------------------------------------------- | +| **ADR-001** | Unified Workflow Engine | ✅ Active | DSL-based workflow implementation | +| **ADR-002** | Document Numbering Strategy | ✅ Active | Document number generation + locking | +| **ADR-007** | Error Handling Strategy | ✅ Active | Layered error classification | +| **ADR-008** | Email Notification Strategy | ✅ Active | BullMQ + multi-channel notification | +| **ADR-009** | Database Migration Strategy | ✅ Active | Schema changes — edit SQL directly | +| **ADR-016** | Security Authentication | ✅ Active | Auth, RBAC, file upload security | +| **ADR-019** | Hybrid Identifier Strategy | ✅ Active | INT PK + UUIDv7 Public API | +| **ADR-021** | Workflow Context | ✅ Active | Integrated workflow & step attachments | +| **ADR-023** | Unified AI Architecture | ✅ Active | AI boundaries and pipeline | +| **ADR-023A** | AI Model Revision | ✅ Active | 2-Model stack with BullMQ queues | +| **ADR-024** | Intent Classification Strategy | ✅ Active | Hybrid Pattern → LLM Fallback intent routing | +| **ADR-025** | AI Tool Layer Architecture | ✅ Active | Server-side Tool dispatch, CASL-guarded bridge | +| **ADR-026** | Document Chat UI Pattern | ✅ Active | Side-panel document chat UI | +| **ADR-027** | AI Admin Console & Dynamic Ctrl | ✅ Active | AI Admin Panel + dynamic model/prompt control | +| **ADR-028** | Migration Architecture Refactor | ✅ Active | Staging Queue & post-migration cleanup | +| **ADR-029** | Dynamic Prompt Management | ✅ Active | Prompt templates in DB (`ai_prompts`), Redis cache TTL 60s, versioned | ### 6.2 ADR References @@ -562,6 +563,7 @@ For detailed architectural decisions, please refer to: | Version | Date | Changes | | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **1.9.7** | 2026-05-25 | Added ADR-029 Dynamic Prompt Management to ADR table; bumped version/date | | **1.9.5** | 2026-05-22 | Added ADR-024/025/026/027/028 to ADR reference table; updated AI Architecture section heading; schema reference corrected to v1.9.0 | | **1.9.2** | 2026-05-18 | Complete restructure following specs/02-Architecture format, added comprehensive diagrams, updated AI Architecture (ADR-023/023A) | | **1.9.0** | 2026-05-13 | AI Architecture consolidation, Agent Infrastructure standardization | diff --git a/CHANGELOG.md b/CHANGELOG.md index bc083d41..0a854c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Version History +## 1.9.7 (2026-05-25) + +### docs(ai): ADR-029 Dynamic Prompt Management + PaddleOCR Sidecar + Bug Fixes + +#### Summary + +เพิ่ม ADR-029 (Dynamic Prompt Management) สำหรับจัดการ Prompt Templates ใน DB และ Deploy PaddleOCR Sidecar บน Desk-5439 พร้อมแก้ไข Bug สำคัญในระบบ n8n, Correspondence และ Tags/Contracts + +#### Changes + +- **ADR-029**: สร้างเอกสาร `ADR-029-dynamic-prompt-management.md` — Prompt templates เก็บใน `ai_prompts` table, Redis cache `ai:prompt:active:{type}` TTL 60s; `activate()` รันใน DB transaction + Redis DEL; CASL guard `system.manage_all` บน mutations +- **PaddleOCR Sidecar (Desk-5439)**: สร้าง Docker Compose stack สำหรับ OCR sidecar — FastAPI (`app.py`) endpoints `/ocr` + `/normalize` + `/health`; CIFS volume เชื่อมต่อ QNAP (`//192.168.10.8/np-dms-as/data/uploads`); model download ตอน runtime (ไม่ pre-download ใน Dockerfile เพื่อหลีกเลี่ยง segfault) +- **n8n Workflow Fixes**: แก้ไข Migration Queue attachment UUID mismatch, normalize migration errors, debug n8n Submit AI Job pipeline +- **TransformInterceptor Bug Fix**: ลบ duplicate registration ออกจาก `main.ts` (เก็บเฉพาะ `APP_INTERCEPTOR` ใน `CommonModule`) — แก้ปัญหา double-wrapped response `{ data: { data: ... } }` +- **Correspondence Module Fixes**: `findOneByUuid()` ใช้ explicit JOIN + revision ordering (DESC) เพื่อให้ deterministic; normalize `recipient_type` whitespace variants ใน frontend detail component +- **Tags/Contracts UUID Fixes**: แก้ TypeORM Tag entity column-name mismatch; แก้ `handleEdit` ใน contracts page อ่าน `contract.project?.id` แทน `project?.publicId` +- **Playwright E2E**: ติดตั้ง MCP Playwright server, สร้าง E2E spec `frontend/e2e/workflow-adr021.spec.ts` +- **Root Docs**: อัปเดต `AGENTS.md` v1.9.7 (ADR-029, gemma4:e4b Q8_0 fix), `README.md`, `ARCHITECTURE.md`, `CHANGELOG.md`, `CONTRIBUTING.md` ทั้งหมดสะท้อน 29 ADRs + +--- + +## 1.9.6 (2026-05-22) + +### spec(agents): AGENTS.md v1.9.6 — AI Runtime Layer + Migration Pipeline Tiers + +#### Summary + +อัปเดต `AGENTS.md` ให้ครอบคลุม AI Runtime Layer (ADR-024~028) อย่างครบถ้วน พร้อมขยาย Tier 3 SPECIALIZED WORK และเพิ่ม Context-Aware Triggers ใหม่ + +#### Changes + +- **Key Spec Files**: เพิ่ม ADR-024/025/026/027/028 ในตาราง Key Spec Files +- **Tier 3 Expanded**: เพิ่ม AI Runtime Layer (ADR-024/025/026/027) และ Migration Pipeline (ADR-028) ใน Specialized Work tiers +- **Context-Aware Triggers**: เพิ่ม 6 triggers ใหม่ (Intent classification, AI Tool Layer, Document Chat UI, AI Admin Console, Migration refactor, งานค้าง/resume) +- **Specialized Work Flow**: เพิ่ม subsection "For AI Runtime Layer" และ "For Migration Pipeline" ใน Development Flow + +--- + ## 1.9.5 (2026-05-22) ### docs(adr): ADR-028 Migration Architecture Refactor + Root Docs Update diff --git a/CONTEXT.md b/CONTEXT.md index aeae44da..61451164 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -221,7 +221,7 @@ User Chat → Intent Router (ยังไม่มี) → Server-side Intent | **AI Tool Layer** | 🟡 ADR Accepted | ADR-025 Accepted — Tool Layer Architecture รอ implementation | | **Document Chat UI** | 🟡 ADR Accepted | ADR-026 Accepted — Side-panel Chat UI รอ implementation | | **AI Admin Console** | 🟡 ADR Accepted | ADR-027 Accepted — Dynamic Control Panel รอ implementation | -| **Dynamic Prompt Mgmt** | 🟡 Spec Ready | ADR-029 Active — speckit.prepare เสร็จแล้ว (spec/plan/tasks/contracts); รอ implementation | +| **Dynamic Prompt Mgmt** | ✅ พร้อม | ADR-029 Active — พัฒนาเสร็จสมบูรณ์ทั้ง Entity, API, Sandbox Runner, Cache และ UI Playgrounds | ## Flagged ambiguities diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c6c1321..191ed9c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # 📝 Contributing to LCBP3-DMS Specifications -> แนวทางการมีส่วนร่วมในการพัฒนาเอกสาร Specifications ของโครงการ LCBP3-DMS (v1.9.5) +> แนวทางการมีส่วนร่วมในการพัฒนาเอกสาร Specifications ของโครงการ LCBP3-DMS (v1.9.7) ยินดีต้อนรับสู่คู่มือการมีส่วนร่วมในการพัฒนาเอกสาร Specifications! เอกสารนี้จะช่วยให้คุณเข้าใจวิธีการสร้าง แก้ไข และปรับปรุงเอกสารข้อกำหนดของโครงการได้อย่างมีประสิทธิภาพ @@ -561,13 +561,15 @@ graph LR | 1.9.3 | 2026-05-19 | Tech Lead | ADR-024 Intent Classification + ADR-025 AI Tool Layer + ADR-026 Chat UI (26 ADRs) | | 1.9.4 | 2026-05-21 | Tech Lead | ADR-027 AI Admin Console & Dynamic Control (27 ADRs) | | 1.9.5 | 2026-05-22 | Tech Lead | ADR-028 Migration Architecture Refactor + Root Docs Update (28 ADRs) | +| 1.9.6 | 2026-05-22 | Tech Lead | AGENTS.md v1.9.6 — AI Runtime Layer + Migration Pipeline Tiers expanded | +| 1.9.7 | 2026-05-25 | Tech Lead | ADR-029 Dynamic Prompt Management + PaddleOCR Sidecar infra + bug fixes (29 ADRs) | -**Current Version**: 1.9.5 +**Current Version**: 1.9.7 **Status**: Approved -**Last Updated**: 2026-05-22 +**Last Updated**: 2026-05-25 **Security**: 0 vulnerabilities (backend) + Compose stack hardened (27 findings → 0) **Workflow Engine**: ADR-021 Integrated Context complete + RFA v1.9.0 finalized -**AI Runtime Layer**: ADR-024/025/026/027 Accepted — Intent Classification, Tool Layer, Chat UI, Admin Console +**AI Runtime Layer**: ADR-024/025/026/027/028/029 Active — Intent Classification, Tool Layer, Chat UI, Admin Console, Dynamic Prompts ``` ### 5. UUID Conventions (ADR-019) diff --git a/README.md b/README.md index 33065788..4433407b 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,26 @@ > **Laem Chabang Port Phase 3 - Document Management System** > ระบบบริหารจัดการเอกสารโครงการแบบครบวงจร สำหรับโครงการก่อสร้างท่าเรือแหลมฉบังระยะที่ 3 -[![Version](https://img.shields.io/badge/version-1.9.5-blue.svg)](./CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.9.7-blue.svg)](./CHANGELOG.md) [![License](https://img.shields.io/badge/license-Internal-red.svg)]() [![Status](https://img.shields.io/badge/status-Production%20Ready-brightgreen.svg)]() [![Docs](https://img.shields.io/badge/docs-10%2F10%20Gaps%20Closed-success.svg)](./specs/00-Overview/README.md) --- -## 📈 Current Status (As of 2026-05-22) +## 📈 Current Status (As of 2026-05-25) -**Version 1.9.5 — AI Intent/Tool Layer/Chat/Admin Console ADRs + Migration Arch Refactor (ADR-024~028)** +**Version 1.9.7 — ADR-029 Dynamic Prompt Management + PaddleOCR Sidecar + n8n Workflow Fixes** -> v1.9.0 shipped May 13; v1.9.2 (ADR-023A) May 15; v1.9.3~1.9.5 (ADR-024~028) May 19–22. +> v1.9.5 (ADR-028) May 22; v1.9.6 (AGENTS update) May 22; v1.9.7 (ADR-029 + sidecar infra) May 25. | Area | Status | หมายเหตุ | | ---------------------- | ------------------------ | ------------------------------------------------------------------ | | 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities | | 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 | | 💾 **Database** | ✅ Schema v1.9.0 Stable | MariaDB 11.8, No-migration Policy | -| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (28 ADRs — v1.9.5) | -| 🤖 **AI Architecture** | ✅ 28 ADRs Accepted | ADR-023A infra + ADR-024/025/026/027/028 runtime layer | +| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (29 ADRs — v1.9.7) | +| 🤖 **AI Architecture** | ✅ 29 ADRs Accepted | ADR-023A infra + ADR-024~029 runtime + dynamic prompts | | 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context | | 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready | | 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station | @@ -52,6 +52,8 @@ LCBP3-DMS เป็นระบบบริหารจัดการเอก - 🤖 **AI-Assisted Migration** - Ollama + n8n นำเข้าเอกสารเก่า ~20,000 ไฟล์ (ADR-023/028) - 💬 **AI Document Assistant** - Intent Classification + Tool Layer + Document Chat UI (ADR-024/025/026) - ⚙️ **AI Admin Console** - Dynamic model/prompt/intent control (ADR-027) +- 📝 **Dynamic Prompt Management** - Prompt templates in DB `ai_prompts`, Redis cache TTL 60s (ADR-029) +- 🔬 **PaddleOCR Sidecar** - FastAPI OCR service on Desk-5439 with CIFS mount to QNAP --- @@ -548,6 +550,5 @@ lcbp3-dms/ **Built with ❤️ for LCBP3 Project** [Documentation](./docs) • [Issues](https://git.np-dms.work/lcbp3/lcbp3-dms/issues) • [Changelog](./CHANGELOG.md) -เพิ่มชั่วคราวเพื่อ push diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 58401a1b..d318a79a 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -56,6 +56,8 @@ import { RbacGuard } from '../../common/guards/rbac.guard'; import { IntentClassifierModule } from './intent-classifier/intent-classifier.module'; import { AiToolModule } from './tool/ai-tool.module'; import { CleanupTempFilesWorker } from './workers/cleanup-temp-files.worker'; +import { AiPromptsModule } from './prompts/ai-prompts.module'; +import { AiPrompt } from './prompts/ai-prompts.entity'; import { QUEUE_AI_BATCH, QUEUE_AI_INGEST, @@ -81,6 +83,7 @@ import { CorrespondenceType, ImportTransaction, MigrationReviewQueue, + AiPrompt, ]), BullModule.registerQueue( @@ -130,6 +133,8 @@ import { IntentClassifierModule, // ADR-025: AI Tool Layer (Tool Registry + CASL-enforced Tool Services) AiToolModule, + // ADR-029: Dynamic Prompt Management for OCR Extraction + AiPromptsModule, ], controllers: [AiController], providers: [ diff --git a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts index 0877c32f..10806389 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts @@ -19,6 +19,7 @@ import { Project } from '../../project/entities/project.entity'; import { AiAuditLog } from '../entities/ai-audit-log.entity'; import { TagsService } from '../../tags/tags.service'; import { MigrationService } from '../../migration/migration.service'; +import { AiPromptsService } from '../prompts/ai-prompts.service'; describe('AiBatchProcessor', () => { let processor: AiBatchProcessor; @@ -90,6 +91,13 @@ describe('AiBatchProcessor', () => { createError: jest.fn().mockResolvedValue(undefined), enqueueRecord: jest.fn().mockResolvedValue(undefined), }; + const mockAiPromptsService = { + resolveActive: jest.fn().mockResolvedValue({ + resolvedPrompt: 'Resolved test prompt with OCR text', + versionNumber: 2, + }), + saveTestResult: jest.fn().mockResolvedValue(undefined), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -113,6 +121,7 @@ describe('AiBatchProcessor', () => { }, { provide: TagsService, useValue: mockTagsService }, { provide: MigrationService, useValue: mockMigrationService }, + { provide: AiPromptsService, useValue: mockAiPromptsService }, ], }).compile(); processor = module.get(AiBatchProcessor); diff --git a/backend/src/modules/ai/processors/ai-batch.processor.ts b/backend/src/modules/ai/processors/ai-batch.processor.ts index 899bff7d..d65086dd 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.ts @@ -6,6 +6,8 @@ // - 2026-05-21: พัฒนาระบบประมวลผล sandbox-extract พร้อมเชื่อมต่อ OcrService, OllamaService และ Redis cache // - 2026-05-21: แก้ไข ESLint unused variable สำหรับ parseError ใน catch block // - 2026-05-22: แก้ไข type compilation error ใน processMigrateDocument และนำช่องว่างภายในฟังก์ชันออก +// - 2026-05-25: เพิ่ม AiPromptsService เพื่อดึง Dynamic Prompt สำหรับ OCR extraction ใน sandbox และ migration pipeline +// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000 import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Logger } from '@nestjs/common'; @@ -25,11 +27,13 @@ import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity'; import { TagsService } from '../../tags/tags.service'; import { MigrationService } from '../../migration/migration.service'; import { MigrationErrorType } from '../../migration/entities/migration-error.entity'; +import { AiPromptsService } from '../prompts/ai-prompts.service'; interface MigrateDocumentMetadata extends Record { documentNumber?: string; subject?: string; category?: string; + discipline?: string; date?: string; confidence?: number; tags?: string[]; @@ -80,6 +84,7 @@ const parseMigrateDocumentMetadata = ( documentNumber: readString(source.documentNumber), subject: readString(source.subject), category: readString(source.category), + discipline: readString(source.discipline), date: readString(source.date), confidence: typeof source.confidence === 'number' ? source.confidence : undefined, @@ -88,8 +93,11 @@ const parseMigrateDocumentMetadata = ( }; }; -/** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM */ -@Processor(QUEUE_AI_BATCH, { concurrency: 1 }) +/** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM + * lockDuration: 150000ms — รองรับ Ollama sandbox ที่ใช้เวลาสูงสุด 120s (ADR-029 FR-008) + * ค่า default ของ BullMQ คือ 30000ms ซึ่งน้อยกว่า timeout → job stall + */ +@Processor(QUEUE_AI_BATCH, { concurrency: 1, lockDuration: 150000 }) export class AiBatchProcessor extends WorkerHost { private readonly logger = new Logger(AiBatchProcessor.name); private readonly abortControllers = new Map(); @@ -107,6 +115,7 @@ export class AiBatchProcessor extends WorkerHost { private readonly ollamaService: OllamaService, private readonly tagsService: TagsService, private readonly migrationService: MigrationService, + private readonly aiPromptsService: AiPromptsService, @InjectRedis() private readonly redis: Redis ) { super(); @@ -252,28 +261,14 @@ export class AiBatchProcessor extends WorkerHost { ); try { const ocrResult = await this.ocrService.detectAndExtract({ pdfPath }); - const prompt = `You are an expert document extraction system. -Analyze the following OCR text extracted from a project document and extract the metadata fields. - -OCR TEXT: -${ocrResult.text} - -Extract these fields: -1. documentNumber: The official document number or code. If not found, return null. -2. subject: The main subject, title, or topic of the document. If not found, return null. -3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified. -4. date: The issue date in YYYY-MM-DD format. If not found, return null. -5. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction. - -Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example: -{ - "documentNumber": "LCBP3-CIV-001", - "subject": "Foundation Inspection Report", - "discipline": "Civil", - "date": "2026-05-20", - "confidence": 0.95 -}`; - const response = await this.ollamaService.generate(prompt); + const { resolvedPrompt, versionNumber } = + await this.aiPromptsService.resolveActive( + 'ocr_extraction', + ocrResult.text + ); + const response = await this.ollamaService.generate(resolvedPrompt, { + timeoutMs: 120000, + }); const cleanedResponse = response .replace(/```json/g, '') .replace(/```/g, '') @@ -289,6 +284,11 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co `Failed to parse LLM response as JSON: ${cleanedResponse}` ); } + await this.aiPromptsService.saveTestResult( + 'ocr_extraction', + versionNumber, + extractedMetadata + ); await this.redis.setex( `ai:rag:result:${idempotencyKey}`, 3600, @@ -296,6 +296,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co requestPublicId: idempotencyKey, status: 'completed', answer: JSON.stringify(extractedMetadata, null, 2), + promptVersionUsed: versionNumber, completedAt: new Date().toISOString(), }) ); @@ -357,33 +358,15 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co }); throw err; } - const prompt = `You are a professional document intelligence engine. -Analyze the following OCR text extracted from a legacy project document and extract the metadata fields. -OCR TEXT: -${ocrResult.text} -Extract these fields: -1. documentNumber: The official document number or code. If not found, return null. -2. subject: The main subject, title, or topic of the document. If not found, return null. -3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified. -4. category: Must be exactly one of: "Correspondence", "Transmittal", "Circulation", "RFA", "Shop Drawing", "Contract Drawing", or null if not specified. -5. date: The issue/document date in YYYY-MM-DD format. If not found, return null. -6. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction. -7. tags: An array of tags/keywords (strings) that describe the document. -8. summary: A short 1-2 sentence summary of the document contents. -Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example: -{ - "documentNumber": "LCBP3-CIV-001", - "subject": "Foundation Inspection Report", - "discipline": "Civil", - "category": "Correspondence", - "date": "2026-05-20", - "confidence": 0.95, - "tags": ["foundation", "inspection", "concrete"], - "summary": "This document is a foundation inspection report for the LCBP3 project, confirming concrete strength." -}`; + const { resolvedPrompt } = await this.aiPromptsService.resolveActive( + 'ocr_extraction', + ocrResult.text + ); let aiResponse: string; try { - aiResponse = await this.ollamaService.generate(prompt); + aiResponse = await this.ollamaService.generate(resolvedPrompt, { + timeoutMs: 120000, + }); } catch (err: unknown) { const errMsg = err instanceof Error ? err.message : String(err); this.logger.error(`การวิเคราะห์ของ AI ล้มเหลว: ${errMsg}`); @@ -395,7 +378,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co }); await this.saveAiAuditLog({ documentPublicId, - aiModel: await this.ollamaService.getMainModelName(), + aiModel: this.ollamaService.getMainModelName(), status: AiAuditStatus.FAILED, errorMessage: errMsg, processingTimeMs: Date.now() - startTime, @@ -421,7 +404,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co }); await this.saveAiAuditLog({ documentPublicId, - aiModel: await this.ollamaService.getMainModelName(), + aiModel: this.ollamaService.getMainModelName(), status: AiAuditStatus.FAILED, errorMessage: errMsg, processingTimeMs: Date.now() - startTime, @@ -463,10 +446,13 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co isValid, confidence, aiJobId: String(job.id), + details: { + discipline: extractedMetadata.discipline, + }, }); await this.saveAiAuditLog({ documentPublicId, - aiModel: await this.ollamaService.getMainModelName(), + aiModel: this.ollamaService.getMainModelName(), status: AiAuditStatus.SUCCESS, aiSuggestionJson: extractedMetadata, confidenceScore: confidence, diff --git a/backend/src/modules/ai/processors/ai-realtime.processor.ts b/backend/src/modules/ai/processors/ai-realtime.processor.ts index 6a025d26..dd343a93 100644 --- a/backend/src/modules/ai/processors/ai-realtime.processor.ts +++ b/backend/src/modules/ai/processors/ai-realtime.processor.ts @@ -114,7 +114,7 @@ export class AiRealtimeProcessor extends WorkerHost { this.aiAuditLogRepo.create({ documentPublicId: job.data.documentPublicId, aiModel: 'gemma4', - modelName: await this.ollamaService.getMainModelName(), + modelName: this.ollamaService.getMainModelName(), aiSuggestionJson: normalizedSuggestion, confidenceScore: this.extractConfidence(normalizedSuggestion), processingTimeMs: Date.now() - startTime, @@ -136,7 +136,7 @@ export class AiRealtimeProcessor extends WorkerHost { this.aiAuditLogRepo.create({ documentPublicId: job.data.documentPublicId, aiModel: 'gemma4', - modelName: await this.ollamaService.getMainModelName(), + modelName: this.ollamaService.getMainModelName(), processingTimeMs: Date.now() - startTime, status: AiAuditStatus.FAILED, errorMessage: err instanceof Error ? err.message : String(err), diff --git a/backend/src/modules/ai/prompts/ai-prompts.controller.ts b/backend/src/modules/ai/prompts/ai-prompts.controller.ts new file mode 100644 index 00000000..6c346ab7 --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.controller.ts @@ -0,0 +1,140 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.controller.ts +// Change Log +// - 2026-05-25: Created AiPromptsController for dynamic prompt management (ADR-029) + +import { + Controller, + Get, + Post, + Delete, + Patch, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, + ParseIntPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { AiPromptsService } from './ai-prompts.service'; +import { AiPrompt } from './ai-prompts.entity'; +import { CreateAiPromptDto } from './dto/create-ai-prompt.dto'; +import { UpdatePromptNoteDto } from './dto/update-prompt-note.dto'; +import { AiPromptResponseDto } from './dto/ai-prompt-response.dto'; +import { plainToInstance } from 'class-transformer'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { RbacGuard } from '../../../common/guards/rbac.guard'; +import { RequirePermission } from '../../../common/decorators/require-permission.decorator'; +import { Audit } from '../../../common/decorators/audit.decorator'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { User } from '../../user/entities/user.entity'; + +/** + * Controller สำหรับจัดการ Prompt Versions ของ AI OCR (ADR-029) + */ +@ApiTags('AI Prompts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RbacGuard) +@Controller('ai/prompts') +export class AiPromptsController { + constructor(private readonly promptsService: AiPromptsService) {} + + private mapToDto(prompt: AiPrompt): AiPromptResponseDto { + return plainToInstance(AiPromptResponseDto, prompt, { + excludeExtraneousValues: true, + }); + } + + @Get(':promptType') + @RequirePermission('system.manage_all') + @ApiOperation({ + summary: 'ดึงรายการ Prompt Versions ทั้งหมดสำหรับ prompt_type ที่กำหนด', + }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + async listPromptVersions( + @Param('promptType') promptType: string + ): Promise<{ data: AiPromptResponseDto[] }> { + const list = await this.promptsService.findAll(promptType); + return { data: list.map((p) => this.mapToDto(p)) }; + } + + @Post(':promptType') + @RequirePermission('system.manage_all') + @Audit('ai_prompt.create', 'AiPrompt') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'สร้าง Prompt Version ใหม่ (เริ่มต้นเป็น inactive)', + }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + async createPromptVersion( + @Param('promptType') promptType: string, + @Body() dto: CreateAiPromptDto, + @CurrentUser() user: User + ): Promise<{ data: AiPromptResponseDto }> { + const newPrompt = await this.promptsService.create( + promptType, + dto, + user.user_id + ); + return { data: this.mapToDto(newPrompt) }; + } + + @Delete(':promptType/:versionNumber') + @RequirePermission('system.manage_all') + @Audit('ai_prompt.delete', 'AiPrompt') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'ลบ Prompt Version (ห้ามลบ active version)' }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + @ApiParam({ name: 'versionNumber', type: Number }) + async deletePromptVersion( + @Param('promptType') promptType: string, + @Param('versionNumber', ParseIntPipe) versionNumber: number, + @CurrentUser() user: User + ): Promise { + await this.promptsService.delete(promptType, versionNumber, user.user_id); + } + + @Post(':promptType/:versionNumber/activate') + @RequirePermission('system.manage_all') + @Audit('ai_prompt.activate', 'AiPrompt') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'เปิดใช้งาน Prompt Version' }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + @ApiParam({ name: 'versionNumber', type: Number }) + async activatePromptVersion( + @Param('promptType') promptType: string, + @Param('versionNumber', ParseIntPipe) versionNumber: number, + @CurrentUser() user: User + ): Promise<{ data: AiPromptResponseDto }> { + const activated = await this.promptsService.activate( + promptType, + versionNumber, + user.user_id + ); + return { data: this.mapToDto(activated) }; + } + + @Patch(':promptType/:versionNumber/note') + @RequirePermission('system.manage_all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'บันทึก Manual Note สำหรับ Prompt Version' }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + @ApiParam({ name: 'versionNumber', type: Number }) + async updatePromptNote( + @Param('promptType') promptType: string, + @Param('versionNumber', ParseIntPipe) versionNumber: number, + @Body() dto: UpdatePromptNoteDto + ): Promise<{ data: AiPromptResponseDto }> { + const updated = await this.promptsService.updateNote( + promptType, + versionNumber, + dto.manualNote + ); + return { data: this.mapToDto(updated) }; + } +} diff --git a/backend/src/modules/ai/prompts/ai-prompts.entity.ts b/backend/src/modules/ai/prompts/ai-prompts.entity.ts new file mode 100644 index 00000000..fabff7ed --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.entity.ts @@ -0,0 +1,57 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.entity.ts +// Change Log +// - 2026-05-25: Created TypeORM entity for dynamic prompt management (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; + +/** + * Entity สำหรับเก็บข้อมูลประวัติและการตั้งค่า Prompt version ต่างๆ + * สำหรับการสกัดข้อมูลเอกสารผ่าน OCR และ LLM + */ +@Entity('ai_prompts') +export class AiPrompt { + @PrimaryGeneratedColumn() + @Exclude() // ADR-019: INT PK ไม่ expose ใน API + id!: number; + + @Column({ name: 'prompt_type', length: 50 }) + promptType!: string; + + @Column({ name: 'version_number' }) + versionNumber!: number; + + @Column({ type: 'text' }) + template!: string; + + @Column({ name: 'field_schema', type: 'json', nullable: true }) + fieldSchema!: Record | null; + + @Column({ name: 'is_active', type: 'tinyint', width: 1, default: 0 }) + isActive!: boolean; + + @Column({ name: 'test_result_json', type: 'json', nullable: true }) + testResultJson!: Record | null; + + @Column({ name: 'manual_note', type: 'text', nullable: true }) + manualNote!: string | null; + + @Column({ name: 'last_tested_at', type: 'timestamp', nullable: true }) + lastTestedAt!: Date | null; + + @Column({ name: 'activated_at', type: 'timestamp', nullable: true }) + activatedAt!: Date | null; + + @Column({ name: 'created_by' }) + @Exclude() // FK ไม่ expose โดยตรง + createdBy!: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/ai/prompts/ai-prompts.module.ts b/backend/src/modules/ai/prompts/ai-prompts.module.ts new file mode 100644 index 00000000..f1c784dc --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.module.ts @@ -0,0 +1,21 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.module.ts +// Change Log +// - 2026-05-25: Created AiPromptsModule for prompt versioning system (ADR-029) + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AiPrompt } from './ai-prompts.entity'; +import { AuditLog } from '../../../common/entities/audit-log.entity'; +import { AiPromptsService } from './ai-prompts.service'; +import { AiPromptsController } from './ai-prompts.controller'; + +/** + * Module สำหรับการจัดการเวอร์ชันของ AI Prompts ใน OCR Pipeline + */ +@Module({ + imports: [TypeOrmModule.forFeature([AiPrompt, AuditLog])], + controllers: [AiPromptsController], + providers: [AiPromptsService], + exports: [AiPromptsService], +}) +export class AiPromptsModule {} diff --git a/backend/src/modules/ai/prompts/ai-prompts.service.spec.ts b/backend/src/modules/ai/prompts/ai-prompts.service.spec.ts new file mode 100644 index 00000000..d36e4391 --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.service.spec.ts @@ -0,0 +1,216 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.service.spec.ts +// Change Log +// - 2026-05-25: Created unit tests for AiPromptsService (T028) + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { AiPromptsService } from './ai-prompts.service'; +import { AiPrompt } from './ai-prompts.entity'; +import { AuditLog } from '../../../common/entities/audit-log.entity'; +import { + BusinessException, + ValidationException, + NotFoundException, +} from '../../../common/exceptions'; + +describe('AiPromptsService', () => { + let service: AiPromptsService; + const mockAiPromptRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + const mockAuditLogRepo = { + create: jest.fn(), + save: jest.fn(), + }; + const mockRedis = { + get: jest.fn(), + setex: jest.fn(), + del: jest.fn(), + }; + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setLock: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + }; + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + save: jest.fn(), + }, + }; + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + }; + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AiPromptsService, + { + provide: getRepositoryToken(AiPrompt), + useValue: mockAiPromptRepo, + }, + { + provide: getRepositoryToken(AuditLog), + useValue: mockAuditLogRepo, + }, + { + provide: 'default_IORedisModuleConnectionToken', + useValue: mockRedis, + }, + { + provide: DataSource, + useValue: mockDataSource, + }, + ], + }).compile(); + service = module.get(AiPromptsService); + }); + describe('create', () => { + it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder', async () => { + await expect( + service.create( + 'ocr_extraction', + { template: 'Invalid prompt structure' }, + 1 + ) + ).rejects.toThrow(ValidationException); + }); + it('ควรปฏิเสธ template ที่ตัวอักษรเกิน 4,000 ตัว', async () => { + const longTemplate = 'a'.repeat(4005) + '{{ocr_text}}'; + await expect( + service.create('ocr_extraction', { template: longTemplate }, 1) + ).rejects.toThrow(ValidationException); + }); + it('ควรบันทึกสำเร็จและรัน version number ต่อเนื่อง', async () => { + mockQueryBuilder.getRawOne.mockResolvedValue({ max: 5 }); + mockAiPromptRepo.create.mockReturnValue({ + id: 12, + promptType: 'ocr_extraction', + versionNumber: 6, + template: 'Test {{ocr_text}}', + isActive: false, + }); + mockQueryRunner.manager.save.mockResolvedValue({ + id: 12, + promptType: 'ocr_extraction', + versionNumber: 6, + template: 'Test {{ocr_text}}', + isActive: false, + }); + const result = await service.create( + 'ocr_extraction', + { template: 'Test {{ocr_text}}' }, + 1 + ); + expect(result.versionNumber).toBe(6); + expect(mockQueryRunner.manager.save).toHaveBeenCalled(); + expect(mockAuditLogRepo.save).toHaveBeenCalled(); + }); + }); + describe('activate', () => { + it('ควร activate สำเร็จ ยกเลิกตัวอื่น และลบ cache', async () => { + const activePrompt = { + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }; + const targetPrompt = { + id: 2, + promptType: 'ocr_extraction', + versionNumber: 2, + isActive: false, + }; + mockQueryRunner.manager.findOne.mockResolvedValue(targetPrompt); + mockQueryRunner.manager.find.mockResolvedValue([activePrompt]); + mockQueryRunner.manager.save.mockResolvedValue({ + ...targetPrompt, + isActive: true, + }); + const result = await service.activate('ocr_extraction', 2, 1); + expect(result.isActive).toBe(true); + expect(mockQueryRunner.manager.update).toHaveBeenCalledWith( + AiPrompt, + { promptType: 'ocr_extraction', isActive: true }, + { isActive: false } + ); + expect(mockRedis.del).toHaveBeenCalledWith( + 'ai:prompt:active:ocr_extraction' + ); + expect(mockAuditLogRepo.save).toHaveBeenCalled(); + }); + it('ควร throw error เมื่อไม่พบ prompt version ที่ต้องการ activate', async () => { + mockQueryRunner.manager.findOne.mockResolvedValue(null); + await expect(service.activate('ocr_extraction', 99, 1)).rejects.toThrow( + NotFoundException + ); + }); + }); + describe('delete', () => { + it('ควร throw error เมื่อลบ active version', async () => { + mockAiPromptRepo.findOne.mockResolvedValue({ + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }); + await expect(service.delete('ocr_extraction', 1, 1)).rejects.toThrow( + BusinessException + ); + }); + it('ควรลบ inactive version สำเร็จและบันทึก audit log', async () => { + const inactivePrompt = { + id: 2, + promptType: 'ocr_extraction', + versionNumber: 2, + isActive: false, + }; + mockAiPromptRepo.findOne.mockResolvedValue(inactivePrompt); + await service.delete('ocr_extraction', 2, 1); + expect(mockAiPromptRepo.remove).toHaveBeenCalledWith(inactivePrompt); + expect(mockAuditLogRepo.save).toHaveBeenCalled(); + }); + }); + describe('getActive', () => { + it('ควรดึงจาก Redis cache เมื่อมี cache hit', async () => { + const cachedPrompt = { + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedPrompt)); + const result = await service.getActive('ocr_extraction'); + expect(result).toEqual(cachedPrompt); + expect(mockAiPromptRepo.findOne).not.toHaveBeenCalled(); + }); + it('ควร fallback ไปหา DB เมื่อ Redis มีปัญหา', async () => { + const dbPrompt = { + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }; + mockRedis.get.mockRejectedValue(new Error('Redis connection lost')); + mockAiPromptRepo.findOne.mockResolvedValue(dbPrompt); + const result = await service.getActive('ocr_extraction'); + expect(result).toEqual(dbPrompt); + expect(mockAiPromptRepo.findOne).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/ai/prompts/ai-prompts.service.ts b/backend/src/modules/ai/prompts/ai-prompts.service.ts new file mode 100644 index 00000000..8125c42c --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.service.ts @@ -0,0 +1,294 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.service.ts +// Change Log +// - 2026-05-25: Created AiPromptsService for dynamic prompt management (ADR-029) +// - 2026-05-25: Fixed BusinessException and NotFoundException constructor signatures +// - 2026-05-25: Cast getRawOne() to resolve TypeScript type assertion error in ESLint + +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { AiPrompt } from './ai-prompts.entity'; +import { AuditLog } from '../../../common/entities/audit-log.entity'; +import { CreateAiPromptDto } from './dto/create-ai-prompt.dto'; +import { + BusinessException, + ValidationException, + NotFoundException, +} from '../../../common/exceptions'; + +/** + * Service สำหรับจัดการ Prompt Versioning และการดึงข้อมูล Prompt ล่าสุดที่พร้อมใช้งาน + */ +@Injectable() +export class AiPromptsService { + private readonly logger = new Logger(AiPromptsService.name); + private readonly cachePrefix = 'ai:prompt:active:'; + + constructor( + @InjectRepository(AiPrompt) + private readonly aiPromptRepo: Repository, + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + @InjectRedis() + private readonly redis: Redis, + private readonly dataSource: DataSource + ) {} + + /** + * ดึงรายการเวอร์ชันทั้งหมดของ prompt_type ที่กำหนด + */ + async findAll(promptType: string): Promise { + return this.aiPromptRepo.find({ + where: { promptType }, + order: { versionNumber: 'DESC' }, + }); + } + + /** + * ดึง Active prompt จาก Redis cache หรือ DB fallback + */ + async getActive(promptType: string): Promise { + const cacheKey = `${this.cachePrefix}${promptType}`; + try { + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as AiPrompt; + } + } catch (err: unknown) { + this.logger.warn( + `Redis unavailable, falling back to DB query: ${err instanceof Error ? err.message : String(err)}` + ); + } + const prompt = await this.aiPromptRepo.findOne({ + where: { promptType, isActive: true }, + }); + if (prompt) { + try { + await this.redis.setex(cacheKey, 60, JSON.stringify(prompt)); + } catch (err: unknown) { + this.logger.warn( + `Failed to set Redis cache: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + return prompt; + } + + /** + * ค้นหา prompt ที่มีผลใช้งานจริง และแทนที่ placeholder {{ocr_text}} ด้วยข้อความ OCR + */ + async resolveActive( + promptType: string, + ocrText: string + ): Promise<{ resolvedPrompt: string; versionNumber: number }> { + const prompt = await this.getActive(promptType); + if (!prompt) { + throw new BusinessException( + 'NO_ACTIVE_PROMPT', + `No active prompt found for type: ${promptType}`, + 'ไม่พบ Prompt Version ที่เปิดใช้งานในระบบ' + ); + } + const resolvedPrompt = prompt.template.replace('{{ocr_text}}', ocrText); + return { resolvedPrompt, versionNumber: prompt.versionNumber }; + } + + /** + * สร้าง Prompt Version ใหม่พร้อมการตรวจสอบ placeholder และ character limit + */ + async create( + promptType: string, + dto: CreateAiPromptDto, + userId: number + ): Promise { + if (!dto.template.includes('{{ocr_text}}')) { + throw new ValidationException('template ต้องมี {{ocr_text}} placeholder'); + } + if (dto.template.length > 4000) { + throw new ValidationException('Template exceeds 4,000 character limit'); + } + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const maxVersionResult = await queryRunner.manager + .createQueryBuilder(AiPrompt, 'prompt') + .select('MAX(prompt.versionNumber)', 'max') + .where('prompt.promptType = :promptType', { promptType }) + .setLock('pessimistic_write') + .getRawOne<{ max: number | string | null }>(); + const nextVersion = + (maxVersionResult?.max ? Number(maxVersionResult.max) : 0) + 1; + const newPrompt = this.aiPromptRepo.create({ + promptType, + versionNumber: nextVersion, + template: dto.template, + isActive: false, + createdBy: userId, + }); + const savedPrompt = await queryRunner.manager.save(newPrompt); + await queryRunner.commitTransaction(); + await this.saveAuditLog( + 'AI_PROMPT_CREATED', + String(savedPrompt.id), + { promptType, versionNumber: nextVersion, userId }, + userId + ); + return savedPrompt; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + /** + * เปิดใช้งานเวอร์ชันที่กำหนด และยกเลิกการใช้งานเวอร์ชันอื่นทั้งหมดภายใต้ prompt_type เดียวกัน + */ + async activate( + promptType: string, + versionNumber: number, + userId: number + ): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const promptToActivate = await queryRunner.manager.findOne(AiPrompt, { + where: { promptType, versionNumber }, + lock: { mode: 'pessimistic_write' }, + }); + if (!promptToActivate) { + throw new NotFoundException('AiPrompt', versionNumber.toString()); + } + await queryRunner.manager.find(AiPrompt, { + where: { promptType, isActive: true }, + lock: { mode: 'pessimistic_write' }, + }); + await queryRunner.manager.update( + AiPrompt, + { promptType, isActive: true }, + { isActive: false } + ); + promptToActivate.isActive = true; + promptToActivate.activatedAt = new Date(); + const activatedPrompt = await queryRunner.manager.save(promptToActivate); + await queryRunner.commitTransaction(); + try { + const cacheKey = `${this.cachePrefix}${promptType}`; + await this.redis.del(cacheKey); + } catch (err: unknown) { + this.logger.warn( + `Failed to clear Redis cache after activation: ${err instanceof Error ? err.message : String(err)}` + ); + } + await this.saveAuditLog( + 'AI_PROMPT_ACTIVATED', + String(activatedPrompt.id), + { promptType, versionNumber, userId }, + userId + ); + return activatedPrompt; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + /** + * ลบเวอร์ชันที่ไม่ได้ใช้งาน (ห้ามลบเวอร์ชันที่เป็น active) + */ + async delete( + promptType: string, + versionNumber: number, + userId: number + ): Promise { + const prompt = await this.aiPromptRepo.findOne({ + where: { promptType, versionNumber }, + }); + if (!prompt) { + throw new NotFoundException('AiPrompt', versionNumber.toString()); + } + if (prompt.isActive) { + throw new BusinessException( + 'CANNOT_DELETE_ACTIVE_PROMPT', + 'Cannot delete active prompt version', + 'ไม่สามารถลบ active version ได้' + ); + } + await this.aiPromptRepo.remove(prompt); + await this.saveAuditLog( + 'AI_PROMPT_DELETED', + String(prompt.id), + { promptType, versionNumber, userId }, + userId + ); + } + + /** + * อัปเดต manual note ของเวอร์ชันที่กำหนด + */ + async updateNote( + promptType: string, + versionNumber: number, + note: string | null + ): Promise { + const prompt = await this.aiPromptRepo.findOne({ + where: { promptType, versionNumber }, + }); + if (!prompt) { + throw new NotFoundException('AiPrompt', versionNumber.toString()); + } + prompt.manualNote = note; + return this.aiPromptRepo.save(prompt); + } + + /** + * บันทึกผลทดสอบของเวอร์ชันหลังจากรัน OCR Sandbox + */ + async saveTestResult( + promptType: string, + versionNumber: number, + resultJson: Record + ): Promise { + const prompt = await this.aiPromptRepo.findOne({ + where: { promptType, versionNumber }, + }); + if (prompt) { + prompt.testResultJson = resultJson; + prompt.lastTestedAt = new Date(); + await this.aiPromptRepo.save(prompt); + } + } + + /** + * บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs + */ + private async saveAuditLog( + action: string, + entityId: string, + detailsJson: Record, + userId?: number + ): Promise { + try { + const auditLog = this.auditLogRepo.create({ + action, + severity: 'INFO', + entityType: 'AiPrompt', + entityId, + detailsJson, + userId, + }); + await this.auditLogRepo.save(auditLog); + } catch (err: unknown) { + this.logger.error( + `Failed to save audit log: ${err instanceof Error ? err.message : String(err)}` + ); + } + } +} diff --git a/backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts b/backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts new file mode 100644 index 00000000..855014d0 --- /dev/null +++ b/backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts @@ -0,0 +1,39 @@ +// File: backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts +// Change Log +// - 2026-05-25: Created AiPromptResponseDto to exclude internal INT PK and expose clean API fields (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { Expose } from 'class-transformer'; + +/** + * Data Transfer Object สำหรับส่งออกข้อมูล Prompt version ทาง API + * โดยคัดกรองเฉพาะข้อมูลภายนอกและปิดบัง PK ดั้งเดิมตามนโยบายความปลอดภัย + */ +export class AiPromptResponseDto { + @Expose() + promptType!: string; + + @Expose() + versionNumber!: number; + + @Expose() + template!: string; + + @Expose() + isActive!: boolean; + + @Expose() + testResultJson!: Record | null; + + @Expose() + manualNote!: string | null; + + @Expose() + lastTestedAt!: Date | null; + + @Expose() + activatedAt!: Date | null; + + @Expose() + createdAt!: Date; +} diff --git a/backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts b/backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts new file mode 100644 index 00000000..77476823 --- /dev/null +++ b/backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts @@ -0,0 +1,16 @@ +// File: backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts +// Change Log +// - 2026-05-25: Created CreateAiPromptDto for prompt version creation (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** + * Data Transfer Object สำหรับการสร้าง prompt version ใหม่ + */ +export class CreateAiPromptDto { + @IsString() + @IsNotEmpty({ message: 'Template text must not be empty' }) + @MaxLength(4000, { message: 'Template exceeds 4,000 character limit' }) + template!: string; +} diff --git a/backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts b/backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts new file mode 100644 index 00000000..6129f5ca --- /dev/null +++ b/backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts @@ -0,0 +1,15 @@ +// File: backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts +// Change Log +// - 2026-05-25: Created UpdatePromptNoteDto for annotation updates (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { IsOptional, IsString } from 'class-validator'; + +/** + * Data Transfer Object สำหรับอัปเดต manual note ของ prompt version + */ +export class UpdatePromptNoteDto { + @IsString() + @IsOptional() + manualNote!: string | null; +} diff --git a/backend/src/modules/ai/services/ollama.service.ts b/backend/src/modules/ai/services/ollama.service.ts index 4f82b9a9..37850631 100644 --- a/backend/src/modules/ai/services/ollama.service.ts +++ b/backend/src/modules/ai/services/ollama.service.ts @@ -1,154 +1,176 @@ // File: src/modules/ai/services/ollama.service.ts -// Change Log -// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack. -// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama -// - 2026-05-25: เพิ่มการใช้งานโมเดลจาก DB (AiSettingsService) แทน ENV เท่านั้น (ADR-027). -import { Injectable, Logger, Optional } from '@nestjs/common'; +// Change Log + +// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack. + +// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama + +import { Injectable, Logger } from '@nestjs/common'; + import { ConfigService } from '@nestjs/config'; + import axios from 'axios'; -import { AiSettingsService } from '../ai-settings.service'; export interface OllamaGenerateOptions { timeoutMs?: number; + signal?: AbortSignal; } /** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */ + @Injectable() export class OllamaService { private readonly logger = new Logger(OllamaService.name); + private readonly ollamaUrl: string; - private readonly defaultMainModel: string; + + private readonly mainModel: string; + private readonly embedModel: string; + private readonly timeoutMs: number; - constructor( - private readonly configService: ConfigService, - @Optional() - private readonly aiSettingsService?: AiSettingsService - ) { + constructor(private readonly configService: ConfigService) { this.ollamaUrl = this.configService.get( 'OLLAMA_URL', + this.configService.get('AI_HOST_URL', 'http://localhost:11434') ); - // Default fallback model (ADR-023A: gemma4:e2b) - this.defaultMainModel = this.configService.get( + + this.mainModel = this.configService.get( 'OLLAMA_MODEL_MAIN', - 'gemma4:e2b' + + 'gemma4:e4b' ); + this.embedModel = this.configService.get( 'OLLAMA_MODEL_EMBED', + this.configService.get('OLLAMA_EMBED_MODEL', 'nomic-embed-text') ); + this.timeoutMs = this.configService.get('AI_TIMEOUT_MS', 30000); } - /** ดึงชื่อโมเดลที่ใช้งานอยู่ (จาก DB หรือ ENV fallback) */ - private async getActiveModelName(): Promise { - if (this.aiSettingsService) { - try { - return await this.aiSettingsService.getActiveModel(); - } catch (err: unknown) { - this.logger.warn( - `Failed to get active model from DB: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - return this.defaultMainModel; - } + /** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */ - /** สร้างข้อความตอบกลับจากโมเดลที่กำหนด (DB หรือ ENV fallback) */ async generate( prompt: string, + options: OllamaGenerateOptions = {} ): Promise { - const modelName = await this.getActiveModelName(); try { const response = await axios.post<{ response: string }>( `${this.ollamaUrl}/api/generate`, + { - model: modelName, + model: this.mainModel, + prompt, + stream: false, }, + { timeout: options.timeoutMs ?? this.timeoutMs, + signal: options.signal, } ); + return response.data.response ?? ''; } catch (err) { this.logger.error( - `Ollama generate failed with model ${modelName}`, + 'Ollama generate failed', + err instanceof Error ? err.stack : String(err) ); + throw err; } } /** สร้าง embedding ด้วย nomic-embed-text หรือค่า ENV ที่กำหนด */ + async generateEmbedding(text: string): Promise { try { const response = await axios.post<{ embedding: number[] }>( `${this.ollamaUrl}/api/embeddings`, + { model: this.embedModel, prompt: text }, + { timeout: this.timeoutMs } ); + return response.data.embedding; } catch (err) { this.logger.error( 'Ollama embedding failed', + err instanceof Error ? err.stack : String(err) ); + throw err; } } - /** คืนชื่อ main model สำหรับ audit log (async เพราะต้องเช็ค DB) */ - async getMainModelName(): Promise { - return this.getActiveModelName(); - } + /** คืนชื่อ main model สำหรับ audit log */ - /** คืนชื่อ main model แบบ sync (fallback สำหรับกรณีที่ไม่ต้องการ async) */ - getMainModelNameSync(): string { - return this.defaultMainModel; + getMainModelName(): string { + return this.mainModel; } /** คืนชื่อ embedding model สำหรับ audit log */ + getEmbeddingModelName(): string { return this.embedModel; } /** ตรวจสอบสุขภาพและความเร็ว (Latency) ของระบบ Ollama */ + async checkHealth(): Promise<{ status: 'HEALTHY' | 'DEGRADED' | 'DOWN'; + latencyMs: number; + models: string[]; + error?: string; }> { const startTime = Date.now(); - const activeModel = await this.getActiveModelName(); + try { await axios.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 }); + const latencyMs = Date.now() - startTime; + return { status: 'HEALTHY', + latencyMs, - models: [activeModel, this.embedModel], + + models: [this.mainModel, this.embedModel], }; } catch (err: unknown) { const latencyMs = Date.now() - startTime; + const error = err instanceof Error ? err.message : String(err); + const isTimeout = err instanceof Error && (err.message.includes('timeout') || err.message.includes('504') || err.message.includes('code ECONNABORTED')); + return { status: isTimeout ? 'DEGRADED' : 'DOWN', + latencyMs, - models: [activeModel, this.embedModel], + + models: [this.mainModel, this.embedModel], + error, }; } diff --git a/frontend/app/(admin)/admin/ai/page.tsx b/frontend/app/(admin)/admin/ai/page.tsx index f6a468f5..83d49700 100644 --- a/frontend/app/(admin)/admin/ai/page.tsx +++ b/frontend/app/(admin)/admin/ai/page.tsx @@ -23,6 +23,7 @@ import { useAiStatus, useToggleAiFeatures, useAiHealth } from '@/hooks/use-ai-st import { projectService } from '@/lib/services/project.service'; import { adminAiService, AiSandboxJobResult, AiAvailableModel } from '@/lib/services/admin-ai.service'; import { toast } from 'sonner'; +import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager'; interface SandboxProject { publicId: string; @@ -43,12 +44,7 @@ export default function AiAdminConsolePage() { const [isSandboxPolling, setIsSandboxPolling] = useState(false); const [sandboxProgress, setSandboxProgress] = useState(0); const [sandboxStatusText, setSandboxStatusText] = useState(''); - const [ocrFile, setOcrFile] = useState(null); - const [ocrJobId, setOcrJobId] = useState(null); - const [ocrJobResult, setOcrJobResult] = useState(null); - const [isOcrPolling, setIsOcrPolling] = useState(false); - const [ocrProgress, setOcrProgress] = useState(0); - const [ocrStatusText, setOcrStatusText] = useState(''); + // AI Model Management State (ADR-027) const { data: aiModelsData, refetch: refetchModels } = useQuery<{ models: AiAvailableModel[]; activeModel: string }>({ @@ -174,80 +170,7 @@ export default function AiAdminConsolePage() { clearInterval(timer); }; }, [sandboxJobId]); - const handleSubmitOcr = async (e: React.FormEvent): Promise => { - e.preventDefault(); - if (!ocrFile) { - toast.error('กรุณาเลือกไฟล์ PDF สำหรับทำ OCR'); - return; - } - if (ocrFile.size > 50 * 1024 * 1024) { - toast.error('ขนาดไฟล์เกินกว่า 50MB'); - return; - } - if (ocrFile.type !== 'application/pdf' && !ocrFile.name.toLowerCase().endsWith('.pdf')) { - toast.error('กรุณาอัปโหลดไฟล์ในรูปแบบ PDF เท่านั้น'); - return; - } - try { - setOcrJobResult(null); - setOcrProgress(10); - setOcrStatusText('กำลังอัปโหลดไฟล์ไปยังระบบเซิร์ฟเวอร์...'); - const response = await adminAiService.submitSandboxExtract(ocrFile); - setOcrJobId(response.requestPublicId); - setIsOcrPolling(true); - toast.success('อัปโหลดไฟล์สำเร็จและเข้าสู่คิว sandbox OCR'); - } catch (err) { - const error = err as { response?: { data?: { message?: string } } }; - toast.error(error.response?.data?.message || 'เกิดข้อผิดพลาดในการทำ OCR Sandbox'); - setOcrProgress(0); - setOcrStatusText(''); - } - }; - useEffect(() => { - if (!ocrJobId) return; - let timer: NodeJS.Timeout; - const pollOcrJob = async () => { - try { - const res = await adminAiService.getSandboxJobStatus(ocrJobId); - setOcrJobResult(res); - if (res.status === 'pending') { - setOcrProgress(30); - setOcrStatusText('อยู่ในคิวรอดำเนินการ (Pending in BullMQ)...'); - } else if (res.status === 'processing') { - setOcrProgress(70); - setOcrStatusText('กำลังอ่านไฟล์ PDF และสกัดข้อความด้วย OCR & LLM...'); - } else if (res.status === 'completed') { - setOcrProgress(100); - setOcrStatusText('การทำ OCR และสกัดข้อมูลเมตาดาต้าเสร็จสิ้น'); - setIsOcrPolling(false); - setOcrJobId(null); - toast.success('ทำ OCR Sandbox สำเร็จ'); - } else if (res.status === 'failed') { - setOcrProgress(100); - setOcrStatusText('การทำ OCR ล้มเหลว'); - setIsOcrPolling(false); - setOcrJobId(null); - toast.error(res.errorMessage || 'การทำ OCR Sandbox เกิดข้อผิดพลาด'); - } else if (res.status === 'cancelled') { - setOcrProgress(100); - setOcrStatusText('การทำ OCR ถูกยกเลิก'); - setIsOcrPolling(false); - setOcrJobId(null); - toast.error('OCR sandbox job ถูกยกเลิก'); - } else if (res.status === 'not_found') { - setOcrProgress(20); - setOcrStatusText('กำลังตรวจสอบสถานะคิวงาน...'); - } - } catch { - // เงียบข้อผิดพลาดตามนโยบาย UI - } - }; - pollOcrJob(); - timer = setInterval(pollOcrJob, 5000); - return () => { - clearInterval(timer); - }; - }, [ocrJobId]); + const renderStatusBadge = (status?: 'HEALTHY' | 'DEGRADED' | 'DOWN') => { if (!status) return Unknown; switch (status) { @@ -745,167 +668,7 @@ export default function AiAdminConsolePage() { )} - - - - - OCR Sandbox Playground (isolated) - -

- พื้นที่อัปโหลดไฟล์ PDF เพื่อทำการทดสอบทำ OCR และจำลองการดึง Metadata ออกมาในรูปแบบโครงสร้าง JSON โดยไม่บันทึกข้อมูลลงฐานข้อมูลจริง -

-
- -
-
- -
{ - e.preventDefault(); - }} - onDrop={(e) => { - e.preventDefault(); - if (isOcrPolling) return; - const file = e.dataTransfer.files?.[0]; - if (file) { - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - setOcrFile(file); - } else { - toast.error('กรุณาเลือกไฟล์ PDF เท่านั้น'); - } - } - }} - > - - {ocrFile ? ( -
-

{ocrFile.name}

-

- ({(ocrFile.size / 1024 / 1024).toFixed(2)} MB) -

- -
- ) : ( -
-

- ลากและวางไฟล์ PDF หรือคลิกเพื่ออัปโหลด -

- { - const file = e.target.files?.[0]; - if (file) setOcrFile(file); - }} - className="hidden" - id="ocr-file-upload" - /> - -
- )} -
-
-
- -
-
-
-
- {isOcrPolling && ( - - -
-
- - {ocrStatusText} -
- {ocrProgress}% -
- -
- - ID คำขอ: {ocrJobId} -
-
-
- )} - {ocrJobResult && ( -
- {ocrJobResult.status === 'completed' && ( - - - - - ผลลัพธ์การสกัด Metadata แบบโครงสร้าง (JSON Output) - - - -
-
-                        {ocrJobResult.answer}
-                      
-
- {ocrJobResult.completedAt && ( -
- เสร็จสิ้นเมื่อ: {new Date(ocrJobResult.completedAt).toLocaleString()} -
- )} -
-
- )} - {ocrJobResult.status === 'failed' && ( - - - - ประมวลผล OCR Sandbox ล้มเหลว - - -

- {ocrJobResult.errorMessage || 'เกิดข้อผิดพลาดขึ้นระหว่างการอ่านไฟล์เอกสาร PDF หรือการเรียก LLM Sandbox สำหรับถอดความเมตาดาต้า กรุณาตรวจสอบสถานะสุขภาพของตัวบริการ'} -

-
-
- )} -
- )} +
diff --git a/frontend/components/admin/ai/OcrSandboxPromptManager.tsx b/frontend/components/admin/ai/OcrSandboxPromptManager.tsx new file mode 100644 index 00000000..8e1c237c --- /dev/null +++ b/frontend/components/admin/ai/OcrSandboxPromptManager.tsx @@ -0,0 +1,415 @@ +// File: frontend/components/admin/ai/OcrSandboxPromptManager.tsx +// Change Log +// - 2026-05-25: Created OcrSandboxPromptManager component for dynamic prompt editing, version control, and sandbox testing (ADR-029) +// - 2026-05-25: Extracted inline strings to i18n keys via useTranslations() (Obs #1 fix) +// - 2026-05-25: Refactored sandbox polling to useSandboxRun hook (Obs #2 fix) +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { + Brain, + Save, + AlertCircle, + Upload, + Play, + FileJson, + ScrollText, + Loader2, + StickyNote, +} from 'lucide-react'; +import { useAiPrompts, useSandboxRun } from '@/hooks/use-ai-prompts'; +import { useTranslations } from '@/hooks/use-translations'; +import PromptVersionHistory from './PromptVersionHistory'; +import { cn } from '@/lib/utils'; +import { AiPrompt } from '@/types/ai-prompts'; + +/** + * Component หลักสำหรับจัดการ Prompt versions ของ OCR sandbox และ Migration + * ประกอบไปด้วยตัวแก้ไข Prompt, รายการเวอร์ชัน, และส่วนสกัดทดสอบ (Sandbox run) + */ +export default function OcrSandboxPromptManager() { + const t = useTranslations(); + const promptType = 'ocr_extraction'; + const { + versionsQuery, + createMutation, + activateMutation, + deleteMutation, + updateNoteMutation, + } = useAiPrompts(promptType); + const versions = versionsQuery.data ?? []; + const activePrompt = versions.find((v) => v.isActive); + const [templateText, setTemplateText] = useState(''); + const [ocrFile, setOcrFile] = useState(null); + const [manualNote, setManualNote] = useState(''); + const [activeTab, setActiveTab] = useState<'editor' | 'sandbox'>('editor'); + const { state: sandboxState, jobId: sandboxJobId, submit: submitSandbox, reset: resetSandbox } = + useSandboxRun(() => { + // เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน + versionsQuery.refetch(); + toast.success(t('ai.prompt.sandboxSuccess')); + }); + useEffect(() => { + if (activePrompt && !templateText) { + setTemplateText(activePrompt.template); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activePrompt]); + const handleSaveVersion = async () => { + if (!templateText.includes('{{ocr_text}}')) { + toast.error(t('ai.prompt.placeholderError')); + return; + } + if (templateText.length > 4000) { + toast.error(t('ai.prompt.charLimitError')); + return; + } + try { + await createMutation.mutateAsync(templateText); + toast.success(t('ai.prompt.saveVersionSuccess')); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.saveVersionError')); + } + }; + const handleLoadTemplate = (version: AiPrompt) => { + setTemplateText(version.template); + setActiveTab('editor'); + toast.success(t('ai.prompt.loadSuccess', { version: String(version.versionNumber) })); + }; + const handleActivateVersion = async (versionNumber: number) => { + try { + await activateMutation.mutateAsync(versionNumber); + toast.success(t('ai.prompt.activateSuccess', { version: String(versionNumber) })); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.activateError')); + } + }; + const handleDeleteVersion = async (versionNumber: number) => { + if (!confirm(t('ai.prompt.deleteConfirm', { version: String(versionNumber) }))) return; + try { + await deleteMutation.mutateAsync(versionNumber); + toast.success(t('ai.prompt.deleteSuccess', { version: String(versionNumber) })); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.deleteError')); + } + }; + const handleSaveManualNote = async (versionNumber: number) => { + try { + await updateNoteMutation.mutateAsync({ versionNumber, note: manualNote }); + toast.success(t('ai.prompt.saveNoteSuccess')); + setManualNote(''); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.saveNoteError')); + } + }; + const handleSubmitOcr = async (e: React.FormEvent) => { + e.preventDefault(); + if (!activePrompt) { + toast.error(t('ai.prompt.noActivePrompt')); + return; + } + if (!ocrFile) { + toast.error(t('ai.prompt.noFile')); + return; + } + try { + resetSandbox(); + await submitSandbox(ocrFile); + toast.success(t('ai.prompt.uploadSuccess')); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.uploadError')); + } + }; + // แปล status key เป็นข้อความตาม locale ปัจจุบัน + const statusLabel = sandboxState.statusText ? t(sandboxState.statusText) : ''; + return ( +
+
+
+ + +
+ {activeTab === 'editor' ? ( + + + + + {t('ai.prompt.cardTitle')} + + {activePrompt && ( + + {t('ai.prompt.activeLabel', { version: String(activePrompt.versionNumber) })} + + )} + + +
+