Files
lcbp3/specs/200-fullstacks/229-dynamic-prompt-management/data-model.md
T
admin 1139e54086
CI / CD Pipeline / build (push) Successful in 4m29s
CI / CD Pipeline / deploy (push) Successful in 1m50s
690525:1720 ADR-028-228-migration-OCR #06 dynamic prompt
2026-05-25 17:20:48 +07:00

8.8 KiB

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)

-- 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

// 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

// 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_byusers.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 นี้

// ❌ ปัจจุบัน (ขาด 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