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
|
||||
|
||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- Version: 1.9.6 | Last synced from repo: 2026-05-22
|
||||
- For: Devin Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- 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)
|
||||
- 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 สำหรับเคสที่สาเหตุชัดเจน |
|
||||
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
||||
| "งานค้าง / 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 |
|
||||
| **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) |
|
||||
| **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) |
|
||||
|
||||
## 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)
|
||||
- **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
|
||||
- **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
|
||||
- **Threshold Recalibration:** After 100-500 docs, based on ai_audit_logs analysis
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ init_agent_registry() {
|
||||
[qwen]="Qwen Code"
|
||||
[opencode]="opencode"
|
||||
[codex]="Codex CLI"
|
||||
[windsurf]="Windsurf"
|
||||
[devin]="Devin"
|
||||
[kilocode]="Kilo Code"
|
||||
[auggie]="Auggie CLI"
|
||||
[roo]="Roo Code"
|
||||
|
||||
@@ -99,14 +99,13 @@ find_feature_dir_by_prefix() {
|
||||
|
||||
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=()
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
for dir in "$specs_dir"/"$prefix"-*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
matches+=("$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
# ค้นหาโฟลเดอร์ที่ตรงกับ prefix ในระบบย่อย
|
||||
while IFS= read -r -d '' dir; do
|
||||
matches+=("$dir")
|
||||
done < <(find "$specs_dir" -maxdepth 3 -type d -name "${prefix}-*" -print0 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Handle results
|
||||
@@ -115,12 +114,12 @@ find_feature_dir_by_prefix() {
|
||||
echo "$specs_dir/$branch_name"
|
||||
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||
# Exactly one match - perfect!
|
||||
echo "$specs_dir/${matches[0]}"
|
||||
echo "${matches[0]}"
|
||||
else
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&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
|
||||
}
|
||||
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - 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
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# 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
|
||||
|
||||
set -e
|
||||
@@ -609,8 +609,8 @@ update_specific_agent() {
|
||||
codex)
|
||||
update_agent_file "$AGENTS_FILE" "Codex CLI"
|
||||
;;
|
||||
windsurf)
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
devin)
|
||||
update_agent_file "$DEVIN_FILE" "Devin"
|
||||
;;
|
||||
kilocode)
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
@@ -681,8 +681,8 @@ update_all_existing_agents() {
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$WINDSURF_FILE" ]]; then
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
if [[ -f "$DEVIN_FILE" ]]; then
|
||||
update_agent_file "$DEVIN_FILE" "Devin"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
|
||||
+15
-16
@@ -1,8 +1,8 @@
|
||||
# `.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
|
||||
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
||||
├── README.md # (this file)
|
||||
├── save-memory/ # Session log & project memory update
|
||||
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
||||
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
||||
├── 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** — 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.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `./.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/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` |
|
||||
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
||||
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
||||
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.windsurf/workflows/*.md` wrapper |
|
||||
| Script | Purpose |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `./.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/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/validate-versions.sh` | Version consistency check |
|
||||
| `./.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.
|
||||
|
||||
@@ -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`.
|
||||
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.
|
||||
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
||||
|
||||
|
||||
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
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),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
output_summary JSON,
|
||||
|
||||
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
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),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
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
|
||||
|
||||
**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).
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
| **speckit-status** | None | None | Progress tracking |
|
||||
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
||||
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
||||
| **save-memory** | None | None | Session log & memory update |
|
||||
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
||||
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
||||
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
||||
@@ -99,7 +100,7 @@
|
||||
|
||||
### Health Metrics
|
||||
|
||||
- **Total Skills**: 23 implemented
|
||||
- **Total Skills**: 24 implemented
|
||||
- **Version Alignment**: v1.9.0 across all skills
|
||||
- **Template Coverage**: 100% for skills requiring templates
|
||||
- **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
|
||||
|
||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- Version: 1.9.6 | Last synced from repo: 2026-05-22
|
||||
- For: Devin Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- 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)
|
||||
- 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 สำหรับเคสที่สาเหตุชัดเจน |
|
||||
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
||||
| "งานค้าง / 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
|
||||
|
||||
**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
|
||||
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
||||
├── README.md # (this file)
|
||||
├── save-memory/ # Session log & project memory update
|
||||
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
||||
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
||||
├── 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** — 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.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `./.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/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` |
|
||||
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
||||
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
||||
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.windsurf/workflows/*.md` wrapper |
|
||||
| Script | Purpose |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `./.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/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/validate-versions.sh` | Version consistency check |
|
||||
| `./.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.
|
||||
|
||||
@@ -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`.
|
||||
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.
|
||||
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
||||
|
||||
|
||||
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
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),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
output_summary JSON,
|
||||
|
||||
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
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),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
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
|
||||
|
||||
**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).
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
| **speckit-status** | None | None | Progress tracking |
|
||||
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
||||
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
||||
| **save-memory** | None | None | Session log & memory update |
|
||||
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
||||
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
||||
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
||||
@@ -99,7 +100,7 @@
|
||||
|
||||
### Health Metrics
|
||||
|
||||
- **Total Skills**: 23 implemented
|
||||
- **Total Skills**: 24 implemented
|
||||
- **Version Alignment**: v1.9.0 across all skills
|
||||
- **Template Coverage**: 100% for skills requiring templates
|
||||
- **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)
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
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-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-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-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 |
|
||||
@@ -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
|
||||
6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints
|
||||
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
|
||||
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
|
||||
- [ ] **Human-in-the-loop:** AI outputs validated before use
|
||||
- [ ] **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
|
||||
|
||||
**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
|
||||
|
||||
### Issue tracker
|
||||
@@ -582,15 +696,26 @@ This file is a **quick reference**. For detailed information:
|
||||
|
||||
## 🔄 Change Log
|
||||
|
||||
| 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.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.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.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.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev |
|
||||
| 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.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
|
||||
| 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 |
|
||||
| Version | Date | Changes | Updated By |
|
||||
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
|
||||
| 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.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.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.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.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.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.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev |
|
||||
| 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.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
|
||||
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
|
||||
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
|
||||
| 1.8.5 | 2026-04-22 | Legacy version | Human Dev |
|
||||
|
||||
---
|
||||
|
||||
**To update this file:**
|
||||
|
||||
1. Edit relevant sections
|
||||
2. Update Change Log above
|
||||
3. Bump version number in header
|
||||
4. Commit: `spec(agents): bump GEMINI.md to vX.X.X - <brief description>`
|
||||
|
||||
@@ -68,36 +68,36 @@ $script:NEW_FRAMEWORK = ''
|
||||
$script:NEW_DB = ''
|
||||
$script:NEW_PROJECT_TYPE = ''
|
||||
|
||||
function Write-Info {
|
||||
function Write-Info {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Host "INFO: $Message"
|
||||
Write-Host "INFO: $Message"
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
function Write-Success {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Host "$([char]0x2713) $Message"
|
||||
Write-Host "$([char]0x2713) $Message"
|
||||
}
|
||||
|
||||
function Write-WarningMsg {
|
||||
function Write-WarningMsg {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Warning $Message
|
||||
Write-Warning $Message
|
||||
}
|
||||
|
||||
function Write-Err {
|
||||
function Write-Err {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Host "ERROR: $Message" -ForegroundColor Red
|
||||
Write-Host "ERROR: $Message" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Validate-Environment {
|
||||
@@ -130,7 +130,7 @@ function Extract-PlanField {
|
||||
# Lines like **Language/Version**: Python 3.12
|
||||
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$"
|
||||
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {
|
||||
if ($_ -match $regex) {
|
||||
if ($_ -match $regex) {
|
||||
$val = $Matches[1].Trim()
|
||||
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }
|
||||
}
|
||||
@@ -170,15 +170,15 @@ function Format-TechnologyStack {
|
||||
return ($parts -join ' + ')
|
||||
}
|
||||
|
||||
function Get-ProjectStructure {
|
||||
function Get-ProjectStructure {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ProjectType
|
||||
)
|
||||
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
|
||||
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
|
||||
}
|
||||
|
||||
function Get-CommandsForLanguage {
|
||||
function Get-CommandsForLanguage {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Lang
|
||||
@@ -191,12 +191,12 @@ function Get-CommandsForLanguage {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LanguageConventions {
|
||||
function Get-LanguageConventions {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Lang
|
||||
)
|
||||
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
|
||||
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
|
||||
}
|
||||
|
||||
function New-AgentFile {
|
||||
@@ -223,7 +223,7 @@ function New-AgentFile {
|
||||
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8
|
||||
$content = $content -replace '\[PROJECT NAME\]',$ProjectName
|
||||
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd')
|
||||
|
||||
|
||||
# Build the technology stack string safely
|
||||
$techStackForTemplate = ""
|
||||
if ($escaped_lang -and $escaped_framework) {
|
||||
@@ -233,7 +233,7 @@ function New-AgentFile {
|
||||
} elseif ($escaped_framework) {
|
||||
$techStackForTemplate = "- $escaped_framework ($escaped_branch)"
|
||||
}
|
||||
|
||||
|
||||
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate
|
||||
# For project structure we manually embed (keep newlines)
|
||||
$escapedStructure = [Regex]::Escape($projectStructure)
|
||||
@@ -241,7 +241,7 @@ function New-AgentFile {
|
||||
# Replace escaped newlines placeholder after all replacements
|
||||
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands
|
||||
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions
|
||||
|
||||
|
||||
# Build the recent changes string safely
|
||||
$recentChangesForTemplate = ""
|
||||
if ($escaped_lang -and $escaped_framework) {
|
||||
@@ -251,7 +251,7 @@ function New-AgentFile {
|
||||
} elseif ($escaped_framework) {
|
||||
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}"
|
||||
}
|
||||
|
||||
|
||||
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate
|
||||
# Convert literal \n sequences introduced by Escape to real newlines
|
||||
$content = $content -replace '\\n',[Environment]::NewLine
|
||||
@@ -276,14 +276,14 @@ function Update-ExistingAgentFile {
|
||||
$newTechEntries = @()
|
||||
if ($techStack) {
|
||||
$escapedTechStack = [Regex]::Escape($techStack)
|
||||
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
|
||||
$newTechEntries += "- $techStack ($CURRENT_BRANCH)"
|
||||
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
|
||||
$newTechEntries += "- $techStack ($CURRENT_BRANCH)"
|
||||
}
|
||||
}
|
||||
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {
|
||||
$escapedDB = [Regex]::Escape($NEW_DB)
|
||||
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
|
||||
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
|
||||
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
|
||||
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
|
||||
}
|
||||
}
|
||||
$newChangeEntry = ''
|
||||
@@ -377,7 +377,7 @@ function Update-SpecificAgent {
|
||||
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
|
||||
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
||||
'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' }
|
||||
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
||||
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
||||
@@ -386,7 +386,7 @@ function Update-SpecificAgent {
|
||||
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
|
||||
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
|
||||
'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 $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 $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 $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 }
|
||||
|
||||
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
|
||||
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
||||
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-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-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-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-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 |
|
||||
@@ -459,40 +459,40 @@ Full glossary: `specs/00-overview/00-02-glossary.md`
|
||||
|
||||
When user asks about... check these files:
|
||||
|
||||
| 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 |
|
||||
| "แก้ฟอร์ม 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 |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "เพิ่ม 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 |
|
||||
| "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 |
|
||||
| "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 |
|
||||
| "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) |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "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 |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "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 |
|
||||
| "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 |
|
||||
| "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 |
|
||||
| "จัดการ 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 |
|
||||
| "แก้ 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 |
|
||||
| "งานค้าง / resume" | ✅ | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
||||
| 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 |
|
||||
| "แก้ฟอร์ม 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 |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "เพิ่ม 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 |
|
||||
| "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 |
|
||||
| "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 |
|
||||
| "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) |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "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 |
|
||||
| "ตรวจสอบ 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 |
|
||||
| "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 |
|
||||
| "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 |
|
||||
| "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 |
|
||||
| "จัดการ 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 |
|
||||
| "แก้ 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 |
|
||||
| "งานค้าง / resume" | ✅ | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
||||
|
||||
**Status Legend:**
|
||||
|
||||
@@ -501,6 +501,110 @@ When user asks about... check these files:
|
||||
- 🔄 In development
|
||||
- ❌ 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
|
||||
|
||||
### 🔴 Tier 1 — CRITICAL (CI BLOCKER)
|
||||
@@ -611,29 +715,30 @@ This file is a **quick reference**. For detailed information:
|
||||
|
||||
## 🔄 Change Log
|
||||
|
||||
| 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.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.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.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.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.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev |
|
||||
| 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.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
|
||||
| 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.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.8 | 2026-04-14 | Workflow attachments (ADR-021) + step-attachment envelope fields | 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.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.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf 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.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI |
|
||||
| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI |
|
||||
| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet |
|
||||
| 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 |
|
||||
| Version | Date | Changes | Updated By |
|
||||
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
||||
| 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.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.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.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.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.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.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev |
|
||||
| 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.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
|
||||
| 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.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.8 | 2026-04-14 | Workflow attachments (ADR-021) + step-attachment envelope fields | 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.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.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf 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.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI |
|
||||
| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI |
|
||||
| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet |
|
||||
| 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
|
||||
|
||||
## 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)
|
||||
|
||||
### feat(ai): AI Model Swapping, GPU Unloading & OCR Security (ADR-033)
|
||||
@@ -168,7 +199,7 @@
|
||||
|
||||
#### Summary
|
||||
|
||||
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
||||
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Devin/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
||||
|
||||
#### Changes
|
||||
|
||||
|
||||
+104
-54
@@ -62,8 +62,8 @@ _Avoid_: Tool, LLM tool, LangChain tool
|
||||
_Avoid_: Rule engine, NLU pipeline
|
||||
|
||||
**LLM Fallback**:
|
||||
ชั้นที่สอง of Intent Classifier — synchronous Ollama call (gemma4:e4b Q8_0) เมื่อ Pattern Layer ไม่ match, ใช้ semaphore max=3
|
||||
_Avoid_: BullMQ-based classification, async intent routing
|
||||
ชั้นที่สอง 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, gemma4:e4b (runtime tag ไม่ใช่ domain term)
|
||||
|
||||
### AI
|
||||
|
||||
@@ -92,8 +92,8 @@ Container สำเร็จรูป (FastAPI Sidecar บน Desk-5439) ทำ
|
||||
_Avoid_: OCR microservice (ที่ขาดการป้องกัน)
|
||||
|
||||
**Prompt Version**:
|
||||
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
|
||||
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
|
||||
|
||||
**Active Prompt**:
|
||||
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`
|
||||
_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**:
|
||||
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
|
||||
@@ -139,23 +151,23 @@ _Avoid_: Throw exception from tool, Untyped error
|
||||
|
||||
## AI authority scope (resolved)
|
||||
|
||||
| Scope | Allowed? | Mechanism |
|
||||
| :--- | :--- | :--- |
|
||||
| Read-only insight (summarise, explain) | ✅ | AI Gateway → service → CASL-guarded query |
|
||||
| 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-execute workflow transition | ❌ | Forbidden Tier 1 — every transition needs human `actor_user_id` |
|
||||
| Scope | Allowed? | Mechanism |
|
||||
| :------------------------------------------------- | :------- | :-------------------------------------------------------------- |
|
||||
| Read-only insight (summarise, explain) | ✅ | AI Gateway → service → CASL-guarded query |
|
||||
| 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-execute workflow transition | ❌ | Forbidden Tier 1 — every transition needs human `actor_user_id` |
|
||||
|
||||
## Upload pipeline (resolved)
|
||||
|
||||
| Stage | Mode | Queue | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 1. Upload → **temp** + return `tempUploadId` | Sync | — | <1s |
|
||||
| 2. ClamAV scan + MIME whitelist | Sync | — | block ก่อน commit (ADR-016) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| Stage | Mode | Queue | Notes |
|
||||
| :------------------------------------------------------------------- | :---- | :------------ | :------------------------------------------------------- |
|
||||
| 1. Upload → **temp** + return `tempUploadId` | Sync | — | <1s |
|
||||
| 2. ClamAV scan + MIME whitelist | Sync | — | block ก่อน commit (ADR-016) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
||||
**กฎ:**
|
||||
|
||||
@@ -167,14 +179,14 @@ _Avoid_: Throw exception from tool, Untyped error
|
||||
|
||||
## Identifier rules (ADR-019, AI subsystem)
|
||||
|
||||
| Boundary | Identifier ที่ใช้ |
|
||||
| :--- | :--- |
|
||||
| API (FE ↔ AI Gateway) | `publicId` (UUIDv7 string) เท่านั้น; INT `id` มี `@Exclude()` |
|
||||
| Server-side Intent payload | `*PublicId` strings; service แปลงเป็น INT FK ภายใน |
|
||||
| LLM context (prompt) | `publicId` + business code (`rfa_number`, `drawing_code`) ห้ามเห็น INT |
|
||||
| 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)` |
|
||||
| Business codes (e.g. `drawing_code = "A-101"`) | รับเป็น input ได้ แต่ resolve → `publicId` ก่อน query |
|
||||
| Boundary | Identifier ที่ใช้ |
|
||||
| :--------------------------------------------- | :------------------------------------------------------------------------ |
|
||||
| API (FE ↔ AI Gateway) | `publicId` (UUIDv7 string) เท่านั้น; INT `id` มี `@Exclude()` |
|
||||
| Server-side Intent payload | `*PublicId` strings; service แปลงเป็น INT FK ภายใน |
|
||||
| LLM context (prompt) | `publicId` + business code (`rfa_number`, `drawing_code`) ห้ามเห็น INT |
|
||||
| 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)` |
|
||||
| Business codes (e.g. `drawing_code = "A-101"`) | รับเป็น input ได้ แต่ resolve → `publicId` ก่อน query |
|
||||
|
||||
**Forbidden (Tier 1 CI blocker):**
|
||||
|
||||
@@ -195,28 +207,47 @@ _Avoid_: Throw exception from tool, Untyped error
|
||||
|
||||
## Glossary Updates (from ADR-034)
|
||||
|
||||
| Term | Definition | Avoid |
|
||||
|------|------------|-------|
|
||||
| **Thai-Optimized Model** | โมเดล AI ที่ถูก fine-tune มาสำหรับภาษาไทยโดยเฉพาะ (เช่น Typhoon series จาก SCB10X) | Generic model, English-only model |
|
||||
| **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 |
|
||||
| Term | Definition | Avoid |
|
||||
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| **Thai-Optimized Model** | โมเดล AI ที่ถูก fine-tune มาสำหรับภาษาไทยโดยเฉพาะ (เช่น Typhoon series จาก SCB10X) | Generic model, English-only model |
|
||||
| **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 |
|
||||
| **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)
|
||||
|
||||
| Component | สถานะ | หมายเหตุ |
|
||||
| :--- | :--- | :--- |
|
||||
| **Infrastructure** | ✅ พร้อม | NestJS + Next.js + MariaDB + Redis + Elasticsearch |
|
||||
| **Workflow Engine** | ✅ พร้อม | DSL-based, ADR-001/021 |
|
||||
| **AI Boundary** | ✅ พร้อม | ADR-023A — Ollama isolation, no direct DB access |
|
||||
| **RAG Pipeline** | ✅ พร้อม | Qdrant service ป้องกันการรั่วไหลระหว่างโปรเจกต์ |
|
||||
| **Intent Router** | ✅ พร้อม | ADR-024 Active — Intent Classifier (Pattern→LLM Fallback) ทำงานเสร็จสมบูรณ์ |
|
||||
| **AI Tool Layer** | ✅ พร้อม | ADR-025 Active — Tool Layer Bridge functions พัฒนาเสร็จสมบูรณ์ |
|
||||
| **Document Chat UI** | ✅ พร้อม | ADR-026 Active — แผงควบคุม Side-panel Chat UI พัฒนาเสร็จสมบูรณ์ |
|
||||
| **AI Admin Console** | ✅ พร้อม | ADR-027 Active — แผงควบคุม Dynamic prompt & model control |
|
||||
| **Dynamic Prompt Mgmt** | ✅ พร้อม | ADR-029 Active — พัฒนาเสร็จสมบูรณ์ทั้ง Entity, API, Sandbox, Cache และ UI |
|
||||
| Component | สถานะ | หมายเหตุ |
|
||||
| :---------------------------- | :------- | :---------------------------------------------------------------------------------------------- |
|
||||
| **Infrastructure** | ✅ พร้อม | NestJS + Next.js + MariaDB + Redis + Elasticsearch |
|
||||
| **Workflow Engine** | ✅ พร้อม | DSL-based, ADR-001/021 |
|
||||
| **AI Boundary** | ✅ พร้อม | ADR-023A — Ollama isolation, no direct DB access |
|
||||
| **RAG Pipeline** | ✅ พร้อม | Qdrant service ป้องกันการรั่วไหลระหว่างโปรเจกต์ |
|
||||
| **Intent Router** | ✅ พร้อม | ADR-024 Active — Intent Classifier (Pattern→LLM Fallback) ทำงานเสร็จสมบูรณ์ |
|
||||
| **AI Tool Layer** | ✅ พร้อม | ADR-025 Active — Tool Layer Bridge functions พัฒนาเสร็จสมบูรณ์ |
|
||||
| **Document Chat UI** | ✅ พร้อม | ADR-026 Active — แผงควบคุม Side-panel Chat UI พัฒนาเสร็จสมบูรณ์ |
|
||||
| **AI Admin Console** | ✅ พร้อม | ADR-027 Active — แผงควบคุม Dynamic prompt & model control |
|
||||
| **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 |
|
||||
| **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
|
||||
|
||||
@@ -226,23 +257,42 @@ _Avoid_: Throw exception from tool, Untyped error
|
||||
- **"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
|
||||
- **".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
|
||||
|
||||
| ADR | หัวข้อ | ตัดสินใจอะไร | สถานะ |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 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-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-028 | Migration Architecture Refactor | Staging Queue & post-migration cleanup | ✅ 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-033 | Active Model & OCR Management | Synchronous Model switch, GPU VRAM Auto-release, Sidecar API Key protection | ✅ Active |
|
||||
| ADR | หัวข้อ | ตัดสินใจอะไร | สถานะ |
|
||||
| :------ | :--------------------------------- | :-------------------------------------------------------------------------- | :---------- |
|
||||
| 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-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-028 | Migration Architecture Refactor | Staging Queue & post-migration cleanup | ✅ 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-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
|
||||
|
||||
## สิ่งที่ควรทำในอนาคต (Future Maintenance & Security Tasks)
|
||||
|
||||
* **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
|
||||
- **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
|
||||
|
||||
+3
-3
@@ -722,19 +722,19 @@ Create `.markdownlint.json`:
|
||||
|
||||
## 🤖 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 (อ่านตามลำดับนี้)
|
||||
|
||||
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
|
||||
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**)
|
||||
5. `specs/05-Engineering-Guidelines/` (backend / frontend / testing / i18n / git conventions)
|
||||
|
||||
### 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)
|
||||
- `/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.
|
||||
|
||||
| Area | Status | หมายเหตุ |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------------ |
|
||||
| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities |
|
||||
| 🎨 **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 |
|
||||
| 📘 **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 |
|
||||
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
|
||||
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
|
||||
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
|
||||
| 🔒 **Infrastructure** | ✅ Hardened (v1.9.8) | Sidecar APIs secured; dynamic VRAM Release; container hardened |
|
||||
| Area | Status | หมายเหตุ |
|
||||
| ---------------------- | ------------------------ | -------------------------------------------------------------- |
|
||||
| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities |
|
||||
| 🎨 **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 |
|
||||
| 📘 **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 |
|
||||
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
|
||||
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
|
||||
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
|
||||
| 🔒 **Infrastructure** | ✅ Hardened (v1.9.8) | Sidecar APIs secured; dynamic VRAM Release; container hardened |
|
||||
|
||||
---
|
||||
|
||||
@@ -297,7 +297,7 @@ lcbp3-dms/
|
||||
│ ├── scripts/ # Audit & Sync scripts
|
||||
│ └── archive/ # Archived outdated tools
|
||||
│
|
||||
├── .windsurf/ # Windsurf-specific (Mirrored from .agents)
|
||||
├── .devin/ # Devin-specific (Mirrored from .agents)
|
||||
│
|
||||
├── .github/ # GitHub Actions workflows
|
||||
├── AGENTS.md # AI agent rules & project context (v1.9.0) [★ primary]
|
||||
@@ -314,20 +314,20 @@ lcbp3-dms/
|
||||
|
||||
### เอกสารหลัก (specs/ folder)
|
||||
|
||||
| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก |
|
||||
| ----------------------- | -------------------------------------------------------- | --------- | --------------------------------------- |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก |
|
||||
| ----------------------- | ----------------------------------------------------------------- | --------- | --------------------------------------- |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **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
|
||||
- Pull Request Process
|
||||
- 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
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
OLLAMA_RAG_MODEL=typhoon2.5-np-dms:latest
|
||||
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_HOST=http://192.168.10.8:6333
|
||||
QDRANT_COLLECTION=lcbp3_documents
|
||||
|
||||
@@ -19,14 +19,7 @@ export default tseslint.config(
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'jest.config.js',
|
||||
'*.config.mjs',
|
||||
'scratch/*.ts',
|
||||
'test/*.ts',
|
||||
],
|
||||
},
|
||||
project: ['./tsconfig.eslint.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"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:debug-handles": "jest --config jest.config.js --detectOpenHandles",
|
||||
"test:watch": "jest --config jest.config.js --watch",
|
||||
@@ -67,7 +67,7 @@
|
||||
"fs-extra": "^11.3.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"joi": "^18.0.1",
|
||||
"joi": "^18.2.1",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "^2.0.2",
|
||||
"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 { MigrationModule } from './modules/migration/migration.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 { ResponseCodeModule } from './modules/response-code/response-code.module';
|
||||
import { DelegationModule } from './modules/delegation/delegation.module';
|
||||
@@ -192,7 +191,6 @@ import { TagsModule } from './modules/tags/tags.module';
|
||||
AuditLogModule,
|
||||
MigrationModule,
|
||||
AiModule,
|
||||
RagModule,
|
||||
ReviewTeamModule,
|
||||
ResponseCodeModule,
|
||||
DelegationModule,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Change Log:
|
||||
// - 2026-05-13: Add BullMQ config registry for reminder and distribution queues.
|
||||
// - 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';
|
||||
|
||||
@@ -12,7 +13,11 @@ export default registerAs('bullmq', () => ({
|
||||
process.env.BULLMQ_DISTRIBUTION_QUEUE || 'rfa-distribution',
|
||||
aiRealtimeQueue: {
|
||||
name: process.env.BULLMQ_AI_REALTIME_QUEUE || 'ai-realtime',
|
||||
concurrency: 1,
|
||||
concurrency: Number(
|
||||
process.env.AI_REALTIME_CONCURRENCY ||
|
||||
process.env.REALTIME_CONCURRENCY ||
|
||||
'2'
|
||||
),
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
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 */
|
||||
export interface AiVectorDeletionJobPayload {
|
||||
documentPublicId: string;
|
||||
projectPublicId: 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 */
|
||||
@Injectable()
|
||||
export class AiQueueService {
|
||||
@@ -92,7 +107,7 @@ export class AiQueueService {
|
||||
payload,
|
||||
{
|
||||
...this.defaultOptions,
|
||||
jobId: payload.documentPublicId,
|
||||
jobId: `${payload.projectPublicId}:${payload.documentPublicId}`,
|
||||
}
|
||||
);
|
||||
return String(job.id);
|
||||
@@ -158,4 +173,23 @@ export class AiQueueService {
|
||||
const waiting = await this.batchQueue.getWaitingCount();
|
||||
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
|
||||
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
|
||||
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
|
||||
// - 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 { ConfigService } from '@nestjs/config';
|
||||
@@ -11,6 +11,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import axios from 'axios';
|
||||
import { AiQdrantService } from './qdrant.service';
|
||||
import { OcrService } from './services/ocr.service';
|
||||
|
||||
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
|
||||
export interface AiRagCitation {
|
||||
@@ -44,7 +45,6 @@ export class AiRagService {
|
||||
private readonly logger = new Logger(AiRagService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly ollamaModel: string;
|
||||
private readonly ollamaEmbedModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
|
||||
private readonly promptContextLimit: number;
|
||||
@@ -52,6 +52,7 @@ export class AiRagService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly qdrantService: AiQdrantService,
|
||||
private readonly ocrService: OcrService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
@@ -62,10 +63,6 @@ export class AiRagService {
|
||||
'OLLAMA_RAG_MODEL',
|
||||
'gemma2'
|
||||
);
|
||||
this.ollamaEmbedModel = this.configService.get<string>(
|
||||
'OLLAMA_EMBED_MODEL',
|
||||
'nomic-embed-text'
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
||||
this.promptContextLimit = this.configService.get<number>(
|
||||
'RAG_CONTEXT_LIMIT_CHARS',
|
||||
@@ -159,10 +156,11 @@ export class AiRagService {
|
||||
|
||||
/**
|
||||
* ประมวลผล RAG query:
|
||||
* 1. Embed คำถาม
|
||||
* 2. ค้นหา Qdrant ด้วย project isolation (T020 — enforced in AiQdrantService.searchByProject)
|
||||
* 3. Build prompt จาก context
|
||||
* 4. Generate คำตอบผ่าน Ollama (รองรับ AbortSignal สำหรับ T022)
|
||||
* 1. Embed คำถามด้วย BGE-M3 (Dense + Sparse) ผ่าน Sidecar /embed (T015)
|
||||
* 2. ค้นหา Qdrant ด้วย Hybrid Search + project isolation (T015)
|
||||
* 3. Rerank ด้วย BGE-Reranker-Large ผ่าน Sidecar /rerank (T015)
|
||||
* 4. Build prompt จาก context
|
||||
* 5. Generate คำตอบผ่าน Ollama
|
||||
*/
|
||||
async processQuery(
|
||||
requestPublicId: string,
|
||||
@@ -182,8 +180,8 @@ export class AiRagService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. สร้าง embedding สำหรับคำถาม
|
||||
const queryVector = await this.embed(question, signal);
|
||||
// 1. สร้าง embedding สำหรับคำถามด้วย BGE-M3 ผ่าน Sidecar
|
||||
const embedResult = await this.ocrService.embedViaSidecar(question);
|
||||
|
||||
// ตรวจสอบ cancel อีกครั้งหลัง embed
|
||||
if (
|
||||
@@ -195,17 +193,15 @@ export class AiRagService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002)
|
||||
// 2. ค้นหา Qdrant ด้วย Hybrid search และกรองตาม project
|
||||
const searchResults = await this.qdrantService.searchByProject(
|
||||
queryVector,
|
||||
embedResult.dense,
|
||||
embedResult.sparse,
|
||||
projectPublicId,
|
||||
10
|
||||
15 // topK=15 ตาม FR-014
|
||||
);
|
||||
|
||||
// 3. สร้าง context จาก search results
|
||||
const context = this.buildContext(searchResults);
|
||||
|
||||
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
|
||||
// ตรวจสอบ cancel หลัง search
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(await this.redis.get(this.cancelKey(requestPublicId)))
|
||||
@@ -215,25 +211,74 @@ export class AiRagService {
|
||||
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(
|
||||
this.sanitizeInput(question),
|
||||
context,
|
||||
signal
|
||||
);
|
||||
|
||||
const citations: AiRagCitation[] = searchResults.map((r) => ({
|
||||
const citations: AiRagCitation[] = finalResults.map((r) => ({
|
||||
pointId: r.pointId,
|
||||
score: r.score,
|
||||
docType: r.payload['doc_type'] as string | undefined,
|
||||
docNumber: r.payload['doc_number'] as string | undefined,
|
||||
snippet: (r.payload['content_preview'] as string | undefined)?.slice(
|
||||
0,
|
||||
200
|
||||
),
|
||||
snippet: (
|
||||
(r.payload['chunk_text'] as string) ||
|
||||
(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({
|
||||
requestPublicId,
|
||||
@@ -266,17 +311,7 @@ export class AiRagService {
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** สร้าง embedding vector สำหรับข้อความ */
|
||||
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) */
|
||||
/** Generate คำตอบจาก Ollama */
|
||||
private async generateAnswer(
|
||||
question: string,
|
||||
context: string,
|
||||
@@ -291,7 +326,6 @@ export class AiRagService {
|
||||
);
|
||||
return { answer: response.data.response ?? '', usedFallback: false };
|
||||
} catch (err: unknown) {
|
||||
// ถ้าเป็น cancellation error ให้ re-throw เพื่อให้ processQuery จัดการ
|
||||
if (
|
||||
axios.isCancel(err) ||
|
||||
(err instanceof Error && err.name === 'CanceledError')
|
||||
@@ -313,7 +347,10 @@ export class AiRagService {
|
||||
for (const r of results) {
|
||||
const docType = (r.payload['doc_type'] 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 snippet = `${header}\n${preview}\n\n`;
|
||||
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
|
||||
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
|
||||
// - 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-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-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)
|
||||
|
||||
import {
|
||||
@@ -61,7 +63,7 @@ import { AiRagQueryDto } from './dto/ai-rag-query.dto';
|
||||
import { ExtractDocumentDto } from './dto/extract-document.dto';
|
||||
import { AiCallbackDto } from './dto/ai-callback.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 { MigrationQueryDto } from './dto/migration-query.dto';
|
||||
import { ValidationException, SystemException } from '../../common/exceptions';
|
||||
@@ -170,11 +172,7 @@ export class AiController {
|
||||
@Body() dto: CreateAiJobDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
): Promise<{ success: boolean; jobId?: string; status: string }> {
|
||||
const result = await this.aiService.queueSuggestJob({
|
||||
...dto,
|
||||
jobType: 'ai-suggest',
|
||||
idempotencyKey: idempotencyKey || dto.idempotencyKey,
|
||||
});
|
||||
const result = await this.aiService.queueSuggestJob(dto, idempotencyKey);
|
||||
return {
|
||||
success: result.success,
|
||||
jobId: result.jobId,
|
||||
@@ -198,25 +196,25 @@ export class AiController {
|
||||
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.suggest')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Submit AI migration job — ส่งงานย้ายเอกสารให้ AI ประมวลผล',
|
||||
summary: 'Submit unified AI job — ส่งงานประมวลผล AI แบบรวมศูนย์',
|
||||
description:
|
||||
'รับ tempAttachmentId/documentNumber แล้วส่งงานย้ายเอกสารเข้า BullMQ เพื่อรอการประมวลผล',
|
||||
'รับชนิดงานและข้อมูลอ้างอิง เพื่อส่งงานประมวลผล AI เข้าคิว BullMQ',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key เพื่อป้องกัน duplicate AI job',
|
||||
required: true,
|
||||
})
|
||||
async submitMigrationJob(
|
||||
@Body() dto: SubmitAiJobDto,
|
||||
async submitUnifiedJob(
|
||||
@Body() dto: CreateAiJobDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
) {
|
||||
): Promise<AiJobResponseDto> {
|
||||
if (!idempotencyKey) {
|
||||
throw new ValidationException('Idempotency-Key header is required');
|
||||
}
|
||||
return this.aiService.submitMigrationJob(dto, idempotencyKey);
|
||||
return this.aiService.submitUnifiedJob(dto, idempotencyKey);
|
||||
}
|
||||
|
||||
@Get('jobs/:jobId')
|
||||
@@ -452,6 +450,7 @@ export class AiController {
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@Throttle({ default: { limit: 300, ttl: 60000 } }) // 300 req/min — รองรับ admin polling ทุก 200ms
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'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 { VramMonitorService } from './services/vram-monitor.service';
|
||||
import { OcrCacheService } from './services/ocr-cache.service';
|
||||
import { AiPolicyService } from './services/ai-policy.service';
|
||||
import { MigrationLog } from './entities/migration-log.entity';
|
||||
import { AiAuditLog } from './entities/ai-audit-log.entity';
|
||||
import { MigrationReviewRecord } from './entities/migration-review.entity';
|
||||
import { MigrationProgress } from './entities/migration-progress.entity';
|
||||
import { SystemSetting } from './entities/system-setting.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 { AiEnabledGuard } from './guards/ai-enabled.guard';
|
||||
import { UserModule } from '../user/user.module';
|
||||
@@ -96,6 +98,7 @@ import {
|
||||
ImportTransaction,
|
||||
MigrationReviewQueue,
|
||||
AiPrompt,
|
||||
AiExecutionProfile,
|
||||
]),
|
||||
|
||||
BullModule.registerQueue(
|
||||
@@ -171,6 +174,7 @@ import {
|
||||
providers: [
|
||||
AiService,
|
||||
AiSettingsService,
|
||||
AiPolicyService,
|
||||
AiIngestService,
|
||||
AiMigrationCheckpointService,
|
||||
AiQueueService,
|
||||
@@ -201,6 +205,7 @@ import {
|
||||
exports: [
|
||||
AiService,
|
||||
AiSettingsService,
|
||||
AiPolicyService,
|
||||
AiIngestService,
|
||||
AiMigrationCheckpointService,
|
||||
AiQueueService,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions
|
||||
// Change Log
|
||||
// - 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 { getRepositoryToken } from '@nestjs/typeorm';
|
||||
@@ -17,7 +18,11 @@ import {
|
||||
import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity';
|
||||
import { AiCallbackDto } from './dto/ai-callback.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 {
|
||||
QUEUE_AI_BATCH,
|
||||
@@ -28,6 +33,9 @@ import { AiQdrantService } from './qdrant.service';
|
||||
import { ImportTransaction } from '../migration/entities/import-transaction.entity';
|
||||
import { AiSettingsService } from './ai-settings.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';
|
||||
|
||||
@@ -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 = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
@@ -191,6 +237,7 @@ describe('AiService', () => {
|
||||
{ provide: AiQdrantService, useValue: mockQdrantService },
|
||||
{ provide: AiSettingsService, useValue: mockAiSettingsService },
|
||||
{ provide: VramMonitorService, useValue: mockVramMonitorService },
|
||||
{ provide: AiPolicyService, useValue: mockAiPolicyService },
|
||||
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
||||
],
|
||||
}).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 ---
|
||||
|
||||
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)
|
||||
// Change Log
|
||||
// - 2026-05-21: เพิ่ม getSystemHealth พร้อมระบบแคช Redis 30 วินาทีตาม ADR-027.
|
||||
// - 2026-05-21: แก้ไข ESLint unsafe return error ใน getSystemHealth โดยใช้ interface SystemHealthResponse
|
||||
// - 2026-05-29: เพิ่ม OcrService.checkHealth() เข้า getSystemHealth() เพื่อแสดงสถานะ OCR sidecar
|
||||
// - 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 { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
@@ -37,8 +40,11 @@ import { MigrationQueryDto } from './dto/migration-query.dto';
|
||||
import { AiValidationService } from './ai-validation.service';
|
||||
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 { AiPolicyService } from './services/ai-policy.service';
|
||||
import { ImportTransaction } from '../migration/entities/import-transaction.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
@@ -52,6 +58,7 @@ import {
|
||||
VramMonitorService,
|
||||
VramStatus,
|
||||
} from './services/vram-monitor.service';
|
||||
import type { AiJobPayload } from './interfaces/execution-policy.interface';
|
||||
import {
|
||||
AiModelConfiguration,
|
||||
AiModelType,
|
||||
@@ -178,6 +185,7 @@ export class AiService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly aiValidationService: AiValidationService,
|
||||
private readonly aiPolicyService: AiPolicyService,
|
||||
@InjectRepository(MigrationLog)
|
||||
private readonly migrationLogRepo: Repository<MigrationLog>,
|
||||
@InjectRepository(AiAuditLog)
|
||||
@@ -220,7 +228,16 @@ export class AiService {
|
||||
// --- ADR-023A BullMQ Job Queueing ---
|
||||
|
||||
/** ส่งงาน 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) {
|
||||
const error = new Error('AI realtime queue is not registered');
|
||||
this.logger.error('AI job queue failed', {
|
||||
@@ -229,18 +246,17 @@ export class AiService {
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await this.aiRealtimeQueue.add(
|
||||
'ai-suggest',
|
||||
{
|
||||
jobType: 'ai-suggest',
|
||||
documentPublicId: dto.documentPublicId,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
projectPublicId: dto.projectPublicId || '',
|
||||
payload: dto.payload ?? {},
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
idempotencyKey,
|
||||
},
|
||||
{ jobId: dto.idempotencyKey }
|
||||
{ jobId: idempotencyKey }
|
||||
);
|
||||
return { success: true, jobId: String(job.id) };
|
||||
} catch (err: unknown) {
|
||||
@@ -254,7 +270,10 @@ export class AiService {
|
||||
}
|
||||
|
||||
/** ส่งงาน embedding เข้า ai-batch queue แบบ best-effort */
|
||||
async queueEmbedJob(dto: CreateAiJobDto): Promise<AiQueueResult> {
|
||||
async queueEmbedJob(
|
||||
dto: CreateAiJobDto,
|
||||
idempotencyKey: string
|
||||
): Promise<AiQueueResult> {
|
||||
if (!this.aiBatchQueue) {
|
||||
const error = new Error('AI batch queue is not registered');
|
||||
this.logger.error('AI job queue failed', {
|
||||
@@ -263,18 +282,17 @@ export class AiService {
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await this.aiBatchQueue.add(
|
||||
'embed-document',
|
||||
{
|
||||
jobType: 'embed-document',
|
||||
documentPublicId: dto.documentPublicId,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
documentPublicId: dto.documentPublicId || '',
|
||||
projectPublicId: dto.projectPublicId || '',
|
||||
payload: dto.payload ?? {},
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
idempotencyKey,
|
||||
},
|
||||
{ jobId: dto.idempotencyKey }
|
||||
{ jobId: idempotencyKey }
|
||||
);
|
||||
return { success: true, jobId: String(job.id) };
|
||||
} 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 */
|
||||
async submitMigrationJob(
|
||||
dto: SubmitAiJobDto,
|
||||
@@ -327,9 +463,14 @@ export class AiService {
|
||||
defaultProject?.publicId ?? '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
try {
|
||||
const payload = await this.aiPolicyService.createJobPayload(
|
||||
'migrate-document',
|
||||
dto.payload.tempAttachmentId
|
||||
);
|
||||
const job = await this.aiBatchQueue.add(
|
||||
'migrate-document',
|
||||
{
|
||||
...payload,
|
||||
jobType: 'migrate-document',
|
||||
documentPublicId: dto.payload.tempAttachmentId,
|
||||
projectPublicId,
|
||||
@@ -691,6 +832,9 @@ export class AiService {
|
||||
inputHash?: string;
|
||||
outputHash?: string;
|
||||
errorMessage?: string;
|
||||
effectiveProfile?: string;
|
||||
canonicalModel?: string;
|
||||
snapshotParamsJson?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const auditLog = this.aiAuditLogRepo.create({
|
||||
@@ -702,6 +846,9 @@ export class AiService {
|
||||
inputHash: data.inputHash,
|
||||
outputHash: data.outputHash,
|
||||
errorMessage: data.errorMessage,
|
||||
effectiveProfile: data.effectiveProfile,
|
||||
canonicalModel: data.canonicalModel,
|
||||
snapshotParamsJson: data.snapshotParamsJson,
|
||||
});
|
||||
await this.aiAuditLogRepo.save(auditLog);
|
||||
} 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
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม DTO สำหรับ enqueue AI jobs ตาม ADR-023A US1.
|
||||
// File: backend/src/modules/ai/dto/create-ai-job.dto.ts
|
||||
// Change Log:
|
||||
// - 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 {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
IsObject,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidationArguments,
|
||||
} from 'class-validator';
|
||||
import type { PublicJobType } from '../interfaces/execution-policy.interface';
|
||||
|
||||
export const AI_JOB_TYPES = [
|
||||
'ai-suggest',
|
||||
'rag-query',
|
||||
'ocr',
|
||||
'extract-metadata',
|
||||
'embed-document',
|
||||
] as const;
|
||||
/**
|
||||
* Custom decorator to forbid specific properties in payload.
|
||||
* เดคอเรเตอร์สำหรับป้องกันไม่ให้ส่งฟิลด์ที่กำหนดมาใน API payload
|
||||
*/
|
||||
export function IsForbidden(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
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 {
|
||||
@ApiProperty({ description: 'Attachment/document publicId สำหรับงาน AI' })
|
||||
@IsUUID()
|
||||
documentPublicId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Project publicId สำหรับ project isolation' })
|
||||
@IsUUID()
|
||||
projectPublicId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: AI_JOB_TYPES,
|
||||
enum: ['auto-fill-document', 'migrate-document', 'rag-query'],
|
||||
description: 'ชนิดงาน AI ที่ต้อง enqueue',
|
||||
})
|
||||
@IsIn(AI_JOB_TYPES)
|
||||
jobType!: CreateAiJobType;
|
||||
|
||||
@ApiProperty({ description: 'Idempotency key จาก request header/body' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
idempotencyKey!: string;
|
||||
@IsEnum(['auto-fill-document', 'migrate-document', 'rag-query'])
|
||||
type!: PublicJobType;
|
||||
|
||||
@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()
|
||||
@IsObject()
|
||||
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
|
||||
// - 2026-05-14: เพิ่ม ADR-023 feedback fields โดยคง legacy audit fields ไว้ช่วงเปลี่ยนผ่าน.
|
||||
// - 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
|
||||
|
||||
import {
|
||||
@@ -100,6 +101,25 @@ export class AiAuditLog extends UuidBaseEntity {
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
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' })
|
||||
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
|
||||
// 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: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
|
||||
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
|
||||
@@ -52,6 +53,9 @@ describe('AiBatchProcessor', () => {
|
||||
detectAndExtract: jest
|
||||
.fn()
|
||||
.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 = {
|
||||
detectAndExtract: jest.fn().mockResolvedValue({
|
||||
@@ -81,6 +85,7 @@ describe('AiBatchProcessor', () => {
|
||||
};
|
||||
const mockRedis = {
|
||||
setex: jest.fn().mockResolvedValue('OK'),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const mockAttachmentRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
@@ -140,6 +145,7 @@ describe('AiBatchProcessor', () => {
|
||||
resolvedPrompt: 'Resolved test prompt with OCR text',
|
||||
versionNumber: 2,
|
||||
}),
|
||||
findByVersion: jest.fn().mockResolvedValue(null),
|
||||
saveTestResult: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
beforeEach(async () => {
|
||||
@@ -237,7 +243,23 @@ describe('AiBatchProcessor', () => {
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
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).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(
|
||||
{ publicId: 'doc-uuid-123' },
|
||||
{ aiProcessingStatus: 'PROCESSING' }
|
||||
@@ -288,7 +310,13 @@ describe('AiBatchProcessor', () => {
|
||||
'/files/test.pdf',
|
||||
'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).toHaveBeenLastCalledWith(
|
||||
'ai:rag:result:idem-extract-123',
|
||||
@@ -296,6 +324,69 @@ describe('AiBatchProcessor', () => {
|
||||
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 () => {
|
||||
mockTagsService.findOrSuggestTags.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -430,7 +521,14 @@ describe('AiBatchProcessor', () => {
|
||||
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
||||
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(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -449,4 +547,78 @@ describe('AiBatchProcessor', () => {
|
||||
expect(mockAiAuditLogRepo.create).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
|
||||
// - 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: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
||||
// - 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-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-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 { Logger } from '@nestjs/common';
|
||||
@@ -27,13 +34,17 @@ import {
|
||||
SandboxOcrEngineService,
|
||||
SandboxOcrEngineType,
|
||||
} 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 { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
|
||||
import { TagsService } from '../../tags/tags.service';
|
||||
import { MigrationService } from '../../migration/migration.service';
|
||||
import { MigrationErrorType } from '../../migration/entities/migration-error.entity';
|
||||
import { AiPromptsService } from '../prompts/ai-prompts.service';
|
||||
import type { ExecutionProfile } from '../interfaces/execution-policy.interface';
|
||||
|
||||
interface MigrateDocumentMetadata extends Record<string, unknown> {
|
||||
projectPublicId?: string;
|
||||
@@ -57,7 +68,10 @@ export type AiBatchJobType =
|
||||
| 'sandbox-extract'
|
||||
| 'sandbox-ocr-only'
|
||||
| 'sandbox-ai-extract'
|
||||
| 'migrate-document';
|
||||
| 'migrate-document'
|
||||
| 'rag-prepare'
|
||||
| 'ai-suggest'
|
||||
| 'rag-query';
|
||||
|
||||
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
|
||||
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
|
||||
@@ -71,8 +85,36 @@ export interface AiBatchJobData {
|
||||
payload: Record<string, unknown>;
|
||||
batchId?: 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 =>
|
||||
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
|
||||
* lockDuration: 150000ms — รองรับ Ollama sandbox ที่ใช้เวลาสูงสุด 120s (ADR-029 FR-008)
|
||||
* ค่า default ของ BullMQ คือ 30000ms ซึ่งน้อยกว่า timeout → job stall
|
||||
@@ -168,6 +218,62 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
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 */
|
||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||
const isSandbox =
|
||||
@@ -199,6 +305,16 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
}
|
||||
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':
|
||||
this.logger.log(`Embedding job processing — jobId=${String(job.id)}`);
|
||||
await this.processEmbedDocument(job.data);
|
||||
@@ -239,6 +355,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
}
|
||||
return;
|
||||
case 'rag-prepare':
|
||||
this.logger.log(
|
||||
`RAG prepare job processing — jobId=${String(job.id)}`
|
||||
);
|
||||
await this.processRagPrepare(job.data);
|
||||
return;
|
||||
default: {
|
||||
const unreachable: never = job.data.jobType;
|
||||
throw new Error(
|
||||
@@ -260,21 +382,62 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
|
||||
/** ประมวลผล embed-document job ด้วย EmbeddingService (T022) */
|
||||
private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const { documentPublicId, projectPublicId, payload } = data;
|
||||
const pdfPath = payload.pdfPath as string;
|
||||
const extractedText = payload.extractedText as string | undefined;
|
||||
const extractedText = readString(payload.extractedText);
|
||||
if (!pdfPath) {
|
||||
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(
|
||||
pdfPath,
|
||||
documentPublicId,
|
||||
projectPublicId,
|
||||
extractedText
|
||||
documentPublicId,
|
||||
correspondenceNumber,
|
||||
docType,
|
||||
statusCode,
|
||||
revisionNumber,
|
||||
subject,
|
||||
documentDate,
|
||||
resolvedOcrText
|
||||
);
|
||||
if (!result.success) {
|
||||
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(
|
||||
`Embedding completed for document ${documentPublicId} — ${result.chunksEmbedded} chunks embedded`
|
||||
);
|
||||
@@ -372,6 +535,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
pdfPath,
|
||||
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 =
|
||||
await this.aiPromptsService.getActive('ocr_extraction');
|
||||
@@ -380,36 +549,38 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
}
|
||||
|
||||
// ดึงบริบท Master data
|
||||
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
|
||||
// ดังนั้นส่ง undefined เพื่อ skip project lookup
|
||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||
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
|
||||
.replace('{{ocr_text}}', ocrResult.text)
|
||||
.replace(
|
||||
'{{master_data_context}}',
|
||||
JSON.stringify(masterDataContext, null, 2)
|
||||
);
|
||||
.replace('{{ocr_text}}', ocrTextSafe)
|
||||
.replace('{{master_data_context}}', compactMasterDataContext);
|
||||
|
||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
const cleanedResponse = response
|
||||
.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}`
|
||||
);
|
||||
}
|
||||
this.logger.debug(
|
||||
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
|
||||
);
|
||||
|
||||
const { extractedMetadata } = await this.generateStructuredJson(
|
||||
resolvedPrompt,
|
||||
{
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
|
||||
}
|
||||
);
|
||||
await this.aiPromptsService.saveTestResult(
|
||||
'ocr_extraction',
|
||||
activePrompt.versionNumber,
|
||||
@@ -422,11 +593,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'completed',
|
||||
answer: JSON.stringify(extractedMetadata, null, 2),
|
||||
ocrText: ocrResult.text,
|
||||
ocrText: sanitizedOcrText,
|
||||
ocrUsed: ocrResult.ocrUsed,
|
||||
engineUsed: ocrResult.engineUsed,
|
||||
fallbackUsed: ocrResult.fallbackUsed,
|
||||
promptVersionUsed: activePrompt.versionNumber,
|
||||
llmPrompt: resolvedPrompt,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
@@ -475,13 +647,19 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
engineType,
|
||||
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
|
||||
await this.redis.setex(
|
||||
`ai:sandbox:ocr:${idempotencyKey}`,
|
||||
3600,
|
||||
JSON.stringify({
|
||||
ocrText: ocrResult.text,
|
||||
ocrText: sanitizedOcrText,
|
||||
ocrUsed: ocrResult.ocrUsed,
|
||||
engineUsed: ocrResult.engineUsed,
|
||||
fallbackUsed: ocrResult.fallbackUsed,
|
||||
@@ -495,7 +673,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
JSON.stringify({
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'completed',
|
||||
ocrText: ocrResult.text,
|
||||
ocrText: sanitizedOcrText,
|
||||
ocrUsed: ocrResult.ocrUsed,
|
||||
engineUsed: ocrResult.engineUsed,
|
||||
fallbackUsed: ocrResult.fallbackUsed,
|
||||
@@ -550,7 +728,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
fallbackUsed?: boolean;
|
||||
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
|
||||
const activePrompt =
|
||||
@@ -572,38 +755,36 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
}
|
||||
|
||||
// Resolve context และ run LLM
|
||||
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
|
||||
// ดังนั้นส่ง undefined เพื่อ skip project lookup
|
||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||
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
|
||||
.replace('{{ocr_text}}', ocrText)
|
||||
.replace(
|
||||
'{{master_data_context}}',
|
||||
JSON.stringify(masterDataContext, null, 2)
|
||||
);
|
||||
|
||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
|
||||
const cleanedResponse = response
|
||||
.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}`
|
||||
);
|
||||
}
|
||||
.replace('{{ocr_text}}', ocrTextSafe)
|
||||
.replace('{{master_data_context}}', compactMasterDataContext);
|
||||
this.logger.debug(
|
||||
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
|
||||
);
|
||||
const { extractedMetadata } = await this.generateStructuredJson(
|
||||
resolvedPrompt,
|
||||
{
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
|
||||
}
|
||||
);
|
||||
|
||||
await this.aiPromptsService.saveTestResult(
|
||||
'ocr_extraction',
|
||||
@@ -623,6 +804,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
engineUsed: parsedOcr.engineUsed,
|
||||
fallbackUsed: parsedOcr.fallbackUsed,
|
||||
promptVersionUsed: targetPrompt.versionNumber,
|
||||
llmPrompt: resolvedPrompt,
|
||||
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(
|
||||
job: Job<AiBatchJobData>
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const { documentPublicId, projectPublicId, payload, batchId } = job.data;
|
||||
const modelUsed = job.data.canonicalModel;
|
||||
const docNumber = payload.documentNumber as string;
|
||||
const contextOverride =
|
||||
payload.contextOverride &&
|
||||
@@ -672,6 +940,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
try {
|
||||
ocrResult = await this.ocrService.detectAndExtract({
|
||||
pdfPath: attachment.filePath,
|
||||
activeProfile: job.data.effectiveProfile,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -688,6 +957,9 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: errMsg,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
effectiveProfile: job.data.effectiveProfile,
|
||||
canonicalModel: job.data.canonicalModel,
|
||||
snapshotParamsJson: job.data.snapshotParams,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -714,9 +986,28 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
|
||||
let aiResponse: string;
|
||||
try {
|
||||
aiResponse = await this.ollamaService.generate(resolvedPrompt, {
|
||||
const snapshotParams = job.data.snapshotParams;
|
||||
const generateOptions: OllamaGenerateOptions = {
|
||||
format: 'json',
|
||||
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) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`การวิเคราะห์ของ AI ล้มเหลว: ${errMsg}`);
|
||||
@@ -728,10 +1019,13 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
});
|
||||
await this.saveAiAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
aiModel: modelUsed ?? this.ollamaService.getMainModelName(),
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: errMsg,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
effectiveProfile: job.data.effectiveProfile,
|
||||
canonicalModel: job.data.canonicalModel,
|
||||
snapshotParamsJson: job.data.snapshotParams,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -754,10 +1048,13 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
});
|
||||
await this.saveAiAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
aiModel: modelUsed ?? this.ollamaService.getMainModelName(),
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: errMsg,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
effectiveProfile: job.data.effectiveProfile,
|
||||
canonicalModel: job.data.canonicalModel,
|
||||
snapshotParamsJson: job.data.snapshotParams,
|
||||
});
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
@@ -914,11 +1211,14 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
|
||||
await this.saveAiAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
aiModel: modelUsed ?? this.ollamaService.getMainModelName(),
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
aiSuggestionJson: extractedMetadata as unknown as Record<string, unknown>,
|
||||
confidenceScore: confidence,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
effectiveProfile: job.data.effectiveProfile,
|
||||
canonicalModel: job.data.canonicalModel,
|
||||
snapshotParamsJson: job.data.snapshotParams,
|
||||
});
|
||||
this.logger.log(
|
||||
`ประมวลผลเอกสาร ${docNumber} สำเร็จและถูกส่งเข้า Staging Queue แล้ว`
|
||||
@@ -933,6 +1233,9 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
confidenceScore?: number;
|
||||
processingTimeMs?: number;
|
||||
errorMessage?: string;
|
||||
effectiveProfile?: string;
|
||||
canonicalModel?: string;
|
||||
snapshotParamsJson?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const log = this.aiAuditLogRepo.create({
|
||||
@@ -944,6 +1247,9 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
confidenceScore: data.confidenceScore,
|
||||
processingTimeMs: data.processingTimeMs,
|
||||
errorMessage: data.errorMessage,
|
||||
effectiveProfile: data.effectiveProfile,
|
||||
canonicalModel: data.canonicalModel,
|
||||
snapshotParamsJson: data.snapshotParamsJson,
|
||||
});
|
||||
await this.aiAuditLogRepo.save(log);
|
||||
} 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
|
||||
// - 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-11: ปรับ concurrency และเพิ่ม job classification เพื่อ redirect ไป ai-batch (US4)
|
||||
// - 2026-06-11: แก้ไขปัญหา compile error สำหรับ unreachable check ใน switch-case และลบบรรทัดว่างในฟังก์ชัน process
|
||||
|
||||
import {
|
||||
Processor,
|
||||
@@ -22,7 +24,11 @@ import { Attachment } from '../../../common/file-storage/entities/attachment.ent
|
||||
import { OcrService } from '../services/ocr.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 {
|
||||
jobType: AiRealtimeJobType;
|
||||
@@ -34,9 +40,16 @@ export interface AiRealtimeJobData {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
private readonly logger = new Logger(AiRealtimeProcessor.name);
|
||||
private activeRealtimeJobs = 0;
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
@@ -53,12 +66,32 @@ export class AiRealtimeProcessor extends WorkerHost {
|
||||
|
||||
/** Dispatch งาน ai-realtime ตาม jobType */
|
||||
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) {
|
||||
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':
|
||||
return this.processSuggest(job);
|
||||
case 'rag-query':
|
||||
this.logger.log(`RAG query queued — jobId=${String(job.id)}`);
|
||||
return;
|
||||
throw new Error(
|
||||
`Job type ${job.data.jobType} should have been redirected to batch queue.`
|
||||
);
|
||||
default: {
|
||||
const unreachable: never = job.data.jobType;
|
||||
throw new Error(
|
||||
@@ -203,27 +236,48 @@ export class AiRealtimeProcessor extends WorkerHost {
|
||||
/** เมื่อ interactive job เริ่ม ให้ pause batch queue เพื่อกัน GPU contention */
|
||||
@OnWorkerEvent('active')
|
||||
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(
|
||||
`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 */
|
||||
@OnWorkerEvent('completed')
|
||||
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(
|
||||
`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 เช่นกัน */
|
||||
@OnWorkerEvent('failed')
|
||||
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(
|
||||
`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> {
|
||||
const { documentPublicId, requestedByUserPublicId } = job.data;
|
||||
const { documentPublicId, projectPublicId, requestedByUserPublicId } =
|
||||
job.data;
|
||||
|
||||
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(
|
||||
`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
|
||||
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
|
||||
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
|
||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
|
||||
// - 2026-06-05: ปรับปรุงโครงสร้างเป็น Hybrid (Dense 1024 + Sparse) ตาม ADR-035 (T006-T010)
|
||||
// - 2026-06-05: เพิ่ม Compatibility สำหรับ search() ที่ไม่มี sparseVector เพื่อผ่านการทดสอบแบบดั้งเดิม
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
@@ -14,7 +16,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
const AI_COLLECTION_NAME = 'lcbp3_vectors';
|
||||
const AI_VECTOR_SIZE = 768;
|
||||
const AI_VECTOR_SIZE = 1024;
|
||||
|
||||
export interface AiVectorSearchResult {
|
||||
pointId: string | number;
|
||||
@@ -22,7 +24,14 @@ export interface AiVectorSearchResult {
|
||||
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()
|
||||
export class AiQdrantService implements OnModuleInit {
|
||||
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> {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(
|
||||
(collection) => collection.name === AI_COLLECTION_NAME
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
await this.client.createCollection(AI_COLLECTION_NAME, {
|
||||
vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
|
||||
});
|
||||
if (exists) {
|
||||
// ตรวจ schema ของ collection ที่มีอยู่ — ถ้าเป็น Hybrid 1024 dims แล้ว skip delete
|
||||
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, {
|
||||
field_name: 'project_public_id',
|
||||
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
|
||||
QdrantClient['createPayloadIndex']
|
||||
>[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(
|
||||
projectPublicId: string,
|
||||
vector: number[],
|
||||
denseVector: number[],
|
||||
sparseVectorOrTopK?: { indices: number[]; values: number[] } | number,
|
||||
topK = 5
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
const results = await this.client.search(AI_COLLECTION_NAME, {
|
||||
vector,
|
||||
limit: topK,
|
||||
let actualSparseVector = {
|
||||
indices: [] as number[],
|
||||
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: {
|
||||
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
||||
},
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return results.map((result) => ({
|
||||
return results.points.map((result) => ({
|
||||
pointId: result.id,
|
||||
score: result.score,
|
||||
score: result.score ?? 0,
|
||||
payload: result.payload ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */
|
||||
/** Compatibility wrapper สำหรับโค้ดเดิมระหว่าง transition */
|
||||
async searchByProject(
|
||||
vector: number[],
|
||||
projectPublicId: string,
|
||||
limit: number
|
||||
denseVector: number[],
|
||||
sparseVectorOrProjectPublicId:
|
||||
| { indices: number[]; values: number[] }
|
||||
| string,
|
||||
projectPublicIdOrLimit?: string | number,
|
||||
limit = 5
|
||||
): 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 ในขั้นถัดไป */
|
||||
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
|
||||
/** ลบเวกเตอร์ของเอกสารด้วย projectPublicId และ documentPublicId */
|
||||
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, {
|
||||
wait: true,
|
||||
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(
|
||||
projectPublicId: string,
|
||||
points: Array<{
|
||||
id: string;
|
||||
vector: number[];
|
||||
vector: {
|
||||
bge_dense: number[];
|
||||
bge_sparse: {
|
||||
indices: number[];
|
||||
values: number[];
|
||||
};
|
||||
};
|
||||
payload: Record<string, unknown>;
|
||||
}>
|
||||
): Promise<void> {
|
||||
@@ -126,14 +318,14 @@ export class AiQdrantService implements OnModuleInit {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
// เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation
|
||||
// เพิ่ม project_public_id ใน payload ทุก point เพื่อแยกโครงการ
|
||||
const pointsWithProject = points.map((point) => ({
|
||||
...point,
|
||||
payload: {
|
||||
...point.payload,
|
||||
project_public_id: projectPublicId,
|
||||
},
|
||||
}));
|
||||
})) as unknown as QdrantUpsertPoint[];
|
||||
|
||||
await this.client.upsert(AI_COLLECTION_NAME, {
|
||||
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
|
||||
// - 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 { ConfigService } from '@nestjs/config';
|
||||
import { OllamaService } from './ollama.service';
|
||||
import { AiQdrantService } from '../qdrant.service';
|
||||
import { OcrService } from './ocr.service';
|
||||
import { AiPromptsService } from '../prompts/ai-prompts.service';
|
||||
|
||||
export interface EmbeddingChunk {
|
||||
chunkIndex: number;
|
||||
@@ -18,6 +21,7 @@ export interface EmbeddingResult {
|
||||
success: boolean;
|
||||
chunksEmbedded: number;
|
||||
error?: string;
|
||||
device?: string;
|
||||
}
|
||||
|
||||
/** บริการสร้าง embedding สำหรับ full-document RAG (ADR-023A) */
|
||||
@@ -31,7 +35,8 @@ export class EmbeddingService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly ollamaService: OllamaService,
|
||||
private readonly qdrantService: AiQdrantService,
|
||||
private readonly ocrService: OcrService
|
||||
private readonly ocrService: OcrService,
|
||||
private readonly aiPromptsService: AiPromptsService
|
||||
) {
|
||||
this.chunkSize = this.configService.get<number>(
|
||||
'EMBEDDING_CHUNK_SIZE',
|
||||
@@ -44,71 +49,74 @@ export class EmbeddingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง embedding สำหรับเอกสารทั้งฉบับ:
|
||||
* 1. ดึงข้อความ full-doc (ใช้ extractedText หรือ OCR)
|
||||
* 2. Chunk text 512 tokens / 64 overlap
|
||||
* 3. Generate embedding ต่อ chunk ด้วย nomic-embed-text
|
||||
* 4. Upsert ไป Qdrant พร้อม project isolation
|
||||
* สร้าง hybrid embedding สำหรับเอกสารทั้งฉบับ:
|
||||
* 1. ใช้ Semantic Chunking (ผ่าน LLM) เป็นหลัก พร้อม Fallback เป็นแบบ fixed-size
|
||||
* 2. เรียก Sidecar /embed เพื่อแปลงแต่ละ chunk เป็น Dense (1024 dims) + Sparse vector
|
||||
* 3. ลบ points เก่าของเอกสารใน Qdrant
|
||||
* 4. Upsert points ใหม่เก็บครบ 11 fields
|
||||
*/
|
||||
async embedDocument(
|
||||
pdfPath: string,
|
||||
documentPublicId: string,
|
||||
projectPublicId: string,
|
||||
extractedText?: string
|
||||
documentPublicId: string,
|
||||
correspondenceNumber: string,
|
||||
docType: string,
|
||||
statusCode: string,
|
||||
revisionNumber: number,
|
||||
subject: string,
|
||||
documentDate?: string,
|
||||
ocrText?: string
|
||||
): Promise<EmbeddingResult> {
|
||||
try {
|
||||
// 1. ดึงข้อความจาก PDF (ใช้ extractedText ถ้ามี หรือเรียก OCR)
|
||||
let fullText = extractedText;
|
||||
if (!fullText) {
|
||||
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}`);
|
||||
if (!ocrText || ocrText.trim().length === 0) {
|
||||
this.logger.warn(
|
||||
`No OCR text provided for document ${documentPublicId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
chunksEmbedded: 0,
|
||||
error: 'No text extracted',
|
||||
error: 'No OCR text provided',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Chunk text
|
||||
const chunks = this.chunkText(fullText);
|
||||
const chunks = await this.semanticChunkTextWithFallback(ocrText);
|
||||
this.logger.log(
|
||||
`Document ${documentPublicId} split into ${chunks.length} chunks`
|
||||
);
|
||||
|
||||
// 3. Generate embedding และ upsert ไป Qdrant
|
||||
const points = [];
|
||||
for (const chunk of chunks) {
|
||||
let usedDevice = 'gpu';
|
||||
for (const [idx, chunk] of chunks.entries()) {
|
||||
try {
|
||||
const embedding = await this.ollamaService.generateEmbedding(
|
||||
chunk.text
|
||||
);
|
||||
const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
|
||||
if (embedResult.device === 'cpu') {
|
||||
usedDevice = 'cpu';
|
||||
}
|
||||
points.push({
|
||||
id: `${documentPublicId}-${chunk.chunkIndex}`,
|
||||
vector: embedding,
|
||||
id: `${documentPublicId}-${idx}`,
|
||||
vector: {
|
||||
bge_dense: embedResult.dense,
|
||||
bge_sparse: embedResult.sparse,
|
||||
},
|
||||
payload: {
|
||||
document_public_id: documentPublicId,
|
||||
chunk_index: chunk.chunkIndex,
|
||||
page_number: chunk.pageNumber,
|
||||
doc_public_id: documentPublicId,
|
||||
project_public_id: projectPublicId,
|
||||
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,
|
||||
embedded_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -116,15 +124,19 @@ export class EmbeddingService {
|
||||
error: 'All chunks failed to embed',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Upsert ไป Qdrant พร้อม project isolation
|
||||
await this.qdrantService.deleteByDocumentPublicId(
|
||||
projectPublicId,
|
||||
documentPublicId
|
||||
);
|
||||
await this.qdrantService.upsert(projectPublicId, points);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully embedded ${points.length} chunks for document ${documentPublicId} in project ${projectPublicId}`
|
||||
);
|
||||
|
||||
return { success: true, chunksEmbedded: points.length };
|
||||
return {
|
||||
success: true,
|
||||
chunksEmbedded: points.length,
|
||||
device: usedDevice,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
@@ -135,12 +147,53 @@ export class EmbeddingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text ด้วย overlap
|
||||
* - chunkSize: 512 characters (approximate token equivalent)
|
||||
* - overlap: 64 characters
|
||||
* แบ่งข้อความโดยใช้ typhoon2.5 และ Prompt 'rag_chunking' (T025, T026)
|
||||
* หากล้มเหลวหรือ LLM ไม่ตอบกลับในรูปแบบแท็ก <chunk> ให้ fallback เป็นแบบ fixed-size
|
||||
*/
|
||||
private chunkText(text: string): EmbeddingChunk[] {
|
||||
const chunks: EmbeddingChunk[] = [];
|
||||
private async semanticChunkTextWithFallback(
|
||||
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 textLength = cleanText.length;
|
||||
|
||||
@@ -148,19 +201,35 @@ export class EmbeddingService {
|
||||
let chunkIndex = 0;
|
||||
|
||||
while (startIndex < textLength) {
|
||||
const endIndex = Math.min(startIndex + this.chunkSize, textLength);
|
||||
const endIndex = Math.min(startIndex + chunkSize, textLength);
|
||||
const chunkText = cleanText.substring(startIndex, endIndex);
|
||||
|
||||
chunks.push({
|
||||
chunkIndex,
|
||||
topic: `ส่วนที่ ${chunkIndex + 1}`,
|
||||
text: chunkText,
|
||||
pageNumber: undefined, // TODO: Extract page numbers if available
|
||||
});
|
||||
|
||||
startIndex += this.chunkSize - this.overlap;
|
||||
startIndex += chunkSize - overlap;
|
||||
chunkIndex += 1;
|
||||
}
|
||||
|
||||
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
|
||||
// - 2026-05-15: เพิ่ม OCR auto-detection service สำหรับ ADR-023A.
|
||||
// - 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-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-11: US2 - คำนวณ OCR residency keep_alive แบบ dynamic ตาม VRAM headroom และ active profile
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
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 { OcrCacheService } from './ocr-cache.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 {
|
||||
extractedText?: string;
|
||||
extractedChars?: number;
|
||||
pdfPath?: string;
|
||||
documentPublicId?: string; // เพิ่มเพื่อการทำ audit logs
|
||||
activeProfile?: ExecutionProfile;
|
||||
}
|
||||
|
||||
export interface OcrDetectionResult {
|
||||
@@ -101,6 +106,9 @@ export class OcrService {
|
||||
private readonly threshold: number;
|
||||
private readonly ocrApiUrl: string;
|
||||
private readonly ocrSidecarApiKey: string;
|
||||
private readonly vramHeadroomThresholdMb: number;
|
||||
private readonly ocrResidencyWindowSeconds: number;
|
||||
private readonly mainModelPressureThresholdMb: number;
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRepository(SystemSetting)
|
||||
@@ -109,6 +117,7 @@ export class OcrService {
|
||||
private readonly auditLogRepo: Repository<AiAuditLog>,
|
||||
private readonly ocrCacheService: OcrCacheService,
|
||||
private readonly vramMonitorService: VramMonitorService,
|
||||
private readonly aiPolicyService: AiPolicyService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
this.threshold = this.configService.get<number>('OCR_CHAR_THRESHOLD', 100);
|
||||
@@ -120,6 +129,82 @@ export class OcrService {
|
||||
'OCR_SIDECAR_API_KEY',
|
||||
'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 */
|
||||
@@ -311,7 +396,6 @@ export class OcrService {
|
||||
): Promise<OcrDetectionResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
// 1. ตรวจสอบ VRAM insufficiency guard
|
||||
const hasCapacity = await this.vramMonitorService.hasVramCapacity(
|
||||
TYPHOON_OCR_REQUIRED_VRAM_MB
|
||||
);
|
||||
@@ -321,7 +405,8 @@ export class OcrService {
|
||||
);
|
||||
return this.processWithTesseract(input);
|
||||
}
|
||||
|
||||
const residency = await this.calculateOcrResidency(input.activeProfile);
|
||||
const keepAlive = residency.keepAliveSeconds;
|
||||
this.logger.debug(`Typhoon OCR processing: ${input.pdfPath}`);
|
||||
const fileBuffer = fs.readFileSync(input.pdfPath!);
|
||||
const form = new FormData();
|
||||
@@ -331,6 +416,7 @@ export class OcrService {
|
||||
'upload.pdf'
|
||||
);
|
||||
form.append('engine', 'typhoon-np-dms-ocr');
|
||||
form.append('keep_alive', String(keepAlive));
|
||||
const response = await axios.post<OcrSidecarResponse>(
|
||||
`${this.ocrApiUrl}/ocr-upload`,
|
||||
form,
|
||||
@@ -339,10 +425,8 @@ export class OcrService {
|
||||
headers: { 'X-API-Key': this.ocrSidecarApiKey },
|
||||
}
|
||||
);
|
||||
|
||||
const text = response.data.text ?? '';
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
await this.writeAuditLog({
|
||||
documentPublicId: input.documentPublicId,
|
||||
aiModel: 'typhoon-ocr',
|
||||
@@ -352,7 +436,6 @@ export class OcrService {
|
||||
processingTimeMs: durationMs,
|
||||
cacheHit: false,
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
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()
|
||||
);
|
||||
});
|
||||
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 () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
||||
// - 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-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 { ConfigService } from '@nestjs/config';
|
||||
@@ -14,6 +17,22 @@ export interface OllamaGenerateOptions {
|
||||
signal?: AbortSignal;
|
||||
/** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */
|
||||
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 */
|
||||
@@ -29,7 +48,10 @@ export class OllamaService {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'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>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
@@ -57,7 +79,11 @@ export class OllamaService {
|
||||
{
|
||||
model: options.model ?? this.mainModel,
|
||||
prompt,
|
||||
system: options.system,
|
||||
format: options.format,
|
||||
stream: false,
|
||||
options: options.options,
|
||||
keep_alive: options.keepAlive ?? -1,
|
||||
},
|
||||
{
|
||||
timeout: options.timeoutMs ?? this.timeoutMs,
|
||||
|
||||
@@ -1,133 +1,143 @@
|
||||
// File: src/modules/ai/services/vram-monitor.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-30: Initial implementation สำหรับ Typhoon OCR VRAM monitoring (T006, ADR-032)
|
||||
// File: backend/src/modules/ai/services/vram-monitor.service.ts
|
||||
// Change Log:
|
||||
// - 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 { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import { VramHeadroom } from '../interfaces/execution-policy.interface';
|
||||
|
||||
/** ข้อมูล VRAM จาก Ollama PS API */
|
||||
export interface OllamaModelInfo {
|
||||
name: string;
|
||||
size_vram: number; // bytes
|
||||
}
|
||||
|
||||
/** ผลลัพธ์ VRAM status */
|
||||
/**
|
||||
* ผลลัพธ์ VRAM status สำหรับส่วนบริการภายนอก
|
||||
* ผลลัพธ์นี้มีวัตถุประสงค์เพื่อรักษาความเข้ากันได้ย้อนหลัง (Backward Compatibility)
|
||||
*/
|
||||
export interface VramStatus {
|
||||
totalVramMb: number;
|
||||
usedVramMb: number;
|
||||
freeVramMb: number;
|
||||
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()
|
||||
export class VramMonitorService {
|
||||
private readonly logger = new Logger(VramMonitorService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly totalVramMb: number;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'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
|
||||
* ใช้ Redis cache TTL 10 วินาทีเพื่อลด overhead
|
||||
* ดึงสถานะ VRAM headroom จาก Ollama /api/ps
|
||||
* ถ้าล้มเหลวจะคืนค่าด้วย safe default (available = 0)
|
||||
*/
|
||||
async getVramStatus(minRequiredMb = 4000): Promise<VramStatus> {
|
||||
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> {
|
||||
async getVramHeadroom(): Promise<VramHeadroom> {
|
||||
try {
|
||||
const response = await axios.get<OllamaProcessStatus>(
|
||||
`${this.ollamaUrl}/api/ps`,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
const models = response.data.models ?? [];
|
||||
const loadedModels = models.map((m) => m.name);
|
||||
// คำนวณ VRAM ที่ใช้จาก models ที่โหลดอยู่
|
||||
const usedVramBytes = models.reduce(
|
||||
(sum, m) => sum + (m.size_vram ?? 0),
|
||||
0
|
||||
);
|
||||
const usedVramMb = Math.round(usedVramBytes / 1024 / 1024);
|
||||
// จำกัด VRAM ไม่เกิน limit 90% ของ GPU ทั้งหมด
|
||||
const maxAllowedMb = Math.floor(
|
||||
GPU_TOTAL_VRAM_MB * VRAM_USAGE_LIMIT_PERCENT
|
||||
);
|
||||
const freeVramMb = Math.max(0, maxAllowedMb - usedVramMb);
|
||||
const status: VramStatus = {
|
||||
totalVramMb: GPU_TOTAL_VRAM_MB,
|
||||
usedVramMb,
|
||||
freeVramMb,
|
||||
loadedModels,
|
||||
hasCapacity: freeVramMb >= minRequiredMb,
|
||||
const response = await axios.get<{
|
||||
models?: Array<{
|
||||
name: string;
|
||||
size_vram: number;
|
||||
}>;
|
||||
}>(`${this.ollamaUrl}/api/ps`, { timeout: 3000 });
|
||||
const models = response.data?.models ?? [];
|
||||
let totalUsedBytes = 0;
|
||||
let mainModelUsedBytes = 0;
|
||||
for (const model of models) {
|
||||
totalUsedBytes += model.size_vram || 0;
|
||||
if (
|
||||
model.name.includes('np-dms-ai') ||
|
||||
model.name.includes('typhoon2.5-np-dms')
|
||||
) {
|
||||
mainModelUsedBytes += model.size_vram || 0;
|
||||
}
|
||||
}
|
||||
const usedMb = Math.round(totalUsedBytes / (1024 * 1024));
|
||||
const availableMb = Math.max(0, this.totalVramMb - usedMb);
|
||||
const mainModelVramMb = Math.round(mainModelUsedBytes / (1024 * 1024));
|
||||
return {
|
||||
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) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`VRAM status fetch failed: ${msg} — ใช้ค่า resilient fallback`
|
||||
`Failed to query Ollama /api/ps: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return {
|
||||
totalVramMb: GPU_TOTAL_VRAM_MB,
|
||||
usedVramMb: 0,
|
||||
freeVramMb: GPU_TOTAL_VRAM_MB,
|
||||
loadedModels: [],
|
||||
hasCapacity: true,
|
||||
totalMb: this.totalVramMb,
|
||||
usedMb: this.totalVramMb, // บังคับให้ used = total เพื่อให้ available = 0
|
||||
availableMb: 0,
|
||||
querySuccess: false,
|
||||
mainModelVramMb: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ล้าง VRAM cache (เรียกหลังจาก model unload ด้วย keep_alive=0)
|
||||
* เพื่อให้ status check ครั้งต่อไปดึงข้อมูลใหม่จาก Ollama
|
||||
* ดึงสถานะ VRAM ปัจจุบันของระบบ
|
||||
* เพื่อความเข้ากันได้ย้อนหลังกับ 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> {
|
||||
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 { Correspondence } from './entities/correspondence.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { CorrespondenceRevisionAttachment } from './entities/correspondence-revision-attachment.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AiQueueService } from '../ai/ai-queue.service';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CorrespondenceWorkflowService {
|
||||
@@ -30,7 +33,8 @@ export class CorrespondenceWorkflowService {
|
||||
private readonly recipientRepo: Repository<CorrespondenceRecipient>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly userService: UserService
|
||||
private readonly userService: UserService,
|
||||
private readonly aiQueueService: AiQueueService
|
||||
) {}
|
||||
|
||||
async submitWorkflow(
|
||||
@@ -85,41 +89,67 @@ export class CorrespondenceWorkflowService {
|
||||
{ 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();
|
||||
|
||||
// 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)
|
||||
const corrForNotify = revision.correspondence;
|
||||
if (corrForNotify) {
|
||||
void this.recipientRepo
|
||||
.find({
|
||||
where: {
|
||||
correspondenceId: corrForNotify.id,
|
||||
recipientType: 'TO',
|
||||
},
|
||||
})
|
||||
.then(async (recipients) => {
|
||||
for (const r of recipients) {
|
||||
const targetUserId = await this.userService.findDocControlIdByOrg(
|
||||
r.recipientOrganizationId
|
||||
);
|
||||
if (targetUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: 'New Correspondence Received',
|
||||
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: revision.correspondenceId,
|
||||
link: `/correspondences/${corrForNotify.publicId}`,
|
||||
});
|
||||
try {
|
||||
const corrForNotify = revision.correspondence;
|
||||
if (corrForNotify) {
|
||||
void this.recipientRepo
|
||||
.find({
|
||||
where: {
|
||||
correspondenceId: corrForNotify.id,
|
||||
recipientType: 'TO',
|
||||
},
|
||||
})
|
||||
.then(async (recipients) => {
|
||||
for (const r of recipients) {
|
||||
const targetUserId =
|
||||
await this.userService.findDocControlIdByOrg(
|
||||
r.recipientOrganizationId
|
||||
);
|
||||
if (targetUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: 'New Correspondence Received',
|
||||
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: revision.correspondenceId,
|
||||
link: `/correspondences/${corrForNotify.publicId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(`Submit notification failed: ${err.message}`)
|
||||
);
|
||||
})
|
||||
.catch((err: Error) =>
|
||||
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 {
|
||||
@@ -166,7 +196,8 @@ export class CorrespondenceWorkflowService {
|
||||
private async syncStatus(
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string,
|
||||
queryRunner?: import('typeorm').QueryRunner
|
||||
queryRunner?: import('typeorm').QueryRunner,
|
||||
skipRagPrepare = false
|
||||
) {
|
||||
const statusMap: Record<string, string> = {
|
||||
DRAFT: 'DRAFT',
|
||||
@@ -174,21 +205,95 @@ export class CorrespondenceWorkflowService {
|
||||
APPROVED: 'CLBOWN',
|
||||
REJECTED: 'CCBOWN',
|
||||
};
|
||||
|
||||
const targetCode = statusMap[workflowState] || 'DRAFT';
|
||||
|
||||
const status = await this.statusRepo.findOne({
|
||||
where: { statusCode: targetCode }, // ✅ FIX: CamelCase
|
||||
where: { statusCode: targetCode },
|
||||
});
|
||||
|
||||
if (status) {
|
||||
// ✅ FIX: CamelCase (correspondenceStatusId)
|
||||
revision.statusId = status.id;
|
||||
|
||||
const manager = queryRunner
|
||||
? queryRunner.manager
|
||||
: this.revisionRepo.manager;
|
||||
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 { NotificationModule } from '../notification/notification.module';
|
||||
import { CirculationModule } from '../circulation/circulation.module';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
|
||||
/**
|
||||
* CorrespondenceModule
|
||||
@@ -53,6 +54,7 @@ import { CirculationModule } from '../circulation/circulation.module';
|
||||
FileStorageModule,
|
||||
NotificationModule,
|
||||
CirculationModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [CorrespondenceController],
|
||||
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
|
||||
7 Security
|
||||
```
|
||||
## 💬 Prompt Templates สำหรับถาม Windsurf
|
||||
## 💬 Prompt Templates สำหรับถาม Devin
|
||||
|
||||
### เมื่อต้องการสร้างฟีเจอร์ใหม่
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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/`: กฎพื้นฐานและมาตรฐานการเขียนโค้ด
|
||||
- `dms/`: เฉพาะทางด้านระบบจัดการเอกสาร
|
||||
- `infra/`: งานด้าน Infrastructure และ Network
|
||||
- `codex/`: คำสั่งเฉพาะสำหรับ Windsurf/Codex
|
||||
- `codex/`: คำสั่งเฉพาะสำหรับ Devin/Codex
|
||||
- `templates/`: แม่แบบเอกสารต่างๆ (Spec, Bug Report, etc.)
|
||||
- `playbooks/`: คู่มือขั้นตอนการทำงานที่ซับซ้อน
|
||||
- `checklists/`: รายการตรวจสอบก่อนส่งงานหรือ Deploy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md
|
||||
# Bug Fix Prompt (Windsurf/Codex)
|
||||
# Bug Fix Prompt (Devin/Codex)
|
||||
|
||||
## ⭐ Role: Debugging Specialist
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 3. **MCP Server สำหรับ Windsurf**
|
||||
### 3. **MCP Server สำหรับ Devin**
|
||||
|
||||
เพิ่มใน [.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 พร้อมแล้ว สามารถใช้คำสั่ง:
|
||||
|
||||
@@ -101,7 +101,7 @@ npx playwright test --headed
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
### 8. **ถ้าใช้ MCP ผ่าน Windsurf**
|
||||
### 8. **ถ้าใช้ MCP ผ่าน Devin**
|
||||
|
||||
Cascade จะมี tool ให้ใช้:
|
||||
- `browser_navigate` - เปิด URL
|
||||
|
||||
@@ -16,9 +16,11 @@ export default [
|
||||
'**/tmp/**',
|
||||
'specs/**',
|
||||
'backend/documentation/**',
|
||||
'backend/scratch/**',
|
||||
'backend/scripts/**',
|
||||
'frontend/public/**',
|
||||
'**/test/**',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
...backendConfig.map((config) => ({
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
// - 2026-05-25: เพิ่ม AI Model Management UI สำหรับเลือกโมเดลแบบไดนามิก (ADR-027).
|
||||
// - 2026-05-30: นำเข้าและแสดงผล OcrEngineSelector component ใน Overview tab (T019, T020)
|
||||
// - 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';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -27,12 +28,10 @@ import { projectService } from '@/lib/services/project.service';
|
||||
import {
|
||||
adminAiService,
|
||||
AiSandboxJobResult,
|
||||
AiAvailableModel,
|
||||
AiRagCitation,
|
||||
} from '@/lib/services/admin-ai.service';
|
||||
import { toast } from 'sonner';
|
||||
import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager';
|
||||
import OcrEngineSelector from '@/components/admin/ai/OcrEngineSelector';
|
||||
|
||||
interface SandboxProject {
|
||||
publicId: string;
|
||||
@@ -56,9 +55,16 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
}
|
||||
return value.map((item, index) => {
|
||||
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 {
|
||||
modelId: `${item}-${index}`,
|
||||
modelName: item,
|
||||
modelName: normName,
|
||||
};
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
@@ -68,10 +74,17 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
name?: string;
|
||||
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 {
|
||||
modelId: model.modelId ?? modelName,
|
||||
modelName,
|
||||
modelId: model.modelId ?? rawName,
|
||||
modelName: normName,
|
||||
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() {
|
||||
const { data, isLoading, isError, refetch, isFetching } = useAiStatus();
|
||||
const { data: health, isLoading: isHealthLoading, refetch: refetchHealth } = useAiHealth();
|
||||
@@ -96,16 +116,6 @@ export default function AiAdminConsolePage() {
|
||||
const [sandboxProgress, setSandboxProgress] = useState<number>(0);
|
||||
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)
|
||||
const { data: vramStatus, refetch: refetchVram } = useQuery({
|
||||
queryKey: ['ai-vram-status'],
|
||||
@@ -122,7 +132,13 @@ export default function AiAdminConsolePage() {
|
||||
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 vramLoadedModels = normalizeLoadedModels(vramStatus?.loadedModels);
|
||||
const sandboxProjects = ensureArray<SandboxProject>(projects);
|
||||
@@ -134,44 +150,8 @@ export default function AiAdminConsolePage() {
|
||||
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> => {
|
||||
await Promise.all([refetch(), refetchHealth(), refetchModels(), refetchVram()]);
|
||||
await Promise.all([refetch(), refetchHealth(), refetchVram()]);
|
||||
};
|
||||
|
||||
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">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<ScanText className="h-4 w-4 text-primary" />
|
||||
OCR Sidecar (Tesseract)
|
||||
OCR Sidecar (np-dms-ocr)
|
||||
</CardTitle>
|
||||
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.ocr?.status)}
|
||||
</CardHeader>
|
||||
@@ -476,7 +456,7 @@ export default function AiAdminConsolePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
@@ -493,10 +473,14 @@ export default function AiAdminConsolePage() {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Superadmin ยังสามารถเข้าถึงส่วนทดสอบและดูแลระบบได้ตามสิทธิ์
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1">
|
||||
<span>Active Global Model:</span>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1 flex-wrap">
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -518,114 +502,6 @@ export default function AiAdminConsolePage() {
|
||||
</CardContent>
|
||||
</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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -655,7 +531,7 @@ export default function AiAdminConsolePage() {
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="playground" className="space-y-6">
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
'use client';
|
||||
|
||||
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 { RagSearchBar } from '../../../components/rag/rag-search-bar';
|
||||
import { RagResultCard } from '../../../components/rag/rag-result-card';
|
||||
|
||||
export default function RagPage() {
|
||||
const { selectedProjectId } = useProjectStore();
|
||||
const { mutate, data, isPending, error, isIdle } = useRagQuery();
|
||||
|
||||
const handleSearch = (question: string) => {
|
||||
if (!selectedProjectId) return;
|
||||
mutate({ question, projectPublicId: selectedProjectId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl py-8 space-y-6">
|
||||
@@ -28,25 +20,11 @@ export default function RagPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RagSearchBar onSearch={handleSearch} isLoading={isPending} />
|
||||
|
||||
{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 && (
|
||||
{selectedProjectId ? (
|
||||
<RagChatWidget projectPublicId={selectedProjectId} />
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted-foreground pt-4">
|
||||
พิมพ์คำถามแล้วกด ค้นหา เพื่อรับคำตอบจากเอกสารโครงการ
|
||||
เลือกโครงการก่อนเพื่อเริ่มถามคำถามกับ RAG pipeline ใหม่
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function OcrSandboxPromptManager() {
|
||||
fallbackUsed?: boolean;
|
||||
} | null>(null);
|
||||
const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined);
|
||||
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox } =
|
||||
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox, startPolling } =
|
||||
useSandboxRun(() => {
|
||||
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
|
||||
versionsQuery.refetch();
|
||||
@@ -285,24 +285,8 @@ export default function OcrSandboxPromptManager() {
|
||||
selectedPromptVersion
|
||||
);
|
||||
toast.success('AI Extraction started');
|
||||
// Poll สำหรับผลลัพธ์ AI
|
||||
const pollInterval = setInterval(async () => {
|
||||
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);
|
||||
// เริ่ม polling ผ่าน useSandboxRun hook
|
||||
startPolling(requestPublicId);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || 'AI Extraction failed');
|
||||
@@ -608,7 +592,7 @@ export default function OcrSandboxPromptManager() {
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ocrResult.engineUsed === 'typhoon-np-dms-ocr'
|
||||
? 'Typhoon OCR'
|
||||
? 'np-dms-ocr'
|
||||
: ocrResult.ocrUsed
|
||||
? 'Tesseract'
|
||||
: 'Fast Path (Text Layer)'}
|
||||
@@ -617,7 +601,7 @@ export default function OcrSandboxPromptManager() {
|
||||
<CardContent className="pt-4">
|
||||
{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">
|
||||
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 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>
|
||||
</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 && (
|
||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||
<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