Compare commits

...

23 Commits

Author SHA1 Message Date
admin 190b9a3af5 690612:1407 ADR-035-235 #01
CI / CD Pipeline / build (push) Successful in 4m7s
CI / CD Pipeline / deploy (push) Successful in 6m24s
2026-06-12 14:07:14 +07:00
admin 2c5a0b6aef 690612:0931 ADR-035-235 #00.2 [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-12 09:31:20 +07:00
admin d333d8a45a 690612:0900 ADR-035-235 #00.1 [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-12 09:00:28 +07:00
admin 0227b7b982 feat(ai-runtime): complete ai runtime policy refactor (ADR-035)
CI / CD Pipeline / build (push) Successful in 4m16s
CI / CD Pipeline / deploy (push) Successful in 11m51s
2026-06-12 08:07:15 +07:00
admin 71c5e88181 690611:1705 ADR-035-235 #00 [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-11 17:05:17 +07:00
admin cd7d20ccd4 690609:2223 Prepare to MOD AI flow [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-09 22:23:59 +07:00
admin 75d07b5ac9 690608:1520 ADR-035-135 #09
CI / CD Pipeline / build (push) Successful in 5m7s
CI / CD Pipeline / deploy (push) Successful in 4m40s
2026-06-08 15:20:34 +07:00
admin 52b96d01de 690608:0012 ADR-035-135 #08
CI / CD Pipeline / build (push) Successful in 5m5s
CI / CD Pipeline / deploy (push) Successful in 3m48s
2026-06-08 00:12:31 +07:00
admin a0f77ad121 690607:2321 ADR-035-135 #07
CI / CD Pipeline / build (push) Successful in 5m20s
CI / CD Pipeline / deploy (push) Successful in 6m42s
2026-06-07 23:21:55 +07:00
admin 16aab2279c 690606:1705 ADR-035-135 #06
CI / CD Pipeline / build (push) Successful in 5m19s
CI / CD Pipeline / deploy (push) Successful in 3m11s
2026-06-06 17:05:51 +07:00
admin 15dec6c3fc 690606:1538 ADR-035-135 #05
CI / CD Pipeline / build (push) Successful in 5m21s
CI / CD Pipeline / deploy (push) Successful in 3m14s
2026-06-06 15:38:10 +07:00
admin 33c3935164 690606:1441 ADR-035-135 #04.2
CI / CD Pipeline / build (push) Successful in 4m51s
CI / CD Pipeline / deploy (push) Successful in 7m18s
2026-06-06 14:41:26 +07:00
admin 6bcd1a5c58 690606:1413 ADR-035-135 #04.1
CI / CD Pipeline / build (push) Failing after 4m2s
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-06 14:13:59 +07:00
admin de4201d7d3 690606:1408 ADR-035-135 #04
CI / CD Pipeline / deploy (push) Has been cancelled
CI / CD Pipeline / build (push) Has been cancelled
2026-06-06 14:08:57 +07:00
admin e3e0de66e9 690606:1354 ADR-035-135 #03.1 [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-06 13:54:36 +07:00
admin 866fea7946 690606:1253 ADR-035-135 #03
CI / CD Pipeline / build (push) Successful in 7m27s
CI / CD Pipeline / deploy (push) Successful in 3m19s
2026-06-06 12:53:37 +07:00
admin 85c7415b8a 260606:1127 ADR-035-234 #2.1 [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-06 11:27:42 +07:00
admin ed1b302274 690606:1120 ADR-035-135 #02
CI / CD Pipeline / build (push) Successful in 5m11s
CI / CD Pipeline / deploy (push) Successful in 3m32s
2026-06-06 11:20:13 +07:00
admin 26cc71ce60 690605:2335 ADR-035-135 #1
CI / CD Pipeline / build (push) Successful in 4m54s
CI / CD Pipeline / deploy (push) Successful in 6m19s
2026-06-05 23:35:22 +07:00
admin 285c007dff Add specs/06-Decision-Records/ADR-035-addon.md
CI / CD Pipeline / build (push) Successful in 5m30s
CI / CD Pipeline / deploy (push) Successful in 1m32s
2026-06-05 19:20:56 +07:00
admin 03aa4efcf0 690605:1725 ADR-035-135 #0 [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped
2026-06-05 17:25:40 +07:00
admin 4f90ed688f 690605:1517 ADR-034-134 #11 fix Step 2 #02
CI / CD Pipeline / build (push) Successful in 5m21s
CI / CD Pipeline / deploy (push) Successful in 4m27s
2026-06-05 15:17:54 +07:00
admin 548dba6476 690605:1247 ADR-034-134 #11 fix Step 2
CI / CD Pipeline / build (push) Successful in 6m5s
CI / CD Pipeline / deploy (push) Successful in 6m30s
2026-06-05 12:47:38 +07:00
186 changed files with 11346 additions and 4074 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# NAP-DMS Project Context & Rules # NAP-DMS Project Context & Rules
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools) - For: Devin 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.10 | Last synced from repo: 2026-06-06
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) - 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) - 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)
+96
View File
@@ -112,3 +112,99 @@
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน | | "แก้ 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 | | "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ | | "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
---
## 🔌 MCP MariaDB Tools
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
- ตรวจสอบ data ใน production/staging
- Validate การเปลี่ยนแปลง schema ก่อน deploy
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|------|----------|------------------|
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
### การใช้งานร่วมกับ Development Flow
**เมื่อเขียน query ใหม่:**
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
**เมื่อเปลี่ยน schema (ADR-009):**
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
**เมื่อ debug ปัญหา database:**
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
2. เปรียบเทียบกับ spec และ data dictionary
3. ตรวจสอบ foreign keys และ constraints
### ข้อควรระวัง
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
---
## 🧠 MCP Memory Tools
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|------|----------|------------------|
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
### การใช้งานร่วมกับ Development Flow
**เมื่อบันทึก context ใหม่:**
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
**เมื่อค้นหา context:**
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
**เมื่อแก้ไข context:**
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
### ข้อควรระวัง
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
+2 -2
View File
@@ -27,7 +27,7 @@ n8n (Migration) → DMS API → BullMQ → Admin Desktop (Ollama) → Backend Va
| **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging | | **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging |
| **BullMQ Queues** | Backend (NestJS) | ai-realtime (RAG/Suggest), ai-batch (OCR/Extract/Embed) | | **BullMQ Queues** | Backend (NestJS) | ai-realtime (RAG/Suggest), ai-batch (OCR/Extract/Embed) |
| **Ollama Engine** | Admin Desktop (Desk-5439) | typhoon2.5-np-dms:latest (Main LLM) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding) | | **Ollama Engine** | Admin Desktop (Desk-5439) | typhoon2.5-np-dms:latest (Main LLM) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding) |
| **OCR Engine** | Admin Desktop (Desk-5439) | PaddleOCR + PyThaiNLP (Thai/English text extraction) | | **OCR Engine** | Admin Desktop (Desk-5439) | Tesseract OCR + Typhoon OCR (via Ollama) + PyThaiNLP (Thai/English text extraction) |
| **Orchestrator** | QNAP NAS (n8n) | Migration Phase orchestrator only (calls DMS API, never Ollama directly) | | **Orchestrator** | QNAP NAS (n8n) | Migration Phase orchestrator only (calls DMS API, never Ollama directly) |
## Backend Implementation (NestJS) ## Backend Implementation (NestJS)
@@ -118,7 +118,7 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => {
- **3-Model Config:** typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding) - **3-Model Config:** typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding)
- **PDF 3-Page Limit:** Classification/Tagging uses first 3 pages only (NOT RAG embedding) - **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 - **RAG Embedding:** Full document chunked at 512 tokens/64 tokens overlap
- **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else PaddleOCR - **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else Tesseract OCR (with Typhoon OCR option)
- **Embed Auto-Trigger:** AUTO after commit (parallel), gap covered by DB search - **Embed Auto-Trigger:** AUTO after commit (parallel), gap covered by DB search
- **Threshold Recalibration:** After 100-500 docs, based on ai_audit_logs analysis - **Threshold Recalibration:** After 100-500 docs, based on ai_audit_logs analysis
+1 -1
View File
@@ -42,7 +42,7 @@ init_agent_registry() {
[qwen]="Qwen Code" [qwen]="Qwen Code"
[opencode]="opencode" [opencode]="opencode"
[codex]="Codex CLI" [codex]="Codex CLI"
[windsurf]="Windsurf" [devin]="Devin"
[kilocode]="Kilo Code" [kilocode]="Kilo Code"
[auggie]="Auggie CLI" [auggie]="Auggie CLI"
[roo]="Roo Code" [roo]="Roo Code"
+7 -8
View File
@@ -99,14 +99,13 @@ find_feature_dir_by_prefix() {
local prefix="${BASH_REMATCH[1]}" local prefix="${BASH_REMATCH[1]}"
# Search for directories in specs/ that start with this prefix # Search for directories in specs/ that start with this prefix (supporting subdirectories)
local matches=() local matches=()
if [[ -d "$specs_dir" ]]; then if [[ -d "$specs_dir" ]]; then
for dir in "$specs_dir"/"$prefix"-*; do # ค้นหาโฟลเดอร์ที่ตรงกับ prefix ในระบบย่อย
if [[ -d "$dir" ]]; then while IFS= read -r -d '' dir; do
matches+=("$(basename "$dir")") matches+=("$dir")
fi done < <(find "$specs_dir" -maxdepth 3 -type d -name "${prefix}-*" -print0 2>/dev/null)
done
fi fi
# Handle results # Handle results
@@ -115,12 +114,12 @@ find_feature_dir_by_prefix() {
echo "$specs_dir/$branch_name" echo "$specs_dir/$branch_name"
elif [[ ${#matches[@]} -eq 1 ]]; then elif [[ ${#matches[@]} -eq 1 ]]; then
# Exactly one match - perfect! # Exactly one match - perfect!
echo "$specs_dir/${matches[0]}" echo "${matches[0]}"
else else
# Multiple matches - this shouldn't happen with proper naming convention # Multiple matches - this shouldn't happen with proper naming convention
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
echo "Please ensure only one spec directory exists per numeric prefix." >&2 echo "Please ensure only one spec directory exists per numeric prefix." >&2
echo "$specs_dir/$branch_name" # Return something to avoid breaking the script echo "${matches[0]}" # Return first match to avoid breaking the script
fi fi
} }
+6 -6
View File
@@ -30,12 +30,12 @@
# #
# 5. Multi-Agent Support # 5. Multi-Agent Support
# - Handles agent-specific file paths and naming conventions # - Handles agent-specific file paths and naming conventions
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI # - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Devin, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI
# - Can update single agents or all existing agent files # - Can update single agents or all existing agent files
# - Creates default Claude file if no agent files exist # - Creates default Claude file if no agent files exist
# #
# Usage: ./update-agent-context.sh [agent_type] # Usage: ./update-agent-context.sh [agent_type]
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|bob|qoder # Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|devin|kilocode|auggie|shai|q|bob|qoder
# Leave empty to update all existing agent files # Leave empty to update all existing agent files
set -e set -e
@@ -609,8 +609,8 @@ update_specific_agent() {
codex) codex)
update_agent_file "$AGENTS_FILE" "Codex CLI" update_agent_file "$AGENTS_FILE" "Codex CLI"
;; ;;
windsurf) devin)
update_agent_file "$WINDSURF_FILE" "Windsurf" update_agent_file "$DEVIN_FILE" "Devin"
;; ;;
kilocode) kilocode)
update_agent_file "$KILOCODE_FILE" "Kilo Code" update_agent_file "$KILOCODE_FILE" "Kilo Code"
@@ -681,8 +681,8 @@ update_all_existing_agents() {
found_agent=true found_agent=true
fi fi
if [[ -f "$WINDSURF_FILE" ]]; then if [[ -f "$DEVIN_FILE" ]]; then
update_agent_file "$WINDSURF_FILE" "Windsurf" update_agent_file "$DEVIN_FILE" "Devin"
found_agent=true found_agent=true
fi fi
+15 -16
View File
@@ -1,8 +1,8 @@
# `.agents/skills/` — LCBP3 Agent Skill Pack # `.agents/skills/` — LCBP3 Agent Skill Pack
**Version:** 1.9.0 | **Last Updated:** 2026-05-17 | **Total Skills:** 23 **Version:** 1.9.0 | **Last Updated:** 2026-06-07 | **Total Skills:** 24
Agent skills for AI-assisted development in **Windsurf IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools). Agent skills for AI-assisted development in **Devin IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
--- ---
@@ -14,6 +14,7 @@ Agent skills for AI-assisted development in **Windsurf IDE** (and compatible age
├── skills.md # Overview + dependency matrix + health monitoring ├── skills.md # Overview + dependency matrix + health monitoring
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill ├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
├── README.md # (this file) ├── README.md # (this file)
├── save-memory/ # Session log & project memory update
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories) ├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
├── next-best-practices/ # Frontend rules (Next.js 15+) ├── next-best-practices/ # Frontend rules (Next.js 15+)
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD) ├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
@@ -30,12 +31,10 @@ Each skill directory contains:
--- ---
## 🚀 How Windsurf Invokes These Skills ## 🚀 How Devin Invokes These Skills
Windsurf exposes two entry points: 1. **Skill tool** — Devin discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
2. **Slash commands**`.devin/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
1. **Skill tool** — Windsurf discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
2. **Slash commands**`.windsurf/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
Both paths end up executing the same `SKILL.md` instructions. Both paths end up executing the same `SKILL.md` instructions.
@@ -65,14 +64,14 @@ Use `/00-speckit.all` to run specify → clarify → plan → tasks → analyze
From repo root: From repo root:
| Script | Purpose | | Script | Purpose |
| --------------------------------------------------------- | ----------------------------------------------------------- | | ------------------------------------------------------ | ---------------------------------------------------------- |
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch | | `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` | | `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
| `./.agents/scripts/bash/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` | | `./.agents/scripts/bash/update-agent-context.sh devin` | Append tech entries to `AGENTS.md` |
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence | | `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check | | `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.windsurf/workflows/*.md` wrapper | | `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.devin/workflows/*.md` wrapper |
All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows. All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows.
@@ -97,7 +96,7 @@ To add a new skill:
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`. 1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`. 2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
3. Wrap with `.windsurf/workflows/NAME.md` so it becomes a slash command. 3. Wrap with `.devin/workflows/NAME.md` so it becomes a slash command.
4. Update [`skills.md`](./skills.md) dependency matrix. 4. Update [`skills.md`](./skills.md) dependency matrix.
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass. 5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
user_id INT NOT NULL, user_id INT NOT NULL,
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc. action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
file_id INT, file_id INT,
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3' model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
confidence DECIMAL(4,3), confidence DECIMAL(4,3),
input_hash CHAR(64), -- SHA-256 of input for replay detection input_hash CHAR(64), -- SHA-256 of input for replay detection
output_summary JSON, output_summary JSON,
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
user_id INT NOT NULL, user_id INT NOT NULL,
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc. action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
file_id INT, file_id INT,
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3' model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
confidence DECIMAL(4,3), confidence DECIMAL(4,3),
input_hash CHAR(64), -- SHA-256 of input for replay detection input_hash CHAR(64), -- SHA-256 of input for replay detection
output_summary JSON, output_summary JSON,
+198
View File
@@ -0,0 +1,198 @@
---
name: save-memory
description: บันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
version: 1.9.0
scope: project-management
depends-on: []
user-invocable: true
---
# บันทึก Memory (Save Memory)
Skill นี้ใช้สำหรับบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่ที่ reorganization แล้ว
## โครงสร้าง Memory ใหม่
```
memory/
├── README.md (index + overview)
├── mcp-tools.md (MCP MariaDB + Memory Tools)
└── project-memory-override.md (OS rules, Current Decisions, Environment, Next Session Focus)
specs/88-logs/
├── rollouts.md (Recent rollouts table)
└── session-YYYY-MM-DD-[topic].md (Session logs)
```
## ขั้นตอนการบันทึก Memory
### 1. สร้าง Session Log (ถ้ามีงาน session ใหม่)
เมื่อทำงาน session ใหม่ให้:
1. **สร้างไฟล์ session log ใหม่** ใน `specs/88-logs/`
- ชื่อไฟล์: `session-YYYY-MM-DD-[topic].md`
- ตัวอย่าง: `session-2026-06-07-memory-reorganization.md`
2. **บันทึกเนื้อหาใน session log**:
```markdown
# Session [N] — YYYY-MM-DD ([Topic])
## Summary
[สรุปสิ่งที่ทำใน session นี้]
## ปัญหาที่พบ (Root Cause)
[อธิบายปัญหาและสาเหตุ]
## การแก้ไข (Fix)
| ไฟล์ | การเปลี่ยนแปลง |
| -------------- | ---------------------- |
| [path/to/file] | [อธิบายการเปลี่ยนแปลง] |
## กฎที่ Lock แล้ว
[บันทึก pattern หรือ decision ที่ตกลง]
## Verification
[วิธีตรวจสอบว่างานสำเร็จ]
```
3. **อัปเดต `specs/88-logs/rollouts.md`**
- เพิ่ม entry ใหม่ในตาราง Recent Rollouts
- รูปแบบ: `| วันที่ | Version | รายการ | สถานะ |`
### 2. อัปเดต Project Memory (ถ้ามี decision ใหม่)
เมื่อมีการตัดสินใจสำคัญใหม่ให้:
1. **เปิดไฟล์ `memory/project-memory-override.md`**
2. **อัปเดตตาราง "Current Decisions (Locked)"**
- เพิ่ม entry ใหม่ถ้ามี decision ใหม่
- รูปแบบ: `| ID | Decision | ADR |`
3. **อัปเดต "Next Session Focus"**
- เพิ่มงานใหม่ถ้ามี
- ทำเครื่องหมาย `[ ]` สำหรับงานที่ยังไม่เสร็จ
- ทำเครื่องหมาย `[X]` สำหรับงานที่เสร็จแล้ว
4. **อัปเดต "Environment & Services"** (ถ้ามีการเปลี่ยนแปลง)
- อัปเดต URL, port, หรือ notes ถ้ามีการเปลี่ยน infrastructure
### 3. อัปเดต MCP Tools (ถ้ามี tools ใหม่)
เมื่อมี MCP tools ใหม่ให้:
1. **เปิดไฟล์ `memory/mcp-tools.md`**
2. **เพิ่ม tool ใหม่ในตาราง "Available Tools"**
- รูปแบบ: `| Tool | Purpose | Example Usage |`
3. **เพิ่ม usage example และ warnings** ถ้าจำเป็น
### 4. อัปเดต Root Documentation (ถ้ามีการเปลี่ยนแปลง)
เมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อเอกสารระดับ root ให้:
1. **ARCHITECTURE.md** — อัปเดตเมื่อ:
- เปลี่ยน architecture หลัก
- เพิ่ม/ลบ component สำคัญ
- เปลี่ยน data flow หรือ integration pattern
2. **CHANGELOG.md** — อัปเดตเมื่อ:
- Deploy version ใหม่
- เพิ่ม feature หรือ breaking change สำคัญ
- รูปแบบ: `## [version] (YYYY-MM-DD)` → `### feat(scope): description`
3. **CONTEXT.md** — อัปเดตเมื่อ:
- เปลี่ยน domain terminology หลัก
- เพิ่ม concept ใหม่ที่ใช้ทั่ว project
- อัปเดต glossary หรือ business rules
4. **CONTRIBUTING.md** — อัปเดตเมื่อ:
- เปลี่ยน workflow การทำงาน
- เพิ่ม/เปลี่ยน coding standards
- อัปเดต CI/CD process
5. **README.md** — อัปเดตเมื่อ:
- เปลี่ยน project structure
- เพิ่ม/เปลี่ยน installation steps
- อัปเดต feature overview หรือ tech stack
## Template สำหรับ Session Log
```markdown
# Session [N] — YYYY-MM-DD ([Topic])
## Summary
[สรุปสิ่งที่ทำใน session นี้ใน 1-2 ประโยค]
## ปัญหาที่พบ (Root Cause)
[อธิบายปัญหาและสาเหตุหลัก]
## การแก้ไข (Fix)
| ไฟล์ | การเปลี่ยนแปลง |
| -------------- | ---------------------- |
| `path/to/file` | [อธิบายการเปลี่ยนแปลง] |
## กฎที่ Lock แล้ว
[บันทึก pattern หรือ decision ที่ตกลงและไม่ควรเปลี่ยน]
## Verification
- [ ] [check 1]
- [ ] [check 2]
```
## ข้อควรระวัง
- **ห้าม** บันทึก rules ที่ซ้ำกับ specs/ (ADRs, glossary, guidelines)
- **ห้าม** บันทึก commands ที่ซ้ำกับ specs/05-Engineering-Guidelines/
- **ห้าม** บันทึก environment ที่ซ้ำกับ specs/04-Infrastructure-OPS/
- **ใช้** `specs/88-logs/` สำหรับ session history และ rollouts
- **ใช้** `memory/project-memory-override.md` สำหรับ OS rules, decisions, environment ที่ไม่มีใน specs
- **ใช้** `memory/mcp-tools.md` สำหรับ MCP tools documentation
- **อัปเดต Root Documentation** (ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, README.md) เฉพาะเมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อ project architecture, version, terminology, workflow หรือ structure
## ตัวอย่างการใช้งาน
### กรณีที่ 1: ทำงาน session ใหม่
```
1. สร้างไฟล์ specs/88-logs/session-2026-06-07-bug-fix.md
2. บันทึกปัญหา, การแก้ไข, verification
3. อัปเดต specs/88-logs/rollouts.md
```
### กรณีที่ 2: มี decision ใหม่
```
1. เปิด memory/project-memory-override.md
2. เพิ่ม entry ใหม่ในตาราง Current Decisions
3. อัปเดต Next Session Focus
```
### กรณีที่ 3: เปลี่ยน infrastructure
```
1. เปิด memory/project-memory-override.md
2. อัปเดตตาราง Environment & Services
3. อัปเดต Key Environment Variables ถ้าจำเป็น
```
### กรณีที่ 4: อัปเดต Root Documentation
```
1. ตรวจสอบว่ามีการเปลี่ยนแปลงที่ส่งผลต่อ ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, หรือ README.md
2. อัปเดตไฟล์ที่เกี่ยวข้องตามรูปแบบที่กำหนด
3. ตรวจสอบว่าการเปลี่ยนแปลงสอดคล้องกับ specs/ และ ADRs
```
+3 -2
View File
@@ -2,7 +2,7 @@
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
**Status**: Production Ready | **Last Updated**: 2026-05-17 | **Total Skills**: 23 **Status**: Production Ready | **Last Updated**: 2026-06-07 | **Total Skills**: 24
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md). > 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
@@ -76,6 +76,7 @@
| **speckit-status** | None | None | Progress tracking | | **speckit-status** | None | None | Progress tracking |
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync | | **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
| **speckit-checklist** | speckit-plan | None | Requirements validation | | **speckit-checklist** | speckit-plan | None | Requirements validation |
| **save-memory** | None | None | Session log & memory update |
| **nestjs-best-practices** | None | speckit-implement | Backend patterns | | **nestjs-best-practices** | None | speckit-implement | Backend patterns |
| **next-best-practices** | None | speckit-implement | Frontend patterns | | **next-best-practices** | None | speckit-implement | Frontend patterns |
| **speckit-security-audit** | None | speckit-reviewer | Security validation | | **speckit-security-audit** | None | speckit-reviewer | Security validation |
@@ -99,7 +100,7 @@
### Health Metrics ### Health Metrics
- **Total Skills**: 23 implemented - **Total Skills**: 24 implemented
- **Version Alignment**: v1.9.0 across all skills - **Version Alignment**: v1.9.0 across all skills
- **Template Coverage**: 100% for skills requiring templates - **Template Coverage**: 100% for skills requiring templates
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix - **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
+11
View File
@@ -0,0 +1,11 @@
---
description: บันทึก session log และอัปเดต project memory
---
# บันทึก Memory
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
```bash
skill save-memory
```
+2 -2
View File
@@ -4,8 +4,8 @@ trigger: always_on
# NAP-DMS Project Context & Rules # NAP-DMS Project Context & Rules
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools) - For: Devin 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.10 | Last synced from repo: 2026-06-06
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) - 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) - 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)
+96
View File
@@ -116,3 +116,99 @@ trigger: always_on
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน | | "แก้ 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 | | "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ | | "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
---
## 🔌 MCP MariaDB Tools
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
- ตรวจสอบ data ใน production/staging
- Validate การเปลี่ยนแปลง schema ก่อน deploy
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|------|----------|------------------|
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
### การใช้งานร่วมกับ Development Flow
**เมื่อเขียน query ใหม่:**
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
**เมื่อเปลี่ยน schema (ADR-009):**
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
**เมื่อ debug ปัญหา database:**
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
2. เปรียบเทียบกับ spec และ data dictionary
3. ตรวจสอบ foreign keys และ constraints
### ข้อควรระวัง
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
---
## 🧠 MCP Memory Tools
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|------|----------|------------------|
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
### การใช้งานร่วมกับ Development Flow
**เมื่อบันทึก context ใหม่:**
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
**เมื่อค้นหา context:**
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
**เมื่อแก้ไข context:**
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
### ข้อควรระวัง
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
+15 -16
View File
@@ -1,8 +1,8 @@
# `.agents/skills/` — LCBP3 Agent Skill Pack # `.agents/skills/` — LCBP3 Agent Skill Pack
**Version:** 1.9.0 | **Last Updated:** 2026-05-17 | **Total Skills:** 23 **Version:** 1.9.0 | **Last Updated:** 2026-06-07 | **Total Skills:** 24
Agent skills for AI-assisted development in **Windsurf IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools). Agent skills for AI-assisted development in **Devin IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
--- ---
@@ -14,6 +14,7 @@ Agent skills for AI-assisted development in **Windsurf IDE** (and compatible age
├── skills.md # Overview + dependency matrix + health monitoring ├── skills.md # Overview + dependency matrix + health monitoring
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill ├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
├── README.md # (this file) ├── README.md # (this file)
├── save-memory/ # Session log & project memory update
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories) ├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
├── next-best-practices/ # Frontend rules (Next.js 15+) ├── next-best-practices/ # Frontend rules (Next.js 15+)
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD) ├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
@@ -30,12 +31,10 @@ Each skill directory contains:
--- ---
## 🚀 How Windsurf Invokes These Skills ## 🚀 How Devin Invokes These Skills
Windsurf exposes two entry points: 1. **Skill tool** — Devin discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
2. **Slash commands**`.devin/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
1. **Skill tool** — Windsurf discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
2. **Slash commands**`.windsurf/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
Both paths end up executing the same `SKILL.md` instructions. Both paths end up executing the same `SKILL.md` instructions.
@@ -65,14 +64,14 @@ Use `/00-speckit.all` to run specify → clarify → plan → tasks → analyze
From repo root: From repo root:
| Script | Purpose | | Script | Purpose |
| --------------------------------------------------------- | ----------------------------------------------------------- | | ------------------------------------------------------ | ---------------------------------------------------------- |
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch | | `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` | | `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
| `./.agents/scripts/bash/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` | | `./.agents/scripts/bash/update-agent-context.sh devin` | Append tech entries to `AGENTS.md` |
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence | | `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check | | `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.windsurf/workflows/*.md` wrapper | | `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.devin/workflows/*.md` wrapper |
All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows. All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows.
@@ -97,7 +96,7 @@ To add a new skill:
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`. 1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`. 2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
3. Wrap with `.windsurf/workflows/NAME.md` so it becomes a slash command. 3. Wrap with `.devin/workflows/NAME.md` so it becomes a slash command.
4. Update [`skills.md`](./skills.md) dependency matrix. 4. Update [`skills.md`](./skills.md) dependency matrix.
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass. 5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
user_id INT NOT NULL, user_id INT NOT NULL,
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc. action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
file_id INT, file_id INT,
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3' model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
confidence DECIMAL(4,3), confidence DECIMAL(4,3),
input_hash CHAR(64), -- SHA-256 of input for replay detection input_hash CHAR(64), -- SHA-256 of input for replay detection
output_summary JSON, output_summary JSON,
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
user_id INT NOT NULL, user_id INT NOT NULL,
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc. action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
file_id INT, file_id INT,
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3' model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
confidence DECIMAL(4,3), confidence DECIMAL(4,3),
input_hash CHAR(64), -- SHA-256 of input for replay detection input_hash CHAR(64), -- SHA-256 of input for replay detection
output_summary JSON, output_summary JSON,
+198
View File
@@ -0,0 +1,198 @@
---
name: save-memory
description: บันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
version: 1.9.0
scope: project-management
depends-on: []
user-invocable: true
---
# บันทึก Memory (Save Memory)
Skill นี้ใช้สำหรับบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่ที่ reorganization แล้ว
## โครงสร้าง Memory ใหม่
```
memory/
├── README.md (index + overview)
├── mcp-tools.md (MCP MariaDB + Memory Tools)
└── project-memory-override.md (OS rules, Current Decisions, Environment, Next Session Focus)
specs/88-logs/
├── rollouts.md (Recent rollouts table)
└── session-YYYY-MM-DD-[topic].md (Session logs)
```
## ขั้นตอนการบันทึก Memory
### 1. สร้าง Session Log (ถ้ามีงาน session ใหม่)
เมื่อทำงาน session ใหม่ให้:
1. **สร้างไฟล์ session log ใหม่** ใน `specs/88-logs/`
- ชื่อไฟล์: `session-YYYY-MM-DD-[topic].md`
- ตัวอย่าง: `session-2026-06-07-memory-reorganization.md`
2. **บันทึกเนื้อหาใน session log**:
```markdown
# Session [N] — YYYY-MM-DD ([Topic])
## Summary
[สรุปสิ่งที่ทำใน session นี้]
## ปัญหาที่พบ (Root Cause)
[อธิบายปัญหาและสาเหตุ]
## การแก้ไข (Fix)
| ไฟล์ | การเปลี่ยนแปลง |
| -------------- | ---------------------- |
| [path/to/file] | [อธิบายการเปลี่ยนแปลง] |
## กฎที่ Lock แล้ว
[บันทึก pattern หรือ decision ที่ตกลง]
## Verification
[วิธีตรวจสอบว่างานสำเร็จ]
```
3. **อัปเดต `specs/88-logs/rollouts.md`**
- เพิ่ม entry ใหม่ในตาราง Recent Rollouts
- รูปแบบ: `| วันที่ | Version | รายการ | สถานะ |`
### 2. อัปเดต Project Memory (ถ้ามี decision ใหม่)
เมื่อมีการตัดสินใจสำคัญใหม่ให้:
1. **เปิดไฟล์ `memory/project-memory-override.md`**
2. **อัปเดตตาราง "Current Decisions (Locked)"**
- เพิ่ม entry ใหม่ถ้ามี decision ใหม่
- รูปแบบ: `| ID | Decision | ADR |`
3. **อัปเดต "Next Session Focus"**
- เพิ่มงานใหม่ถ้ามี
- ทำเครื่องหมาย `[ ]` สำหรับงานที่ยังไม่เสร็จ
- ทำเครื่องหมาย `[X]` สำหรับงานที่เสร็จแล้ว
4. **อัปเดต "Environment & Services"** (ถ้ามีการเปลี่ยนแปลง)
- อัปเดต URL, port, หรือ notes ถ้ามีการเปลี่ยน infrastructure
### 3. อัปเดต MCP Tools (ถ้ามี tools ใหม่)
เมื่อมี MCP tools ใหม่ให้:
1. **เปิดไฟล์ `memory/mcp-tools.md`**
2. **เพิ่ม tool ใหม่ในตาราง "Available Tools"**
- รูปแบบ: `| Tool | Purpose | Example Usage |`
3. **เพิ่ม usage example และ warnings** ถ้าจำเป็น
### 4. อัปเดต Root Documentation (ถ้ามีการเปลี่ยนแปลง)
เมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อเอกสารระดับ root ให้:
1. **ARCHITECTURE.md** — อัปเดตเมื่อ:
- เปลี่ยน architecture หลัก
- เพิ่ม/ลบ component สำคัญ
- เปลี่ยน data flow หรือ integration pattern
2. **CHANGELOG.md** — อัปเดตเมื่อ:
- Deploy version ใหม่
- เพิ่ม feature หรือ breaking change สำคัญ
- รูปแบบ: `## [version] (YYYY-MM-DD)``### feat(scope): description`
3. **CONTEXT.md** — อัปเดตเมื่อ:
- เปลี่ยน domain terminology หลัก
- เพิ่ม concept ใหม่ที่ใช้ทั่ว project
- อัปเดต glossary หรือ business rules
4. **CONTRIBUTING.md** — อัปเดตเมื่อ:
- เปลี่ยน workflow การทำงาน
- เพิ่ม/เปลี่ยน coding standards
- อัปเดต CI/CD process
5. **README.md** — อัปเดตเมื่อ:
- เปลี่ยน project structure
- เพิ่ม/เปลี่ยน installation steps
- อัปเดต feature overview หรือ tech stack
## Template สำหรับ Session Log
```markdown
# Session [N] — YYYY-MM-DD ([Topic])
## Summary
[สรุปสิ่งที่ทำใน session นี้ใน 1-2 ประโยค]
## ปัญหาที่พบ (Root Cause)
[อธิบายปัญหาและสาเหตุหลัก]
## การแก้ไข (Fix)
| ไฟล์ | การเปลี่ยนแปลง |
| -------------- | ---------------------- |
| `path/to/file` | [อธิบายการเปลี่ยนแปลง] |
## กฎที่ Lock แล้ว
[บันทึก pattern หรือ decision ที่ตกลงและไม่ควรเปลี่ยน]
## Verification
- [ ] [check 1]
- [ ] [check 2]
```
## ข้อควรระวัง
- **ห้าม** บันทึก rules ที่ซ้ำกับ specs/ (ADRs, glossary, guidelines)
- **ห้าม** บันทึก commands ที่ซ้ำกับ specs/05-Engineering-Guidelines/
- **ห้าม** บันทึก environment ที่ซ้ำกับ specs/04-Infrastructure-OPS/
- **ใช้** `specs/88-logs/` สำหรับ session history และ rollouts
- **ใช้** `memory/project-memory-override.md` สำหรับ OS rules, decisions, environment ที่ไม่มีใน specs
- **ใช้** `memory/mcp-tools.md` สำหรับ MCP tools documentation
- **อัปเดต Root Documentation** (ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, README.md) เฉพาะเมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อ project architecture, version, terminology, workflow หรือ structure
## ตัวอย่างการใช้งาน
### กรณีที่ 1: ทำงาน session ใหม่
```
1. สร้างไฟล์ specs/88-logs/session-2026-06-07-bug-fix.md
2. บันทึกปัญหา, การแก้ไข, verification
3. อัปเดต specs/88-logs/rollouts.md
```
### กรณีที่ 2: มี decision ใหม่
```
1. เปิด memory/project-memory-override.md
2. เพิ่ม entry ใหม่ในตาราง Current Decisions
3. อัปเดต Next Session Focus
```
### กรณีที่ 3: เปลี่ยน infrastructure
```
1. เปิด memory/project-memory-override.md
2. อัปเดตตาราง Environment & Services
3. อัปเดต Key Environment Variables ถ้าจำเป็น
```
### กรณีที่ 4: อัปเดต Root Documentation
```
1. ตรวจสอบว่ามีการเปลี่ยนแปลงที่ส่งผลต่อ ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, หรือ README.md
2. อัปเดตไฟล์ที่เกี่ยวข้องตามรูปแบบที่กำหนด
3. ตรวจสอบว่าการเปลี่ยนแปลงสอดคล้องกับ specs/ และ ADRs
```
+3 -2
View File
@@ -2,7 +2,7 @@
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
**Status**: Production Ready | **Last Updated**: 2026-05-17 | **Total Skills**: 23 **Status**: Production Ready | **Last Updated**: 2026-06-07 | **Total Skills**: 24
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md). > 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
@@ -76,6 +76,7 @@
| **speckit-status** | None | None | Progress tracking | | **speckit-status** | None | None | Progress tracking |
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync | | **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
| **speckit-checklist** | speckit-plan | None | Requirements validation | | **speckit-checklist** | speckit-plan | None | Requirements validation |
| **save-memory** | None | None | Session log & memory update |
| **nestjs-best-practices** | None | speckit-implement | Backend patterns | | **nestjs-best-practices** | None | speckit-implement | Backend patterns |
| **next-best-practices** | None | speckit-implement | Frontend patterns | | **next-best-practices** | None | speckit-implement | Frontend patterns |
| **speckit-security-audit** | None | speckit-reviewer | Security validation | | **speckit-security-audit** | None | speckit-reviewer | Security validation |
@@ -99,7 +100,7 @@
### Health Metrics ### Health Metrics
- **Total Skills**: 23 implemented - **Total Skills**: 24 implemented
- **Version Alignment**: v1.9.0 across all skills - **Version Alignment**: v1.9.0 across all skills
- **Template Coverage**: 100% for skills requiring templates - **Template Coverage**: 100% for skills requiring templates
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix - **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
+11
View File
@@ -0,0 +1,11 @@
---
description: บันทึก session log และอัปเดต project memory
---
# บันทึก Memory
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
```bash
skill save-memory
```
+142 -17
View File
@@ -1,12 +1,23 @@
# NAP-DMS Gemini Rules & Standards # NAP-DMS Project Context & Rules
- For: Gemini (Google AI Studio, Vertex AI, Antigravity, Gemini CLI) - For: Gemini (Google AI Studio, Vertex AI, Antigravity, Gemini CLI)
- Version: 1.9.8 | Last synced from AGENTS.md: 2026-06-02 - Version: 1.9.10 | Last synced from AGENTS.md: 2026-06-11
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) - 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) - 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)
--- ---
## 📦 Project Memory Override
For this repository (`E:\np-dms\lcbp3`), use project memory from:
`E:\np-dms\lcbp3\memory\project-memory-override.md`
**Before using global Gemini memory**, read this project memory file first when the task depends on prior repo context, conventions, decisions, or rollout history.
If project memory conflicts with global memory, prefer `memory/project-memory-override.md` for LCBP3-specific facts.
---
## 🧠 Role & Persona ## 🧠 Role & Persona
Act as **Senior Full Stack Developer** specialized in NestJS, Next.js, TypeScript, DMS. Focus: Data Integrity, Security, Maintainability, Performance. Act as **Senior Full Stack Developer** specialized in NestJS, Next.js, TypeScript, DMS. Focus: Data Integrity, Security, Maintainability, Performance.
@@ -126,7 +137,8 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | | **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-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-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-023A AI Model Rev.** | `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` | ✅ Active | 2-queue, RAG embed scope, OCR auto-detect (model stack superseded by ADR-034) |
| **ADR-034 Thai Model Stack** | `specs/06-Decision-Records/ADR-034-AI-model-change.md` | ✅ Active | typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) |
| **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-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-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-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 |
@@ -243,7 +255,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 5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days
6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints 6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints
7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan 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:e4b Q8_0` + `nomic-embed-text`; all inference via BullMQ (`ai-realtime` / `ai-batch`) 8. **AI Isolation (ADR-023/023A/034):** Ollama on Admin Desktop ONLY — NO direct DB/storage access; model stack `typhoon2.5-np-dms:latest` (main) + `typhoon-np-dms-ocr:latest` (OCR, keep_alive: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 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 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
@@ -529,7 +541,7 @@ When user asks about... check these files:
- [ ] **Qdrant Multi-tenancy:** `projectPublicId` filter enforced - [ ] **Qdrant Multi-tenancy:** `projectPublicId` filter enforced
- [ ] **Human-in-the-loop:** AI outputs validated before use - [ ] **Human-in-the-loop:** AI outputs validated before use
- [ ] **Audit Logging:** All AI interactions logged to `ai_audit_logs` - [ ] **Audit Logging:** All AI interactions logged to `ai_audit_logs`
- [ ] **2-Model Stack:** gemma4:e4b Q8_0 + nomic-embed-text verified - [ ] **Model Stack (ADR-034):** typhoon2.5-np-dms:latest + typhoon-np-dms-ocr:latest + nomic-embed-text verified
- [ ] **Dynamic Prompts (ADR-029):** Prompt templates loaded from `ai_prompts` DB, not hardcoded - [ ] **Dynamic Prompts (ADR-029):** Prompt templates loaded from `ai_prompts` DB, not hardcoded
**Performance & Complex Logic:** **Performance & Complex Logic:**
@@ -549,6 +561,108 @@ When user asks about... check these files:
--- ---
## 🔌 MCP MariaDB Tools
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
- ตรวจสอบ data ใน production/staging
- Validate การเปลี่ยนแปลง schema ก่อน deploy
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
| ---------------------------- | ------------------------------ | -------------------------------------------------- |
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
### การใช้งานร่วมกับ Development Flow
**เมื่อเขียน query ใหม่:**
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
**เมื่อเปลี่ยน schema (ADR-009):**
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
**เมื่อ debug ปัญหา database:**
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
2. เปรียบเทียบกับ spec และ data dictionary
3. ตรวจสอบ foreign keys และ constraints
### ข้อควรระวัง
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
---
## 🧠 MCP Memory Tools
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
| -------------------------- | -------------------------------------------- | -------------------------------------------- |
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
### การใช้งานร่วมกับ Development Flow
**เมื่อบันทึก context ใหม่:**
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
**เมื่อค้นหา context:**
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
**เมื่อแก้ไข context:**
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
### ข้อควรระวัง
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
---
## Agent skills ## Agent skills
### Issue tracker ### Issue tracker
@@ -582,15 +696,26 @@ This file is a **quick reference**. For detailed information:
## 🔄 Change Log ## 🔄 Change Log
| Version | Date | Changes | Updated By | | Version | Date | Changes | Updated By |
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
| 1.9.8 | 2026-06-02 | Added ADR-033 Active Model & OCR; ADR-031/032 status Draft→Active; ADR-032/033 in Tier 3 AI Runtime Layer & Specialized Work; Dynamic Prompt context trigger; AI Model/OCR Active Switch trigger; Dynamic Prompts checklist item | Windsurf AI | | 1.9.10 | 2026-06-11 | Synced from AGENTS.md: Added MCP MariaDB Tools section, MCP Memory Tools section; Added ADR-034 Thai Model Stack; Updated AI Isolation to ADR-034 typhoon2.5 model stack; Added Project Memory Override section; Updated Change Log | Windsurf AI |
| 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.9 | 2026-06-06 | ADR-034 Thai-Optimized AI Model Stack: typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR); model switching in ai-batch processor; AiSettingsService static constants; SQL delta; updated Key Spec Files + AI isolation rule | Windsurf AI |
| 1.9.6 | 2026-05-22 | Added ADR-024/025/026/027/028 to Key Spec Files; Tier 3 expanded (AI Runtime Layer + Migration Pipeline); Specialized Work updated; 6 new Context-Aware Triggers; Forbidden Actions + Domain Terminology synced from AGENTS.md v1.9.6 | Windsurf AI | | 1.9.8 | 2026-06-02 | Added ADR-033 Active Model & OCR; ADR-031/032 status Draft→Active; ADR-032/033 in Tier 3 AI Runtime Layer & Specialized Work; Dynamic Prompt context trigger; AI Model/OCR Active Switch trigger; Dynamic Prompts checklist item | 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.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.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev | | 1.9.6 | 2026-05-22 | Added ADR-024/025/026/027/028 to Key Spec Files; Tier 3 expanded (AI Runtime Layer + Migration Pipeline); Specialized Work updated; 6 new Context-Aware Triggers; Forbidden Actions + Domain Terminology synced from AGENTS.md v1.9.6 | Windsurf AI |
| 1.9.3 | 2026-05-15 | ADR-023A: Model revision — gemma4:9b+Typhoon→gemma4:e4b Q8_0 (2-model stack), BullMQ 2-queue split, RAG full-doc embed, OCR auto-detect, n8n→DMS API boundary, QdrantService multi-tenancy contract | 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.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI | | 1.9.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev |
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI | | 1.9.3 | 2026-05-15 | ADR-023A: Model revision — gemma4:9b+Typhoon→gemma4:e4b Q8_0 (2-model stack), BullMQ 2-queue split, RAG full-doc embed, OCR auto-detect, n8n→DMS API boundary, QdrantService multi-tenancy contract | Windsurf AI |
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI | | 1.9.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
| 1.8.5 | 2026-04-22 | Legacy version | Human Dev | | 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
| 1.8.5 | 2026-04-22 | Legacy version | Human Dev |
---
**To update this file:**
1. Edit relevant sections
2. Update Change Log above
3. Bump version number in header
4. Commit: `spec(agents): bump GEMINI.md to vX.X.X - <brief description>`
@@ -68,36 +68,36 @@ $script:NEW_FRAMEWORK = ''
$script:NEW_DB = '' $script:NEW_DB = ''
$script:NEW_PROJECT_TYPE = '' $script:NEW_PROJECT_TYPE = ''
function Write-Info { function Write-Info {
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[string]$Message [string]$Message
) )
Write-Host "INFO: $Message" Write-Host "INFO: $Message"
} }
function Write-Success { function Write-Success {
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[string]$Message [string]$Message
) )
Write-Host "$([char]0x2713) $Message" Write-Host "$([char]0x2713) $Message"
} }
function Write-WarningMsg { function Write-WarningMsg {
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[string]$Message [string]$Message
) )
Write-Warning $Message Write-Warning $Message
} }
function Write-Err { function Write-Err {
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[string]$Message [string]$Message
) )
Write-Host "ERROR: $Message" -ForegroundColor Red Write-Host "ERROR: $Message" -ForegroundColor Red
} }
function Validate-Environment { function Validate-Environment {
@@ -130,7 +130,7 @@ function Extract-PlanField {
# Lines like **Language/Version**: Python 3.12 # Lines like **Language/Version**: Python 3.12
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" $regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$"
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {
if ($_ -match $regex) { if ($_ -match $regex) {
$val = $Matches[1].Trim() $val = $Matches[1].Trim()
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }
} }
@@ -170,15 +170,15 @@ function Format-TechnologyStack {
return ($parts -join ' + ') return ($parts -join ' + ')
} }
function Get-ProjectStructure { function Get-ProjectStructure {
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$ProjectType [string]$ProjectType
) )
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
} }
function Get-CommandsForLanguage { function Get-CommandsForLanguage {
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$Lang [string]$Lang
@@ -191,12 +191,12 @@ function Get-CommandsForLanguage {
} }
} }
function Get-LanguageConventions { function Get-LanguageConventions {
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$Lang [string]$Lang
) )
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
} }
function New-AgentFile { function New-AgentFile {
@@ -223,7 +223,7 @@ function New-AgentFile {
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8
$content = $content -replace '\[PROJECT NAME\]',$ProjectName $content = $content -replace '\[PROJECT NAME\]',$ProjectName
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') $content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd')
# Build the technology stack string safely # Build the technology stack string safely
$techStackForTemplate = "" $techStackForTemplate = ""
if ($escaped_lang -and $escaped_framework) { if ($escaped_lang -and $escaped_framework) {
@@ -233,7 +233,7 @@ function New-AgentFile {
} elseif ($escaped_framework) { } elseif ($escaped_framework) {
$techStackForTemplate = "- $escaped_framework ($escaped_branch)" $techStackForTemplate = "- $escaped_framework ($escaped_branch)"
} }
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate $content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate
# For project structure we manually embed (keep newlines) # For project structure we manually embed (keep newlines)
$escapedStructure = [Regex]::Escape($projectStructure) $escapedStructure = [Regex]::Escape($projectStructure)
@@ -241,7 +241,7 @@ function New-AgentFile {
# Replace escaped newlines placeholder after all replacements # Replace escaped newlines placeholder after all replacements
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands $content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions $content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions
# Build the recent changes string safely # Build the recent changes string safely
$recentChangesForTemplate = "" $recentChangesForTemplate = ""
if ($escaped_lang -and $escaped_framework) { if ($escaped_lang -and $escaped_framework) {
@@ -251,7 +251,7 @@ function New-AgentFile {
} elseif ($escaped_framework) { } elseif ($escaped_framework) {
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}"
} }
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate $content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate
# Convert literal \n sequences introduced by Escape to real newlines # Convert literal \n sequences introduced by Escape to real newlines
$content = $content -replace '\\n',[Environment]::NewLine $content = $content -replace '\\n',[Environment]::NewLine
@@ -276,14 +276,14 @@ function Update-ExistingAgentFile {
$newTechEntries = @() $newTechEntries = @()
if ($techStack) { if ($techStack) {
$escapedTechStack = [Regex]::Escape($techStack) $escapedTechStack = [Regex]::Escape($techStack)
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
$newTechEntries += "- $techStack ($CURRENT_BRANCH)" $newTechEntries += "- $techStack ($CURRENT_BRANCH)"
} }
} }
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {
$escapedDB = [Regex]::Escape($NEW_DB) $escapedDB = [Regex]::Escape($NEW_DB)
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" $newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
} }
} }
$newChangeEntry = '' $newChangeEntry = ''
@@ -377,7 +377,7 @@ function Update-SpecificAgent {
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } 'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } 'devin' { Update-AgentFile -TargetFile $DEVIN_FILE -AgentName 'Devin' }
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
@@ -386,7 +386,7 @@ function Update-SpecificAgent {
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false } default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|devin|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false }
} }
} }
@@ -399,7 +399,7 @@ function Update-AllExistingAgents {
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true } if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true } if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true } if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true } if (Test-Path $DEVIN_FILE) { if (-not (Update-AgentFile -TargetFile $DEVIN_FILE -AgentName 'Devin')) { $ok = $false }; $found = $true }
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true } if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true } if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true } if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
+14
View File
@@ -0,0 +1,14 @@
# File: .vscode/setup-terminal.ps1
# Change Log:
# - 2026-06-07: Initial creation - bypass PSReadline history restoration
param(
[Parameter(Mandatory=$true)]
[string]$TargetPath
)
# Disable PSReadline history for this session
Set-PSReadlineOption -HistorySaveStyle SaveNothing
# Change to target directory
Set-Location $TargetPath
+167 -62
View File
@@ -1,7 +1,7 @@
# NAP-DMS Project Context & Rules # NAP-DMS Project Context & Rules
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools) - For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
- Version: 1.9.9 | Last synced from repo: 2026-06-03 - Version: 1.9.10 | Last synced from repo: 2026-06-06
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) - 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) - 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)
@@ -10,11 +10,11 @@
## 📦 Project Memory Override ## 📦 Project Memory Override
For this repository (`E:\np-dms\lcbp3`), use project memory from: For this repository (`E:\np-dms\lcbp3`), use project memory from:
`E:\np-dms\lcbp3\memory\agent-memory.md` `E:\np-dms\lcbp3\memory\project-memory-override.md`
**Before using global Codex memory**, read this project memory file first when the task depends on prior repo context, conventions, decisions, or rollout history. **Before using global Codex memory**, read this project memory file first when the task depends on prior repo context, conventions, decisions, or rollout history.
If project memory conflicts with global memory, prefer `memory/agent-memory.md` for LCBP3-specific facts. If project memory conflicts with global memory, prefer `memory/project-memory-override.md` for LCBP3-specific facts.
--- ---
@@ -137,8 +137,8 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | | **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-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-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-queue, RAG embed scope, OCR auto-detect (model stack superseded by ADR-034) | | **ADR-023A AI Model Rev.** | `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` | ✅ Active | 2-queue, RAG embed scope, OCR auto-detect (model stack superseded by ADR-034) |
| **ADR-034 Thai Model Stack** | `specs/06-Decision-Records/ADR-034-AI-model-change.md` | ✅ Active | typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) | | **ADR-034 Thai Model Stack** | `specs/06-Decision-Records/ADR-034-AI-model-change.md` | ✅ Active | typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) |
| **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-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-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-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 |
@@ -459,40 +459,40 @@ Full glossary: `specs/00-overview/00-02-glossary.md`
When user asks about... check these files: When user asks about... check these files:
| Request | Status | Files to Check | Expected Response | | Request | Status | 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 | | "สร้าง 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 | | "แก้ฟอร์ม 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 | | "เพิ่ม 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 | | "ตรวจสอบ 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 | | "สร้าง 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 | | "ตรวจสอบ 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 | | "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 | | "เพิ่ม 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 | | "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 | | "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 | | "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 | | "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 | | "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 | | "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) | | "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 และการล็อกเลขที่เอกสาร | | "ตรวจสอบ 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 | | "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 | | "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 | | "สร้าง 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 | | "ตรวจสอบ 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 | | "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 | | "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 | | "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 | | "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 | | "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 | | "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 |
| "AI Model / OCR Active Switch"| ✅ | `ADR-032`, `ADR-033`, `specs/200-fullstacks/233-ai-model-ocr-runner-management/` | Synchronous LLM switches, VRAM Release, sidecar API Key protection | | "AI Model / OCR Active Switch" | ✅ | `ADR-032`, `ADR-033`, `specs/200-fullstacks/233-ai-model-ocr-runner-management/` | Synchronous LLM switches, VRAM Release, sidecar API Key protection |
| "จัดการ document numbering" | ✅ | `ADR-002`, `specs/03-Data-and-Storage/03-04-document-numbering.md` | Redis Redlock + template system + preview/override workflows | | "จัดการ 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 | | "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 สำหรับเคสที่สาเหตุชัดเจน | | "แก้ 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 | | "ตรวจแอปจริง" | ✅ | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
| "งานค้าง / resume" | ✅ | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ | | "งานค้าง / resume" | ✅ | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
**Status Legend:** **Status Legend:**
@@ -501,6 +501,110 @@ When user asks about... check these files:
- 🔄 In development - 🔄 In development
- ❌ Not yet started - ❌ Not yet started
---
## 🔌 MCP MariaDB Tools
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
- ตรวจสอบ data ใน production/staging
- Validate การเปลี่ยนแปลง schema ก่อน deploy
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
| ---------------------------- | ------------------------------ | -------------------------------------------------- |
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
### การใช้งานร่วมกับ Development Flow
**เมื่อเขียน query ใหม่:**
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
**เมื่อเปลี่ยน schema (ADR-009):**
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
**เมื่อ debug ปัญหา database:**
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
2. เปรียบเทียบกับ spec และ data dictionary
3. ตรวจสอบ foreign keys และ constraints
### ข้อควรระวัง
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
---
## 🧠 MCP Memory Tools
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
### Available Tools
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
| -------------------------- | -------------------------------------------- | -------------------------------------------- |
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
### การใช้งานร่วมกับ Development Flow
**เมื่อบันทึก context ใหม่:**
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
**เมื่อค้นหา context:**
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
**เมื่อแก้ไข context:**
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
### ข้อควรระวัง
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
---
## 🛠️ Final Checklists ## 🛠️ Final Checklists
### 🔴 Tier 1 — CRITICAL (CI BLOCKER) ### 🔴 Tier 1 — CRITICAL (CI BLOCKER)
@@ -611,29 +715,30 @@ This file is a **quick reference**. For detailed information:
## 🔄 Change Log ## 🔄 Change Log
| Version | Date | Changes | Updated By | | Version | Date | Changes | Updated By |
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | | ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| 1.9.9 | 2026-06-03 | ADR-034 Thai-Optimized AI Model Stack: typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR); model switching in ai-batch processor; AiSettingsService static constants; SQL delta; updated Key Spec Files + AI isolation rule | Windsurf AI | | 1.9.10 | 2026-06-06 | Added MCP MariaDB Tools section with available tools (test_connection, show_databases, show_tables, describe_table, query, insert, update, delete), usage guidelines for development flow, and safety warnings for DDL operations; Added MCP Memory Tools section with Knowledge Graph management tools (create_entities, create_relations, add_observations, delete_entities, delete_relations, delete_observations, open_nodes, read_graph, search_nodes) for long-term context storage | Windsurf AI |
| 1.9.8 | 2026-06-02 | Added ADR-033 Active Model & OCR Runner Management; implemented Synchronous LLM switches, GPU Memory Auto-release, sidecar `X-API-Key` headers protection; updated Key Spec Files & Specialized Work AI runtime sections | Windsurf AI | | 1.9.9 | 2026-06-03 | ADR-034 Thai-Optimized AI Model Stack: typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR); model switching in ai-batch processor; AiSettingsService static constants; SQL delta; updated Key Spec Files + AI isolation rule | Windsurf AI |
| 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.8 | 2026-06-02 | Added ADR-033 Active Model & OCR Runner Management; implemented Synchronous LLM switches, GPU Memory Auto-release, sidecar `X-API-Key` headers protection; updated Key Spec Files & Specialized Work AI runtime sections | 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.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.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.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.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev | | 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.3 | 2026-05-15 | ADR-023A: Model revision — gemma4:9b+Typhoon→gemma4:e2b (2-model stack), BullMQ 2-queue split, RAG full-doc embed, OCR auto-detect, n8n→DMS API boundary, QdrantService multi-tenancy contract | 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 |
| 1.9.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI | | 1.9.3 | 2026-05-15 | ADR-023A: Model revision — gemma4:9b+Typhoon→gemma4:e2b (2-model stack), BullMQ 2-queue split, RAG full-doc embed, OCR auto-detect, n8n→DMS API boundary, QdrantService multi-tenancy contract | Windsurf AI |
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI | | 1.9.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI | | 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
| 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI | | 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
| 1.8.8 | 2026-04-14 | Workflow attachments (ADR-021) + step-attachment envelope fields | Windsurf AI | | 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI |
| 1.8.7 | 2026-04-14 | + ADR-021 Workflow Context integration, + ADR-021 Integration Work tier, + Transmittal/Circulation context triggers, updated ADR-020 status | Windsurf AI | | 1.8.8 | 2026-04-14 | Workflow attachments (ADR-021) + step-attachment envelope fields | Windsurf AI |
| 1.8.6 | 2026-04-10 | + DMS Workflow Engine Protocol, + Security & Integrity Audit Protocol, + 2 Context-Aware Triggers, ADR Status column, Forbidden Why column | Human Dev | | 1.8.7 | 2026-04-14 | + ADR-021 Workflow Context integration, + ADR-021 Integration Work tier, + Transmittal/Circulation context triggers, updated ADR-020 status | Windsurf AI |
| 1.8.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf AI | | 1.8.6 | 2026-04-10 | + DMS Workflow Engine Protocol, + Security & Integrity Audit Protocol, + 2 Context-Aware Triggers, ADR Status column, Forbidden Why column | Human Dev |
| 1.8.4 | 2026-03-24 | Phase 5.4→✅ DONE, Tailwind 3.4.3, ADR count(16), MariaDB UUID note | Windsurf AI | | 1.8.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf AI |
| 1.8.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI | | 1.8.4 | 2026-03-24 | Phase 5.4→✅ DONE, Tailwind 3.4.3, ADR count(16), MariaDB UUID note | Windsurf AI |
| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI | | 1.8.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI |
| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet | | 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI |
| 1.8.0 | 2026-03-19 | + Security overrides, + UAT criteria reference | Human Dev | | 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet |
| 1.7.2 | 2026-03-15 | + AI Boundary rules (ADR-018) | Gemini Pro | | 1.8.0 | 2026-03-19 | + Security overrides, + UAT criteria reference | Human Dev |
| 1.7.2 | 2026-03-15 | + AI Boundary rules (ADR-018) | Gemini Pro |
--- ---
+32 -1
View File
@@ -1,5 +1,36 @@
# Version History # Version History
## 1.9.10 (2026-06-08)
### bugfix(ai): Fix LLM JSON Response Truncation in OCR Sandbox & Migration
#### Summary
แก้ไขปัญหา LLM JSON Response Truncation ใน OCR Sandbox Step 2 และ Migration Pipeline โดยการขยายขนาดหน้าต่างบริบท `num_ctx` ของ Ollama เป็น `16384` สำหรับงานสกัดข้อมูล (ดำเนินการแก้ไขโดย AGY Gemini 3.5 Flash (Medium))
#### Changes
- **Ollama Context Window Expansion**: เพิ่มพารามิเตอร์ `num_ctx: 16384` ใน `processSandboxExtract` และ `processSandboxAiExtract` สำหรับงานสกัดข้อมูลใน Sandbox เพื่อรองรับข้อมูลขนาดใหญ่ (สูงสุด 15,000 ตัวอักษร)
- **Migration Pipeline Hardening**: อัปเดต `processMigrateDocument` ให้บังคับส่ง `format: 'json'` และ `options: { num_ctx: 16384, num_predict: 4096 }` ให้ตรงกับพฤติกรรมของ Sandbox
- **Regression Tests**: ปรับปรุง Unit Test ใน `ai-batch.processor.spec.ts` เพื่อให้สอดคล้องกับพารามิเตอร์การเรียก Ollama แบบใหม่
---
## 1.9.9 (2026-06-06)
### feat(ai): LLM JSON Parse Failure & VRAM Fix (ADR-035-135)
#### Summary
แก้ไขข้อผิดพลาด JSON Parse และหน่วยความจำ VRAM โดยเพิ่มระบบ retry logic และปรับปรุง VRAM switching
#### Changes
- **JSON Parse Retry**: เพิ่มระบบ retry logic (2 attempts) สำหรับกรณี JSON parse fail พร้อมแสดงรายละเอียด log
- **VRAM limit**: ปรับแต่งค่า `keep_alive=0` สำหรับ OCR model และแก้ปัญหาความจำรั่วไหลใน Node.js/ESLint heap
---
## 1.9.8 (2026-06-02) ## 1.9.8 (2026-06-02)
### feat(ai): AI Model Swapping, GPU Unloading & OCR Security (ADR-033) ### feat(ai): AI Model Swapping, GPU Unloading & OCR Security (ADR-033)
@@ -168,7 +199,7 @@
#### Summary #### Summary
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Devin/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
#### Changes #### Changes
+104 -54
View File
@@ -62,8 +62,8 @@ _Avoid_: Tool, LLM tool, LangChain tool
_Avoid_: Rule engine, NLU pipeline _Avoid_: Rule engine, NLU pipeline
**LLM Fallback**: **LLM Fallback**:
ชั้นที่สอง of Intent Classifier — synchronous Ollama call (gemma4:e4b Q8_0) เมื่อ Pattern Layer ไม่ match, ใช้ semaphore max=3 ชั้นที่สอง of Intent Classifier — synchronous Ollama call (`np-dms-ai`) เมื่อ Pattern Layer ไม่ match, ใช้ semaphore max=3; runtime model tag เป็น ops detail ใน Modelfile เท่านั้น
_Avoid_: BullMQ-based classification, async intent routing _Avoid_: BullMQ-based classification, async intent routing, gemma4:e4b (runtime tag ไม่ใช่ domain term)
### AI ### AI
@@ -92,8 +92,8 @@ Container สำเร็จรูป (FastAPI Sidecar บน Desk-5439) ทำ
_Avoid_: OCR microservice (ที่ขาดการป้องกัน) _Avoid_: OCR microservice (ที่ขาดการป้องกัน)
**Prompt Version**: **Prompt Version**:
Immutable snapshot ของ prompt template ใน `ai_prompts` table — ทุกครั้งที่ admin กด "บันทึก" จะสร้าง version ใหม่ (version_number เพิ่มทีละ 1) version เก่ายังอยู่ใน history ลบได้ยกเว้น active version (ADR-029) Immutable snapshot ของ prompt template ใน `ai_prompts` table — ทุกครั้งที่ admin กด "บันทึก" จะสร้าง version ใหม่ (version*number เพิ่มทีละ 1) version เก่ายังอยู่ใน history ลบได้ยกเว้น active version (ADR-029)
_Avoid_: Prompt config, Prompt setting, Editable prompt \_Avoid*: Prompt config, Prompt setting, Editable prompt
**Active Prompt**: **Active Prompt**:
Prompt Version ที่มี `is_active = 1` ต่อ `prompt_type` — ใช้โดยทั้ง OCR Sandbox และ `processMigrateDocument` พร้อมกัน, cached ใน Redis TTL 60s; invalidated เมื่อ admin activate version อื่น (ADR-029) Prompt Version ที่มี `is_active = 1` ต่อ `prompt_type` — ใช้โดยทั้ง OCR Sandbox และ `processMigrateDocument` พร้อมกัน, cached ใน Redis TTL 60s; invalidated เมื่อ admin activate version อื่น (ADR-029)
@@ -107,6 +107,18 @@ _Avoid_: Prompt string, Prompt text (ambiguous)
ทุก AI suggestion ต้องผ่านการ accept/reject โดย user ก่อนกลายเป็น state change — บันทึกใน `ai_audit_logs` ทุก AI suggestion ต้องผ่านการ accept/reject โดย user ก่อนกลายเป็น state change — บันทึกใน `ai_audit_logs`
_Avoid_: Auto-apply, AI auto-execute _Avoid_: Auto-apply, AI auto-execute
**Execution Profile** _(admin-facing only)_:
Policy ภายในที่ backend กำหนดให้ AI job อัตโนมัติจาก `job.type` — ไม่มี caller input; มี 4 ค่า: `interactive` (ตอบเร็ว), `standard` (ทั่วไป), `quality` (แม่นยำสูง, ภาษาไทย), `deep-analysis` (context ยาว) — admin เห็นใน audit log และ Admin Console; ค่า default ใน `docs/ai-profiles.md`, calibrate ได้ผ่าน Admin Console (ADR-029)
_Avoid_: executionProfile (API field), model selection, profile override
**Canonical Model Identity**:
ชื่อ `np-dms-ai` (LLM หลัก) และ `np-dms-ocr` (OCR) — ชื่อที่แสดงต่อทุก layer ที่มนุษย์อ่าน (API response, audit log, Admin Console) แทนชื่อ runtime จริง (เช่น `typhoon2.5-np-dms:latest`)
_Avoid_: runtime model name, model tag, Ollama model name (ใช้ใน ops เท่านั้น)
**OCR Residency**:
Policy ที่ตัดสินว่า `np-dms-ocr` จะถูก unload ออกจาก VRAM หลัง job เสร็จทันที (`keep_alive: 0`) หรือเก็บไว้ช่วงหนึ่ง (`keep_alive > 0`) — คำนวณ dynamic จาก VRAM headroom ณ ขณะนั้น; ถ้า `deep-analysis` active หรือ VRAM pressure สูง → unload ทันทีเสมอ
_Avoid_: OCR keep_alive setting, fixed keep_alive, OCR cache
**AI Tool Layer**: **AI Tool Layer**:
Bridge layer ระหว่าง AI Gateway กับ business modules — dispatch โดย AI Gateway หลังได้ Server-side Intent, enforce CASL ภายใน tool เอง (ADR-025) Bridge layer ระหว่าง AI Gateway กับ business modules — dispatch โดย AI Gateway หลังได้ Server-side Intent, enforce CASL ภายใน tool เอง (ADR-025)
_Avoid_: LLM function calling, Tool plugin, LangChain tool _Avoid_: LLM function calling, Tool plugin, LangChain tool
@@ -139,23 +151,23 @@ _Avoid_: Throw exception from tool, Untyped error
## AI authority scope (resolved) ## AI authority scope (resolved)
| Scope | Allowed? | Mechanism | | Scope | Allowed? | Mechanism |
| :--- | :--- | :--- | | :------------------------------------------------- | :------- | :-------------------------------------------------------------- |
| Read-only insight (summarise, explain) | ✅ | AI Gateway → service → CASL-guarded query | | Read-only insight (summarise, explain) | ✅ | AI Gateway → service → CASL-guarded query |
| Suggest action (UI shows button) | ✅ | Response shape `{ suggestedAction, confidence, reasoning }` | | Suggest action (UI shows button) | ✅ | Response shape `{ suggestedAction, confidence, reasoning }` |
| Auto-trigger side-effects (notify, alert, comment) | ✅ | BullMQ job (ADR-008); MUST NOT change workflow state | | Auto-trigger side-effects (notify, alert, comment) | ✅ | BullMQ job (ADR-008); MUST NOT change workflow state |
| Auto-execute workflow transition | ❌ | Forbidden Tier 1 — every transition needs human `actor_user_id` | | Auto-execute workflow transition | ❌ | Forbidden Tier 1 — every transition needs human `actor_user_id` |
## Upload pipeline (resolved) ## Upload pipeline (resolved)
| Stage | Mode | Queue | Notes | | Stage | Mode | Queue | Notes |
| :--- | :--- | :--- | :--- | | :------------------------------------------------------------------- | :---- | :------------ | :------------------------------------------------------- |
| 1. Upload → **temp** + return `tempUploadId` | Sync | — | <1s | | 1. Upload → **temp** + return `tempUploadId` | Sync | — | <1s |
| 2. ClamAV scan + MIME whitelist | Sync | — | block ก่อน commit (ADR-016) | | 2. ClamAV scan + MIME whitelist | Sync | — | block ก่อน commit (ADR-016) |
| 3. User commit (metadata + ย้าย permanent) | Sync | — | สร้าง `documents` row, ใช้ `Idempotency-Key` | | 3. User commit (metadata + ย้าย permanent) | Sync | — | สร้าง `documents` row, ใช้ `Idempotency-Key` |
| 4. **Classification/Tagging** (3 pages แรก) | Async | `ai-realtime` | suggest metadata; user accept/reject (human-in-the-loop) | | 4. **Classification/Tagging** (3 pages แรก) | Async | `ai-realtime` | suggest metadata; user accept/reject (human-in-the-loop) |
| 5. **RAG Embedding** (full doc; OCR ถ้า text-layer < 100 chars/page) | Async | `ai-batch` | trigger AUTO หลัง commit, parallel กับ stage 4 | | 5. **RAG Embedding** (full doc; OCR ถ้า text-layer < 100 chars/page) | Async | `ai-batch` | trigger AUTO หลัง commit, parallel กับ stage 4 |
| 6. Qdrant upsert + `ai_document_chunks.embedded_at = NOW()` | Async | (worker) | gap = DB full-text fallback | | 6. Qdrant upsert + `ai_document_chunks.embedded_at = NOW()` | Async | (worker) | gap = DB full-text fallback |
**กฎ:** **กฎ:**
@@ -167,14 +179,14 @@ _Avoid_: Throw exception from tool, Untyped error
## Identifier rules (ADR-019, AI subsystem) ## Identifier rules (ADR-019, AI subsystem)
| Boundary | Identifier ที่ใช้ | | Boundary | Identifier ที่ใช้ |
| :--- | :--- | | :--------------------------------------------- | :------------------------------------------------------------------------ |
| API (FE ↔ AI Gateway) | `publicId` (UUIDv7 string) เท่านั้น; INT `id` มี `@Exclude()` | | API (FE ↔ AI Gateway) | `publicId` (UUIDv7 string) เท่านั้น; INT `id` มี `@Exclude()` |
| Server-side Intent payload | `*PublicId` strings; service แปลงเป็น INT FK ภายใน | | Server-side Intent payload | `*PublicId` strings; service แปลงเป็น INT FK ภายใน |
| LLM context (prompt) | `publicId` + business code (`rfa_number`, `drawing_code`) ห้ามเห็น INT | | LLM context (prompt) | `publicId` + business code (`rfa_number`, `drawing_code`) ห้ามเห็น INT |
| Qdrant payload | `project_public_id`, `document_public_id`, `chunk_public_id` | | Qdrant payload | `project_public_id`, `document_public_id`, `chunk_public_id` |
| `ai_document_chunks` internals | INT FK ใช้ได้ภายใน DB; identity ที่ expose = `chunk_public_id BINARY(16)` | | `ai_document_chunks` internals | INT FK ใช้ได้ภายใน DB; identity ที่ expose = `chunk_public_id BINARY(16)` |
| Business codes (e.g. `drawing_code = "A-101"`) | รับเป็น input ได้ แต่ resolve → `publicId` ก่อน query | | Business codes (e.g. `drawing_code = "A-101"`) | รับเป็น input ได้ แต่ resolve → `publicId` ก่อน query |
**Forbidden (Tier 1 CI blocker):** **Forbidden (Tier 1 CI blocker):**
@@ -195,28 +207,47 @@ _Avoid_: Throw exception from tool, Untyped error
## Glossary Updates (from ADR-034) ## Glossary Updates (from ADR-034)
| Term | Definition | Avoid | | Term | Definition | Avoid |
|------|------------|-------| | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| **Thai-Optimized Model** | โมเดล AI ที่ถูก fine-tune มาสำหรับภาษาไทยโดยเฉพาะ (เช่น Typhoon series จาก SCB10X) | Generic model, English-only model | | **Thai-Optimized Model** | โมเดล AI ที่ถูก fine-tune มาสำหรับภาษาไทยโดยเฉพาะ (เช่น Typhoon series จาก SCB10X) | Generic model, English-only model |
| **Model Unload/Load** | กระบวนการยกเลิกโหลดโมเดลจาก VRAM และโหลดโมเดลใหม่เข้าไปแทน เพื่อสลับการใช้งานระหว่างโมเดลต่างๆ | Model switching (ambiguous), Hot swap | | **Model Unload/Load** | กระบวนการยกเลิกโหลดโมเดลจาก VRAM และโหลดโมเดลใหม่เข้าไปแทน เพื่อสลับการใช้งานระหว่างโมเดลต่างๆ | Model switching (ambiguous), Hot swap |
| **Cold Start Penalty** | ความล่าช้า 5-15 วินาทีที่เกิดจากการโหลดโมเดล weights เข้า VRAM หลังจากโมเดลถูก unload (keep_alive: 0) | Initial delay, First-run latency | | **Cold Start Penalty** | ความล่าช้า 5-15 วินาทีที่เกิดจากการโหลดโมเดล weights เข้า VRAM หลังจากโมเดลถูก unload (keep_alive: 0) | Initial delay, First-run latency |
| **Canonical AI Model Identity** | ชื่อโมเดลหลักที่ระบบ backend, admin console และเอกสารสถาปัตยกรรมใช้อ้างอิงร่วมกันเป็น source of truth เดียว | Alias-only model name, temporary deploy tag |
| **Adaptive OCR Residency** | นโยบาย keep_alive ของ OCR model ที่ปรับตาม VRAM headroom และ active model ขณะนั้น แทนการค้างหรือ unload แบบตายตัว | Fixed keep_alive, always-resident OCR |
| **Execution Profile** | สัญญาณเชิงนโยบายที่ caller ส่งมาเพื่อบอกระดับความเร็ว/ความแม่นยำ/บริบทที่ต้องการ โดย backend map ต่อไปเป็น model และ parameters ที่อนุญาต | Free-form model key, direct model override |
| **Canonical Profile Set** | ชุดค่า `Execution Profile` มาตรฐานที่คงที่ระดับ contract เช่น `fast`, `balanced`, `thai-accurate`, `large-context` แทนการแตก profile ตาม internal pipeline | Job-specific routing key, per-endpoint profile taxonomy |
| **Policy-Enforced Profile Override** | กฎที่ backend มีสิทธิ์บังคับ profile สำหรับงานที่มีผลต่อข้อมูลหรือ metadata โดยไม่ยึดค่าที่ caller ส่งมา | Caller-controlled quality for write-affecting jobs, advisory-only governance |
| **LLM-First GPU Ownership** | นโยบายจัดลำดับสิทธิ์ VRAM ที่ให้ main LLM และ OCR path มาก่อน embedding/reranking; retrieval side ใช้ GPU ได้เฉพาะเมื่อมี headroom ผ่าน policy | Flat shared GPU pool, equal-priority GPU consumers |
| **CPU Fallback Retrieval** | พฤติกรรม degrade ของ embedding/reranking ที่สลับกลับไปใช้ CPU ทันทีเมื่อ GPU headroom ไม่พอ โดยไม่รอคิว GPU | GPU wait queue for retrieval, hard failure on low VRAM |
| **Selective Realtime Concurrency** | นโยบายเพิ่ม concurrency ของ `ai-realtime` ได้เฉพาะ job type ที่ไม่แตะ OCR path หรือ model switching; pause/resume coordination หลักยังคงอยู่ | Global realtime concurrency uplift, scheduler rewrite |
| **Lightweight Realtime Job** | งานใน `ai-realtime` ที่ไม่เรียก OCR, ไม่บังคับ model switch, และไม่พึ่ง GPU-heavy generation path จึงมีสิทธิ์อยู่ใน concurrency uplift set | RAG query, OCR-triggering job, GPU-heavy generation |
| **Generation-Centric RAG Query** | การจัดประเภท `rag-query` ว่าเป็นงาน generation เป็นหลัก โดย retrieval ทำหน้าที่เตรียม context และยอม degrade ได้ | Retrieval-first RAG, search-only job |
| **Restricted Large-Context Profile** | โปรไฟล์ `large-context` เป็นความสามารถพิเศษที่จำกัดใช้เฉพาะ admin หรือ special workflows ที่ backend อนุญาต ไม่ใช่ตัวเลือกทั่วไปของ `rag-query` | Public long-context option, caller-driven context inflation |
| **Big Bang AI Runtime Rollout** | การเปลี่ยน runtime policy, model identity, และ GPU scheduling หลายส่วนพร้อมกันในรอบ deploy เดียว เพราะระบบยังไม่เปิด production | Phase-gated rollout, incremental policy cutover |
| **Big Bang Cutover Gate** | เกณฑ์ผ่านก่อน cutover ที่บังคับให้ policy contract, model switching, adaptive OCR residency, และ RAG fallback ต้องผ่านครบทั้งชุด ไม่รับ partial success | Best-effort rollout, partial completion gate |
| **Executable-First Verification** | เกณฑ์ยืนยันผลหลักของ AI runtime rollout ต้องอิง test, log, metric, หรือ trace ที่รันซ้ำได้ แต่แต่ละแกนต้องมี manual validation path สำหรับยืนยันพฤติกรรมเชิงใช้งานจริงประกบเสมอ | Manual-only signoff, unverifiable smoke check |
| **Single-Name Canonical Model Policy** | เมื่อประกาศ canonical model identity ใหม่ ชื่อเดียวกันต้องถูกใช้สอดคล้องกันทุกชั้นของระบบที่ผู้ใช้และนักพัฒนาเห็น ส่วนชื่อ base runtime จริงเป็น implementation detail ใน ops/runtime internals เท่านั้น | Dual naming, mixed canonical and base model labels |
| **Canonical OCR Identity** | OCR model ต้องใช้ชื่อ canonical เดียวทุกชั้นของระบบเช่น `np-dms-ocr` โดยไม่เปิดชื่อ runtime เดิมเป็น public/internal contract หลัก | Legacy OCR runtime label as primary name, mixed OCR naming |
| **Profile-Only Parameter Governance** | API caller ส่งได้เพียง `Execution Profile`; ค่า temperature, top_p, max tokens และ runtime parameters จริงถูกกำหนดโดย backend policy เท่านั้น | Caller parameter override, free-form runtime tuning |
| **Integrated Retrieval Acceleration Policy** | การเร่งความเร็ว retrieval เช่น BGE embedding/reranking บน GPU เป็นส่วนหนึ่งของ AI runtime resource policy เดียวกับ main model และ OCR ไม่ใช่งาน optimization แยกอิสระ | Standalone retrieval tuning, separate GPU policy for RAG only |
--- ---
## System readiness summary (resolved) ## System readiness summary (resolved)
| Component | สถานะ | หมายเหตุ | | Component | สถานะ | หมายเหตุ |
| :--- | :--- | :--- | | :---------------------------- | :------- | :---------------------------------------------------------------------------------------------- |
| **Infrastructure** | ✅ พร้อม | NestJS + Next.js + MariaDB + Redis + Elasticsearch | | **Infrastructure** | ✅ พร้อม | NestJS + Next.js + MariaDB + Redis + Elasticsearch |
| **Workflow Engine** | ✅ พร้อม | DSL-based, ADR-001/021 | | **Workflow Engine** | ✅ พร้อม | DSL-based, ADR-001/021 |
| **AI Boundary** | ✅ พร้อม | ADR-023A — Ollama isolation, no direct DB access | | **AI Boundary** | ✅ พร้อม | ADR-023A — Ollama isolation, no direct DB access |
| **RAG Pipeline** | ✅ พร้อม | Qdrant service ป้องกันการรั่วไหลระหว่างโปรเจกต์ | | **RAG Pipeline** | ✅ พร้อม | Qdrant service ป้องกันการรั่วไหลระหว่างโปรเจกต์ |
| **Intent Router** | ✅ พร้อม | ADR-024 Active — Intent Classifier (Pattern→LLM Fallback) ทำงานเสร็จสมบูรณ์ | | **Intent Router** | ✅ พร้อม | ADR-024 Active — Intent Classifier (Pattern→LLM Fallback) ทำงานเสร็จสมบูรณ์ |
| **AI Tool Layer** | ✅ พร้อม | ADR-025 Active — Tool Layer Bridge functions พัฒนาเสร็จสมบูรณ์ | | **AI Tool Layer** | ✅ พร้อม | ADR-025 Active — Tool Layer Bridge functions พัฒนาเสร็จสมบูรณ์ |
| **Document Chat UI** | ✅ พร้อม | ADR-026 Active — แผงควบคุม Side-panel Chat UI พัฒนาเสร็จสมบูรณ์ | | **Document Chat UI** | ✅ พร้อม | ADR-026 Active — แผงควบคุม Side-panel Chat UI พัฒนาเสร็จสมบูรณ์ |
| **AI Admin Console** | ✅ พร้อม | ADR-027 Active — แผงควบคุม Dynamic prompt & model control | | **AI Admin Console** | ✅ พร้อม | ADR-027 Active — แผงควบคุม Dynamic prompt & model control |
| **Dynamic Prompt Mgmt** | ✅ พร้อม | ADR-029 Active — พัฒนาเสร็จสมบูรณ์ทั้ง Entity, API, Sandbox, Cache และ UI | | **Dynamic Prompt Mgmt** | ✅ พร้อม | ADR-029 Active — พัฒนาเสร็จสมบูรณ์ทั้ง Entity, API, Sandbox, Cache และ UI |
| **Active Model & OCR Switch** | ✅ พร้อม | ADR-033 Active — สลับโมเดลแบบ Synchronous, GPU VRAM Auto-release และ API Key sidecar protection | | **Active Model & OCR Switch** | ✅ พร้อม | ADR-033 Active — สลับโมเดลแบบ Synchronous, GPU VRAM Auto-release และ API Key sidecar protection |
| **AI Runtime Policy Refactor**| ✅ พร้อม | Feature-235 — `np-dms-ai`/`np-dms-ocr` canonical names, adaptive OCR residency, CPU fallback retrieval, queue policy (ai-realtime concurrency=2) |
## Flagged ambiguities ## Flagged ambiguities
@@ -226,23 +257,42 @@ _Avoid_: Throw exception from tool, Untyped error
- **"AI = Document Controller"** — resolved: ใช้ **AI Document Assistant** (Suggest + Insight) แทน เพื่อกัน scope creep ไปทาง autonomous agent - **"AI = Document Controller"** — resolved: ใช้ **AI Document Assistant** (Suggest + Insight) แทน เพื่อกัน scope creep ไปทาง autonomous agent
- **OpenRAG vs ADR-023A** — resolved: **ADR-023A เป็น canonical source** — ใช้ Qdrant + nomic-embed-text สำหรับ vector search; Elasticsearch ใช้สำหรับ keyword/full-text เท่านั้น; `specs/03-Data-and-Storage/03-07-OpenRAG.md` เป็นเอกสาร reference แต่ไม่ใช่ active spec - **OpenRAG vs ADR-023A** — resolved: **ADR-023A เป็น canonical source** — ใช้ Qdrant + nomic-embed-text สำหรับ vector search; Elasticsearch ใช้สำหรับ keyword/full-text เท่านั้น; `specs/03-Data-and-Storage/03-07-OpenRAG.md` เป็นเอกสาร reference แต่ไม่ใช่ active spec
- **".agents/ กับ Production AI"** — resolved: `.agents/` คือ Dev AI toolkit (ช่วยเขียนโค้ด); Production AI คือ AI Gateway + n8n + Ollama — เป็นคนละ layer กัน - **".agents/ กับ Production AI"** — resolved: `.agents/` คือ Dev AI toolkit (ช่วยเขียนโค้ด); Production AI คือ AI Gateway + n8n + Ollama — เป็นคนละ layer กัน
- **"np-dms-ai" vs `typhoon2.5-np-dms:latest`** — resolved: ถ้าเดินตาม AI refactor ใหม่ `np-dms-ai` คือ **Canonical AI Model Identity** ใหม่ของระบบ ไม่ใช่แค่ deploy alias
- **"OCR keep_alive"** — resolved: policy ใหม่ควรถูกอธิบายเป็น **Adaptive OCR Residency** ตาม VRAM headroom และ active model ไม่ใช่ fixed `0` หรือ fixed `300`
- **"`model.key` ใน API job request"** — resolved: caller ไม่ควรเลือกชื่อโมเดลตรง ๆ; ควรส่ง **Execution Profile** แล้วให้ backend policy เป็นคน map ไป model/parameters ที่อนุญาต
- **"profile names"** — resolved: ใช้ **Canonical Profile Set** แบบเล็กและเสถียร (`interactive`, `standard`, `quality`, `deep-analysis`) แทนการแตกชื่อ profile ตาม job ภายใน
- **"profile สำหรับ migrate-document / auto-fill-document / OCR extraction"** — resolved: ใช้ **Policy-Enforced Profile Override**; backend บังคับ profile เองสำหรับงานที่มีผลต่อข้อมูล ไม่เปิดให้ caller เลือกคุณภาพอย่างอิสระ
- **"BGE-M3 / Reranker บน GPU"** — resolved: ถ้าย้ายขึ้น GPU ต้องอยู่ใต้ **LLM-First GPU Ownership**; LLM/OCR มี priority สูงกว่า retrieval path เสมอ
- **"embed/rerank ตอน VRAM ไม่พอ"** — resolved: ใช้ **CPU Fallback Retrieval**; retrieval path ต้อง degrade ไป CPU ทันที ไม่รอ GPU queue
- **"`ai-realtime = 2`"** — resolved: ใช้ **Selective Realtime Concurrency**; เพิ่มได้เฉพาะงาน realtime ที่ไม่ชนกับ OCR/model switching และยังคง pause/resume model เดิมเป็นแกนหลัก
- **"งานไหนได้สิทธิ์ realtime concurrency 2"** — resolved: จำกัดเฉพาะ **Lightweight Realtime Job**; ไม่รวม `rag-query`
- **"`rag-query` ควรถูกมองเป็นอะไร"** — resolved: ใช้ **Generation-Centric RAG Query**; main model path เป็น policy หลัก ส่วน retrieval เป็นขั้นเตรียม context ที่ fallback CPU ได้
- **"`large-context` ใช้กับอะไร"** — resolved: ใช้ **Restricted Large-Context Profile**; จำกัดเฉพาะ admin/special workflows ไม่เปิดเป็นตัวเลือกทั่วไปของ `rag-query`
- **"rollout ของ AI refactor"** — resolved: ใช้ **Big Bang AI Runtime Rollout** แม้มีหลาย runtime policy changes พร้อมกัน เพราะระบบยังไม่เปิด production
- **"อะไรคือเกณฑ์ผ่านของ big bang"** — resolved: ใช้ **Big Bang Cutover Gate**; ต้องผ่านครบทั้ง policy contract, model switching, adaptive OCR residency และ RAG fallback
- **"evidence แบบไหนนับว่าผ่าน gate"** — resolved: ใช้ **Executable-First Verification** เป็นหลัก แต่ต้องมี manual validation path ควบคู่ในแต่ละแกน
- **"`np-dms-ai` ควรตั้งชื่ออย่างไรในระบบ"** — resolved: ใช้ **Single-Name Canonical Model Policy**; `np-dms-ai` เป็นชื่อเดียวทุกชั้นที่ผู้ใช้และนักพัฒนาเห็น
- **"`np-dms-ocr` ควรเดินตาม naming policy เดียวกันไหม"** — resolved: ใช้ **Canonical OCR Identity**; `np-dms-ocr` เป็นชื่อ canonical เดียวทุกชั้นเหมือน `np-dms-ai`
- **"`temperature/topP/maxTokens` ใครคุม"** — resolved: ใช้ **Profile-Only Parameter Governance**; caller ส่งได้แค่ profile ส่วน runtime parameters จริงให้ backend policy คุมทั้งหมด
- **"BGE GPU uplift อยู่ใน scope เดียวกันไหม"** — resolved: ใช้ **Integrated Retrieval Acceleration Policy**; retrieval acceleration เป็นส่วนหนึ่งของ runtime resource policy เดียวกัน
## ADRs ที่เกี่ยวข้องกับ AI Runtime Layer ## ADRs ที่เกี่ยวข้องกับ AI Runtime Layer
| ADR | หัวข้อ | ตัดสินใจอะไร | สถานะ | | ADR | หัวข้อ | ตัดสินใจอะไร | สถานะ |
| :--- | :--- | :--- | :--- | | :------ | :--------------------------------- | :-------------------------------------------------------------------------- | :---------- |
| ADR-024 | Intent Classification Strategy | Hybrid: Pattern First → LLM Fallback | ✅ Accepted | | ADR-024 | Intent Classification Strategy | Hybrid: Pattern First → LLM Fallback | ✅ Accepted |
| ADR-025 | AI Tool Layer Architecture | Bridge pattern, CASL enforcement, response shape | ✅ Accepted | | ADR-025 | AI Tool Layer Architecture | Bridge pattern, CASL enforcement, response shape | ✅ Accepted |
| ADR-026 | Document Chat UI Pattern | Side-panel vs modal vs separate page | ✅ Accepted | | ADR-026 | Document Chat UI Pattern | Side-panel vs modal vs separate page | ✅ Accepted |
| ADR-027 | AI Admin Console & Dynamic Control | Admin Panel + dynamic model/prompt/intent control | ✅ Accepted | | ADR-027 | AI Admin Console & Dynamic Control | Admin Panel + dynamic model/prompt/intent control | ✅ Accepted |
| ADR-028 | Migration Architecture Refactor | Staging Queue & post-migration cleanup | ✅ Active | | ADR-028 | Migration Architecture Refactor | Staging Queue & post-migration cleanup | ✅ Active |
| ADR-029 | Dynamic Prompt Management | `ai_prompts` table, versioned OCR extraction prompt | ✅ Active | | ADR-029 | Dynamic Prompt Management | `ai_prompts` table, versioned OCR extraction prompt | ✅ Active |
| ADR-032 | Typhoon OCR Integration | Typhoon OCR-3B + typhoon2.1-gemma3-4b on Admin Desktop | ✅ Active | | ADR-032 | Typhoon OCR Integration | Typhoon OCR-3B + typhoon2.1-gemma3-4b on Admin Desktop | ✅ Active |
| ADR-033 | Active Model & OCR Management | Synchronous Model switch, GPU VRAM Auto-release, Sidecar API Key protection | ✅ Active | | ADR-033 | Active Model & OCR Management | Synchronous Model switch, GPU VRAM Auto-release, Sidecar API Key protection | ✅ Active |
| ADR-034 | Thai Model Stack | typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) | ✅ Active |
**หมายเหตุ**: ADR-023A ยังคงเป็น canonical สำหรับ infrastructure — ADR-024/025/026/027 เพิ่ม runtime layer; ADR-028 ปรับ Migration Pipeline; ADR-033 จัดระบบโมเดลและ OCR **หมายเหตุ**: ADR-023A ยังคงเป็น canonical สำหรับ infrastructure — ADR-024/025/026/027 เพิ่ม runtime layer; ADR-028 ปรับ Migration Pipeline; ADR-033 จัดระบบโมเดลและ OCR
## สิ่งที่ควรทำในอนาคต (Future Maintenance & Security Tasks) ## สิ่งที่ควรทำในอนาคต (Future Maintenance & Security Tasks)
* **Axios Dependency**: ได้รับการอัปเกรด dependencies เป็นรุ่นปลอดภัยล่าสุดและแก้ไขช่องโหว่ Prototype Pollution เรียบร้อยแล้ว (pnpm audit CLEAN 100%) - **Axios Dependency**: ได้รับการอัปเกรด dependencies เป็นรุ่นปลอดภัยล่าสุดและแก้ไขช่องโหว่ Prototype Pollution เรียบร้อยแล้ว (pnpm audit CLEAN 100%)
* **ความปลอดภัยของ Sidecar และ GPU**: นำระบบ API Key Header verification (`X-API-Key`) และกลไก Unload model (`keep_alive: 0`) มาประยุกต์ใช้อย่างสมบูรณ์บนเครื่องประมวลผลโลคัล Desk-5439 - **ความปลอดภัยของ Sidecar และ GPU**: นำระบบ API Key Header verification (`X-API-Key`) และกลไก Unload model (`keep_alive: 0`) มาประยุกต์ใช้อย่างสมบูรณ์บนเครื่องประมวลผลโลคัล Desk-5439
+3 -3
View File
@@ -722,19 +722,19 @@ Create `.markdownlint.json`:
## 🤖 AI-Assisted Contributions ## 🤖 AI-Assisted Contributions
โปรเจกต์นี้รองรับ AI agents (Windsurf Cascade, Codex CLI, opencode, Amp, Antigravity) ในการเขียน / review / refactor โค้ด — ผ่านคู่มือกลางคือ [`AGENTS.md`](./AGENTS.md) และชุดทักษะใน [`.agents/skills/`](./.agents/skills/) โปรเจกต์นี้รองรับ AI agents (Devin Cascade, Codex CLI, opencode, Amp, Antigravity) ในการเขียน / review / refactor โค้ด — ผ่านคู่มือกลางคือ [`AGENTS.md`](./AGENTS.md) และชุดทักษะใน [`.agents/skills/`](./.agents/skills/)
### Canonical Rule Sources (อ่านตามลำดับนี้) ### Canonical Rule Sources (อ่านตามลำดับนี้)
1. **[`AGENTS.md`](./AGENTS.md)** — quick-reference rules + change log (supersedes legacy `GEMINI.md`) 1. **[`AGENTS.md`](./AGENTS.md)** — quick-reference rules + change log (supersedes legacy `GEMINI.md`)
2. **[`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)** — shared context loaded by every speckit-\* skill 2. **[`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)** — shared context loaded by every speckit-\* skill
3. **[`.agents/skills/README.md`](./.agents/skills/README.md)** — skill-pack layout + Windsurf invocation guide 3. **[`.agents/skills/README.md`](./.agents/skills/README.md)** — skill-pack layout + Devin invocation guide
4. `specs/06-Decision-Records/` (โดยเฉพาะ ADR-019 — UUID **March 2026 pattern**) 4. `specs/06-Decision-Records/` (โดยเฉพาะ ADR-019 — UUID **March 2026 pattern**)
5. `specs/05-Engineering-Guidelines/` (backend / frontend / testing / i18n / git conventions) 5. `specs/05-Engineering-Guidelines/` (backend / frontend / testing / i18n / git conventions)
### Invocation (v1.9.0 Unified) ### Invocation (v1.9.0 Unified)
ใช้ slash commands ผ่านโฟลเดอร์หลักคือ [`.agents/workflows/`](./.agents/workflows/) (ซึ่งถูก Mirror ไปยัง `.windsurf/workflows/` อัตโนมัติ): ใช้ slash commands ผ่านโฟลเดอร์หลักคือ [`.agents/workflows/`](./.agents/workflows/) (ซึ่งถูก Mirror ไปยัง `.devin/workflows/` อัตโนมัติ):
- `/00-speckit.all` → Full Pipeline (Specify → Validate) - `/00-speckit.all` → Full Pipeline (Specify → Validate)
- `/102-speckit.specify` → สร้าง spec.md (ต้องระบุหมวดหมู่ 100/200/300) - `/102-speckit.specify` → สร้าง spec.md (ต้องระบุหมวดหมู่ 100/200/300)
+27 -27
View File
@@ -16,17 +16,17 @@
> v1.9.7 (ADR-029 + sidecar) May 25; v1.9.8 (ADR-033 Model/OCR Sync & Security) June 2. > v1.9.7 (ADR-029 + sidecar) May 25; v1.9.8 (ADR-033 Model/OCR Sync & Security) June 2.
| Area | Status | หมายเหตุ | | Area | Status | หมายเหตุ |
| ---------------------- | ------------------------ | ------------------------------------------------------------------ | | ---------------------- | ------------------------ | -------------------------------------------------------------- |
| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities | | 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities |
| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 | | 🎨 **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 | | 💾 **Database** | ✅ Schema v1.9.0 Stable | MariaDB 11.8, No-migration Policy |
| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (33 ADRs — v1.9.8) | | 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (33 ADRs — v1.9.8) |
| 🤖 **AI Architecture** | ✅ 33 ADRs Accepted | ADR-023A + ADR-024~029 + ADR-033 Model Sync & Security | | 🤖 **AI Architecture** | ✅ 33 ADRs Accepted | ADR-023A + ADR-024~029 + ADR-033 Model Sync & Security |
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context | | 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready | | 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station | | 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
| 🔒 **Infrastructure** | ✅ Hardened (v1.9.8) | Sidecar APIs secured; dynamic VRAM Release; container hardened | | 🔒 **Infrastructure** | ✅ Hardened (v1.9.8) | Sidecar APIs secured; dynamic VRAM Release; container hardened |
--- ---
@@ -297,7 +297,7 @@ lcbp3-dms/
│ ├── scripts/ # Audit & Sync scripts │ ├── scripts/ # Audit & Sync scripts
│ └── archive/ # Archived outdated tools │ └── archive/ # Archived outdated tools
├── .windsurf/ # Windsurf-specific (Mirrored from .agents) ├── .devin/ # Devin-specific (Mirrored from .agents)
├── .github/ # GitHub Actions workflows ├── .github/ # GitHub Actions workflows
├── AGENTS.md # AI agent rules & project context (v1.9.0) [★ primary] ├── AGENTS.md # AI agent rules & project context (v1.9.0) [★ primary]
@@ -314,20 +314,20 @@ lcbp3-dms/
### เอกสารหลัก (specs/ folder) ### เอกสารหลัก (specs/ folder)
| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก | | เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก |
| ----------------------- | -------------------------------------------------------- | --------- | --------------------------------------- | | ----------------------- | ----------------------------------------------------------------- | --------- | --------------------------------------- |
| **Product Vision** | Vision, Strategic Pillars, Guardrails | Gap 1 ✅ | `00-03-product-vision.md` | | **Product Vision** | Vision, Strategic Pillars, Guardrails | Gap 1 ✅ | `00-03-product-vision.md` |
| **User Stories** | 27 Stories, 8 Epics, MoSCoW | Gap 2 ✅ | `01-04-user-stories.md` | | **User Stories** | 27 Stories, 8 Epics, MoSCoW | Gap 2 ✅ | `01-04-user-stories.md` |
| **Acceptance Criteria** | UAT Criteria, Sign-off Process | Gap 3 ✅ | `01-05-acceptance-criteria.md` | | **Acceptance Criteria** | UAT Criteria, Sign-off Process | Gap 3 ✅ | `01-05-acceptance-criteria.md` |
| **UI/UX Wireframes** | 26 Screens, ASCII Wireframes, Design System | Gap 4 ✅ | `01-07-ui-wireframes.md` | | **UI/UX Wireframes** | 26 Screens, ASCII Wireframes, Design System | Gap 4 ✅ | `01-07-ui-wireframes.md` |
| **Stakeholder & Risk** | Sign-off, Risk Register, Change Control | Gap 5 ✅ | `00-04-stakeholder-signoff-and-risk.md` | | **Stakeholder & Risk** | Sign-off, Risk Register, Change Control | Gap 5 ✅ | `00-04-stakeholder-signoff-and-risk.md` |
| **KPI Baseline** | 14 KPIs, SQL Queries, Grafana Specs | Gap 6 ✅ | `00-05-kpi-baseline.md` | | **KPI Baseline** | 14 KPIs, SQL Queries, Grafana Specs | Gap 6 ✅ | `00-05-kpi-baseline.md` |
| **Migration Scope** | 20K Docs, 3 Tiers, Go/No-Go Gates | Gap 7 ✅ | `03-06-migration-business-scope.md` | | **Migration Scope** | 20K Docs, 3 Tiers, Go/No-Go Gates | Gap 7 ✅ | `03-06-migration-business-scope.md` |
| **Release Policy** | SemVer, 5 Gates, Hotfix, Rollback | Gap 8 ✅ | `04-08-release-management-policy.md` | | **Release Policy** | SemVer, 5 Gates, Hotfix, Rollback | Gap 8 ✅ | `04-08-release-management-policy.md` |
| **Training Plan** | Curriculum per Role, UAT Training | Gap 9 ✅ | `00-06-training-plan.md` | | **Training Plan** | Curriculum per Role, UAT Training | Gap 9 ✅ | `00-06-training-plan.md` |
| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` | | **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` |
| **Schema v1.9.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.9.0-schema-*.sql` | | **Schema v1.9.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.9.0-schema-*.sql` |
| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` | | **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` |
| **ADRs (33)** | All Architecture Decisions incl. ADR-019/021/023/024-029, ADR-033 | - | `06-Decision-Records/` | | **ADRs (33)** | All Architecture Decisions incl. ADR-019/021/023/024-029, ADR-033 | - | `06-Decision-Records/` |
--- ---
@@ -366,7 +366,7 @@ lcbp3-dms/
- Development Process - Development Process
- Pull Request Process - Pull Request Process
- Coding Standards - Coding Standards
- **AI-Assisted Contributions** (AGENTS.md + `.agents/skills/` skill pack + Windsurf slash commands) - **AI-Assisted Contributions** (AGENTS.md + `.agents/skills/` skill pack + Devin slash commands)
### 🤖 For AI Agents ### 🤖 For AI Agents
+6
View File
@@ -57,6 +57,12 @@ OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_RAG_MODEL=typhoon2.5-np-dms:latest OLLAMA_RAG_MODEL=typhoon2.5-np-dms:latest
OLLAMA_URL=http://192.168.10.8:11434 OLLAMA_URL=http://192.168.10.8:11434
# VRAM, Residency & Concurrency settings (Feature-235 AI Runtime Policy)
AI_VRAM_HEADROOM_THRESHOLD_MB=3000
AI_GPU_MAIN_MODEL_PRESSURE_THRESHOLD_MB=12000
AI_OCR_RESIDENCY_WINDOW_SECONDS=120
AI_REALTIME_CONCURRENCY=2
# Qdrant (ADR-023A) # Qdrant (ADR-023A)
QDRANT_HOST=http://192.168.10.8:6333 QDRANT_HOST=http://192.168.10.8:6333
QDRANT_COLLECTION=lcbp3_documents QDRANT_COLLECTION=lcbp3_documents
+1 -8
View File
@@ -19,14 +19,7 @@ export default tseslint.config(
}, },
sourceType: 'commonjs', sourceType: 'commonjs',
parserOptions: { parserOptions: {
projectService: { project: ['./tsconfig.eslint.json'],
allowDefaultProject: [
'jest.config.js',
'*.config.mjs',
'scratch/*.ts',
'test/*.ts',
],
},
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
+2 -2
View File
@@ -19,7 +19,7 @@
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint:ci": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\" --cache", "lint:ci": "node --max-old-space-size=4096 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\" --cache",
"test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance", "test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance",
"test:debug-handles": "jest --config jest.config.js --detectOpenHandles", "test:debug-handles": "jest --config jest.config.js --detectOpenHandles",
"test:watch": "jest --config jest.config.js --watch", "test:watch": "jest --config jest.config.js --watch",
@@ -67,7 +67,7 @@
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"joi": "^18.0.1", "joi": "^18.2.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
-2
View File
@@ -51,7 +51,6 @@ import { SearchModule } from './modules/search/search.module';
import { AuditLogModule } from './modules/audit-log/audit-log.module'; import { AuditLogModule } from './modules/audit-log/audit-log.module';
import { MigrationModule } from './modules/migration/migration.module'; import { MigrationModule } from './modules/migration/migration.module';
import { AiModule } from './modules/ai/ai.module'; import { AiModule } from './modules/ai/ai.module';
import { RagModule } from './modules/rag/rag.module';
import { ReviewTeamModule } from './modules/review-team/review-team.module'; import { ReviewTeamModule } from './modules/review-team/review-team.module';
import { ResponseCodeModule } from './modules/response-code/response-code.module'; import { ResponseCodeModule } from './modules/response-code/response-code.module';
import { DelegationModule } from './modules/delegation/delegation.module'; import { DelegationModule } from './modules/delegation/delegation.module';
@@ -192,7 +191,6 @@ import { TagsModule } from './modules/tags/tags.module';
AuditLogModule, AuditLogModule,
MigrationModule, MigrationModule,
AiModule, AiModule,
RagModule,
ReviewTeamModule, ReviewTeamModule,
ResponseCodeModule, ResponseCodeModule,
DelegationModule, DelegationModule,
+6 -1
View File
@@ -2,6 +2,7 @@
// Change Log: // Change Log:
// - 2026-05-13: Add BullMQ config registry for reminder and distribution queues. // - 2026-05-13: Add BullMQ config registry for reminder and distribution queues.
// - 2026-05-15: เพิ่ม config สำหรับ ai-realtime และ ai-batch ตาม ADR-023A. // - 2026-05-15: เพิ่ม config สำหรับ ai-realtime และ ai-batch ตาม ADR-023A.
// - 2026-06-11: ปรับ aiRealtimeQueue.concurrency ให้รองรับ AI_REALTIME_CONCURRENCY / REALTIME_CONCURRENCY
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
@@ -12,7 +13,11 @@ export default registerAs('bullmq', () => ({
process.env.BULLMQ_DISTRIBUTION_QUEUE || 'rfa-distribution', process.env.BULLMQ_DISTRIBUTION_QUEUE || 'rfa-distribution',
aiRealtimeQueue: { aiRealtimeQueue: {
name: process.env.BULLMQ_AI_REALTIME_QUEUE || 'ai-realtime', name: process.env.BULLMQ_AI_REALTIME_QUEUE || 'ai-realtime',
concurrency: 1, concurrency: Number(
process.env.AI_REALTIME_CONCURRENCY ||
process.env.REALTIME_CONCURRENCY ||
'2'
),
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
backoff: { type: 'exponential', delay: 2000 }, backoff: { type: 'exponential', delay: 2000 },
@@ -0,0 +1,74 @@
// File: backend/src/modules/ai/ai-qdrant.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ AiQdrantService ครอบคลุม deleteByDocumentPublicId (T4)
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { AiQdrantService } from './qdrant.service';
describe('AiQdrantService', () => {
let service: AiQdrantService;
let mockConfigService: jest.Mocked<ConfigService>;
beforeEach(async () => {
mockConfigService = {
get: jest.fn(),
} as unknown as jest.Mocked<ConfigService>;
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'AI_QDRANT_URL' || key === 'QDRANT_URL') {
return 'http://localhost:6333';
}
return undefined;
});
const module: TestingModule = await Test.createTestingModule({
providers: [
AiQdrantService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<AiQdrantService>(AiQdrantService);
});
it('ควรถูกสร้างขึ้นสำเร็จ', () => {
expect(service).toBeDefined();
});
describe('deleteByDocumentPublicId', () => {
it('ควร throw error ถ้า projectPublicId ว่าง', async () => {
await expect(
service.deleteByDocumentPublicId('', 'doc-uuid-123')
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
});
it('ควร throw error ถ้า projectPublicId เป็น undefined', async () => {
await expect(
service.deleteByDocumentPublicId(
undefined as unknown as string,
'doc-uuid-123'
)
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
});
it('ควรเรียก Qdrant delete ด้วย filter ที่ถูกต้อง (project_public_id + doc_public_id)', async () => {
// Mock QdrantClient.delete method
const mockDelete = jest.fn().mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(service as any).client.delete = mockDelete;
await service.deleteByDocumentPublicId('proj-uuid-456', 'doc-uuid-123');
expect(mockDelete).toHaveBeenCalledWith('lcbp3_vectors', {
wait: true,
filter: {
must: [
{ key: 'project_public_id', match: { value: 'proj-uuid-456' } },
{ key: 'doc_public_id', match: { value: 'doc-uuid-123' } },
],
},
});
});
});
});
+35 -1
View File
@@ -32,9 +32,24 @@ export interface AiRagJobPayload {
/** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */ /** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */
export interface AiVectorDeletionJobPayload { export interface AiVectorDeletionJobPayload {
documentPublicId: string; documentPublicId: string;
projectPublicId: string;
requestedByUserPublicId: string; requestedByUserPublicId: string;
} }
/** Payload สำหรับงาน RAG Prepare เมื่อผู้ใช้ submit workflow */
export interface RagPrepareJobPayload {
documentPublicId: string;
projectPublicId: string;
correspondenceNumber: string;
docType: string;
statusCode: string;
revisionNumber: number;
subject: string;
documentDate?: string;
cachedOcrText?: string;
attachmentPath?: string;
}
/** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */ /** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */
@Injectable() @Injectable()
export class AiQueueService { export class AiQueueService {
@@ -92,7 +107,7 @@ export class AiQueueService {
payload, payload,
{ {
...this.defaultOptions, ...this.defaultOptions,
jobId: payload.documentPublicId, jobId: `${payload.projectPublicId}:${payload.documentPublicId}`,
} }
); );
return String(job.id); return String(job.id);
@@ -158,4 +173,23 @@ export class AiQueueService {
const waiting = await this.batchQueue.getWaitingCount(); const waiting = await this.batchQueue.getWaitingCount();
return active + waiting; return active + waiting;
} }
/**
* RAG Prepare queue embedding
* @idempotency `jobId = rag-prepare:${documentPublicId}:${revisionNumber}` revision
*/
async enqueueRagPrepare(payload: RagPrepareJobPayload): Promise<string> {
const job = await this.batchQueue.add(
'rag-prepare',
{
jobType: 'rag-prepare',
...payload,
},
{
...this.defaultOptions,
jobId: `rag-prepare:${payload.documentPublicId}:${payload.revisionNumber}`,
}
);
return String(job.id);
}
} }
@@ -0,0 +1,370 @@
// File: backend/src/modules/ai/ai-rag-pipeline.integration.spec.ts
// Change Log:
// - 2026-06-05: สร้าง integration test สำหรับ RAG Pipeline end-to-end (SC-002, Gap fix)
// ครอบคลุม: enqueueRagPrepare jobId dedup, EmbeddingService pipeline, project isolation
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getQueueToken } from '@nestjs/bullmq';
import { AiQueueService, RagPrepareJobPayload } from './ai-queue.service';
import { EmbeddingService } from './services/embedding.service';
import { OllamaService } from './services/ollama.service';
import { OcrService } from './services/ocr.service';
import { AiQdrantService } from './qdrant.service';
import { AiPromptsService } from './prompts/ai-prompts.service';
import {
QUEUE_AI_INGEST,
QUEUE_AI_RAG,
QUEUE_AI_VECTOR_DELETION,
QUEUE_AI_BATCH,
} from '../common/constants/queue.constants';
// ────────────────────────────────────────────────────────────────────────────────
// Mock helpers
// ────────────────────────────────────────────────────────────────────────────────
/** สร้าง mock BullMQ Queue ที่ track jobId เพื่อ verify deduplication */
const createMockQueue = () => {
return {
add: jest
.fn()
.mockImplementation(
(name: string, data: unknown, opts: { jobId?: string } = {}) =>
Promise.resolve({ id: opts.jobId ?? 'auto-id' })
),
};
};
/** สร้าง mock EmbeddingService dependencies */
const buildEmbeddingModule = async (
ollamaGenerateResponse: string,
chunkSize = 512,
chunkOverlap = 64
) => {
const mockOllamaService = {
generate: jest.fn().mockResolvedValue(ollamaGenerateResponse),
};
const mockAiPromptsService = {
resolveActive: jest.fn().mockResolvedValue({
resolvedPrompt: 'แบ่ง OCR text ออกเป็น chunks',
versionNumber: 1,
}),
};
const mockConfigService = {
get: jest.fn((key: string, def?: unknown) => {
const vals: Record<string, unknown> = {
EMBEDDING_CHUNK_SIZE: chunkSize,
EMBEDDING_CHUNK_OVERLAP: chunkOverlap,
};
return vals[key] ?? def;
}),
};
const mockEmbedViaSidecar = jest.fn().mockResolvedValue({
dense: Array(1024).fill(0.1),
sparse: { indices: [10, 20], values: [0.8, 0.4] },
});
const mockDeleteByDocumentPublicId = jest.fn().mockResolvedValue(undefined);
const mockUpsert = jest.fn().mockResolvedValue(undefined);
const module: TestingModule = await Test.createTestingModule({
providers: [
EmbeddingService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: OllamaService, useValue: mockOllamaService },
{
provide: AiQdrantService,
useValue: {
deleteByDocumentPublicId: mockDeleteByDocumentPublicId,
upsert: mockUpsert,
},
},
{
provide: OcrService,
useValue: { embedViaSidecar: mockEmbedViaSidecar },
},
{ provide: AiPromptsService, useValue: mockAiPromptsService },
],
}).compile();
return {
service: module.get<EmbeddingService>(EmbeddingService),
mockEmbedViaSidecar,
mockDeleteByDocumentPublicId,
mockUpsert,
mockOllamaService,
};
};
// ────────────────────────────────────────────────────────────────────────────────
describe('RAG Pipeline — Integration (SC-002 / Gap fixes)', () => {
// ──────────────────────────────────────────────────────────────────────────────
// Test Group 1: BullMQ Job Deduplication (Gap 1 verify)
// ──────────────────────────────────────────────────────────────────────────────
describe('enqueueRagPrepare — jobId deduplication', () => {
let queueService: AiQueueService;
let mockBatchQueue: ReturnType<typeof createMockQueue>;
beforeEach(async () => {
mockBatchQueue = createMockQueue();
const module: TestingModule = await Test.createTestingModule({
providers: [
AiQueueService,
{
provide: getQueueToken(QUEUE_AI_INGEST),
useValue: { add: jest.fn() },
},
{
provide: getQueueToken(QUEUE_AI_RAG),
useValue: { add: jest.fn() },
},
{
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
useValue: { add: jest.fn() },
},
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockBatchQueue },
],
}).compile();
queueService = module.get<AiQueueService>(AiQueueService);
});
it('ควรสร้าง jobId = rag-prepare:{documentPublicId}:{revisionNumber} (SC-004 dedup)', async () => {
const payload: RagPrepareJobPayload = {
documentPublicId: 'doc-uuid-001',
projectPublicId: 'proj-uuid-abc',
correspondenceNumber: 'CORR-2026-001',
docType: 'LETTER',
statusCode: 'SUBOWN',
revisionNumber: 1,
subject: 'เอกสารทดสอบ Dedup',
};
await queueService.enqueueRagPrepare(payload);
const calls = mockBatchQueue.add.mock.calls as [
string,
unknown,
{ jobId?: string },
][];
expect(calls[0][2]?.jobId).toBe('rag-prepare:doc-uuid-001:1');
});
it('ควร enqueue ด้วยชื่อ job rag-prepare และ payload ครบ', async () => {
const payload: RagPrepareJobPayload = {
documentPublicId: 'doc-uuid-002',
projectPublicId: 'proj-uuid-xyz',
correspondenceNumber: 'CORR-2026-002',
docType: 'RFA',
statusCode: 'CLBOWN',
revisionNumber: 0,
subject: 'RFA Test',
documentDate: '2026-06-05',
attachmentPath: '/files/rfa.pdf',
};
await queueService.enqueueRagPrepare(payload);
expect(mockBatchQueue.add).toHaveBeenCalledWith(
'rag-prepare',
expect.objectContaining({
jobType: 'rag-prepare',
documentPublicId: 'doc-uuid-002',
revisionNumber: 0,
}),
expect.objectContaining({
jobId: 'rag-prepare:doc-uuid-002:0',
attempts: 3,
})
);
});
it('ควรคืน jobId เดิมเมื่อ enqueue revision เดียวกัน 2 ครั้ง (idempotency)', async () => {
const payload: RagPrepareJobPayload = {
documentPublicId: 'doc-same',
projectPublicId: 'proj-same',
correspondenceNumber: 'CORR-SAME',
docType: 'LETTER',
statusCode: 'SUBOWN',
revisionNumber: 3,
subject: 'Idempotency Test',
};
const id1 = await queueService.enqueueRagPrepare(payload);
const id2 = await queueService.enqueueRagPrepare(payload);
// jobId เหมือนกัน — BullMQ จะ deduplicate ที่ server side
expect(id1).toBe(id2);
const calls = mockBatchQueue.add.mock.calls as [
string,
unknown,
{ jobId?: string },
][];
expect(calls[0][2]?.jobId).toBe(calls[1][2]?.jobId);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Test Group 2: processRagPrepare → EmbeddingService pipeline (SC-002)
// ──────────────────────────────────────────────────────────────────────────────
describe('EmbeddingService.embedDocument — full pipeline (SC-002)', () => {
const semanticLlmResponse =
'<chunk topic="บทนำ">เนื้อหาบทนำของเอกสารที่มีความยาวเพียงพอสำหรับการทดสอบ</chunk>' +
'<chunk topic="รายละเอียด">เนื้อหารายละเอียดของเอกสารฉบับนี้ครอบคลุมหัวข้อสำคัญ</chunk>';
const ocrText =
'เนื้อหาเอกสารที่มีความยาวเกิน 50 ตัวอักษร สำหรับทดสอบ RAG pipeline integration test ครบ pipeline';
it('SC-002: ควรเรียก Sidecar /embed และ Qdrant upsert สำหรับ semantic chunks', async () => {
const {
service,
mockEmbedViaSidecar,
mockDeleteByDocumentPublicId,
mockUpsert,
} = await buildEmbeddingModule(semanticLlmResponse);
const result = await service.embedDocument(
'proj-uuid-123',
'doc-uuid-456',
'CORR-2026-001',
'LETTER',
'SUBOWN',
1,
'Test Subject',
'2026-06-05',
ocrText
);
// ตรวจสอบว่า Sidecar /embed ถูกเรียกสำหรับแต่ละ semantic chunk (2 chunks)
expect(mockEmbedViaSidecar).toHaveBeenCalledTimes(2);
// ตรวจสอบว่าลบ points เก่าก่อน upsert (delete-before-upsert)
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-uuid-123',
'doc-uuid-456'
);
// ตรวจสอบ upsert payload ครบ 11 fields
expect(mockUpsert).toHaveBeenCalledWith(
'proj-uuid-123',
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({
doc_public_id: 'doc-uuid-456',
project_public_id: 'proj-uuid-123',
doc_number: 'CORR-2026-001',
doc_type: 'LETTER',
status_code: 'SUBOWN',
revision_number: 1,
subject: 'Test Subject',
document_date: '2026-06-05',
}),
}),
])
);
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBe(2);
});
it('SC-003: project isolation — upsert และ delete ต้องใช้ projectPublicId ที่ถูกต้อง', async () => {
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
await buildEmbeddingModule(semanticLlmResponse);
await service.embedDocument(
'proj-ISOLATED-999',
'doc-iso',
'CORR-ISO',
'LETTER',
'SUBOWN',
0,
'Subject',
undefined,
ocrText
);
// deleteByDocumentPublicId ต้องใช้ projectPublicId ที่ถูกต้อง
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-ISOLATED-999',
'doc-iso'
);
// upsert ต้องส่ง projectPublicId ที่ถูกต้องเป็น arg แรก
const upsertCalls = mockUpsert.mock.calls as [string, unknown][];
expect(upsertCalls[0][0]).toBe('proj-ISOLATED-999');
});
it('SC-006: ลำดับ delete → upsert ต้องถูกต้องเสมอ (ป้องกัน stale chunks)', async () => {
const callOrder: string[] = [];
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
await buildEmbeddingModule(semanticLlmResponse);
mockDeleteByDocumentPublicId.mockImplementationOnce(() => {
callOrder.push('delete');
});
mockUpsert.mockImplementationOnce(() => {
callOrder.push('upsert');
});
await service.embedDocument(
'proj-x',
'doc-stale',
'CORR-X',
'LETTER',
'SUBOWN',
2,
'Sub',
undefined,
ocrText
);
// ตรวจสอบลำดับ: delete ต้องเกิดก่อน upsert เสมอ (SC-006)
expect(callOrder).toEqual(['delete', 'upsert']);
});
it('ควรคืน success=false เมื่อ ocrText ว่าง (edge case — skip guard)', async () => {
const { service } = await buildEmbeddingModule(semanticLlmResponse);
const result = await service.embedDocument(
'proj-x',
'doc-empty',
'CORR-X',
'LETTER',
'SUBOWN',
1,
'Sub',
undefined,
''
);
expect(result.success).toBe(false);
expect(result.error).toContain('No OCR text');
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Test Group 3: Semantic Chunking fallback → fixed-size (FR-005)
// ──────────────────────────────────────────────────────────────────────────────
describe('Semantic Chunking fallback (FR-005)', () => {
it('ควร fallback เป็น fixed-size และยังคง embed ได้ เมื่อ LLM output ไม่มี <chunk> tag', async () => {
const { service, mockEmbedViaSidecar, mockUpsert } =
await buildEmbeddingModule(
'ไม่มี tag chunk เลย — plain text output',
60,
0
);
const ocrText = 'ก'.repeat(80); // 80 chars → 2 chunks (60 + 20 chars)
const result = await service.embedDocument(
'proj-fallback',
'doc-fallback',
'CORR-FB',
'LETTER',
'SUBOWN',
1,
'Fallback',
undefined,
ocrText
);
// fallback ยังต้อง embed ได้
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBeGreaterThan(0);
expect(mockEmbedViaSidecar).toHaveBeenCalled();
// ตรวจสอบว่า chunk_topic มาจาก fixed-size (ขึ้นต้นด้วย "ส่วนที่")
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const upsertPoints = mockUpsert.mock.calls[0]?.[1] as Array<{
payload: { chunk_topic: string };
}>;
expect(upsertPoints[0]?.payload.chunk_topic).toMatch(/ส่วนที่/);
});
it('ควร fallback ทันทีเมื่อ LLM throw error', async () => {
const { service, mockUpsert, mockOllamaService } =
await buildEmbeddingModule('', 60, 0);
mockOllamaService.generate.mockRejectedValueOnce(
new Error('Ollama timeout')
);
const ocrText = 'ก'.repeat(80);
const result = await service.embedDocument(
'proj-err',
'doc-err',
'CORR-ERR',
'LETTER',
'SUBOWN',
1,
'Sub',
undefined,
ocrText
);
// ถึงแม้ LLM throw แต่ fallback ยังทำงาน
expect(result.success).toBe(true);
expect(mockUpsert).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,156 @@
// File: backend/src/modules/ai/ai-rag.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ AiRagService เพื่อทดสอบกระบวนการทำ RAG query ด้วย Hybrid Search และ Reranker (T011)
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { AiRagService } from './ai-rag.service';
import { AiQdrantService } from './qdrant.service';
import { OcrService } from './services/ocr.service';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
describe('AiRagService (US1 — Chat Q&A)', () => {
let service: AiRagService;
let qdrantService: AiQdrantService;
let ocrService: OcrService;
const mockRedis = {
get: jest.fn(),
setex: jest.fn(),
del: jest.fn(),
};
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
const values: Record<string, unknown> = {
OLLAMA_URL: 'http://localhost:11434',
OLLAMA_RAG_MODEL: 'typhoon2.5-np-dms:latest',
RAG_TIMEOUT_MS: 30000,
RAG_CONTEXT_LIMIT_CHARS: 3000,
};
return values[key] ?? defaultValue;
}),
};
const mockQdrantService = {
searchByProject: jest.fn(),
};
const mockOcrService = {
embedViaSidecar: jest.fn(),
rerankViaSidecar: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AiRagService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: AiQdrantService, useValue: mockQdrantService },
{ provide: OcrService, useValue: mockOcrService },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
service = module.get<AiRagService>(AiRagService);
qdrantService = module.get<AiQdrantService>(AiQdrantService);
ocrService = module.get<OcrService>(OcrService);
jest.clearAllMocks();
});
describe('processQuery()', () => {
it('ควรเรียกใช้ embedViaSidecar, searchByProject, rerankViaSidecar และจบด้วยการสร้างคำตอบด้วย LLM', async () => {
// Setup mock data
const mockDenseVector = Array(1024).fill(0.1);
const mockSparseVector = { indices: [1, 2], values: [0.5, 0.6] };
mockOcrService.embedViaSidecar.mockResolvedValueOnce({
dense: mockDenseVector,
sparse: mockSparseVector,
});
const mockQdrantResults = [
{
pointId: 'point-1',
score: 0.85,
payload: {
doc_type: 'LETTER',
doc_number: 'CORR-001',
chunk_text: 'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
},
},
{
pointId: 'point-2',
score: 0.72,
payload: {
doc_type: 'LETTER',
doc_number: 'CORR-002',
chunk_text: 'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
},
},
];
mockQdrantService.searchByProject.mockResolvedValueOnce(
mockQdrantResults
);
mockOcrService.rerankViaSidecar.mockResolvedValueOnce({
scores: [0.95, 0.45],
ranked_indices: [0, 1],
});
mockedAxios.post.mockResolvedValueOnce({
data: {
response: 'คำตอบที่ได้รับความช่วยเหลือจาก LLM อ้างอิงเอกสาร CORR-001',
},
});
// Run query
await service.processQuery(
'req-123',
'ต้องการอนุมัติโครงการอย่างไร?',
'proj-456',
'user-789'
);
// Verify pipeline calls
expect(ocrService.embedViaSidecar).toHaveBeenCalledWith(
'ต้องการอนุมัติโครงการอย่างไร?'
);
expect(qdrantService.searchByProject).toHaveBeenCalledWith(
mockDenseVector,
mockSparseVector,
'proj-456',
15
);
expect(ocrService.rerankViaSidecar).toHaveBeenCalledWith(
'ต้องการอนุมัติโครงการอย่างไร?',
[
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
]
);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('/api/generate'),
expect.objectContaining({
model: 'typhoon2.5-np-dms:latest',
prompt: expect.stringContaining(
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline'
),
}),
expect.any(Object)
);
// Verify saving job status
expect(mockRedis.setex).toHaveBeenCalledWith(
expect.stringContaining('ai:rag:result:req-123'),
expect.any(Number),
expect.stringContaining('completed')
);
});
});
});
+77 -40
View File
@@ -1,9 +1,9 @@
// File: src/modules/ai/ai-rag.service.ts // File: backend/src/modules/ai/ai-rag.service.ts
// Change Log // Change Log
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4. // - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version. // - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
// - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1). // - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1).
// Service จัดการ RAG query ผ่าน Ollama + AiQdrantService (project-isolated) // - 2026-06-05: ปรับปรุงใช้ Hybrid Search + Reranker ผ่าน Sidecar ตาม ADR-035 (T015, T030)
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -11,6 +11,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import axios from 'axios'; import axios from 'axios';
import { AiQdrantService } from './qdrant.service'; import { AiQdrantService } from './qdrant.service';
import { OcrService } from './services/ocr.service';
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */ /** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
export interface AiRagCitation { export interface AiRagCitation {
@@ -44,7 +45,6 @@ export class AiRagService {
private readonly logger = new Logger(AiRagService.name); private readonly logger = new Logger(AiRagService.name);
private readonly ollamaUrl: string; private readonly ollamaUrl: string;
private readonly ollamaModel: string; private readonly ollamaModel: string;
private readonly ollamaEmbedModel: string;
private readonly timeoutMs: number; private readonly timeoutMs: number;
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */ /** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
private readonly promptContextLimit: number; private readonly promptContextLimit: number;
@@ -52,6 +52,7 @@ export class AiRagService {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly qdrantService: AiQdrantService, private readonly qdrantService: AiQdrantService,
private readonly ocrService: OcrService,
@InjectRedis() private readonly redis: Redis @InjectRedis() private readonly redis: Redis
) { ) {
this.ollamaUrl = this.configService.get<string>( this.ollamaUrl = this.configService.get<string>(
@@ -62,10 +63,6 @@ export class AiRagService {
'OLLAMA_RAG_MODEL', 'OLLAMA_RAG_MODEL',
'gemma2' 'gemma2'
); );
this.ollamaEmbedModel = this.configService.get<string>(
'OLLAMA_EMBED_MODEL',
'nomic-embed-text'
);
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000); this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
this.promptContextLimit = this.configService.get<number>( this.promptContextLimit = this.configService.get<number>(
'RAG_CONTEXT_LIMIT_CHARS', 'RAG_CONTEXT_LIMIT_CHARS',
@@ -159,10 +156,11 @@ export class AiRagService {
/** /**
* RAG query: * RAG query:
* 1. Embed * 1. Embed BGE-M3 (Dense + Sparse) Sidecar /embed (T015)
* 2. Qdrant project isolation (T020 enforced in AiQdrantService.searchByProject) * 2. Qdrant Hybrid Search + project isolation (T015)
* 3. Build prompt context * 3. Rerank BGE-Reranker-Large Sidecar /rerank (T015)
* 4. Generate Ollama ( AbortSignal T022) * 4. Build prompt context
* 5. Generate Ollama
*/ */
async processQuery( async processQuery(
requestPublicId: string, requestPublicId: string,
@@ -182,8 +180,8 @@ export class AiRagService {
return; return;
} }
// 1. สร้าง embedding สำหรับคำถาม // 1. สร้าง embedding สำหรับคำถามด้วย BGE-M3 ผ่าน Sidecar
const queryVector = await this.embed(question, signal); const embedResult = await this.ocrService.embedViaSidecar(question);
// ตรวจสอบ cancel อีกครั้งหลัง embed // ตรวจสอบ cancel อีกครั้งหลัง embed
if ( if (
@@ -195,17 +193,15 @@ export class AiRagService {
return; return;
} }
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002) // 2. ค้นหา Qdrant ด้วย Hybrid search และกรองตาม project
const searchResults = await this.qdrantService.searchByProject( const searchResults = await this.qdrantService.searchByProject(
queryVector, embedResult.dense,
embedResult.sparse,
projectPublicId, projectPublicId,
10 15 // topK=15 ตาม FR-014
); );
// 3. สร้าง context จาก search results // ตรวจสอบ cancel หลัง search
const context = this.buildContext(searchResults);
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
if ( if (
signal?.aborted || signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId))) (await this.redis.get(this.cancelKey(requestPublicId)))
@@ -215,25 +211,74 @@ export class AiRagService {
return; return;
} }
// 4. Generate คำตอบผ่าน Ollama (ส่ง signal เพื่อรองรับ T022) // 3. Rerank ผลลัพธ์การค้นหา
let finalResults = searchResults;
const rawChunks = searchResults
.map(
(r) =>
(r.payload['chunk_text'] as string) ||
(r.payload['content_preview'] as string) ||
''
)
.filter((c) => c.trim().length > 0);
if (rawChunks.length > 0) {
this.logger.log(
`Calling Sidecar /rerank for ${rawChunks.length} candidates...`
);
const rerankResult = await this.ocrService.rerankViaSidecar(
question,
rawChunks
);
// เลือก top 3-5 chunks ที่ได้คะแนนสูงสุด
const topN = Math.min(5, rerankResult.ranked_indices.length);
finalResults = [];
for (let i = 0; i < topN; i++) {
const originalIndex = rerankResult.ranked_indices[i];
finalResults.push(searchResults[originalIndex]);
}
// Log รายละเอียดการจัดอันดับ (T030)
this.logger.log(
`Reranking completed: candidates input ${searchResults.length} -> output ${finalResults.length}. ` +
`Top-1 score: ${rerankResult.scores[rerankResult.ranked_indices[0]]?.toFixed(4) ?? 'N/A'}`
);
}
// 4. สร้าง context จาก search results
const context = this.buildContext(finalResults);
// ตรวจสอบ cancel ก่อนเรียก LLM
if (
signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId)))
) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 5. Generate คำตอบผ่าน Ollama
const { answer, usedFallback } = await this.generateAnswer( const { answer, usedFallback } = await this.generateAnswer(
this.sanitizeInput(question), this.sanitizeInput(question),
context, context,
signal signal
); );
const citations: AiRagCitation[] = searchResults.map((r) => ({ const citations: AiRagCitation[] = finalResults.map((r) => ({
pointId: r.pointId, pointId: r.pointId,
score: r.score, score: r.score,
docType: r.payload['doc_type'] as string | undefined, docType: r.payload['doc_type'] as string | undefined,
docNumber: r.payload['doc_number'] as string | undefined, docNumber: r.payload['doc_number'] as string | undefined,
snippet: (r.payload['content_preview'] as string | undefined)?.slice( snippet: (
0, (r.payload['chunk_text'] as string) ||
200 (r.payload['content_preview'] as string) ||
), ''
).slice(0, 200),
})); }));
const confidence = searchResults.length > 0 ? searchResults[0].score : 0; const confidence = finalResults.length > 0 ? finalResults[0].score : 0;
await this.saveJobResult({ await this.saveJobResult({
requestPublicId, requestPublicId,
@@ -266,17 +311,7 @@ export class AiRagService {
// ─── Private Helpers ───────────────────────────────────────────────────────── // ─── Private Helpers ─────────────────────────────────────────────────────────
/** สร้าง embedding vector สำหรับข้อความ */ /** Generate คำตอบจาก Ollama */
private async embed(text: string, signal?: AbortSignal): Promise<number[]> {
const response = await axios.post<{ embedding: number[] }>(
`${this.ollamaUrl}/api/embeddings`,
{ model: this.ollamaEmbedModel, prompt: text },
{ timeout: this.timeoutMs, signal }
);
return response.data.embedding;
}
/** Generate คำตอบจาก Ollama (รองรับ AbortSignal สำหรับ T022 FR-011) */
private async generateAnswer( private async generateAnswer(
question: string, question: string,
context: string, context: string,
@@ -291,7 +326,6 @@ export class AiRagService {
); );
return { answer: response.data.response ?? '', usedFallback: false }; return { answer: response.data.response ?? '', usedFallback: false };
} catch (err: unknown) { } catch (err: unknown) {
// ถ้าเป็น cancellation error ให้ re-throw เพื่อให้ processQuery จัดการ
if ( if (
axios.isCancel(err) || axios.isCancel(err) ||
(err instanceof Error && err.name === 'CanceledError') (err instanceof Error && err.name === 'CanceledError')
@@ -313,7 +347,10 @@ export class AiRagService {
for (const r of results) { for (const r of results) {
const docType = (r.payload['doc_type'] as string) ?? ''; const docType = (r.payload['doc_type'] as string) ?? '';
const docNumber = (r.payload['doc_number'] as string) ?? ''; const docNumber = (r.payload['doc_number'] as string) ?? '';
const preview = (r.payload['content_preview'] as string) ?? ''; const preview =
(r.payload['chunk_text'] as string) ??
(r.payload['content_preview'] as string) ??
'';
const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`; const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
const snippet = `${header}\n${preview}\n\n`; const snippet = `${header}\n${preview}\n\n`;
if ((context + snippet).length > this.promptContextLimit) break; if ((context + snippet).length > this.promptContextLimit) break;
+13 -14
View File
@@ -1,4 +1,4 @@
// File: src/modules/ai/ai.controller.ts // File: backend/src/modules/ai/ai.controller.ts
// Change Log // Change Log
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023. // - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2). // - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
@@ -12,6 +12,8 @@
// - 2026-05-30: เพิ่ม endpoints GET/POST/PATCH models และ GET vram/status สำหรับ dynamic AI model management และ VRAM monitoring (T031-T034, US2) // - 2026-05-30: เพิ่ม endpoints GET/POST/PATCH models และ GET vram/status สำหรับ dynamic AI model management และ VRAM monitoring (T031-T034, US2)
// - 2026-06-01: [BUGFIX] submitSandboxOcr: เพิ่ม @ApiBearerAuth(), @HttpCode(ACCEPTED), Body({ engineType }) และส่ง engineType ไปยัง enqueueSandboxJob // - 2026-06-01: [BUGFIX] submitSandboxOcr: เพิ่ม @ApiBearerAuth(), @HttpCode(ACCEPTED), Body({ engineType }) และส่ง engineType ไปยัง enqueueSandboxJob
// - 2026-06-02: เพิ่ม REST endpoints GET /ai/ocr-engines และ POST /ai/ocr-engines/:engineId/select (T003, T004, ADR-033) และนำเข้า SystemException เพื่อป้องกันความเสียหายในการคอมไพล์ // - 2026-06-02: เพิ่ม REST endpoints GET /ai/ocr-engines และ POST /ai/ocr-engines/:engineId/select (T003, T004, ADR-033) และนำเข้า SystemException เพื่อป้องกันความเสียหายในการคอมไพล์
// - 2026-06-06: [BUGFIX] เพิ่ม @Throttle({ default: { limit: 300, ttl: 60000 } }) บน GET admin/sandbox/job/:id เพื่อแก้ ThrottlerException spam จาก frontend polling
// - 2026-06-11: แก้ไขการส่งพารามิเตอร์ให้กับ queueSuggestJob ใน suggestDocumentMetadata
// Controller สำหรับ AI Gateway Endpoints (ADR-023) // Controller สำหรับ AI Gateway Endpoints (ADR-023)
import { import {
@@ -61,7 +63,7 @@ import { AiRagQueryDto } from './dto/ai-rag-query.dto';
import { ExtractDocumentDto } from './dto/extract-document.dto'; import { ExtractDocumentDto } from './dto/extract-document.dto';
import { AiCallbackDto } from './dto/ai-callback.dto'; import { AiCallbackDto } from './dto/ai-callback.dto';
import { CreateAiJobDto } from './dto/create-ai-job.dto'; import { CreateAiJobDto } from './dto/create-ai-job.dto';
import { SubmitAiJobDto } from './dto/submit-ai-job.dto'; import { AiJobResponseDto } from './dto/ai-job-response.dto';
import { MigrationUpdateDto } from './dto/migration-update.dto'; import { MigrationUpdateDto } from './dto/migration-update.dto';
import { MigrationQueryDto } from './dto/migration-query.dto'; import { MigrationQueryDto } from './dto/migration-query.dto';
import { ValidationException, SystemException } from '../../common/exceptions'; import { ValidationException, SystemException } from '../../common/exceptions';
@@ -170,11 +172,7 @@ export class AiController {
@Body() dto: CreateAiJobDto, @Body() dto: CreateAiJobDto,
@Headers('idempotency-key') idempotencyKey: string @Headers('idempotency-key') idempotencyKey: string
): Promise<{ success: boolean; jobId?: string; status: string }> { ): Promise<{ success: boolean; jobId?: string; status: string }> {
const result = await this.aiService.queueSuggestJob({ const result = await this.aiService.queueSuggestJob(dto, idempotencyKey);
...dto,
jobType: 'ai-suggest',
idempotencyKey: idempotencyKey || dto.idempotencyKey,
});
return { return {
success: result.success, success: result.success,
jobId: result.jobId, jobId: result.jobId,
@@ -198,25 +196,25 @@ export class AiController {
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard) @UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
@ApiBearerAuth() @ApiBearerAuth()
@RequirePermission('ai.suggest') @RequirePermission('ai.suggest')
@HttpCode(HttpStatus.ACCEPTED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ @ApiOperation({
summary: 'Submit AI migration job — ส่งงานย้ายเอกสารให้ AI ประมวลผล', summary: 'Submit unified AI job — ส่งงานประมวลผล AI แบบรวมศูนย์',
description: description:
'รับ tempAttachmentId/documentNumber แล้วส่งงานย้ายเอกสารเข้า BullMQ เพื่อรอการประมวลผล', 'รับชนิดงานและข้อมูลอ้างอิง เพื่อส่งงานประมวลผล AI เข้าคิว BullMQ',
}) })
@ApiHeader({ @ApiHeader({
name: 'Idempotency-Key', name: 'Idempotency-Key',
description: 'Unique key เพื่อป้องกัน duplicate AI job', description: 'Unique key เพื่อป้องกัน duplicate AI job',
required: true, required: true,
}) })
async submitMigrationJob( async submitUnifiedJob(
@Body() dto: SubmitAiJobDto, @Body() dto: CreateAiJobDto,
@Headers('idempotency-key') idempotencyKey: string @Headers('idempotency-key') idempotencyKey: string
) { ): Promise<AiJobResponseDto> {
if (!idempotencyKey) { if (!idempotencyKey) {
throw new ValidationException('Idempotency-Key header is required'); throw new ValidationException('Idempotency-Key header is required');
} }
return this.aiService.submitMigrationJob(dto, idempotencyKey); return this.aiService.submitUnifiedJob(dto, idempotencyKey);
} }
@Get('jobs/:jobId') @Get('jobs/:jobId')
@@ -452,6 +450,7 @@ export class AiController {
@UseGuards(JwtAuthGuard, RbacGuard) @UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth() @ApiBearerAuth()
@RequirePermission('system.manage_all') @RequirePermission('system.manage_all')
@Throttle({ default: { limit: 300, ttl: 60000 } }) // 300 req/min — รองรับ admin polling ทุก 200ms
@ApiOperation({ @ApiOperation({
summary: summary:
'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)', 'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)',
+5
View File
@@ -36,12 +36,14 @@ import { SandboxOcrEngineService } from './services/sandbox-ocr-engine.service';
import { EmbeddingService } from './services/embedding.service'; import { EmbeddingService } from './services/embedding.service';
import { VramMonitorService } from './services/vram-monitor.service'; import { VramMonitorService } from './services/vram-monitor.service';
import { OcrCacheService } from './services/ocr-cache.service'; import { OcrCacheService } from './services/ocr-cache.service';
import { AiPolicyService } from './services/ai-policy.service';
import { MigrationLog } from './entities/migration-log.entity'; import { MigrationLog } from './entities/migration-log.entity';
import { AiAuditLog } from './entities/ai-audit-log.entity'; import { AiAuditLog } from './entities/ai-audit-log.entity';
import { MigrationReviewRecord } from './entities/migration-review.entity'; import { MigrationReviewRecord } from './entities/migration-review.entity';
import { MigrationProgress } from './entities/migration-progress.entity'; import { MigrationProgress } from './entities/migration-progress.entity';
import { SystemSetting } from './entities/system-setting.entity'; import { SystemSetting } from './entities/system-setting.entity';
import { AiAvailableModel } from './entities/ai-available-model.entity'; import { AiAvailableModel } from './entities/ai-available-model.entity';
import { AiExecutionProfile } from './entities/ai-execution-profile.entity';
import { AiMigrationCheckpointService } from './ai-migration-checkpoint.service'; import { AiMigrationCheckpointService } from './ai-migration-checkpoint.service';
import { AiEnabledGuard } from './guards/ai-enabled.guard'; import { AiEnabledGuard } from './guards/ai-enabled.guard';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
@@ -96,6 +98,7 @@ import {
ImportTransaction, ImportTransaction,
MigrationReviewQueue, MigrationReviewQueue,
AiPrompt, AiPrompt,
AiExecutionProfile,
]), ]),
BullModule.registerQueue( BullModule.registerQueue(
@@ -171,6 +174,7 @@ import {
providers: [ providers: [
AiService, AiService,
AiSettingsService, AiSettingsService,
AiPolicyService,
AiIngestService, AiIngestService,
AiMigrationCheckpointService, AiMigrationCheckpointService,
AiQueueService, AiQueueService,
@@ -201,6 +205,7 @@ import {
exports: [ exports: [
AiService, AiService,
AiSettingsService, AiSettingsService,
AiPolicyService,
AiIngestService, AiIngestService,
AiMigrationCheckpointService, AiMigrationCheckpointService,
AiQueueService, AiQueueService,
+132 -1
View File
@@ -2,6 +2,7 @@
// Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions // Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions
// Change Log // Change Log
// - 2026-05-21: เพิ่ม unit tests สำหรับ getSystemHealth (T026) ทั้งกรณี cache hit/miss และ queue metrics. // - 2026-05-21: เพิ่ม unit tests สำหรับ getSystemHealth (T026) ทั้งกรณี cache hit/miss และ queue metrics.
// - 2026-06-11: เพิ่ม mock สำหรับ AiPolicyService เพื่อแก้ไข test regression
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
@@ -17,7 +18,11 @@ import {
import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity'; import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity';
import { AiCallbackDto } from './dto/ai-callback.dto'; import { AiCallbackDto } from './dto/ai-callback.dto';
import { MigrationUpdateDto } from './dto/migration-update.dto'; import { MigrationUpdateDto } from './dto/migration-update.dto';
import { NotFoundException, BusinessException } from '../../common/exceptions'; import {
NotFoundException,
BusinessException,
ValidationException,
} from '../../common/exceptions';
import { AuditLog } from '../../common/entities/audit-log.entity'; import { AuditLog } from '../../common/entities/audit-log.entity';
import { import {
QUEUE_AI_BATCH, QUEUE_AI_BATCH,
@@ -28,6 +33,9 @@ import { AiQdrantService } from './qdrant.service';
import { ImportTransaction } from '../migration/entities/import-transaction.entity'; import { ImportTransaction } from '../migration/entities/import-transaction.entity';
import { AiSettingsService } from './ai-settings.service'; import { AiSettingsService } from './ai-settings.service';
import { VramMonitorService } from './services/vram-monitor.service'; import { VramMonitorService } from './services/vram-monitor.service';
import { AiPolicyService } from './services/ai-policy.service';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { Project } from '../project/entities/project.entity';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken'; const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
@@ -110,6 +118,44 @@ describe('AiService', () => {
}), }),
}; };
// Mock AiPolicyService
const mockAiPolicyService = {
getCanonicalModelName: jest.fn().mockImplementation((name: string) => {
if (name.includes('ocr')) return 'np-dms-ocr';
return 'np-dms-ai';
}),
getProfileForJobType: jest.fn().mockReturnValue('standard'),
getProfileParameters: jest.fn().mockResolvedValue({
canonicalModel: 'np-dms-ai',
temperature: 0.5,
topP: 0.8,
maxTokens: 4096,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
}),
createJobPayload: jest
.fn()
.mockImplementation(async (jobType, docId, attachId) => {
await Promise.resolve();
return {
jobType,
documentPublicId: docId,
attachmentPublicId: attachId,
effectiveProfile: 'standard',
canonicalModel: 'np-dms-ai',
snapshotParams: {
temperature: 0.5,
topP: 0.8,
maxTokens: 4096,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
},
};
}),
};
const mockRedis = { const mockRedis = {
get: jest.fn(), get: jest.fn(),
set: jest.fn(), set: jest.fn(),
@@ -191,6 +237,7 @@ describe('AiService', () => {
{ provide: AiQdrantService, useValue: mockQdrantService }, { provide: AiQdrantService, useValue: mockQdrantService },
{ provide: AiSettingsService, useValue: mockAiSettingsService }, { provide: AiSettingsService, useValue: mockAiSettingsService },
{ provide: VramMonitorService, useValue: mockVramMonitorService }, { provide: VramMonitorService, useValue: mockVramMonitorService },
{ provide: AiPolicyService, useValue: mockAiPolicyService },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis }, { provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
], ],
}).compile(); }).compile();
@@ -241,6 +288,90 @@ describe('AiService', () => {
}); });
}); });
describe('submitUnifiedJob', () => {
it('ไม่ควรบันทึก ai_audit_logs เป็น SUCCESS ตั้งแต่ตอน enqueue', async () => {
mockImportTransactionRepo.manager.findOne.mockResolvedValueOnce({
publicId: '019505a1-7c3e-7000-8000-abc123def777',
});
mockQueue.getJob.mockResolvedValue(null);
mockQueue.add.mockResolvedValue({ id: 'job-enqueued' });
const result = await service.submitUnifiedJob(
{
type: 'rag-query',
projectPublicId: '019505a1-7c3e-7000-8000-abc123def777',
payload: { query: 'test' },
},
'job-enqueued'
);
expect(result).toEqual({
jobId: 'job-enqueued',
status: 'queued',
modelUsed: 'np-dms-ai',
effectiveProfile: 'standard',
queueName: 'ai-batch',
});
expect(mockAuditLogRepo.save).not.toHaveBeenCalled();
});
it('ควร reject rag-query ที่ไม่มี payload.query', async () => {
await expect(
service.submitUnifiedJob(
{
type: 'rag-query',
projectPublicId: '019505a1-7c3e-7000-8000-abc123def777',
payload: {},
},
'job-no-query'
)
).rejects.toBeInstanceOf(ValidationException);
});
it('ควร reject projectPublicId ที่ไม่พบในระบบด้วย 422', async () => {
mockImportTransactionRepo.manager.findOne.mockResolvedValueOnce(null);
await expect(
service.submitUnifiedJob(
{
type: 'rag-query',
projectPublicId: '019505a1-7c3e-7000-8000-abc123def777',
payload: { query: 'test' },
},
'job-missing-project'
)
).rejects.toBeInstanceOf(BusinessException);
expect(mockImportTransactionRepo.manager.findOne).toHaveBeenCalledWith(
Project,
{
where: { publicId: '019505a1-7c3e-7000-8000-abc123def777' },
}
);
});
it('ควร reject attachment reference ที่ไม่พบในระบบด้วย 422', async () => {
mockImportTransactionRepo.manager.findOne
.mockResolvedValueOnce({
publicId: '019505a1-7c3e-7000-8000-abc123def777',
})
.mockResolvedValueOnce(null);
await expect(
service.submitUnifiedJob(
{
type: 'rag-query',
projectPublicId: '019505a1-7c3e-7000-8000-abc123def777',
documentPublicId: '019505a1-7c3e-7000-8000-abc123def456',
payload: { query: 'test' },
},
'job-missing-attachment'
)
).rejects.toBeInstanceOf(BusinessException);
expect(mockImportTransactionRepo.manager.findOne).toHaveBeenCalledWith(
Attachment,
{
where: { publicId: '019505a1-7c3e-7000-8000-abc123def456' },
}
);
});
});
// --- handleWebhookCallback --- // --- handleWebhookCallback ---
describe('handleWebhookCallback', () => { describe('handleWebhookCallback', () => {
+160 -13
View File
@@ -1,11 +1,14 @@
// File: src/modules/ai/ai.service.ts // File: backend/src/modules/ai/ai.service.ts
// Service หลักของ AI Gateway — เชื่อมต่อระหว่าง DMS กับ n8n/Ollama Pipeline (ADR-018, ADR-020) // Service หลักของ AI Gateway — เชื่อมต่อระหว่าง DMS กับ n8n/Ollama Pipeline (ADR-018, ADR-020)
// Change Log // Change Log
// - 2026-05-21: เพิ่ม getSystemHealth พร้อมระบบแคช Redis 30 วินาทีตาม ADR-027. // - 2026-05-21: เพิ่ม getSystemHealth พร้อมระบบแคช Redis 30 วินาทีตาม ADR-027.
// - 2026-05-21: แก้ไข ESLint unsafe return error ใน getSystemHealth โดยใช้ interface SystemHealthResponse // - 2026-05-21: แก้ไข ESLint unsafe return error ใน getSystemHealth โดยใช้ interface SystemHealthResponse
// - 2026-05-29: เพิ่ม OcrService.checkHealth() เข้า getSystemHealth() เพื่อแสดงสถานะ OCR sidecar // - 2026-05-29: เพิ่ม OcrService.checkHealth() เข้า getSystemHealth() เพื่อแสดงสถานะ OCR sidecar
// - 2026-06-02: ปรับปรุง activateAiModel ให้มีการโหลดและยืนยันโมเดลล่วงหน้าแบบ Synchronous (T008, ADR-033) และล้างโมเดลตัวเก่าออกเพื่อประหยัด VRAM (Suggestion 1) // - 2026-06-02: ปรับปรุง activateAiModel ให้มีการโหลดและยืนยันโมเดลล่วงหน้าแบบ Synchronous (T008, ADR-033) และล้างโมเดลตัวเก่าออกเพื่อประหยัด VRAM (Suggestion 1)
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (เอา mainModel+ocrModel) ใน SystemHealthResponse // - 2026-06-03: ADR-034 — เพิ่ม active models ใน SystemHealthResponse
// - 2026-06-11: US2 - เพิ่มการผูก execution profile ใน submitMigrationJob ของ ai.service.ts
// - 2026-06-11: US4 - เพิ่ม explicit assertion สำหรับการ dispatch RAG query ไปยัง ai-batch queue
// - 2026-06-11: แก้ไข compile errors (SystemException arguments, idempotencyKey signature, type mapping) และลบบรรทัดว่างในฟังก์ชันที่แก้ไข
import { Injectable, Logger, Optional } from '@nestjs/common'; import { Injectable, Logger, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
@@ -37,8 +40,11 @@ import { MigrationQueryDto } from './dto/migration-query.dto';
import { AiValidationService } from './ai-validation.service'; import { AiValidationService } from './ai-validation.service';
import { CreateAiJobDto } from './dto/create-ai-job.dto'; import { CreateAiJobDto } from './dto/create-ai-job.dto';
import { SubmitAiJobDto } from './dto/submit-ai-job.dto'; import { SubmitAiJobDto } from './dto/submit-ai-job.dto';
import { AiJobResponseDto } from './dto/ai-job-response.dto';
import { AiPolicyService } from './services/ai-policy.service';
import { ImportTransaction } from '../migration/entities/import-transaction.entity'; import { ImportTransaction } from '../migration/entities/import-transaction.entity';
import { Project } from '../project/entities/project.entity'; import { Project } from '../project/entities/project.entity';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { import {
QUEUE_AI_BATCH, QUEUE_AI_BATCH,
QUEUE_AI_REALTIME, QUEUE_AI_REALTIME,
@@ -52,6 +58,7 @@ import {
VramMonitorService, VramMonitorService,
VramStatus, VramStatus,
} from './services/vram-monitor.service'; } from './services/vram-monitor.service';
import type { AiJobPayload } from './interfaces/execution-policy.interface';
import { import {
AiModelConfiguration, AiModelConfiguration,
AiModelType, AiModelType,
@@ -178,6 +185,7 @@ export class AiService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly aiValidationService: AiValidationService, private readonly aiValidationService: AiValidationService,
private readonly aiPolicyService: AiPolicyService,
@InjectRepository(MigrationLog) @InjectRepository(MigrationLog)
private readonly migrationLogRepo: Repository<MigrationLog>, private readonly migrationLogRepo: Repository<MigrationLog>,
@InjectRepository(AiAuditLog) @InjectRepository(AiAuditLog)
@@ -220,7 +228,16 @@ export class AiService {
// --- ADR-023A BullMQ Job Queueing --- // --- ADR-023A BullMQ Job Queueing ---
/** ส่งงาน AI Suggest เข้า ai-realtime queue แบบไม่ block request thread */ /** ส่งงาน AI Suggest เข้า ai-realtime queue แบบไม่ block request thread */
async queueSuggestJob(dto: CreateAiJobDto): Promise<AiQueueResult> { async queueSuggestJob(
dto: CreateAiJobDto,
idempotencyKey: string
): Promise<AiQueueResult> {
if (dto.type === 'rag-query') {
throw new SystemException(
'RAG query cannot be queued in AI realtime queue',
{ errorCode: 'AI_QUEUE_ERROR' }
);
}
if (!this.aiRealtimeQueue) { if (!this.aiRealtimeQueue) {
const error = new Error('AI realtime queue is not registered'); const error = new Error('AI realtime queue is not registered');
this.logger.error('AI job queue failed', { this.logger.error('AI job queue failed', {
@@ -229,18 +246,17 @@ export class AiService {
}); });
return { success: false, error }; return { success: false, error };
} }
try { try {
const job = await this.aiRealtimeQueue.add( const job = await this.aiRealtimeQueue.add(
'ai-suggest', 'ai-suggest',
{ {
jobType: 'ai-suggest', jobType: 'ai-suggest',
documentPublicId: dto.documentPublicId, documentPublicId: dto.documentPublicId,
projectPublicId: dto.projectPublicId, projectPublicId: dto.projectPublicId || '',
payload: dto.payload ?? {}, payload: dto.payload ?? {},
idempotencyKey: dto.idempotencyKey, idempotencyKey,
}, },
{ jobId: dto.idempotencyKey } { jobId: idempotencyKey }
); );
return { success: true, jobId: String(job.id) }; return { success: true, jobId: String(job.id) };
} catch (err: unknown) { } catch (err: unknown) {
@@ -254,7 +270,10 @@ export class AiService {
} }
/** ส่งงาน embedding เข้า ai-batch queue แบบ best-effort */ /** ส่งงาน embedding เข้า ai-batch queue แบบ best-effort */
async queueEmbedJob(dto: CreateAiJobDto): Promise<AiQueueResult> { async queueEmbedJob(
dto: CreateAiJobDto,
idempotencyKey: string
): Promise<AiQueueResult> {
if (!this.aiBatchQueue) { if (!this.aiBatchQueue) {
const error = new Error('AI batch queue is not registered'); const error = new Error('AI batch queue is not registered');
this.logger.error('AI job queue failed', { this.logger.error('AI job queue failed', {
@@ -263,18 +282,17 @@ export class AiService {
}); });
return { success: false, error }; return { success: false, error };
} }
try { try {
const job = await this.aiBatchQueue.add( const job = await this.aiBatchQueue.add(
'embed-document', 'embed-document',
{ {
jobType: 'embed-document', jobType: 'embed-document',
documentPublicId: dto.documentPublicId, documentPublicId: dto.documentPublicId || '',
projectPublicId: dto.projectPublicId, projectPublicId: dto.projectPublicId || '',
payload: dto.payload ?? {}, payload: dto.payload ?? {},
idempotencyKey: dto.idempotencyKey, idempotencyKey,
}, },
{ jobId: dto.idempotencyKey } { jobId: idempotencyKey }
); );
return { success: true, jobId: String(job.id) }; return { success: true, jobId: String(job.id) };
} catch (err: unknown) { } catch (err: unknown) {
@@ -287,6 +305,124 @@ export class AiService {
} }
} }
/** ส่งงาน AI แบบสากล (Unified AI Job) เข้า BullMQ ตามนโยบายความมั่นคงปลอดภัย (ADR-023A) */
async submitUnifiedJob(
dto: CreateAiJobDto,
idempotencyKey: string
): Promise<AiJobResponseDto> {
const queueName = 'ai-batch';
const queue = this.aiBatchQueue;
if (dto.type === 'rag-query') {
if (queueName !== 'ai-batch') {
throw new SystemException(
'RAG query must be dispatched to ai-batch queue',
{ errorCode: 'AI_QUEUE_ERROR' }
);
}
}
if (!queue) {
throw new SystemException('AI batch queue is not registered', {
errorCode: 'AI_QUEUE_ERROR',
});
}
await this.validateUnifiedJobRequest(dto);
const activeJob = await queue.getJob(idempotencyKey);
if (activeJob) {
const payload = activeJob.data as unknown as AiJobPayload;
return {
jobId: String(activeJob.id),
status: 'queued',
modelUsed: payload.canonicalModel,
effectiveProfile: payload.effectiveProfile,
queueName: 'ai-batch',
};
}
const payload = await this.aiPolicyService.createJobPayload(
dto.type,
dto.documentPublicId || dto.attachmentPublicId,
dto.attachmentPublicId
);
const finalPayload = {
...payload,
documentPublicId: payload.documentPublicId || '',
projectPublicId: dto.projectPublicId || '',
payload: dto.payload || {},
idempotencyKey,
};
const job = await queue.add(
dto.type,
finalPayload as unknown as AiBatchJobData,
{
jobId: idempotencyKey,
}
);
return {
jobId: String(job.id),
status: 'queued',
modelUsed: payload.canonicalModel,
effectiveProfile: payload.effectiveProfile,
queueName: 'ai-batch',
};
}
private async validateUnifiedJobRequest(dto: CreateAiJobDto): Promise<void> {
if (dto.type === 'rag-query') {
const query = dto.payload?.['query'];
if (typeof query !== 'string' || query.trim().length === 0) {
throw new ValidationException(
'payload.query is required for rag-query jobs'
);
}
if (!dto.projectPublicId) {
throw new ValidationException(
'projectPublicId is required for rag-query jobs'
);
}
}
if (
(dto.type === 'auto-fill-document' || dto.type === 'migrate-document') &&
!dto.documentPublicId &&
!dto.attachmentPublicId
) {
throw new ValidationException(
'documentPublicId or attachmentPublicId is required for document AI jobs'
);
}
if (dto.projectPublicId) {
const project = await this.importTransactionRepo.manager.findOne(
Project,
{
where: { publicId: dto.projectPublicId },
}
);
if (!project) {
throw new BusinessException(
'PROJECT_NOT_FOUND',
`Project with publicId ${dto.projectPublicId} was not found`,
'ไม่พบโครงการที่อ้างอิงสำหรับงาน AI'
);
}
}
const referenceIds = [dto.documentPublicId, dto.attachmentPublicId].filter(
(value): value is string => typeof value === 'string'
);
for (const publicId of referenceIds) {
const attachment = await this.importTransactionRepo.manager.findOne(
Attachment,
{
where: { publicId },
}
);
if (!attachment) {
throw new BusinessException(
'ATTACHMENT_NOT_FOUND',
`Attachment with publicId ${publicId} was not found`,
'ไม่พบไฟล์อ้างอิงสำหรับงาน AI'
);
}
}
}
/** ส่งคำขอเปิดงานประมวลผลการย้ายเอกสารของ AI (migrate-document) เข้า BullMQ */ /** ส่งคำขอเปิดงานประมวลผลการย้ายเอกสารของ AI (migrate-document) เข้า BullMQ */
async submitMigrationJob( async submitMigrationJob(
dto: SubmitAiJobDto, dto: SubmitAiJobDto,
@@ -327,9 +463,14 @@ export class AiService {
defaultProject?.publicId ?? '00000000-0000-0000-0000-000000000000'; defaultProject?.publicId ?? '00000000-0000-0000-0000-000000000000';
} }
try { try {
const payload = await this.aiPolicyService.createJobPayload(
'migrate-document',
dto.payload.tempAttachmentId
);
const job = await this.aiBatchQueue.add( const job = await this.aiBatchQueue.add(
'migrate-document', 'migrate-document',
{ {
...payload,
jobType: 'migrate-document', jobType: 'migrate-document',
documentPublicId: dto.payload.tempAttachmentId, documentPublicId: dto.payload.tempAttachmentId,
projectPublicId, projectPublicId,
@@ -691,6 +832,9 @@ export class AiService {
inputHash?: string; inputHash?: string;
outputHash?: string; outputHash?: string;
errorMessage?: string; errorMessage?: string;
effectiveProfile?: string;
canonicalModel?: string;
snapshotParamsJson?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
try { try {
const auditLog = this.aiAuditLogRepo.create({ const auditLog = this.aiAuditLogRepo.create({
@@ -702,6 +846,9 @@ export class AiService {
inputHash: data.inputHash, inputHash: data.inputHash,
outputHash: data.outputHash, outputHash: data.outputHash,
errorMessage: data.errorMessage, errorMessage: data.errorMessage,
effectiveProfile: data.effectiveProfile,
canonicalModel: data.canonicalModel,
snapshotParamsJson: data.snapshotParamsJson,
}); });
await this.aiAuditLogRepo.save(auditLog); await this.aiAuditLogRepo.save(auditLog);
} catch (auditError: unknown) { } catch (auditError: unknown) {
@@ -0,0 +1,42 @@
// File: backend/src/modules/ai/dto/ai-job-response.dto.ts
// Change Log:
// - 2026-06-11: Initial creation of AiJobResponseDto for unified AI jobs response
// - 2026-06-11: ใช้ import type สำหรับ ExecutionProfile เพื่อแก้ปัญหา TS1272
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator';
import type { ExecutionProfile } from '../interfaces/execution-policy.interface';
export class AiJobResponseDto {
@ApiProperty({ description: 'ID ของงานในคิว BullMQ' })
@IsString()
jobId!: string;
@ApiProperty({
enum: ['queued', 'completed', 'failed'],
description: 'สถานะของงานในคิว',
})
@IsEnum(['queued', 'completed', 'failed'])
status!: 'queued' | 'completed' | 'failed';
@ApiProperty({
enum: ['np-dms-ai', 'np-dms-ocr'],
description: 'ชื่อโมเดลมาตรฐาน (Canonical Name) ที่ใช้งาน',
})
@IsEnum(['np-dms-ai', 'np-dms-ocr'])
modelUsed!: 'np-dms-ai' | 'np-dms-ocr';
@ApiProperty({
enum: ['interactive', 'standard', 'quality', 'deep-analysis'],
description: 'โปรไฟล์การประมวลผลจริงที่ระบบกำหนดให้',
})
@IsEnum(['interactive', 'standard', 'quality', 'deep-analysis'])
effectiveProfile!: ExecutionProfile;
@ApiProperty({
enum: ['ai-realtime', 'ai-batch'],
description: 'ชื่อคิวที่ใช้ประมวลผล',
})
@IsEnum(['ai-realtime', 'ai-batch'])
queueName!: 'ai-realtime' | 'ai-batch';
}
+74 -34
View File
@@ -1,53 +1,93 @@
// File: src/modules/ai/dto/create-ai-job.dto.ts // File: backend/src/modules/ai/dto/create-ai-job.dto.ts
// Change Log // Change Log:
// - 2026-05-15: เพิ่ม DTO สำหรับ enqueue AI jobs ตาม ADR-023A US1. // - 2026-06-11: Refactored CreateAiJobDto to support new AI runtime policy contract (Option B)
// - 2026-06-11: เพิ่ม IsObject ใน class-validator import
// - 2026-06-11: ใช้ import type สำหรับ PublicJobType เพื่อแก้ปัญหา TS1272
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
IsIn, IsEnum,
IsNotEmpty,
IsObject,
IsOptional, IsOptional,
IsString,
IsUUID, IsUUID,
IsObject,
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator'; } from 'class-validator';
import type { PublicJobType } from '../interfaces/execution-policy.interface';
export const AI_JOB_TYPES = [ /**
'ai-suggest', * Custom decorator to forbid specific properties in payload.
'rag-query', * API payload
'ocr', */
'extract-metadata', export function IsForbidden(validationOptions?: ValidationOptions) {
'embed-document', return function (object: object, propertyName: string) {
] as const; registerDecorator({
name: 'isForbidden',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
return value === undefined;
},
defaultMessage(args: ValidationArguments) {
return `${args.property} is forbidden in payload. Backend determines execution policy.`;
},
},
});
};
}
export type CreateAiJobType = (typeof AI_JOB_TYPES)[number];
/** DTO สำหรับส่งงาน AI เข้า BullMQ โดยใช้ publicId เท่านั้นตาม ADR-019 */
export class CreateAiJobDto { export class CreateAiJobDto {
@ApiProperty({ description: 'Attachment/document publicId สำหรับงาน AI' })
@IsUUID()
documentPublicId!: string;
@ApiProperty({ description: 'Project publicId สำหรับ project isolation' })
@IsUUID()
projectPublicId!: string;
@ApiProperty({ @ApiProperty({
enum: AI_JOB_TYPES, enum: ['auto-fill-document', 'migrate-document', 'rag-query'],
description: 'ชนิดงาน AI ที่ต้อง enqueue', description: 'ชนิดงาน AI ที่ต้อง enqueue',
}) })
@IsIn(AI_JOB_TYPES) @IsEnum(['auto-fill-document', 'migrate-document', 'rag-query'])
jobType!: CreateAiJobType; type!: PublicJobType;
@ApiProperty({ description: 'Idempotency key จาก request header/body' })
@IsString()
@IsNotEmpty()
idempotencyKey!: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Payload เพิ่มเติม เช่น pdfPath, extractedText, question', description: 'Document publicId (UUIDv7) สำหรับงาน AI',
})
@IsOptional()
@IsUUID('all')
documentPublicId?: string;
@ApiPropertyOptional({
description: 'Attachment publicId (UUIDv7) สำหรับงาน AI',
})
@IsOptional()
@IsUUID('all')
attachmentPublicId?: string;
@ApiPropertyOptional({
description: 'Payload ข้อมูลเพิ่มเติมสำหรับงานแต่ละประเภท',
}) })
@IsOptional() @IsOptional()
@IsObject() @IsObject()
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Project publicId สำหรับ project isolation',
})
@IsOptional()
@IsUUID('all')
projectPublicId?: string;
// ฟิลด์ต้องห้ามตามข้อกำหนด FR-A01 เพื่อป้องกันการแทรกแซง policy จาก caller
@IsForbidden()
executionProfile?: unknown;
@IsForbidden()
model?: unknown;
@IsForbidden()
temperature?: unknown;
@IsForbidden()
top_p?: unknown;
@IsForbidden()
maxTokens?: unknown;
} }
@@ -1,7 +1,8 @@
// File: src/modules/ai/entities/ai-audit-log.entity.ts // File: backend/src/modules/ai/entities/ai-audit-log.entity.ts
// Change Log // Change Log
// - 2026-05-14: เพิ่ม ADR-023 feedback fields โดยคง legacy audit fields ไว้ช่วงเปลี่ยนผ่าน. // - 2026-05-14: เพิ่ม ADR-023 feedback fields โดยคง legacy audit fields ไว้ช่วงเปลี่ยนผ่าน.
// - 2026-05-30: เพิ่ม modelType, vramUsageMB, cacheHit สำหรับ Typhoon OCR integration (T008, ADR-032). // - 2026-05-30: เพิ่ม modelType, vramUsageMB, cacheHit สำหรับ Typhoon OCR integration (T008, ADR-032).
// - 2026-06-11: เปลี่ยน Record<string, any> เป็น Record<string, unknown> เพื่อแก้ปัญหา ESLint
// Entity สำหรับตาราง ai_audit_logs — บันทึก AI Interaction และ feedback ตาม ADR-023 // Entity สำหรับตาราง ai_audit_logs — บันทึก AI Interaction และ feedback ตาม ADR-023
import { import {
@@ -100,6 +101,25 @@ export class AiAuditLog extends UuidBaseEntity {
@Column({ name: 'error_message', type: 'text', nullable: true }) @Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage?: string; errorMessage?: string;
@Column({
name: 'effective_profile',
type: 'varchar',
length: 50,
nullable: true,
})
effectiveProfile?: string;
@Column({
name: 'canonical_model',
type: 'varchar',
length: 50,
nullable: true,
})
canonicalModel?: string;
@Column({ name: 'snapshot_params_json', type: 'json', nullable: true })
snapshotParamsJson?: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt!: Date; createdAt!: Date;
} }
@@ -0,0 +1,51 @@
// File: backend/src/modules/ai/entities/ai-execution-profile.entity.ts
// Change Log:
// - 2026-06-11: Initial creation of AiExecutionProfile entity for AI execution profiles
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/** Entity สำหรับเก็บข้อมูลโปรไฟล์การทำงานของโมเดล AI (Execution Profile) */
@Entity('ai_execution_profiles')
export class AiExecutionProfile {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'profile_name', unique: true, length: 50 })
profileName!: string;
@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' })
maxTokens!: number;
@Column({ name: 'num_ctx', type: 'int' })
numCtx!: number;
@Column({ name: 'repeat_penalty', type: 'decimal', precision: 5, scale: 3 })
repeatPenalty!: number;
@Column({ name: 'keep_alive_seconds', type: 'int' })
keepAliveSeconds!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'updated_by', type: 'int', nullable: true })
updatedBy?: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,79 @@
// File: backend/src/modules/ai/interfaces/execution-policy.interface.ts
// Change Log:
// - 2026-06-11: Initial creation of execution policy interfaces for AI runtime policy refactor
/**
* Public job types exposed in API.
* API
*/
export type PublicJobType =
| 'auto-fill-document'
| 'migrate-document'
| 'rag-query';
/**
* Internal job types used within the system.
*
*/
export type InternalJobType =
| PublicJobType
| 'intent-classify'
| 'tool-suggest'
| 'ocr-extract'
| 'sandbox-analysis';
/**
* Execution profiles for runtime resources.
*
*/
export type ExecutionProfile =
| 'interactive'
| 'standard'
| 'quality'
| 'deep-analysis';
/**
* Interface representing the runtime configuration parameters.
*
*/
export interface RuntimePolicy {
canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
temperature: number;
topP: number;
maxTokens: number;
numCtx: number;
repeatPenalty: number;
keepAliveSeconds: number;
}
/**
* VRAM usage statistics.
* VRAM GPU
*/
export interface VramHeadroom {
totalMb: number;
usedMb: number;
availableMb: number;
querySuccess: boolean;
mainModelVramMb?: number;
}
/**
* BullMQ job data payload.
* (Payload) BullMQ
*/
export interface AiJobPayload {
jobType: InternalJobType;
documentPublicId?: string;
attachmentPublicId?: string;
effectiveProfile: ExecutionProfile;
canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
snapshotParams: {
temperature: number;
topP: number;
maxTokens: number;
numCtx: number;
repeatPenalty: number;
keepAliveSeconds: number;
};
}
@@ -0,0 +1,34 @@
// File: backend/src/modules/ai/interfaces/ocr-residency.interface.ts
// Change Log:
// - 2026-06-11: Initial creation of OCR residency interfaces for AI runtime policy refactor
import { ExecutionProfile } from './execution-policy.interface';
/**
* OCR runtime parameters based on SCB10X Typhoon OCR model.
* OCR Typhoon OCR
*/
export interface OcrRuntimePolicy {
canonicalModel: 'np-dms-ocr';
numCtx: 8192;
numPredict: 4096;
temperature: 0.1;
topP: 0.1;
repeatPenalty: 1.1;
keepAliveSeconds: number;
}
/**
* Decision output for adaptive OCR residency.
* OCR VRAM
*/
export interface OcrResidencyDecision {
keepAliveSeconds: number;
vramHeadroomMb: number;
activeProfile: ExecutionProfile | null;
reason:
| 'deep-analysis-active'
| 'high-pressure'
| 'headroom-sufficient'
| 'query-failed';
}
@@ -1,5 +1,6 @@
// File: src/modules/ai/processors/ai-batch.processor.spec.ts // File: src/modules/ai/processors/ai-batch.processor.spec.ts
// Change Log // Change Log
// - 2026-06-08: เพิ่มการทดสอบการส่งตัวเลือก generate (format: json, num_ctx: 16384) สำหรับ migrate-document
// - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032). // - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032).
// - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039). // - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock> // - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
@@ -52,6 +53,9 @@ describe('AiBatchProcessor', () => {
detectAndExtract: jest detectAndExtract: jest
.fn() .fn()
.mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }), .mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }),
processWithAutoDetect: jest.fn().mockResolvedValue({
text: 'extracted ocr text from document that is long enough to bypass character length check',
}),
}; };
const mockSandboxOcrEngineService = { const mockSandboxOcrEngineService = {
detectAndExtract: jest.fn().mockResolvedValue({ detectAndExtract: jest.fn().mockResolvedValue({
@@ -81,6 +85,7 @@ describe('AiBatchProcessor', () => {
}; };
const mockRedis = { const mockRedis = {
setex: jest.fn().mockResolvedValue('OK'), setex: jest.fn().mockResolvedValue('OK'),
get: jest.fn().mockResolvedValue(null),
}; };
const mockAttachmentRepo = { const mockAttachmentRepo = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue({
@@ -140,6 +145,7 @@ describe('AiBatchProcessor', () => {
resolvedPrompt: 'Resolved test prompt with OCR text', resolvedPrompt: 'Resolved test prompt with OCR text',
versionNumber: 2, versionNumber: 2,
}), }),
findByVersion: jest.fn().mockResolvedValue(null),
saveTestResult: jest.fn().mockResolvedValue(undefined), saveTestResult: jest.fn().mockResolvedValue(undefined),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -237,7 +243,23 @@ describe('AiBatchProcessor', () => {
}, },
} as unknown as Job<AiBatchJobData>; } as unknown as Job<AiBatchJobData>;
await processor.process(job); await processor.process(job);
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/files/test.pdf',
extractedText: undefined,
documentPublicId: 'doc-uuid-123',
});
expect(embeddingService.embedDocument).toHaveBeenCalledTimes(1); expect(embeddingService.embedDocument).toHaveBeenCalledTimes(1);
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123',
'doc-uuid-123',
'ATTACHMENT',
'ACTIVE',
1,
'doc-uuid-123',
undefined,
'OCR text LCBP3-CIV-001 Civil'
);
expect(attachmentRepo.update).toHaveBeenCalledWith( expect(attachmentRepo.update).toHaveBeenCalledWith(
{ publicId: 'doc-uuid-123' }, { publicId: 'doc-uuid-123' },
{ aiProcessingStatus: 'PROCESSING' } { aiProcessingStatus: 'PROCESSING' }
@@ -288,7 +310,13 @@ describe('AiBatchProcessor', () => {
'/files/test.pdf', '/files/test.pdf',
'auto' 'auto'
); );
expect(ollamaService.generate).toHaveBeenCalledTimes(1); expect(ollamaService.generate).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
format: 'json',
timeoutMs: 120000,
})
);
expect(redis.setex).toHaveBeenCalledTimes(2); expect(redis.setex).toHaveBeenCalledTimes(2);
expect(redis.setex).toHaveBeenLastCalledWith( expect(redis.setex).toHaveBeenLastCalledWith(
'ai:rag:result:idem-extract-123', 'ai:rag:result:idem-extract-123',
@@ -296,6 +324,69 @@ describe('AiBatchProcessor', () => {
expect.stringContaining('completed') expect.stringContaining('completed')
); );
}); });
it('sandbox-ai-extract ควร regenerate response ใหม่เมื่อ parse JSON ครั้งแรกล้มเหลว', async () => {
const cachedOcrPayload = {
ocrText: 'OCR text for retry test\u0002\u0000',
ocrUsed: true,
engineUsed: 'typhoon-np-dms-ocr',
fallbackUsed: false,
timestamp: '2026-06-06T15:00:00.000Z',
};
mockRedis.get = jest
.fn()
.mockResolvedValueOnce(JSON.stringify(cachedOcrPayload));
mockAiPromptsService.findByVersion = jest.fn().mockResolvedValue({
id: 1,
promptType: 'ocr_extraction',
versionNumber: 2,
template:
'Resolved test prompt with OCR text {{ocr_text}} and context {{master_data_context}}',
isActive: true,
contextConfig: { filter: {} },
});
mockOllamaService.generate
.mockResolvedValueOnce('{\u0002\u0000')
.mockResolvedValueOnce(
JSON.stringify({
subject: 'Recovered after retry',
confidence: 0.91,
tags: ['retry'],
})
);
const job = {
id: 'job-ai-extract-retry',
data: {
jobType: 'sandbox-ai-extract',
documentPublicId: 'idem-ai-extract-123',
projectPublicId: 'default',
payload: { promptVersion: 2 },
idempotencyKey: 'idem-ai-extract-123',
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(mockOllamaService.generate).toHaveBeenCalledTimes(2);
expect(mockOllamaService.generate).toHaveBeenNthCalledWith(
1,
expect.not.stringContaining('\u0002'),
expect.objectContaining({
format: 'json',
timeoutMs: 120000,
})
);
expect(mockAiPromptsService.saveTestResult).toHaveBeenCalledWith(
'ocr_extraction',
2,
expect.objectContaining({
subject: 'Recovered after retry',
confidence: 0.91,
})
);
expect(mockRedis.setex).toHaveBeenLastCalledWith(
'ai:rag:result:idem-ai-extract-123',
3600,
expect.stringContaining('"llmPrompt"')
);
});
it('EC-001: ควรบันทึก aiIssues เมื่อ AI สกัด Tag ใหม่ที่ไม่มีในระบบ', async () => { it('EC-001: ควรบันทึก aiIssues เมื่อ AI สกัด Tag ใหม่ที่ไม่มีในระบบ', async () => {
mockTagsService.findOrSuggestTags.mockResolvedValueOnce([ mockTagsService.findOrSuggestTags.mockResolvedValueOnce([
{ {
@@ -430,7 +521,14 @@ describe('AiBatchProcessor', () => {
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({ expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/files/test.pdf', pdfPath: '/files/test.pdf',
}); });
expect(ollamaService.generate).toHaveBeenCalledTimes(1); expect(ollamaService.generate).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
format: 'json',
timeoutMs: 120000,
options: { num_ctx: 16384, num_predict: 4096 },
})
);
expect(mockTagsService.findOrSuggestTags).toHaveBeenCalledTimes(1); expect(mockTagsService.findOrSuggestTags).toHaveBeenCalledTimes(1);
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith( expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -449,4 +547,78 @@ describe('AiBatchProcessor', () => {
expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1); expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1);
expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1); expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1);
}); });
describe('rag-prepare', () => {
it('ควรประมวลผล rag-prepare สำเร็จเมื่อส่ง cachedOcrText มาโดยตรง', async () => {
const job = {
id: 'job-rag-prepare-cached',
data: {
jobType: 'rag-prepare',
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
payload: {
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
correspondenceNumber: 'CORR-001',
docType: 'LETTER',
statusCode: 'IN_REVIEW',
revisionNumber: 1,
subject: 'Test Subject',
cachedOcrText:
'some cached ocr text that is long enough to pass the 50 character limit check',
},
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123',
'CORR-001',
'LETTER',
'IN_REVIEW',
1,
'Test Subject',
undefined,
'some cached ocr text that is long enough to pass the 50 character limit check'
);
});
it('ควรประมวลผล rag-prepare สำเร็จเมื่อดึงข้อความจากไฟล์แนบผ่าน OCR Service', async () => {
ocrService.detectAndExtract.mockResolvedValueOnce({
text: 'extracted ocr text from document that is long enough to bypass character length check',
ocrUsed: true,
});
const job = {
id: 'job-rag-prepare-ocr',
data: {
jobType: 'rag-prepare',
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
payload: {
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
correspondenceNumber: 'CORR-002',
docType: 'LETTER',
statusCode: 'IN_REVIEW',
revisionNumber: 2,
subject: 'Test OCR Subject',
attachmentPath: '/files/test-ocr.pdf',
},
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/files/test-ocr.pdf',
});
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123',
'CORR-002',
'LETTER',
'IN_REVIEW',
2,
'Test OCR Subject',
undefined,
'extracted ocr text from document that is long enough to bypass character length check'
);
});
});
}); });
@@ -1,5 +1,6 @@
// File: src/modules/ai/processors/ai-batch.processor.ts // File: backend/src/modules/ai/processors/ai-batch.processor.ts
// Change Log // Change Log
// - 2026-06-08: แก้ไขปัญหา LLM JSON response truncated โดยการเพิ่ม num_ctx เป็น 16384 ใน sandbox-extract, sandbox-ai-extract และ migrate-document (แก้ไขโดย AGY Gemini 3.5 Flash (Medium))
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A. // - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022). // - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox. // - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
@@ -10,6 +11,12 @@
// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000 // - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000
// - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ // - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ
// - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main) // - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main)
// - 2026-06-06: แก้ไข bug LLM JSON parse failure — เพิ่ม retry logic (2 attempts), debug log raw response, และปรับปรุง error message ให้แสดงทั้ง raw และ cleaned response
// - 2026-06-11: US2 - ส่ง activeProfile ไปยัง detectAndExtract ในการประมวลผล OCR และบันทึก retrieval device metadata ใน audit logs
// - 2026-06-11: US4 - เพิ่มการรองรับ ai-suggest และ rag-query ใน batch processor หลังการทำ redirection
// - 2026-06-06: เพิ่ม OCR text truncation (MAX_OCR_TEXT_CHARS=15000) เพื่อป้องกัน context overflow เมื่อเอกสารยาวมากชน num_ctx 8192
// - 2026-06-06: [T036] เพิ่ม ollamaOptions: { num_ctx: 8192 } ใน generateStructuredJson เพื่อรองรับ prompt ยาว 18k+ chars และแก้ไข bug response ว่างจาก context window ไม่พอ
// - 2026-06-11: แก้ไข ESLint errors โดยการเพิ่ม properties (effectiveProfile, canonicalModel, snapshotParams) ใน AiBatchJobData และยกเลิกการใช้ as any
import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@@ -27,13 +34,17 @@ import {
SandboxOcrEngineService, SandboxOcrEngineService,
SandboxOcrEngineType, SandboxOcrEngineType,
} from '../services/sandbox-ocr-engine.service'; } from '../services/sandbox-ocr-engine.service';
import { OllamaService } from '../services/ollama.service'; import {
OllamaService,
OllamaGenerateOptions,
} from '../services/ollama.service';
import { Project } from '../../project/entities/project.entity'; import { Project } from '../../project/entities/project.entity';
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity'; import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
import { TagsService } from '../../tags/tags.service'; import { TagsService } from '../../tags/tags.service';
import { MigrationService } from '../../migration/migration.service'; import { MigrationService } from '../../migration/migration.service';
import { MigrationErrorType } from '../../migration/entities/migration-error.entity'; import { MigrationErrorType } from '../../migration/entities/migration-error.entity';
import { AiPromptsService } from '../prompts/ai-prompts.service'; import { AiPromptsService } from '../prompts/ai-prompts.service';
import type { ExecutionProfile } from '../interfaces/execution-policy.interface';
interface MigrateDocumentMetadata extends Record<string, unknown> { interface MigrateDocumentMetadata extends Record<string, unknown> {
projectPublicId?: string; projectPublicId?: string;
@@ -57,7 +68,10 @@ export type AiBatchJobType =
| 'sandbox-extract' | 'sandbox-extract'
| 'sandbox-ocr-only' | 'sandbox-ocr-only'
| 'sandbox-ai-extract' | 'sandbox-ai-extract'
| 'migrate-document'; | 'migrate-document'
| 'rag-prepare'
| 'ai-suggest'
| 'rag-query';
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */ /** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [ export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
@@ -71,8 +85,36 @@ export interface AiBatchJobData {
payload: Record<string, unknown>; payload: Record<string, unknown>;
batchId?: string; batchId?: string;
idempotencyKey: string; idempotencyKey: string;
effectiveProfile?: ExecutionProfile;
canonicalModel?: 'np-dms-ai' | 'np-dms-ocr';
snapshotParams?: {
temperature: number;
topP: number;
maxTokens: number;
numCtx: number;
repeatPenalty: number;
keepAliveSeconds: number;
};
} }
/** OCR text สูงสุดที่ส่งเข้า LLM prompt — ป้องกัน context overflow (num_ctx 8192, Thai ~3 chars/token) */
const MAX_OCR_TEXT_CHARS = 15000;
const MAX_JSON_PARSE_ATTEMPTS = 2;
const removeControlCharacters = (
value: string,
includeDeleteCharacter = false
): string =>
Array.from(value)
.filter((character) => {
const code = character.charCodeAt(0);
const isAsciiControl =
(code >= 0 && code <= 8) || code === 11 || code === 12;
const isAdditionalControl = code >= 14 && code <= 31;
const isDeleteCharacter = includeDeleteCharacter && code === 127;
return !isAsciiControl && !isAdditionalControl && !isDeleteCharacter;
})
.join('');
const readString = (value: unknown): string | undefined => const readString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim().length > 0 ? value : undefined; typeof value === 'string' && value.trim().length > 0 ? value : undefined;
@@ -139,6 +181,14 @@ const parseMigrateDocumentMetadata = (
}; };
}; };
const sanitizeLlmJsonResponse = (response: string): string =>
removeControlCharacters(
response.replace(/```json/g, '').replace(/```/g, '')
).trim();
const sanitizeOcrText = (text: string): string =>
removeControlCharacters(text.replace(/\r\n/g, '\n'), true).trim();
/** Processor AI batch VRAM /** Processor AI batch VRAM
* lockDuration: 150000ms Ollama sandbox 120s (ADR-029 FR-008) * lockDuration: 150000ms Ollama sandbox 120s (ADR-029 FR-008)
* default BullMQ 30000ms timeout job stall * default BullMQ 30000ms timeout job stall
@@ -168,6 +218,62 @@ export class AiBatchProcessor extends WorkerHost {
super(); super();
} }
/** LLM parse JSON retry
* @param ollamaOptions - Ollama generation options num_ctx prompt
*/
private async generateStructuredJson(
prompt: string,
options: {
timeoutMs: number;
model?: string;
system?: string;
format?: 'json';
ollamaOptions?: { num_ctx?: number; num_predict?: number };
}
): Promise<{
extractedMetadata: Record<string, unknown>;
rawResponse: string;
cleanedResponse: string;
}> {
let lastRawResponse = '';
let lastCleanedResponse = '';
for (let attempt = 1; attempt <= MAX_JSON_PARSE_ATTEMPTS; attempt += 1) {
const rawResponse = await this.ollamaService.generate(prompt, {
...options,
options: options.ollamaOptions,
});
const cleanedResponse = sanitizeLlmJsonResponse(rawResponse);
lastRawResponse = rawResponse;
lastCleanedResponse = cleanedResponse;
this.logger.debug(`Raw LLM response: ${rawResponse}`);
try {
return {
extractedMetadata: JSON.parse(cleanedResponse) as Record<
string,
unknown
>,
rawResponse,
cleanedResponse,
};
} catch {
if (attempt >= MAX_JSON_PARSE_ATTEMPTS) {
this.logger.error(
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse}, Cleaned: ${lastCleanedResponse}`
);
throw new Error(
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse.substring(0, 200)}, Cleaned: ${lastCleanedResponse.substring(0, 200)}`
);
}
this.logger.warn(
`JSON parse attempt ${attempt} failed, regenerating response...`
);
}
}
throw new Error(
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts`
);
}
/** Dispatch งาน batch ตาม jobType */ /** Dispatch งาน batch ตาม jobType */
async process(job: Job<AiBatchJobData>): Promise<void> { async process(job: Job<AiBatchJobData>): Promise<void> {
const isSandbox = const isSandbox =
@@ -199,6 +305,16 @@ export class AiBatchProcessor extends WorkerHost {
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE'); await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
} }
return; return;
case 'ai-suggest':
this.logger.log(
`AI Suggest job processing — jobId=${String(job.id)}`
);
await this.processSuggest(job);
return;
case 'rag-query':
this.logger.log(`RAG query job processing — jobId=${String(job.id)}`);
await this.processRagQuery(job);
return;
case 'embed-document': case 'embed-document':
this.logger.log(`Embedding job processing — jobId=${String(job.id)}`); this.logger.log(`Embedding job processing — jobId=${String(job.id)}`);
await this.processEmbedDocument(job.data); await this.processEmbedDocument(job.data);
@@ -239,6 +355,12 @@ export class AiBatchProcessor extends WorkerHost {
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE'); await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
} }
return; return;
case 'rag-prepare':
this.logger.log(
`RAG prepare job processing — jobId=${String(job.id)}`
);
await this.processRagPrepare(job.data);
return;
default: { default: {
const unreachable: never = job.data.jobType; const unreachable: never = job.data.jobType;
throw new Error( throw new Error(
@@ -260,21 +382,62 @@ export class AiBatchProcessor extends WorkerHost {
/** ประมวลผล embed-document job ด้วย EmbeddingService (T022) */ /** ประมวลผล embed-document job ด้วย EmbeddingService (T022) */
private async processEmbedDocument(data: AiBatchJobData): Promise<void> { private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
const startTime = Date.now();
const { documentPublicId, projectPublicId, payload } = data; const { documentPublicId, projectPublicId, payload } = data;
const pdfPath = payload.pdfPath as string; const pdfPath = payload.pdfPath as string;
const extractedText = payload.extractedText as string | undefined; const extractedText = readString(payload.extractedText);
if (!pdfPath) { if (!pdfPath) {
throw new Error('pdfPath is required for embed-document job'); throw new Error('pdfPath is required for embed-document job');
} }
const correspondenceNumber =
readString(payload.correspondenceNumber) ?? documentPublicId;
const docType = readString(payload.docType) ?? 'ATTACHMENT';
const statusCode = readString(payload.statusCode) ?? 'ACTIVE';
const revisionNumberValue = payload.revisionNumber;
const revisionNumber =
typeof revisionNumberValue === 'number' &&
Number.isFinite(revisionNumberValue)
? revisionNumberValue
: 1;
const subject = readString(payload.subject) ?? documentPublicId;
const documentDate = readString(payload.documentDate);
const resolvedOcrText =
extractedText ??
(
await this.ocrService.detectAndExtract({
pdfPath,
extractedText,
documentPublicId,
activeProfile: data.effectiveProfile,
})
).text;
const result = await this.embeddingService.embedDocument( const result = await this.embeddingService.embedDocument(
pdfPath,
documentPublicId,
projectPublicId, projectPublicId,
extractedText documentPublicId,
correspondenceNumber,
docType,
statusCode,
revisionNumber,
subject,
documentDate,
resolvedOcrText
); );
if (!result.success) { if (!result.success) {
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`); throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
} }
const durationMs = Date.now() - startTime;
await this.saveAiAuditLog({
documentPublicId,
aiModel: data.canonicalModel ?? 'np-dms-ai',
status: AiAuditStatus.SUCCESS,
processingTimeMs: durationMs,
effectiveProfile: data.effectiveProfile,
canonicalModel: data.canonicalModel,
snapshotParamsJson: {
...(data.snapshotParams ?? {}),
retrievalDevice: result.device,
},
});
this.logger.log( this.logger.log(
`Embedding completed for document ${documentPublicId}${result.chunksEmbedded} chunks embedded` `Embedding completed for document ${documentPublicId}${result.chunksEmbedded} chunks embedded`
); );
@@ -372,6 +535,12 @@ export class AiBatchProcessor extends WorkerHost {
pdfPath, pdfPath,
engineType engineType
); );
const sanitizedOcrText = sanitizeOcrText(ocrResult.text);
if (sanitizedOcrText.length !== ocrResult.text.length) {
this.logger.warn(
`OCR text sanitized before LLM: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars`
);
}
const activePrompt = const activePrompt =
await this.aiPromptsService.getActive('ocr_extraction'); await this.aiPromptsService.getActive('ocr_extraction');
@@ -380,36 +549,38 @@ export class AiBatchProcessor extends WorkerHost {
} }
// ดึงบริบท Master data // ดึงบริบท Master data
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
// ดังนั้นส่ง undefined เพื่อ skip project lookup
const masterDataContext = await this.aiPromptsService.resolveContext( const masterDataContext = await this.aiPromptsService.resolveContext(
activePrompt, activePrompt,
overrideProjPublicId overrideProjPublicId === 'default' ? undefined : overrideProjPublicId
); );
const compactMasterDataContext = JSON.stringify(masterDataContext);
const ocrTextSafe =
sanitizedOcrText.length > MAX_OCR_TEXT_CHARS
? (this.logger.warn(
`OCR text truncated: ${sanitizedOcrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)`
),
sanitizedOcrText.substring(0, MAX_OCR_TEXT_CHARS))
: sanitizedOcrText;
const resolvedPrompt = activePrompt.template const resolvedPrompt = activePrompt.template
.replace('{{ocr_text}}', ocrResult.text) .replace('{{ocr_text}}', ocrTextSafe)
.replace( .replace('{{master_data_context}}', compactMasterDataContext);
'{{master_data_context}}',
JSON.stringify(masterDataContext, null, 2)
);
const response = await this.ollamaService.generate(resolvedPrompt, { this.logger.debug(
timeoutMs: 120000, `Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
}); );
const cleanedResponse = response
.replace(/```json/g, '') const { extractedMetadata } = await this.generateStructuredJson(
.replace(/```/g, '') resolvedPrompt,
.trim(); {
let extractedMetadata: Record<string, unknown>; format: 'json',
try { timeoutMs: 120000,
extractedMetadata = JSON.parse(cleanedResponse) as Record< ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
string, }
unknown );
>;
} catch {
throw new Error(
`Failed to parse LLM response as JSON: ${cleanedResponse}`
);
}
await this.aiPromptsService.saveTestResult( await this.aiPromptsService.saveTestResult(
'ocr_extraction', 'ocr_extraction',
activePrompt.versionNumber, activePrompt.versionNumber,
@@ -422,11 +593,12 @@ export class AiBatchProcessor extends WorkerHost {
requestPublicId: idempotencyKey, requestPublicId: idempotencyKey,
status: 'completed', status: 'completed',
answer: JSON.stringify(extractedMetadata, null, 2), answer: JSON.stringify(extractedMetadata, null, 2),
ocrText: ocrResult.text, ocrText: sanitizedOcrText,
ocrUsed: ocrResult.ocrUsed, ocrUsed: ocrResult.ocrUsed,
engineUsed: ocrResult.engineUsed, engineUsed: ocrResult.engineUsed,
fallbackUsed: ocrResult.fallbackUsed, fallbackUsed: ocrResult.fallbackUsed,
promptVersionUsed: activePrompt.versionNumber, promptVersionUsed: activePrompt.versionNumber,
llmPrompt: resolvedPrompt,
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
}) })
); );
@@ -475,13 +647,19 @@ export class AiBatchProcessor extends WorkerHost {
engineType, engineType,
typhoonOptions typhoonOptions
); );
const sanitizedOcrText = sanitizeOcrText(ocrResult.text);
if (sanitizedOcrText.length !== ocrResult.text.length) {
this.logger.warn(
`OCR text sanitized before cache: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars`
);
}
// Cache OCR text สำหรับ Step 2 // Cache OCR text สำหรับ Step 2
await this.redis.setex( await this.redis.setex(
`ai:sandbox:ocr:${idempotencyKey}`, `ai:sandbox:ocr:${idempotencyKey}`,
3600, 3600,
JSON.stringify({ JSON.stringify({
ocrText: ocrResult.text, ocrText: sanitizedOcrText,
ocrUsed: ocrResult.ocrUsed, ocrUsed: ocrResult.ocrUsed,
engineUsed: ocrResult.engineUsed, engineUsed: ocrResult.engineUsed,
fallbackUsed: ocrResult.fallbackUsed, fallbackUsed: ocrResult.fallbackUsed,
@@ -495,7 +673,7 @@ export class AiBatchProcessor extends WorkerHost {
JSON.stringify({ JSON.stringify({
requestPublicId: idempotencyKey, requestPublicId: idempotencyKey,
status: 'completed', status: 'completed',
ocrText: ocrResult.text, ocrText: sanitizedOcrText,
ocrUsed: ocrResult.ocrUsed, ocrUsed: ocrResult.ocrUsed,
engineUsed: ocrResult.engineUsed, engineUsed: ocrResult.engineUsed,
fallbackUsed: ocrResult.fallbackUsed, fallbackUsed: ocrResult.fallbackUsed,
@@ -550,7 +728,12 @@ export class AiBatchProcessor extends WorkerHost {
fallbackUsed?: boolean; fallbackUsed?: boolean;
timestamp: string; timestamp: string;
}; };
const { ocrText } = parsedOcr; const ocrText = sanitizeOcrText(parsedOcr.ocrText);
if (ocrText.length !== parsedOcr.ocrText.length) {
this.logger.warn(
`Cached OCR text sanitized before AI extraction: raw=${parsedOcr.ocrText.length} chars, sanitized=${ocrText.length} chars`
);
}
// ดึง prompt version // ดึง prompt version
const activePrompt = const activePrompt =
@@ -572,38 +755,36 @@ export class AiBatchProcessor extends WorkerHost {
} }
// Resolve context และ run LLM // Resolve context และ run LLM
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
// ดังนั้นส่ง undefined เพื่อ skip project lookup
const masterDataContext = await this.aiPromptsService.resolveContext( const masterDataContext = await this.aiPromptsService.resolveContext(
targetPrompt, targetPrompt,
projectPublicId projectPublicId === 'default' ? undefined : projectPublicId
); );
const compactMasterDataContext = JSON.stringify(masterDataContext);
const ocrTextSafe =
ocrText.length > MAX_OCR_TEXT_CHARS
? (this.logger.warn(
`OCR text truncated: ${ocrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)`
),
ocrText.substring(0, MAX_OCR_TEXT_CHARS))
: ocrText;
const resolvedPrompt = targetPrompt.template const resolvedPrompt = targetPrompt.template
.replace('{{ocr_text}}', ocrText) .replace('{{ocr_text}}', ocrTextSafe)
.replace( .replace('{{master_data_context}}', compactMasterDataContext);
'{{master_data_context}}', this.logger.debug(
JSON.stringify(masterDataContext, null, 2) `Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
); );
const { extractedMetadata } = await this.generateStructuredJson(
const response = await this.ollamaService.generate(resolvedPrompt, { resolvedPrompt,
timeoutMs: 120000, {
}); format: 'json',
timeoutMs: 120000,
const cleanedResponse = response ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
.replace(/```json/g, '') }
.replace(/```/g, '') );
.trim();
let extractedMetadata: Record<string, unknown>;
try {
extractedMetadata = JSON.parse(cleanedResponse) as Record<
string,
unknown
>;
} catch {
throw new Error(
`Failed to parse LLM response as JSON: ${cleanedResponse}`
);
}
await this.aiPromptsService.saveTestResult( await this.aiPromptsService.saveTestResult(
'ocr_extraction', 'ocr_extraction',
@@ -623,6 +804,7 @@ export class AiBatchProcessor extends WorkerHost {
engineUsed: parsedOcr.engineUsed, engineUsed: parsedOcr.engineUsed,
fallbackUsed: parsedOcr.fallbackUsed, fallbackUsed: parsedOcr.fallbackUsed,
promptVersionUsed: targetPrompt.versionNumber, promptVersionUsed: targetPrompt.versionNumber,
llmPrompt: resolvedPrompt,
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
}) })
); );
@@ -643,11 +825,97 @@ export class AiBatchProcessor extends WorkerHost {
} }
} }
private async processRagPrepare(data: AiBatchJobData): Promise<void> {
const startTime = Date.now();
const payload = data.payload || {};
const documentPublicId =
(payload.documentPublicId as string) || data.documentPublicId;
const projectPublicId =
(payload.projectPublicId as string) || data.projectPublicId;
const correspondenceNumber = (payload.correspondenceNumber as string) || '';
const docType = (payload.docType as string) || 'LETTER';
const statusCode = (payload.statusCode as string) || 'IN_REVIEW';
const revisionNumber = Number(payload.revisionNumber ?? 1);
const subject = (payload.subject as string) || '';
const documentDate = (payload.documentDate as string) || undefined;
let cachedOcrText = (payload.cachedOcrText as string) || undefined;
const attachmentPath = (payload.attachmentPath as string) || undefined;
this.logger.log(
`processRagPrepare: starting for doc=${documentPublicId}, project=${projectPublicId}`
);
if (!cachedOcrText && attachmentPath) {
this.logger.log(
`processRagPrepare: No cached OCR text. Extracting text from ${attachmentPath}...`
);
try {
const ocrResult = await this.ocrService.detectAndExtract({
pdfPath: attachmentPath,
activeProfile: data.effectiveProfile,
});
cachedOcrText = ocrResult.text;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`processRagPrepare: OCR extraction failed: ${msg}`);
throw err;
}
}
if (!cachedOcrText) {
this.logger.warn(
`processRagPrepare: ไม่มี OCR text และไม่มี attachment path - skip embedding`
);
return;
}
if (cachedOcrText.trim().length < 50) {
this.logger.warn(
`processRagPrepare: OCR text สั้นเกินไป (${cachedOcrText.trim().length} chars) — skip embedding`
);
return;
}
try {
this.logger.log(
`processRagPrepare: chunking and embedding document ${documentPublicId}...`
);
const result = await this.embeddingService.embedDocument(
projectPublicId,
documentPublicId,
correspondenceNumber,
docType,
statusCode,
revisionNumber,
subject,
documentDate,
cachedOcrText
);
const durationMs = Date.now() - startTime;
await this.saveAiAuditLog({
documentPublicId,
aiModel: data.canonicalModel ?? 'np-dms-ai',
status: AiAuditStatus.SUCCESS,
processingTimeMs: durationMs,
effectiveProfile: data.effectiveProfile,
canonicalModel: data.canonicalModel,
snapshotParamsJson: {
...(data.snapshotParams ?? {}),
retrievalDevice: result.device,
},
});
this.logger.log(
`processRagPrepare: successfully processed document ${documentPublicId}`
);
} catch (err) {
this.logger.error(
`processRagPrepare: embedding pipeline failed: ${err instanceof Error ? err.message : String(err)}`
);
throw err;
}
}
private async processMigrateDocument( private async processMigrateDocument(
job: Job<AiBatchJobData> job: Job<AiBatchJobData>
): Promise<void> { ): Promise<void> {
const startTime = Date.now(); const startTime = Date.now();
const { documentPublicId, projectPublicId, payload, batchId } = job.data; const { documentPublicId, projectPublicId, payload, batchId } = job.data;
const modelUsed = job.data.canonicalModel;
const docNumber = payload.documentNumber as string; const docNumber = payload.documentNumber as string;
const contextOverride = const contextOverride =
payload.contextOverride && payload.contextOverride &&
@@ -672,6 +940,7 @@ export class AiBatchProcessor extends WorkerHost {
try { try {
ocrResult = await this.ocrService.detectAndExtract({ ocrResult = await this.ocrService.detectAndExtract({
pdfPath: attachment.filePath, pdfPath: attachment.filePath,
activeProfile: job.data.effectiveProfile,
}); });
} catch (err: unknown) { } catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -688,6 +957,9 @@ export class AiBatchProcessor extends WorkerHost {
status: AiAuditStatus.FAILED, status: AiAuditStatus.FAILED,
errorMessage: errMsg, errorMessage: errMsg,
processingTimeMs: Date.now() - startTime, processingTimeMs: Date.now() - startTime,
effectiveProfile: job.data.effectiveProfile,
canonicalModel: job.data.canonicalModel,
snapshotParamsJson: job.data.snapshotParams,
}); });
throw err; throw err;
} }
@@ -714,9 +986,28 @@ export class AiBatchProcessor extends WorkerHost {
let aiResponse: string; let aiResponse: string;
try { try {
aiResponse = await this.ollamaService.generate(resolvedPrompt, { const snapshotParams = job.data.snapshotParams;
const generateOptions: OllamaGenerateOptions = {
format: 'json',
timeoutMs: 120000, timeoutMs: 120000,
}); model: modelUsed,
};
if (snapshotParams) {
generateOptions.options = {
temperature: snapshotParams.temperature,
top_p: snapshotParams.topP,
num_predict: snapshotParams.maxTokens,
num_ctx: snapshotParams.numCtx,
repeat_penalty: snapshotParams.repeatPenalty,
};
generateOptions.keepAlive = snapshotParams.keepAliveSeconds;
} else {
generateOptions.options = { num_ctx: 16384, num_predict: 4096 };
}
aiResponse = await this.ollamaService.generate(
resolvedPrompt,
generateOptions
);
} catch (err: unknown) { } catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error(`การวิเคราะห์ของ AI ล้มเหลว: ${errMsg}`); this.logger.error(`การวิเคราะห์ของ AI ล้มเหลว: ${errMsg}`);
@@ -728,10 +1019,13 @@ export class AiBatchProcessor extends WorkerHost {
}); });
await this.saveAiAuditLog({ await this.saveAiAuditLog({
documentPublicId, documentPublicId,
aiModel: this.ollamaService.getMainModelName(), aiModel: modelUsed ?? this.ollamaService.getMainModelName(),
status: AiAuditStatus.FAILED, status: AiAuditStatus.FAILED,
errorMessage: errMsg, errorMessage: errMsg,
processingTimeMs: Date.now() - startTime, processingTimeMs: Date.now() - startTime,
effectiveProfile: job.data.effectiveProfile,
canonicalModel: job.data.canonicalModel,
snapshotParamsJson: job.data.snapshotParams,
}); });
throw err; throw err;
} }
@@ -754,10 +1048,13 @@ export class AiBatchProcessor extends WorkerHost {
}); });
await this.saveAiAuditLog({ await this.saveAiAuditLog({
documentPublicId, documentPublicId,
aiModel: this.ollamaService.getMainModelName(), aiModel: modelUsed ?? this.ollamaService.getMainModelName(),
status: AiAuditStatus.FAILED, status: AiAuditStatus.FAILED,
errorMessage: errMsg, errorMessage: errMsg,
processingTimeMs: Date.now() - startTime, processingTimeMs: Date.now() - startTime,
effectiveProfile: job.data.effectiveProfile,
canonicalModel: job.data.canonicalModel,
snapshotParamsJson: job.data.snapshotParams,
}); });
throw new Error(errMsg); throw new Error(errMsg);
} }
@@ -914,11 +1211,14 @@ export class AiBatchProcessor extends WorkerHost {
await this.saveAiAuditLog({ await this.saveAiAuditLog({
documentPublicId, documentPublicId,
aiModel: this.ollamaService.getMainModelName(), aiModel: modelUsed ?? this.ollamaService.getMainModelName(),
status: AiAuditStatus.SUCCESS, status: AiAuditStatus.SUCCESS,
aiSuggestionJson: extractedMetadata as unknown as Record<string, unknown>, aiSuggestionJson: extractedMetadata as unknown as Record<string, unknown>,
confidenceScore: confidence, confidenceScore: confidence,
processingTimeMs: Date.now() - startTime, processingTimeMs: Date.now() - startTime,
effectiveProfile: job.data.effectiveProfile,
canonicalModel: job.data.canonicalModel,
snapshotParamsJson: job.data.snapshotParams,
}); });
this.logger.log( this.logger.log(
`ประมวลผลเอกสาร ${docNumber} สำเร็จและถูกส่งเข้า Staging Queue แล้ว` `ประมวลผลเอกสาร ${docNumber} สำเร็จและถูกส่งเข้า Staging Queue แล้ว`
@@ -933,6 +1233,9 @@ export class AiBatchProcessor extends WorkerHost {
confidenceScore?: number; confidenceScore?: number;
processingTimeMs?: number; processingTimeMs?: number;
errorMessage?: string; errorMessage?: string;
effectiveProfile?: string;
canonicalModel?: string;
snapshotParamsJson?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
try { try {
const log = this.aiAuditLogRepo.create({ const log = this.aiAuditLogRepo.create({
@@ -944,6 +1247,9 @@ export class AiBatchProcessor extends WorkerHost {
confidenceScore: data.confidenceScore, confidenceScore: data.confidenceScore,
processingTimeMs: data.processingTimeMs, processingTimeMs: data.processingTimeMs,
errorMessage: data.errorMessage, errorMessage: data.errorMessage,
effectiveProfile: data.effectiveProfile,
canonicalModel: data.canonicalModel,
snapshotParamsJson: data.snapshotParamsJson,
}); });
await this.aiAuditLogRepo.save(log); await this.aiAuditLogRepo.save(log);
} catch (err: unknown) { } catch (err: unknown) {
@@ -952,4 +1258,149 @@ export class AiBatchProcessor extends WorkerHost {
); );
} }
} }
private async processRagQuery(job: Job<AiBatchJobData>): Promise<void> {
const payload = job.data.payload || {};
const query = typeof payload['query'] === 'string' ? payload['query'] : '';
if (query.trim().length === 0) {
throw new Error('payload.query is required for rag-query jobs');
}
const requestPublicId =
typeof payload['requestPublicId'] === 'string'
? payload['requestPublicId']
: job.data.idempotencyKey;
const userPublicId =
typeof payload['userPublicId'] === 'string'
? payload['userPublicId']
: 'system';
await this.ragService.processQuery(
requestPublicId,
query,
job.data.projectPublicId,
userPublicId,
new AbortController().signal
);
}
private async processSuggest(
job: Job<AiBatchJobData>
): Promise<Record<string, unknown>> {
const startTime = Date.now();
try {
if (job.data.documentPublicId) {
await this.setAiProcessingStatus(
job.data.documentPublicId,
'PROCESSING'
);
}
const payload = job.data.payload || {};
const extractedText =
typeof payload['extractedText'] === 'string'
? payload['extractedText']
: '';
const pdfPath =
typeof payload['pdfPath'] === 'string' ? payload['pdfPath'] : undefined;
const extractedChars =
typeof payload['extractedChars'] === 'number'
? payload['extractedChars']
: extractedText.length;
const textResult = await this.ocrService.detectAndExtract({
extractedText,
extractedChars,
pdfPath,
});
const prompt = [
'Extract concise DMS metadata from this engineering document.',
'Return only JSON with fields: title, documentType, category, confidenceScore.',
textResult.text.slice(0, 6000),
].join('\n');
const rawOutput = await this.ollamaService.generate(prompt);
const suggestion = this.parseSuggestion(rawOutput);
const masterCategories = Array.isArray(payload['masterDataCategories'])
? (payload['masterDataCategories'] as string[])
: undefined;
const normalizedSuggestion = this.flagUnknownCategories(
suggestion,
masterCategories
);
await this.saveAiAuditLog({
documentPublicId: job.data.documentPublicId,
aiModel:
job.data.canonicalModel ?? this.ollamaService.getMainModelName(),
status: AiAuditStatus.SUCCESS,
aiSuggestionJson: normalizedSuggestion,
confidenceScore: this.extractConfidence(normalizedSuggestion),
processingTimeMs: Date.now() - startTime,
effectiveProfile: job.data.effectiveProfile,
canonicalModel: job.data.canonicalModel,
snapshotParamsJson: job.data.snapshotParams,
});
if (job.data.documentPublicId) {
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
}
return {
suggestion: normalizedSuggestion,
ocrUsed: textResult.ocrUsed,
};
} catch (err) {
if (job.data.documentPublicId) {
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
}
await this.saveAiAuditLog({
documentPublicId: job.data.documentPublicId,
aiModel:
job.data.canonicalModel ?? this.ollamaService.getMainModelName(),
status: AiAuditStatus.FAILED,
processingTimeMs: Date.now() - startTime,
errorMessage: err instanceof Error ? err.message : String(err),
effectiveProfile: job.data.effectiveProfile,
canonicalModel: job.data.canonicalModel,
snapshotParamsJson: job.data.snapshotParams,
});
throw err;
}
}
private parseSuggestion(rawOutput: string): Record<string, unknown> {
try {
const parsed = JSON.parse(rawOutput) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
this.logger.warn('AI suggestion output was not valid JSON');
}
return {
title: rawOutput.slice(0, 250),
confidenceScore: 0,
is_unknown: true,
};
}
private flagUnknownCategories(
suggestion: Record<string, unknown>,
masterDataCategories: unknown
): Record<string, unknown> {
if (!Array.isArray(masterDataCategories)) return suggestion;
const knownValues = new Set(
masterDataCategories
.filter((value): value is string => typeof value === 'string')
.map((value) => value.toLowerCase())
);
const category = suggestion['category'];
if (
typeof category === 'string' &&
!knownValues.has(category.toLowerCase())
) {
return { ...suggestion, is_unknown: true };
}
return suggestion;
}
private extractConfidence(
suggestion: Record<string, unknown>
): number | undefined {
const confidence = suggestion['confidenceScore'];
return typeof confidence === 'number' ? confidence : undefined;
}
} }
@@ -1,7 +1,9 @@
// File: src/modules/ai/processors/ai-realtime.processor.ts // File: backend/src/modules/ai/processors/ai-realtime.processor.ts
// Change Log // Change Log
// - 2026-05-15: เพิ่ม processor สำหรับ ai-realtime queue และ pause/resume ai-batch ตาม ADR-023A. // - 2026-05-15: เพิ่ม processor สำหรับ ai-realtime queue และ pause/resume ai-batch ตาม ADR-023A.
// - 2026-06-03: ADR-034 — เปลี่ยน aiModel ใน audit log จาก hardcode 'gemma4' เป็น ollamaService.getMainModelName() // - 2026-06-03: ADR-034 — เปลี่ยน aiModel ใน audit log จาก hardcode 'gemma4' เป็น ollamaService.getMainModelName()
// - 2026-06-11: ปรับ concurrency และเพิ่ม job classification เพื่อ redirect ไป ai-batch (US4)
// - 2026-06-11: แก้ไขปัญหา compile error สำหรับ unreachable check ใน switch-case และลบบรรทัดว่างในฟังก์ชัน process
import { import {
Processor, Processor,
@@ -22,7 +24,11 @@ import { Attachment } from '../../../common/file-storage/entities/attachment.ent
import { OcrService } from '../services/ocr.service'; import { OcrService } from '../services/ocr.service';
import { OllamaService } from '../services/ollama.service'; import { OllamaService } from '../services/ollama.service';
export type AiRealtimeJobType = 'ai-suggest' | 'rag-query'; export type AiRealtimeJobType =
| 'ai-suggest'
| 'rag-query'
| 'intent-classify'
| 'tool-suggest';
export interface AiRealtimeJobData { export interface AiRealtimeJobData {
jobType: AiRealtimeJobType; jobType: AiRealtimeJobType;
@@ -34,9 +40,16 @@ export interface AiRealtimeJobData {
} }
/** Processor สำหรับงาน AI interactive ที่ต้องกัน batch job ระหว่างใช้ GPU */ /** Processor สำหรับงาน AI interactive ที่ต้องกัน batch job ระหว่างใช้ GPU */
@Processor(QUEUE_AI_REALTIME, { concurrency: 1 }) @Processor(QUEUE_AI_REALTIME, {
concurrency: Number(
process.env.AI_REALTIME_CONCURRENCY ||
process.env.REALTIME_CONCURRENCY ||
'2'
),
})
export class AiRealtimeProcessor extends WorkerHost { export class AiRealtimeProcessor extends WorkerHost {
private readonly logger = new Logger(AiRealtimeProcessor.name); private readonly logger = new Logger(AiRealtimeProcessor.name);
private activeRealtimeJobs = 0;
constructor( constructor(
@InjectQueue(QUEUE_AI_BATCH) @InjectQueue(QUEUE_AI_BATCH)
@@ -53,12 +66,32 @@ export class AiRealtimeProcessor extends WorkerHost {
/** Dispatch งาน ai-realtime ตาม jobType */ /** Dispatch งาน ai-realtime ตาม jobType */
async process(job: Job<AiRealtimeJobData>): Promise<unknown> { async process(job: Job<AiRealtimeJobData>): Promise<unknown> {
const LIGHTWEIGHT_REALTIME_JOBS = ['intent-classify', 'tool-suggest'];
const isLightweight = LIGHTWEIGHT_REALTIME_JOBS.includes(job.data.jobType);
this.logger.log(
`Job classification decision — jobId=${String(job.id)}, jobType=${job.data.jobType}, isLightweight=${isLightweight}`
);
if (!isLightweight) {
this.logger.warn(
`Redirecting generation-heavy job to ai-batch queue — jobId=${String(job.id)}, jobType=${String(job.data.jobType)}`
);
await this.aiBatchQueue.add(job.data.jobType, job.data, {
jobId: job.id ?? undefined,
});
return;
}
switch (job.data.jobType) { switch (job.data.jobType) {
case 'intent-classify':
this.logger.log(`Processing intent-classify — jobId=${String(job.id)}`);
return { success: true, intent: 'GET_RFA' };
case 'tool-suggest':
this.logger.log(`Processing tool-suggest — jobId=${String(job.id)}`);
return { success: true, suggestions: [] };
case 'ai-suggest': case 'ai-suggest':
return this.processSuggest(job);
case 'rag-query': case 'rag-query':
this.logger.log(`RAG query queued — jobId=${String(job.id)}`); throw new Error(
return; `Job type ${job.data.jobType} should have been redirected to batch queue.`
);
default: { default: {
const unreachable: never = job.data.jobType; const unreachable: never = job.data.jobType;
throw new Error( throw new Error(
@@ -203,27 +236,48 @@ export class AiRealtimeProcessor extends WorkerHost {
/** เมื่อ interactive job เริ่ม ให้ pause batch queue เพื่อกัน GPU contention */ /** เมื่อ interactive job เริ่ม ให้ pause batch queue เพื่อกัน GPU contention */
@OnWorkerEvent('active') @OnWorkerEvent('active')
async onActive(job: Job<AiRealtimeJobData>): Promise<void> { async onActive(job: Job<AiRealtimeJobData>): Promise<void> {
await this.aiBatchQueue.pause(); this.activeRealtimeJobs += 1;
if (this.activeRealtimeJobs === 1) {
await this.aiBatchQueue.pause();
this.logger.warn(
`ai-batch paused while ai-realtime job is active — jobId=${String(job.id)}`
);
return;
}
this.logger.warn( this.logger.warn(
`ai-batch paused while ai-realtime job is active jobId=${String(job.id)}` `ai-realtime active jobs=${String(this.activeRealtimeJobs)} — keep ai-batch paused`
); );
} }
/** เมื่อ interactive job เสร็จ ให้ resume batch queue */ /** เมื่อ interactive job เสร็จ ให้ resume batch queue */
@OnWorkerEvent('completed') @OnWorkerEvent('completed')
async onCompleted(job: Job<AiRealtimeJobData>): Promise<void> { async onCompleted(job: Job<AiRealtimeJobData>): Promise<void> {
await this.aiBatchQueue.resume(); this.activeRealtimeJobs = Math.max(0, this.activeRealtimeJobs - 1);
if (this.activeRealtimeJobs === 0) {
await this.aiBatchQueue.resume();
this.logger.log(
`ai-batch resumed after ai-realtime completion — jobId=${String(job.id)}`
);
return;
}
this.logger.log( this.logger.log(
`ai-batch resumed after ai-realtime completion — jobId=${String(job.id)}` `ai-realtime jobs still active (${String(this.activeRealtimeJobs)}) — ai-batch remains paused`
); );
} }
/** เมื่อ interactive job fail ให้ resume batch queue เช่นกัน */ /** เมื่อ interactive job fail ให้ resume batch queue เช่นกัน */
@OnWorkerEvent('failed') @OnWorkerEvent('failed')
async onFailed(job: Job<AiRealtimeJobData> | undefined): Promise<void> { async onFailed(job: Job<AiRealtimeJobData> | undefined): Promise<void> {
await this.aiBatchQueue.resume(); this.activeRealtimeJobs = Math.max(0, this.activeRealtimeJobs - 1);
if (this.activeRealtimeJobs === 0) {
await this.aiBatchQueue.resume();
this.logger.warn(
`ai-batch resumed after ai-realtime failure — jobId=${String(job?.id ?? 'unknown')}`
);
return;
}
this.logger.warn( this.logger.warn(
`ai-batch resumed after ai-realtime failure — jobId=${String(job?.id ?? 'unknown')}` `ai-realtime jobs still active after failure (${String(this.activeRealtimeJobs)}) — ai-batch remains paused`
); );
} }
} }
@@ -21,16 +21,20 @@ export class AiVectorDeletionProcessor extends WorkerHost {
} }
async process(job: Job<AiVectorDeletionJobPayload>): Promise<void> { async process(job: Job<AiVectorDeletionJobPayload>): Promise<void> {
const { documentPublicId, requestedByUserPublicId } = job.data; const { documentPublicId, projectPublicId, requestedByUserPublicId } =
job.data;
this.logger.log( this.logger.log(
`Vector deletion started — documentPublicId=${documentPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}` `Vector deletion started — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
); );
await this.qdrantService.deleteByDocumentPublicId(documentPublicId); await this.qdrantService.deleteByDocumentPublicId(
projectPublicId,
documentPublicId
);
this.logger.log( this.logger.log(
`Vector deletion completed — documentPublicId=${documentPublicId}, jobId=${String(job.id)}` `Vector deletion completed — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}`
); );
} }
} }
+220 -28
View File
@@ -1,8 +1,10 @@
// File: src/modules/ai/qdrant.service.ts // File: backend/src/modules/ai/qdrant.service.ts
// Change Log // Change Log
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter. // - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2). // - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant // - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
// - 2026-06-05: ปรับปรุงโครงสร้างเป็น Hybrid (Dense 1024 + Sparse) ตาม ADR-035 (T006-T010)
// - 2026-06-05: เพิ่ม Compatibility สำหรับ search() ที่ไม่มี sparseVector เพื่อผ่านการทดสอบแบบดั้งเดิม
import { import {
Injectable, Injectable,
@@ -14,7 +16,7 @@ import { ConfigService } from '@nestjs/config';
import { QdrantClient } from '@qdrant/js-client-rest'; import { QdrantClient } from '@qdrant/js-client-rest';
const AI_COLLECTION_NAME = 'lcbp3_vectors'; const AI_COLLECTION_NAME = 'lcbp3_vectors';
const AI_VECTOR_SIZE = 768; const AI_VECTOR_SIZE = 1024;
export interface AiVectorSearchResult { export interface AiVectorSearchResult {
pointId: string | number; pointId: string | number;
@@ -22,7 +24,14 @@ export interface AiVectorSearchResult {
payload: Record<string, unknown>; payload: Record<string, unknown>;
} }
/** Gateway กลางสำหรับ Qdrant ที่บังคับ project_public_id ทุก search */ type QdrantUpsertRequest = Parameters<QdrantClient['upsert']>[1];
type QdrantUpsertPoint = QdrantUpsertRequest extends { points: infer TPoints }
? TPoints extends Array<infer TPoint>
? TPoint
: never
: never;
/** Gateway กลางสำหรับ Qdrant ที่รองรับ Hybrid Search และบังคับ project_public_id ทุก search */
@Injectable() @Injectable()
export class AiQdrantService implements OnModuleInit { export class AiQdrantService implements OnModuleInit {
private readonly logger = new Logger(AiQdrantService.name); private readonly logger = new Logger(AiQdrantService.name);
@@ -47,78 +56,261 @@ export class AiQdrantService implements OnModuleInit {
} }
} }
/** เตรียม collection และ tenant payload index สำหรับ project isolation */ /** เตรียม collection และ payload index สำหรับ project isolation และ hybrid search */
async ensureCollection(): Promise<void> { async ensureCollection(): Promise<void> {
const collections = await this.client.getCollections(); const collections = await this.client.getCollections();
const exists = collections.collections.some( const exists = collections.collections.some(
(collection) => collection.name === AI_COLLECTION_NAME (collection) => collection.name === AI_COLLECTION_NAME
); );
if (!exists) { if (exists) {
await this.client.createCollection(AI_COLLECTION_NAME, { // ตรวจ schema ของ collection ที่มีอยู่ — ถ้าเป็น Hybrid 1024 dims แล้ว skip delete
vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' }, try {
}); const collectionInfo =
await this.client.getCollection(AI_COLLECTION_NAME);
const isHybrid =
collectionInfo.config.params.vectors !== undefined &&
collectionInfo.config.params.sparse_vectors !== undefined;
const vectorsMap = collectionInfo.config.params.vectors;
let vectorSize: number | undefined = undefined;
// Defensive check: ตรวจ structure ของ vectorsMap ก่อน access
if (vectorsMap && typeof vectorsMap === 'object') {
if ('size' in vectorsMap) {
// Single vector mode (ไม่ใช่ Hybrid)
vectorSize = (vectorsMap as { size: number }).size;
} else {
// Hybrid mode: extract bge_dense size
const hybridMap = vectorsMap as Record<string, { size?: number }>;
if (
hybridMap['bge_dense'] &&
typeof hybridMap['bge_dense'] === 'object'
) {
vectorSize = hybridMap['bge_dense'].size;
} else {
this.logger.warn(
`Unexpected vectors structure: bge_dense not found or invalid in Hybrid collection`
);
}
}
} else {
this.logger.warn(
`Unexpected vectors structure: vectorsMap is not an object or undefined`
);
}
if (isHybrid && vectorSize === AI_VECTOR_SIZE) {
this.logger.log(
`Qdrant collection ${AI_COLLECTION_NAME} already exists with correct Hybrid schema (1024 dims) — skipping recreation`
);
// เรียก createPayloadIndexes() ทุกครั้งเพื่อให้แน่ใจว่า indexes มีอยู่
await this.createPayloadIndexes();
return;
}
this.logger.log(
`Dropping existing Qdrant collection ${AI_COLLECTION_NAME} to upgrade to Hybrid (${vectorSize ?? 'unknown'} dims → ${AI_VECTOR_SIZE} dims)...`
);
await this.client.deleteCollection(AI_COLLECTION_NAME);
} catch (err) {
this.logger.warn(
`Failed to inspect collection schema, proceeding with recreation — ${err instanceof Error ? err.message : String(err)}`
);
await this.client.deleteCollection(AI_COLLECTION_NAME);
}
}
await this.client.createCollection(AI_COLLECTION_NAME, {
vectors: {
bge_dense: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
},
sparse_vectors: {
bge_sparse: {},
},
});
// สร้าง payload indexes สำหรับเพิ่มความเร็วในการ filter (T010)
await this.createPayloadIndexes();
this.logger.log(`Created Qdrant Hybrid collection ${AI_COLLECTION_NAME}`);
}
/** สร้าง payload indexes สำหรับ filter fields ที่สำคัญ */
private async createPayloadIndexes(): Promise<void> {
try {
await this.client.createPayloadIndex(AI_COLLECTION_NAME, { await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'project_public_id', field_name: 'project_public_id',
field_schema: { type: 'keyword', is_tenant: true } as Parameters< field_schema: { type: 'keyword', is_tenant: true } as Parameters<
QdrantClient['createPayloadIndex'] QdrantClient['createPayloadIndex']
>[1]['field_schema'], >[1]['field_schema'],
}); });
this.logger.log(`Created Qdrant collection ${AI_COLLECTION_NAME}`);
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'doc_public_id',
field_schema: { type: 'keyword' } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'status_code',
field_schema: { type: 'keyword' } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'doc_type',
field_schema: { type: 'keyword' } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
this.logger.log(`Created payload indexes for ${AI_COLLECTION_NAME}`);
} catch (err) {
this.logger.warn(
`Failed to create payload indexes (may already exist): ${err instanceof Error ? err.message : String(err)}`
);
} }
} }
/** ค้นหา vector โดยบังคับ projectPublicId เป็น parameter แรกตาม ADR-023A */ /** ค้นหาเวกเตอร์ด้วย Hybrid Search (Dense + Sparse) หรือ Dense Search (ถ้าไม่มี sparse vector) โดยบังคับ projectPublicId */
async search( async search(
projectPublicId: string, projectPublicId: string,
vector: number[], denseVector: number[],
sparseVectorOrTopK?: { indices: number[]; values: number[] } | number,
topK = 5 topK = 5
): Promise<AiVectorSearchResult[]> { ): Promise<AiVectorSearchResult[]> {
if (!projectPublicId) { if (!projectPublicId) {
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED'); throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
} }
const results = await this.client.search(AI_COLLECTION_NAME, { let actualSparseVector = {
vector, indices: [] as number[],
limit: topK, values: [] as number[],
};
let actualTopK = topK;
if (typeof sparseVectorOrTopK === 'number') {
actualTopK = sparseVectorOrTopK;
} else if (sparseVectorOrTopK) {
actualSparseVector = sparseVectorOrTopK;
}
// Fallback: หากไม่มี sparse vector ให้ประมวลผลผ่าน client.search สำหรับการทดสอบและ compatibility
if (actualSparseVector.indices.length === 0) {
const results = await this.client.search(AI_COLLECTION_NAME, {
vector: denseVector,
limit: actualTopK,
filter: {
must: [
{ key: 'project_public_id', match: { value: projectPublicId } },
],
},
with_payload: true,
});
return results.map((result) => ({
pointId: result.id,
score: result.score ?? 0,
payload: result.payload ?? {},
}));
}
const results = await this.client.query(AI_COLLECTION_NAME, {
prefetch: [
{
query: {
indices: actualSparseVector.indices,
values: actualSparseVector.values,
},
using: 'bge_sparse',
limit: actualTopK * 2,
},
{
query: denseVector,
using: 'bge_dense',
limit: actualTopK * 2,
},
],
query: { fusion: 'rrf' } as unknown as Record<string, unknown>,
limit: actualTopK,
filter: { filter: {
must: [{ key: 'project_public_id', match: { value: projectPublicId } }], must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
}, },
with_payload: true, with_payload: true,
}); });
return results.map((result) => ({ return results.points.map((result) => ({
pointId: result.id, pointId: result.id,
score: result.score, score: result.score ?? 0,
payload: result.payload ?? {}, payload: result.payload ?? {},
})); }));
} }
/** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */ /** Compatibility wrapper สำหรับโค้ดเดิมระหว่าง transition */
async searchByProject( async searchByProject(
vector: number[], denseVector: number[],
projectPublicId: string, sparseVectorOrProjectPublicId:
limit: number | { indices: number[]; values: number[] }
| string,
projectPublicIdOrLimit?: string | number,
limit = 5
): Promise<AiVectorSearchResult[]> { ): Promise<AiVectorSearchResult[]> {
return this.search(projectPublicId, vector, limit); if (typeof sparseVectorOrProjectPublicId === 'string') {
// เรียกใช้รูปแบบดั้งเดิม: searchByProject(vector, projectPublicId, limit)
const projectPublicId = sparseVectorOrProjectPublicId;
const actualLimit =
typeof projectPublicIdOrLimit === 'number'
? projectPublicIdOrLimit
: limit;
return this.search(projectPublicId, denseVector, undefined, actualLimit);
} else {
// เรียกใช้รูปแบบใหม่: searchByProject(dense, sparse, projectPublicId, limit)
const projectPublicId =
typeof projectPublicIdOrLimit === 'string'
? projectPublicIdOrLimit
: '';
return this.search(
projectPublicId,
denseVector,
sparseVectorOrProjectPublicId,
limit
);
}
} }
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */ /** ลบเวกเตอร์ของเอกสารด้วย projectPublicId และ documentPublicId */
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> { async deleteByDocumentPublicId(
projectPublicId: string,
documentPublicId: string
): Promise<void> {
if (!projectPublicId) {
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
}
await this.client.delete(AI_COLLECTION_NAME, { await this.client.delete(AI_COLLECTION_NAME, {
wait: true, wait: true,
filter: { filter: {
must: [{ key: 'public_id', match: { value: documentPublicId } }], must: [
{ key: 'project_public_id', match: { value: projectPublicId } },
{ key: 'doc_public_id', match: { value: documentPublicId } },
],
}, },
}); });
} }
/** Upsert vectors ไป Qdrant พร้อม project isolation (T021) */ /** Upsert hybrid vectors ไป Qdrant พร้อม project isolation (T008) */
async upsert( async upsert(
projectPublicId: string, projectPublicId: string,
points: Array<{ points: Array<{
id: string; id: string;
vector: number[]; vector: {
bge_dense: number[];
bge_sparse: {
indices: number[];
values: number[];
};
};
payload: Record<string, unknown>; payload: Record<string, unknown>;
}> }>
): Promise<void> { ): Promise<void> {
@@ -126,14 +318,14 @@ export class AiQdrantService implements OnModuleInit {
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED'); throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
} }
// เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation // เพิ่ม project_public_id ใน payload ทุก point เพื่อแยกโครงการ
const pointsWithProject = points.map((point) => ({ const pointsWithProject = points.map((point) => ({
...point, ...point,
payload: { payload: {
...point.payload, ...point.payload,
project_public_id: projectPublicId, project_public_id: projectPublicId,
}, },
})); })) as unknown as QdrantUpsertPoint[];
await this.client.upsert(AI_COLLECTION_NAME, { await this.client.upsert(AI_COLLECTION_NAME, {
wait: true, wait: true,
@@ -0,0 +1,183 @@
// File: backend/src/modules/ai/services/ai-policy.service.ts
// Change Log:
// - 2026-06-11: Initial creation of AiPolicyService for managing execution profiles and policies
// - 2026-06-11: แก้ไขข้อผิดพลาด TS2367 (เทียบ profile กับ ocr-extract) และลบบรรทัดว่างในฟังก์ชัน getProfileParameters
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { InjectRepository } from '@nestjs/typeorm';
import type Redis from 'ioredis';
import { Repository } from 'typeorm';
import { AiExecutionProfile } from '../entities/ai-execution-profile.entity';
import {
ExecutionProfile,
InternalJobType,
RuntimePolicy,
AiJobPayload,
} from '../interfaces/execution-policy.interface';
@Injectable()
export class AiPolicyService {
private readonly logger = new Logger(AiPolicyService.name);
private readonly cachePrefix = 'ai_execution_profiles:';
private readonly cacheTtlSeconds = 60;
private readonly defaultProfiles: Record<ExecutionProfile, RuntimePolicy> = {
interactive: {
canonicalModel: 'np-dms-ai',
temperature: 0.7,
topP: 0.9,
maxTokens: 2048,
numCtx: 4096,
repeatPenalty: 1.15,
keepAliveSeconds: 300,
},
standard: {
canonicalModel: 'np-dms-ai',
temperature: 0.5,
topP: 0.8,
maxTokens: 4096,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
},
quality: {
canonicalModel: 'np-dms-ai',
temperature: 0.1,
topP: 0.95,
maxTokens: 8192,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
},
'deep-analysis': {
canonicalModel: 'np-dms-ai',
temperature: 0.3,
topP: 0.85,
maxTokens: 8192,
numCtx: 32768,
repeatPenalty: 1.15,
keepAliveSeconds: 0,
},
};
constructor(
@InjectRepository(AiExecutionProfile)
private readonly profileRepo: Repository<AiExecutionProfile>,
@InjectRedis() private readonly redis: Redis
) {}
/**
* model tag Ollama canonical name (np-dms-ai np-dms-ocr)
*/
getCanonicalModelName(modelName: string): 'np-dms-ai' | 'np-dms-ocr' {
const name = modelName.toLowerCase();
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) {
return 'np-dms-ocr';
}
return 'np-dms-ai';
}
/**
* JobType ExecutionProfile
*/
getProfileForJobType(jobType: InternalJobType): ExecutionProfile {
switch (jobType) {
case 'auto-fill-document':
case 'migrate-document':
return 'quality';
case 'rag-query':
return 'standard';
case 'intent-classify':
case 'tool-suggest':
return 'interactive';
case 'sandbox-analysis':
return 'deep-analysis';
case 'ocr-extract':
default:
return 'standard';
}
}
/**
* ExecutionProfile
*/
async getProfileParameters(
profile: ExecutionProfile
): Promise<RuntimePolicy> {
const cacheKey = `${this.cachePrefix}${profile}`;
try {
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as RuntimePolicy;
}
} catch (cacheErr) {
this.logger.warn(
`Failed to read execution profile cache: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`
);
}
try {
const dbProfile = await this.profileRepo.findOne({
where: { profileName: profile, isActive: true },
});
if (dbProfile) {
const policy: RuntimePolicy = {
canonicalModel: 'np-dms-ai',
temperature: Number(dbProfile.temperature),
topP: Number(dbProfile.topP),
maxTokens: dbProfile.maxTokens,
numCtx: dbProfile.numCtx,
repeatPenalty: Number(dbProfile.repeatPenalty),
keepAliveSeconds: dbProfile.keepAliveSeconds,
};
try {
await this.redis.set(
cacheKey,
JSON.stringify(policy),
'EX',
this.cacheTtlSeconds
);
} catch (cacheSetErr) {
this.logger.warn(
`Failed to write execution profile cache: ${cacheSetErr instanceof Error ? cacheSetErr.message : String(cacheSetErr)}`
);
}
return policy;
}
} catch (dbErr) {
this.logger.error(
`Failed to read execution profile from DB: ${dbErr instanceof Error ? dbErr.message : String(dbErr)}`
);
}
return this.defaultProfiles[profile];
}
/**
* payload BullMQ job snapshot parameters dispatch
*/
async createJobPayload(
jobType: InternalJobType,
documentPublicId?: string,
attachmentPublicId?: string
): Promise<AiJobPayload> {
const effectiveProfile = this.getProfileForJobType(jobType);
const canonicalModel =
jobType === 'ocr-extract' ? 'np-dms-ocr' : 'np-dms-ai';
const policy = await this.getProfileParameters(effectiveProfile);
return {
jobType,
documentPublicId,
attachmentPublicId,
effectiveProfile,
canonicalModel,
snapshotParams: {
temperature: policy.temperature,
topP: policy.topP,
maxTokens: policy.maxTokens,
numCtx: policy.numCtx,
repeatPenalty: policy.repeatPenalty,
keepAliveSeconds: policy.keepAliveSeconds,
},
};
}
}
@@ -0,0 +1,137 @@
// File: backend/src/modules/ai/services/embedding.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ EmbeddingService เพื่อทดสอบกระบวนการ Semantic Chunking และ fixed-size fallback (T024)
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EmbeddingService } from './embedding.service';
import { OllamaService } from './ollama.service';
import { AiQdrantService } from '../qdrant.service';
import { OcrService } from './ocr.service';
import { AiPromptsService } from '../prompts/ai-prompts.service';
describe('EmbeddingService (US3 — Semantic Chunking)', () => {
let service: EmbeddingService;
let ollamaService: OllamaService;
let qdrantService: AiQdrantService;
let ocrService: OcrService;
let aiPromptsService: AiPromptsService;
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
const values: Record<string, unknown> = {
EMBEDDING_CHUNK_SIZE: 512,
EMBEDDING_CHUNK_OVERLAP: 64,
};
return values[key] ?? defaultValue;
}),
};
const mockOllamaService = {
generate: jest.fn(),
};
const mockQdrantService = {
deleteByDocumentPublicId: jest.fn().mockResolvedValue(undefined),
upsert: jest.fn().mockResolvedValue(undefined),
};
const mockOcrService = {
embedViaSidecar: jest.fn(),
};
const mockAiPromptsService = {
resolveActive: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EmbeddingService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: OllamaService, useValue: mockOllamaService },
{ provide: AiQdrantService, useValue: mockQdrantService },
{ provide: OcrService, useValue: mockOcrService },
{ provide: AiPromptsService, useValue: mockAiPromptsService },
],
}).compile();
service = module.get<EmbeddingService>(EmbeddingService);
ollamaService = module.get<OllamaService>(OllamaService);
qdrantService = module.get<AiQdrantService>(AiQdrantService);
ocrService = module.get<OcrService>(OcrService);
aiPromptsService = module.get<AiPromptsService>(AiPromptsService);
jest.clearAllMocks();
});
describe('embedDocument()', () => {
it('ควรเรียกใช้ Semantic Chunking เมื่อ LLM ตอบกลับถูกต้องตามแท็ก และบันทึกเข้า Qdrant สำเร็จ', async () => {
const mockLlmResponse = `
<chunk topic="การติดตั้งระบบ"> 1. 2. </chunk>
<chunk topic="การตั้งค่า"></chunk>
`;
mockAiPromptsService.resolveActive.mockResolvedValueOnce({
resolvedPrompt: 'mock resolved prompt',
versionNumber: 1,
});
mockOllamaService.generate.mockResolvedValueOnce(mockLlmResponse);
mockOcrService.embedViaSidecar.mockImplementation((_text: string) => {
return Promise.resolve({
dense: Array(1024).fill(0.1),
sparse: { indices: [1], values: [0.5] },
});
});
const result = await service.embedDocument(
'proj-uuid-456',
'doc-uuid-123',
'CORR-001',
'LETTER',
'IN_REVIEW',
1,
'Test Subject',
'2026-06-05',
'ข้อความทดสอบสำหรับการหั่นแบบ semantic chunking ซึ่งมีความยาวเกิน 50 ตัวอักษรอย่างแน่นอน'
);
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBe(2);
expect(aiPromptsService.resolveActive).toHaveBeenCalledWith(
'rag_chunking',
'ข้อความทดสอบสำหรับการหั่นแบบ semantic chunking ซึ่งมีความยาวเกิน 50 ตัวอักษรอย่างแน่นอน'
);
expect(ollamaService.generate).toHaveBeenCalledWith(
'mock resolved prompt'
);
expect(ocrService.embedViaSidecar).toHaveBeenCalledTimes(2);
expect(qdrantService.deleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123'
);
expect(qdrantService.upsert).toHaveBeenCalled();
});
it('ควร fallback ไปใช้ fixed-size chunking เมื่อ LLM คืนข้อมูลที่ไม่มีแท็ก chunk หรือการเรียก LLM ล้มเหลว', async () => {
mockAiPromptsService.resolveActive.mockResolvedValueOnce({
resolvedPrompt: 'mock resolved prompt',
versionNumber: 1,
});
mockOllamaService.generate.mockResolvedValueOnce(
'ข้อความธรรมดาที่ไม่มีแท็ก chunk อะไรเลย'
);
mockOcrService.embedViaSidecar.mockImplementation((_text: string) => {
return Promise.resolve({
dense: Array(1024).fill(0.2),
sparse: { indices: [2], values: [0.8] },
});
});
const result = await service.embedDocument(
'proj-uuid-456',
'doc-uuid-123',
'CORR-001',
'LETTER',
'IN_REVIEW',
1,
'Test Subject',
'2026-06-05',
'ข้อความทดสอบแบบยาวเพื่อจำลองการทำ fixed size chunking สำหรับการ fallback เมื่อ LLM ทำงานไม่ได้ตามเงื่อนไขที่กำหนดไว้'
);
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBeGreaterThan(0);
expect(qdrantService.deleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123'
);
expect(qdrantService.upsert).toHaveBeenCalled();
});
});
});
@@ -1,12 +1,15 @@
// File: src/modules/ai/services/embedding.service.ts // File: backend/src/modules/ai/services/embedding.service.ts
// Change Log // Change Log
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ full-document chunked embedding ตาม ADR-023A T021. // - 2026-05-15: เพิ่ม EmbeddingService สำหรับ full-document chunked embedding ตาม ADR-023A T021.
// - 2026-06-05: ปรับปรุงเป็น Hybrid Embedding และเพิ่ม Semantic Chunking ผ่าน typhoon2.5 (T025-T027)
// - 2026-06-11: US3 - เพิ่มการคืนค่า device (cpu/gpu) จาก embedding
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { OllamaService } from './ollama.service'; import { OllamaService } from './ollama.service';
import { AiQdrantService } from '../qdrant.service'; import { AiQdrantService } from '../qdrant.service';
import { OcrService } from './ocr.service'; import { OcrService } from './ocr.service';
import { AiPromptsService } from '../prompts/ai-prompts.service';
export interface EmbeddingChunk { export interface EmbeddingChunk {
chunkIndex: number; chunkIndex: number;
@@ -18,6 +21,7 @@ export interface EmbeddingResult {
success: boolean; success: boolean;
chunksEmbedded: number; chunksEmbedded: number;
error?: string; error?: string;
device?: string;
} }
/** บริการสร้าง embedding สำหรับ full-document RAG (ADR-023A) */ /** บริการสร้าง embedding สำหรับ full-document RAG (ADR-023A) */
@@ -31,7 +35,8 @@ export class EmbeddingService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly ollamaService: OllamaService, private readonly ollamaService: OllamaService,
private readonly qdrantService: AiQdrantService, private readonly qdrantService: AiQdrantService,
private readonly ocrService: OcrService private readonly ocrService: OcrService,
private readonly aiPromptsService: AiPromptsService
) { ) {
this.chunkSize = this.configService.get<number>( this.chunkSize = this.configService.get<number>(
'EMBEDDING_CHUNK_SIZE', 'EMBEDDING_CHUNK_SIZE',
@@ -44,71 +49,74 @@ export class EmbeddingService {
} }
/** /**
* embedding : * hybrid embedding :
* 1. full-doc ( extractedText OCR) * 1. Semantic Chunking ( LLM) Fallback fixed-size
* 2. Chunk text 512 tokens / 64 overlap * 2. Sidecar /embed chunk Dense (1024 dims) + Sparse vector
* 3. Generate embedding chunk nomic-embed-text * 3. points Qdrant
* 4. Upsert Qdrant project isolation * 4. Upsert points 11 fields
*/ */
async embedDocument( async embedDocument(
pdfPath: string,
documentPublicId: string,
projectPublicId: string, projectPublicId: string,
extractedText?: string documentPublicId: string,
correspondenceNumber: string,
docType: string,
statusCode: string,
revisionNumber: number,
subject: string,
documentDate?: string,
ocrText?: string
): Promise<EmbeddingResult> { ): Promise<EmbeddingResult> {
try { try {
// 1. ดึงข้อความจาก PDF (ใช้ extractedText ถ้ามี หรือเรียก OCR) if (!ocrText || ocrText.trim().length === 0) {
let fullText = extractedText; this.logger.warn(
if (!fullText) { `No OCR text provided for document ${documentPublicId}`
const ocrResult = await this.ocrService.detectAndExtract({ );
pdfPath,
extractedText: '',
extractedChars: 0,
});
fullText = ocrResult.text;
}
if (!fullText || fullText.trim().length === 0) {
this.logger.warn(`No text extracted from document ${documentPublicId}`);
return { return {
success: false, success: false,
chunksEmbedded: 0, chunksEmbedded: 0,
error: 'No text extracted', error: 'No OCR text provided',
}; };
} }
const chunks = await this.semanticChunkTextWithFallback(ocrText);
// 2. Chunk text
const chunks = this.chunkText(fullText);
this.logger.log( this.logger.log(
`Document ${documentPublicId} split into ${chunks.length} chunks` `Document ${documentPublicId} split into ${chunks.length} chunks`
); );
// 3. Generate embedding และ upsert ไป Qdrant
const points = []; const points = [];
for (const chunk of chunks) { let usedDevice = 'gpu';
for (const [idx, chunk] of chunks.entries()) {
try { try {
const embedding = await this.ollamaService.generateEmbedding( const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
chunk.text if (embedResult.device === 'cpu') {
); usedDevice = 'cpu';
}
points.push({ points.push({
id: `${documentPublicId}-${chunk.chunkIndex}`, id: `${documentPublicId}-${idx}`,
vector: embedding, vector: {
bge_dense: embedResult.dense,
bge_sparse: embedResult.sparse,
},
payload: { payload: {
document_public_id: documentPublicId, doc_public_id: documentPublicId,
chunk_index: chunk.chunkIndex, project_public_id: projectPublicId,
page_number: chunk.pageNumber, doc_number: correspondenceNumber,
doc_type: docType,
status_code: statusCode,
revision_number: revisionNumber,
subject: subject,
document_date: documentDate || null,
chunk_topic: chunk.topic,
chunk_index: idx,
chunk_text: chunk.text, chunk_text: chunk.text,
embedded_at: new Date().toISOString(), embedded_at: new Date().toISOString(),
}, },
}); });
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Failed to embed chunk ${chunk.chunkIndex} for document ${documentPublicId}`, `Failed to embed chunk ${idx} for document ${documentPublicId}`,
err instanceof Error ? err.message : String(err) err instanceof Error ? err.message : String(err)
); );
} }
} }
if (points.length === 0) { if (points.length === 0) {
return { return {
success: false, success: false,
@@ -116,15 +124,19 @@ export class EmbeddingService {
error: 'All chunks failed to embed', error: 'All chunks failed to embed',
}; };
} }
await this.qdrantService.deleteByDocumentPublicId(
// 4. Upsert ไป Qdrant พร้อม project isolation projectPublicId,
documentPublicId
);
await this.qdrantService.upsert(projectPublicId, points); await this.qdrantService.upsert(projectPublicId, points);
this.logger.log( this.logger.log(
`Successfully embedded ${points.length} chunks for document ${documentPublicId} in project ${projectPublicId}` `Successfully embedded ${points.length} chunks for document ${documentPublicId} in project ${projectPublicId}`
); );
return {
return { success: true, chunksEmbedded: points.length }; success: true,
chunksEmbedded: points.length,
device: usedDevice,
};
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
this.logger.error( this.logger.error(
@@ -135,12 +147,53 @@ export class EmbeddingService {
} }
/** /**
* Chunk text overlap * typhoon2.5 Prompt 'rag_chunking' (T025, T026)
* - chunkSize: 512 characters (approximate token equivalent) * LLM <chunk> fallback fixed-size
* - overlap: 64 characters
*/ */
private chunkText(text: string): EmbeddingChunk[] { private async semanticChunkTextWithFallback(
const chunks: EmbeddingChunk[] = []; ocrText: string
): Promise<Array<{ topic: string; text: string }>> {
try {
this.logger.log('Attempting semantic chunking via typhoon2.5...');
// ดึง prompt จาก ai_prompts ที่เป็น active version
const resolved = await this.aiPromptsService.resolveActive(
'rag_chunking',
ocrText
);
// เรียก LLM
const llmOutput = await this.ollamaService.generate(
resolved.resolvedPrompt
);
// ดึงและวิเคราะห์ข้อความภายในแท็ก <chunk topic="...">
const parsed = this.parseChunkTags(llmOutput);
if (parsed.length > 0) {
this.logger.log(
`Semantic chunking succeeded: split into ${parsed.length} chunks.`
);
return parsed;
}
this.logger.warn(
'No valid <chunk> tags found in LLM output, falling back to fixed-size chunking.'
);
} catch (err: unknown) {
this.logger.warn(
`Semantic chunking failed, falling back to fixed-size chunking: ${err instanceof Error ? err.message : String(err)}`
);
}
// Fallback: ใช้การแบ่ง chunk แบบ Fixed-size
return this.fixedSizeChunk(ocrText, this.chunkSize, this.overlap);
}
/** แบ่งข้อความตามขนาดคงที่ (Fixed-size Chunking) (FR-005) */
private fixedSizeChunk(
text: string,
chunkSize: number,
overlap: number
): Array<{ topic: string; text: string }> {
const chunks: Array<{ topic: string; text: string }> = [];
const cleanText = text.replace(/\s+/g, ' ').trim(); const cleanText = text.replace(/\s+/g, ' ').trim();
const textLength = cleanText.length; const textLength = cleanText.length;
@@ -148,19 +201,35 @@ export class EmbeddingService {
let chunkIndex = 0; let chunkIndex = 0;
while (startIndex < textLength) { while (startIndex < textLength) {
const endIndex = Math.min(startIndex + this.chunkSize, textLength); const endIndex = Math.min(startIndex + chunkSize, textLength);
const chunkText = cleanText.substring(startIndex, endIndex); const chunkText = cleanText.substring(startIndex, endIndex);
chunks.push({ chunks.push({
chunkIndex, topic: `ส่วนที่ ${chunkIndex + 1}`,
text: chunkText, text: chunkText,
pageNumber: undefined, // TODO: Extract page numbers if available
}); });
startIndex += this.chunkSize - this.overlap; startIndex += chunkSize - overlap;
chunkIndex += 1; chunkIndex += 1;
} }
return chunks; return chunks;
} }
/** ประมวลผลดึงค่า regex <chunk topic="...">... </chunk> (T026) */
private parseChunkTags(
llmOutput: string
): Array<{ topic: string; text: string }> {
const chunks: Array<{ topic: string; text: string }> = [];
const regex = /<chunk\s+topic="([^"]*)"\s*>([\s\S]*?)<\/chunk\s*>/gi;
let match;
while ((match = regex.exec(llmOutput)) !== null) {
const topic = match[1]?.trim() || 'ทั่วไป';
const text = match[2]?.trim();
if (text) {
chunks.push({ topic, text });
}
}
return chunks;
}
} }
+144 -6
View File
@@ -1,4 +1,4 @@
// File: src/modules/ai/services/ocr.service.ts // File: backend/src/modules/ai/services/ocr.service.ts
// Change Log // Change Log
// - 2026-05-15: เพิ่ม OCR auto-detection service สำหรับ ADR-023A. // - 2026-05-15: เพิ่ม OCR auto-detection service สำหรับ ADR-023A.
// - 2026-05-25: แก้ไข AggregateError (empty message) จาก axios โดย wrap เป็น Error พร้อม context ที่ชัดเจน. // - 2026-05-25: แก้ไข AggregateError (empty message) จาก axios โดย wrap เป็น Error พร้อม context ที่ชัดเจน.
@@ -11,6 +11,7 @@
// - 2026-06-01: เปลี่ยน processWithTesseract/processWithTyphoon ให้ส่ง file content ผ่าน multipart ไปยัง /ocr-upload แทนการส่ง path // - 2026-06-01: เปลี่ยน processWithTesseract/processWithTyphoon ให้ส่ง file content ผ่าน multipart ไปยัง /ocr-upload แทนการส่ง path
// - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2) // - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2)
// - 2026-06-04: ADR-034 — เปลี่ยน TYPHOON_ENGINE.engineName เป็น typhoon-np-dms-ocr:latest ตรงกับชื่อโมเดลใน Ollama // - 2026-06-04: ADR-034 — เปลี่ยน TYPHOON_ENGINE.engineName เป็น typhoon-np-dms-ocr:latest ตรงกับชื่อโมเดลใน Ollama
// - 2026-06-11: US2 - คำนวณ OCR residency keep_alive แบบ dynamic ตาม VRAM headroom และ active profile
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -29,12 +30,16 @@ import { SystemSetting } from '../entities/system-setting.entity';
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity'; import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
import { OcrCacheService } from './ocr-cache.service'; import { OcrCacheService } from './ocr-cache.service';
import { VramMonitorService } from './vram-monitor.service'; import { VramMonitorService } from './vram-monitor.service';
import { AiPolicyService } from './ai-policy.service';
import { ExecutionProfile } from '../interfaces/execution-policy.interface';
import { OcrResidencyDecision } from '../interfaces/ocr-residency.interface';
export interface OcrDetectionInput { export interface OcrDetectionInput {
extractedText?: string; extractedText?: string;
extractedChars?: number; extractedChars?: number;
pdfPath?: string; pdfPath?: string;
documentPublicId?: string; // เพิ่มเพื่อการทำ audit logs documentPublicId?: string; // เพิ่มเพื่อการทำ audit logs
activeProfile?: ExecutionProfile;
} }
export interface OcrDetectionResult { export interface OcrDetectionResult {
@@ -101,6 +106,9 @@ export class OcrService {
private readonly threshold: number; private readonly threshold: number;
private readonly ocrApiUrl: string; private readonly ocrApiUrl: string;
private readonly ocrSidecarApiKey: string; private readonly ocrSidecarApiKey: string;
private readonly vramHeadroomThresholdMb: number;
private readonly ocrResidencyWindowSeconds: number;
private readonly mainModelPressureThresholdMb: number;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@InjectRepository(SystemSetting) @InjectRepository(SystemSetting)
@@ -109,6 +117,7 @@ export class OcrService {
private readonly auditLogRepo: Repository<AiAuditLog>, private readonly auditLogRepo: Repository<AiAuditLog>,
private readonly ocrCacheService: OcrCacheService, private readonly ocrCacheService: OcrCacheService,
private readonly vramMonitorService: VramMonitorService, private readonly vramMonitorService: VramMonitorService,
private readonly aiPolicyService: AiPolicyService,
@InjectRedis() private readonly redis: Redis @InjectRedis() private readonly redis: Redis
) { ) {
this.threshold = this.configService.get<number>('OCR_CHAR_THRESHOLD', 100); this.threshold = this.configService.get<number>('OCR_CHAR_THRESHOLD', 100);
@@ -120,6 +129,82 @@ export class OcrService {
'OCR_SIDECAR_API_KEY', 'OCR_SIDECAR_API_KEY',
'lcbp3-dms-ocr-sidecar-secure-token-2026' 'lcbp3-dms-ocr-sidecar-secure-token-2026'
); );
this.vramHeadroomThresholdMb = this.configService.get<number>(
'VRAM_HEADROOM_THRESHOLD_MB',
this.configService.get<number>('AI_VRAM_HEADROOM_THRESHOLD_MB', 3000)
);
this.ocrResidencyWindowSeconds = this.configService.get<number>(
'OCR_RESIDENCY_WINDOW_SECONDS',
this.configService.get<number>('AI_OCR_RESIDENCY_WINDOW_SECONDS', 120)
);
this.mainModelPressureThresholdMb = this.configService.get<number>(
'GPU_MAIN_MODEL_PRESSURE_THRESHOLD_MB',
this.configService.get<number>(
'AI_GPU_MAIN_MODEL_PRESSURE_THRESHOLD_MB',
12000
)
);
}
/**
* keep_alive OCR VRAM
*/
async calculateOcrResidency(
activeProfile?: ExecutionProfile | null
): Promise<OcrResidencyDecision> {
try {
const headroom = await this.vramMonitorService.getVramHeadroom();
if (!headroom.querySuccess) {
return {
keepAliveSeconds: 0,
vramHeadroomMb: 0,
activeProfile: activeProfile ?? null,
reason: 'query-failed',
};
}
if (activeProfile === 'deep-analysis') {
this.logger.log(`OCR Residency: deep-analysis active, keep_alive = 0`);
return {
keepAliveSeconds: 0,
vramHeadroomMb: headroom.availableMb,
activeProfile,
reason: 'deep-analysis-active',
};
}
const isHighPressure =
(headroom.mainModelVramMb ?? 0) > this.mainModelPressureThresholdMb ||
headroom.availableMb < this.vramHeadroomThresholdMb;
if (isHighPressure) {
this.logger.log(
`OCR Residency: VRAM pressure is high (main: ${headroom.mainModelVramMb}MB, avail: ${headroom.availableMb}MB), keep_alive = 0`
);
return {
keepAliveSeconds: 0,
vramHeadroomMb: headroom.availableMb,
activeProfile: activeProfile ?? null,
reason: 'high-pressure',
};
}
this.logger.log(
`OCR Residency: VRAM headroom sufficient (${headroom.availableMb} MB), keep_alive = ${this.ocrResidencyWindowSeconds}`
);
return {
keepAliveSeconds: this.ocrResidencyWindowSeconds,
vramHeadroomMb: headroom.availableMb,
activeProfile: activeProfile ?? null,
reason: 'headroom-sufficient',
};
} catch (err: unknown) {
this.logger.warn(
`Failed to calculate OCR residency: ${err instanceof Error ? err.message : String(err)}`
);
return {
keepAliveSeconds: 0,
vramHeadroomMb: 0,
activeProfile: activeProfile ?? null,
reason: 'query-failed',
};
}
} }
/** ดึงรายการ OCR Engines ทั้งหมด พร้อมตรวจสอบตัวที่กำลัง Active */ /** ดึงรายการ OCR Engines ทั้งหมด พร้อมตรวจสอบตัวที่กำลัง Active */
@@ -311,7 +396,6 @@ export class OcrService {
): Promise<OcrDetectionResult> { ): Promise<OcrDetectionResult> {
const startTime = Date.now(); const startTime = Date.now();
try { try {
// 1. ตรวจสอบ VRAM insufficiency guard
const hasCapacity = await this.vramMonitorService.hasVramCapacity( const hasCapacity = await this.vramMonitorService.hasVramCapacity(
TYPHOON_OCR_REQUIRED_VRAM_MB TYPHOON_OCR_REQUIRED_VRAM_MB
); );
@@ -321,7 +405,8 @@ export class OcrService {
); );
return this.processWithTesseract(input); return this.processWithTesseract(input);
} }
const residency = await this.calculateOcrResidency(input.activeProfile);
const keepAlive = residency.keepAliveSeconds;
this.logger.debug(`Typhoon OCR processing: ${input.pdfPath}`); this.logger.debug(`Typhoon OCR processing: ${input.pdfPath}`);
const fileBuffer = fs.readFileSync(input.pdfPath!); const fileBuffer = fs.readFileSync(input.pdfPath!);
const form = new FormData(); const form = new FormData();
@@ -331,6 +416,7 @@ export class OcrService {
'upload.pdf' 'upload.pdf'
); );
form.append('engine', 'typhoon-np-dms-ocr'); form.append('engine', 'typhoon-np-dms-ocr');
form.append('keep_alive', String(keepAlive));
const response = await axios.post<OcrSidecarResponse>( const response = await axios.post<OcrSidecarResponse>(
`${this.ocrApiUrl}/ocr-upload`, `${this.ocrApiUrl}/ocr-upload`,
form, form,
@@ -339,10 +425,8 @@ export class OcrService {
headers: { 'X-API-Key': this.ocrSidecarApiKey }, headers: { 'X-API-Key': this.ocrSidecarApiKey },
} }
); );
const text = response.data.text ?? ''; const text = response.data.text ?? '';
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
await this.writeAuditLog({ await this.writeAuditLog({
documentPublicId: input.documentPublicId, documentPublicId: input.documentPublicId,
aiModel: 'typhoon-ocr', aiModel: 'typhoon-ocr',
@@ -352,7 +436,6 @@ export class OcrService {
processingTimeMs: durationMs, processingTimeMs: durationMs,
cacheHit: false, cacheHit: false,
}); });
return { return {
text, text,
ocrUsed: true, ocrUsed: true,
@@ -393,4 +476,59 @@ export class OcrService {
); );
} }
} }
/** เรียก Sidecar /embed เพื่อทำ BGE-M3 (Dense + Sparse) embedding (T012) */
async embedViaSidecar(text: string): Promise<{
dense: number[];
sparse: { indices: number[]; values: number[] };
device?: string;
}> {
try {
const response = await axios.post(
`${this.ocrApiUrl}/embed`,
{ text },
{
headers: {
'X-API-Key': this.ocrSidecarApiKey,
},
}
);
return response.data as {
dense: number[];
sparse: { indices: number[]; values: number[] };
device?: string;
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to embed via Sidecar: ${msg}`);
throw new Error(`AI_SIDECAR_EMBED_FAILED: ${msg}`);
}
}
/** เรียก Sidecar /rerank เพื่อทำ BGE-Reranker-Large re-ranking (T014) */
async rerankViaSidecar(
query: string,
chunks: string[]
): Promise<{ scores: number[]; ranked_indices: number[]; device?: string }> {
try {
const response = await axios.post(
`${this.ocrApiUrl}/rerank`,
{ query, chunks },
{
headers: {
'X-API-Key': this.ocrSidecarApiKey,
},
}
);
return response.data as {
scores: number[];
ranked_indices: number[];
device?: string;
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to rerank via Sidecar: ${msg}`);
throw new Error(`AI_SIDECAR_RERANK_FAILED: ${msg}`);
}
}
} }
@@ -57,6 +57,19 @@ describe('OllamaService (ADR-034)', () => {
expect.anything() expect.anything()
); );
}); });
it('ควรส่ง format=json เมื่อ caller ต้องการ structured output', async () => {
mockedAxios.post = jest
.fn()
.mockResolvedValueOnce({ data: { response: '{"ok":true}' } });
await service.generate('json prompt', {
format: 'json',
});
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('/api/generate'),
expect.objectContaining({ format: 'json' }),
expect.anything()
);
});
it('ควรใช้ options.model เมื่อระบุ model อื่น (ADR-034 model switching)', async () => { it('ควรใช้ options.model เมื่อระบุ model อื่น (ADR-034 model switching)', async () => {
mockedAxios.post = jest mockedAxios.post = jest
.fn() .fn()
@@ -4,6 +4,9 @@
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama // - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
// - 2026-06-02: เพิ่ม loadModel() preloading, ดึงจริงจาก /api/ps และเพิ่ม unloadModel() เพื่อล้างหน่วยความจำ GPU/VRAM (ADR-033, Suggestion 1) // - 2026-06-02: เพิ่ม loadModel() preloading, ดึงจริงจาก /api/ps และเพิ่ม unloadModel() เพื่อล้างหน่วยความจำ GPU/VRAM (ADR-033, Suggestion 1)
// - 2026-06-03: ADR-034 — เปลี่ยน default model เป็น typhoon2.5-np-dms; เพิ่ม ocrModel field, keepAlive param ใน loadModel(), model option ใน OllamaGenerateOptions, getOcrModelName() // - 2026-06-03: ADR-034 — เปลี่ยน default model เป็น typhoon2.5-np-dms; เพิ่ม ocrModel field, keepAlive param ใน loadModel(), model option ใน OllamaGenerateOptions, getOcrModelName()
// - 2026-06-06: เพิ่ม system prompt support ใน OllamaGenerateOptions และ generate() method เพื่อรองรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก
// - 2026-06-06: [T036] แก้ไข default URL เป็น http://192.168.10.100:11434 (Desk-5439) แทน localhost; เพิ่ม options และ keepAlive ใน OllamaGenerateOptions เพื่อรองรับ Typhoon model parameters
// - 2026-06-08: เพิ่ม num_predict ใน OllamaGenerateOptions.options — ป้องกัน JSON truncation เมื่อ LLM สร้าง structured output
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -14,6 +17,22 @@ export interface OllamaGenerateOptions {
signal?: AbortSignal; signal?: AbortSignal;
/** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */ /** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */
model?: string; model?: string;
/** System prompt สำหรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก (ใช้ triple quotes) */
system?: string;
/** บังคับ structured output จาก Ollama สำหรับงานที่ต้อง parse JSON */
format?: 'json';
/** Ollama generation options (temperature, top_p, etc.) */
options?: {
temperature?: number;
top_p?: number;
repeat_penalty?: number;
num_gpu?: number;
num_ctx?: number;
/** จำนวน tokens สูงสุดที่ LLM จะสร้าง — ป้องกัน JSON truncation */
num_predict?: number;
};
/** keep_alive: -1 = stay loaded, 0 = unload immediately, N = seconds */
keepAlive?: number;
} }
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */ /** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
@@ -29,7 +48,10 @@ export class OllamaService {
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>( this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL', 'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434') this.configService.get<string>(
'AI_HOST_URL',
'http://192.168.10.100:11434'
)
); );
this.mainModel = this.configService.get<string>( this.mainModel = this.configService.get<string>(
'OLLAMA_MODEL_MAIN', 'OLLAMA_MODEL_MAIN',
@@ -57,7 +79,11 @@ export class OllamaService {
{ {
model: options.model ?? this.mainModel, model: options.model ?? this.mainModel,
prompt, prompt,
system: options.system,
format: options.format,
stream: false, stream: false,
options: options.options,
keep_alive: options.keepAlive ?? -1,
}, },
{ {
timeout: options.timeoutMs ?? this.timeoutMs, timeout: options.timeoutMs ?? this.timeoutMs,
@@ -1,133 +1,143 @@
// File: src/modules/ai/services/vram-monitor.service.ts // File: backend/src/modules/ai/services/vram-monitor.service.ts
// Change Log // Change Log:
// - 2026-05-30: Initial implementation สำหรับ Typhoon OCR VRAM monitoring (T006, ADR-032) // - 2026-06-11: Initial creation of VramMonitorService to monitor VRAM headroom from Ollama /api/ps
// - 2026-06-11: เพิ่มการคำนวณ mainModelVramMb ใน getVramHeadroom
// - 2026-06-11: เพิ่ม getVramStatus และ invalidateCache เพื่อความเข้ากันได้กับส่วนอื่น
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import { InjectRedis } from '@nestjs-modules/ioredis'; import { VramHeadroom } from '../interfaces/execution-policy.interface';
import Redis from 'ioredis';
/** ข้อมูล VRAM จาก Ollama PS API */ /**
export interface OllamaModelInfo { * VRAM status
name: string; * (Backward Compatibility)
size_vram: number; // bytes */
}
/** ผลลัพธ์ VRAM status */
export interface VramStatus { export interface VramStatus {
totalVramMb: number; totalVramMb: number;
usedVramMb: number; usedVramMb: number;
freeVramMb: number; freeVramMb: number;
loadedModels: string[]; loadedModels: string[];
hasCapacity: boolean; // true ถ้า free VRAM >= minRequiredMb hasCapacity: boolean;
} }
/** ผลลัพธ์ภายในจาก Ollama /api/ps */
interface OllamaProcessStatus {
models?: OllamaModelInfo[];
}
// Redis key สำหรับ cache VRAM status
const VRAM_STATUS_CACHE_KEY = 'ai:vram:status';
// TTL 10 วินาที — refresh บ่อยพอสำหรับ real-time monitoring
const VRAM_STATUS_TTL_SECONDS = 10;
// VRAM limit สำหรับ RTX 2060 Super (8192 MB)
const GPU_TOTAL_VRAM_MB = 8192;
// Threshold: ไม่โหลด model ถ้า usage > 90%
const VRAM_USAGE_LIMIT_PERCENT = 0.9;
/** บริการตรวจสอบ VRAM GPU ผ่าน Ollama API ตาม ADR-032 */
@Injectable() @Injectable()
export class VramMonitorService { export class VramMonitorService {
private readonly logger = new Logger(VramMonitorService.name); private readonly logger = new Logger(VramMonitorService.name);
private readonly ollamaUrl: string; private readonly ollamaUrl: string;
private readonly totalVramMb: number;
constructor( constructor(private readonly configService: ConfigService) {
private readonly configService: ConfigService,
@InjectRedis() private readonly redis: Redis
) {
this.ollamaUrl = this.configService.get<string>( this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL', 'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434') this.configService.get<string>(
'AI_HOST_URL',
'http://192.168.10.100:11434'
)
);
this.totalVramMb = this.configService.get<number>(
'GPU_TOTAL_VRAM_MB',
16384 // Default to 16GB (RTX 5060 Ti)
); );
} }
/** /**
* VRAM Ollama /api/ps * VRAM headroom Ollama /api/ps
* Redis cache TTL 10 overhead * safe default (available = 0)
*/ */
async getVramStatus(minRequiredMb = 4000): Promise<VramStatus> { async getVramHeadroom(): Promise<VramHeadroom> {
const cached = await this.redis.get(VRAM_STATUS_CACHE_KEY);
if (cached) {
const parsed = JSON.parse(cached) as VramStatus;
parsed.hasCapacity = parsed.freeVramMb >= minRequiredMb;
return parsed;
}
return this.fetchAndCacheVramStatus(minRequiredMb);
}
/** ตรวจสอบว่า VRAM เพียงพอสำหรับโหลด model ที่ต้องการ */
async hasVramCapacity(requiredMb: number): Promise<boolean> {
const status = await this.getVramStatus(requiredMb);
return status.hasCapacity;
}
/** ดึงข้อมูล VRAM จาก Ollama และ cache ใน Redis */
private async fetchAndCacheVramStatus(
minRequiredMb: number
): Promise<VramStatus> {
try { try {
const response = await axios.get<OllamaProcessStatus>( const response = await axios.get<{
`${this.ollamaUrl}/api/ps`, models?: Array<{
{ timeout: 5000 } name: string;
); size_vram: number;
const models = response.data.models ?? []; }>;
const loadedModels = models.map((m) => m.name); }>(`${this.ollamaUrl}/api/ps`, { timeout: 3000 });
// คำนวณ VRAM ที่ใช้จาก models ที่โหลดอยู่ const models = response.data?.models ?? [];
const usedVramBytes = models.reduce( let totalUsedBytes = 0;
(sum, m) => sum + (m.size_vram ?? 0), let mainModelUsedBytes = 0;
0 for (const model of models) {
); totalUsedBytes += model.size_vram || 0;
const usedVramMb = Math.round(usedVramBytes / 1024 / 1024); if (
// จำกัด VRAM ไม่เกิน limit 90% ของ GPU ทั้งหมด model.name.includes('np-dms-ai') ||
const maxAllowedMb = Math.floor( model.name.includes('typhoon2.5-np-dms')
GPU_TOTAL_VRAM_MB * VRAM_USAGE_LIMIT_PERCENT ) {
); mainModelUsedBytes += model.size_vram || 0;
const freeVramMb = Math.max(0, maxAllowedMb - usedVramMb); }
const status: VramStatus = { }
totalVramMb: GPU_TOTAL_VRAM_MB, const usedMb = Math.round(totalUsedBytes / (1024 * 1024));
usedVramMb, const availableMb = Math.max(0, this.totalVramMb - usedMb);
freeVramMb, const mainModelVramMb = Math.round(mainModelUsedBytes / (1024 * 1024));
loadedModels, return {
hasCapacity: freeVramMb >= minRequiredMb, totalMb: this.totalVramMb,
usedMb,
availableMb,
querySuccess: true,
mainModelVramMb,
}; };
await this.redis.setex(
VRAM_STATUS_CACHE_KEY,
VRAM_STATUS_TTL_SECONDS,
JSON.stringify(status)
);
return status;
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.warn( this.logger.warn(
`VRAM status fetch failed: ${msg} — ใช้ค่า resilient fallback` `Failed to query Ollama /api/ps: ${err instanceof Error ? err.message : String(err)}`
); );
return { return {
totalVramMb: GPU_TOTAL_VRAM_MB, totalMb: this.totalVramMb,
usedVramMb: 0, usedMb: this.totalVramMb, // บังคับให้ used = total เพื่อให้ available = 0
freeVramMb: GPU_TOTAL_VRAM_MB, availableMb: 0,
loadedModels: [], querySuccess: false,
hasCapacity: true, mainModelVramMb: 0,
}; };
} }
} }
/** /**
* VRAM cache ( model unload keep_alive=0) * VRAM
* status check Ollama * endpoint vram/status
*/
async getVramStatus(minRequiredMb = 4000): Promise<VramStatus> {
try {
const response = await axios.get<{
models?: Array<{
name: string;
size_vram: number;
}>;
}>(`${this.ollamaUrl}/api/ps`, { timeout: 3000 });
const models = response.data?.models ?? [];
const loadedModels = models.map((m) => m.name);
const headroom = await this.getVramHeadroom();
return {
totalVramMb: headroom.totalMb,
usedVramMb: headroom.usedMb,
freeVramMb: headroom.availableMb,
loadedModels,
hasCapacity: headroom.availableMb >= minRequiredMb,
};
} catch (err: unknown) {
this.logger.warn(
`Failed to get VRAM status: ${err instanceof Error ? err.message : String(err)}`
);
return {
totalVramMb: this.totalVramMb,
usedVramMb: this.totalVramMb,
freeVramMb: 0,
loadedModels: [],
hasCapacity: false,
};
}
}
/**
* VRAM
*/
async hasVramCapacity(requiredMb: number): Promise<boolean> {
const headroom = await this.getVramHeadroom();
return headroom.availableMb >= requiredMb;
}
/**
* cache VRAM ( cache )
*/ */
async invalidateCache(): Promise<void> { async invalidateCache(): Promise<void> {
await this.redis.del(VRAM_STATUS_CACHE_KEY); await Promise.resolve();
this.logger.log('VRAM cache invalidation requested (no-op in new policy)');
} }
} }
@@ -0,0 +1,138 @@
// File: backend/src/modules/ai/tests/ai-policy.service.spec.ts
// Change Log:
// - 2026-06-11: สร้าง unit tests สำหรับ AiPolicyService (US5)
// - 2026-06-11: แก้ไข DEFAULT_REDIS_TOKEN import เป็นค่าคงที่ string
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiPolicyService } from '../services/ai-policy.service';
import { AiExecutionProfile } from '../entities/ai-execution-profile.entity';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
describe('AiPolicyService', () => {
let service: AiPolicyService;
const mockProfileRepo = {
findOne: jest.fn(),
};
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AiPolicyService,
{
provide: getRepositoryToken(AiExecutionProfile),
useValue: mockProfileRepo,
},
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
service = module.get<AiPolicyService>(AiPolicyService);
});
describe('getCanonicalModelName', () => {
it('ควรคืนค่า np-dms-ocr สำหรับชื่อโมเดลที่มีคำว่า ocr', () => {
expect(service.getCanonicalModelName('typhoon-np-dms-ocr:latest')).toBe(
'np-dms-ocr'
);
expect(service.getCanonicalModelName('my-ocr-model')).toBe('np-dms-ocr');
});
it('ควรคืนค่า np-dms-ai สำหรับโมเดลอื่นๆ', () => {
expect(service.getCanonicalModelName('typhoon2.5-np-dms:latest')).toBe(
'np-dms-ai'
);
expect(service.getCanonicalModelName('gemma')).toBe('np-dms-ai');
});
});
describe('getProfileForJobType', () => {
it('ควร map job type ต่างๆ เป็น profile ที่ถูกต้อง', () => {
expect(service.getProfileForJobType('auto-fill-document')).toBe(
'quality'
);
expect(service.getProfileForJobType('migrate-document')).toBe('quality');
expect(service.getProfileForJobType('rag-query')).toBe('standard');
expect(service.getProfileForJobType('intent-classify')).toBe(
'interactive'
);
expect(service.getProfileForJobType('tool-suggest')).toBe('interactive');
expect(service.getProfileForJobType('sandbox-analysis')).toBe(
'deep-analysis'
);
expect(service.getProfileForJobType('ocr-extract')).toBe('standard');
});
});
describe('getProfileParameters', () => {
it('ควรดึงพารามิเตอร์จาก Redis cache เมื่อมี cache hit', async () => {
const mockPolicy = {
canonicalModel: 'np-dms-ai' as const,
temperature: 0.2,
topP: 0.9,
maxTokens: 1000,
numCtx: 4000,
repeatPenalty: 1.1,
keepAliveSeconds: 120,
};
mockRedis.get.mockResolvedValue(JSON.stringify(mockPolicy));
const result = await service.getProfileParameters('standard');
expect(result).toEqual(mockPolicy);
expect(mockRedis.get).toHaveBeenCalledWith(
'ai_execution_profiles:standard'
);
expect(mockProfileRepo.findOne).not.toHaveBeenCalled();
});
it('ควรดึงพารามิเตอร์จาก DB เมื่อ cache miss และบันทึกลง cache', async () => {
mockRedis.get.mockResolvedValue(null);
const mockDbProfile = {
profileName: 'standard',
isActive: true,
temperature: 0.4,
topP: 0.85,
maxTokens: 3000,
numCtx: 6000,
repeatPenalty: 1.2,
keepAliveSeconds: 400,
};
mockProfileRepo.findOne.mockResolvedValue(mockDbProfile);
const result = await service.getProfileParameters('standard');
expect(result.temperature).toBe(0.4);
expect(result.maxTokens).toBe(3000);
expect(mockRedis.set).toHaveBeenCalled();
});
it('ควร fallback ไปยัง Default parameters เมื่อดึงจาก DB หรือ Redis ล้มเหลว', async () => {
mockRedis.get.mockRejectedValue(new Error('Redis down'));
mockProfileRepo.findOne.mockRejectedValue(new Error('DB down'));
const result = await service.getProfileParameters('deep-analysis');
expect(result.canonicalModel).toBe('np-dms-ai');
expect(result.keepAliveSeconds).toBe(0);
});
});
describe('createJobPayload', () => {
it('ควรสร้าง payload ของ BullMQ job ที่มี snapshot parameters ครบถ้วน', async () => {
mockRedis.get.mockResolvedValue(null);
mockProfileRepo.findOne.mockResolvedValue(null); // ใช้ default
const payload = await service.createJobPayload(
'rag-query',
'doc-1',
'attach-1'
);
expect(payload.jobType).toBe('rag-query');
expect(payload.documentPublicId).toBe('doc-1');
expect(payload.attachmentPublicId).toBe('attach-1');
expect(payload.effectiveProfile).toBe('standard');
expect(payload.canonicalModel).toBe('np-dms-ai');
expect(payload.snapshotParams).toBeDefined();
expect(payload.snapshotParams.temperature).toBe(0.5);
});
});
});
@@ -0,0 +1,171 @@
// File: backend/src/modules/ai/tests/ai.controller.spec.ts
// Change Log:
// - 2026-06-11: สร้าง integration tests สำหรับ AiController forbidden fields (US5)
// - 2026-06-11: เพิ่ม ConfigService mock และ override ServiceAccountGuard เพื่อแก้ DI error
// - 2026-06-11: แก้ไขการ import supertest ให้ถูกต้อง เพื่อป้องกัน TypeError: request is not a function
// - 2026-06-11: แก้ไขการตรวจสอบ message array ในการทดสอบ validation ให้ถูกต้อง
// - 2026-06-11: แก้ไข ESLint unsafe argument/member access errors ใน integration tests
// - 2026-06-11: เพิ่ม mock 'default_IORedisModuleConnectionToken' เพื่อแก้ปัญหา NestJS DI และลบบรรทัดว่างในฟังก์ชัน
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AiController } from '../ai.controller';
import { AiService } from '../ai.service';
import { AiIngestService } from '../ai-ingest.service';
import { AiRagService } from '../ai-rag.service';
import { AiQueueService } from '../ai-queue.service';
import { AiSettingsService } from '../ai-settings.service';
import { AiToolRegistryService } from '../tool/ai-tool-registry.service';
import { FileStorageService } from '../../../common/file-storage/file-storage.service';
import { AiMigrationCheckpointService } from '../ai-migration-checkpoint.service';
import { OcrService } from '../services/ocr.service';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../common/guards/rbac.guard';
import { AiEnabledGuard } from '../guards/ai-enabled.guard';
import { ServiceAccountGuard } from '../guards/service-account.guard';
import { ConfigService } from '@nestjs/config';
describe('AiController (Integration)', () => {
let app: INestApplication;
const mockGuard = { canActivate: () => true };
const mockAiService = {
submitUnifiedJob: jest.fn().mockResolvedValue({
jobId: 'job-123',
status: 'queued',
effectiveProfile: 'standard',
modelUsed: 'np-dms-ai',
}),
};
const mockAiIngestService = {};
const mockAiRagService = {};
const mockAiQueueService = {};
const mockAiSettingsService = {};
const mockAiToolRegistryService = {};
const mockFileStorageService = {};
const mockMigrationCheckpointService = {};
const mockOcrService = {};
beforeEach(async () => {
jest.clearAllMocks();
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [AiController],
providers: [
{ provide: AiService, useValue: mockAiService },
{ provide: AiIngestService, useValue: mockAiIngestService },
{ provide: AiRagService, useValue: mockAiRagService },
{ provide: AiQueueService, useValue: mockAiQueueService },
{ provide: AiSettingsService, useValue: mockAiSettingsService },
{ provide: AiToolRegistryService, useValue: mockAiToolRegistryService },
{ provide: FileStorageService, useValue: mockFileStorageService },
{
provide: AiMigrationCheckpointService,
useValue: mockMigrationCheckpointService,
},
{ provide: OcrService, useValue: mockOcrService },
{
provide: 'default_IORedisModuleConnectionToken',
useValue: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue('OK'),
del: jest.fn().mockResolvedValue(1),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'AI_ENABLED') return 'true';
return null;
}),
},
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockGuard)
.overrideGuard(RbacGuard)
.useValue(mockGuard)
.overrideGuard(AiEnabledGuard)
.useValue(mockGuard)
.overrideGuard(ServiceAccountGuard)
.useValue(mockGuard)
.compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
await app.init();
});
afterEach(async () => {
await app.close();
});
describe('POST /ai/jobs - Validation', () => {
it('ควรส่งผ่านเมื่อส่ง payload ที่ถูกต้อง (ไม่มี executionProfile, model, temperature ฯลฯ)', async () => {
const validPayload = {
type: 'rag-query',
documentPublicId: '019505a1-7c3e-7000-8000-abc123def456',
payload: { query: 'test' },
};
const response = await request(app.getHttpServer() as () => void)
.post('/ai/jobs')
.set('idempotency-key', 'key-123')
.send(validPayload);
expect(response.status).toBe(201);
expect(response.body).toEqual({
jobId: 'job-123',
status: 'queued',
effectiveProfile: 'standard',
modelUsed: 'np-dms-ai',
});
expect(mockAiService.submitUnifiedJob).toHaveBeenCalled();
});
it('ควรคืนสถานะ 400 Bad Request เมื่อส่ง executionProfile มาใน payload', async () => {
const invalidPayload = {
type: 'rag-query',
documentPublicId: '019505a1-7c3e-7000-8000-abc123def456',
executionProfile: 'quality',
};
const response = await request(app.getHttpServer() as () => void)
.post('/ai/jobs')
.set('idempotency-key', 'key-123')
.send(invalidPayload);
expect(response.status).toBe(400);
const body = response.body as { message: string[] };
expect(body.message[0]).toContain(
'executionProfile is forbidden in payload'
);
});
it('ควรคืนสถานะ 400 Bad Request เมื่อส่ง model มาใน payload', async () => {
const invalidPayload = {
type: 'rag-query',
documentPublicId: '019505a1-7c3e-7000-8000-abc123def456',
model: { key: 'custom' },
};
const response = await request(app.getHttpServer() as () => void)
.post('/ai/jobs')
.set('idempotency-key', 'key-123')
.send(invalidPayload);
expect(response.status).toBe(400);
const body = response.body as { message: string[] };
expect(body.message[0]).toContain('model is forbidden in payload');
});
it('ควรคืนสถานะ 400 Bad Request เมื่อส่ง temperature มาใน payload', async () => {
const invalidPayload = {
type: 'rag-query',
documentPublicId: '019505a1-7c3e-7000-8000-abc123def456',
temperature: 0.7,
};
const response = await request(app.getHttpServer() as () => void)
.post('/ai/jobs')
.set('idempotency-key', 'key-123')
.send(invalidPayload);
expect(response.status).toBe(400);
const body = response.body as { message: string[] };
expect(body.message[0]).toContain('temperature is forbidden in payload');
});
});
});
@@ -0,0 +1,141 @@
// File: backend/src/modules/ai/tests/ocr-residency.spec.ts
// Change Log:
// - 2026-06-11: Initial unit tests for adaptive OCR residency
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getRepositoryToken } from '@nestjs/typeorm';
import { OcrService } from '../services/ocr.service';
import { VramMonitorService } from '../services/vram-monitor.service';
import { AiPolicyService } from '../services/ai-policy.service';
import { OcrCacheService } from '../services/ocr-cache.service';
import { SystemSetting } from '../entities/system-setting.entity';
import { AiAuditLog } from '../entities/ai-audit-log.entity';
describe('OcrService Adaptive Residency (US2)', () => {
let service: OcrService;
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
const config: Record<string, unknown> = {
OCR_CHAR_THRESHOLD: 100,
OCR_API_URL: 'http://localhost:8765',
OCR_SIDECAR_API_KEY: 'test-key',
VRAM_HEADROOM_THRESHOLD_MB: 3000,
OCR_RESIDENCY_WINDOW_SECONDS: 120,
GPU_MAIN_MODEL_PRESSURE_THRESHOLD_MB: 12000,
};
return config[key] ?? defaultValue;
}),
};
const mockSystemSettingRepo = {
findOne: jest.fn().mockResolvedValue({
settingValue: '019505a1-7c3e-7000-8000-abc123def002',
}),
};
const mockAiAuditLogRepo = {
create: jest.fn().mockReturnValue({}),
save: jest.fn().mockResolvedValue({}),
};
const mockOcrCacheService = {};
const mockVramMonitorService = {
getVramHeadroom: jest.fn(),
hasVramCapacity: jest.fn().mockResolvedValue(true),
};
const mockAiPolicyService = {};
const mockRedis = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue('OK'),
del: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OcrService,
{ provide: ConfigService, useValue: mockConfigService },
{
provide: getRepositoryToken(SystemSetting),
useValue: mockSystemSettingRepo,
},
{
provide: getRepositoryToken(AiAuditLog),
useValue: mockAiAuditLogRepo,
},
{ provide: OcrCacheService, useValue: mockOcrCacheService },
{ provide: VramMonitorService, useValue: mockVramMonitorService },
{ provide: AiPolicyService, useValue: mockAiPolicyService },
{
provide: 'default_IORedisModuleConnectionToken',
useValue: mockRedis,
},
],
}).compile();
service = module.get<OcrService>(OcrService);
jest.clearAllMocks();
});
it('ควรคืน keepAliveSeconds=0 เมื่อ activeProfile เป็น deep-analysis (FR-B03)', async () => {
mockVramMonitorService.getVramHeadroom.mockResolvedValueOnce({
totalMb: 16384,
usedMb: 4000,
availableMb: 12384,
querySuccess: true,
mainModelVramMb: 4000,
});
const decision = await service.calculateOcrResidency('deep-analysis');
expect(decision.keepAliveSeconds).toBe(0);
expect(decision.reason).toBe('deep-analysis-active');
});
it('ควรคืน keepAliveSeconds=0 เมื่อ VRAM ของโมเดลหลักเกิน pressure threshold (FR-B03)', async () => {
mockVramMonitorService.getVramHeadroom.mockResolvedValueOnce({
totalMb: 16384,
usedMb: 13000,
availableMb: 3384,
querySuccess: true,
mainModelVramMb: 13000,
});
const decision = await service.calculateOcrResidency('standard');
expect(decision.keepAliveSeconds).toBe(0);
expect(decision.reason).toBe('high-pressure');
});
it('ควรคืน keepAliveSeconds=0 เมื่อ VRAM headroom ต่ำกว่า headroom threshold (FR-B03)', async () => {
mockVramMonitorService.getVramHeadroom.mockResolvedValueOnce({
totalMb: 16384,
usedMb: 14000,
availableMb: 2384,
querySuccess: true,
mainModelVramMb: 8000,
});
const decision = await service.calculateOcrResidency('standard');
expect(decision.keepAliveSeconds).toBe(0);
expect(decision.reason).toBe('high-pressure');
});
it('ควรคืน keepAliveSeconds > 0 (residency window) เมื่อ VRAM เพียงพอและไม่มี pressure (FR-B04)', async () => {
mockVramMonitorService.getVramHeadroom.mockResolvedValueOnce({
totalMb: 16384,
usedMb: 4000,
availableMb: 12384,
querySuccess: true,
mainModelVramMb: 4000,
});
const decision = await service.calculateOcrResidency('standard');
expect(decision.keepAliveSeconds).toBe(120);
expect(decision.reason).toBe('headroom-sufficient');
});
it('ควรคืน keepAliveSeconds=0 และ reason=query-failed เมื่อ query VRAM ล้มเหลว (FR-B05)', async () => {
mockVramMonitorService.getVramHeadroom.mockResolvedValueOnce({
totalMb: 16384,
usedMb: 16384,
availableMb: 0,
querySuccess: false,
mainModelVramMb: 0,
});
const decision = await service.calculateOcrResidency('standard');
expect(decision.keepAliveSeconds).toBe(0);
expect(decision.reason).toBe('query-failed');
});
});
@@ -0,0 +1,153 @@
// File: backend/src/modules/ai/tests/queue-policy.spec.ts
// Change Log:
// - 2026-06-11: สร้าง unit tests สำหรับทดสอบ Queue Policy & Selective Realtime Concurrency (US4)
// - 2026-06-11: แก้ไข relative import ของ Attachment ให้ถูกต้อง (3 ระดับ)
// - 2026-06-11: นำเข้า Job และ AiRealtimeJobData เพื่อแก้ไข compile/lint errors
import { Test, TestingModule } from '@nestjs/testing';
import { getQueueToken } from '@nestjs/bullmq';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Job } from 'bullmq';
import { QUEUE_AI_BATCH } from '../../common/constants/queue.constants';
import {
AiRealtimeProcessor,
AiRealtimeJobData,
} from '../processors/ai-realtime.processor';
import { OcrService } from '../services/ocr.service';
import { OllamaService } from '../services/ollama.service';
import { AiAuditLog } from '../entities/ai-audit-log.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
describe('Queue Policy (US4)', () => {
let processor: AiRealtimeProcessor;
const mockBatchQueue = {
add: jest.fn().mockResolvedValue({ id: 'redirected-job-id' }),
pause: jest.fn().mockResolvedValue(undefined),
resume: jest.fn().mockResolvedValue(undefined),
};
const mockOcrService = {
detectAndExtract: jest.fn(),
};
const mockOllamaService = {
getMainModelName: jest.fn().mockReturnValue('np-dms-ai'),
generate: jest.fn(),
};
const mockAiAuditLogRepo = {
create: jest.fn(),
save: jest.fn(),
};
const mockAttachmentRepo = {
update: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AiRealtimeProcessor,
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockBatchQueue },
{ provide: OcrService, useValue: mockOcrService },
{ provide: OllamaService, useValue: mockOllamaService },
{
provide: getRepositoryToken(AiAuditLog),
useValue: mockAiAuditLogRepo,
},
{
provide: getRepositoryToken(Attachment),
useValue: mockAttachmentRepo,
},
],
}).compile();
processor = module.get<AiRealtimeProcessor>(AiRealtimeProcessor);
});
it('ควรอนุญาตให้ lightweight jobs รันได้โดยไม่ redirect', async () => {
const jobClassify = {
id: '1',
data: {
jobType: 'intent-classify',
projectPublicId: 'project-1',
payload: { query: 'test' },
},
} as unknown as Job<AiRealtimeJobData>;
const resultClassify = await processor.process(jobClassify);
expect(resultClassify).toEqual({ success: true, intent: 'GET_RFA' });
expect(mockBatchQueue.add).not.toHaveBeenCalled();
const jobTool = {
id: '2',
data: {
jobType: 'tool-suggest',
projectPublicId: 'project-1',
payload: { query: 'test' },
},
} as unknown as Job<AiRealtimeJobData>;
const resultTool = await processor.process(jobTool);
expect(resultTool).toEqual({ success: true, suggestions: [] });
expect(mockBatchQueue.add).not.toHaveBeenCalled();
});
it('ควร redirect generation-heavy jobs ไปยัง ai-batch queue', async () => {
const jobSuggest = {
id: '3',
data: {
jobType: 'ai-suggest',
projectPublicId: 'project-1',
payload: { query: 'test' },
},
} as unknown as Job<AiRealtimeJobData>;
await processor.process(jobSuggest);
expect(mockBatchQueue.add).toHaveBeenCalledWith(
'ai-suggest',
jobSuggest.data,
{ jobId: '3' }
);
const jobRag = {
id: '4',
data: {
jobType: 'rag-query',
projectPublicId: 'project-1',
payload: { query: 'test' },
},
} as unknown as Job<AiRealtimeJobData>;
await processor.process(jobRag);
expect(mockBatchQueue.add).toHaveBeenCalledWith('rag-query', jobRag.data, {
jobId: '4',
});
});
it('ควร resume ai-batch เมื่อ realtime jobs ทั้งหมดเสร็จแล้วเท่านั้น', async () => {
const firstJob = {
id: '10',
data: { jobType: 'intent-classify' },
} as Job<AiRealtimeJobData>;
const secondJob = {
id: '11',
data: { jobType: 'tool-suggest' },
} as Job<AiRealtimeJobData>;
await processor.onActive(firstJob);
await processor.onActive(secondJob);
expect(mockBatchQueue.pause).toHaveBeenCalledTimes(1);
await processor.onCompleted(firstJob);
expect(mockBatchQueue.resume).not.toHaveBeenCalled();
await processor.onCompleted(secondJob);
expect(mockBatchQueue.resume).toHaveBeenCalledTimes(1);
});
it('ควรยัง pause ai-batch ต่อเมื่อมี realtime job อื่น active อยู่แม้มี job หนึ่ง fail', async () => {
const firstJob = {
id: '12',
data: { jobType: 'intent-classify' },
} as Job<AiRealtimeJobData>;
const secondJob = {
id: '13',
data: { jobType: 'tool-suggest' },
} as Job<AiRealtimeJobData>;
await processor.onActive(firstJob);
await processor.onActive(secondJob);
expect(mockBatchQueue.pause).toHaveBeenCalledTimes(1);
await processor.onFailed(firstJob);
expect(mockBatchQueue.resume).not.toHaveBeenCalled();
await processor.onCompleted(secondJob);
expect(mockBatchQueue.resume).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,102 @@
// File: backend/src/modules/ai/tests/vram-monitor.service.spec.ts
// Change Log:
// - 2026-06-11: สร้าง unit tests สำหรับ VramMonitorService (US5)
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { VramMonitorService } from '../services/vram-monitor.service';
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('VramMonitorService', () => {
let service: VramMonitorService;
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
const config: Record<string, unknown> = {
OLLAMA_URL: 'http://localhost:11434',
GPU_TOTAL_VRAM_MB: 8192, // mock total 8GB
};
return config[key] !== undefined ? config[key] : defaultValue;
}),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
VramMonitorService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<VramMonitorService>(VramMonitorService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getVramHeadroom', () => {
it('ควรคำนวณ headroom ถูกต้องเมื่อ Ollama คืนข้อมูลโมเดลปกติ', async () => {
mockedAxios.get.mockResolvedValue({
data: {
models: [
{
name: 'typhoon2.5-np-dms:latest',
size_vram: 4 * 1024 * 1024 * 1024,
}, // 4GB
{ name: 'other-model', size_vram: 2 * 1024 * 1024 * 1024 }, // 2GB
],
},
});
const headroom = await service.getVramHeadroom();
expect(headroom.querySuccess).toBe(true);
expect(headroom.totalMb).toBe(8192);
expect(headroom.usedMb).toBe(6144); // 4GB + 2GB = 6GB (6144MB)
expect(headroom.availableMb).toBe(2048); // 8GB - 6GB = 2GB (2048MB)
expect(headroom.mainModelVramMb).toBe(4096); // 4GB main model (4096MB)
});
it('ควรคำนวณ headroom เป็น safe default (0 available) เมื่อ Ollama query ล้มเหลว', async () => {
mockedAxios.get.mockRejectedValue(new Error('Connection timeout'));
const headroom = await service.getVramHeadroom();
expect(headroom.querySuccess).toBe(false);
expect(headroom.availableMb).toBe(0);
expect(headroom.usedMb).toBe(8192);
expect(headroom.mainModelVramMb).toBe(0);
});
});
describe('hasVramCapacity', () => {
it('ควรคืน true เมื่อ headroom พอตามค่าที่ขอ', async () => {
mockedAxios.get.mockResolvedValue({
data: {
models: [
{
name: 'typhoon2.5-np-dms:latest',
size_vram: 4 * 1024 * 1024 * 1024,
},
],
},
});
const result = await service.hasVramCapacity(3000); // query available is 4096MB
expect(result).toBe(true);
});
it('ควรคืน false เมื่อ headroom ไม่พอตามค่าที่ขอ', async () => {
mockedAxios.get.mockResolvedValue({
data: {
models: [
{
name: 'typhoon2.5-np-dms:latest',
size_vram: 6 * 1024 * 1024 * 1024,
}, // 6GB used
],
},
});
const result = await service.hasVramCapacity(3000); // query available is 2048MB, required 3000MB
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,175 @@
// File: src/modules/correspondence/correspondence-workflow.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ CorrespondenceWorkflowService เพื่อทดสอบการเรียกใช้ RAG prepare job เมื่อสถานะเปลี่ยนจาก DRAFT (T017)
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { NotificationService } from '../notification/notification.service';
import { UserService } from '../user/user.service';
import { AiQueueService } from '../ai/ai-queue.service';
describe('CorrespondenceWorkflowService', () => {
let service: CorrespondenceWorkflowService;
let aiQueueService: AiQueueService;
const mockWorkflowEngine = {
createInstance: jest.fn(),
processTransition: jest.fn(),
getInstanceById: jest.fn(),
};
const mockCorrespondenceRepo = {
findOne: jest.fn(),
save: jest.fn(),
};
const mockRevisionRepo = {
findOne: jest.fn(),
save: jest.fn(),
manager: {
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
};
const mockStatusRepo = {
findOne: jest.fn(),
};
const mockRecipientRepo = {
find: jest.fn(),
};
const mockDataSource = {
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: mockRevisionRepo.manager,
}),
};
const mockNotificationService = {
send: jest.fn(),
};
const mockUserService = {
findDocControlIdByOrg: jest.fn(),
};
const mockAiQueueService = {
enqueueRagPrepare: jest.fn().mockResolvedValue('job-id-123'),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CorrespondenceWorkflowService,
{ provide: WorkflowEngineService, useValue: mockWorkflowEngine },
{
provide: getRepositoryToken(Correspondence),
useValue: mockCorrespondenceRepo,
},
{
provide: getRepositoryToken(CorrespondenceRevision),
useValue: mockRevisionRepo,
},
{
provide: getRepositoryToken(CorrespondenceStatus),
useValue: mockStatusRepo,
},
{
provide: getRepositoryToken(CorrespondenceRecipient),
useValue: mockRecipientRepo,
},
{ provide: DataSource, useValue: mockDataSource },
{ provide: NotificationService, useValue: mockNotificationService },
{ provide: UserService, useValue: mockUserService },
{ provide: AiQueueService, useValue: mockAiQueueService },
],
}).compile();
service = module.get<CorrespondenceWorkflowService>(
CorrespondenceWorkflowService
);
aiQueueService = module.get<AiQueueService>(AiQueueService);
jest.clearAllMocks();
});
describe('syncStatus RAG trigger', () => {
it('ควรเรียก enqueueRagPrepare เมื่อสถานะเอกสารถูกเปลี่ยนจาก DRAFT เป็นอย่างอื่น', async () => {
const mockStatus = { id: 2, statusCode: 'SUBOWN' };
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
const mockProject = { id: 10, publicId: 'proj-uuid-123' };
const mockCorrespondence = {
id: 100,
publicId: 'doc-uuid-999',
correspondenceNumber: 'CORR-001',
projectId: 10,
project: mockProject,
type: { correspondenceTypeCode: 'LETTER' },
};
const mockRevision = {
id: 50,
correspondenceId: 100,
revisionNumber: 0,
subject: 'Test Subject',
documentDate: new Date('2026-06-05'),
correspondence: mockCorrespondence,
statusId: 1,
};
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
mockRevisionRepo.manager.find.mockResolvedValueOnce([
{
correspondenceRevisionId: 50,
attachmentId: 88,
isMainDocument: true,
attachment: { filePath: '/files/doc.pdf', fileExtension: 'pdf' },
},
]);
await (
service as unknown as {
syncStatus: (
revision: CorrespondenceRevision,
workflowState: string
) => Promise<void>;
}
).syncStatus(
mockRevision as unknown as CorrespondenceRevision,
'IN_REVIEW'
);
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
expect(aiQueueService.enqueueRagPrepare).toHaveBeenCalledWith({
documentPublicId: 'doc-uuid-999',
projectPublicId: 'proj-uuid-123',
correspondenceNumber: 'CORR-001',
docType: 'LETTER',
statusCode: 'SUBOWN',
revisionNumber: 0,
subject: 'Test Subject',
documentDate: '2026-06-05',
attachmentPath: '/files/doc.pdf',
});
});
it('ไม่ควรเรียก enqueueRagPrepare เมื่อเอกสารยังคงอยู่ในสถานะ DRAFT', async () => {
const mockStatus = { id: 1, statusCode: 'DRAFT' };
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
const mockRevision = {
id: 50,
correspondenceId: 100,
revisionNumber: 0,
subject: 'Test Subject',
statusId: 1,
};
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
await (
service as unknown as {
syncStatus: (
revision: CorrespondenceRevision,
workflowState: string
) => Promise<void>;
}
).syncStatus(mockRevision as unknown as CorrespondenceRevision, 'DRAFT');
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
expect(aiQueueService.enqueueRagPrepare).not.toHaveBeenCalled();
});
});
});
@@ -10,8 +10,11 @@ import { CorrespondenceRevision } from './entities/correspondence-revision.entit
import { CorrespondenceStatus } from './entities/correspondence-status.entity'; import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { Correspondence } from './entities/correspondence.entity'; import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity'; import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { CorrespondenceRevisionAttachment } from './entities/correspondence-revision-attachment.entity';
import { NotificationService } from '../notification/notification.service'; import { NotificationService } from '../notification/notification.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { AiQueueService } from '../ai/ai-queue.service';
import { Project } from '../project/entities/project.entity';
@Injectable() @Injectable()
export class CorrespondenceWorkflowService { export class CorrespondenceWorkflowService {
@@ -30,7 +33,8 @@ export class CorrespondenceWorkflowService {
private readonly recipientRepo: Repository<CorrespondenceRecipient>, private readonly recipientRepo: Repository<CorrespondenceRecipient>,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly userService: UserService private readonly userService: UserService,
private readonly aiQueueService: AiQueueService
) {} ) {}
async submitWorkflow( async submitWorkflow(
@@ -85,41 +89,67 @@ export class CorrespondenceWorkflowService {
{ roles: userRoles } // [FIX] Pass roles for DSL requirements check { roles: userRoles } // [FIX] Pass roles for DSL requirements check
); );
await this.syncStatus(revision, transitionResult.nextState, queryRunner); await this.syncStatus(
revision,
transitionResult.nextState,
queryRunner,
true
);
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
// After-commit: RAG preparation (fire-and-forget)
// ย้ายมาหลัง commit เพื่อป้องกัน job ถูก enqueue แต่ transaction rollback
try {
if (transitionResult.nextState !== 'DRAFT') {
await this.triggerRagPrepare(revision, transitionResult.nextState);
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`After-commit RAG preparation failed (non-critical): ${errMsg}`
);
}
// Notify TO recipient org users (fire-and-forget) // Notify TO recipient org users (fire-and-forget)
const corrForNotify = revision.correspondence; try {
if (corrForNotify) { const corrForNotify = revision.correspondence;
void this.recipientRepo if (corrForNotify) {
.find({ void this.recipientRepo
where: { .find({
correspondenceId: corrForNotify.id, where: {
recipientType: 'TO', correspondenceId: corrForNotify.id,
}, recipientType: 'TO',
}) },
.then(async (recipients) => { })
for (const r of recipients) { .then(async (recipients) => {
const targetUserId = await this.userService.findDocControlIdByOrg( for (const r of recipients) {
r.recipientOrganizationId const targetUserId =
); await this.userService.findDocControlIdByOrg(
if (targetUserId) { r.recipientOrganizationId
await this.notificationService.send({ );
userId: targetUserId, if (targetUserId) {
title: 'New Correspondence Received', await this.notificationService.send({
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`, userId: targetUserId,
type: 'EMAIL', title: 'New Correspondence Received',
entityType: 'correspondence', message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
entityId: revision.correspondenceId, type: 'EMAIL',
link: `/correspondences/${corrForNotify.publicId}`, entityType: 'correspondence',
}); entityId: revision.correspondenceId,
link: `/correspondences/${corrForNotify.publicId}`,
});
}
} }
} })
}) .catch((err: Error) =>
.catch((err: Error) => this.logger.warn(`Submit notification failed: ${err.message}`)
this.logger.warn(`Submit notification failed: ${err.message}`) );
); }
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`After-commit notification setup failed (non-critical): ${errMsg}`
);
} }
return { return {
@@ -166,7 +196,8 @@ export class CorrespondenceWorkflowService {
private async syncStatus( private async syncStatus(
revision: CorrespondenceRevision, revision: CorrespondenceRevision,
workflowState: string, workflowState: string,
queryRunner?: import('typeorm').QueryRunner queryRunner?: import('typeorm').QueryRunner,
skipRagPrepare = false
) { ) {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
DRAFT: 'DRAFT', DRAFT: 'DRAFT',
@@ -174,21 +205,95 @@ export class CorrespondenceWorkflowService {
APPROVED: 'CLBOWN', APPROVED: 'CLBOWN',
REJECTED: 'CCBOWN', REJECTED: 'CCBOWN',
}; };
const targetCode = statusMap[workflowState] || 'DRAFT'; const targetCode = statusMap[workflowState] || 'DRAFT';
const status = await this.statusRepo.findOne({ const status = await this.statusRepo.findOne({
where: { statusCode: targetCode }, // ✅ FIX: CamelCase where: { statusCode: targetCode },
}); });
if (status) { if (status) {
// ✅ FIX: CamelCase (correspondenceStatusId)
revision.statusId = status.id; revision.statusId = status.id;
const manager = queryRunner const manager = queryRunner
? queryRunner.manager ? queryRunner.manager
: this.revisionRepo.manager; : this.revisionRepo.manager;
await manager.save(revision); await manager.save(revision);
} }
// Await RAG preparation เพื่อให้ unit test assert ได้
// caller (submitWorkflow/processAction) ก็ยังคง await syncStatus ตามปกติ
if (!skipRagPrepare && workflowState !== 'DRAFT') {
await this.triggerRagPrepare(revision, targetCode);
}
}
/**
* triggerRagPrepare revision/correspondence enqueue rag-prepare job
* Promise test await assert production caller await syncStatus
*/
private async triggerRagPrepare(
revision: CorrespondenceRevision,
statusCode: string
): Promise<void> {
try {
let correspondence: Correspondence | null | undefined =
revision.correspondence;
if (!correspondence) {
correspondence = await this.correspondenceRepo.findOne({
where: { id: revision.correspondenceId },
relations: ['project', 'type'],
});
}
if (!correspondence) {
return;
}
let projectPublicId = '';
if (correspondence.project) {
projectPublicId = correspondence.project.publicId;
} else {
const proj = await this.correspondenceRepo.manager.findOne(Project, {
where: { id: correspondence.projectId },
});
if (proj) {
projectPublicId = proj.publicId;
}
}
const docType = correspondence.type?.typeCode || 'LETTER';
let attachmentPath: string | undefined;
const attachments = await this.revisionRepo.manager.find(
CorrespondenceRevisionAttachment,
{ where: { correspondenceRevisionId: revision.id } }
);
if (attachments && attachments.length > 0) {
const pdfAtt = attachments.find((att) => {
const ext =
att.attachment?.originalFilename?.split('.').pop()?.toLowerCase() ||
'';
return (
ext === 'pdf' ||
att.attachment?.filePath?.toLowerCase().endsWith('.pdf')
);
});
if (pdfAtt && pdfAtt.attachment) {
attachmentPath = pdfAtt.attachment.filePath;
} else if (attachments[0].attachment) {
attachmentPath = attachments[0].attachment.filePath;
}
}
await this.aiQueueService.enqueueRagPrepare({
documentPublicId: correspondence.publicId,
projectPublicId: projectPublicId,
correspondenceNumber: correspondence.correspondenceNumber,
docType: docType,
statusCode: statusCode,
revisionNumber: revision.revisionNumber,
subject: revision.subject,
documentDate: revision.documentDate
? revision.documentDate.toISOString().split('T')[0]
: undefined,
attachmentPath: attachmentPath,
});
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Failed to enqueue RAG preparation for revision ${revision.id}: ${errMsg}`
);
}
} }
} }
@@ -25,6 +25,7 @@ import { SearchModule } from '../search/search.module';
import { FileStorageModule } from '../../common/file-storage/file-storage.module'; import { FileStorageModule } from '../../common/file-storage/file-storage.module';
import { NotificationModule } from '../notification/notification.module'; import { NotificationModule } from '../notification/notification.module';
import { CirculationModule } from '../circulation/circulation.module'; import { CirculationModule } from '../circulation/circulation.module';
import { AiModule } from '../ai/ai.module';
/** /**
* CorrespondenceModule * CorrespondenceModule
@@ -53,6 +54,7 @@ import { CirculationModule } from '../circulation/circulation.module';
FileStorageModule, FileStorageModule,
NotificationModule, NotificationModule,
CirculationModule, CirculationModule,
AiModule,
], ],
controllers: [CorrespondenceController], controllers: [CorrespondenceController],
providers: [ providers: [
@@ -1,86 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IngestionService } from '../ingestion.service';
const QUEUE_TOKEN = 'BullQueue_rag-ocr';
const mockOcrQueue = {
getJob: jest.fn(),
add: jest.fn(),
};
const baseJobData = {
attachmentPublicId: 'att-uuid-001',
filePath: '/uploads/permanent/CORR/2026/04/file.pdf',
docType: 'CORR',
docNumber: 'REF-001',
revision: null,
projectCode: 'PRJ-001',
projectPublicId: 'proj-uuid-001',
classification: 'INTERNAL' as const,
};
describe('IngestionService', () => {
let service: IngestionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IngestionService,
{ provide: QUEUE_TOKEN, useValue: mockOcrQueue },
],
}).compile();
service = module.get<IngestionService>(IngestionService);
jest.clearAllMocks();
});
it('should enqueue rag-ocr job with attachmentPublicId as jobId', async () => {
mockOcrQueue.getJob.mockResolvedValue(null);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledWith('ocr', baseJobData, {
jobId: baseJobData.attachmentPublicId,
});
});
it('EC-RAG-001: duplicate enqueue when job is active → second call is no-op (log only)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('active') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).not.toHaveBeenCalled();
});
it('EC-RAG-001: duplicate enqueue when job is waiting → second call is no-op', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('waiting') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).not.toHaveBeenCalled();
});
it('should re-enqueue if job exists but is completed (state=completed)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('completed') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
});
it('should re-enqueue if job exists but is failed (state=failed)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('failed') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
});
});
@@ -1,213 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ServiceUnavailableException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getQueueToken } from '@nestjs/bullmq';
import { RagService } from '../rag.service';
import { QdrantService } from '../qdrant.service';
import { EmbeddingService } from '../embedding.service';
import { LocalLlmService } from '../local-llm.service';
import { IngestionService } from '../ingestion.service';
import { DocumentChunk } from '../entities/document-chunk.entity';
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
const mockQdrant = {
isReady: jest.fn(),
hybridSearch: jest.fn(),
deleteByDocumentId: jest.fn(),
};
const mockEmbedding = {
embed: jest.fn(),
};
const mockLocalLlm = {
generate: jest.fn(),
sanitizeInput: jest.fn((t: string) => t),
};
const mockIngestion = { enqueue: jest.fn() };
const mockChunkRepo = {
count: jest.fn(),
delete: jest.fn(),
manager: {
query: jest.fn(),
},
};
const mockRedis = {
get: jest.fn(),
setex: jest.fn(),
};
const mockVectorDeletionQueue = {
add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),
};
describe('RagService', () => {
let service: RagService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RagService,
{ provide: QdrantService, useValue: mockQdrant },
{ provide: EmbeddingService, useValue: mockEmbedding },
{ provide: LocalLlmService, useValue: mockLocalLlm },
{ provide: IngestionService, useValue: mockIngestion },
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
{
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
useValue: mockVectorDeletionQueue,
},
],
}).compile();
service = module.get<RagService>(RagService);
jest.clearAllMocks();
});
describe('query()', () => {
const dto = {
question: 'เอกสารเกี่ยวกับอะไร?',
projectPublicId: 'proj-uuid-1234',
};
const memberPerms: string[] = [];
const adminPerms = ['system.manage_all'];
it('should return answer with citations on PUBLIC cache miss → write cache', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([
{
chunkId: 'chunk-1',
publicId: 'att-1',
docType: 'CORR',
docNumber: 'REF-001',
revision: null,
projectCode: 'PRJ-001',
contentPreview: 'เนื้อหาเอกสาร',
score: 0.92,
},
]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'คำตอบ',
usedFallbackModel: false,
});
const result = await service.query(dto, memberPerms);
expect(result.answer).toBe('คำตอบ');
expect(result.citations).toHaveLength(1);
expect(result.usedFallbackModel).toBe(false);
expect(mockRedis.setex).toHaveBeenCalledTimes(1);
});
it('should return cached result without calling Qdrant on cache hit', async () => {
mockQdrant.isReady.mockReturnValue(true);
const cached = JSON.stringify({
answer: 'cached answer',
citations: [],
confidence: 0.9,
usedFallbackModel: false,
});
mockRedis.get.mockResolvedValue(cached);
const result = await service.query(dto, memberPerms);
expect(result.answer).toBe('cached answer');
expect(mockQdrant.hybridSearch).not.toHaveBeenCalled();
expect(mockEmbedding.embed).not.toHaveBeenCalled();
});
it('CONFIDENTIAL: must use Ollama only, skip cache read and write', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'ลับมาก',
usedFallbackModel: false,
});
const result = await service.query(dto, adminPerms);
expect(mockRedis.get).not.toHaveBeenCalled();
expect(mockRedis.setex).not.toHaveBeenCalled();
expect(mockLocalLlm.generate).toHaveBeenCalledWith(expect.any(String));
expect(result.usedFallbackModel).toBe(false);
});
it('collectionReady=false → throw ServiceUnavailableException RAG_NOT_READY', async () => {
mockQdrant.isReady.mockReturnValue(false);
await expect(service.query(dto, memberPerms)).rejects.toThrow(
ServiceUnavailableException
);
});
it('cross-project cache isolation: same question different projectPublicId → different cache key', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'A',
usedFallbackModel: false,
});
await service.query(
{ question: 'Q?', projectPublicId: 'proj-A' },
memberPerms
);
await service.query(
{ question: 'Q?', projectPublicId: 'proj-B' },
memberPerms
);
const calls = mockRedis.setex.mock.calls as [string, ...unknown[]][];
expect(calls[0][0]).not.toBe(calls[1][0]);
});
it('classification ceiling derived from role, not from request body', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
anwer: 'ok',
usedFallbackModel: false,
});
await service.query(dto, memberPerms);
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
expect.any(Array),
dto.projectPublicId,
'INTERNAL',
20
);
jest.clearAllMocks();
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'ok',
usedFallbackModel: false,
});
await service.query(dto, adminPerms);
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
expect.any(Array),
dto.projectPublicId,
'CONFIDENTIAL',
20
);
});
});
});
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
export class RagQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(500)
question!: string;
@IsUUID()
projectPublicId!: string;
}
@@ -1,16 +0,0 @@
export interface RagCitation {
chunkId: string;
docNumber: string | null;
docType: string;
revision: string | null;
snippet: string;
score: number;
}
export class RagResponseDto {
answer!: string;
citations!: RagCitation[];
confidence!: number;
usedFallbackModel!: boolean;
cachedAt?: string;
}
@@ -1,46 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
@Injectable()
export class EmbeddingService {
private readonly logger = new Logger(EmbeddingService.name);
private readonly ollamaUrl: string;
private readonly model: string;
constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
'http://localhost:11434'
);
this.model = this.configService.get<string>(
'OLLAMA_EMBED_MODEL',
'nomic-embed-text'
);
}
async embed(text: string): Promise<number[]> {
try {
const response = await axios.post<{ embedding: number[] }>(
`${this.ollamaUrl}/api/embeddings`,
{ model: this.model, prompt: text },
{ timeout: 30000 }
);
return response.data.embedding;
} catch (err) {
this.logger.error(
'Embedding failed',
err instanceof Error ? err.stack : String(err)
);
throw err;
}
}
async embedBatch(texts: string[]): Promise<number[][]> {
return Promise.all(texts.map((t) => this.embed(t)));
}
getModelName(): string {
return this.model;
}
}
@@ -1,47 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
@Entity('document_chunks')
export class DocumentChunk {
@PrimaryColumn({ type: 'char', length: 36 })
id!: string;
@Column({ type: 'char', length: 36, name: 'document_id' })
documentId!: string;
@Column({ name: 'chunk_index' })
chunkIndex!: number;
@Column({ type: 'text' })
content!: string;
@Column({ length: 20, name: 'doc_type' })
docType!: string;
@Column({ type: 'varchar', length: 100, name: 'doc_number', nullable: true })
docNumber!: string | null;
@Column({ type: 'varchar', length: 20, nullable: true })
revision!: string | null;
@Column({ length: 50, name: 'project_code' })
projectCode!: string;
@Column({ length: 36, name: 'project_public_id' })
projectPublicId!: string;
@Column({
type: 'enum',
enum: ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'],
default: 'INTERNAL',
})
classification!: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
@Column({ type: 'varchar', length: 20, nullable: true })
version!: string | null;
@Column({ length: 100, name: 'embedding_model', default: 'nomic-embed-text' })
embeddingModel!: string;
@CreateDateColumn({ name: 'created_at', precision: 3 })
createdAt!: Date;
}
@@ -1,30 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { OcrJobData } from './processors/ocr.processor';
@Injectable()
export class IngestionService {
private readonly logger = new Logger(IngestionService.name);
constructor(@InjectQueue('rag-ocr') private readonly ocrQueue: Queue) {}
async enqueue(data: OcrJobData): Promise<void> {
const jobId = data.attachmentPublicId;
const existing = await this.ocrQueue.getJob(jobId);
if (existing) {
const state = await existing.getState();
if (state === 'active' || state === 'waiting' || state === 'delayed') {
this.logger.log(
`rag-ocr job already queued for ${jobId} (state: ${state})`
);
return;
}
}
await this.ocrQueue.add('ocr', data, { jobId });
this.logger.log(`Enqueued rag-ocr for attachment ${jobId}`);
}
}
@@ -1,71 +0,0 @@
// File: src/modules/rag/local-llm.service.ts
// Change Log
// - 2026-05-15: แทนที่ cloud LLM API ด้วย Ollama local-only ตาม ADR-023A.
// - 2026-06-03: ADR-034 — เปลี่ยน default fallback จาก gemma4:e4b เป็น typhoon2.5-np-dms:latest
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
export interface LlmGenerateResult {
answer: string;
usedFallbackModel: boolean;
}
/** บริการเรียก LLM ภายในองค์กรผ่าน Ollama เท่านั้น */
@Injectable()
export class LocalLlmService {
private readonly logger = new Logger(LocalLlmService.name);
private readonly ollamaUrl: string;
private readonly ollamaModel: string;
private readonly timeoutMs: number;
constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
);
this.ollamaModel = this.configService.get<string>(
'OLLAMA_MODEL_MAIN',
this.configService.get<string>(
'OLLAMA_RAG_MODEL',
'typhoon2.5-np-dms:latest'
)
);
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
}
/** สร้างคำตอบจากโมเดล local-only โดยไม่มี cloud fallback */
async generate(prompt: string): Promise<LlmGenerateResult> {
try {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{
model: this.ollamaModel,
prompt,
stream: false,
},
{ timeout: this.timeoutMs }
);
return {
answer: response.data.response ?? '',
usedFallbackModel: false,
};
} catch (err) {
this.logger.error(
'Local Ollama generation failed',
err instanceof Error ? err.stack : String(err)
);
throw err;
}
}
/** ทำความสะอาด prompt injection pattern พื้นฐานก่อนส่งเข้าโมเดล */
sanitizeInput(text: string): string {
return text
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
.replace(/ignore previous instructions/gi, '')
.replace(/system:/gi, '')
.slice(0, 1000);
}
}
@@ -1,110 +0,0 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import { EmbeddingService } from '../embedding.service';
import { QdrantService, VectorMetadata } from '../qdrant.service';
import { DocumentChunk } from '../entities/document-chunk.entity';
import { EmbeddingJobData } from './thai-preprocess.processor';
const CHUNK_SIZE = 512;
const CHUNK_OVERLAP = 50;
@Processor('rag-embedding')
export class EmbeddingProcessor extends WorkerHost {
private readonly logger = new Logger(EmbeddingProcessor.name);
constructor(
private readonly embeddingService: EmbeddingService,
private readonly qdrantService: QdrantService,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>
) {
super();
}
async process(job: Job<EmbeddingJobData>): Promise<void> {
const {
attachmentPublicId,
normalizedText,
docType,
docNumber,
revision,
projectCode,
projectPublicId,
classification,
} = job.data;
const chunks = this.chunkText(normalizedText);
const model = this.embeddingService.getModelName();
const upsertPoints: Parameters<QdrantService['upsertBatch']>[0] = [];
const chunkEntities: DocumentChunk[] = [];
for (let i = 0; i < chunks.length; i++) {
const chunkId = uuidv4();
const vector = await this.embeddingService.embed(chunks[i]);
const payload: VectorMetadata = {
chunk_id: chunkId,
public_id: attachmentPublicId,
project_public_id: projectPublicId,
doc_type: docType,
doc_number: docNumber,
revision,
project_code: projectCode,
classification,
content_preview: chunks[i].slice(0, 500),
embedding_model: model,
};
upsertPoints.push({ id: chunkId, vector, payload });
const entity = this.chunkRepo.create({
id: chunkId,
documentId: attachmentPublicId,
chunkIndex: i,
content: chunks[i],
docType,
docNumber,
revision,
projectCode,
projectPublicId,
classification,
embeddingModel: model,
});
chunkEntities.push(entity);
}
if (upsertPoints.length > 0) {
await this.qdrantService.upsertBatch(upsertPoints);
await this.chunkRepo.save(chunkEntities);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'INDEXED', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
);
this.logger.log(
`Embedded ${chunks.length} chunks for ${attachmentPublicId}`
);
}
private chunkText(text: string): string[] {
const words = text.split(/\s+/);
const chunks: string[] = [];
let start = 0;
while (start < words.length) {
const end = Math.min(start + CHUNK_SIZE, words.length);
chunks.push(words.slice(start, end).join(' '));
start += CHUNK_SIZE - CHUNK_OVERLAP;
}
return chunks.filter((c) => c.trim().length > 0);
}
}
@@ -1,68 +0,0 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import * as fs from 'fs';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { DocumentChunk } from '../entities/document-chunk.entity';
export interface OcrJobData {
attachmentPublicId: string;
filePath: string;
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
projectPublicId: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
}
@Processor('rag-ocr')
export class OcrProcessor extends WorkerHost {
private readonly logger = new Logger(OcrProcessor.name);
constructor(
@InjectQueue('rag-thai-preprocess') private readonly thaiQueue: Queue,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>
) {
super();
}
async process(job: Job<OcrJobData>): Promise<void> {
const { attachmentPublicId, filePath } = job.data;
const existing = await this.chunkRepo.count({
where: { documentId: attachmentPublicId },
});
if (existing > 0) {
this.logger.log(
`rag-ocr job already indexed for ${attachmentPublicId}, skipping`
);
return;
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PROCESSING' WHERE public_id = ?`,
[attachmentPublicId]
);
let rawText: string;
try {
rawText = fs.readFileSync(filePath, 'utf-8');
} catch {
rawText = `[binary:${attachmentPublicId}]`;
}
await this.thaiQueue.add(
'preprocess',
{ ...job.data, rawText },
{ jobId: `thai:${attachmentPublicId}` }
);
this.logger.log(`OCR enqueued thai-preprocess for ${attachmentPublicId}`);
}
}
@@ -1,56 +0,0 @@
import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue, Job } from 'bullmq';
import axios from 'axios';
import { OcrJobData } from './ocr.processor';
export interface ThaiPreprocessJobData extends OcrJobData {
rawText: string;
}
export interface EmbeddingJobData extends ThaiPreprocessJobData {
normalizedText: string;
}
@Processor('rag-thai-preprocess')
export class ThaiPreprocessProcessor extends WorkerHost {
private readonly logger = new Logger(ThaiPreprocessProcessor.name);
private readonly thaiUrl: string;
constructor(
private readonly configService: ConfigService,
@InjectQueue('rag-embedding') private readonly embeddingQueue: Queue
) {
super();
this.thaiUrl = this.configService.get<string>(
'THAI_PREPROCESS_URL',
'http://localhost:8765'
);
}
async process(job: Job<ThaiPreprocessJobData>): Promise<void> {
const { rawText, attachmentPublicId } = job.data;
let normalizedText = rawText;
try {
const response = await axios.post<{ normalized: string }>(
`${this.thaiUrl}/normalize`,
{ text: rawText },
{ timeout: 30000 }
);
normalizedText = response.data.normalized ?? rawText;
} catch (err) {
this.logger.warn(
`Thai preprocess failed for ${attachmentPublicId}, using raw text: ${err instanceof Error ? err.message : String(err)}`
);
}
await this.embeddingQueue.add(
'embed',
{ ...job.data, normalizedText } as EmbeddingJobData,
{ jobId: `embed:${attachmentPublicId}` }
);
}
}
-179
View File
@@ -1,179 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { QdrantClient } from '@qdrant/js-client-rest';
export interface VectorMetadata extends Record<string, unknown> {
chunk_id: string;
public_id: string;
project_public_id: string;
doc_type: string;
doc_number: string | null;
revision: string | null;
project_code: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
content_preview: string;
embedding_model: string;
}
export interface HybridSearchResult {
chunkId: string;
publicId: string;
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
contentPreview: string;
score: number;
}
const COLLECTION_NAME = 'lcbp3_vectors';
const VECTOR_SIZE = 768;
@Injectable()
export class QdrantService implements OnModuleInit {
private readonly logger = new Logger(QdrantService.name);
private client: QdrantClient;
private collectionReady = false;
constructor(private readonly configService: ConfigService) {
const url = this.configService.get<string>(
'QDRANT_URL',
'http://localhost:6333'
);
this.client = new QdrantClient({ url });
}
async onModuleInit(): Promise<void> {
try {
await this.initCollection();
this.collectionReady = true;
this.logger.log(`Qdrant collection '${COLLECTION_NAME}' ready`);
} catch (err) {
this.logger.error(
'Qdrant collection init failed — RAG queries will return 503',
err instanceof Error ? err.stack : String(err)
);
this.collectionReady = false;
}
}
isReady(): boolean {
return this.collectionReady;
}
private async initCollection(): Promise<void> {
const collections = await this.client.getCollections();
const exists = collections.collections.some(
(c) => c.name === COLLECTION_NAME
);
if (!exists) {
await this.client.createCollection(COLLECTION_NAME, {
vectors: { size: VECTOR_SIZE, distance: 'Cosine' },
hnsw_config: {
payload_m: 16,
m: 0,
},
optimizers_config: { indexing_threshold: 10000 },
});
this.logger.log(`Created Qdrant collection '${COLLECTION_NAME}'`);
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'project_public_id',
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'classification',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'doc_type',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'doc_number',
field_schema: 'keyword',
});
}
}
async upsertBatch(
points: Array<{ id: string; vector: number[]; payload: VectorMetadata }>
): Promise<void> {
await this.client.upsert(COLLECTION_NAME, {
wait: true,
points: points.map((p) => ({
id: p.id,
vector: p.vector,
payload: p.payload,
})),
});
}
async hybridSearch(
queryVector: number[],
projectPublicId: string,
classificationCeiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL',
topK: number
): Promise<HybridSearchResult[]> {
const classificationValues = this.getAllowedClassifications(
classificationCeiling
);
const vectorResults = await this.client.search(COLLECTION_NAME, {
vector: queryVector,
limit: topK,
filter: {
must: [
{ key: 'project_public_id', match: { value: projectPublicId } },
{ key: 'classification', match: { any: classificationValues } },
],
},
with_payload: true,
});
return vectorResults.map((r) => {
const payload = r.payload as unknown as VectorMetadata;
return {
chunkId: payload.chunk_id,
publicId: payload.public_id,
docType: payload.doc_type,
docNumber: payload.doc_number,
revision: payload.revision,
projectCode: payload.project_code,
contentPreview: payload.content_preview,
score: r.score,
};
});
}
async deleteByDocumentId(documentId: string): Promise<void> {
await this.client.delete(COLLECTION_NAME, {
wait: true,
filter: {
must: [{ key: 'public_id', match: { value: documentId } }],
},
});
}
async forceInitCollection(): Promise<void> {
await this.initCollection();
this.collectionReady = true;
this.logger.log(`Qdrant collection force-initialized`);
}
private getAllowedClassifications(
ceiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'
): string[] {
const order: Array<'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'> = [
'PUBLIC',
'INTERNAL',
'CONFIDENTIAL',
];
const ceilIdx = order.indexOf(ceiling);
return order.slice(0, ceilIdx + 1);
}
}
-93
View File
@@ -1,93 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { UserService } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { RagQueryDto } from './dto/rag-query.dto';
import { RagService } from './rag.service';
@ApiTags('RAG')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Controller('rag')
export class RagController {
private readonly logger = new Logger(RagController.name);
constructor(
private readonly ragService: RagService,
private readonly userService: UserService
) {}
@Post('query')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'RAG Q&A — ค้นหาคำตอบจากเอกสารโครงการ' })
@RequirePermission('rag.query')
async query(
@Body() dto: RagQueryDto,
@CurrentUser() user: User,
@Headers('Idempotency-Key') idempotencyKey: string
) {
if (!idempotencyKey) {
this.logger.warn(`Missing Idempotency-Key from user ${user.user_id}`);
}
const permissions = await this.userService.getUserPermissions(user.user_id);
return this.ragService.query(dto, permissions);
}
@Get('status/:attachmentId')
@ApiOperation({ summary: 'ดูสถานะ RAG ingestion ของ attachment' })
@RequirePermission('rag.query')
async getStatus(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
return this.ragService.getStatus(attachmentId);
}
@Post('ingest/:attachmentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Re-ingest attachment ที่ FAILED (Admin only)' })
@RequirePermission('rag.manage')
async reIngest(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
await this.ragService.reIngest(attachmentId);
return { message: 'Re-ingestion queued' };
}
@Delete('vectors/:attachmentId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'ลบ vectors ของ attachment ออกจาก Qdrant' })
@RequirePermission('rag.manage')
async deleteVectors(
@Param('attachmentId', ParseUuidPipe) attachmentId: string
) {
await this.ragService.deleteVectors(attachmentId);
}
@Post('admin/init-collection')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'T038: Init Qdrant collection lcbp3_vectors (admin only)',
})
@RequirePermission('rag.manage')
async initCollection() {
await this.ragService.initCollection();
return { message: 'Qdrant collection initialized' };
}
}
-58
View File
@@ -1,58 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule } from '@nestjs/config';
import { DocumentChunk } from './entities/document-chunk.entity';
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
import { EmbeddingService } from './embedding.service';
import { QdrantService } from './qdrant.service';
import { LocalLlmService } from './local-llm.service';
import { RagService } from './rag.service';
import { RagController } from './rag.controller';
import { IngestionService } from './ingestion.service';
import { OcrProcessor } from './processors/ocr.processor';
import { ThaiPreprocessProcessor } from './processors/thai-preprocess.processor';
import { EmbeddingProcessor } from './processors/embedding.processor';
import { UserModule } from '../user/user.module';
const DLQ_DEFAULTS = {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 2000 },
removeOnComplete: 100,
removeOnFail: 200,
};
@Module({
imports: [
ConfigModule,
UserModule,
TypeOrmModule.forFeature([DocumentChunk]),
BullModule.registerQueue(
{ name: 'rag-ocr', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag-thai-preprocess', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag-embedding', defaultJobOptions: DLQ_DEFAULTS },
// T028: Producer สำหรับ dispatch vector deletion jobs (ADR-023 FR-008)
{ name: QUEUE_AI_VECTOR_DELETION }
),
],
controllers: [RagController],
providers: [
EmbeddingService,
QdrantService,
LocalLlmService,
RagService,
IngestionService,
OcrProcessor,
ThaiPreprocessProcessor,
EmbeddingProcessor,
],
exports: [
EmbeddingService,
QdrantService,
LocalLlmService,
RagService,
IngestionService,
],
})
export class RagModule {}
-263
View File
@@ -1,263 +0,0 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
import { AiVectorDeletionJobPayload } from '../ai/ai-queue.service';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { createHash } from 'crypto';
import { QdrantService } from './qdrant.service';
import { EmbeddingService } from './embedding.service';
import { LocalLlmService } from './local-llm.service';
import { IngestionService } from './ingestion.service';
import { DocumentChunk } from './entities/document-chunk.entity';
import { RagQueryDto } from './dto/rag-query.dto';
import { RagResponseDto, RagCitation } from './dto/rag-response.dto';
const CACHE_TTL_SECONDS = 300;
const PROMPT_CONTEXT_LIMIT = 3000;
@Injectable()
export class RagService {
private readonly logger = new Logger(RagService.name);
constructor(
private readonly qdrant: QdrantService,
private readonly embedding: EmbeddingService,
private readonly localLlm: LocalLlmService,
private readonly ingestionService: IngestionService,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>,
@InjectRedis() private readonly redis: Redis,
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
) {}
async query(
dto: RagQueryDto,
userPermissions: string[]
): Promise<RagResponseDto> {
const { question, projectPublicId } = dto;
const classificationCeiling =
this.deriveClassificationCeiling(userPermissions);
const isConfidential = classificationCeiling === 'CONFIDENTIAL';
if (!this.qdrant.isReady()) {
throw new ServiceUnavailableException('RAG_NOT_READY');
}
const cacheKey = this.buildCacheKey(
question,
projectPublicId,
classificationCeiling
);
if (!isConfidential) {
const cached = await this.redis.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached) as RagResponseDto;
parsed.cachedAt = new Date().toISOString();
return parsed;
}
}
const queryVector = await this.embedding.embed(question);
const topK = 20;
const results = await this.qdrant.hybridSearch(
queryVector,
projectPublicId,
classificationCeiling,
topK
);
const reranked = results.sort((a, b) => b.score - a.score).slice(0, 5);
const context = this.buildContext(reranked);
const safeQuestion = this.localLlm.sanitizeInput(question);
const prompt = this.buildPrompt(safeQuestion, context);
const { answer, usedFallbackModel } = await this.localLlm.generate(prompt);
const citations: RagCitation[] = reranked.map((r) => ({
chunkId: r.chunkId,
docNumber: r.docNumber,
docType: r.docType,
revision: r.revision,
snippet: r.contentPreview.slice(0, 200),
score: r.score,
}));
const confidence = reranked.length > 0 ? reranked[0].score : 0;
const response: RagResponseDto = {
answer,
citations,
confidence,
usedFallbackModel,
};
if (!isConfidential) {
await this.redis.setex(
cacheKey,
CACHE_TTL_SECONDS,
JSON.stringify(response)
);
}
return response;
}
async getStatus(
attachmentPublicId: string
): Promise<{ ragStatus: string; chunkCount: number }> {
const chunkCount = await this.chunkRepo.count({
where: { documentId: attachmentPublicId },
});
const result = await this.chunkRepo.manager.query<{ rag_status: string }[]>(
`SELECT rag_status FROM attachments WHERE public_id = ? LIMIT 1`,
[attachmentPublicId]
);
const ragStatus = result[0]?.rag_status ?? 'PENDING';
return { ragStatus, chunkCount };
}
async reIngest(attachmentPublicId: string): Promise<void> {
const statusResult = await this.chunkRepo.manager.query<
{ rag_status: string; file_path: string }[]
>(
`SELECT rag_status, file_path FROM attachments WHERE public_id = ? LIMIT 1`,
[attachmentPublicId]
);
const current = statusResult[0]?.rag_status;
if (current !== 'FAILED') {
throw new BadRequestException(
`Cannot re-ingest: current status is '${current ?? 'unknown'}', expected 'FAILED'`
);
}
const sample = await this.chunkRepo.findOne({
where: { documentId: attachmentPublicId },
});
await this.chunkRepo.delete({ documentId: attachmentPublicId });
try {
await this.qdrant.deleteByDocumentId(attachmentPublicId);
} catch (err) {
this.logger.error(
`Qdrant delete failed for ${attachmentPublicId} — continuing`,
err instanceof Error ? err.stack : String(err)
);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PENDING', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
);
if (sample) {
await this.ingestionService.enqueue({
attachmentPublicId,
filePath: statusResult[0]?.file_path ?? '',
docType: sample.docType,
docNumber: sample.docNumber,
revision: sample.revision,
projectCode: sample.projectCode,
projectPublicId: sample.projectPublicId,
classification: sample.classification,
});
}
}
async initCollection(): Promise<void> {
await this.qdrant.onModuleInit();
}
async deleteVectors(
attachmentPublicId: string,
requestedByUserPublicId = 'system'
): Promise<void> {
// ลบ DocumentChunk ออกจาก DB แบบ synchronous (รวดเร็ว ไม่มี external dependency)
await this.chunkRepo.delete({ documentId: attachmentPublicId });
// T028: เปลี่ยน Qdrant deletion เป็น async ผ่าน BullMQ เพื่อ eventual consistency (FR-008)
await this.vectorDeletionQueue.add(
'delete-document-vectors',
{ documentPublicId: attachmentPublicId, requestedByUserPublicId },
{
jobId: attachmentPublicId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
}
);
this.logger.log(
`Vector deletion queued for attachment=${attachmentPublicId}`
);
}
buildContext(
results: Array<{
docType: string;
docNumber: string | null;
revision: string | null;
contentPreview: string;
}>
): string {
let context = '';
for (const r of results) {
const header = `[${r.docType}${r.docNumber ? ` - ${r.docNumber}` : ''}${r.revision ? ` - ${r.revision}` : ''}]`;
const snippet = `${header}\n${r.contentPreview}\n\n`;
if ((context + snippet).length > PROMPT_CONTEXT_LIMIT) break;
context += snippet;
}
return context.trim();
}
private buildPrompt(question: string, context: string): string {
return [
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
'',
'=== เอกสารอ้างอิง ===',
context,
'',
'=== คำถาม ===',
question,
].join('\n');
}
private buildCacheKey(
question: string,
projectPublicId: string,
classificationCeiling: string
): string {
const raw = `${question}|${projectPublicId}|${classificationCeiling}`;
return `rag:query:${createHash('sha256').update(raw).digest('hex')}`;
}
private deriveClassificationCeiling(
permissions: string[]
): 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' {
if (
permissions.includes('system.manage_all') ||
permissions.includes('document.view_confidential')
) {
return 'CONFIDENTIAL';
}
return 'INTERNAL';
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"allowJs": true,
"noEmit": true
},
"include": [
"src/**/*.ts",
"test/**/*.ts",
"tests/**/*.ts",
"scratch/**/*.ts",
"scratch/**/*.js",
"jest.config.js",
"*.config.mjs"
],
"exclude": ["node_modules", "dist", "documentation"]
}
+12
View File
@@ -0,0 +1,12 @@
# AI Runtime Policy Refactor for RTX 5060 Ti 16GB
ระบบ AI runtime ของ LCBP3-DMS จะเปลี่ยนไปใช้ canonical identities `np-dms-ai` และ `np-dms-ocr`, ใช้ `executionProfile` เป็น policy-level contract แทน model key/parameter overrides, และรวม GPU scheduling ของ main model, OCR, embedding, และ reranking ไว้ใต้ policy เดียวกัน. การตัดสินใจนี้รองรับการอัปเกรดเป็น RTX 5060 Ti 16GB โดยยังรักษา AI governance เดิมของระบบ: backend policy เป็นผู้ตัดสิน model/parameters จริง, `rag-query` เป็น generation-centric job, retrieval ใช้ GPU ได้ภายใต้ LLM-first ownership เท่านั้นและต้อง fallback CPU ได้, ส่วน rollout ใช้ big bang cutover พร้อม executable-first verification และ manual validation path สำหรับทุกแกนสำคัญ.
## Considered Options
- เก็บชื่อ canonical เดิม (`typhoon2.5-np-dms:latest` / `typhoon-np-dms-ocr:latest`) แล้วใช้ alias เฉพาะ deploy
- เปิดให้ caller ส่ง `model.key` และ runtime parameters มาใน job request
- ใช้ shared GPU pool แบบสิทธิ์เท่ากันระหว่าง LLM, OCR, embed, rerank
- phase-gated rollout แยก naming, residency, retrieval acceleration, queue policy เป็นหลายรอบ
เราไม่เลือกแนวทางเหล่านี้เพราะทำให้ governance ซ้ำซ้อน, เปิดช่อง bypass policy กลาง, หรือแยก resource policy ที่จริงผูกกันอยู่ให้กลายเป็นคนละเรื่อง. สำหรับ refactor รอบนี้ ระบบจะใช้ single-name canonical model policy, profile-only parameter governance, adaptive OCR residency, LLM-first GPU ownership, CPU fallback retrieval, selective realtime concurrency เฉพาะ lightweight realtime jobs และ big bang cutover gate ที่ต้องผ่านครบทั้ง contract, model switching, OCR residency, และ RAG fallback.
+315
View File
@@ -0,0 +1,315 @@
# AI Runtime Refactor
เอกสารนี้สรุปผล grilling session สำหรับการ refactor AI runtime หลังอัปเกรด GPU จาก RTX 2060 SUPER 8GB เป็น ASUS DUAL RTX 5060 Ti 16GB
เอกสารอ้างอิง:
- [ADR-033](../specs/06-Decision-Records/ADR-033-active-model-and-ocr-management.md)
- [ADR-034](../specs/06-Decision-Records/ADR-034-AI-model-change.md)
- [ADR ใหม่: AI Runtime Policy Refactor](./adr/0001-ai-runtime-policy-refactor.md)
- [CONTEXT.md](../CONTEXT.md)
## เป้าหมาย
- เปลี่ยนชื่อโมเดลหลักและ OCR ไปเป็น canonical identities ใหม่
- ย้ายสัญญา API จาก caller-driven model selection ไปเป็น policy-driven `executionProfile`
- รวมการจัดการ VRAM ของ main model, OCR, embedding, และ reranking ไว้ใน policy เดียว
- ใช้ big bang rollout แบบมีกติกา cutover และ verification ที่รันซ้ำได้
## Decision Summary
### 1. Canonical naming
- ใช้ `np-dms-ai` เป็น canonical model identity เดียวทุกชั้นที่ผู้ใช้และนักพัฒนาเห็น
- ใช้ `np-dms-ocr` เป็น canonical OCR identity เดียวทุกชั้น
- ชื่อ runtime/base model จริงเป็น implementation detail ใน Modelfile, deploy script, หรือ ops internals เท่านั้น
### 2. API contract
- caller ส่งได้เพียง `executionProfile`
- caller ห้ามส่ง `model.key`
- caller ห้าม override `temperature`, `top_p`, `maxTokens`, หรือ runtime parameters อื่นโดยตรง
- backend policy เป็นผู้ map `executionProfile` ไปยัง canonical model, runtime parameters, และ keep_alive policy
### 3. Canonical profile set
โปรไฟล์ระดับ contract มีแค่:
- `fast`
- `balanced`
- `thai-accurate`
- `large-context`
กฎเพิ่ม:
- `large-context` จำกัดเฉพาะ admin/special workflows
- งานที่มีผลต่อข้อมูล เช่น `migrate-document`, `auto-fill-document`, OCR extraction ใช้ backend override profile เอง
### 4. Runtime resource policy
- `np-dms-ai` เป็น workload หลักของ generation path
- `np-dms-ocr` ใช้ adaptive residency แทน fixed `keep_alive`
- retrieval acceleration (`BGE-M3`, `BGE-Reranker-Large`) อยู่ใน policy เดียวกับ main/OCR
- GPU ownership ใช้หลัก LLM-first
- ถ้า VRAM headroom ไม่พอ retrieval ต้อง fallback CPU ทันที
### 5. Queue policy
- คงโครง `ai-realtime` / `ai-batch` และ pause/resume coordination เดิมเป็นแกน
- อนุญาต `ai-realtime = 2` ได้เฉพาะ lightweight realtime jobs
- `rag-query` ไม่ใช่ lightweight realtime job
- `rag-query` เป็น generation-centric job: retrieval เป็นขั้นเตรียม context และ fallback CPU ได้
### 6. Rollout policy
- rollout ใช้ `Big Bang`
- cutover จะถือว่าสำเร็จต่อเมื่อผ่านครบทั้ง:
- policy contract
- model switching
- adaptive OCR residency
- RAG fallback
## Canonical Models
| Canonical Name | บทบาท | Residency policy | หมายเหตุ |
|---|---|---|---|
| `np-dms-ai` | main generation model | resident by default | backend policy คุม runtime parameters |
| `np-dms-ocr` | OCR model | adaptive | ใช้ policy ตาม VRAM headroom และ active workload |
หมายเหตุ:
- เอกสารนี้ไม่บังคับว่าฐานจริงต้องเป็น model family ใดเสมอไป
- การเปลี่ยน base runtime model ในอนาคตไม่ควรเปลี่ยน canonical API/UI name ถ้า semantics เดิมยังอยู่
## Execution Profile Contract
### Request DTO
```typescript
interface CreateAiJobRequest {
type: 'auto-fill-document' | 'migrate-document' | 'rag-query';
documentId?: string;
attachmentId?: string;
executionProfile?: 'fast' | 'balanced' | 'thai-accurate' | 'large-context';
}
```
### Policy rules
- `migrate-document`: backend override เป็น profile ที่ deterministic สูงเสมอ
- `auto-fill-document`: backend override ได้ตาม data-affecting policy
- `rag-query`: ปกติใช้ `balanced` หรือ policy ที่ backend กำหนด
- `large-context`: ใช้ได้เฉพาะ admin/special workflows ที่ backend whitelist
### Forbidden contract
สิ่งต่อไปนี้ต้องไม่มีใน public contract:
```typescript
model: {
key: string;
parameters: {
temperature?: number;
topP?: number;
maxTokens?: number;
};
}
```
เหตุผล:
- caller bypass governance ได้
- verification matrix โตเกินจำเป็น
- profile abstraction หมดความหมายทันที
## Adaptive OCR Residency
หลักการ:
- `np-dms-ocr` ไม่ใช้ fixed `keep_alive: 0` หรือ fixed `keep_alive: 300` ตายตัว
- backend policy คำนวณ residency จาก VRAM headroom และ active model/workload ปัจจุบัน
- ถ้า active workload กิน VRAM สูง หรือ profile ปัจจุบันเสี่ยงชน headroom ให้ fallback เป็น `keep_alive: 0`
- ถ้า headroom เหลือและไม่มี contention สำคัญ อนุญาต residency window ชั่วคราวได้
ตัวอย่าง policy:
```text
if active_profile == 'large-context' => OCR keep_alive = 0
if active_main_model_pressure == high => OCR keep_alive = 0
if headroom >= policy threshold => OCR keep_alive = short residency window
```
## LLM-First GPU Ownership
ลำดับสิทธิ์ VRAM:
1. `np-dms-ai`
2. `np-dms-ocr`
3. `BGE-M3`
4. `BGE-Reranker-Large`
ผลเชิงพฤติกรรม:
- retrieval path ใช้ GPU ได้เฉพาะเมื่อ policy ระบุว่ามี headroom จริง
- retrieval path ไม่มีสิทธิ์บังคับรอ GPU เพื่อแย่ง resource จาก main/OCR path
- หาก headroom ไม่พอ `embed` และ `rerank` ต้อง fallback CPU ทันที
## Retrieval Acceleration
### Scope
เอกสารนี้ถือว่า retrieval acceleration เป็นส่วนหนึ่งของ runtime resource policy เดียวกัน ไม่ใช่ tuning แยก
### Sidecar policy
ปัจจุบัน:
```text
POST /embed -> CPU
POST /rerank -> CPU
```
เป้าหมาย:
```text
POST /embed -> GPU เมื่อ headroom ผ่าน policy, ไม่เช่นนั้นใช้ CPU
POST /rerank -> GPU เมื่อ headroom ผ่าน policy, ไม่เช่นนั้นใช้ CPU
POST /ocr-upload -> OCR path ตาม adaptive OCR residency
POST /normalize -> CPU
```
### Retrieval fallback rule
- ห้าม queue รอ GPU เพื่อให้ retrieval ได้ acceleration
- ห้าม fail hard เพียงเพราะ GPU ไม่พอ
- ให้ degrade ไป CPU แล้วตอบงานต่อ
## Queue and Scheduling
### Baseline
- `ai-batch` ยังสามารถถูก pause/resume โดย realtime path ตาม coordination model เดิม
- `ai-realtime = 1` ยังคงเป็น baseline สำหรับงาน generation-heavy
### Selective realtime uplift
อนุญาต `ai-realtime = 2` เฉพาะกลุ่มงานที่เป็น lightweight realtime jobs เช่น:
- intent classification ที่ไม่เรียก OCR
- tool-only suggestion path ที่ไม่บังคับ model switching
- metadata-free chat steps ที่ไม่ใช้ GPU-heavy generation
ไม่รวม:
- `rag-query`
- OCR-triggering jobs
- งานที่บังคับ model switching
- generation-heavy jobs
## Big Bang Rollout
### Decision
refactor รอบนี้ใช้ big bang rollout เพราะระบบยังไม่เปิด production
### Consequence
ห้ามใช้เกณฑ์ partial success แบบ "บางแกนผ่านก็ถือว่าปล่อยได้"
### Cutover gate
ต้องผ่านครบทุกแกน:
1. policy contract ใหม่ทำงานจริง
2. canonical naming ใหม่ทำงานจริง
3. model switching และ OCR residency ตรง policy ใหม่
4. retrieval GPU/CPU fallback ทำงานจริง
## Verification
ใช้แนวทาง executable-first แต่ทุกแกนต้องมี manual validation path ประกบ
### 1. Policy contract
Executable:
- unit/integration tests สำหรับ DTO และ policy mapping
- tests ว่า caller ส่ง `model.key` หรือ parameter overrides ไม่ได้
- tests ว่า data-affecting jobs ถูก backend override profile จริง
Manual:
- ยิง request จาก admin/sandbox แล้วตรวจว่า UI/API ไม่ expose free-form model selection
### 2. Canonical naming
Executable:
- search-based checks ว่า public-facing contract ใช้ `np-dms-ai` / `np-dms-ocr`
- tests สำหรับ settings/service/controller ที่คืนชื่อ canonical
Manual:
- เปิด AI Admin Console และ OCR sandbox ตรวจ label/option/log surface ที่ผู้ใช้เห็น
### 3. Adaptive OCR residency
Executable:
- tests ว่า residency policy ให้ `keep_alive` ต่างกันตาม headroom scenario
- logs/trace ว่า OCR requests ใช้ residency decision ตาม policy
Manual:
- รัน OCR ซ้ำหลายงานในเงื่อนไข headroom ต่างกันและตรวจ behavior จริง
### 4. Retrieval fallback
Executable:
- tests ว่า `/embed` และ `/rerank` fallback CPU เมื่อ GPU threshold ไม่ผ่าน
- trace/log ว่า `rag-query` ยังตอบได้เมื่อ GPU retrieval path ถูกปิด
Manual:
- ทดลอง RAG query ภายใต้ภาระ GPU สูงและยืนยันว่าคำตอบยังออกได้แม้ช้าลง
## Implementation Workstreams
### Workstream A: Contract and naming
- เปลี่ยน public contract ให้ใช้ `executionProfile`
- ลบ `model.key` และ parameter override จาก API docs/DTO ที่เกี่ยวข้อง
- เปลี่ยน public-facing names เป็น `np-dms-ai` และ `np-dms-ocr`
### Workstream B: Runtime policy
- สร้าง policy mapping profile -> runtime configuration
- เพิ่ม adaptive OCR residency logic
- แยก policy ของ data-affecting jobs ออกจาก caller input
### Workstream C: Retrieval acceleration
- เพิ่ม GPU eligibility check สำหรับ `embed` และ `rerank`
- เพิ่ม CPU fallback path ที่ explicit
- บันทึก telemetry/log สำหรับ fallback decisions
### Workstream D: Queue policy
- คง pause/resume coordination เดิม
- แยก lightweight realtime jobs ออกจาก generation-heavy jobs
- ใช้ selective concurrency uplift เฉพาะ job ที่ allowed
### Workstream E: Verification
- เพิ่ม automated tests ตาม cutover gate
- เพิ่ม manual validation checklist สำหรับ admin console, OCR sandbox, และ RAG path
## Non-Goals
- ไม่เปิดให้ caller เลือก runtime parameters เอง
- ไม่เปลี่ยน `rag-query` ให้เป็น retrieval-first job
- ไม่ยกเลิก pause/resume coordination เดิมทั้งหมด
- ไม่แยก retrieval acceleration ออกเป็น policy คนละชุดกับ main/OCR
- ไม่ใช้ phased rollout ในเอกสารฉบับนี้
## Migration Note for Current Repo
repo ปัจจุบันยังมีจุดที่อิงชื่อและ policy เดิม เช่น `typhoon2.5-np-dms:latest`, `typhoon-np-dms-ocr:latest`, และ `keep_alive: 0` ในหลาย service/spec. เอกสารนี้จึงเป็น target architecture/policy ใหม่ และต้องมีการอัปเดตโค้ด, tests, cross-spec docs, และ admin UI ให้สอดคล้องก่อนจะถือว่า cutover สำเร็จ.
+1 -1
View File
@@ -726,7 +726,7 @@ AI-powered Document Management System
6 Automation workflow 6 Automation workflow
7 Security 7 Security
``` ```
## 💬 Prompt Templates สำหรับถาม Windsurf ## 💬 Prompt Templates สำหรับถาม Devin
### เมื่อต้องการสร้างฟีเจอร์ใหม่ ### เมื่อต้องการสร้างฟีเจอร์ใหม่
+2 -2
View File
@@ -1,6 +1,6 @@
# AI Knowledge Base for NAP-DMS (LCBP3) # AI Knowledge Base for NAP-DMS (LCBP3)
คลังความรู้สำหรับ AI Assistant (Antigravity, Windsurf, Codex) เพื่อช่วยในการพัฒนาระบบ Document Management System (DMS) คลังความรู้สำหรับ AI Assistant (Antigravity, Devin, Codex) เพื่อช่วยในการพัฒนาระบบ Document Management System (DMS)
## 📁 โครงสร้างโฟลเดอร์ ## 📁 โครงสร้างโฟลเดอร์
@@ -8,7 +8,7 @@
- `core/`: กฎพื้นฐานและมาตรฐานการเขียนโค้ด - `core/`: กฎพื้นฐานและมาตรฐานการเขียนโค้ด
- `dms/`: เฉพาะทางด้านระบบจัดการเอกสาร - `dms/`: เฉพาะทางด้านระบบจัดการเอกสาร
- `infra/`: งานด้าน Infrastructure และ Network - `infra/`: งานด้าน Infrastructure และ Network
- `codex/`: คำสั่งเฉพาะสำหรับ Windsurf/Codex - `codex/`: คำสั่งเฉพาะสำหรับ Devin/Codex
- `templates/`: แม่แบบเอกสารต่างๆ (Spec, Bug Report, etc.) - `templates/`: แม่แบบเอกสารต่างๆ (Spec, Bug Report, etc.)
- `playbooks/`: คู่มือขั้นตอนการทำงานที่ซับซ้อน - `playbooks/`: คู่มือขั้นตอนการทำงานที่ซับซ้อน
- `checklists/`: รายการตรวจสอบก่อนส่งงานหรือ Deploy - `checklists/`: รายการตรวจสอบก่อนส่งงานหรือ Deploy
@@ -1,5 +1,5 @@
// File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md // File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md
# Bug Fix Prompt (Windsurf/Codex) # Bug Fix Prompt (Devin/Codex)
## ⭐ Role: Debugging Specialist ## ⭐ Role: Debugging Specialist
@@ -1,5 +1,5 @@
// File: docs/ai-knowledge-base/prompts/codex/codex-feature.md // File: docs/ai-knowledge-base/prompts/codex/codex-feature.md
# Feature Implementation Prompt (Windsurf/Codex) # Feature Implementation Prompt (Devin/Codex)
## ⭐ Role: Senior Full Stack Developer (DMS Specialist) ## ⭐ Role: Senior Full Stack Developer (DMS Specialist)
@@ -1,5 +1,5 @@
// File: docs/ai-knowledge-base/prompts/codex/codex-review.md // File: docs/ai-knowledge-base/prompts/codex/codex-review.md
# Code Review Prompt (Windsurf/Codex) # Code Review Prompt (Devin/Codex)
## ⭐ Role: Senior Code Reviewer (DMS Specialist) ## ⭐ Role: Senior Code Reviewer (DMS Specialist)
+35
View File
@@ -0,0 +1,35 @@
interactive
model np-dms-ai
temperature 0.7
top_p 0.9
max_tokens 2048
keep_alive "5m"
num_ctx 4096
repeat_penalty 1.15
standard
model np-dms-ai
temperature 0.5
top_p 0.8
max_tokens 4096
keep_alive "10m"
num_ctx 8192
repeat_penalty 1.15
quality
model np-dms-ai
temperature 0.1
top_p 0.95
max_tokens 8192
keep_alive "10m"
num_ctx 8192
repeat_penalty 1.15
deep-analysis
model np-dms-ai
temperature 0.3
top_p 0.85
max_tokens 8192
keep_alive "0"
num_ctx 32768
repeat_penalty 1.15
+4 -4
View File
@@ -18,7 +18,7 @@ npx playwright install chromium
npx playwright install npx playwright install
``` ```
### 3. **MCP Server สำหรับ Windsurf** ### 3. **MCP Server สำหรับ Devin**
เพิ่มใน [.windsurfrc](cci:7://file:///e:/np-dms/lcbp3/.windsurfrc:0:0-0:0): เพิ่มใน [.windsurfrc](cci:7://file:///e:/np-dms/lcbp3/.windsurfrc:0:0-0:0):
@@ -33,9 +33,9 @@ npx playwright install
} }
``` ```
**Restart Windsurf** แล้วจะเห็น Playwright MCP panel **Restart Devin** แล้วจะเห็น Playwright MCP panel
### 4. **การใช้งานผ่าน Windsurf Cascade** ### 4. **การใช้งานผ่าน Devin Cascade**
เมื่อ MCP พร้อมแล้ว สามารถใช้คำสั่ง: เมื่อ MCP พร้อมแล้ว สามารถใช้คำสั่ง:
@@ -101,7 +101,7 @@ npx playwright test --headed
npx playwright show-report npx playwright show-report
``` ```
### 8. **ถ้าใช้ MCP ผ่าน Windsurf** ### 8. **ถ้าใช้ MCP ผ่าน Devin**
Cascade จะมี tool ให้ใช้: Cascade จะมี tool ให้ใช้:
- `browser_navigate` - เปิด URL - `browser_navigate` - เปิด URL
+2
View File
@@ -16,9 +16,11 @@ export default [
'**/tmp/**', '**/tmp/**',
'specs/**', 'specs/**',
'backend/documentation/**', 'backend/documentation/**',
'backend/scratch/**',
'backend/scripts/**', 'backend/scripts/**',
'frontend/public/**', 'frontend/public/**',
'**/test/**', '**/test/**',
'**/*.d.ts',
], ],
}, },
...backendConfig.map((config) => ({ ...backendConfig.map((config) => ({
+45 -169
View File
@@ -8,12 +8,13 @@
// - 2026-05-25: เพิ่ม AI Model Management UI สำหรับเลือกโมเดลแบบไดนามิก (ADR-027). // - 2026-05-25: เพิ่ม AI Model Management UI สำหรับเลือกโมเดลแบบไดนามิก (ADR-027).
// - 2026-05-30: นำเข้าและแสดงผล OcrEngineSelector component ใน Overview tab (T019, T020) // - 2026-05-30: นำเข้าและแสดงผล OcrEngineSelector component ใน Overview tab (T019, T020)
// - 2026-06-02: เพิ่มตัวบ่งชี้โมเดลหลักที่กำลังใช้งาน (Active Global Model badge) บนการ์ด System Toggle (T010, ADR-033) // - 2026-06-02: เพิ่มตัวบ่งชี้โมเดลหลักที่กำลังใช้งาน (Active Global Model badge) บนการ์ด System Toggle (T010, ADR-033)
// - 2026-06-13: [235] ลบ AI Model Management (ADR-027) และ OCR Engine Selector ออก; แก้ System Toggle แสดง canonical names (np-dms-ai/np-dms-ocr); แก้ label OCR Sidecar
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Brain, Loader2, Power, ShieldCheck, Cpu, Database, Activity, Search, Info, HelpCircle, AlertCircle, Settings2, Trash2, ScanText } from 'lucide-react'; import { Brain, Loader2, Power, ShieldCheck, Cpu, Database, Activity, Search, Info, HelpCircle, AlertCircle, ScanText } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -27,12 +28,10 @@ import { projectService } from '@/lib/services/project.service';
import { import {
adminAiService, adminAiService,
AiSandboxJobResult, AiSandboxJobResult,
AiAvailableModel,
AiRagCitation, AiRagCitation,
} from '@/lib/services/admin-ai.service'; } from '@/lib/services/admin-ai.service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager'; import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager';
import OcrEngineSelector from '@/components/admin/ai/OcrEngineSelector';
interface SandboxProject { interface SandboxProject {
publicId: string; publicId: string;
@@ -56,9 +55,16 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
} }
return value.map((item, index) => { return value.map((item, index) => {
if (typeof item === 'string') { if (typeof item === 'string') {
const name = item.toLowerCase();
let normName = item;
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) {
normName = 'np-dms-ocr';
} else if (name.includes('typhoon') || name.includes('np-dms-ai')) {
normName = 'np-dms-ai';
}
return { return {
modelId: `${item}-${index}`, modelId: `${item}-${index}`,
modelName: item, modelName: normName,
}; };
} }
if (item && typeof item === 'object') { if (item && typeof item === 'object') {
@@ -68,10 +74,17 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
name?: string; name?: string;
vramUsageMB?: number; vramUsageMB?: number;
}; };
const modelName = model.modelName ?? model.name ?? `model-${index + 1}`; const rawName = model.modelName ?? model.name ?? `model-${index + 1}`;
const name = rawName.toLowerCase();
let normName = rawName;
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) {
normName = 'np-dms-ocr';
} else if (name.includes('typhoon') || name.includes('np-dms-ai')) {
normName = 'np-dms-ai';
}
return { return {
modelId: model.modelId ?? modelName, modelId: model.modelId ?? rawName,
modelName, modelName: normName,
vramUsageMB: model.vramUsageMB, vramUsageMB: model.vramUsageMB,
}; };
} }
@@ -82,6 +95,13 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
}); });
} }
function toCanonicalModel(rawName: string): string {
const name = rawName.toLowerCase();
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return 'np-dms-ocr';
if (name.includes('typhoon') || name.includes('np-dms-ai')) return 'np-dms-ai';
return rawName;
}
export default function AiAdminConsolePage() { export default function AiAdminConsolePage() {
const { data, isLoading, isError, refetch, isFetching } = useAiStatus(); const { data, isLoading, isError, refetch, isFetching } = useAiStatus();
const { data: health, isLoading: isHealthLoading, refetch: refetchHealth } = useAiHealth(); const { data: health, isLoading: isHealthLoading, refetch: refetchHealth } = useAiHealth();
@@ -96,16 +116,6 @@ export default function AiAdminConsolePage() {
const [sandboxProgress, setSandboxProgress] = useState<number>(0); const [sandboxProgress, setSandboxProgress] = useState<number>(0);
const [sandboxStatusText, setSandboxStatusText] = useState<string>(''); const [sandboxStatusText, setSandboxStatusText] = useState<string>('');
// AI Model Management State (ADR-027)
const { data: aiModelsData, refetch: refetchModels } = useQuery<{ models: AiAvailableModel[]; activeModel: string }>({
queryKey: ['ai-available-models'],
queryFn: async () => {
return await adminAiService.getAvailableModels();
},
});
const availableModels = ensureArray<AiAvailableModel>(aiModelsData?.models);
const activeModel = aiModelsData?.activeModel ?? '';
// VRAM Monitoring State (T034, T036, US2) // VRAM Monitoring State (T034, T036, US2)
const { data: vramStatus, refetch: refetchVram } = useQuery({ const { data: vramStatus, refetch: refetchVram } = useQuery({
queryKey: ['ai-vram-status'], queryKey: ['ai-vram-status'],
@@ -122,7 +132,13 @@ export default function AiAdminConsolePage() {
return res as SandboxProject[]; return res as SandboxProject[];
}, },
}); });
const healthOllamaModels = ensureArray<string>(health?.ollama?.models); const rawHealthOllamaModels = ensureArray<string>(health?.ollama?.models);
const healthOllamaModels = Array.from(new Set(rawHealthOllamaModels.map((m) => {
const name = m.toLowerCase();
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return 'np-dms-ocr';
if (name.includes('typhoon') || name.includes('np-dms-ai')) return 'np-dms-ai';
return m;
})));
const healthQdrantCollections = ensureArray<string>(health?.qdrant?.collections); const healthQdrantCollections = ensureArray<string>(health?.qdrant?.collections);
const vramLoadedModels = normalizeLoadedModels(vramStatus?.loadedModels); const vramLoadedModels = normalizeLoadedModels(vramStatus?.loadedModels);
const sandboxProjects = ensureArray<SandboxProject>(projects); const sandboxProjects = ensureArray<SandboxProject>(projects);
@@ -134,44 +150,8 @@ export default function AiAdminConsolePage() {
await toggleMutation.mutateAsync(enabled); await toggleMutation.mutateAsync(enabled);
}; };
const handleModelChange = async (modelId: string): Promise<void> => {
try {
const selectedModel = availableModels.find(m => m.modelId === modelId || String(m.id) === modelId);
const name = selectedModel?.modelName || modelId;
await adminAiService.setActiveModel(modelId);
toast.success(`เปลี่ยนโมเดลเป็น ${name} สำเร็จ`);
await refetchModels();
refetchVram();
} catch (err: unknown) {
const errorResponse = err as { response?: { data?: { message?: string } } };
const errorMsg = errorResponse.response?.data?.message || 'ไม่สามารถเปลี่ยนโมเดลได้เนื่องจาก VRAM ไม่เพียงพอ';
toast.error(errorMsg);
}
};
const handleToggleModel = async (modelName: string): Promise<void> => {
try {
await adminAiService.toggleModelActive(modelName);
toast.success(`เปลี่ยนสถานะโมเดล ${modelName} สำเร็จ`);
await refetchModels();
} catch {
toast.error('ไม่สามารถเปลี่ยนสถานะโมเดลได้');
}
};
const handleRemoveModel = async (modelName: string): Promise<void> => {
if (!confirm(`ต้องการลบโมเดล ${modelName} ใช่หรือไม่?`)) return;
try {
await adminAiService.removeModel(modelName);
toast.success(`ลบโมเดล ${modelName} สำเร็จ`);
await refetchModels();
} catch {
toast.error('ไม่สามารถลบโมเดลได้');
}
};
const handleRefreshAll = async (): Promise<void> => { const handleRefreshAll = async (): Promise<void> => {
await Promise.all([refetch(), refetchHealth(), refetchModels(), refetchVram()]); await Promise.all([refetch(), refetchHealth(), refetchVram()]);
}; };
const handleSubmitSandbox = async (e: React.FormEvent): Promise<void> => { const handleSubmitSandbox = async (e: React.FormEvent): Promise<void> => {
@@ -348,7 +328,7 @@ export default function AiAdminConsolePage() {
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium"> <CardTitle className="flex items-center gap-2 text-sm font-medium">
<ScanText className="h-4 w-4 text-primary" /> <ScanText className="h-4 w-4 text-primary" />
OCR Sidecar (Tesseract) OCR Sidecar (np-dms-ocr)
</CardTitle> </CardTitle>
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.ocr?.status)} {isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.ocr?.status)}
</CardHeader> </CardHeader>
@@ -476,7 +456,7 @@ export default function AiAdminConsolePage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-lg"> <CardTitle className="flex items-center gap-2 text-lg">
@@ -493,10 +473,14 @@ export default function AiAdminConsolePage() {
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Superadmin Superadmin
</div> </div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1"> <div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1 flex-wrap">
<span>Active Global Model:</span> <span>Active Models:</span>
<Badge variant="outline" className="text-[10px] py-0 px-1.5 border-primary/20 text-primary bg-primary/5 font-semibold"> <Badge variant="outline" className="text-[10px] py-0 px-1.5 border-primary/20 text-primary bg-primary/5 font-semibold">
{activeModel || 'Loading...'} {isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.main ?? 'np-dms-ai')}
</Badge>
<span className="text-muted-foreground/50">+</span>
<Badge variant="outline" className="text-[10px] py-0 px-1.5 border-purple-500/20 text-purple-600 dark:text-purple-400 bg-purple-500/5 font-semibold">
{isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.ocr ?? 'np-dms-ocr')}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -518,114 +502,6 @@ export default function AiAdminConsolePage() {
</CardContent> </CardContent>
</Card> </Card>
{/* AI Model Management Card (ADR-027) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5" />
AI Model Management
<Badge variant="outline" className="text-[10px]">ADR-027</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2 flex-1">
<label htmlFor="model-select" className="text-sm font-medium text-foreground">
AI (Global)
</label>
<Select
value={availableModels.find((m) => m.modelName === activeModel)?.modelId || availableModels.find((m) => m.modelName === activeModel)?.id?.toString() || ''}
onValueChange={handleModelChange}
>
<SelectTrigger id="model-select" className="w-full sm:w-[300px]">
<SelectValue placeholder="-- เลือกโมเดล --" />
</SelectTrigger>
<SelectContent>
{availableModels
.filter((m) => m.isActive)
.map((model) => (
<SelectItem key={model.modelId || model.modelName} value={model.modelId || model.id?.toString() || model.modelName}>
{model.modelName}
{model.isDefault && (
<Badge variant="secondary" className="ml-2 text-[10px]">Default</Badge>
)}
{model.vramRequirementMB && (
<span className="ml-1 text-muted-foreground">({Math.round(model.vramRequirementMB / 1024 * 10) / 10}GB VRAM)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground">
: <Badge variant="default">{activeModel || 'Loading...'}</Badge>
</div>
</div>
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-3"></h4>
<div className="space-y-2">
{availableModels.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
availableModels.map((model) => (
<div
key={model.modelId || model.modelName}
className="flex items-center justify-between p-2 rounded border bg-background/50"
>
<div className="flex items-center gap-2">
<Badge
variant={model.isActive ? 'default' : 'secondary'}
className="text-[10px]"
>
{model.isActive ? 'Active' : 'Inactive'}
</Badge>
<span className="text-sm font-medium">{model.modelName}</span>
{model.isDefault && (
<Badge variant="outline" className="text-[10px]">Default</Badge>
)}
{activeModel === model.modelName && (
<Badge variant="default" className="text-[10px] bg-emerald-500">Current</Badge>
)}
{model.vramRequirementMB && (
<Badge variant="outline" className="text-[10px] border-amber-500/20 text-amber-500 bg-amber-500/5">
{Math.round(model.vramRequirementMB / 1024 * 10) / 10} GB VRAM
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{!model.isDefault && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleModel(model.modelName)}
disabled={activeModel === model.modelName && model.isActive}
>
{model.isActive ? 'Deactivate' : 'Activate'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveModel(model.modelName)}
disabled={model.isDefault || activeModel === model.modelName}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
</div>
))
)}
</div>
</div>
</CardContent>
</Card>
{/* OCR Engine Management Card (ADR-032) */}
<OcrEngineSelector />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -655,7 +531,7 @@ export default function AiAdminConsolePage() {
</Card> </Card>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="playground" className="space-y-6"> <TabsContent value="playground" className="space-y-6">
<Card className="border border-border/50 bg-background/50 backdrop-blur-md"> <Card className="border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader> <CardHeader>
+5 -27
View File
@@ -1,19 +1,11 @@
'use client'; 'use client';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { useRagQuery } from '../../../hooks/use-rag'; import { RagChatWidget } from '../../../components/ai/RagChatWidget';
import { useProjectStore } from '../../../lib/stores/project-store'; import { useProjectStore } from '../../../lib/stores/project-store';
import { RagSearchBar } from '../../../components/rag/rag-search-bar';
import { RagResultCard } from '../../../components/rag/rag-result-card';
export default function RagPage() { export default function RagPage() {
const { selectedProjectId } = useProjectStore(); const { selectedProjectId } = useProjectStore();
const { mutate, data, isPending, error, isIdle } = useRagQuery();
const handleSearch = (question: string) => {
if (!selectedProjectId) return;
mutate({ question, projectPublicId: selectedProjectId });
};
return ( return (
<div className="container mx-auto max-w-3xl py-8 space-y-6"> <div className="container mx-auto max-w-3xl py-8 space-y-6">
@@ -28,25 +20,11 @@ export default function RagPage() {
</div> </div>
)} )}
<RagSearchBar onSearch={handleSearch} isLoading={isPending} /> {selectedProjectId ? (
<RagChatWidget projectPublicId={selectedProjectId} />
{isPending && ( ) : (
<div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground animate-pulse">
...
</div>
)}
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
: {error.message}
</div>
)}
{data && !isPending && <RagResultCard result={data} />}
{isIdle && !error && (
<p className="text-center text-sm text-muted-foreground pt-4"> <p className="text-center text-sm text-muted-foreground pt-4">
RAG pipeline
</p> </p>
)} )}
</div> </div>
@@ -147,7 +147,7 @@ export default function OcrSandboxPromptManager() {
fallbackUsed?: boolean; fallbackUsed?: boolean;
} | null>(null); } | null>(null);
const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined); const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined);
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox } = const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox, startPolling } =
useSandboxRun(() => { useSandboxRun(() => {
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน // เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
versionsQuery.refetch(); versionsQuery.refetch();
@@ -285,24 +285,8 @@ export default function OcrSandboxPromptManager() {
selectedPromptVersion selectedPromptVersion
); );
toast.success('AI Extraction started'); toast.success('AI Extraction started');
// Poll สำหรับผลลัพธ์ AI // เริ่ม polling ผ่าน useSandboxRun hook
const pollInterval = setInterval(async () => { startPolling(requestPublicId);
try {
const result = await adminAiService.getSandboxJobStatus(requestPublicId);
if (result.status === 'completed') {
clearInterval(pollInterval);
// Trigger sandbox state update via useSandboxRun
toast.success(t('ai.prompt.sandboxSuccess'));
versionsQuery.refetch();
} else if (result.status === 'failed') {
clearInterval(pollInterval);
toast.error(result.errorMessage || 'AI Extraction failed');
}
} catch (_err) {
clearInterval(pollInterval);
toast.error('Poll error occurred');
}
}, 1000);
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }; const error = err as { response?: { data?: { message?: string } } };
toast.error(error.response?.data?.message || 'AI Extraction failed'); toast.error(error.response?.data?.message || 'AI Extraction failed');
@@ -608,7 +592,7 @@ export default function OcrSandboxPromptManager() {
</CardTitle> </CardTitle>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{ocrResult.engineUsed === 'typhoon-np-dms-ocr' {ocrResult.engineUsed === 'typhoon-np-dms-ocr'
? 'Typhoon OCR' ? 'np-dms-ocr'
: ocrResult.ocrUsed : ocrResult.ocrUsed
? 'Tesseract' ? 'Tesseract'
: 'Fast Path (Text Layer)'} : 'Fast Path (Text Layer)'}
@@ -617,7 +601,7 @@ export default function OcrSandboxPromptManager() {
<CardContent className="pt-4"> <CardContent className="pt-4">
{ocrResult.fallbackUsed && ( {ocrResult.fallbackUsed && (
<div className="mb-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400"> <div className="mb-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
Typhoon OCR unavailable. Fallback to Tesseract was used for this run. np-dms-ocr unavailable. Fallback to Tesseract was used for this run.
</div> </div>
)} )}
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[200px] border border-border/10"> <div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[200px] border border-border/10">
@@ -628,6 +612,26 @@ export default function OcrSandboxPromptManager() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{sandboxState.result && sandboxState.result.llmPrompt && (
<Card className="border border-purple-500/20 bg-purple-500/5">
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
<CardTitle className="text-base text-purple-600 dark:text-purple-400 flex items-center gap-2">
<StickyNote className="h-4 w-4" />
LLM Prompt (Step 2 Input)
</CardTitle>
<Badge variant="outline" className="text-xs">
{sandboxState.result.llmPrompt.length} chars
</Badge>
</CardHeader>
<CardContent className="pt-4">
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[300px] border border-border/10">
<pre className="text-purple-600 dark:text-purple-400 select-text leading-relaxed whitespace-pre-wrap">
{sandboxState.result.llmPrompt}
</pre>
</div>
</CardContent>
</Card>
)}
{sandboxState.isRunning && ( {sandboxState.isRunning && (
<Card className="border border-amber-500/20 bg-amber-500/5"> <Card className="border border-amber-500/20 bg-amber-500/5">
<CardContent className="pt-6 space-y-4"> <CardContent className="pt-6 space-y-4">
@@ -1,12 +0,0 @@
'use client';
import { AlertTriangle } from 'lucide-react';
export function RagFallbackBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<AlertTriangle className="h-3 w-3" />
local model
</span>
);
}
@@ -1,74 +0,0 @@
'use client';
import { FileText } from 'lucide-react';
import type { RagQueryResponse, RagCitation } from '../../hooks/use-rag';
import { RagFallbackBadge } from './rag-fallback-badge';
interface RagResultCardProps {
result: RagQueryResponse;
}
function ConfidenceBar({ score }: { score: number }) {
const pct = Math.round(score * 100);
const color =
pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="flex items-center gap-2">
<div className="h-2 w-24 rounded-full bg-muted overflow-hidden">
<div className={`h-full ${color} transition-all`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground">{pct}%</span>
</div>
);
}
function CitationItem({ citation }: { citation: RagCitation }) {
return (
<div className="rounded border p-3 text-sm space-y-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 font-medium text-foreground">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{citation.docType}</span>
{citation.docNumber && (
<span className="text-muted-foreground"> {citation.docNumber}</span>
)}
{citation.revision && (
<span className="rounded bg-muted px-1 text-xs">Rev. {citation.revision}</span>
)}
</div>
<ConfidenceBar score={citation.score} />
</div>
<p className="text-muted-foreground line-clamp-3">{citation.snippet}</p>
</div>
);
}
export function RagResultCard({ result }: RagResultCardProps) {
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="font-semibold text-base mb-1"></h3>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{result.answer}</p>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<ConfidenceBar score={result.confidence} />
{result.usedFallbackModel && <RagFallbackBadge />}
</div>
</div>
{result.citations.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
({result.citations.length} )
</h4>
<div className="space-y-2">
{result.citations.map((c) => (
<CitationItem key={c.chunkId} citation={c} />
))}
</div>
</div>
)}
</div>
);
}
@@ -1,64 +0,0 @@
'use client';
import { useState } from 'react';
import { Loader2, Search } from 'lucide-react';
import { z } from 'zod';
const schema = z.object({
question: z.string().min(1, 'กรุณาระบุคำถาม').max(500, 'คำถามต้องไม่เกิน 500 ตัวอักษร'),
});
interface RagSearchBarProps {
onSearch: (question: string) => void;
isLoading: boolean;
}
export function RagSearchBar({ onSearch, isLoading }: RagSearchBarProps) {
const [question, setQuestion] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = schema.safeParse({ question });
if (!result.success) {
setError(result.error.issues[0]?.message ?? 'ข้อมูลไม่ถูกต้อง');
return;
}
setError(null);
onSearch(question);
};
return (
<form onSubmit={handleSubmit} className="w-full">
<div className="flex gap-2">
<div className="flex-1">
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="ถามคำถามเกี่ยวกับเอกสารโครงการ..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={isLoading}
maxLength={500}
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
<p className="mt-1 text-xs text-muted-foreground text-right">
{question.length}/500
</p>
</div>
<button
type="submit"
disabled={isLoading || question.trim().length === 0}
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</button>
</div>
</form>
);
}

Some files were not shown because too many files have changed in this diff Show More