Files
lcbp3/specs/200-fullstacks/236-unified-ocr-architecture/data-model.md
T
admin 7e8f4859cd
CI / CD Pipeline / build (push) Failing after 6m24s
CI / CD Pipeline / deploy (push) Has been skipped
feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
- Add ADR-036 unified OCR architecture (typhoon-ocr via Ollama)
- Extend AI execution profiles for OCR sandbox configuration
- Add comprehensive frontend test coverage (components, hooks, services)
- Add backend test coverage for document-numbering services
- Update OCR sidecar with typhoon-ocr integration
- Add AI policy service and execution profile management
- Update AGENTS.md and architecture documentation
2026-06-14 06:34:07 +07:00

8.3 KiB

// File: specs/200-fullstacks/236-unified-ocr-architecture/data-model.md // Change Log: // - 2026-06-13: Data model for Unified AI Model Architecture — Sandbox-Production Parity (ADR-036)

Data Model: Unified AI Model Architecture — Sandbox-Production Parity

ADR-009 compliant — all schema changes via SQL delta, no TypeORM migrations. Delta file: specs/03-Data-and-Storage/deltas/2026-06-13-extend-ai-execution-profiles-ocr.sql


DB Schema Extensions

ai_execution_profiles (extended)

-- Delta: 2026-06-13-extend-ai-execution-profiles-ocr.sql
ALTER TABLE ai_execution_profiles
  ADD COLUMN canonical_model VARCHAR(50) NOT NULL DEFAULT 'np-dms-ai' COMMENT 'np-dms-ai | np-dms-ocr',
  MODIFY COLUMN num_ctx      INT NULL COMMENT 'NULL for OCR model (not used)',
  MODIFY COLUMN max_tokens   INT NULL COMMENT 'NULL for OCR model (not used)';

-- Seed ocr-extract row
INSERT INTO ai_execution_profiles
  (profile_name, canonical_model, temperature, top_p, max_tokens, num_ctx, repeat_penalty, keep_alive_seconds, is_active)
VALUES
  ('ocr-extract', 'np-dms-ocr', 0.1, 0.1, NULL, NULL, 1.1, 0, 1)
ON DUPLICATE KEY UPDATE canonical_model = canonical_model;

-- Update existing rows with canonical name
UPDATE ai_execution_profiles SET canonical_model = 'np-dms-ai'
WHERE profile_name IN ('interactive', 'standard', 'quality', 'deep-analysis');

ai_sandbox_profiles (new table)

-- Delta: 2026-06-13-extend-ai-execution-profiles-ocr.sql
CREATE TABLE IF NOT EXISTS ai_sandbox_profiles (
  id                INT PRIMARY KEY AUTO_INCREMENT,
  profile_name      VARCHAR(50)    NOT NULL,
  canonical_model   VARCHAR(50)    NOT NULL DEFAULT 'np-dms-ai',  -- 'np-dms-ai' | 'np-dms-ocr'
  temperature       DECIMAL(4,3)   NOT NULL,
  top_p             DECIMAL(4,3)   NOT NULL,
  max_tokens        INT            NULL,    -- NULL for np-dms-ocr
  num_ctx           INT            NULL,    -- NULL for np-dms-ocr
  repeat_penalty    DECIMAL(5,3)   NOT NULL,
  keep_alive_seconds INT           NOT NULL DEFAULT 0,
  updated_by        INT            NULL,
  updated_at        TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  created_at        TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_sandbox_profile_name (profile_name)
);
  • Mirrors ai_execution_profiles structure exactly
  • Used as admin draft store — does not affect production jobs until "Apply to Production"
  • Auto-seeded from production row when draft is absent (getSandboxParameters)

ai_audit_logs (extended — action type)

-- No schema change needed — action column already VARCHAR(50)
-- New action value: 'APPLY_PROFILE'
-- Metadata JSON extended with:
--   { profileName, canonicalModel, oldValues: {...}, newValues: {...} }

TypeScript Types (Backend)

AiExecutionProfile (entity, modified)

// File: backend/src/modules/ai/entities/ai-execution-profile.entity.ts
// MODIFY: +canonicalModel column; numCtx/maxTokens nullable
@Entity('ai_execution_profiles')
export class AiExecutionProfile {
  @PrimaryGeneratedColumn() id: number;

  @Column({ name: 'profile_name', unique: true }) profileName: string;

  @Column({ name: 'canonical_model', default: 'np-dms-ai' })
  canonicalModel: 'np-dms-ai' | 'np-dms-ocr';

  @Column({ type: 'decimal', precision: 4, scale: 3 }) temperature: number;

  @Column({ name: 'top_p', type: 'decimal', precision: 4, scale: 3 }) topP: number;

  @Column({ name: 'max_tokens', type: 'int', nullable: true })
  maxTokens: number | null;  // NULL for np-dms-ocr

  @Column({ name: 'num_ctx', type: 'int', nullable: true })
  numCtx: number | null;     // NULL for np-dms-ocr

  @Column({ name: 'repeat_penalty', type: 'decimal', precision: 5, scale: 3 })
  repeatPenalty: number;

