690525:1720 ADR-028-228-migration-OCR #06 dynamic prompt
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-25
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded (prompt_type = 'ocr_extraction' only)
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows (manage versions, sandbox test, runtime resolution)
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Spec is derived from ADR-029 grilling session (2026-05-25) — high confidence, minimal ambiguity
|
||||
- `field_schema` column: assumed system-managed metadata (not user-editable) in v1 — documented in Assumptions
|
||||
- Timeout fix scope (sandbox only) documented in Assumptions to prevent scope creep
|
||||
- Seed data requirement (FR-011) explicitly stated to prevent deploy-time failure
|
||||
@@ -0,0 +1,278 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: AI Prompts Management API
|
||||
description: ADR-029 — Versioned prompt management for OCR extraction
|
||||
version: "1.0.0"
|
||||
|
||||
# Base path: /api/ai/prompts (mounted under AiController)
|
||||
|
||||
paths:
|
||||
/ai/prompts/{promptType}:
|
||||
get:
|
||||
operationId: listPromptVersions
|
||||
summary: ดึง Prompt Versions ทั้งหมดของ prompt_type นั้น
|
||||
description: Returns all versions sorted by version_number DESC. No pagination (v1). Guarded by system.manage_all.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: promptType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: ocr_extraction
|
||||
responses:
|
||||
"200":
|
||||
description: List of prompt versions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiPromptResponse"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
|
||||
post:
|
||||
operationId: createPromptVersion
|
||||
summary: สร้าง Prompt Version ใหม่ (inactive)
|
||||
description: Validates {{ocr_text}} placeholder. Assigns next version_number automatically. Logs to audit_logs.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: promptType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: ocr_extraction
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateAiPromptRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Created prompt version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AiPromptResponse"
|
||||
"400":
|
||||
description: Template missing {{ocr_text}} placeholder
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
message: "template ต้องมี {{ocr_text}} placeholder"
|
||||
userMessage: "กรุณาเพิ่ม {{ocr_text}} ใน template ก่อนบันทึก"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
|
||||
/ai/prompts/{promptType}/{versionNumber}:
|
||||
delete:
|
||||
operationId: deletePromptVersion
|
||||
summary: ลบ Prompt Version (ห้ามลบ active version)
|
||||
description: Guards against deleting the active version. Logs to audit_logs.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: promptType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: versionNumber
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted successfully
|
||||
"400":
|
||||
description: Cannot delete active version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
message: "ไม่สามารถลบ active version ได้"
|
||||
userMessage: "กรุณา activate version อื่นก่อน แล้วจึงลบ version นี้"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
|
||||
/ai/prompts/{promptType}/{versionNumber}/activate:
|
||||
post:
|
||||
operationId: activatePromptVersion
|
||||
summary: Activate Prompt Version — นำไปใช้จริงทั้ง sandbox และ migrate-document
|
||||
description: Runs in transaction — deactivates current active, activates this version, invalidates Redis cache. Logs to audit_logs.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: promptType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: versionNumber
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Activated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AiPromptResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
|
||||
/ai/prompts/{promptType}/{versionNumber}/note:
|
||||
patch:
|
||||
operationId: updatePromptNote
|
||||
summary: บันทึก Manual Note สำหรับ Prompt Version
|
||||
description: Updates manual_note field only. Does not create new version. No audit log required.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: promptType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: versionNumber
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdatePromptNoteRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Note updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AiPromptResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
schemas:
|
||||
AiPromptResponse:
|
||||
type: object
|
||||
properties:
|
||||
promptType:
|
||||
type: string
|
||||
example: ocr_extraction
|
||||
versionNumber:
|
||||
type: integer
|
||||
example: 3
|
||||
template:
|
||||
type: string
|
||||
description: Full prompt template with {{ocr_text}} placeholder
|
||||
isActive:
|
||||
type: boolean
|
||||
example: true
|
||||
testResultJson:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Last sandbox run result (8 fields)
|
||||
manualNote:
|
||||
type: string
|
||||
nullable: true
|
||||
lastTestedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
activatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- promptType
|
||||
- versionNumber
|
||||
- template
|
||||
- isActive
|
||||
- createdAt
|
||||
|
||||
CreateAiPromptRequest:
|
||||
type: object
|
||||
properties:
|
||||
template:
|
||||
type: string
|
||||
description: "Must contain {{ocr_text}} placeholder"
|
||||
example: "Extract fields from the following text:\n{{ocr_text}}\nReturn JSON."
|
||||
required:
|
||||
- template
|
||||
|
||||
UpdatePromptNoteRequest:
|
||||
type: object
|
||||
properties:
|
||||
manualNote:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 2000
|
||||
required:
|
||||
- manualNote
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
userMessage:
|
||||
type: string
|
||||
recoveryAction:
|
||||
type: string
|
||||
|
||||
responses:
|
||||
Forbidden:
|
||||
description: "Forbidden — requires system.manage_all permission"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
NotFound:
|
||||
description: "Prompt version not found"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
@@ -0,0 +1,210 @@
|
||||
# Data Model: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
**Feature**: `229-dynamic-prompt-management`
|
||||
**Date**: 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## Entity: AiPrompt (`ai_prompts`)
|
||||
|
||||
### SQL Schema (delta file)
|
||||
|
||||
```sql
|
||||
-- File: specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-prompts.sql
|
||||
-- ADR-029: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
CREATE TABLE ai_prompts (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT
|
||||
COMMENT 'Internal INT PK — never exposed in API (ADR-019)',
|
||||
prompt_type VARCHAR(50) NOT NULL
|
||||
COMMENT 'ประเภท prompt เช่น ocr_extraction — ใช้เป็น public identifier',
|
||||
version_number INT NOT NULL
|
||||
COMMENT 'เลข version ต่อเนื่องต่อ prompt_type (1, 2, 3...) — monotonically increasing, ไม่ fill gaps',
|
||||
template TEXT NOT NULL
|
||||
COMMENT 'prompt template ที่มี {{ocr_text}} placeholder บังคับ — validated ก่อน save',
|
||||
field_schema JSON NULL
|
||||
COMMENT 'definition ของ fields ที่คาดหวังในผลลัพธ์ JSON (system-managed, ไม่ user-editable ใน v1)',
|
||||
is_active TINYINT(1) DEFAULT 0
|
||||
COMMENT '1 = version นี้ใช้งานจริงทั้ง sandbox และ migrate-document; exactly 1 active ต่อ prompt_type',
|
||||
test_result_json JSON NULL
|
||||
COMMENT 'ผลลัพธ์ JSON จาก OCR sandbox run ล่าสุด (auto-save โดย processSandboxExtract)',
|
||||
manual_note TEXT NULL
|
||||
COMMENT 'หมายเหตุ/annotation จาก admin (PATCH endpoint)',
|
||||
last_tested_at TIMESTAMP NULL
|
||||
COMMENT 'เวลาที่ sandbox รันสำเร็จครั้งล่าสุดสำหรับ version นี้',
|
||||
activated_at TIMESTAMP NULL
|
||||
COMMENT 'เวลาที่ version นี้ถูก activate เป็น active — NULL ถ้ายังไม่เคย activate',
|
||||
created_by INT NOT NULL
|
||||
COMMENT 'FK → users.user_id — ผู้สร้าง version นี้',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_type_version (prompt_type, version_number)
|
||||
COMMENT 'ป้องกัน race condition — version_number ต้อง unique ต่อ prompt_type',
|
||||
INDEX idx_prompt_type_active (prompt_type, is_active)
|
||||
COMMENT 'ใช้สำหรับ query active prompt (Redis cache miss path)',
|
||||
CONSTRAINT fk_ai_prompts_created_by FOREIGN KEY (created_by) REFERENCES users(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='ADR-029: Versioned prompt templates สำหรับ OCR extraction — ใช้ร่วมกันโดย sandbox และ migrate-document';
|
||||
|
||||
-- Seed data: default active version จาก hardcoded prompt ปัจจุบัน
|
||||
-- NOTE: template text ต้องแทนที่ด้วย exact hardcoded prompt จาก ai-batch.processor.ts ก่อน deploy
|
||||
INSERT INTO ai_prompts (prompt_type, version_number, template, field_schema, is_active, created_by)
|
||||
VALUES (
|
||||
'ocr_extraction',
|
||||
1,
|
||||
'You are a document metadata extraction assistant. Extract the following fields from the OCR text below and return a valid JSON object.\n\nFields to extract:\n- documentNumber: document number or reference code\n- subject: document title or subject\n- discipline: engineering discipline (e.g., Civil, Mechanical, Electrical)\n- date: document date (ISO 8601 format if possible)\n- confidence: your confidence score 0.0-1.0\n- category: document category\n- tags: array of relevant tags\n- summary: brief document summary (max 200 chars)\n\nReturn ONLY valid JSON. No explanation text.\n\nOCR Text:\n{{ocr_text}}',
|
||||
JSON_OBJECT(
|
||||
-- key = ชื่อ field ใน JSON output ที่ LLM ควร return (ไม่ใช่ column name ของ ai_prompts table)
|
||||
-- value = type constraint ที่ processor ใช้ validate/document
|
||||
'documentNumber', 'string|null',
|
||||
'subject', 'string|null',
|
||||
'discipline', 'enum:Civil,Mechanical,Electrical,Architectural|null',
|
||||
'category', 'enum:Correspondence,Transmittal,Circulation,RFA,Shop Drawing,Contract Drawing|null',
|
||||
'date', 'date:YYYY-MM-DD|null',
|
||||
'confidence', 'float:0-1',
|
||||
'tags', 'string[]',
|
||||
'summary', 'string|null'
|
||||
),
|
||||
1,
|
||||
1
|
||||
);
|
||||
```
|
||||
|
||||
### TypeORM Entity
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/ai/prompts/ai-prompts.entity.ts
|
||||
// ADR-029: Entity สำหรับ ai_prompts table
|
||||
|
||||
@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<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', width: 1, default: 0 })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'test_result_json', type: 'json', nullable: true })
|
||||
testResultJson: Record<string, unknown> | 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;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Transitions
|
||||
|
||||
```
|
||||
[CREATE] → is_active = 0 (inactive)
|
||||
│
|
||||
▼
|
||||
[ACTIVATE] → is_active = 1 (active) ── replaces previous active version
|
||||
│
|
||||
▼ (when another version is activated)
|
||||
[DEACTIVATE] → is_active = 0 (inactive)
|
||||
│
|
||||
▼ (only if not active)
|
||||
[DELETE] → row removed from DB
|
||||
```
|
||||
|
||||
**Invariant**: Exactly 1 row with `is_active = 1` per `prompt_type` at all times (enforced by transaction in `AiPromptsService.activate()`)
|
||||
|
||||
---
|
||||
|
||||
## Redis Cache
|
||||
|
||||
| Key | Value | TTL | Invalidated by |
|
||||
|-----|-------|-----|---------------|
|
||||
| `ai:prompt:active:ocr_extraction` | Serialized `AiPrompt` (JSON) | 60s | `AiPromptsService.activate()` — `DEL key` after transaction commit |
|
||||
|
||||
**Fallback**: If Redis unavailable (`ioredis` connection error), `AiPromptsService.getActive()` queries DB directly with `LOG.warn('Redis unavailable, falling back to DB query')` — no throw.
|
||||
|
||||
---
|
||||
|
||||
## API Response Shape
|
||||
|
||||
```typescript
|
||||
// AiPromptResponseDto — สำหรับ expose ใน API response
|
||||
interface AiPromptResponse {
|
||||
promptType: string; // 'ocr_extraction'
|
||||
versionNumber: number; // 1, 2, 3...
|
||||
template: string; // full template text
|
||||
isActive: boolean;
|
||||
testResultJson: Record<string, unknown> | null;
|
||||
manualNote: string | null;
|
||||
lastTestedAt: string | null; // ISO 8601
|
||||
activatedAt: string | null; // ISO 8601
|
||||
createdAt: string; // ISO 8601
|
||||
}
|
||||
// NOTE: id (INT) ไม่ expose — @Exclude() per ADR-019
|
||||
// NOTE: createdBy (INT) ไม่ expose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
- `ai_prompts.created_by` → `users.user_id` (FK)
|
||||
- No relationship to other AI tables (standalone)
|
||||
- Consumed by: `AiBatchProcessor.processSandboxExtract()` and `AiBatchProcessor.processMigrateDocument()`
|
||||
|
||||
---
|
||||
|
||||
## Pre-existing Bug (must fix in T024)
|
||||
|
||||
`MigrateDocumentMetadata` interface (บรรทัด 29-37 ใน `ai-batch.processor.ts`) **ขาด `discipline?: string`** — แม้ `processMigrateDocument` prompt จะ extract `discipline` ออกมาได้ แต่ `parseMigrateDocumentMetadata()` ทิ้งค่านี้ทุกครั้งเพราะ interface ไม่รับ field นี้
|
||||
|
||||
```typescript
|
||||
// ❌ ปัจจุบัน (ขาด discipline)
|
||||
interface MigrateDocumentMetadata {
|
||||
documentNumber?: string;
|
||||
subject?: string;
|
||||
category?: string; // มี category
|
||||
date?: string;
|
||||
confidence?: number;
|
||||
tags?: string[];
|
||||
summary?: string;
|
||||
// discipline หายไปเลย!
|
||||
}
|
||||
|
||||
// ✅ ต้องแก้เป็น (เพิ่ม discipline)
|
||||
interface MigrateDocumentMetadata {
|
||||
documentNumber?: string;
|
||||
subject?: string;
|
||||
discipline?: string; // เพิ่ม
|
||||
category?: string;
|
||||
date?: string;
|
||||
confidence?: number;
|
||||
tags?: string[];
|
||||
summary?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: เพิ่มการแก้ bug นี้เข้าไปใน T026 หรือ T024 เมื่อ implement — ก่อน/หลัง replace hardcoded prompt
|
||||
@@ -0,0 +1,143 @@
|
||||
# Implementation Plan: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
**Branch**: `229-dynamic-prompt-management` | **Date**: 2026-05-25 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `specs/200-fullstacks/229-dynamic-prompt-management/spec.md`
|
||||
**ADR Reference**: ADR-029, ADR-009, ADR-016, ADR-019, ADR-023/023A, ADR-027
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
เพิ่มระบบ Versioned Prompt Management สำหรับ OCR extraction — แทนที่ hardcoded prompt ใน `processSandboxExtract` และ `processMigrateDocument` ด้วย DB-driven prompt ที่ Superadmin แก้ไขได้ runtime ผ่าน AI Admin Console พร้อมแก้ bug AI_TIMEOUT_MS และ Redis cache สำหรับ active prompt
|
||||
|
||||
---
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.x — NestJS 11 (backend), Next.js 16 (frontend)
|
||||
**Primary Dependencies**: TypeORM (MariaDB), Redis (ioredis), BullMQ, TanStack Query v5, shadcn/ui, Zod
|
||||
**Storage**: MariaDB 11.8 (`ai_prompts` table via SQL delta), Redis (TTL 60s cache)
|
||||
**Testing**: Jest (backend unit/integration), Vitest (frontend unit)
|
||||
**Target Platform**: QNAP NAS (backend + frontend containers), Desk-5439 (Ollama + OCR sidecar)
|
||||
**Project Type**: Web application (backend + frontend)
|
||||
**Performance Goals**: Cache hit < 5ms; activation cycle < 1s
|
||||
**Constraints**: ADR-009 no TypeORM migrations; ADR-019 no parseInt on UUID; ADR-016 CASL guard on all mutations; AI_TIMEOUT_MS bug fix scope = sandbox only
|
||||
**Scale/Scope**: Single `prompt_type = 'ocr_extraction'`; expected < 20 versions total
|
||||
|
||||
---
|
||||
|
||||
## Constitution Check
|
||||
|
||||
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| ADR-009: No TypeORM migrations | ✅ PASS | Schema change via SQL delta in `specs/03-Data-and-Storage/deltas/` |
|
||||
| ADR-019: UUID — no parseInt | ✅ PASS | `ai_prompts` uses INT PK (internal only); `prompt_type` + `version_number` are public identifiers (strings + ints, not UUID) |
|
||||
| ADR-016: CASL guard on mutations | ✅ PASS | All 5 endpoints guarded with `system.manage_all` |
|
||||
| ADR-007: Error handling | ✅ PASS | `BusinessException` for validation errors; NestJS Logger for technical logs |
|
||||
| ADR-023/023A: AI boundary | ✅ PASS | Prompt is config data — stored in DB, not in AI model; Ollama call remains via existing `OllamaService` |
|
||||
| ADR-008: BullMQ for background | ✅ PASS | Sandbox run already in `ai-batch` queue; no change to queue routing |
|
||||
| TypeScript Strict | ✅ PASS | Zero `any`, zero `console.log` |
|
||||
| i18n | ✅ PASS | All UI text via i18n keys |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/200-fullstacks/229-dynamic-prompt-management/
|
||||
├── plan.md (this file)
|
||||
├── research.md (Phase 0 output)
|
||||
├── data-model.md (Phase 1 output)
|
||||
├── quickstart.md (Phase 1 output)
|
||||
├── contracts/ (Phase 1 output)
|
||||
│ └── prompts.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md (Phase 2 output — /speckit-tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/src/modules/ai/
|
||||
├── prompts/ [NEW MODULE]
|
||||
│ ├── ai-prompts.entity.ts
|
||||
│ ├── ai-prompts.service.ts
|
||||
│ ├── ai-prompts.controller.ts
|
||||
│ ├── ai-prompts.module.ts
|
||||
│ └── dto/
|
||||
│ ├── create-ai-prompt.dto.ts
|
||||
│ ├── update-prompt-note.dto.ts
|
||||
│ └── ai-prompt-response.dto.ts
|
||||
├── processors/
|
||||
│ └── ai-batch.processor.ts [MODIFY — add resolvePrompt(), fix timeout]
|
||||
└── ai.module.ts [MODIFY — import AiPromptsModule]
|
||||
|
||||
specs/03-Data-and-Storage/deltas/
|
||||
└── 2026-05-25-create-ai-prompts.sql [NEW — SQL delta per ADR-009]
|
||||
|
||||
frontend/
|
||||
├── components/admin/ai/
|
||||
│ ├── OcrSandboxPromptManager.tsx [NEW — Prompt Editor + Version History]
|
||||
│ └── PromptVersionHistory.tsx [NEW — Version History panel]
|
||||
├── lib/services/
|
||||
│ └── ai-prompts.service.ts [NEW — API client for prompts]
|
||||
├── hooks/
|
||||
│ └── use-ai-prompts.ts [NEW — TanStack Query hooks]
|
||||
├── types/
|
||||
│ └── ai-prompts.ts [NEW — TypeScript interfaces]
|
||||
└── public/locales/{en,th}/
|
||||
└── ai-admin.json [MODIFY — add prompt management i18n keys]
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (Option 2) — NestJS backend + Next.js frontend, standard LCBP3 monorepo pattern
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Research (complete — findings below)
|
||||
|
||||
All unknowns resolved from ADR-029 + existing codebase patterns.
|
||||
|
||||
### Phase 1: Design & Contracts (this document + artifacts)
|
||||
|
||||
1. SQL delta for `ai_prompts` table — see `data-model.md`
|
||||
2. API contract — see `contracts/prompts.yaml`
|
||||
3. Seed data strategy — insert hardcoded prompt as version 1 in delta
|
||||
4. Redis cache key strategy — `ai:prompt:active:{prompt_type}` TTL 60s
|
||||
|
||||
### Phase 2: Implementation
|
||||
|
||||
Follow tasks.md phases. Implementation entry point: see `quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D1: AiPromptsService as Standalone Module
|
||||
`AiPromptsService` lives in `ai/prompts/` sub-module and is imported into `AiModule`. This keeps version management logic separate from the batch processor while sharing the Redis connection.
|
||||
|
||||
### D2: resolvePrompt() Placement
|
||||
`resolvePrompt(promptType, ocrText)` is a private method inside `AiBatchProcessor` (or extracted to `AiPromptsService.resolveActive()`). It must be accessible from both `processSandboxExtract` and `processMigrateDocument` — placing it in `AiPromptsService` is cleaner (injectable service vs private method).
|
||||
|
||||
### D3: Timeout Fix Scope
|
||||
`timeoutMs: 120000` passed only to `processSandboxExtract` Ollama call. `processMigrateDocument` retains its existing job-level timeout (controlled by BullMQ job options), which is already longer.
|
||||
|
||||
### D4: Activation Transaction
|
||||
`activate()` runs in a TypeORM transaction:
|
||||
1. `UPDATE ai_prompts SET is_active = 0 WHERE prompt_type = ? AND is_active = 1`
|
||||
2. `UPDATE ai_prompts SET is_active = 1, activated_at = NOW() WHERE id = ?`
|
||||
3. **After** COMMIT (outside TX): Redis `DEL ai:prompt:active:ocr_extraction`
|
||||
|
||||
**Redis DEL failure behavior**: If Redis DEL fails after DB commit, do nothing — log `WARN` and let TTL 60s expire naturally. Stale-on-Redis-fail is in the same category as normal TTL expiry: max 60s window, acceptable per ADR-029 design intent. No retry, no error surfaced to admin (DB state is already correct).
|
||||
|
||||
### D5: Seed Data Strategy
|
||||
Seed data inserted in the SQL delta file itself (not separate seed script) so it runs automatically with the schema change. Initial hardcoded prompt content extracted from `ai-batch.processor.ts` before migration.
|
||||
|
||||
### D6: Version Number Assignment
|
||||
On create: `SELECT MAX(version_number) + 1 FROM ai_prompts WHERE prompt_type = ?` within a transaction. Uses `@VersionColumn` or DB-level unique constraint to prevent race conditions.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Quickstart: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
**Feature**: `229-dynamic-prompt-management`
|
||||
**Date**: 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
- [ ] Backend running: `pnpm --filter backend dev` (port 3001)
|
||||
- [ ] Frontend running: `pnpm --filter frontend dev` (port 3000)
|
||||
- [ ] MariaDB running with SQL delta applied
|
||||
- [ ] Redis running (for cache testing)
|
||||
- [ ] Logged in as Superadmin (user with `system.manage_all` permission)
|
||||
|
||||
---
|
||||
|
||||
> **Prerequisites**: User seed (`pnpm --filter backend seed` or `ts-node run-seed.ts`) MUST run before applying this delta. The seed INSERT for `ai_prompts` references `users.username = 'superadmin'` — if the user table is empty, the INSERT will fail with a FK constraint error.
|
||||
|
||||
## Step 1: Apply SQL Delta
|
||||
|
||||
```sql
|
||||
-- Run in MariaDB:
|
||||
SOURCE specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-prompts.sql;
|
||||
|
||||
-- Verify:
|
||||
SELECT id, prompt_type, version_number, is_active, LEFT(template, 50) AS template_preview
|
||||
FROM ai_prompts;
|
||||
-- Expected: 1 row, version_number=1, is_active=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Verify API Endpoint (List Versions)
|
||||
|
||||
```bash
|
||||
# List all versions for ocr_extraction:
|
||||
curl -X GET http://localhost:3001/api/ai/prompts/ocr_extraction \
|
||||
-H "Authorization: Bearer <superadmin_token>"
|
||||
|
||||
# Expected: { data: [{ promptType: 'ocr_extraction', versionNumber: 1, isActive: true, ... }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create a New Prompt Version
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/ai/prompts/ocr_extraction \
|
||||
-H "Authorization: Bearer <superadmin_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"template": "Improved prompt for OCR extraction:\n{{ocr_text}}\nReturn JSON with documentNumber, subject, discipline, date, confidence, category, tags, summary."
|
||||
}'
|
||||
|
||||
# Expected: { data: { versionNumber: 2, isActive: false, ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Activate New Version
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/ai/prompts/ocr_extraction/2/activate \
|
||||
-H "Authorization: Bearer <superadmin_token>"
|
||||
|
||||
# Expected: { data: { versionNumber: 2, isActive: true, activatedAt: "...", ... } }
|
||||
|
||||
# Verify old version is now inactive:
|
||||
curl -X GET http://localhost:3001/api/ai/prompts/ocr_extraction \
|
||||
-H "Authorization: Bearer <superadmin_token>"
|
||||
# Expected: v1 isActive=false, v2 isActive=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Test Validation (Missing Placeholder)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/ai/prompts/ocr_extraction \
|
||||
-H "Authorization: Bearer <superadmin_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "template": "This prompt has no placeholder" }'
|
||||
|
||||
# Expected: 400 Bad Request
|
||||
# { message: "template ต้องมี {{ocr_text}} placeholder", userMessage: "กรุณาเพิ่ม {{ocr_text}} ใน template ก่อนบันทึก" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify Redis Cache
|
||||
|
||||
```bash
|
||||
# After GET or activate, check Redis:
|
||||
redis-cli GET "ai:prompt:active:ocr_extraction"
|
||||
# Expected: JSON string of active prompt
|
||||
|
||||
# After activate:
|
||||
redis-cli TTL "ai:prompt:active:ocr_extraction"
|
||||
# Expected: number between 1-60 (seconds remaining)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: UI Verification
|
||||
|
||||
> **Sandbox polling mechanism**: "เริ่มทำ OCR Sandbox" enqueues a BullMQ job to the `ai-batch` queue via the existing sandbox trigger endpoint. The frontend polls the existing job-status endpoint (`GET /api/ai/jobs/:jobId/status`) until the job completes or fails — this is the **existing pattern**, no new polling endpoint is introduced by this feature.
|
||||
|
||||
1. Navigate to `/admin` → AI Admin Console → OCR Sandbox tab
|
||||
2. Verify: Prompt Editor shows active version template in textarea
|
||||
3. Verify: Version History panel shows v1 (inactive), v2 (active ✅)
|
||||
4. Click "Load" on v1 → template should appear in textarea (v2 still active)
|
||||
5. Upload a PDF → Click "เริ่มทำ OCR Sandbox" → wait for result (up to 120s — cold start allowed)
|
||||
6. Verify: Result JSON appears with 8 fields; `test_result_json` and `last_tested_at` updated on v2
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Delete Inactive Version
|
||||
|
||||
```bash
|
||||
# Try to delete active version (should fail):
|
||||
curl -X DELETE http://localhost:3001/api/ai/prompts/ocr_extraction/2 \
|
||||
-H "Authorization: Bearer <superadmin_token>"
|
||||
# Expected: 400 Bad Request
|
||||
|
||||
# Delete inactive version (should succeed):
|
||||
curl -X DELETE http://localhost:3001/api/ai/prompts/ocr_extraction/1 \
|
||||
-H "Authorization: Bearer <superadmin_token>"
|
||||
# Expected: 204 No Content
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- [ ] SQL delta applied; seed data present (v1, is_active=1)
|
||||
- [ ] GET returns all versions for prompt_type
|
||||
- [ ] POST validates {{ocr_text}} placeholder
|
||||
- [ ] POST creates new inactive version with auto-incremented version_number
|
||||
- [ ] Activate deactivates old + activates new + invalidates Redis cache (single transaction)
|
||||
- [ ] Cannot delete active version
|
||||
- [ ] Can delete inactive version
|
||||
- [ ] PATCH saves manual_note
|
||||
- [ ] processSandboxExtract uses 120s timeout (no timeout on cold start)
|
||||
- [ ] processMigrateDocument uses same resolvePrompt() — no hardcoded prompt
|
||||
- [ ] UI: Prompt Editor + Version History rendered correctly
|
||||
- [ ] UI: OCR Sandbox run shows result + auto-saves test_result_json
|
||||
- [ ] audit_logs records: create, activate, delete events
|
||||
@@ -0,0 +1,75 @@
|
||||
# Research: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
**Feature**: `229-dynamic-prompt-management`
|
||||
**Date**: 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## R1: Hardcoded Prompt Location
|
||||
|
||||
**Decision**: Extract hardcoded prompt from `ai-batch.processor.ts` (both `processSandboxExtract` and `processMigrateDocument`) before removing it
|
||||
**Rationale**: This exact text becomes the seed data for `ai_prompts` version 1 — must preserve exact wording
|
||||
**Action Required**: Before creating the delta, read the current hardcoded prompt from the processor to capture the exact template
|
||||
|
||||
---
|
||||
|
||||
## R2: Redis Cache Strategy for Active Prompt
|
||||
|
||||
**Decision**: Cache key `ai:prompt:active:{prompt_type}`, TTL 60s, invalidate on `activate()` with `RedisClient.del()`
|
||||
**Rationale**: Active prompt changes infrequently (only on admin action). 60s TTL means max 60s delay for processors to pick up new prompt — acceptable per ADR-029. If Redis unavailable, fall back to DB query.
|
||||
**Alternatives considered**:
|
||||
- TTL 5min (same as intent patterns, ADR-024): Rejected — prompt activation should propagate faster than intent patterns
|
||||
- No cache (always DB): Rejected — every BullMQ job would hit DB; ai-batch can process many jobs concurrently
|
||||
|
||||
---
|
||||
|
||||
## R3: Version Number Race Condition Prevention
|
||||
|
||||
**Decision**: Use `SELECT MAX(version_number) + 1 FROM ai_prompts WHERE prompt_type = ? FOR UPDATE` within a DB transaction; UNIQUE KEY `uk_type_version` on `(prompt_type, version_number)` provides final guard
|
||||
**Rationale**: Concurrent create requests could generate the same version number. `FOR UPDATE` row lock + unique constraint prevents this cleanly without Redis Redlock (not needed for admin-only low-frequency operations)
|
||||
**Alternatives considered**:
|
||||
- Redis Redlock (ADR-002): Recommended for document numbering — overkill for admin-only prompt versioning; admin operations are inherently low-concurrency
|
||||
|
||||
---
|
||||
|
||||
## R4: AiPromptsService.resolveActive() vs Private Method in Processor
|
||||
|
||||
**Decision**: Implement `resolveActive(promptType: string): Promise<AiPrompt>` in `AiPromptsService` and inject service into processor
|
||||
**Rationale**: Both `processSandboxExtract` and `processMigrateDocument` call the same method — centralizing in service enables unit testing independently of processor; processor depends on service (correct direction)
|
||||
**Alternatives considered**:
|
||||
- Private method in processor: Cannot be unit tested independently; duplicated if multiple processors need it in the future
|
||||
|
||||
---
|
||||
|
||||
## R5: Activation Transaction Isolation
|
||||
|
||||
**Decision**: Use TypeORM `EntityManager.transaction()` for activate() — deactivate old + activate new + log to audit_logs in single transaction
|
||||
**Rationale**: Prevents state where two versions are active simultaneously (even briefly). audit_logs insert inside transaction ensures no audit record without state change.
|
||||
**Pattern**: Follows existing `WorkflowEngineService` transaction patterns in codebase
|
||||
|
||||
---
|
||||
|
||||
## R6: OcrSandboxPromptManager Component Architecture
|
||||
|
||||
**Decision**: Single component `OcrSandboxPromptManager` with two panels — left: `PromptEditor` (textarea + save button), right: `PromptVersionHistory` (list + Load/Activate/Delete actions). File upload + sandbox run at bottom.
|
||||
**Rationale**: Matches ADR-029 UI mockup. Two-column layout on desktop (md:grid-cols-2), stacked on mobile. `useAiPrompts` TanStack Query hook provides version list with optimistic updates on activate.
|
||||
|
||||
---
|
||||
|
||||
## R7: Existing Patterns to Reuse
|
||||
|
||||
| Pattern | Source | Reuse |
|
||||
|---------|--------|-------|
|
||||
| CASL guard decorator | `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` | All 5 endpoints |
|
||||
| Audit decorator | `@Audit(...)` from audit-log module | activate, create, delete |
|
||||
| Redis inject | `@InjectRedis()` from `@liaoliaots/nestjs-redis` | AiPromptsService |
|
||||
| TanStack Query | `useMutation` + `useQuery` | `useAiPrompts` hook |
|
||||
| BusinessException | `backend/src/common/exceptions/` | Validation errors |
|
||||
|
||||
---
|
||||
|
||||
## R8: SQL Delta Filename Convention
|
||||
|
||||
**Decision**: `2026-05-25-create-ai-prompts.sql` in `specs/03-Data-and-Storage/deltas/`
|
||||
**Rationale**: Follows existing delta naming pattern (e.g., `2026-05-22-alter-migration-review-queue.sql`)
|
||||
**Action**: Include both `CREATE TABLE` and `INSERT` seed data in same file
|
||||
@@ -0,0 +1,142 @@
|
||||
# Feature Specification: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
**Feature Branch**: `229-dynamic-prompt-management`
|
||||
**Created**: 2026-05-25
|
||||
**Status**: Draft
|
||||
**ADR Reference**: [ADR-029](../../06-Decision-Records/ADR-029-dynamic-prompt-management.md)
|
||||
**Input**: Dynamic, runtime-editable OCR extraction prompts shared by OCR Sandbox and Migration processor — replaces hardcoded prompts, adds versioning, timeout bug fix, Redis caching.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing _(mandatory)_
|
||||
|
||||
### User Story 1 - Prompt Version Management (Priority: P1)
|
||||
|
||||
Superadmin สามารถดูรายการ Prompt Versions ทั้งหมด, สร้าง Prompt Version ใหม่, activate version ที่ต้องการให้ระบบใช้จริง และลบ version ที่ไม่ต้องการ (ยกเว้น active version) ผ่าน AI Admin Console
|
||||
|
||||
**Why this priority**: นี่คือรากฐานของ feature ทั้งหมด — หากไม่มีโครงสร้างการจัดการ Prompt Version, ระบบยังคงใช้ prompt แบบ hardcoded และไม่สามารถแก้ไข runtime ได้
|
||||
|
||||
**Independent Test**: เปิด AI Admin Console → OCR Sandbox tab → จัดการ Prompt Versions ได้โดยไม่ต้อง upload PDF ใดๆ
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** มี Prompt Versions ใน DB, **When** superadmin เปิด OCR Sandbox tab, **Then** เห็น Version History panel ทางขวามือพร้อม active version ที่มีเครื่องหมาย ✅
|
||||
2. **Given** superadmin กรอก Prompt Template ที่มี `{{ocr_text}}` placeholder, **When** กด "บันทึก Version ใหม่", **Then** สร้าง version ใหม่ (inactive) และปรากฏใน Version History
|
||||
3. **Given** superadmin กรอก Prompt Template ที่ไม่มี `{{ocr_text}}`, **When** กด "บันทึก Version ใหม่", **Then** ระบบแสดง error "template ต้องมี {{ocr_text}} placeholder" และไม่บันทึก
|
||||
4. **Given** มี inactive version, **When** superadmin กด "Activate", **Then** version นั้นกลายเป็น active, version เดิมกลายเป็น inactive, Redis cache ถูก invalidate ทันที
|
||||
5. **Given** มี active version, **When** superadmin พยายามกด Delete บน active version, **Then** ระบบปฏิเสธพร้อมข้อความ "ไม่สามารถลบ active version ได้"
|
||||
6. **Given** มี inactive version, **When** superadmin กด Delete, **Then** version ถูกลบออกจาก DB และหายจาก Version History
|
||||
7. **Given** superadmin กด Load บน version ใดๆ, **When** version โหลด, **Then** template ของ version นั้นปรากฏใน Prompt Editor textarea (ไม่ activate อัตโนมัติ)
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - OCR Sandbox Testing with Prompt Evaluation (Priority: P2)
|
||||
|
||||
Superadmin สามารถ upload PDF และทดสอบ OCR + LLM extraction ด้วย Active Prompt ปัจจุบัน เพื่อประเมินคุณภาพผลลัพธ์ก่อนใช้งานจริงกับ Migration batch โดยผลลัพธ์ auto-save ลง active version และรองรับ Manual Note annotation
|
||||
|
||||
**Why this priority**: Sandbox testing ช่วยให้ admin ตรวจสอบและเปรียบเทียบ prompt ก่อน activate จริง — ลดความเสี่ยงที่ prompt ไม่ดีจะกระทบ Migration batch
|
||||
|
||||
**Independent Test**: upload PDF → กด "เริ่มทำ OCR Sandbox" → เห็นผล JSON ครบ 8 fields และผลลัพธ์ auto-save ลง active version
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** มี Active Prompt, **When** superadmin upload PDF และกด "เริ่มทำ OCR Sandbox", **Then** ระบบรัน OCR + LLM extraction ด้วย active prompt และแสดงผล JSON ที่มี 8 fields (documentNumber, subject, discipline, date, confidence, category, tags, summary)
|
||||
2. **Given** sandbox run เสร็จสิ้น, **When** ผลลัพธ์ JSON ออกมา, **Then** ผลลัพธ์ auto-save ลงใน active version's `test_result_json` และ update `last_tested_at` อัตโนมัติ
|
||||
3. **Given** ผลลัพธ์ sandbox ปรากฏ, **When** superadmin พิมพ์ manual note และกด "บันทึก Manual Note", **Then** note ถูกบันทึกลงใน `manual_note` field ของ active version
|
||||
4. **Given** OCR Sandbox ถูก trigger และ Ollama ต้องโหลด model ใหม่ (cold start), **When** ระบบรอผล, **Then** ระบบรอได้นานถึง 120 วินาที ไม่ timeout ก่อนกำหนด (แก้ bug จาก AI_TIMEOUT_MS = 30s)
|
||||
5. **Given** ไม่มี Active Prompt, **When** superadmin กด "เริ่มทำ OCR Sandbox", **Then** ระบบแสดง error ว่าไม่พบ active prompt และไม่รัน sandbox
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Runtime Prompt Resolution (Priority: P3)
|
||||
|
||||
ระบบ (processor layer) สามารถดึง Active Prompt จาก `ai_prompts` table ผ่าน Redis cache (TTL 60s) และใช้ใน `processSandboxExtract` กับ `processMigrateDocument` ได้โดยทั้งสองใช้ prompt เดียวกัน — ไม่มี hardcoded prompt ใน codebase
|
||||
|
||||
**Why this priority**: Backend plumbing ที่ทำให้ US1 และ US2 มีผลจริงในระบบ Production — ถ้าไม่มี US3, prompt ที่ admin set ผ่าน UI ไม่มีผลต่อ processor
|
||||
|
||||
**Independent Test**: activate prompt version ใหม่ → trigger sandbox job → ตรวจสอบ log ว่า job ใช้ prompt version ใหม่
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** มี Active Prompt ใน DB, **When** processor เรียก `resolvePrompt('ocr_extraction', ocrText)`, **Then** ได้รับ resolved prompt ที่แทนที่ `{{ocr_text}}` ด้วย OCR text จริง
|
||||
2. **Given** Active Prompt ถูก cache ใน Redis TTL 60s, **When** processor เรียก `resolvePrompt()` ภายใน 60s, **Then** ได้รับ prompt จาก Redis cache (ไม่ query DB ซ้ำ)
|
||||
3. **Given** admin activate version ใหม่ (cache invalidated), **When** processor เรียก `resolvePrompt()` ครั้งถัดไป, **Then** ได้รับ prompt version ล่าสุดจาก DB และ cache ถูก refresh
|
||||
4. **Given** ไม่มี Active Prompt ใน DB (เช่น deploy ใหม่ก่อน seed), **When** processor พยายาม `resolveActive()`, **Then** `BusinessException` throw → BullMQ mark job **failed** → `migrationService.createError()` บันทึก error → batch หยุด (fail-fast — ไม่ใช้ skip เพราะทำให้เกิด silent data gap)
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- อะไรเกิดขึ้นถ้า admin สองคน activate พร้อมกัน? → `activate()` ต้องใช้ `SELECT ... FOR UPDATE` เพื่อ lock current active row ก่อน deactivate — serializes concurrent activations ต่อ `prompt_type`; admin คนที่สองจะ block จนกว่าคนแรก COMMIT — ไม่มี double-active
|
||||
- อะไรเกิดขึ้นถ้า admin activate version ขณะที่ Migration batch กำลังรัน? → job ที่กำลัง run ใช้ prompt ที่ดึงมาแล้ว (per-job resolution); job ถัดไปจะใช้ prompt ใหม่ — acceptable tradeoff
|
||||
- อะไรเกิดขึ้นถ้า Redis ล่มขณะที่ processor เรียก `resolvePrompt()`? → fallback query DB โดยตรง; ระบบยังทำงานได้ แต่ performance ลด
|
||||
- อะไรเกิดขึ้นถ้า template ยาวเกิน 4,000 ตัวอักษร? → `create()` reject ด้วย `ValidationException('Template exceeds 4,000 character limit')` — ป้องกัน context window overflow ใน Ollama (FR-015)
|
||||
- อะไรเกิดขึ้นถ้า PDF ที่ upload ใน sandbox ไม่มี text (scanned image)? → OCR Service รัน PaddleOCR ตาม existing flow; ไม่ใช่ scope ของ feature นี้
|
||||
- อะไรเกิดขึ้นถ้า Ollama timeout แม้จะตั้ง 120s? → sandbox job fail พร้อม error message; ไม่กระทบ Migration jobs อื่น
|
||||
- อะไรเกิดขึ้นถ้า version 1 (seed data) ถูกลบก่อนที่จะมี version อื่น active? → ป้องกันได้ด้วย guard "ห้ามลบ active version"
|
||||
- อะไรเกิดขึ้นถ้าผลลัพธ์ JSON จาก sandbox ไม่ครบ 8 fields? → save ทุกอย่างที่ได้ใน `test_result_json`, UI แสดงตามที่มี
|
||||
- อะไรเกิดขึ้นถ้า admin ลบ v2 แล้วสร้าง version ใหม่ → version number กลายเป็น v4 ข้าม v2? → by design (monotonically increasing, ไม่ fill gaps per plan.md D6); UI MUST แสดง version numbers ตามที่เป็น ไม่ reindex — ป้องกัน admin สับสน "v2 หายไปไหน?"
|
||||
- อะไรเกิดขึ้นถ้า processor crash หลัง Ollama return แต่ก่อน `saveTestResult()` รัน? → `test_result_json` ยังเป็น `NULL` (เหมือนยังไม่ทดสอบ) — acceptable; admin รัน sandbox ใหม่ได้ทันที; ไม่กระทบ Active Prompt หรือ migration batch
|
||||
|
||||
---
|
||||
|
||||
## Requirements _(mandatory)_
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: ระบบ MUST บันทึก Prompt Template เป็น versioned records ใน `ai_prompts` table — ทุกการบันทึกสร้าง version ใหม่เสมอ (immutable; ไม่มี update template เดิม)
|
||||
- **FR-002**: ระบบ MUST validate ว่า template มี `{{ocr_text}}` placeholder ก่อน save — reject พร้อม user-friendly error message ถ้าไม่มี
|
||||
- **FR-003**: ระบบ MUST enforce single active version ต่อ `prompt_type` — activate version ใหม่จะ deactivate version เดิมอัตโนมัติใน transaction เดียว
|
||||
- **FR-004**: ระบบ MUST ห้ามลบ active version — แสดง error ถ้าพยายามลบ active version
|
||||
- **FR-005**: ระบบ MUST auto-save `test_result_json` และ update `last_tested_at` ลงใน Prompt Version ที่ **active ณ เวลาที่ job เริ่มทำงาน** (ไม่ใช่เวลาที่ result กลับมา) — กัน race condition ที่ admin activate version อื่นระหว่างที่ sandbox กำลังรันอยู่
|
||||
- **FR-006**: ระบบ MUST รองรับ `manual_note` annotation จาก admin ต่อ Prompt Version ผ่าน PATCH endpoint
|
||||
- **FR-007**: ระบบ MUST invalidate Redis cache (`ai:prompt:active:ocr_extraction`) ทันทีหลัง activate สำเร็จ
|
||||
- **FR-008**: `processSandboxExtract` MUST ใช้ timeout 120000ms เพื่อรองรับ Ollama cold start (แก้ bug AI_TIMEOUT_MS = 30000ms)
|
||||
- **FR-009**: ทั้ง `processSandboxExtract` และ `processMigrateDocument` MUST ใช้ `resolvePrompt()` method เดียวกัน — ไม่มี hardcoded prompt ใน processor
|
||||
- **FR-010**: API endpoints ทั้งหมดสำหรับ Prompt Management MUST ป้องกันด้วย `system.manage_all` CASL permission
|
||||
- **FR-011**: ระบบ MUST มี seed data (Prompt Version 1 ที่ migrate จาก hardcoded prompt ปัจจุบัน พร้อม `is_active = 1`) ก่อน deploy
|
||||
- **FR-012**: Redis cache MUST fallback ไป DB query ถ้า Redis ไม่พร้อมใช้งาน (graceful degradation)
|
||||
- **FR-013**: Prompt activation, creation, deletion events MUST be recorded in standard `audit_logs` table (ไม่ใช่ `ai_audit_logs`)
|
||||
- **FR-014**: `GET /ai/prompts/:type` MUST return all versions สำหรับ prompt_type นั้น (ไม่ paginate ใน v1)
|
||||
- **FR-015**: `template` MUST NOT exceed **4,000 characters** — รักษา headroom ใน context window 8192 tokens ของ gemma4:e4b เพื่อให้ OCR text มีที่เหลือ — reject พร้อม user-friendly error ถ้าเกิน
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Prompt Version** (`ai_prompts`): Immutable snapshot ของ prompt template — มี `prompt_type`, `version_number`, `template`, `is_active`, `test_result_json`, `manual_note`, `last_tested_at`, `activated_at`, `created_by`
|
||||
- **Active Prompt**: Prompt Version ที่ `is_active = 1` ต่อ `prompt_type` — cached ใน Redis key `ai:prompt:active:{prompt_type}` TTL 60s
|
||||
- **Prompt Template**: String ที่มี `{{ocr_text}}` placeholder บังคับ — resolved เป็น final prompt โดย processor ก่อนส่งเข้า Ollama
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria _(mandatory)_
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Superadmin สามารถสร้าง, activate, และลบ Prompt Version ได้ภายใน 30 วินาที ต่อการดำเนินการหนึ่งครั้ง
|
||||
- **SC-002**: OCR Sandbox รันได้สำเร็จสำหรับ PDF ที่ถูก upload โดยไม่ timeout ก่อน 120 วินาที
|
||||
- **SC-003**: Processor ดึง Active Prompt จาก Redis cache ภายใน 5ms ในช่วง TTL 60s (ไม่ query DB ซ้ำ)
|
||||
- **SC-004**: หลัง activate Prompt Version ใหม่, jobs ถัดไปทั้งหมดใช้ prompt version ใหม่ภายใน 60 วินาที (Redis TTL expiry)
|
||||
- **SC-005**: ไม่มี hardcoded prompt template ใน codebase หลังจาก feature นี้ deploy — 100% DB-driven
|
||||
- **SC-006**: Version History แสดง Prompt Versions ทั้งหมดพร้อม status (active/inactive) และ last_tested_at ได้อย่างถูกต้อง
|
||||
|
||||
---
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-05-25
|
||||
|
||||
- Q: Should prompt activation and sandbox run events be recorded in `ai_audit_logs` or standard `audit_logs`? → A: Standard `audit_logs` — these are admin config actions, not AI inference results; keeps `AiPromptsModule` decoupled from `AiAuditLogModule`
|
||||
- Q: Should `GET /ai/prompts/:type` use pagination? → A: Return all versions (no pagination in v1) — prompt versions are expected to be low-count (single digits to low tens); simplifies UI implementation
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Scope จำกัดที่ `prompt_type = 'ocr_extraction'` เดียว (8 fields: documentNumber, subject, discipline, date, confidence, category, tags, summary) ตาม ADR-029 core decisions
|
||||
- Admin ที่ใช้ feature นี้คือ Superadmin ที่มี `system.manage_all` permission เท่านั้น
|
||||
- OCR Service (PaddleOCR sidecar บน Desk-5439) ยังคงทำงานเหมือนเดิม — feature นี้เปลี่ยนเฉพาะ LLM prompt หลัง OCR
|
||||
- Seed data (version 1 ที่ migrate จาก hardcoded prompt) ต้องถูก insert ก่อน first deploy ผ่าน SQL delta (ADR-009)
|
||||
- Redis พร้อมใช้งาน; ถ้า Redis ล่ม ระบบ graceful degrade ไป DB query
|
||||
- Existing `AiController` / `AiModule` สามารถ extend ได้โดยไม่ต้อง refactor โครงสร้างหลัก
|
||||
- `field_schema JSON NULL` column ใน `ai_prompts` เป็น system-managed metadata (ไม่ user-editable ใน v1) — ระบุ expected output fields สำหรับ validation
|
||||
- Timeout fix ใช้กับ `processSandboxExtract` เท่านั้น; `processMigrateDocument` ใช้ default timeout ของ BullMQ job (queue-level timeout ต่างกัน)
|
||||
@@ -0,0 +1,201 @@
|
||||
# Tasks: Dynamic Prompt Management for OCR Extraction
|
||||
|
||||
**Input**: Design documents from `specs/200-fullstacks/229-dynamic-prompt-management/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/prompts.yaml ✅, quickstart.md ✅
|
||||
**Branch**: `229-dynamic-prompt-management`
|
||||
|
||||
## Format: `[ID] [P?] [Story?] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[US#]**: User Story mapping (US1 = Version Management, US2 = Sandbox Testing, US3 = Runtime Resolution)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Schema, entity, and module scaffolding that all user stories depend on
|
||||
|
||||
- [ ] T001 Read hardcoded prompt from `backend/src/modules/ai/processors/ai-batch.processor.ts` (both `processSandboxExtract` and `processMigrateDocument`) and capture exact template text for seed data
|
||||
- [ ] T002 Create SQL delta `specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-prompts.sql` with `CREATE TABLE ai_prompts` + seed data INSERT (use exact template from T001)
|
||||
- [ ] T002b [P] Create rollback delta `specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-prompts.rollback.sql` with `DROP TABLE IF EXISTS ai_prompts` — follows existing delta rollback convention
|
||||
- [ ] T003 [P] Create TypeORM entity `backend/src/modules/ai/prompts/ai-prompts.entity.ts` per data-model.md (INT PK with `@Exclude()`, all columns, no publicId needed)
|
||||
- [ ] T004 [P] Create `backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts` with `template: string` (class-validator `@IsNotEmpty()`)
|
||||
- [ ] T005 [P] Create `backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts` with `manualNote: string | null`
|
||||
- [ ] T006 [P] Create `backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts` with `@Expose()` fields per data-model.md API Response Shape
|
||||
|
||||
**Checkpoint**: Schema applied, entity and DTOs compile — T007 can begin
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Backend Prerequisites)
|
||||
|
||||
**Purpose**: Core service and module wiring — MUST complete before US1 frontend or US3 processor work
|
||||
|
||||
**⚠️ CRITICAL**: All user story implementation depends on this phase
|
||||
|
||||
- [ ] T007 Create `backend/src/modules/ai/prompts/ai-prompts.service.ts` with:
|
||||
- `findAll(promptType: string): Promise<AiPrompt[]>` — ORDER BY version_number DESC
|
||||
- `getActive(promptType: string): Promise<AiPrompt | null>` — Redis cache first, DB fallback
|
||||
- `create(promptType, dto, userId): Promise<AiPrompt>` — validate `{{ocr_text}}` present (FR-002); validate `template.length <= 4000` (FR-015, reject with `ValidationException`); assign `MAX(version_number)+1 FOR UPDATE`
|
||||
- `activate(promptType, versionNumber, userId): Promise<AiPrompt>` — transaction: **`SELECT id FROM ai_prompts WHERE prompt_type=? AND is_active=1 FOR UPDATE`** first (serializes concurrent activations) → deactivate old → activate new → COMMIT → Redis DEL + audit_logs
|
||||
- `delete(promptType, versionNumber, userId): Promise<void>` — guard active version + audit_logs
|
||||
- `updateNote(promptType, versionNumber, note): Promise<AiPrompt>` — PATCH manual_note only
|
||||
- `saveTestResult(promptType, versionNumber, resultJson): Promise<void>` — auto-save from sandbox
|
||||
- [ ] T008 Create `backend/src/modules/ai/prompts/ai-prompts.controller.ts` — 5 endpoints per contracts/prompts.yaml with `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` + `@Audit()` on mutating endpoints
|
||||
- [ ] T009 Create `backend/src/modules/ai/prompts/ai-prompts.module.ts` — import `TypeOrmModule.forFeature([AiPrompt])`, `RedisModule`, export `AiPromptsService`
|
||||
- [ ] T010 Register `AiPromptsModule` in `backend/src/modules/ai/ai.module.ts` imports array
|
||||
- [ ] T011 Add `AiPrompt` entity to `backend/src/database/` or TypeORM config entities array so it is picked up by the DB connection
|
||||
|
||||
**Checkpoint**: `pnpm --filter backend build` passes; GET /api/ai/prompts/ocr_extraction returns seed data — US1 frontend and US3 processor can proceed in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Prompt Version Management UI (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Superadmin จัดการ Prompt Versions ได้ผ่าน AI Admin Console (ไม่ต้อง upload PDF)
|
||||
|
||||
**Independent Test**: เปิด AI Admin Console → OCR Sandbox tab → เห็น Version History, สร้าง version ใหม่, activate, ลบ inactive version ได้
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T012 [P] [US1] Create `frontend/types/ai-prompts.ts` — `AiPrompt` interface พร้อม `promptType`, `versionNumber`, `template`, `isActive`, `testResultJson`, `manualNote`, `lastTestedAt`, `activatedAt`, `createdAt`; and `SandboxResult` interface พร้อม `promptVersionUsed: number`, `answer: string`, `completedAt: string`
|
||||
- [ ] T013 [P] [US1] Create `frontend/lib/services/ai-prompts.service.ts` — `listVersions(promptType)`, `createVersion(promptType, template)`, `activateVersion(promptType, versionNumber)`, `deleteVersion(promptType, versionNumber)`, `updateNote(promptType, versionNumber, note)` — calls `/api/ai/prompts/...` via `apiClient`
|
||||
- [ ] T014 [US1] Create `frontend/hooks/use-ai-prompts.ts` — TanStack Query `useQuery` (listVersions) + `useMutation` (create, activate, delete, updateNote) with `invalidateQueries` on success
|
||||
- [ ] T015 [US1] Create `frontend/components/admin/ai/PromptVersionHistory.tsx` — Version list panel ทางขวา แสดง version_number, is_active badge (✅), last_tested_at, buttons: Load / Activate / Delete (Delete ปิดถ้า isActive)
|
||||
- [ ] T016 [US1] Create `frontend/components/admin/ai/OcrSandboxPromptManager.tsx` — 2-column layout: left (Prompt Editor textarea + "บันทึก Version ใหม่" button) + right (`PromptVersionHistory`) — โหลด active version template เข้า textarea เมื่อ mount
|
||||
- [ ] T017 [US1] Wire `OcrSandboxPromptManager` into existing AI Admin Console OCR Sandbox tab (replace or extend existing component in `frontend/app/(admin)/admin/...`)
|
||||
- [ ] T018 [P] [US1] Add i18n keys to `frontend/public/locales/th/ai-admin.json` and `frontend/public/locales/en/ai-admin.json` — keys: `prompt.saveVersion`, `prompt.activate`, `prompt.delete`, `prompt.load`, `prompt.activeLabel`, `prompt.placeholderError`, `prompt.deleteActiveError`
|
||||
|
||||
**Checkpoint**: US1 fully functional — version list, create, activate, delete work in UI without PDF upload
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — OCR Sandbox Testing with Prompt Evaluation (Priority: P2)
|
||||
|
||||
**Goal**: Superadmin upload PDF → ทดสอบ active prompt → ผลลัพธ์ auto-save ลง active version
|
||||
|
||||
**Independent Test**: upload PDF → รัน sandbox → เห็นผล 8 fields; test_result_json updated; manual note saved
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T019 [US2] Extend `OcrSandboxPromptManager.tsx` — เพิ่ม File Upload section (PDF only) + "เริ่มทำ OCR Sandbox" button + ผลลัพธ์ JSON display panel + "บันทึก Manual Note" field (textarea + button)
|
||||
- [ ] T020 [US2] Add `runSandbox(promptType, file)` to `frontend/lib/services/ai-prompts.service.ts` — calls existing sandbox endpoint (POST /api/ai/ocr-sandbox or existing BullMQ trigger endpoint) passing PDF file
|
||||
- [ ] T021 [US2] Add `useSandboxRun` mutation to `frontend/hooks/use-ai-prompts.ts` — triggers sandbox run, polls for result, updates version list on completion (to reflect new `testResultJson`); read `promptVersionUsed` from job result and display as "ผลจาก Prompt Version X" badge in the result panel
|
||||
- [ ] T022 [US2] Add i18n keys: `prompt.runSandbox`, `prompt.sandboxResult`, `prompt.saveNote`, `prompt.noActivePrompt`, `prompt.timeoutInfo`, `prompt.versionUsed` to both locale files
|
||||
|
||||
**Checkpoint**: US2 fully functional — upload PDF, run sandbox, see results, save note
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Runtime Prompt Resolution in Processor (Priority: P3)
|
||||
|
||||
**Goal**: `processSandboxExtract` + `processMigrateDocument` ใช้ `resolvePrompt()` จาก DB — ไม่มี hardcoded prompt
|
||||
|
||||
**Independent Test**: activate version 2 → trigger sandbox job → log shows "Using prompt version 2" — no hardcoded string in processor
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T023 [US3] Inject `AiPromptsService` into `backend/src/modules/ai/processors/ai-batch.processor.ts` constructor
|
||||
- [ ] T024 [US3] Add `resolveActive(promptType: string, ocrText: string): Promise<{ resolvedPrompt: string; versionNumber: number }>` method to `AiPromptsService` — calls `getActive(promptType)`, replaces `{{ocr_text}}` with `ocrText`; if no active prompt: throw `BusinessException('No active prompt for type: ocr_extraction')` → caller (processor) lets it propagate → BullMQ marks job failed (fail-fast; do NOT catch-and-skip as that creates silent data gaps); returns both resolved string and versionNumber on success (FR-005)
|
||||
- [ ] T025 [US3] Replace hardcoded prompt string in `processSandboxExtract` with `const { resolvedPrompt, versionNumber } = await this.aiPromptsService.resolveActive('ocr_extraction', ocrResult.text)` — pass `resolvedPrompt` to `OllamaService.generate()` with `timeoutMs: 120000` (fix AI_TIMEOUT_MS bug); carry `versionNumber` forward to T027; **add `promptVersionUsed: versionNumber` to the completed Redis result shape** so frontend can display which version produced the result
|
||||
- [ ] T026 [US3] Replace hardcoded prompt string in `processMigrateDocument` with `const { resolvedPrompt } = await this.aiPromptsService.resolveActive('ocr_extraction', ocrResult.text)` — pass `resolvedPrompt` to `OllamaService.generate()`; keep existing timeout (no change); **also fix pre-existing bug: add `discipline?: string` to `MigrateDocumentMetadata` interface and add `discipline: readString(source.discipline)` to `parseMigrateDocumentMetadata()`** (see data-model.md "Pre-existing Bug"); NOTE: BullMQ job-level timeout is adequate for batch workloads — no change needed per ADR-029 D3
|
||||
- [ ] T027 [US3] In `processSandboxExtract` — `versionNumber` is captured at job start via `resolveActive()` (T025); after run completes call `this.aiPromptsService.saveTestResult('ocr_extraction', versionNumber, resultJson)` — no re-query needed (FR-005 race condition already guarded)
|
||||
|
||||
**Checkpoint**: US3 complete — `pnpm --filter backend build` passes; no hardcoded prompt strings in processor; resolvePrompt() uses DB/Redis
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Seed data verification, error boundary, tests
|
||||
|
||||
- [ ] T028 [P] Write unit tests for `AiPromptsService` in `backend/src/modules/ai/prompts/ai-prompts.service.spec.ts`:
|
||||
- `create()`: rejects template without `{{ocr_text}}`; assigns correct version_number
|
||||
- `activate()`: deactivates old version; invalidates Redis cache; **calls `AuditLogService` (or `@Audit()` decorator) — verifies audit record created** (FR-013)
|
||||
- `delete()`: throws BusinessException when deleting active version; **calls AuditLogService on successful delete** (FR-013)
|
||||
- `delete()`: succeeds for inactive version
|
||||
- `getActive()`: returns from Redis cache when cache hit
|
||||
- `getActive()`: falls back to DB query when Redis unavailable (mock Redis to throw connection error)
|
||||
- [ ] T029 [P] Verify SQL delta syntax — run against a local MariaDB copy and confirm seed data is present with correct `is_active = 1`
|
||||
- [ ] T030 [P] Run `pnpm --filter backend lint` and `pnpm --filter frontend lint` — fix any issues in new files
|
||||
- [ ] T031 Run quickstart.md acceptance checklist end-to-end and confirm all checkboxes pass
|
||||
- [ ] T032 Update `CONTEXT.md` "System readiness summary" table — change ADR-029 row status from "🟡 ADR Accepted" to "✅ พร้อม" (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: No dependencies — start immediately; T002b/T003/T004/T005/T006 parallel after T002
|
||||
- **Phase 2 (Foundational)**: Depends on T002 (SQL delta) + T003/T004/T005/T006; BLOCKS all user stories
|
||||
- **Phase 3 (US1)**: Depends on Phase 2 complete; T012/T013 parallel with T014
|
||||
- **Phase 4 (US2)**: Depends on Phase 2 + most of Phase 3 (needs `OcrSandboxPromptManager` base)
|
||||
- **Phase 5 (US3)**: Depends on Phase 2 (AiPromptsService); **can run in parallel with Phases 3 & 4**
|
||||
- **Phase 6 (Polish)**: Depends on Phases 3, 4, 5 complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Needs Phase 2 complete — independently testable
|
||||
- **US2 (P2)**: Needs Phase 2 + US1 UI base — builds on US1 component
|
||||
- **US3 (P3)**: Needs Phase 2 only — **fully independent from US1/US2 frontend**; can be done in parallel
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- Phase 1: T001 → T002 (sequential, T001 informs seed data); T002b/T003/T004/T005/T006 parallel after T002
|
||||
- Phase 2: T007 first → T008 (needs service) → T009 → T010/T011 parallel
|
||||
- Phase 3: T012/T013 parallel → T014 → T015 → T016 → T017; T018 anytime
|
||||
- Phase 4: T019 → T020 → T021; T022 anytime
|
||||
- Phase 5: T023 → T024 → T025 → T026 → T027 (sequential — each builds on previous)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 2 Backend + Phase 5 Processor
|
||||
|
||||
```bash
|
||||
# Once Phase 1 complete, these can run in parallel:
|
||||
|
||||
# Developer A — Phase 2 (backend service/controller/module)
|
||||
T007: AiPromptsService
|
||||
T008: AiPromptsController
|
||||
T009: AiPromptsModule
|
||||
T010: Register in AiModule
|
||||
|
||||
# Developer B — Phase 5 (processor changes)
|
||||
# NOTE: Developer B needs AiPromptsService injectable (T007 done)
|
||||
# so Phase 5 starts after T007 completes
|
||||
T023: Inject AiPromptsService
|
||||
T024: resolvePrompt() method
|
||||
T025: fix sandbox timeout + replace hardcoded prompt
|
||||
T026: replace migrate-document hardcoded prompt
|
||||
T027: auto-save test_result_json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 — Version Management)
|
||||
|
||||
1. Complete Phase 1: Setup (T001–T006)
|
||||
2. Complete Phase 2: Foundational (T007–T011)
|
||||
3. Complete Phase 3: US1 (T012–T018)
|
||||
4. **STOP and VALIDATE**: Admin can manage prompt versions without PDF upload
|
||||
5. Deploy/demo US1 independently
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 + 2 → Foundation ready
|
||||
2. Phase 3 (US1) → Admin can manage versions (**MVP delivered**)
|
||||
3. Phase 5 (US3) in parallel with Phase 4 (US2) → Runtime resolution + Sandbox testing
|
||||
4. Phase 6 → Polish and release
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **T001 is critical**: Must read exact hardcoded prompt before deleting it — seed data depends on it
|
||||
- **T025 + T026**: The hardcoded prompt removal is the "100% DB-driven" success criterion (SC-005)
|
||||
- **T028**: Service unit tests should mock Redis to test cache hit + fallback scenarios
|
||||
- **[P] tasks**: T003/T004/T005/T006 can all run in parallel (separate files)
|
||||
- **Avoid**: modifying `ai-batch.processor.ts` (T025/T026) before `AiPromptsService` (T007) is injectable
|
||||
Reference in New Issue
Block a user