Compare commits
23 Commits
4a808dd9c4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 190b9a3af5 | |||
| 2c5a0b6aef | |||
| d333d8a45a | |||
| 0227b7b982 | |||
| 71c5e88181 | |||
| cd7d20ccd4 | |||
| 75d07b5ac9 | |||
| 52b96d01de | |||
| a0f77ad121 | |||
| 16aab2279c | |||
| 15dec6c3fc | |||
| 33c3935164 | |||
| 6bcd1a5c58 | |||
| de4201d7d3 | |||
| e3e0de66e9 | |||
| 866fea7946 | |||
| 85c7415b8a | |||
| ed1b302274 | |||
| 26cc71ce60 | |||
| 285c007dff | |||
| 03aa4efcf0 | |||
| 4f90ed688f | |||
| 548dba6476 |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: บันทึก session log และอัปเดต project memory
|
||||||
|
---
|
||||||
|
|
||||||
|
# บันทึก Memory
|
||||||
|
|
||||||
|
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skill save-memory
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: บันทึก session log และอัปเดต project memory
|
||||||
|
---
|
||||||
|
|
||||||
|
# บันทึก Memory
|
||||||
|
|
||||||
|
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skill save-memory
|
||||||
|
```
|
||||||
+142
-17
@@ -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>`
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Vendored
+14
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}` }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
### เมื่อต้องการสร้างฟีเจอร์ใหม่
|
### เมื่อต้องการสร้างฟีเจอร์ใหม่
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user