Files
lcbp3/specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md
T
admin ea5499123e
CI / CD Pipeline / build (push) Failing after 3m57s
CI / CD Pipeline / deploy (push) Has been skipped
690519:1631 224 to 226 AI #01
2026-05-19 16:31:50 +07:00

12 KiB

ADR-025: AI Tool Layer Architecture

Status: Accepted Date: 2026-05-19 Decision Makers: Development Team, System Architect, AI Integration Lead Related Documents:

หมายเหตุ: ADR นี้กำหนดสถาปัตยกรรมของ AI Tool Layer — bridge ระหว่าง AI Gateway (ADR-023A) กับ business modules (RFA, Drawing, Transmittal ฯลฯ) หลังจาก Intent Classifier (ADR-024) คืน Server-side Intent แล้ว


Context and Problem Statement

เมื่อ Intent Classifier (ADR-024) คืน Server-side Intent เช่น GET_RFA, GET_DRAWING แล้ว ระบบต้องการ layer ที่:

  1. Map Intent → business service call ภายใต้ CASL authorization
  2. คืน response ที่ LLM ใช้ได้ โดยไม่ expose INT primary key (ADR-019)
  3. Handle error อย่างมีโครงสร้าง เพื่อ AI Gateway ตัดสินใจ graceful degrade ได้
  4. Extensible — เพิ่ม tool ใหม่โดยไม่กระทบ Gateway logic

ความท้าทาย:

  • Business services มี CASL guard ที่ controller layer — Tool Layer ต้อง enforce permission เองโดยไม่ bypass
  • LLM prompt token budget จำกัด — ห้ามส่ง raw entity ที่มี field ไม่จำเป็น
  • Tool call อาจ fail ด้วยเหตุผลต่างกัน (permission, not found, service error) — Gateway ต้องรู้ reason เพื่อ route ถูก

Decision Drivers

  • CASL enforcement ต้องครบทุก tool call — ห้ามมี unguarded path เข้า business data
  • ADR-019 compliance — ห้าม expose INT id ใน LLM context
  • Predictable error handling — ADR-007 layered classification
  • Type safety — TypeScript strict, ไม่มี any
  • Testability — แต่ละ tool test แยกได้โดยไม่ต้องมี full Gateway

Considered Options

Option 1: LLM Function Calling (LLM เรียก tool เอง)

ให้ LLM ตัดสินใจว่าจะเรียก tool ไหน ผ่าน function calling protocol

Cons:

  • CASL enforcement เกิดที่ LLM runtime — ไม่ controllable
  • gemma4:e4b Q8_0 ไม่รองรับ function calling อย่างน่าเชื่อถือ
  • Non-deterministic — LLM อาจเรียกผิด tool

Option 2: AI Gateway เรียก Tool Layer ตรง (Server-side dispatch) (เลือก)

Intent Classifier คืน Intent → AI Gateway map กับ static registry → เรียก tool โดยตรง → inject result ใน LLM prompt

Pros:

  • CASL check อยู่ใน server code ทั้งหมด
  • Deterministic — Intent enum map กับ tool function แบบ 1:1
  • Testable, type-safe

Decision

เลือก Option 2: Server-side dispatch ผ่าน Static Tool Registry


Architecture Overview

User Query
    │
    ▼
Intent Classifier (ADR-024)
    │ returns { intent, confidence, params }
    ▼
AI Gateway
    │
    ├─ lookup AiToolRegistryService.getHandler(intent)
    │
    ▼
AiToolRegistryService (Static Map)
    │ TOOL_REGISTRY[intent] → tool function
    ▼
Tool Function (e.g. RfaToolService.getRfa)
    │ receives (params, requestUser: RequestUser)
    │ enforce CASL internally
    │ call business service
    │ map entity → *ToolResult DTO
    ▼
ToolCallResult<T>
    { ok: true, data: T }
  | { ok: false, reason, message }
    │
    ▼
AI Gateway
    │ ok=true  → inject data ใน LLM prompt → Ollama → response
    │ ok=false → handle by reason (log, fallback, error message)
    ▼
User Response

Tool Registry

Static Map