  @Column({ name: 'keep_alive_seconds' }) keepAliveSeconds: number;

  @Column({ name: 'is_active', type: 'tinyint', default: 1 }) isActive: boolean;
}

AiSandboxProfile (entity, new)

// File: backend/src/modules/ai/entities/ai-sandbox-profile.entity.ts
// NEW: draft store for sandbox parameter testing
@Entity('ai_sandbox_profiles')
export class AiSandboxProfile {
  @PrimaryGeneratedColumn() id: number;

  @Column({ name: 'profile_name', unique: true }) profileName: string;

  @Column({ name: 'canonical_model', default: 'np-dms-ai' })
  canonicalModel: 'np-dms-ai' | 'np-dms-ocr';

  @Column({ type: 'decimal', precision: 4, scale: 3 }) temperature: number;

  @Column({ name: 'top_p', type: 'decimal', precision: 4, scale: 3 }) topP: number;

  @Column({ name: 'max_tokens', type: 'int', nullable: true })
  maxTokens: number | null;

  @Column({ name: 'num_ctx', type: 'int', nullable: true })
  numCtx: number | null;

  @Column({ name: 'repeat_penalty', type: 'decimal', precision: 5, scale: 3 })
  repeatPenalty: number;

  @Column({ name: 'keep_alive_seconds', default: 0 }) keepAliveSeconds: number;
}

AiJobPayload (interface, modified)

// File: backend/src/modules/ai/interfaces/execution-policy.interface.ts
// MODIFY: +ocrSnapshotParams for dual-model jobs
export interface SnapshotParams {
  temperature: number;
  topP: number;
  maxTokens: number | null;  // null for OCR
  numCtx: number | null;     // null for OCR
  repeatPenalty: number;
  // keep_alive excluded — lazy-loaded per ADR-033
}

export interface AiJobPayload {
  jobType: InternalJobType;
  documentPublicId?: string;
  attachmentPublicId?: string;
  effectiveProfile: string;
  canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
  snapshotParams: SnapshotParams;           // LLM params (np-dms-ai)
  ocrSnapshotParams?: SnapshotParams;       // OCR params (np-dms-ocr); present for dual-model jobs
}
  • snapshotParams frozen at dispatch time — worker uses directly, no DB/Redis re-read
  • ocrSnapshotParams present for migrate-document jobs using both models
  • keepAliveSeconds excluded from snapshot (lazy-loaded per ADR-033)

ApplyProfileDto (DTO, new)

// File: backend/src/modules/ai/dto/apply-profile.dto.ts
export class ApplyProfileDto {
  @IsString()
  @IsNotEmpty()
  profileName: string;

  @IsIn(['np-dms-ai', 'np-dms-ocr'])
  canonicalModel: 'np-dms-ai' | 'np-dms-ocr';

  @IsNumber()
  @Min(0) @Max(1)
  temperature: number;

  @IsNumber()
  @Min(0) @Max(1)
  topP: number;

  @IsNumber()
  @Min(1) @Max(2)
  repeatPenalty: number;

  @IsNumber()
  @Min(0)
  keepAliveSeconds: number;

  @IsOptional() @IsInt() @Min(512)
  numCtx?: number | null;    // omit for np-dms-ocr

  @IsOptional() @IsInt() @Min(256)
  maxTokens?: number | null; // omit for np-dms-ocr
}

ApplyResultDto (DTO, new)

// File: backend/src/modules/ai/dto/apply-result.dto.ts
export class ApplyResultDto {
  profileName: string;
  canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
  appliedAt: string;       // ISO8601
  appliedBy: string;       // user publicId
  oldValues: SnapshotParams;
  newValues: SnapshotParams;
  cacheInvalidated: boolean;
}

Service Methods Summary

AiPolicyService (extended)

Method Description
getSandboxParameters(profileName) Get sandbox draft; auto-seed from production if absent
saveSandboxDraft(profileName, params) UPSERT to ai_sandbox_profiles
resetSandboxToProduction(profileName) Overwrite sandbox draft with current production values
applyProfile(profileName, idempotencyKey, user) Copy sandbox draft → production; DEL Redis cache; audit log
getProfileParameters(profileName) Read from ai_execution_profiles with Redis cache TTL 60s
getModelDefaults(canonicalModel) Query ai_execution_profiles by canonical_model column

Redis Cache Keys

Key TTL Invalidated by
ai:profile:{profileName} 60s applyProfile()
ai:idempotency:apply:{key} 5min Automatic expiry

Endpoint Summary

Method Path Description
GET /api/ai/sandbox-profiles/:profileName Get sandbox draft (auto-seed if absent)
PUT /api/ai/sandbox-profiles/:profileName Save sandbox draft
POST /api/ai/sandbox-profiles/:profileName/reset Reset sandbox draft to production values
POST /api/ai/profiles/:profileName/apply Apply sandbox → production (requires Idempotency-Key, CASL system.manage_ai)
GET /api/ai/profiles/:profileName Get production defaults (read-only)