// File: backend/src/modules/ai/tool/ai-tool-registry.service.ts

type ToolHandler = (
  params: Record<string, unknown>,
  user: RequestUser,
) => Promise<ToolCallResult<unknown>>;

const TOOL_REGISTRY: Partial<Record<ServerIntent, ToolHandler>> = {
  [ServerIntent.GET_RFA]:           (p, u) => rfaToolService.getRfa(p, u),
  [ServerIntent.GET_DRAWING]:       (p, u) => drawingToolService.getDrawing(p, u),
  [ServerIntent.GET_TRANSMITTAL]:   (p, u) => transmittalToolService.getTransmittal(p, u),
  [ServerIntent.GET_CORRESPONDENCE]:(p, u) => correspondenceToolService.getCorrespondence(p, u),
  [ServerIntent.GET_CIRCULATION]:   (p, u) => circulationToolService.getCirculation(p, u),
  [ServerIntent.GET_RFA_DRAWINGS]:  (p, u) => rfaToolService.getRfaDrawings(p, u),
  [ServerIntent.SUMMARIZE_DOCUMENT]:(p, u) => documentToolService.summarize(p, u),
  [ServerIntent.LIST_OVERDUE]:      (p, u) => documentToolService.listOverdue(p, u),
};

Intent ที่ไม่มีใน registry (เช่น RAG_QUERY, SUGGEST_*, FALLBACK) → AI Gateway route ไปยัง pipeline อื่น (RAG หรือ error) โดยไม่ผ่าน Tool Layer


CASL Enforcement Pattern

แต่ละ tool รับ RequestUser และ check permission ก่อน query:

// File: backend/src/modules/ai/tool/rfa-tool.service.ts

async getRfa(
  params: GetRfaToolParams,
  user: RequestUser,
): Promise<ToolCallResult<RfaToolResult[]>> {
  // 1. CASL check
  const ability = this.caslFactory.createForUser(user);
  if (ability.cannot('read', 'Rfa')) {
    return { ok: false, reason: 'FORBIDDEN', message: 'ไม่มีสิทธิ์เข้าถึง RFA' };
  }

  // 2. projectPublicId scope (ADR-019, ADR-023A)
  if (!params.projectPublicId) {
    return { ok: false, reason: 'INVALID_PARAMS', message: 'ต้องระบุ projectPublicId' };
  }

  // 3. query business service
  try {
    const rfas = await this.rfaService.findByTool(params, user);
    return { ok: true, data: rfas.map(toRfaToolResult) };
  } catch (err) {
    this.logger.error('RfaToolService.getRfa failed', err);
    return { ok: false, reason: 'SERVICE_ERROR', message: 'เกิดข้อผิดพลาดในการดึงข้อมูล RFA' };
  }
}

ToolCallResult Type

// File: backend/src/modules/ai/tool/types/tool-call-result.type.ts

export type ToolCallReason = 'FORBIDDEN' | 'NOT_FOUND' | 'INVALID_PARAMS' | 'SERVICE_ERROR';

export type ToolCallResult<T> =
  | { ok: true; data: T }
  | { ok: false; reason: ToolCallReason; message: string };

LLM-Friendly ToolResult DTOs

Tool คืน *ToolResult DTO แทน raw entity — มีเฉพาะ fields ที่ LLM ต้องการ ไม่มี INT id:

// File: backend/src/modules/ai/tool/types/rfa-tool-result.type.ts

export interface RfaToolResult {
  publicId: string;
  rfaNumber: string;
  revisionCode: string;
  statusCode: string;       // business code: "1A", "1B", "PENDING" ฯลฯ
  drawingCount: number;
  submittedAt: string | null; // ISO string
  respondedAt: string | null;
  contractPublicId: string;
}

// File: backend/src/modules/ai/tool/types/drawing-tool-result.type.ts

export interface DrawingToolResult {
  publicId: string;
  drawingCode: string;
  drawingTitle: string;
  discipline: string;
  currentRevision: string;
  latestRfaPublicId: string | null;
  latestRfaStatus: string | null;
  contractPublicId: string;
}

กฎ:

  • ห้ามมี id: number ในทุก ToolResult type
  • ห้ามมี TypeORM entity relation objects
  • ใช้ publicId + business codes เท่านั้น
  • Date fields เป็น ISO string (ไม่ใช่ Date object)

LLM Prompt Integration (v1)

v1 ใช้ Tool result inject ใน prompt ตรง — ไม่ผสม RAG chunks (Phase 4):

[System]
คุณเป็น AI ผู้ช่วยระบบจัดการเอกสารก่อสร้าง NAP-DMS
ตอบโดยใช้ข้อมูลใน Context เท่านั้น ห้ามคาดเดา

[Context — Tool Result]
{{TOOL_RESULT_JSON}}

[User]
{{USER_QUERY}}
  • {{TOOL_RESULT_JSON}} = JSON.stringify(toolResult.data) (compact, ไม่มี indent)
  • Token budget สำหรับ tool result: สูงสุด 500 tokens
  • ถ้าผล tool เกิน 500 tokens → truncate ด้วย slice(0, N) + append "... (แสดงผลบางส่วน)"

Error Handling by Reason

AI Gateway handle ok=false ตาม reason:

reason action
FORBIDDEN คืน error message ให้ user + บันทึก audit log (security event)
NOT_FOUND คืน "ไม่พบข้อมูล" + แนะนำให้ตรวจสอบ parameter
INVALID_PARAMS คืน error พร้อมบอก field ที่ขาด
SERVICE_ERROR log error + คืน "ขณะนี้ระบบไม่สามารถดึงข้อมูลได้ กรุณาลองใหม่"

Module Structure

backend/src/modules/ai/
├── tool/
│   ├── ai-tool-registry.service.ts   ← static map + dispatch
│   ├── rfa-tool.service.ts
│   ├── drawing-tool.service.ts
│   ├── transmittal-tool.service.ts
│   ├── correspondence-tool.service.ts
│   ├── circulation-tool.service.ts
│   ├── document-tool.service.ts
│   └── types/
│       ├── tool-call-result.type.ts
│       ├── rfa-tool-result.type.ts
│       ├── drawing-tool-result.type.ts
│       └── ...

Audit & Observability

ทุก tool call บันทึกใน ai_audit_logs:

{
  "action": "tool_call",
  "intent": "GET_RFA",
  "params": { "projectPublicId": "...", "limit": 5 },
  "result": "ok" | "forbidden" | "not_found" | "service_error",
  "latencyMs": 45,
  "projectPublicId": "...",
  "userPublicId": "..."
}

Consequences

Positive

  • CASL enforcement อยู่ใน server code ทั้งหมด — ไม่มี unguarded path
  • ADR-019 compliance — INT id ไม่เคยปรากฏใน LLM context
  • Static registry อ่านง่าย, type-safe, test แยก tool ได้
  • Result wrapper ทำให้ Gateway handle error ได้อย่างมีโครงสร้าง

Negative

  • Tool ใหม่ต้องเพิ่ม entry ใน static registry (code deploy) — ต่างจาก Intent Pattern ที่ admin แก้ได้ runtime
  • *ToolResult DTO เพิ่ม maintenance surface (แต่ต้องมีเพื่อ ADR-019)

Risks

  • Tool result เกิน token budget → truncation อาจทำให้ LLM ตอบไม่ครบ → mitigate ด้วย pagination parameter ใน tool params (limit default = 5)
  • CASL factory ใน tool service อาจ duplicate กับ controller layer → mitigate ด้วย shared CaslAbilityFactory injection

Out of Scope (Phase 4)

  • Hybrid RAG + Tool — ผสม RAG chunks กับ tool result ใน prompt เดียว (CONTEXT.md Phase 4)
  • Streaming tool response — v1 คืนครั้งเดียว
  • Tool chaining — tool เรียก tool อื่น (ไม่รองรับ v1)

Migration Notes (ADR-009)

  • ไม่มี schema เพิ่มใน ADR-025 — ใช้ตาราง ai_audit_logs ที่มีอยู่แล้ว
  • สร้าง NestJS module AiToolModule แยกจาก AiModule หลัก และ import เข้า AiModule