Compare commits
18 Commits
4a808dd9c4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cd7d20ccd4 | |||
| 75d07b5ac9 | |||
| 52b96d01de | |||
| a0f77ad121 | |||
| 16aab2279c | |||
| 15dec6c3fc | |||
| 33c3935164 | |||
| 6bcd1a5c58 | |||
| de4201d7d3 | |||
| e3e0de66e9 | |||
| 866fea7946 | |||
| 85c7415b8a | |||
| ed1b302274 | |||
| 26cc71ce60 | |||
| 285c007dff | |||
| 03aa4efcf0 | |||
| 4f90ed688f | |||
| 548dba6476 |
@@ -1,7 +1,7 @@
|
|||||||
# NAP-DMS Project Context & Rules
|
# NAP-DMS Project Context & Rules
|
||||||
|
|
||||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
- For: Devin Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||||
- Version: 1.9.6 | Last synced from repo: 2026-05-22
|
- Version: 1.9.10 | Last synced from repo: 2026-06-06
|
||||||
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
||||||
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
||||||
|
|
||||||
|
|||||||
@@ -112,3 +112,99 @@
|
|||||||
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
||||||
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
||||||
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 MCP MariaDB Tools
|
||||||
|
|
||||||
|
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
|
||||||
|
|
||||||
|
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
|
||||||
|
- ตรวจสอบ data ใน production/staging
|
||||||
|
- Validate การเปลี่ยนแปลง schema ก่อน deploy
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||||
|
|------|----------|------------------|
|
||||||
|
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
|
||||||
|
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
|
||||||
|
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
|
||||||
|
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
|
||||||
|
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
|
||||||
|
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
|
||||||
|
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
|
||||||
|
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
|
||||||
|
|
||||||
|
### การใช้งานร่วมกับ Development Flow
|
||||||
|
|
||||||
|
**เมื่อเขียน query ใหม่:**
|
||||||
|
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
|
||||||
|
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
|
||||||
|
|
||||||
|
**เมื่อเปลี่ยน schema (ADR-009):**
|
||||||
|
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
|
||||||
|
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
|
||||||
|
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
|
||||||
|
|
||||||
|
**เมื่อ debug ปัญหา database:**
|
||||||
|
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
|
||||||
|
2. เปรียบเทียบกับ spec และ data dictionary
|
||||||
|
3. ตรวจสอบ foreign keys และ constraints
|
||||||
|
|
||||||
|
### ข้อควรระวัง
|
||||||
|
|
||||||
|
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
|
||||||
|
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
|
||||||
|
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
|
||||||
|
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 MCP Memory Tools
|
||||||
|
|
||||||
|
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
|
||||||
|
|
||||||
|
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
|
||||||
|
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
|
||||||
|
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||||
|
|------|----------|------------------|
|
||||||
|
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
|
||||||
|
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
|
||||||
|
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
|
||||||
|
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
|
||||||
|
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
|
||||||
|
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
|
||||||
|
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
|
||||||
|
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
|
||||||
|
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
|
||||||
|
|
||||||
|
### การใช้งานร่วมกับ Development Flow
|
||||||
|
|
||||||
|
**เมื่อบันทึก context ใหม่:**
|
||||||
|
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
|
||||||
|
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
|
||||||
|
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
|
||||||
|
|
||||||
|
**เมื่อค้นหา context:**
|
||||||
|
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
|
||||||
|
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
|
||||||
|
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
|
||||||
|
|
||||||
|
**เมื่อแก้ไข context:**
|
||||||
|
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
|
||||||
|
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
|
||||||
|
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
|
||||||
|
|
||||||
|
### ข้อควรระวัง
|
||||||
|
|
||||||
|
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
|
||||||
|
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
|
||||||
|
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
|
||||||
|
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ n8n (Migration) → DMS API → BullMQ → Admin Desktop (Ollama) → Backend Va
|
|||||||
| **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging |
|
| **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging |
|
||||||
| **BullMQ Queues** | Backend (NestJS) | ai-realtime (RAG/Suggest), ai-batch (OCR/Extract/Embed) |
|
| **BullMQ Queues** | Backend (NestJS) | ai-realtime (RAG/Suggest), ai-batch (OCR/Extract/Embed) |
|
||||||
| **Ollama Engine** | Admin Desktop (Desk-5439) | typhoon2.5-np-dms:latest (Main LLM) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding) |
|
| **Ollama Engine** | Admin Desktop (Desk-5439) | typhoon2.5-np-dms:latest (Main LLM) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding) |
|
||||||
| **OCR Engine** | Admin Desktop (Desk-5439) | PaddleOCR + PyThaiNLP (Thai/English text extraction) |
|
| **OCR Engine** | Admin Desktop (Desk-5439) | Tesseract OCR + Typhoon OCR (via Ollama) + PyThaiNLP (Thai/English text extraction) |
|
||||||
| **Orchestrator** | QNAP NAS (n8n) | Migration Phase orchestrator only (calls DMS API, never Ollama directly) |
|
| **Orchestrator** | QNAP NAS (n8n) | Migration Phase orchestrator only (calls DMS API, never Ollama directly) |
|
||||||
|
|
||||||
## Backend Implementation (NestJS)
|
## Backend Implementation (NestJS)
|
||||||
@@ -118,7 +118,7 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => {
|
|||||||
- **3-Model Config:** typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding)
|
- **3-Model Config:** typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding)
|
||||||
- **PDF 3-Page Limit:** Classification/Tagging uses first 3 pages only (NOT RAG embedding)
|
- **PDF 3-Page Limit:** Classification/Tagging uses first 3 pages only (NOT RAG embedding)
|
||||||
- **RAG Embedding:** Full document chunked at 512 tokens/64 tokens overlap
|
- **RAG Embedding:** Full document chunked at 512 tokens/64 tokens overlap
|
||||||
- **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else PaddleOCR
|
- **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else Tesseract OCR (with Typhoon OCR option)
|
||||||
- **Embed Auto-Trigger:** AUTO after commit (parallel), gap covered by DB search
|
- **Embed Auto-Trigger:** AUTO after commit (parallel), gap covered by DB search
|
||||||
- **Threshold Recalibration:** After 100-500 docs, based on ai_audit_logs analysis
|
- **Threshold Recalibration:** After 100-500 docs, based on ai_audit_logs analysis
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ init_agent_registry() {
|
|||||||
[qwen]="Qwen Code"
|
[qwen]="Qwen Code"
|
||||||
[opencode]="opencode"
|
[opencode]="opencode"
|
||||||
[codex]="Codex CLI"
|
[codex]="Codex CLI"
|
||||||
[windsurf]="Windsurf"
|
[devin]="Devin"
|
||||||
[kilocode]="Kilo Code"
|
[kilocode]="Kilo Code"
|
||||||
[auggie]="Auggie CLI"
|
[auggie]="Auggie CLI"
|
||||||
[roo]="Roo Code"
|
[roo]="Roo Code"
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
#
|
#
|
||||||
# 5. Multi-Agent Support
|
# 5. Multi-Agent Support
|
||||||
# - Handles agent-specific file paths and naming conventions
|
# - Handles agent-specific file paths and naming conventions
|
||||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI
|
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Devin, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI
|
||||||
# - Can update single agents or all existing agent files
|
# - Can update single agents or all existing agent files
|
||||||
# - Creates default Claude file if no agent files exist
|
# - Creates default Claude file if no agent files exist
|
||||||
#
|
#
|
||||||
# Usage: ./update-agent-context.sh [agent_type]
|
# Usage: ./update-agent-context.sh [agent_type]
|
||||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|bob|qoder
|
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|devin|kilocode|auggie|shai|q|bob|qoder
|
||||||
# Leave empty to update all existing agent files
|
# Leave empty to update all existing agent files
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -609,8 +609,8 @@ update_specific_agent() {
|
|||||||
codex)
|
codex)
|
||||||
update_agent_file "$AGENTS_FILE" "Codex CLI"
|
update_agent_file "$AGENTS_FILE" "Codex CLI"
|
||||||
;;
|
;;
|
||||||
windsurf)
|
devin)
|
||||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
update_agent_file "$DEVIN_FILE" "Devin"
|
||||||
;;
|
;;
|
||||||
kilocode)
|
kilocode)
|
||||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||||
@@ -681,8 +681,8 @@ update_all_existing_agents() {
|
|||||||
found_agent=true
|
found_agent=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$WINDSURF_FILE" ]]; then
|
if [[ -f "$DEVIN_FILE" ]]; then
|
||||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
update_agent_file "$DEVIN_FILE" "Devin"
|
||||||
found_agent=true
|
found_agent=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+15
-16
@@ -1,8 +1,8 @@
|
|||||||
# `.agents/skills/` — LCBP3 Agent Skill Pack
|
# `.agents/skills/` — LCBP3 Agent Skill Pack
|
||||||
|
|
||||||
**Version:** 1.9.0 | **Last Updated:** 2026-05-17 | **Total Skills:** 23
|
**Version:** 1.9.0 | **Last Updated:** 2026-06-07 | **Total Skills:** 24
|
||||||
|
|
||||||
Agent skills for AI-assisted development in **Windsurf IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
Agent skills for AI-assisted development in **Devin IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ Agent skills for AI-assisted development in **Windsurf IDE** (and compatible age
|
|||||||
├── skills.md # Overview + dependency matrix + health monitoring
|
├── skills.md # Overview + dependency matrix + health monitoring
|
||||||
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
||||||
├── README.md # (this file)
|
├── README.md # (this file)
|
||||||
|
├── save-memory/ # Session log & project memory update
|
||||||
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
||||||
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
||||||
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
|
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
|
||||||
@@ -30,12 +31,10 @@ Each skill directory contains:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 How Windsurf Invokes These Skills
|
## 🚀 How Devin Invokes These Skills
|
||||||
|
|
||||||
Windsurf exposes two entry points:
|
1. **Skill tool** — Devin discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
||||||
|
2. **Slash commands** — `.devin/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
||||||
1. **Skill tool** — Windsurf discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
|
||||||
2. **Slash commands** — `.windsurf/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
|
||||||
|
|
||||||
Both paths end up executing the same `SKILL.md` instructions.
|
Both paths end up executing the same `SKILL.md` instructions.
|
||||||
|
|
||||||
@@ -65,14 +64,14 @@ Use `/00-speckit.all` to run specify → clarify → plan → tasks → analyze
|
|||||||
|
|
||||||
From repo root:
|
From repo root:
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------------------------------------------- | ----------------------------------------------------------- |
|
| ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||||
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
|
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
|
||||||
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
|
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
|
||||||
| `./.agents/scripts/bash/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` |
|
| `./.agents/scripts/bash/update-agent-context.sh devin` | Append tech entries to `AGENTS.md` |
|
||||||
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
||||||
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
||||||
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.windsurf/workflows/*.md` wrapper |
|
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.devin/workflows/*.md` wrapper |
|
||||||
|
|
||||||
All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows.
|
All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows.
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ To add a new skill:
|
|||||||
|
|
||||||
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
|
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
|
||||||
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
|
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
|
||||||
3. Wrap with `.windsurf/workflows/NAME.md` so it becomes a slash command.
|
3. Wrap with `.devin/workflows/NAME.md` so it becomes a slash command.
|
||||||
4. Update [`skills.md`](./skills.md) dependency matrix.
|
4. Update [`skills.md`](./skills.md) dependency matrix.
|
||||||
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
||||||
|
|
||||||
|
|||||||
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
|
|||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||||
file_id INT,
|
file_id INT,
|
||||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||||
confidence DECIMAL(4,3),
|
confidence DECIMAL(4,3),
|
||||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||||
output_summary JSON,
|
output_summary JSON,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
|
|||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||||
file_id INT,
|
file_id INT,
|
||||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||||
confidence DECIMAL(4,3),
|
confidence DECIMAL(4,3),
|
||||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||||
output_summary JSON,
|
output_summary JSON,
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
name: save-memory
|
||||||
|
description: บันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||||
|
version: 1.9.0
|
||||||
|
scope: project-management
|
||||||
|
depends-on: []
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# บันทึก Memory (Save Memory)
|
||||||
|
|
||||||
|
Skill นี้ใช้สำหรับบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่ที่ reorganization แล้ว
|
||||||
|
|
||||||
|
## โครงสร้าง Memory ใหม่
|
||||||
|
|
||||||
|
```
|
||||||
|
memory/
|
||||||
|
├── README.md (index + overview)
|
||||||
|
├── mcp-tools.md (MCP MariaDB + Memory Tools)
|
||||||
|
└── project-memory-override.md (OS rules, Current Decisions, Environment, Next Session Focus)
|
||||||
|
|
||||||
|
specs/88-logs/
|
||||||
|
├── rollouts.md (Recent rollouts table)
|
||||||
|
└── session-YYYY-MM-DD-[topic].md (Session logs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ขั้นตอนการบันทึก Memory
|
||||||
|
|
||||||
|
### 1. สร้าง Session Log (ถ้ามีงาน session ใหม่)
|
||||||
|
|
||||||
|
เมื่อทำงาน session ใหม่ให้:
|
||||||
|
|
||||||
|
1. **สร้างไฟล์ session log ใหม่** ใน `specs/88-logs/`
|
||||||
|
- ชื่อไฟล์: `session-YYYY-MM-DD-[topic].md`
|
||||||
|
- ตัวอย่าง: `session-2026-06-07-memory-reorganization.md`
|
||||||
|
|
||||||
|
2. **บันทึกเนื้อหาใน session log**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Session [N] — YYYY-MM-DD ([Topic])
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
[สรุปสิ่งที่ทำใน session นี้]
|
||||||
|
|
||||||
|
## ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
[อธิบายปัญหาและสาเหตุ]
|
||||||
|
|
||||||
|
## การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
| -------------- | ---------------------- |
|
||||||
|
| [path/to/file] | [อธิบายการเปลี่ยนแปลง] |
|
||||||
|
|
||||||
|
## กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
[บันทึก pattern หรือ decision ที่ตกลง]
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
[วิธีตรวจสอบว่างานสำเร็จ]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **อัปเดต `specs/88-logs/rollouts.md`**
|
||||||
|
- เพิ่ม entry ใหม่ในตาราง Recent Rollouts
|
||||||
|
- รูปแบบ: `| วันที่ | Version | รายการ | สถานะ |`
|
||||||
|
|
||||||
|
### 2. อัปเดต Project Memory (ถ้ามี decision ใหม่)
|
||||||
|
|
||||||
|
เมื่อมีการตัดสินใจสำคัญใหม่ให้:
|
||||||
|
|
||||||
|
1. **เปิดไฟล์ `memory/project-memory-override.md`**
|
||||||
|
|
||||||
|
2. **อัปเดตตาราง "Current Decisions (Locked)"**
|
||||||
|
- เพิ่ม entry ใหม่ถ้ามี decision ใหม่
|
||||||
|
- รูปแบบ: `| ID | Decision | ADR |`
|
||||||
|
|
||||||
|
3. **อัปเดต "Next Session Focus"**
|
||||||
|
- เพิ่มงานใหม่ถ้ามี
|
||||||
|
- ทำเครื่องหมาย `[ ]` สำหรับงานที่ยังไม่เสร็จ
|
||||||
|
- ทำเครื่องหมาย `[X]` สำหรับงานที่เสร็จแล้ว
|
||||||
|
|
||||||
|
4. **อัปเดต "Environment & Services"** (ถ้ามีการเปลี่ยนแปลง)
|
||||||
|
- อัปเดต URL, port, หรือ notes ถ้ามีการเปลี่ยน infrastructure
|
||||||
|
|
||||||
|
### 3. อัปเดต MCP Tools (ถ้ามี tools ใหม่)
|
||||||
|
|
||||||
|
เมื่อมี MCP tools ใหม่ให้:
|
||||||
|
|
||||||
|
1. **เปิดไฟล์ `memory/mcp-tools.md`**
|
||||||
|
|
||||||
|
2. **เพิ่ม tool ใหม่ในตาราง "Available Tools"**
|
||||||
|
- รูปแบบ: `| Tool | Purpose | Example Usage |`
|
||||||
|
|
||||||
|
3. **เพิ่ม usage example และ warnings** ถ้าจำเป็น
|
||||||
|
|
||||||
|
### 4. อัปเดต Root Documentation (ถ้ามีการเปลี่ยนแปลง)
|
||||||
|
|
||||||
|
เมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อเอกสารระดับ root ให้:
|
||||||
|
|
||||||
|
1. **ARCHITECTURE.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน architecture หลัก
|
||||||
|
- เพิ่ม/ลบ component สำคัญ
|
||||||
|
- เปลี่ยน data flow หรือ integration pattern
|
||||||
|
|
||||||
|
2. **CHANGELOG.md** — อัปเดตเมื่อ:
|
||||||
|
- Deploy version ใหม่
|
||||||
|
- เพิ่ม feature หรือ breaking change สำคัญ
|
||||||
|
- รูปแบบ: `## [version] (YYYY-MM-DD)` → `### feat(scope): description`
|
||||||
|
|
||||||
|
3. **CONTEXT.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน domain terminology หลัก
|
||||||
|
- เพิ่ม concept ใหม่ที่ใช้ทั่ว project
|
||||||
|
- อัปเดต glossary หรือ business rules
|
||||||
|
|
||||||
|
4. **CONTRIBUTING.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน workflow การทำงาน
|
||||||
|
- เพิ่ม/เปลี่ยน coding standards
|
||||||
|
- อัปเดต CI/CD process
|
||||||
|
|
||||||
|
5. **README.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน project structure
|
||||||
|
- เพิ่ม/เปลี่ยน installation steps
|
||||||
|
- อัปเดต feature overview หรือ tech stack
|
||||||
|
|
||||||
|
## Template สำหรับ Session Log
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Session [N] — YYYY-MM-DD ([Topic])
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
[สรุปสิ่งที่ทำใน session นี้ใน 1-2 ประโยค]
|
||||||
|
|
||||||
|
## ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
[อธิบายปัญหาและสาเหตุหลัก]
|
||||||
|
|
||||||
|
## การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
| -------------- | ---------------------- |
|
||||||
|
| `path/to/file` | [อธิบายการเปลี่ยนแปลง] |
|
||||||
|
|
||||||
|
## กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
[บันทึก pattern หรือ decision ที่ตกลงและไม่ควรเปลี่ยน]
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] [check 1]
|
||||||
|
- [ ] [check 2]
|
||||||
|
```
|
||||||
|
|
||||||
|
## ข้อควรระวัง
|
||||||
|
|
||||||
|
- **ห้าม** บันทึก rules ที่ซ้ำกับ specs/ (ADRs, glossary, guidelines)
|
||||||
|
- **ห้าม** บันทึก commands ที่ซ้ำกับ specs/05-Engineering-Guidelines/
|
||||||
|
- **ห้าม** บันทึก environment ที่ซ้ำกับ specs/04-Infrastructure-OPS/
|
||||||
|
- **ใช้** `specs/88-logs/` สำหรับ session history และ rollouts
|
||||||
|
- **ใช้** `memory/project-memory-override.md` สำหรับ OS rules, decisions, environment ที่ไม่มีใน specs
|
||||||
|
- **ใช้** `memory/mcp-tools.md` สำหรับ MCP tools documentation
|
||||||
|
- **อัปเดต Root Documentation** (ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, README.md) เฉพาะเมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อ project architecture, version, terminology, workflow หรือ structure
|
||||||
|
|
||||||
|
## ตัวอย่างการใช้งาน
|
||||||
|
|
||||||
|
### กรณีที่ 1: ทำงาน session ใหม่
|
||||||
|
|
||||||
|
```
|
||||||
|
1. สร้างไฟล์ specs/88-logs/session-2026-06-07-bug-fix.md
|
||||||
|
2. บันทึกปัญหา, การแก้ไข, verification
|
||||||
|
3. อัปเดต specs/88-logs/rollouts.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### กรณีที่ 2: มี decision ใหม่
|
||||||
|
|
||||||
|
```
|
||||||
|
1. เปิด memory/project-memory-override.md
|
||||||
|
2. เพิ่ม entry ใหม่ในตาราง Current Decisions
|
||||||
|
3. อัปเดต Next Session Focus
|
||||||
|
```
|
||||||
|
|
||||||
|
### กรณีที่ 3: เปลี่ยน infrastructure
|
||||||
|
|
||||||
|
```
|
||||||
|
1. เปิด memory/project-memory-override.md
|
||||||
|
2. อัปเดตตาราง Environment & Services
|
||||||
|
3. อัปเดต Key Environment Variables ถ้าจำเป็น
|
||||||
|
```
|
||||||
|
|
||||||
|
### กรณีที่ 4: อัปเดต Root Documentation
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ตรวจสอบว่ามีการเปลี่ยนแปลงที่ส่งผลต่อ ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, หรือ README.md
|
||||||
|
2. อัปเดตไฟล์ที่เกี่ยวข้องตามรูปแบบที่กำหนด
|
||||||
|
3. ตรวจสอบว่าการเปลี่ยนแปลงสอดคล้องกับ specs/ และ ADRs
|
||||||
|
```
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
|
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
|
||||||
|
|
||||||
**Status**: Production Ready | **Last Updated**: 2026-05-17 | **Total Skills**: 23
|
**Status**: Production Ready | **Last Updated**: 2026-06-07 | **Total Skills**: 24
|
||||||
|
|
||||||
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
|
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
| **speckit-status** | None | None | Progress tracking |
|
| **speckit-status** | None | None | Progress tracking |
|
||||||
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
||||||
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
||||||
|
| **save-memory** | None | None | Session log & memory update |
|
||||||
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
||||||
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
||||||
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
|
|
||||||
### Health Metrics
|
### Health Metrics
|
||||||
|
|
||||||
- **Total Skills**: 23 implemented
|
- **Total Skills**: 24 implemented
|
||||||
- **Version Alignment**: v1.9.0 across all skills
|
- **Version Alignment**: v1.9.0 across all skills
|
||||||
- **Template Coverage**: 100% for skills requiring templates
|
- **Template Coverage**: 100% for skills requiring templates
|
||||||
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
|
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: บันทึก session log และอัปเดต project memory
|
||||||
|
---
|
||||||
|
|
||||||
|
# บันทึก Memory
|
||||||
|
|
||||||
|
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skill save-memory
|
||||||
|
```
|
||||||
@@ -4,8 +4,8 @@ trigger: always_on
|
|||||||
|
|
||||||
# NAP-DMS Project Context & Rules
|
# NAP-DMS Project Context & Rules
|
||||||
|
|
||||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
- For: Devin Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||||
- Version: 1.9.6 | Last synced from repo: 2026-05-22
|
- Version: 1.9.10 | Last synced from repo: 2026-06-06
|
||||||
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
||||||
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
||||||
|
|
||||||
|
|||||||
@@ -116,3 +116,99 @@ trigger: always_on
|
|||||||
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
||||||
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
||||||
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 MCP MariaDB Tools
|
||||||
|
|
||||||
|
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
|
||||||
|
|
||||||
|
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
|
||||||
|
- ตรวจสอบ data ใน production/staging
|
||||||
|
- Validate การเปลี่ยนแปลง schema ก่อน deploy
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||||
|
|------|----------|------------------|
|
||||||
|
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
|
||||||
|
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
|
||||||
|
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
|
||||||
|
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
|
||||||
|
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
|
||||||
|
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
|
||||||
|
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
|
||||||
|
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
|
||||||
|
|
||||||
|
### การใช้งานร่วมกับ Development Flow
|
||||||
|
|
||||||
|
**เมื่อเขียน query ใหม่:**
|
||||||
|
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
|
||||||
|
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
|
||||||
|
|
||||||
|
**เมื่อเปลี่ยน schema (ADR-009):**
|
||||||
|
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
|
||||||
|
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
|
||||||
|
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
|
||||||
|
|
||||||
|
**เมื่อ debug ปัญหา database:**
|
||||||
|
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
|
||||||
|
2. เปรียบเทียบกับ spec และ data dictionary
|
||||||
|
3. ตรวจสอบ foreign keys และ constraints
|
||||||
|
|
||||||
|
### ข้อควรระวัง
|
||||||
|
|
||||||
|
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
|
||||||
|
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
|
||||||
|
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
|
||||||
|
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 MCP Memory Tools
|
||||||
|
|
||||||
|
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
|
||||||
|
|
||||||
|
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
|
||||||
|
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
|
||||||
|
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||||
|
|------|----------|------------------|
|
||||||
|
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
|
||||||
|
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
|
||||||
|
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
|
||||||
|
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
|
||||||
|
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
|
||||||
|
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
|
||||||
|
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
|
||||||
|
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
|
||||||
|
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
|
||||||
|
|
||||||
|
### การใช้งานร่วมกับ Development Flow
|
||||||
|
|
||||||
|
**เมื่อบันทึก context ใหม่:**
|
||||||
|
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
|
||||||
|
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
|
||||||
|
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
|
||||||
|
|
||||||
|
**เมื่อค้นหา context:**
|
||||||
|
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
|
||||||
|
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
|
||||||
|
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
|
||||||
|
|
||||||
|
**เมื่อแก้ไข context:**
|
||||||
|
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
|
||||||
|
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
|
||||||
|
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
|
||||||
|
|
||||||
|
### ข้อควรระวัง
|
||||||
|
|
||||||
|
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
|
||||||
|
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
|
||||||
|
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
|
||||||
|
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
|
||||||
|
|||||||
+15
-16
@@ -1,8 +1,8 @@
|
|||||||
# `.agents/skills/` — LCBP3 Agent Skill Pack
|
# `.agents/skills/` — LCBP3 Agent Skill Pack
|
||||||
|
|
||||||
**Version:** 1.9.0 | **Last Updated:** 2026-05-17 | **Total Skills:** 23
|
**Version:** 1.9.0 | **Last Updated:** 2026-06-07 | **Total Skills:** 24
|
||||||
|
|
||||||
Agent skills for AI-assisted development in **Windsurf IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
Agent skills for AI-assisted development in **Devin IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ Agent skills for AI-assisted development in **Windsurf IDE** (and compatible age
|
|||||||
├── skills.md # Overview + dependency matrix + health monitoring
|
├── skills.md # Overview + dependency matrix + health monitoring
|
||||||
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
||||||
├── README.md # (this file)
|
├── README.md # (this file)
|
||||||
|
├── save-memory/ # Session log & project memory update
|
||||||
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
||||||
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
||||||
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
|
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
|
||||||
@@ -30,12 +31,10 @@ Each skill directory contains:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 How Windsurf Invokes These Skills
|
## 🚀 How Devin Invokes These Skills
|
||||||
|
|
||||||
Windsurf exposes two entry points:
|
1. **Skill tool** — Devin discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
||||||
|
2. **Slash commands** — `.devin/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
||||||
1. **Skill tool** — Windsurf discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
|
||||||
2. **Slash commands** — `.windsurf/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
|
||||||
|
|
||||||
Both paths end up executing the same `SKILL.md` instructions.
|
Both paths end up executing the same `SKILL.md` instructions.
|
||||||
|
|
||||||
@@ -65,14 +64,14 @@ Use `/00-speckit.all` to run specify → clarify → plan → tasks → analyze
|
|||||||
|
|
||||||
From repo root:
|
From repo root:
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------------------------------------------- | ----------------------------------------------------------- |
|
| ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||||
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
|
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
|
||||||
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
|
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
|
||||||
| `./.agents/scripts/bash/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` |
|
| `./.agents/scripts/bash/update-agent-context.sh devin` | Append tech entries to `AGENTS.md` |
|
||||||
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
||||||
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
||||||
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.windsurf/workflows/*.md` wrapper |
|
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.devin/workflows/*.md` wrapper |
|
||||||
|
|
||||||
All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows.
|
All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows.
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ To add a new skill:
|
|||||||
|
|
||||||
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
|
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
|
||||||
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
|
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
|
||||||
3. Wrap with `.windsurf/workflows/NAME.md` so it becomes a slash command.
|
3. Wrap with `.devin/workflows/NAME.md` so it becomes a slash command.
|
||||||
4. Update [`skills.md`](./skills.md) dependency matrix.
|
4. Update [`skills.md`](./skills.md) dependency matrix.
|
||||||
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
||||||
|
|
||||||
|
|||||||
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
|
|||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||||
file_id INT,
|
file_id INT,
|
||||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||||
confidence DECIMAL(4,3),
|
confidence DECIMAL(4,3),
|
||||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||||
output_summary JSON,
|
output_summary JSON,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
|
|||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||||
file_id INT,
|
file_id INT,
|
||||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||||
confidence DECIMAL(4,3),
|
confidence DECIMAL(4,3),
|
||||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||||
output_summary JSON,
|
output_summary JSON,
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
name: save-memory
|
||||||
|
description: บันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||||
|
version: 1.9.0
|
||||||
|
scope: project-management
|
||||||
|
depends-on: []
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# บันทึก Memory (Save Memory)
|
||||||
|
|
||||||
|
Skill นี้ใช้สำหรับบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่ที่ reorganization แล้ว
|
||||||
|
|
||||||
|
## โครงสร้าง Memory ใหม่
|
||||||
|
|
||||||
|
```
|
||||||
|
memory/
|
||||||
|
├── README.md (index + overview)
|
||||||
|
├── mcp-tools.md (MCP MariaDB + Memory Tools)
|
||||||
|
└── project-memory-override.md (OS rules, Current Decisions, Environment, Next Session Focus)
|
||||||
|
|
||||||
|
specs/88-logs/
|
||||||
|
├── rollouts.md (Recent rollouts table)
|
||||||
|
└── session-YYYY-MM-DD-[topic].md (Session logs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ขั้นตอนการบันทึก Memory
|
||||||
|
|
||||||
|
### 1. สร้าง Session Log (ถ้ามีงาน session ใหม่)
|
||||||
|
|
||||||
|
เมื่อทำงาน session ใหม่ให้:
|
||||||
|
|
||||||
|
1. **สร้างไฟล์ session log ใหม่** ใน `specs/88-logs/`
|
||||||
|
- ชื่อไฟล์: `session-YYYY-MM-DD-[topic].md`
|
||||||
|
- ตัวอย่าง: `session-2026-06-07-memory-reorganization.md`
|
||||||
|
|
||||||
|
2. **บันทึกเนื้อหาใน session log**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Session [N] — YYYY-MM-DD ([Topic])
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
[สรุปสิ่งที่ทำใน session นี้]
|
||||||
|
|
||||||
|
## ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
[อธิบายปัญหาและสาเหตุ]
|
||||||
|
|
||||||
|
## การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
| -------------- | ---------------------- |
|
||||||
|
| [path/to/file] | [อธิบายการเปลี่ยนแปลง] |
|
||||||
|
|
||||||
|
## กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
[บันทึก pattern หรือ decision ที่ตกลง]
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
[วิธีตรวจสอบว่างานสำเร็จ]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **อัปเดต `specs/88-logs/rollouts.md`**
|
||||||
|
- เพิ่ม entry ใหม่ในตาราง Recent Rollouts
|
||||||
|
- รูปแบบ: `| วันที่ | Version | รายการ | สถานะ |`
|
||||||
|
|
||||||
|
### 2. อัปเดต Project Memory (ถ้ามี decision ใหม่)
|
||||||
|
|
||||||
|
เมื่อมีการตัดสินใจสำคัญใหม่ให้:
|
||||||
|
|
||||||
|
1. **เปิดไฟล์ `memory/project-memory-override.md`**
|
||||||
|
|
||||||
|
2. **อัปเดตตาราง "Current Decisions (Locked)"**
|
||||||
|
- เพิ่ม entry ใหม่ถ้ามี decision ใหม่
|
||||||
|
- รูปแบบ: `| ID | Decision | ADR |`
|
||||||
|
|
||||||
|
3. **อัปเดต "Next Session Focus"**
|
||||||
|
- เพิ่มงานใหม่ถ้ามี
|
||||||
|
- ทำเครื่องหมาย `[ ]` สำหรับงานที่ยังไม่เสร็จ
|
||||||
|
- ทำเครื่องหมาย `[X]` สำหรับงานที่เสร็จแล้ว
|
||||||
|
|
||||||
|
4. **อัปเดต "Environment & Services"** (ถ้ามีการเปลี่ยนแปลง)
|
||||||
|
- อัปเดต URL, port, หรือ notes ถ้ามีการเปลี่ยน infrastructure
|
||||||
|
|
||||||
|
### 3. อัปเดต MCP Tools (ถ้ามี tools ใหม่)
|
||||||
|
|
||||||
|
เมื่อมี MCP tools ใหม่ให้:
|
||||||
|
|
||||||
|
1. **เปิดไฟล์ `memory/mcp-tools.md`**
|
||||||
|
|
||||||
|
2. **เพิ่ม tool ใหม่ในตาราง "Available Tools"**
|
||||||
|
- รูปแบบ: `| Tool | Purpose | Example Usage |`
|
||||||
|
|
||||||
|
3. **เพิ่ม usage example และ warnings** ถ้าจำเป็น
|
||||||
|
|
||||||
|
### 4. อัปเดต Root Documentation (ถ้ามีการเปลี่ยนแปลง)
|
||||||
|
|
||||||
|
เมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อเอกสารระดับ root ให้:
|
||||||
|
|
||||||
|
1. **ARCHITECTURE.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน architecture หลัก
|
||||||
|
- เพิ่ม/ลบ component สำคัญ
|
||||||
|
- เปลี่ยน data flow หรือ integration pattern
|
||||||
|
|
||||||
|
2. **CHANGELOG.md** — อัปเดตเมื่อ:
|
||||||
|
- Deploy version ใหม่
|
||||||
|
- เพิ่ม feature หรือ breaking change สำคัญ
|
||||||
|
- รูปแบบ: `## [version] (YYYY-MM-DD)` → `### feat(scope): description`
|
||||||
|
|
||||||
|
3. **CONTEXT.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน domain terminology หลัก
|
||||||
|
- เพิ่ม concept ใหม่ที่ใช้ทั่ว project
|
||||||
|
- อัปเดต glossary หรือ business rules
|
||||||
|
|
||||||
|
4. **CONTRIBUTING.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน workflow การทำงาน
|
||||||
|
- เพิ่ม/เปลี่ยน coding standards
|
||||||
|
- อัปเดต CI/CD process
|
||||||
|
|
||||||
|
5. **README.md** — อัปเดตเมื่อ:
|
||||||
|
- เปลี่ยน project structure
|
||||||
|
- เพิ่ม/เปลี่ยน installation steps
|
||||||
|
- อัปเดต feature overview หรือ tech stack
|
||||||
|
|
||||||
|
## Template สำหรับ Session Log
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Session [N] — YYYY-MM-DD ([Topic])
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
[สรุปสิ่งที่ทำใน session นี้ใน 1-2 ประโยค]
|
||||||
|
|
||||||
|
## ปัญหาที่พบ (Root Cause)
|
||||||
|
|
||||||
|
[อธิบายปัญหาและสาเหตุหลัก]
|
||||||
|
|
||||||
|
## การแก้ไข (Fix)
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
| -------------- | ---------------------- |
|
||||||
|
| `path/to/file` | [อธิบายการเปลี่ยนแปลง] |
|
||||||
|
|
||||||
|
## กฎที่ Lock แล้ว
|
||||||
|
|
||||||
|
[บันทึก pattern หรือ decision ที่ตกลงและไม่ควรเปลี่ยน]
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] [check 1]
|
||||||
|
- [ ] [check 2]
|
||||||
|
```
|
||||||
|
|
||||||
|
## ข้อควรระวัง
|
||||||
|
|
||||||
|
- **ห้าม** บันทึก rules ที่ซ้ำกับ specs/ (ADRs, glossary, guidelines)
|
||||||
|
- **ห้าม** บันทึก commands ที่ซ้ำกับ specs/05-Engineering-Guidelines/
|
||||||
|
- **ห้าม** บันทึก environment ที่ซ้ำกับ specs/04-Infrastructure-OPS/
|
||||||
|
- **ใช้** `specs/88-logs/` สำหรับ session history และ rollouts
|
||||||
|
- **ใช้** `memory/project-memory-override.md` สำหรับ OS rules, decisions, environment ที่ไม่มีใน specs
|
||||||
|
- **ใช้** `memory/mcp-tools.md` สำหรับ MCP tools documentation
|
||||||
|
- **อัปเดต Root Documentation** (ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, README.md) เฉพาะเมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อ project architecture, version, terminology, workflow หรือ structure
|
||||||
|
|
||||||
|
## ตัวอย่างการใช้งาน
|
||||||
|
|
||||||
|
### กรณีที่ 1: ทำงาน session ใหม่
|
||||||
|
|
||||||
|
```
|
||||||
|
1. สร้างไฟล์ specs/88-logs/session-2026-06-07-bug-fix.md
|
||||||
|
2. บันทึกปัญหา, การแก้ไข, verification
|
||||||
|
3. อัปเดต specs/88-logs/rollouts.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### กรณีที่ 2: มี decision ใหม่
|
||||||
|
|
||||||
|
```
|
||||||
|
1. เปิด memory/project-memory-override.md
|
||||||
|
2. เพิ่ม entry ใหม่ในตาราง Current Decisions
|
||||||
|
3. อัปเดต Next Session Focus
|
||||||
|
```
|
||||||
|
|
||||||
|
### กรณีที่ 3: เปลี่ยน infrastructure
|
||||||
|
|
||||||
|
```
|
||||||
|
1. เปิด memory/project-memory-override.md
|
||||||
|
2. อัปเดตตาราง Environment & Services
|
||||||
|
3. อัปเดต Key Environment Variables ถ้าจำเป็น
|
||||||
|
```
|
||||||
|
|
||||||
|
### กรณีที่ 4: อัปเดต Root Documentation
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ตรวจสอบว่ามีการเปลี่ยนแปลงที่ส่งผลต่อ ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, หรือ README.md
|
||||||
|
2. อัปเดตไฟล์ที่เกี่ยวข้องตามรูปแบบที่กำหนด
|
||||||
|
3. ตรวจสอบว่าการเปลี่ยนแปลงสอดคล้องกับ specs/ และ ADRs
|
||||||
|
```
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
|
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
|
||||||
|
|
||||||
**Status**: Production Ready | **Last Updated**: 2026-05-17 | **Total Skills**: 23
|
**Status**: Production Ready | **Last Updated**: 2026-06-07 | **Total Skills**: 24
|
||||||
|
|
||||||
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
|
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
| **speckit-status** | None | None | Progress tracking |
|
| **speckit-status** | None | None | Progress tracking |
|
||||||
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
||||||
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
||||||
|
| **save-memory** | None | None | Session log & memory update |
|
||||||
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
||||||
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
||||||
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
|
|
||||||
### Health Metrics
|
### Health Metrics
|
||||||
|
|
||||||
- **Total Skills**: 23 implemented
|
- **Total Skills**: 24 implemented
|
||||||
- **Version Alignment**: v1.9.0 across all skills
|
- **Version Alignment**: v1.9.0 across all skills
|
||||||
- **Template Coverage**: 100% for skills requiring templates
|
- **Template Coverage**: 100% for skills requiring templates
|
||||||
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
|
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: บันทึก session log และอัปเดต project memory
|
||||||
|
---
|
||||||
|
|
||||||
|
# บันทึก Memory
|
||||||
|
|
||||||
|
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skill save-memory
|
||||||
|
```
|
||||||
@@ -68,36 +68,36 @@ $script:NEW_FRAMEWORK = ''
|
|||||||
$script:NEW_DB = ''
|
$script:NEW_DB = ''
|
||||||
$script:NEW_PROJECT_TYPE = ''
|
$script:NEW_PROJECT_TYPE = ''
|
||||||
|
|
||||||
function Write-Info {
|
function Write-Info {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$Message
|
[string]$Message
|
||||||
)
|
)
|
||||||
Write-Host "INFO: $Message"
|
Write-Host "INFO: $Message"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-Success {
|
function Write-Success {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$Message
|
[string]$Message
|
||||||
)
|
)
|
||||||
Write-Host "$([char]0x2713) $Message"
|
Write-Host "$([char]0x2713) $Message"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-WarningMsg {
|
function Write-WarningMsg {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$Message
|
[string]$Message
|
||||||
)
|
)
|
||||||
Write-Warning $Message
|
Write-Warning $Message
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-Err {
|
function Write-Err {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$Message
|
[string]$Message
|
||||||
)
|
)
|
||||||
Write-Host "ERROR: $Message" -ForegroundColor Red
|
Write-Host "ERROR: $Message" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
function Validate-Environment {
|
function Validate-Environment {
|
||||||
@@ -130,7 +130,7 @@ function Extract-PlanField {
|
|||||||
# Lines like **Language/Version**: Python 3.12
|
# Lines like **Language/Version**: Python 3.12
|
||||||
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$"
|
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$"
|
||||||
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {
|
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {
|
||||||
if ($_ -match $regex) {
|
if ($_ -match $regex) {
|
||||||
$val = $Matches[1].Trim()
|
$val = $Matches[1].Trim()
|
||||||
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }
|
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }
|
||||||
}
|
}
|
||||||
@@ -170,15 +170,15 @@ function Format-TechnologyStack {
|
|||||||
return ($parts -join ' + ')
|
return ($parts -join ' + ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ProjectStructure {
|
function Get-ProjectStructure {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$ProjectType
|
[string]$ProjectType
|
||||||
)
|
)
|
||||||
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
|
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-CommandsForLanguage {
|
function Get-CommandsForLanguage {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$Lang
|
[string]$Lang
|
||||||
@@ -191,12 +191,12 @@ function Get-CommandsForLanguage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-LanguageConventions {
|
function Get-LanguageConventions {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$Lang
|
[string]$Lang
|
||||||
)
|
)
|
||||||
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
|
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function New-AgentFile {
|
function New-AgentFile {
|
||||||
@@ -223,7 +223,7 @@ function New-AgentFile {
|
|||||||
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8
|
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8
|
||||||
$content = $content -replace '\[PROJECT NAME\]',$ProjectName
|
$content = $content -replace '\[PROJECT NAME\]',$ProjectName
|
||||||
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd')
|
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd')
|
||||||
|
|
||||||
# Build the technology stack string safely
|
# Build the technology stack string safely
|
||||||
$techStackForTemplate = ""
|
$techStackForTemplate = ""
|
||||||
if ($escaped_lang -and $escaped_framework) {
|
if ($escaped_lang -and $escaped_framework) {
|
||||||
@@ -233,7 +233,7 @@ function New-AgentFile {
|
|||||||
} elseif ($escaped_framework) {
|
} elseif ($escaped_framework) {
|
||||||
$techStackForTemplate = "- $escaped_framework ($escaped_branch)"
|
$techStackForTemplate = "- $escaped_framework ($escaped_branch)"
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate
|
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate
|
||||||
# For project structure we manually embed (keep newlines)
|
# For project structure we manually embed (keep newlines)
|
||||||
$escapedStructure = [Regex]::Escape($projectStructure)
|
$escapedStructure = [Regex]::Escape($projectStructure)
|
||||||
@@ -241,7 +241,7 @@ function New-AgentFile {
|
|||||||
# Replace escaped newlines placeholder after all replacements
|
# Replace escaped newlines placeholder after all replacements
|
||||||
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands
|
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands
|
||||||
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions
|
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions
|
||||||
|
|
||||||
# Build the recent changes string safely
|
# Build the recent changes string safely
|
||||||
$recentChangesForTemplate = ""
|
$recentChangesForTemplate = ""
|
||||||
if ($escaped_lang -and $escaped_framework) {
|
if ($escaped_lang -and $escaped_framework) {
|
||||||
@@ -251,7 +251,7 @@ function New-AgentFile {
|
|||||||
} elseif ($escaped_framework) {
|
} elseif ($escaped_framework) {
|
||||||
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}"
|
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}"
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate
|
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate
|
||||||
# Convert literal \n sequences introduced by Escape to real newlines
|
# Convert literal \n sequences introduced by Escape to real newlines
|
||||||
$content = $content -replace '\\n',[Environment]::NewLine
|
$content = $content -replace '\\n',[Environment]::NewLine
|
||||||
@@ -276,14 +276,14 @@ function Update-ExistingAgentFile {
|
|||||||
$newTechEntries = @()
|
$newTechEntries = @()
|
||||||
if ($techStack) {
|
if ($techStack) {
|
||||||
$escapedTechStack = [Regex]::Escape($techStack)
|
$escapedTechStack = [Regex]::Escape($techStack)
|
||||||
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
|
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
|
||||||
$newTechEntries += "- $techStack ($CURRENT_BRANCH)"
|
$newTechEntries += "- $techStack ($CURRENT_BRANCH)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {
|
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {
|
||||||
$escapedDB = [Regex]::Escape($NEW_DB)
|
$escapedDB = [Regex]::Escape($NEW_DB)
|
||||||
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
|
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
|
||||||
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
|
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$newChangeEntry = ''
|
$newChangeEntry = ''
|
||||||
@@ -377,7 +377,7 @@ function Update-SpecificAgent {
|
|||||||
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
|
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
|
||||||
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
||||||
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
|
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
|
||||||
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
|
'devin' { Update-AgentFile -TargetFile $DEVIN_FILE -AgentName 'Devin' }
|
||||||
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
|
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
|
||||||
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
||||||
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
||||||
@@ -386,7 +386,7 @@ function Update-SpecificAgent {
|
|||||||
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
|
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
|
||||||
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
|
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
|
||||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false }
|
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|devin|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ function Update-AllExistingAgents {
|
|||||||
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
|
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
|
if (Test-Path $DEVIN_FILE) { if (-not (Update-AgentFile -TargetFile $DEVIN_FILE -AgentName 'Devin')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
||||||
|
|||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# File: .vscode/setup-terminal.ps1
|
||||||
|
# Change Log:
|
||||||
|
# - 2026-06-07: Initial creation - bypass PSReadline history restoration
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$TargetPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# Disable PSReadline history for this session
|
||||||
|
Set-PSReadlineOption -HistorySaveStyle SaveNothing
|
||||||
|
|
||||||
|
# Change to target directory
|
||||||
|
Set-Location $TargetPath
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# NAP-DMS Project Context & Rules
|
# NAP-DMS Project Context & Rules
|
||||||
|
|
||||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||||
- Version: 1.9.9 | Last synced from repo: 2026-06-03
|
- Version: 1.9.10 | Last synced from repo: 2026-06-06
|
||||||
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
||||||
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
||||||
|
|
||||||
@@ -137,8 +137,8 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
|
|||||||
| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work |
|
| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work |
|
||||||
| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments |
|
| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments |
|
||||||
| **ADR-023 AI Architecture** | `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` | ✅ Active | Unified AI boundaries and pipeline (base architecture) |
|
| **ADR-023 AI Architecture** | `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` | ✅ Active | Unified AI boundaries and pipeline (base architecture) |
|
||||||
| **ADR-023A AI Model Rev.** | `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` | ✅ Active | 2-queue, RAG embed scope, OCR auto-detect (model stack superseded by ADR-034) |
|
| **ADR-023A AI Model Rev.** | `specs/06-Decision-Records/ADR-023A-unified-ai-architecture.md` | ✅ Active | 2-queue, RAG embed scope, OCR auto-detect (model stack superseded by ADR-034) |
|
||||||
| **ADR-034 Thai Model Stack** | `specs/06-Decision-Records/ADR-034-AI-model-change.md` | ✅ Active | typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) |
|
| **ADR-034 Thai Model Stack** | `specs/06-Decision-Records/ADR-034-AI-model-change.md` | ✅ Active | typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) |
|
||||||
| **ADR-024 Intent Class.** | `specs/06-Decision-Records/ADR-024-intent-classification-strategy.md` | ✅ Active | Hybrid Pattern→LLM Fallback; ai_intent_patterns DB; Redis cache 5 min |
|
| **ADR-024 Intent Class.** | `specs/06-Decision-Records/ADR-024-intent-classification-strategy.md` | ✅ Active | Hybrid Pattern→LLM Fallback; ai_intent_patterns DB; Redis cache 5 min |
|
||||||
| **ADR-025 AI Tool Layer** | `specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md` | ✅ Active | Server-side Tool dispatch; CASL-guarded bridge; ToolResult uses publicId only |
|
| **ADR-025 AI Tool Layer** | `specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md` | ✅ Active | Server-side Tool dispatch; CASL-guarded bridge; ToolResult uses publicId only |
|
||||||
| **ADR-026 Chat UI** | `specs/06-Decision-Records/ADR-026-document-chat-ui-pattern.md` | ✅ Active | Side-panel Document Chat UI; useAiChat() hook; streaming response support |
|
| **ADR-026 Chat UI** | `specs/06-Decision-Records/ADR-026-document-chat-ui-pattern.md` | ✅ Active | Side-panel Document Chat UI; useAiChat() hook; streaming response support |
|
||||||
@@ -459,40 +459,40 @@ Full glossary: `specs/00-overview/00-02-glossary.md`
|
|||||||
|
|
||||||
When user asks about... check these files:
|
When user asks about... check these files:
|
||||||
|
|
||||||
| Request | Status | Files to Check | Expected Response |
|
| Request | Status | Files to Check | Expected Response |
|
||||||
| --------------------------- | ------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
| ------------------------------ | ------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
| "สร้าง API ใหม่" | ✅ | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard |
|
| "สร้าง API ใหม่" | ✅ | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard |
|
||||||
| "แก้ฟอร์ม frontend" | ✅ | `05-03-frontend-guidelines.md`, `01-06-edge-cases-and-rules.md` | RHF+Zod + TanStack Query + Thai comments |
|
| "แก้ฟอร์ม frontend" | ✅ | `05-03-frontend-guidelines.md`, `01-06-edge-cases-and-rules.md` | RHF+Zod + TanStack Query + Thai comments |
|
||||||
| "เพิ่ม field ใหม่" | ✅ | `ADR-009`, `03-01-data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity |
|
| "เพิ่ม field ใหม่" | ✅ | `ADR-009`, `03-01-data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity |
|
||||||
| "ตรวจสอบ UUID" | ✅ | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor |
|
| "ตรวจสอบ UUID" | ✅ | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor |
|
||||||
| "สร้าง migration" | ✅ | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow |
|
| "สร้าง migration" | ✅ | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow |
|
||||||
| "ตรวจสอบ permission" | ✅ | `lcbp3-v1.9.0-seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix |
|
| "ตรวจสอบ permission" | ✅ | `lcbp3-v1.9.0-seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix |
|
||||||
| "deploy production" | ✅ | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy |
|
| "deploy production" | ✅ | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy |
|
||||||
| "เพิ่ม test" | ✅ | `05-04-testing-strategy.md` | Coverage goals + test patterns |
|
| "เพิ่ม test" | ✅ | `05-04-testing-strategy.md` | Coverage goals + test patterns |
|
||||||
| "AI integration" | ✅ | `ADR-023`, `ADR-023A`, `ADR-024`, `ADR-025` | AI boundary + 2-model stack + BullMQ queue policy + Intent/Tool Layer |
|
| "AI integration" | ✅ | `ADR-023`, `ADR-023A`, `ADR-024`, `ADR-025` | AI boundary + 2-model stack + BullMQ queue policy + Intent/Tool Layer |
|
||||||
| "Error handling" | ✅ | `ADR-007` | Layered error classification + recovery |
|
| "Error handling" | ✅ | `ADR-007` | Layered error classification + recovery |
|
||||||
| "File upload" | ✅ | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist |
|
| "File upload" | ✅ | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist |
|
||||||
| "Notifications / Queue" | ✅ | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter |
|
| "Notifications / Queue" | ✅ | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter |
|
||||||
| "Add i18n / translate" | ✅ | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text |
|
| "Add i18n / translate" | ✅ | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text |
|
||||||
| "Workflow / DSL" | ✅ | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService |
|
| "Workflow / DSL" | ✅ | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService |
|
||||||
| "Document numbering" | ✅ | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) |
|
| "Document numbering" | ✅ | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) |
|
||||||
| "ตรวจสอบ Workflow" | ✅ | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร |
|
| "ตรวจสอบ Workflow" | ✅ | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร |
|
||||||
| "Transmittal submit" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | submit() with EC-RFA-004 validation |
|
| "Transmittal submit" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | submit() with EC-RFA-004 validation |
|
||||||
| "Circulation reassign" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | reassignRouting() with EC-CIRC-001 |
|
| "Circulation reassign" | 📋 | `ADR-021`, `specs/200-fullstacks/201-transmittals-circulation/` | reassignRouting() with EC-CIRC-001 |
|
||||||
| "สร้าง workflow ใหม่" | 📋 | `ADR-001`, `ADR-021`, `specs/200-fullstacks/203-unified-workflow-engine/` | DSL workflow definition + WorkflowEngineService setup |
|
| "สร้าง workflow ใหม่" | 📋 | `ADR-001`, `ADR-021`, `specs/200-fullstacks/203-unified-workflow-engine/` | DSL workflow definition + WorkflowEngineService setup |
|
||||||
| "ตรวจสอบ AI boundary" | ✅ | `ADR-023`, `ADR-023A` | Verify Ollama isolation + BullMQ queues + Qdrant projectPublicId filter |
|
| "ตรวจสอบ AI boundary" | ✅ | `ADR-023`, `ADR-023A` | Verify Ollama isolation + BullMQ queues + Qdrant projectPublicId filter |
|
||||||
| "Intent classification" | ✅ | `ADR-024`, `specs/200-fullstacks/224-intent-classification/` | Pattern Layer → LLM Fallback; ai_intent_patterns; Redis cache 5 min |
|
| "Intent classification" | ✅ | `ADR-024`, `specs/200-fullstacks/224-intent-classification/` | Pattern Layer → LLM Fallback; ai_intent_patterns; Redis cache 5 min |
|
||||||
| "AI Tool Layer" | ✅ | `ADR-025`, `specs/200-fullstacks/225-ai-tool-layer-architecture/` | Tool Registry; CASL-guarded dispatch; ToolResult publicId only |
|
| "AI Tool Layer" | ✅ | `ADR-025`, `specs/200-fullstacks/225-ai-tool-layer-architecture/` | Tool Registry; CASL-guarded dispatch; ToolResult publicId only |
|
||||||
| "Document Chat UI" | ✅ | `ADR-026`, `specs/200-fullstacks/226-document-chat-ui-pattern/` | Side-panel; useAiChat() hook; streaming SSE; TanStack Query cache |
|
| "Document Chat UI" | ✅ | `ADR-026`, `specs/200-fullstacks/226-document-chat-ui-pattern/` | Side-panel; useAiChat() hook; streaming SSE; TanStack Query cache |
|
||||||
| "AI Admin Console" | ✅ | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints |
|
| "AI Admin Console" | ✅ | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints |
|
||||||
| "Migration refactor" | ✅ | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates |
|
| "Migration refactor" | ✅ | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates |
|
||||||
| "Dynamic Prompt / Prompt" | ✅ | `ADR-029`, `specs/06-Decision-Records/ADR-029-dynamic-prompt-management.md` | ai_prompts table; Redis cache `ai:prompt:active:{type}` TTL 60s |
|
| "Dynamic Prompt / Prompt" | ✅ | `ADR-029`, `specs/06-Decision-Records/ADR-029-dynamic-prompt-management.md` | ai_prompts table; Redis cache `ai:prompt:active:{type}` TTL 60s |
|
||||||
| "AI Model / OCR Active Switch"| ✅ | `ADR-032`, `ADR-033`, `specs/200-fullstacks/233-ai-model-ocr-runner-management/` | Synchronous LLM switches, VRAM Release, sidecar API Key protection |
|
| "AI Model / OCR Active Switch" | ✅ | `ADR-032`, `ADR-033`, `specs/200-fullstacks/233-ai-model-ocr-runner-management/` | Synchronous LLM switches, VRAM Release, sidecar API Key protection |
|
||||||
| "จัดการ document numbering" | ✅ | `ADR-002`, `specs/03-Data-and-Storage/03-04-document-numbering.md` | Redis Redlock + template system + preview/override workflows |
|
| "จัดการ document numbering" | ✅ | `ADR-002`, `specs/03-Data-and-Storage/03-04-document-numbering.md` | Redis Redlock + template system + preview/override workflows |
|
||||||
| "Audit ความปลอดภัย" | ✅ | `ADR-016`, `ADR-019`, `ADR-023`, `ADR-023A` | ตรวจสอบ UUID pattern, CASL Guard, AI Boundary และ Qdrant multi-tenancy |
|
| "Audit ความปลอดภัย" | ✅ | `ADR-016`, `ADR-019`, `ADR-023`, `ADR-023A` | ตรวจสอบ UUID pattern, CASL Guard, AI Boundary และ Qdrant multi-tenancy |
|
||||||
| "แก้ bug / bugfix" | ✅ | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
| "แก้ bug / bugfix" | ✅ | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
||||||
| "ตรวจแอปจริง" | ✅ | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
| "ตรวจแอปจริง" | ✅ | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
||||||
| "งานค้าง / resume" | ✅ | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
| "งานค้าง / resume" | ✅ | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
||||||
|
|
||||||
**Status Legend:**
|
**Status Legend:**
|
||||||
|
|
||||||
@@ -501,6 +501,110 @@ When user asks about... check these files:
|
|||||||
- 🔄 In development
|
- 🔄 In development
|
||||||
- ❌ Not yet started
|
- ❌ Not yet started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 MCP MariaDB Tools
|
||||||
|
|
||||||
|
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
|
||||||
|
|
||||||
|
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
|
||||||
|
- ตรวจสอบ data ใน production/staging
|
||||||
|
- Validate การเปลี่ยนแปลง schema ก่อน deploy
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||||
|
| ---------------------------- | ------------------------------ | -------------------------------------------------- |
|
||||||
|
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
|
||||||
|
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
|
||||||
|
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
|
||||||
|
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
|
||||||
|
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
|
||||||
|
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
|
||||||
|
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
|
||||||
|
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
|
||||||
|
|
||||||
|
### การใช้งานร่วมกับ Development Flow
|
||||||
|
|
||||||
|
**เมื่อเขียน query ใหม่:**
|
||||||
|
|
||||||
|
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
|
||||||
|
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
|
||||||
|
|
||||||
|
**เมื่อเปลี่ยน schema (ADR-009):**
|
||||||
|
|
||||||
|
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
|
||||||
|
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
|
||||||
|
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
|
||||||
|
|
||||||
|
**เมื่อ debug ปัญหา database:**
|
||||||
|
|
||||||
|
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
|
||||||
|
2. เปรียบเทียบกับ spec และ data dictionary
|
||||||
|
3. ตรวจสอบ foreign keys และ constraints
|
||||||
|
|
||||||
|
### ข้อควรระวัง
|
||||||
|
|
||||||
|
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
|
||||||
|
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
|
||||||
|
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
|
||||||
|
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 MCP Memory Tools
|
||||||
|
|
||||||
|
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
|
||||||
|
|
||||||
|
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
|
||||||
|
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
|
||||||
|
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||||
|
| -------------------------- | -------------------------------------------- | -------------------------------------------- |
|
||||||
|
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
|
||||||
|
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
|
||||||
|
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
|
||||||
|
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
|
||||||
|
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
|
||||||
|
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
|
||||||
|
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
|
||||||
|
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
|
||||||
|
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
|
||||||
|
|
||||||
|
### การใช้งานร่วมกับ Development Flow
|
||||||
|
|
||||||
|
**เมื่อบันทึก context ใหม่:**
|
||||||
|
|
||||||
|
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
|
||||||
|
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
|
||||||
|
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
|
||||||
|
|
||||||
|
**เมื่อค้นหา context:**
|
||||||
|
|
||||||
|
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
|
||||||
|
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
|
||||||
|
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
|
||||||
|
|
||||||
|
**เมื่อแก้ไข context:**
|
||||||
|
|
||||||
|
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
|
||||||
|
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
|
||||||
|
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
|
||||||
|
|
||||||
|
### ข้อควรระวัง
|
||||||
|
|
||||||
|
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
|
||||||
|
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
|
||||||
|
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
|
||||||
|
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🛠️ Final Checklists
|
## 🛠️ Final Checklists
|
||||||
|
|
||||||
### 🔴 Tier 1 — CRITICAL (CI BLOCKER)
|
### 🔴 Tier 1 — CRITICAL (CI BLOCKER)
|
||||||
@@ -611,29 +715,30 @@ This file is a **quick reference**. For detailed information:
|
|||||||
|
|
||||||
## 🔄 Change Log
|
## 🔄 Change Log
|
||||||
|
|
||||||
| Version | Date | Changes | Updated By |
|
| Version | Date | Changes | Updated By |
|
||||||
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
|
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
||||||
| 1.9.9 | 2026-06-03 | ADR-034 Thai-Optimized AI Model Stack: typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR); model switching in ai-batch processor; AiSettingsService static constants; SQL delta; updated Key Spec Files + AI isolation rule | Windsurf AI |
|
| 1.9.10 | 2026-06-06 | Added MCP MariaDB Tools section with available tools (test_connection, show_databases, show_tables, describe_table, query, insert, update, delete), usage guidelines for development flow, and safety warnings for DDL operations; Added MCP Memory Tools section with Knowledge Graph management tools (create_entities, create_relations, add_observations, delete_entities, delete_relations, delete_observations, open_nodes, read_graph, search_nodes) for long-term context storage | Windsurf AI |
|
||||||
| 1.9.8 | 2026-06-02 | Added ADR-033 Active Model & OCR Runner Management; implemented Synchronous LLM switches, GPU Memory Auto-release, sidecar `X-API-Key` headers protection; updated Key Spec Files & Specialized Work AI runtime sections | Windsurf AI |
|
| 1.9.9 | 2026-06-03 | ADR-034 Thai-Optimized AI Model Stack: typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR); model switching in ai-batch processor; AiSettingsService static constants; SQL delta; updated Key Spec Files + AI isolation rule | Windsurf AI |
|
||||||
| 1.9.7 | 2026-05-25 | Added ADR-029 Dynamic Prompt Management to Key Spec Files table; fixed gemma4 model name e2b→e4b Q8_0; added Dynamic Prompt context trigger; added ADR-029 to Tier 3 AI checklist; bumped last synced date | Windsurf AI |
|
| 1.9.8 | 2026-06-02 | Added ADR-033 Active Model & OCR Runner Management; implemented Synchronous LLM switches, GPU Memory Auto-release, sidecar `X-API-Key` headers protection; updated Key Spec Files & Specialized Work AI runtime sections | Windsurf AI |
|
||||||
| 1.9.6 | 2026-05-22 | Added ADR-024/025/026/027/028 to Key Spec Files table; Tier 3 expanded with AI Runtime Layer + Migration Pipeline tiers; Specialized Work section updated with ADR-024~028 patterns; 6 new Context-Aware Triggers; bumped Last synced date | Windsurf AI |
|
| 1.9.7 | 2026-05-25 | Added ADR-029 Dynamic Prompt Management to Key Spec Files table; fixed gemma4 model name e2b→e4b Q8_0; added Dynamic Prompt context trigger; added ADR-029 to Tier 3 AI checklist; bumped last synced date | Windsurf AI |
|
||||||
| 1.9.5 | 2026-05-18 | **Grill-with-Docs Session:** Domain terminology clarified (Correspondence = all doc types), Tier 3: SPECIALIZED WORK added, Context-Aware Triggers with Status column, Tier-specific Final Checklists | Windsurf AI |
|
| 1.9.6 | 2026-05-22 | Added ADR-024/025/026/027/028 to Key Spec Files table; Tier 3 expanded with AI Runtime Layer + Migration Pipeline tiers; Specialized Work section updated with ADR-024~028 patterns; 6 new Context-Aware Triggers; bumped Last synced date | Windsurf AI |
|
||||||
| 1.9.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev |
|
| 1.9.5 | 2026-05-18 | **Grill-with-Docs Session:** Domain terminology clarified (Correspondence = all doc types), Tier 3: SPECIALIZED WORK added, Context-Aware Triggers with Status column, Tier-specific Final Checklists | Windsurf AI |
|
||||||
| 1.9.3 | 2026-05-15 | ADR-023A: Model revision — gemma4:9b+Typhoon→gemma4:e2b (2-model stack), BullMQ 2-queue split, RAG full-doc embed, OCR auto-detect, n8n→DMS API boundary, QdrantService multi-tenancy contract | Windsurf AI |
|
| 1.9.4 | 2026-05-16 | Added ADR-015 Release Strategy to Key Spec Files table (Blue-Green deployment + release gates) | Human Dev |
|
||||||
| 1.9.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
|
| 1.9.3 | 2026-05-15 | ADR-023A: Model revision — gemma4:9b+Typhoon→gemma4:e2b (2-model stack), BullMQ 2-queue split, RAG full-doc embed, OCR auto-detect, n8n→DMS API boundary, QdrantService multi-tenancy contract | Windsurf AI |
|
||||||
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
|
| 1.9.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
|
||||||
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
|
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
|
||||||
| 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI |
|
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
|
||||||
| 1.8.8 | 2026-04-14 | Workflow attachments (ADR-021) + step-attachment envelope fields | Windsurf AI |
|
| 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI |
|
||||||
| 1.8.7 | 2026-04-14 | + ADR-021 Workflow Context integration, + ADR-021 Integration Work tier, + Transmittal/Circulation context triggers, updated ADR-020 status | Windsurf AI |
|
| 1.8.8 | 2026-04-14 | Workflow attachments (ADR-021) + step-attachment envelope fields | Windsurf AI |
|
||||||
| 1.8.6 | 2026-04-10 | + DMS Workflow Engine Protocol, + Security & Integrity Audit Protocol, + 2 Context-Aware Triggers, ADR Status column, Forbidden Why column | Human Dev |
|
| 1.8.7 | 2026-04-14 | + ADR-021 Workflow Context integration, + ADR-021 Integration Work tier, + Transmittal/Circulation context triggers, updated ADR-020 status | Windsurf AI |
|
||||||
| 1.8.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf AI |
|
| 1.8.6 | 2026-04-10 | + DMS Workflow Engine Protocol, + Security & Integrity Audit Protocol, + 2 Context-Aware Triggers, ADR Status column, Forbidden Why column | Human Dev |
|
||||||
| 1.8.4 | 2026-03-24 | Phase 5.4→✅ DONE, Tailwind 3.4.3, ADR count(16), MariaDB UUID note | Windsurf AI |
|
| 1.8.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf AI |
|
||||||
| 1.8.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI |
|
| 1.8.4 | 2026-03-24 | Phase 5.4→✅ DONE, Tailwind 3.4.3, ADR count(16), MariaDB UUID note | Windsurf AI |
|
||||||
| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI |
|
| 1.8.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI |
|
||||||
| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet |
|
| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI |
|
||||||
| 1.8.0 | 2026-03-19 | + Security overrides, + UAT criteria reference | Human Dev |
|
| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet |
|
||||||
| 1.7.2 | 2026-03-15 | + AI Boundary rules (ADR-018) | Gemini Pro |
|
| 1.8.0 | 2026-03-19 | + Security overrides, + UAT criteria reference | Human Dev |
|
||||||
|
| 1.7.2 | 2026-03-15 | + AI Boundary rules (ADR-018) | Gemini Pro |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -1,5 +1,36 @@
|
|||||||
# Version History
|
# Version History
|
||||||
|
|
||||||
|
## 1.9.10 (2026-06-08)
|
||||||
|
|
||||||
|
### bugfix(ai): Fix LLM JSON Response Truncation in OCR Sandbox & Migration
|
||||||
|
|
||||||
|
#### Summary
|
||||||
|
|
||||||
|
แก้ไขปัญหา LLM JSON Response Truncation ใน OCR Sandbox Step 2 และ Migration Pipeline โดยการขยายขนาดหน้าต่างบริบท `num_ctx` ของ Ollama เป็น `16384` สำหรับงานสกัดข้อมูล (ดำเนินการแก้ไขโดย AGY Gemini 3.5 Flash (Medium))
|
||||||
|
|
||||||
|
#### Changes
|
||||||
|
|
||||||
|
- **Ollama Context Window Expansion**: เพิ่มพารามิเตอร์ `num_ctx: 16384` ใน `processSandboxExtract` และ `processSandboxAiExtract` สำหรับงานสกัดข้อมูลใน Sandbox เพื่อรองรับข้อมูลขนาดใหญ่ (สูงสุด 15,000 ตัวอักษร)
|
||||||
|
- **Migration Pipeline Hardening**: อัปเดต `processMigrateDocument` ให้บังคับส่ง `format: 'json'` และ `options: { num_ctx: 16384, num_predict: 4096 }` ให้ตรงกับพฤติกรรมของ Sandbox
|
||||||
|
- **Regression Tests**: ปรับปรุง Unit Test ใน `ai-batch.processor.spec.ts` เพื่อให้สอดคล้องกับพารามิเตอร์การเรียก Ollama แบบใหม่
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.9.9 (2026-06-06)
|
||||||
|
|
||||||
|
### feat(ai): LLM JSON Parse Failure & VRAM Fix (ADR-035-135)
|
||||||
|
|
||||||
|
#### Summary
|
||||||
|
|
||||||
|
แก้ไขข้อผิดพลาด JSON Parse และหน่วยความจำ VRAM โดยเพิ่มระบบ retry logic และปรับปรุง VRAM switching
|
||||||
|
|
||||||
|
#### Changes
|
||||||
|
|
||||||
|
- **JSON Parse Retry**: เพิ่มระบบ retry logic (2 attempts) สำหรับกรณี JSON parse fail พร้อมแสดงรายละเอียด log
|
||||||
|
- **VRAM limit**: ปรับแต่งค่า `keep_alive=0` สำหรับ OCR model และแก้ปัญหาความจำรั่วไหลใน Node.js/ESLint heap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1.9.8 (2026-06-02)
|
## 1.9.8 (2026-06-02)
|
||||||
|
|
||||||
### feat(ai): AI Model Swapping, GPU Unloading & OCR Security (ADR-033)
|
### feat(ai): AI Model Swapping, GPU Unloading & OCR Security (ADR-033)
|
||||||
@@ -168,7 +199,7 @@
|
|||||||
|
|
||||||
#### Summary
|
#### Summary
|
||||||
|
|
||||||
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Devin/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
||||||
|
|
||||||
#### Changes
|
#### Changes
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -722,19 +722,19 @@ Create `.markdownlint.json`:
|
|||||||
|
|
||||||
## 🤖 AI-Assisted Contributions
|
## 🤖 AI-Assisted Contributions
|
||||||
|
|
||||||
โปรเจกต์นี้รองรับ AI agents (Windsurf Cascade, Codex CLI, opencode, Amp, Antigravity) ในการเขียน / review / refactor โค้ด — ผ่านคู่มือกลางคือ [`AGENTS.md`](./AGENTS.md) และชุดทักษะใน [`.agents/skills/`](./.agents/skills/)
|
โปรเจกต์นี้รองรับ AI agents (Devin Cascade, Codex CLI, opencode, Amp, Antigravity) ในการเขียน / review / refactor โค้ด — ผ่านคู่มือกลางคือ [`AGENTS.md`](./AGENTS.md) และชุดทักษะใน [`.agents/skills/`](./.agents/skills/)
|
||||||
|
|
||||||
### Canonical Rule Sources (อ่านตามลำดับนี้)
|
### Canonical Rule Sources (อ่านตามลำดับนี้)
|
||||||
|
|
||||||
1. **[`AGENTS.md`](./AGENTS.md)** — quick-reference rules + change log (supersedes legacy `GEMINI.md`)
|
1. **[`AGENTS.md`](./AGENTS.md)** — quick-reference rules + change log (supersedes legacy `GEMINI.md`)
|
||||||
2. **[`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)** — shared context loaded by every speckit-\* skill
|
2. **[`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)** — shared context loaded by every speckit-\* skill
|
||||||
3. **[`.agents/skills/README.md`](./.agents/skills/README.md)** — skill-pack layout + Windsurf invocation guide
|
3. **[`.agents/skills/README.md`](./.agents/skills/README.md)** — skill-pack layout + Devin invocation guide
|
||||||
4. `specs/06-Decision-Records/` (โดยเฉพาะ ADR-019 — UUID **March 2026 pattern**)
|
4. `specs/06-Decision-Records/` (โดยเฉพาะ ADR-019 — UUID **March 2026 pattern**)
|
||||||
5. `specs/05-Engineering-Guidelines/` (backend / frontend / testing / i18n / git conventions)
|
5. `specs/05-Engineering-Guidelines/` (backend / frontend / testing / i18n / git conventions)
|
||||||
|
|
||||||
### Invocation (v1.9.0 Unified)
|
### Invocation (v1.9.0 Unified)
|
||||||
|
|
||||||
ใช้ slash commands ผ่านโฟลเดอร์หลักคือ [`.agents/workflows/`](./.agents/workflows/) (ซึ่งถูก Mirror ไปยัง `.windsurf/workflows/` อัตโนมัติ):
|
ใช้ slash commands ผ่านโฟลเดอร์หลักคือ [`.agents/workflows/`](./.agents/workflows/) (ซึ่งถูก Mirror ไปยัง `.devin/workflows/` อัตโนมัติ):
|
||||||
|
|
||||||
- `/00-speckit.all` → Full Pipeline (Specify → Validate)
|
- `/00-speckit.all` → Full Pipeline (Specify → Validate)
|
||||||
- `/102-speckit.specify` → สร้าง spec.md (ต้องระบุหมวดหมู่ 100/200/300)
|
- `/102-speckit.specify` → สร้าง spec.md (ต้องระบุหมวดหมู่ 100/200/300)
|
||||||
|
|||||||
@@ -16,17 +16,17 @@
|
|||||||
|
|
||||||
> v1.9.7 (ADR-029 + sidecar) May 25; v1.9.8 (ADR-033 Model/OCR Sync & Security) June 2.
|
> v1.9.7 (ADR-029 + sidecar) May 25; v1.9.8 (ADR-033 Model/OCR Sync & Security) June 2.
|
||||||
|
|
||||||
| Area | Status | หมายเหตุ |
|
| Area | Status | หมายเหตุ |
|
||||||
| ---------------------- | ------------------------ | ------------------------------------------------------------------ |
|
| ---------------------- | ------------------------ | -------------------------------------------------------------- |
|
||||||
| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities |
|
| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities |
|
||||||
| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 |
|
| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 |
|
||||||
| 💾 **Database** | ✅ Schema v1.9.0 Stable | MariaDB 11.8, No-migration Policy |
|
| 💾 **Database** | ✅ Schema v1.9.0 Stable | MariaDB 11.8, No-migration Policy |
|
||||||
| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (33 ADRs — v1.9.8) |
|
| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (33 ADRs — v1.9.8) |
|
||||||
| 🤖 **AI Architecture** | ✅ 33 ADRs Accepted | ADR-023A + ADR-024~029 + ADR-033 Model Sync & Security |
|
| 🤖 **AI Architecture** | ✅ 33 ADRs Accepted | ADR-023A + ADR-024~029 + ADR-033 Model Sync & Security |
|
||||||
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
|
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
|
||||||
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
|
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
|
||||||
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
|
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
|
||||||
| 🔒 **Infrastructure** | ✅ Hardened (v1.9.8) | Sidecar APIs secured; dynamic VRAM Release; container hardened |
|
| 🔒 **Infrastructure** | ✅ Hardened (v1.9.8) | Sidecar APIs secured; dynamic VRAM Release; container hardened |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ lcbp3-dms/
|
|||||||
│ ├── scripts/ # Audit & Sync scripts
|
│ ├── scripts/ # Audit & Sync scripts
|
||||||
│ └── archive/ # Archived outdated tools
|
│ └── archive/ # Archived outdated tools
|
||||||
│
|
│
|
||||||
├── .windsurf/ # Windsurf-specific (Mirrored from .agents)
|
├── .devin/ # Devin-specific (Mirrored from .agents)
|
||||||
│
|
│
|
||||||
├── .github/ # GitHub Actions workflows
|
├── .github/ # GitHub Actions workflows
|
||||||
├── AGENTS.md # AI agent rules & project context (v1.9.0) [★ primary]
|
├── AGENTS.md # AI agent rules & project context (v1.9.0) [★ primary]
|
||||||
@@ -314,20 +314,20 @@ lcbp3-dms/
|
|||||||
|
|
||||||
### เอกสารหลัก (specs/ folder)
|
### เอกสารหลัก (specs/ folder)
|
||||||
|
|
||||||
| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก |
|
| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก |
|
||||||
| ----------------------- | -------------------------------------------------------- | --------- | --------------------------------------- |
|
| ----------------------- | ----------------------------------------------------------------- | --------- | --------------------------------------- |
|
||||||
| **Product Vision** | Vision, Strategic Pillars, Guardrails | Gap 1 ✅ | `00-03-product-vision.md` |
|
| **Product Vision** | Vision, Strategic Pillars, Guardrails | Gap 1 ✅ | `00-03-product-vision.md` |
|
||||||
| **User Stories** | 27 Stories, 8 Epics, MoSCoW | Gap 2 ✅ | `01-04-user-stories.md` |
|
| **User Stories** | 27 Stories, 8 Epics, MoSCoW | Gap 2 ✅ | `01-04-user-stories.md` |
|
||||||
| **Acceptance Criteria** | UAT Criteria, Sign-off Process | Gap 3 ✅ | `01-05-acceptance-criteria.md` |
|
| **Acceptance Criteria** | UAT Criteria, Sign-off Process | Gap 3 ✅ | `01-05-acceptance-criteria.md` |
|
||||||
| **UI/UX Wireframes** | 26 Screens, ASCII Wireframes, Design System | Gap 4 ✅ | `01-07-ui-wireframes.md` |
|
| **UI/UX Wireframes** | 26 Screens, ASCII Wireframes, Design System | Gap 4 ✅ | `01-07-ui-wireframes.md` |
|
||||||
| **Stakeholder & Risk** | Sign-off, Risk Register, Change Control | Gap 5 ✅ | `00-04-stakeholder-signoff-and-risk.md` |
|
| **Stakeholder & Risk** | Sign-off, Risk Register, Change Control | Gap 5 ✅ | `00-04-stakeholder-signoff-and-risk.md` |
|
||||||
| **KPI Baseline** | 14 KPIs, SQL Queries, Grafana Specs | Gap 6 ✅ | `00-05-kpi-baseline.md` |
|
| **KPI Baseline** | 14 KPIs, SQL Queries, Grafana Specs | Gap 6 ✅ | `00-05-kpi-baseline.md` |
|
||||||
| **Migration Scope** | 20K Docs, 3 Tiers, Go/No-Go Gates | Gap 7 ✅ | `03-06-migration-business-scope.md` |
|
| **Migration Scope** | 20K Docs, 3 Tiers, Go/No-Go Gates | Gap 7 ✅ | `03-06-migration-business-scope.md` |
|
||||||
| **Release Policy** | SemVer, 5 Gates, Hotfix, Rollback | Gap 8 ✅ | `04-08-release-management-policy.md` |
|
| **Release Policy** | SemVer, 5 Gates, Hotfix, Rollback | Gap 8 ✅ | `04-08-release-management-policy.md` |
|
||||||
| **Training Plan** | Curriculum per Role, UAT Training | Gap 9 ✅ | `00-06-training-plan.md` |
|
| **Training Plan** | Curriculum per Role, UAT Training | Gap 9 ✅ | `00-06-training-plan.md` |
|
||||||
| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` |
|
| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` |
|
||||||
| **Schema v1.9.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.9.0-schema-*.sql` |
|
| **Schema v1.9.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.9.0-schema-*.sql` |
|
||||||
| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` |
|
| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` |
|
||||||
| **ADRs (33)** | All Architecture Decisions incl. ADR-019/021/023/024-029, ADR-033 | - | `06-Decision-Records/` |
|
| **ADRs (33)** | All Architecture Decisions incl. ADR-019/021/023/024-029, ADR-033 | - | `06-Decision-Records/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -366,7 +366,7 @@ lcbp3-dms/
|
|||||||
- Development Process
|
- Development Process
|
||||||
- Pull Request Process
|
- Pull Request Process
|
||||||
- Coding Standards
|
- Coding Standards
|
||||||
- **AI-Assisted Contributions** (AGENTS.md + `.agents/skills/` skill pack + Windsurf slash commands)
|
- **AI-Assisted Contributions** (AGENTS.md + `.agents/skills/` skill pack + Devin slash commands)
|
||||||
|
|
||||||
### 🤖 For AI Agents
|
### 🤖 For AI Agents
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"lint:ci": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\" --cache",
|
"lint:ci": "node --max-old-space-size=4096 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\" --cache",
|
||||||
"test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance",
|
"test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance",
|
||||||
"test:debug-handles": "jest --config jest.config.js --detectOpenHandles",
|
"test:debug-handles": "jest --config jest.config.js --detectOpenHandles",
|
||||||
"test:watch": "jest --config jest.config.js --watch",
|
"test:watch": "jest --config jest.config.js --watch",
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { SearchModule } from './modules/search/search.module';
|
|||||||
import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { AiModule } from './modules/ai/ai.module';
|
import { AiModule } from './modules/ai/ai.module';
|
||||||
import { RagModule } from './modules/rag/rag.module';
|
|
||||||
import { ReviewTeamModule } from './modules/review-team/review-team.module';
|
import { ReviewTeamModule } from './modules/review-team/review-team.module';
|
||||||
import { ResponseCodeModule } from './modules/response-code/response-code.module';
|
import { ResponseCodeModule } from './modules/response-code/response-code.module';
|
||||||
import { DelegationModule } from './modules/delegation/delegation.module';
|
import { DelegationModule } from './modules/delegation/delegation.module';
|
||||||
@@ -192,7 +191,6 @@ import { TagsModule } from './modules/tags/tags.module';
|
|||||||
AuditLogModule,
|
AuditLogModule,
|
||||||
MigrationModule,
|
MigrationModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
RagModule,
|
|
||||||
ReviewTeamModule,
|
ReviewTeamModule,
|
||||||
ResponseCodeModule,
|
ResponseCodeModule,
|
||||||
DelegationModule,
|
DelegationModule,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// File: backend/src/modules/ai/ai-qdrant.service.spec.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-05: สร้าง unit test สำหรับ AiQdrantService ครอบคลุม deleteByDocumentPublicId (T4)
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AiQdrantService } from './qdrant.service';
|
||||||
|
|
||||||
|
describe('AiQdrantService', () => {
|
||||||
|
let service: AiQdrantService;
|
||||||
|
let mockConfigService: jest.Mocked<ConfigService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<ConfigService>;
|
||||||
|
|
||||||
|
mockConfigService.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'AI_QDRANT_URL' || key === 'QDRANT_URL') {
|
||||||
|
return 'http://localhost:6333';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AiQdrantService,
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AiQdrantService>(AiQdrantService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรถูกสร้างขึ้นสำเร็จ', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteByDocumentPublicId', () => {
|
||||||
|
it('ควร throw error ถ้า projectPublicId ว่าง', async () => {
|
||||||
|
await expect(
|
||||||
|
service.deleteByDocumentPublicId('', 'doc-uuid-123')
|
||||||
|
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควร throw error ถ้า projectPublicId เป็น undefined', async () => {
|
||||||
|
await expect(
|
||||||
|
service.deleteByDocumentPublicId(
|
||||||
|
undefined as unknown as string,
|
||||||
|
'doc-uuid-123'
|
||||||
|
)
|
||||||
|
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ควรเรียก Qdrant delete ด้วย filter ที่ถูกต้อง (project_public_id + doc_public_id)', async () => {
|
||||||
|
// Mock QdrantClient.delete method
|
||||||
|
const mockDelete = jest.fn().mockResolvedValue(undefined);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||||
|
(service as any).client.delete = mockDelete;
|
||||||
|
|
||||||
|
await service.deleteByDocumentPublicId('proj-uuid-456', 'doc-uuid-123');
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('lcbp3_vectors', {
|
||||||
|
wait: true,
|
||||||
|
filter: {
|
||||||
|
must: [
|
||||||
|
{ key: 'project_public_id', match: { value: 'proj-uuid-456' } },
|
||||||
|
{ key: 'doc_public_id', match: { value: 'doc-uuid-123' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,9 +32,24 @@ export interface AiRagJobPayload {
|
|||||||
/** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */
|
/** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */
|
||||||
export interface AiVectorDeletionJobPayload {
|
export interface AiVectorDeletionJobPayload {
|
||||||
documentPublicId: string;
|
documentPublicId: string;
|
||||||
|
projectPublicId: string;
|
||||||
requestedByUserPublicId: string;
|
requestedByUserPublicId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Payload สำหรับงาน RAG Prepare เมื่อผู้ใช้ submit workflow */
|
||||||
|
export interface RagPrepareJobPayload {
|
||||||
|
documentPublicId: string;
|
||||||
|
projectPublicId: string;
|
||||||
|
correspondenceNumber: string;
|
||||||
|
docType: string;
|
||||||
|
statusCode: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
subject: string;
|
||||||
|
documentDate?: string;
|
||||||
|
cachedOcrText?: string;
|
||||||
|
attachmentPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */
|
/** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiQueueService {
|
export class AiQueueService {
|
||||||
@@ -92,7 +107,7 @@ export class AiQueueService {
|
|||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
...this.defaultOptions,
|
...this.defaultOptions,
|
||||||
jobId: payload.documentPublicId,
|
jobId: `${payload.projectPublicId}:${payload.documentPublicId}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return String(job.id);
|
return String(job.id);
|
||||||
@@ -158,4 +173,23 @@ export class AiQueueService {
|
|||||||
const waiting = await this.batchQueue.getWaitingCount();
|
const waiting = await this.batchQueue.getWaitingCount();
|
||||||
return active + waiting;
|
return active + waiting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ส่งงาน RAG Prepare เข้า queue เพื่อเตรียมหั่นข้อมูลและทำ embedding ในเบื้องหลัง
|
||||||
|
* @idempotency `jobId = rag-prepare:${documentPublicId}:${revisionNumber}` — ป้องกันการรันซ้ำสำหรับ revision เดียวกัน
|
||||||
|
*/
|
||||||
|
async enqueueRagPrepare(payload: RagPrepareJobPayload): Promise<string> {
|
||||||
|
const job = await this.batchQueue.add(
|
||||||
|
'rag-prepare',
|
||||||
|
{
|
||||||
|
jobType: 'rag-prepare',
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...this.defaultOptions,
|
||||||
|
jobId: `rag-prepare:${payload.documentPublicId}:${payload.revisionNumber}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return String(job.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
// File: backend/src/modules/ai/ai-rag-pipeline.integration.spec.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-05: สร้าง integration test สำหรับ RAG Pipeline end-to-end (SC-002, Gap fix)
|
||||||
|
// ครอบคลุม: enqueueRagPrepare jobId dedup, EmbeddingService pipeline, project isolation
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { getQueueToken } from '@nestjs/bullmq';
|
||||||
|
import { AiQueueService, RagPrepareJobPayload } from './ai-queue.service';
|
||||||
|
import { EmbeddingService } from './services/embedding.service';
|
||||||
|
import { OllamaService } from './services/ollama.service';
|
||||||
|
import { OcrService } from './services/ocr.service';
|
||||||
|
import { AiQdrantService } from './qdrant.service';
|
||||||
|
import { AiPromptsService } from './prompts/ai-prompts.service';
|
||||||
|
import {
|
||||||
|
QUEUE_AI_INGEST,
|
||||||
|
QUEUE_AI_RAG,
|
||||||
|
QUEUE_AI_VECTOR_DELETION,
|
||||||
|
QUEUE_AI_BATCH,
|
||||||
|
} from '../common/constants/queue.constants';
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Mock helpers
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
/** สร้าง mock BullMQ Queue ที่ track jobId เพื่อ verify deduplication */
|
||||||
|
const createMockQueue = () => {
|
||||||
|
return {
|
||||||
|
add: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
(name: string, data: unknown, opts: { jobId?: string } = {}) =>
|
||||||
|
Promise.resolve({ id: opts.jobId ?? 'auto-id' })
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** สร้าง mock EmbeddingService dependencies */
|
||||||
|
const buildEmbeddingModule = async (
|
||||||
|
ollamaGenerateResponse: string,
|
||||||
|
chunkSize = 512,
|
||||||
|
chunkOverlap = 64
|
||||||
|
) => {
|
||||||
|
const mockOllamaService = {
|
||||||
|
generate: jest.fn().mockResolvedValue(ollamaGenerateResponse),
|
||||||
|
};
|
||||||
|
const mockAiPromptsService = {
|
||||||
|
resolveActive: jest.fn().mockResolvedValue({
|
||||||
|
resolvedPrompt: 'แบ่ง OCR text ออกเป็น chunks',
|
||||||
|
versionNumber: 1,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key: string, def?: unknown) => {
|
||||||
|
const vals: Record<string, unknown> = {
|
||||||
|
EMBEDDING_CHUNK_SIZE: chunkSize,
|
||||||
|
EMBEDDING_CHUNK_OVERLAP: chunkOverlap,
|
||||||
|
};
|
||||||
|
return vals[key] ?? def;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const mockEmbedViaSidecar = jest.fn().mockResolvedValue({
|
||||||
|
dense: Array(1024).fill(0.1),
|
||||||
|
sparse: { indices: [10, 20], values: [0.8, 0.4] },
|
||||||
|
});
|
||||||
|
const mockDeleteByDocumentPublicId = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const mockUpsert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
EmbeddingService,
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: OllamaService, useValue: mockOllamaService },
|
||||||
|
{
|
||||||
|
provide: AiQdrantService,
|
||||||
|
useValue: {
|
||||||
|
deleteByDocumentPublicId: mockDeleteByDocumentPublicId,
|
||||||
|
upsert: mockUpsert,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: OcrService,
|
||||||
|
useValue: { embedViaSidecar: mockEmbedViaSidecar },
|
||||||
|
},
|
||||||
|
{ provide: AiPromptsService, useValue: mockAiPromptsService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
return {
|
||||||
|
service: module.get<EmbeddingService>(EmbeddingService),
|
||||||
|
mockEmbedViaSidecar,
|
||||||
|
mockDeleteByDocumentPublicId,
|
||||||
|
mockUpsert,
|
||||||
|
mockOllamaService,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('RAG Pipeline — Integration (SC-002 / Gap fixes)', () => {
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test Group 1: BullMQ Job Deduplication (Gap 1 verify)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('enqueueRagPrepare — jobId deduplication', () => {
|
||||||
|
let queueService: AiQueueService;
|
||||||
|
let mockBatchQueue: ReturnType<typeof createMockQueue>;
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockBatchQueue = createMockQueue();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AiQueueService,
|
||||||
|
{
|
||||||
|
provide: getQueueToken(QUEUE_AI_INGEST),
|
||||||
|
useValue: { add: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getQueueToken(QUEUE_AI_RAG),
|
||||||
|
useValue: { add: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
|
||||||
|
useValue: { add: jest.fn() },
|
||||||
|
},
|
||||||
|
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockBatchQueue },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
queueService = module.get<AiQueueService>(AiQueueService);
|
||||||
|
});
|
||||||
|
it('ควรสร้าง jobId = rag-prepare:{documentPublicId}:{revisionNumber} (SC-004 dedup)', async () => {
|
||||||
|
const payload: RagPrepareJobPayload = {
|
||||||
|
documentPublicId: 'doc-uuid-001',
|
||||||
|
projectPublicId: 'proj-uuid-abc',
|
||||||
|
correspondenceNumber: 'CORR-2026-001',
|
||||||
|
docType: 'LETTER',
|
||||||
|
statusCode: 'SUBOWN',
|
||||||
|
revisionNumber: 1,
|
||||||
|
subject: 'เอกสารทดสอบ Dedup',
|
||||||
|
};
|
||||||
|
await queueService.enqueueRagPrepare(payload);
|
||||||
|
const calls = mockBatchQueue.add.mock.calls as [
|
||||||
|
string,
|
||||||
|
unknown,
|
||||||
|
{ jobId?: string },
|
||||||
|
][];
|
||||||
|
expect(calls[0][2]?.jobId).toBe('rag-prepare:doc-uuid-001:1');
|
||||||
|
});
|
||||||
|
it('ควร enqueue ด้วยชื่อ job rag-prepare และ payload ครบ', async () => {
|
||||||
|
const payload: RagPrepareJobPayload = {
|
||||||
|
documentPublicId: 'doc-uuid-002',
|
||||||
|
projectPublicId: 'proj-uuid-xyz',
|
||||||
|
correspondenceNumber: 'CORR-2026-002',
|
||||||
|
docType: 'RFA',
|
||||||
|
statusCode: 'CLBOWN',
|
||||||
|
revisionNumber: 0,
|
||||||
|
subject: 'RFA Test',
|
||||||
|
documentDate: '2026-06-05',
|
||||||
|
attachmentPath: '/files/rfa.pdf',
|
||||||
|
};
|
||||||
|
await queueService.enqueueRagPrepare(payload);
|
||||||
|
expect(mockBatchQueue.add).toHaveBeenCalledWith(
|
||||||
|
'rag-prepare',
|
||||||
|
expect.objectContaining({
|
||||||
|
jobType: 'rag-prepare',
|
||||||
|
documentPublicId: 'doc-uuid-002',
|
||||||
|
revisionNumber: 0,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
jobId: 'rag-prepare:doc-uuid-002:0',
|
||||||
|
attempts: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรคืน jobId เดิมเมื่อ enqueue revision เดียวกัน 2 ครั้ง (idempotency)', async () => {
|
||||||
|
const payload: RagPrepareJobPayload = {
|
||||||
|
documentPublicId: 'doc-same',
|
||||||
|
projectPublicId: 'proj-same',
|
||||||
|
correspondenceNumber: 'CORR-SAME',
|
||||||
|
docType: 'LETTER',
|
||||||
|
statusCode: 'SUBOWN',
|
||||||
|
revisionNumber: 3,
|
||||||
|
subject: 'Idempotency Test',
|
||||||
|
};
|
||||||
|
const id1 = await queueService.enqueueRagPrepare(payload);
|
||||||
|
const id2 = await queueService.enqueueRagPrepare(payload);
|
||||||
|
// jobId เหมือนกัน — BullMQ จะ deduplicate ที่ server side
|
||||||
|
expect(id1).toBe(id2);
|
||||||
|
const calls = mockBatchQueue.add.mock.calls as [
|
||||||
|
string,
|
||||||
|
unknown,
|
||||||
|
{ jobId?: string },
|
||||||
|
][];
|
||||||
|
expect(calls[0][2]?.jobId).toBe(calls[1][2]?.jobId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test Group 2: processRagPrepare → EmbeddingService pipeline (SC-002)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('EmbeddingService.embedDocument — full pipeline (SC-002)', () => {
|
||||||
|
const semanticLlmResponse =
|
||||||
|
'<chunk topic="บทนำ">เนื้อหาบทนำของเอกสารที่มีความยาวเพียงพอสำหรับการทดสอบ</chunk>' +
|
||||||
|
'<chunk topic="รายละเอียด">เนื้อหารายละเอียดของเอกสารฉบับนี้ครอบคลุมหัวข้อสำคัญ</chunk>';
|
||||||
|
const ocrText =
|
||||||
|
'เนื้อหาเอกสารที่มีความยาวเกิน 50 ตัวอักษร สำหรับทดสอบ RAG pipeline integration test ครบ pipeline';
|
||||||
|
it('SC-002: ควรเรียก Sidecar /embed และ Qdrant upsert สำหรับ semantic chunks', async () => {
|
||||||
|
const {
|
||||||
|
service,
|
||||||
|
mockEmbedViaSidecar,
|
||||||
|
mockDeleteByDocumentPublicId,
|
||||||
|
mockUpsert,
|
||||||
|
} = await buildEmbeddingModule(semanticLlmResponse);
|
||||||
|
const result = await service.embedDocument(
|
||||||
|
'proj-uuid-123',
|
||||||
|
'doc-uuid-456',
|
||||||
|
'CORR-2026-001',
|
||||||
|
'LETTER',
|
||||||
|
'SUBOWN',
|
||||||
|
1,
|
||||||
|
'Test Subject',
|
||||||
|
'2026-06-05',
|
||||||
|
ocrText
|
||||||
|
);
|
||||||
|
// ตรวจสอบว่า Sidecar /embed ถูกเรียกสำหรับแต่ละ semantic chunk (2 chunks)
|
||||||
|
expect(mockEmbedViaSidecar).toHaveBeenCalledTimes(2);
|
||||||
|
// ตรวจสอบว่าลบ points เก่าก่อน upsert (delete-before-upsert)
|
||||||
|
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
|
||||||
|
'proj-uuid-123',
|
||||||
|
'doc-uuid-456'
|
||||||
|
);
|
||||||
|
// ตรวจสอบ upsert payload ครบ 11 fields
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
'proj-uuid-123',
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
doc_public_id: 'doc-uuid-456',
|
||||||
|
project_public_id: 'proj-uuid-123',
|
||||||
|
doc_number: 'CORR-2026-001',
|
||||||
|
doc_type: 'LETTER',
|
||||||
|
status_code: 'SUBOWN',
|
||||||
|
revision_number: 1,
|
||||||
|
subject: 'Test Subject',
|
||||||
|
document_date: '2026-06-05',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.chunksEmbedded).toBe(2);
|
||||||
|
});
|
||||||
|
it('SC-003: project isolation — upsert และ delete ต้องใช้ projectPublicId ที่ถูกต้อง', async () => {
|
||||||
|
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
|
||||||
|
await buildEmbeddingModule(semanticLlmResponse);
|
||||||
|
await service.embedDocument(
|
||||||
|
'proj-ISOLATED-999',
|
||||||
|
'doc-iso',
|
||||||
|
'CORR-ISO',
|
||||||
|
'LETTER',
|
||||||
|
'SUBOWN',
|
||||||
|
0,
|
||||||
|
'Subject',
|
||||||
|
undefined,
|
||||||
|
ocrText
|
||||||
|
);
|
||||||
|
// deleteByDocumentPublicId ต้องใช้ projectPublicId ที่ถูกต้อง
|
||||||
|
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
|
||||||
|
'proj-ISOLATED-999',
|
||||||
|
'doc-iso'
|
||||||
|
);
|
||||||
|
// upsert ต้องส่ง projectPublicId ที่ถูกต้องเป็น arg แรก
|
||||||
|
const upsertCalls = mockUpsert.mock.calls as [string, unknown][];
|
||||||
|
expect(upsertCalls[0][0]).toBe('proj-ISOLATED-999');
|
||||||
|
});
|
||||||
|
it('SC-006: ลำดับ delete → upsert ต้องถูกต้องเสมอ (ป้องกัน stale chunks)', async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
|
||||||
|
await buildEmbeddingModule(semanticLlmResponse);
|
||||||
|
mockDeleteByDocumentPublicId.mockImplementationOnce(() => {
|
||||||
|
callOrder.push('delete');
|
||||||
|
});
|
||||||
|
mockUpsert.mockImplementationOnce(() => {
|
||||||
|
callOrder.push('upsert');
|
||||||
|
});
|
||||||
|
await service.embedDocument(
|
||||||
|
'proj-x',
|
||||||
|
'doc-stale',
|
||||||
|
'CORR-X',
|
||||||
|
'LETTER',
|
||||||
|
'SUBOWN',
|
||||||
|
2,
|
||||||
|
'Sub',
|
||||||
|
undefined,
|
||||||
|
ocrText
|
||||||
|
);
|
||||||
|
// ตรวจสอบลำดับ: delete ต้องเกิดก่อน upsert เสมอ (SC-006)
|
||||||
|
expect(callOrder).toEqual(['delete', 'upsert']);
|
||||||
|
});
|
||||||
|
it('ควรคืน success=false เมื่อ ocrText ว่าง (edge case — skip guard)', async () => {
|
||||||
|
const { service } = await buildEmbeddingModule(semanticLlmResponse);
|
||||||
|
const result = await service.embedDocument(
|
||||||
|
'proj-x',
|
||||||
|
'doc-empty',
|
||||||
|
'CORR-X',
|
||||||
|
'LETTER',
|
||||||
|
'SUBOWN',
|
||||||
|
1,
|
||||||
|
'Sub',
|
||||||
|
undefined,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('No OCR text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test Group 3: Semantic Chunking fallback → fixed-size (FR-005)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Semantic Chunking fallback (FR-005)', () => {
|
||||||
|
it('ควร fallback เป็น fixed-size และยังคง embed ได้ เมื่อ LLM output ไม่มี <chunk> tag', async () => {
|
||||||
|
const { service, mockEmbedViaSidecar, mockUpsert } =
|
||||||
|
await buildEmbeddingModule(
|
||||||
|
'ไม่มี tag chunk เลย — plain text output',
|
||||||
|
60,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const ocrText = 'ก'.repeat(80); // 80 chars → 2 chunks (60 + 20 chars)
|
||||||
|
const result = await service.embedDocument(
|
||||||
|
'proj-fallback',
|
||||||
|
'doc-fallback',
|
||||||
|
'CORR-FB',
|
||||||
|
'LETTER',
|
||||||
|
'SUBOWN',
|
||||||
|
1,
|
||||||
|
'Fallback',
|
||||||
|
undefined,
|
||||||
|
ocrText
|
||||||
|
);
|
||||||
|
// fallback ยังต้อง embed ได้
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.chunksEmbedded).toBeGreaterThan(0);
|
||||||
|
expect(mockEmbedViaSidecar).toHaveBeenCalled();
|
||||||
|
// ตรวจสอบว่า chunk_topic มาจาก fixed-size (ขึ้นต้นด้วย "ส่วนที่")
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
const upsertPoints = mockUpsert.mock.calls[0]?.[1] as Array<{
|
||||||
|
payload: { chunk_topic: string };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
expect(upsertPoints[0]?.payload.chunk_topic).toMatch(/ส่วนที่/);
|
||||||
|
});
|
||||||
|
it('ควร fallback ทันทีเมื่อ LLM throw error', async () => {
|
||||||
|
const { service, mockUpsert, mockOllamaService } =
|
||||||
|
await buildEmbeddingModule('', 60, 0);
|
||||||
|
mockOllamaService.generate.mockRejectedValueOnce(
|
||||||
|
new Error('Ollama timeout')
|
||||||
|
);
|
||||||
|
const ocrText = 'ก'.repeat(80);
|
||||||
|
const result = await service.embedDocument(
|
||||||
|
'proj-err',
|
||||||
|
'doc-err',
|
||||||
|
'CORR-ERR',
|
||||||
|
'LETTER',
|
||||||
|
'SUBOWN',
|
||||||
|
1,
|
||||||
|
'Sub',
|
||||||
|
undefined,
|
||||||
|
ocrText
|
||||||
|
);
|
||||||
|
// ถึงแม้ LLM throw แต่ fallback ยังทำงาน
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockUpsert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// File: backend/src/modules/ai/ai-rag.service.spec.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-05: สร้าง unit test สำหรับ AiRagService เพื่อทดสอบกระบวนการทำ RAG query ด้วย Hybrid Search และ Reranker (T011)
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { AiRagService } from './ai-rag.service';
|
||||||
|
import { AiQdrantService } from './qdrant.service';
|
||||||
|
import { OcrService } from './services/ocr.service';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
||||||
|
|
||||||
|
describe('AiRagService (US1 — Chat Q&A)', () => {
|
||||||
|
let service: AiRagService;
|
||||||
|
let qdrantService: AiQdrantService;
|
||||||
|
let ocrService: OcrService;
|
||||||
|
|
||||||
|
const mockRedis = {
|
||||||
|
get: jest.fn(),
|
||||||
|
setex: jest.fn(),
|
||||||
|
del: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
OLLAMA_URL: 'http://localhost:11434',
|
||||||
|
OLLAMA_RAG_MODEL: 'typhoon2.5-np-dms:latest',
|
||||||
|
RAG_TIMEOUT_MS: 30000,
|
||||||
|
RAG_CONTEXT_LIMIT_CHARS: 3000,
|
||||||
|
};
|
||||||
|
return values[key] ?? defaultValue;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockQdrantService = {
|
||||||
|
searchByProject: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockOcrService = {
|
||||||
|
embedViaSidecar: jest.fn(),
|
||||||
|
rerankViaSidecar: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AiRagService,
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: AiQdrantService, useValue: mockQdrantService },
|
||||||
|
{ provide: OcrService, useValue: mockOcrService },
|
||||||
|
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AiRagService>(AiRagService);
|
||||||
|
qdrantService = module.get<AiQdrantService>(AiQdrantService);
|
||||||
|
ocrService = module.get<OcrService>(OcrService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processQuery()', () => {
|
||||||
|
it('ควรเรียกใช้ embedViaSidecar, searchByProject, rerankViaSidecar และจบด้วยการสร้างคำตอบด้วย LLM', async () => {
|
||||||
|
// Setup mock data
|
||||||
|
const mockDenseVector = Array(1024).fill(0.1);
|
||||||
|
const mockSparseVector = { indices: [1, 2], values: [0.5, 0.6] };
|
||||||
|
|
||||||
|
mockOcrService.embedViaSidecar.mockResolvedValueOnce({
|
||||||
|
dense: mockDenseVector,
|
||||||
|
sparse: mockSparseVector,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockQdrantResults = [
|
||||||
|
{
|
||||||
|
pointId: 'point-1',
|
||||||
|
score: 0.85,
|
||||||
|
payload: {
|
||||||
|
doc_type: 'LETTER',
|
||||||
|
doc_number: 'CORR-001',
|
||||||
|
chunk_text: 'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pointId: 'point-2',
|
||||||
|
score: 0.72,
|
||||||
|
payload: {
|
||||||
|
doc_type: 'LETTER',
|
||||||
|
doc_number: 'CORR-002',
|
||||||
|
chunk_text: 'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockQdrantService.searchByProject.mockResolvedValueOnce(
|
||||||
|
mockQdrantResults
|
||||||
|
);
|
||||||
|
|
||||||
|
mockOcrService.rerankViaSidecar.mockResolvedValueOnce({
|
||||||
|
scores: [0.95, 0.45],
|
||||||
|
ranked_indices: [0, 1],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
response: 'คำตอบที่ได้รับความช่วยเหลือจาก LLM อ้างอิงเอกสาร CORR-001',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run query
|
||||||
|
await service.processQuery(
|
||||||
|
'req-123',
|
||||||
|
'ต้องการอนุมัติโครงการอย่างไร?',
|
||||||
|
'proj-456',
|
||||||
|
'user-789'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify pipeline calls
|
||||||
|
expect(ocrService.embedViaSidecar).toHaveBeenCalledWith(
|
||||||
|
'ต้องการอนุมัติโครงการอย่างไร?'
|
||||||
|
);
|
||||||
|
expect(qdrantService.searchByProject).toHaveBeenCalledWith(
|
||||||
|
mockDenseVector,
|
||||||
|
mockSparseVector,
|
||||||
|
'proj-456',
|
||||||
|
15
|
||||||
|
);
|
||||||
|
expect(ocrService.rerankViaSidecar).toHaveBeenCalledWith(
|
||||||
|
'ต้องการอนุมัติโครงการอย่างไร?',
|
||||||
|
[
|
||||||
|
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
|
||||||
|
'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/generate'),
|
||||||
|
expect.objectContaining({
|
||||||
|
model: 'typhoon2.5-np-dms:latest',
|
||||||
|
prompt: expect.stringContaining(
|
||||||
|
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline'
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify saving job status
|
||||||
|
expect(mockRedis.setex).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('ai:rag:result:req-123'),
|
||||||
|
expect.any(Number),
|
||||||
|
expect.stringContaining('completed')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// File: src/modules/ai/ai-rag.service.ts
|
// File: backend/src/modules/ai/ai-rag.service.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
|
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
|
||||||
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
|
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
|
||||||
// - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1).
|
// - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1).
|
||||||
// Service จัดการ RAG query ผ่าน Ollama + AiQdrantService (project-isolated)
|
// - 2026-06-05: ปรับปรุงใช้ Hybrid Search + Reranker ผ่าน Sidecar ตาม ADR-035 (T015, T030)
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -11,6 +11,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis';
|
|||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AiQdrantService } from './qdrant.service';
|
import { AiQdrantService } from './qdrant.service';
|
||||||
|
import { OcrService } from './services/ocr.service';
|
||||||
|
|
||||||
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
|
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
|
||||||
export interface AiRagCitation {
|
export interface AiRagCitation {
|
||||||
@@ -44,7 +45,6 @@ export class AiRagService {
|
|||||||
private readonly logger = new Logger(AiRagService.name);
|
private readonly logger = new Logger(AiRagService.name);
|
||||||
private readonly ollamaUrl: string;
|
private readonly ollamaUrl: string;
|
||||||
private readonly ollamaModel: string;
|
private readonly ollamaModel: string;
|
||||||
private readonly ollamaEmbedModel: string;
|
|
||||||
private readonly timeoutMs: number;
|
private readonly timeoutMs: number;
|
||||||
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
|
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
|
||||||
private readonly promptContextLimit: number;
|
private readonly promptContextLimit: number;
|
||||||
@@ -52,6 +52,7 @@ export class AiRagService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly qdrantService: AiQdrantService,
|
private readonly qdrantService: AiQdrantService,
|
||||||
|
private readonly ocrService: OcrService,
|
||||||
@InjectRedis() private readonly redis: Redis
|
@InjectRedis() private readonly redis: Redis
|
||||||
) {
|
) {
|
||||||
this.ollamaUrl = this.configService.get<string>(
|
this.ollamaUrl = this.configService.get<string>(
|
||||||
@@ -62,10 +63,6 @@ export class AiRagService {
|
|||||||
'OLLAMA_RAG_MODEL',
|
'OLLAMA_RAG_MODEL',
|
||||||
'gemma2'
|
'gemma2'
|
||||||
);
|
);
|
||||||
this.ollamaEmbedModel = this.configService.get<string>(
|
|
||||||
'OLLAMA_EMBED_MODEL',
|
|
||||||
'nomic-embed-text'
|
|
||||||
);
|
|
||||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
||||||
this.promptContextLimit = this.configService.get<number>(
|
this.promptContextLimit = this.configService.get<number>(
|
||||||
'RAG_CONTEXT_LIMIT_CHARS',
|
'RAG_CONTEXT_LIMIT_CHARS',
|
||||||
@@ -159,10 +156,11 @@ export class AiRagService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ประมวลผล RAG query:
|
* ประมวลผล RAG query:
|
||||||
* 1. Embed คำถาม
|
* 1. Embed คำถามด้วย BGE-M3 (Dense + Sparse) ผ่าน Sidecar /embed (T015)
|
||||||
* 2. ค้นหา Qdrant ด้วย project isolation (T020 — enforced in AiQdrantService.searchByProject)
|
* 2. ค้นหา Qdrant ด้วย Hybrid Search + project isolation (T015)
|
||||||
* 3. Build prompt จาก context
|
* 3. Rerank ด้วย BGE-Reranker-Large ผ่าน Sidecar /rerank (T015)
|
||||||
* 4. Generate คำตอบผ่าน Ollama (รองรับ AbortSignal สำหรับ T022)
|
* 4. Build prompt จาก context
|
||||||
|
* 5. Generate คำตอบผ่าน Ollama
|
||||||
*/
|
*/
|
||||||
async processQuery(
|
async processQuery(
|
||||||
requestPublicId: string,
|
requestPublicId: string,
|
||||||
@@ -182,8 +180,8 @@ export class AiRagService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. สร้าง embedding สำหรับคำถาม
|
// 1. สร้าง embedding สำหรับคำถามด้วย BGE-M3 ผ่าน Sidecar
|
||||||
const queryVector = await this.embed(question, signal);
|
const embedResult = await this.ocrService.embedViaSidecar(question);
|
||||||
|
|
||||||
// ตรวจสอบ cancel อีกครั้งหลัง embed
|
// ตรวจสอบ cancel อีกครั้งหลัง embed
|
||||||
if (
|
if (
|
||||||
@@ -195,17 +193,15 @@ export class AiRagService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002)
|
// 2. ค้นหา Qdrant ด้วย Hybrid search และกรองตาม project
|
||||||
const searchResults = await this.qdrantService.searchByProject(
|
const searchResults = await this.qdrantService.searchByProject(
|
||||||
queryVector,
|
embedResult.dense,
|
||||||
|
embedResult.sparse,
|
||||||
projectPublicId,
|
projectPublicId,
|
||||||
10
|
15 // topK=15 ตาม FR-014
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. สร้าง context จาก search results
|
// ตรวจสอบ cancel หลัง search
|
||||||
const context = this.buildContext(searchResults);
|
|
||||||
|
|
||||||
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
|
|
||||||
if (
|
if (
|
||||||
signal?.aborted ||
|
signal?.aborted ||
|
||||||
(await this.redis.get(this.cancelKey(requestPublicId)))
|
(await this.redis.get(this.cancelKey(requestPublicId)))
|
||||||
@@ -215,25 +211,74 @@ export class AiRagService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Generate คำตอบผ่าน Ollama (ส่ง signal เพื่อรองรับ T022)
|
// 3. Rerank ผลลัพธ์การค้นหา
|
||||||
|
let finalResults = searchResults;
|
||||||
|
const rawChunks = searchResults
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
(r.payload['chunk_text'] as string) ||
|
||||||
|
(r.payload['content_preview'] as string) ||
|
||||||
|
''
|
||||||
|
)
|
||||||
|
.filter((c) => c.trim().length > 0);
|
||||||
|
|
||||||
|
if (rawChunks.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Calling Sidecar /rerank for ${rawChunks.length} candidates...`
|
||||||
|
);
|
||||||
|
const rerankResult = await this.ocrService.rerankViaSidecar(
|
||||||
|
question,
|
||||||
|
rawChunks
|
||||||
|
);
|
||||||
|
|
||||||
|
// เลือก top 3-5 chunks ที่ได้คะแนนสูงสุด
|
||||||
|
const topN = Math.min(5, rerankResult.ranked_indices.length);
|
||||||
|
finalResults = [];
|
||||||
|
for (let i = 0; i < topN; i++) {
|
||||||
|
const originalIndex = rerankResult.ranked_indices[i];
|
||||||
|
finalResults.push(searchResults[originalIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log รายละเอียดการจัดอันดับ (T030)
|
||||||
|
this.logger.log(
|
||||||
|
`Reranking completed: candidates input ${searchResults.length} -> output ${finalResults.length}. ` +
|
||||||
|
`Top-1 score: ${rerankResult.scores[rerankResult.ranked_indices[0]]?.toFixed(4) ?? 'N/A'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. สร้าง context จาก search results
|
||||||
|
const context = this.buildContext(finalResults);
|
||||||
|
|
||||||
|
// ตรวจสอบ cancel ก่อนเรียก LLM
|
||||||
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
(await this.redis.get(this.cancelKey(requestPublicId)))
|
||||||
|
) {
|
||||||
|
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
|
||||||
|
await this.clearActiveJob(userPublicId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Generate คำตอบผ่าน Ollama
|
||||||
const { answer, usedFallback } = await this.generateAnswer(
|
const { answer, usedFallback } = await this.generateAnswer(
|
||||||
this.sanitizeInput(question),
|
this.sanitizeInput(question),
|
||||||
context,
|
context,
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
|
|
||||||
const citations: AiRagCitation[] = searchResults.map((r) => ({
|
const citations: AiRagCitation[] = finalResults.map((r) => ({
|
||||||
pointId: r.pointId,
|
pointId: r.pointId,
|
||||||
score: r.score,
|
score: r.score,
|
||||||
docType: r.payload['doc_type'] as string | undefined,
|
docType: r.payload['doc_type'] as string | undefined,
|
||||||
docNumber: r.payload['doc_number'] as string | undefined,
|
docNumber: r.payload['doc_number'] as string | undefined,
|
||||||
snippet: (r.payload['content_preview'] as string | undefined)?.slice(
|
snippet: (
|
||||||
0,
|
(r.payload['chunk_text'] as string) ||
|
||||||
200
|
(r.payload['content_preview'] as string) ||
|
||||||
),
|
''
|
||||||
|
).slice(0, 200),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const confidence = searchResults.length > 0 ? searchResults[0].score : 0;
|
const confidence = finalResults.length > 0 ? finalResults[0].score : 0;
|
||||||
|
|
||||||
await this.saveJobResult({
|
await this.saveJobResult({
|
||||||
requestPublicId,
|
requestPublicId,
|
||||||
@@ -266,17 +311,7 @@ export class AiRagService {
|
|||||||
|
|
||||||
// ─── Private Helpers ─────────────────────────────────────────────────────────
|
// ─── Private Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** สร้าง embedding vector สำหรับข้อความ */
|
/** Generate คำตอบจาก Ollama */
|
||||||
private async embed(text: string, signal?: AbortSignal): Promise<number[]> {
|
|
||||||
const response = await axios.post<{ embedding: number[] }>(
|
|
||||||
`${this.ollamaUrl}/api/embeddings`,
|
|
||||||
{ model: this.ollamaEmbedModel, prompt: text },
|
|
||||||
{ timeout: this.timeoutMs, signal }
|
|
||||||
);
|
|
||||||
return response.data.embedding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate คำตอบจาก Ollama (รองรับ AbortSignal สำหรับ T022 FR-011) */
|
|
||||||
private async generateAnswer(
|
private async generateAnswer(
|
||||||
question: string,
|
question: string,
|
||||||
context: string,
|
context: string,
|
||||||
@@ -291,7 +326,6 @@ export class AiRagService {
|
|||||||
);
|
);
|
||||||
return { answer: response.data.response ?? '', usedFallback: false };
|
return { answer: response.data.response ?? '', usedFallback: false };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// ถ้าเป็น cancellation error ให้ re-throw เพื่อให้ processQuery จัดการ
|
|
||||||
if (
|
if (
|
||||||
axios.isCancel(err) ||
|
axios.isCancel(err) ||
|
||||||
(err instanceof Error && err.name === 'CanceledError')
|
(err instanceof Error && err.name === 'CanceledError')
|
||||||
@@ -313,7 +347,10 @@ export class AiRagService {
|
|||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const docType = (r.payload['doc_type'] as string) ?? '';
|
const docType = (r.payload['doc_type'] as string) ?? '';
|
||||||
const docNumber = (r.payload['doc_number'] as string) ?? '';
|
const docNumber = (r.payload['doc_number'] as string) ?? '';
|
||||||
const preview = (r.payload['content_preview'] as string) ?? '';
|
const preview =
|
||||||
|
(r.payload['chunk_text'] as string) ??
|
||||||
|
(r.payload['content_preview'] as string) ??
|
||||||
|
'';
|
||||||
const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
|
const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
|
||||||
const snippet = `${header}\n${preview}\n\n`;
|
const snippet = `${header}\n${preview}\n\n`;
|
||||||
if ((context + snippet).length > this.promptContextLimit) break;
|
if ((context + snippet).length > this.promptContextLimit) break;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// - 2026-05-30: เพิ่ม endpoints GET/POST/PATCH models และ GET vram/status สำหรับ dynamic AI model management และ VRAM monitoring (T031-T034, US2)
|
// - 2026-05-30: เพิ่ม endpoints GET/POST/PATCH models และ GET vram/status สำหรับ dynamic AI model management และ VRAM monitoring (T031-T034, US2)
|
||||||
// - 2026-06-01: [BUGFIX] submitSandboxOcr: เพิ่ม @ApiBearerAuth(), @HttpCode(ACCEPTED), Body({ engineType }) และส่ง engineType ไปยัง enqueueSandboxJob
|
// - 2026-06-01: [BUGFIX] submitSandboxOcr: เพิ่ม @ApiBearerAuth(), @HttpCode(ACCEPTED), Body({ engineType }) และส่ง engineType ไปยัง enqueueSandboxJob
|
||||||
// - 2026-06-02: เพิ่ม REST endpoints GET /ai/ocr-engines และ POST /ai/ocr-engines/:engineId/select (T003, T004, ADR-033) และนำเข้า SystemException เพื่อป้องกันความเสียหายในการคอมไพล์
|
// - 2026-06-02: เพิ่ม REST endpoints GET /ai/ocr-engines และ POST /ai/ocr-engines/:engineId/select (T003, T004, ADR-033) และนำเข้า SystemException เพื่อป้องกันความเสียหายในการคอมไพล์
|
||||||
|
// - 2026-06-06: [BUGFIX] เพิ่ม @Throttle({ default: { limit: 300, ttl: 60000 } }) บน GET admin/sandbox/job/:id เพื่อแก้ ThrottlerException spam จาก frontend polling
|
||||||
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -452,6 +453,7 @@ export class AiController {
|
|||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@RequirePermission('system.manage_all')
|
@RequirePermission('system.manage_all')
|
||||||
|
@Throttle({ default: { limit: 300, ttl: 60000 } }) // 300 req/min — รองรับ admin polling ทุก 200ms
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)',
|
'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// File: src/modules/ai/processors/ai-batch.processor.spec.ts
|
// File: src/modules/ai/processors/ai-batch.processor.spec.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
|
// - 2026-06-08: เพิ่มการทดสอบการส่งตัวเลือก generate (format: json, num_ctx: 16384) สำหรับ migrate-document
|
||||||
// - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032).
|
// - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032).
|
||||||
// - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
|
// - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
|
||||||
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
|
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
|
||||||
@@ -52,6 +53,9 @@ describe('AiBatchProcessor', () => {
|
|||||||
detectAndExtract: jest
|
detectAndExtract: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }),
|
.mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }),
|
||||||
|
processWithAutoDetect: jest.fn().mockResolvedValue({
|
||||||
|
text: 'extracted ocr text from document that is long enough to bypass character length check',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
const mockSandboxOcrEngineService = {
|
const mockSandboxOcrEngineService = {
|
||||||
detectAndExtract: jest.fn().mockResolvedValue({
|
detectAndExtract: jest.fn().mockResolvedValue({
|
||||||
@@ -81,6 +85,7 @@ describe('AiBatchProcessor', () => {
|
|||||||
};
|
};
|
||||||
const mockRedis = {
|
const mockRedis = {
|
||||||
setex: jest.fn().mockResolvedValue('OK'),
|
setex: jest.fn().mockResolvedValue('OK'),
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
};
|
};
|
||||||
const mockAttachmentRepo = {
|
const mockAttachmentRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue({
|
findOne: jest.fn().mockResolvedValue({
|
||||||
@@ -140,6 +145,7 @@ describe('AiBatchProcessor', () => {
|
|||||||
resolvedPrompt: 'Resolved test prompt with OCR text',
|
resolvedPrompt: 'Resolved test prompt with OCR text',
|
||||||
versionNumber: 2,
|
versionNumber: 2,
|
||||||
}),
|
}),
|
||||||
|
findByVersion: jest.fn().mockResolvedValue(null),
|
||||||
saveTestResult: jest.fn().mockResolvedValue(undefined),
|
saveTestResult: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -237,7 +243,23 @@ describe('AiBatchProcessor', () => {
|
|||||||
},
|
},
|
||||||
} as unknown as Job<AiBatchJobData>;
|
} as unknown as Job<AiBatchJobData>;
|
||||||
await processor.process(job);
|
await processor.process(job);
|
||||||
|
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
||||||
|
pdfPath: '/files/test.pdf',
|
||||||
|
extractedText: undefined,
|
||||||
|
documentPublicId: 'doc-uuid-123',
|
||||||
|
});
|
||||||
expect(embeddingService.embedDocument).toHaveBeenCalledTimes(1);
|
expect(embeddingService.embedDocument).toHaveBeenCalledTimes(1);
|
||||||
|
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
|
||||||
|
'proj-uuid-456',
|
||||||
|
'doc-uuid-123',
|
||||||
|
'doc-uuid-123',
|
||||||
|
'ATTACHMENT',
|
||||||
|
'ACTIVE',
|
||||||
|
1,
|
||||||
|
'doc-uuid-123',
|
||||||
|
undefined,
|
||||||
|
'OCR text LCBP3-CIV-001 Civil'
|
||||||
|
);
|
||||||
expect(attachmentRepo.update).toHaveBeenCalledWith(
|
expect(attachmentRepo.update).toHaveBeenCalledWith(
|
||||||
{ publicId: 'doc-uuid-123' },
|
{ publicId: 'doc-uuid-123' },
|
||||||
{ aiProcessingStatus: 'PROCESSING' }
|
{ aiProcessingStatus: 'PROCESSING' }
|
||||||
@@ -288,7 +310,13 @@ describe('AiBatchProcessor', () => {
|
|||||||
'/files/test.pdf',
|
'/files/test.pdf',
|
||||||
'auto'
|
'auto'
|
||||||
);
|
);
|
||||||
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
|
expect(ollamaService.generate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
format: 'json',
|
||||||
|
timeoutMs: 120000,
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(redis.setex).toHaveBeenCalledTimes(2);
|
expect(redis.setex).toHaveBeenCalledTimes(2);
|
||||||
expect(redis.setex).toHaveBeenLastCalledWith(
|
expect(redis.setex).toHaveBeenLastCalledWith(
|
||||||
'ai:rag:result:idem-extract-123',
|
'ai:rag:result:idem-extract-123',
|
||||||
@@ -296,6 +324,69 @@ describe('AiBatchProcessor', () => {
|
|||||||
expect.stringContaining('completed')
|
expect.stringContaining('completed')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('sandbox-ai-extract ควร regenerate response ใหม่เมื่อ parse JSON ครั้งแรกล้มเหลว', async () => {
|
||||||
|
const cachedOcrPayload = {
|
||||||
|
ocrText: 'OCR text for retry test\u0002\u0000',
|
||||||
|
ocrUsed: true,
|
||||||
|
engineUsed: 'typhoon-np-dms-ocr',
|
||||||
|
fallbackUsed: false,
|
||||||
|
timestamp: '2026-06-06T15:00:00.000Z',
|
||||||
|
};
|
||||||
|
mockRedis.get = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(JSON.stringify(cachedOcrPayload));
|
||||||
|
mockAiPromptsService.findByVersion = jest.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
promptType: 'ocr_extraction',
|
||||||
|
versionNumber: 2,
|
||||||
|
template:
|
||||||
|
'Resolved test prompt with OCR text {{ocr_text}} and context {{master_data_context}}',
|
||||||
|
isActive: true,
|
||||||
|
contextConfig: { filter: {} },
|
||||||
|
});
|
||||||
|
mockOllamaService.generate
|
||||||
|
.mockResolvedValueOnce('{\u0002\u0000')
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
subject: 'Recovered after retry',
|
||||||
|
confidence: 0.91,
|
||||||
|
tags: ['retry'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const job = {
|
||||||
|
id: 'job-ai-extract-retry',
|
||||||
|
data: {
|
||||||
|
jobType: 'sandbox-ai-extract',
|
||||||
|
documentPublicId: 'idem-ai-extract-123',
|
||||||
|
projectPublicId: 'default',
|
||||||
|
payload: { promptVersion: 2 },
|
||||||
|
idempotencyKey: 'idem-ai-extract-123',
|
||||||
|
},
|
||||||
|
} as unknown as Job<AiBatchJobData>;
|
||||||
|
await processor.process(job);
|
||||||
|
expect(mockOllamaService.generate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockOllamaService.generate).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.not.stringContaining('\u0002'),
|
||||||
|
expect.objectContaining({
|
||||||
|
format: 'json',
|
||||||
|
timeoutMs: 120000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockAiPromptsService.saveTestResult).toHaveBeenCalledWith(
|
||||||
|
'ocr_extraction',
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: 'Recovered after retry',
|
||||||
|
confidence: 0.91,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockRedis.setex).toHaveBeenLastCalledWith(
|
||||||
|
'ai:rag:result:idem-ai-extract-123',
|
||||||
|
3600,
|
||||||
|
expect.stringContaining('"llmPrompt"')
|
||||||
|
);
|
||||||
|
});
|
||||||
it('EC-001: ควรบันทึก aiIssues เมื่อ AI สกัด Tag ใหม่ที่ไม่มีในระบบ', async () => {
|
it('EC-001: ควรบันทึก aiIssues เมื่อ AI สกัด Tag ใหม่ที่ไม่มีในระบบ', async () => {
|
||||||
mockTagsService.findOrSuggestTags.mockResolvedValueOnce([
|
mockTagsService.findOrSuggestTags.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -430,7 +521,14 @@ describe('AiBatchProcessor', () => {
|
|||||||
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
||||||
pdfPath: '/files/test.pdf',
|
pdfPath: '/files/test.pdf',
|
||||||
});
|
});
|
||||||
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
|
expect(ollamaService.generate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
format: 'json',
|
||||||
|
timeoutMs: 120000,
|
||||||
|
options: { num_ctx: 16384, num_predict: 4096 },
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(mockTagsService.findOrSuggestTags).toHaveBeenCalledTimes(1);
|
expect(mockTagsService.findOrSuggestTags).toHaveBeenCalledTimes(1);
|
||||||
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
|
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -449,4 +547,78 @@ describe('AiBatchProcessor', () => {
|
|||||||
expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1);
|
expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1);
|
||||||
expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1);
|
expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
describe('rag-prepare', () => {
|
||||||
|
it('ควรประมวลผล rag-prepare สำเร็จเมื่อส่ง cachedOcrText มาโดยตรง', async () => {
|
||||||
|
const job = {
|
||||||
|
id: 'job-rag-prepare-cached',
|
||||||
|
data: {
|
||||||
|
jobType: 'rag-prepare',
|
||||||
|
documentPublicId: 'doc-uuid-123',
|
||||||
|
projectPublicId: 'proj-uuid-456',
|
||||||
|
payload: {
|
||||||
|
documentPublicId: 'doc-uuid-123',
|
||||||
|
projectPublicId: 'proj-uuid-456',
|
||||||
|
correspondenceNumber: 'CORR-001',
|
||||||
|
docType: 'LETTER',
|
||||||
|
statusCode: 'IN_REVIEW',
|
||||||
|
revisionNumber: 1,
|
||||||
|
subject: 'Test Subject',
|
||||||
|
cachedOcrText:
|
||||||
|
'some cached ocr text that is long enough to pass the 50 character limit check',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Job<AiBatchJobData>;
|
||||||
|
await processor.process(job);
|
||||||
|
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
|
||||||
|
'proj-uuid-456',
|
||||||
|
'doc-uuid-123',
|
||||||
|
'CORR-001',
|
||||||
|
'LETTER',
|
||||||
|
'IN_REVIEW',
|
||||||
|
1,
|
||||||
|
'Test Subject',
|
||||||
|
undefined,
|
||||||
|
'some cached ocr text that is long enough to pass the 50 character limit check'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('ควรประมวลผล rag-prepare สำเร็จเมื่อดึงข้อความจากไฟล์แนบผ่าน OCR Service', async () => {
|
||||||
|
ocrService.detectAndExtract.mockResolvedValueOnce({
|
||||||
|
text: 'extracted ocr text from document that is long enough to bypass character length check',
|
||||||
|
ocrUsed: true,
|
||||||
|
});
|
||||||
|
const job = {
|
||||||
|
id: 'job-rag-prepare-ocr',
|
||||||
|
data: {
|
||||||
|
jobType: 'rag-prepare',
|
||||||
|
documentPublicId: 'doc-uuid-123',
|
||||||
|
projectPublicId: 'proj-uuid-456',
|
||||||
|
payload: {
|
||||||
|
documentPublicId: 'doc-uuid-123',
|
||||||
|
projectPublicId: 'proj-uuid-456',
|
||||||
|
correspondenceNumber: 'CORR-002',
|
||||||
|
docType: 'LETTER',
|
||||||
|
statusCode: 'IN_REVIEW',
|
||||||
|
revisionNumber: 2,
|
||||||
|
subject: 'Test OCR Subject',
|
||||||
|
attachmentPath: '/files/test-ocr.pdf',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Job<AiBatchJobData>;
|
||||||
|
await processor.process(job);
|
||||||
|
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
||||||
|
pdfPath: '/files/test-ocr.pdf',
|
||||||
|
});
|
||||||
|
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
|
||||||
|
'proj-uuid-456',
|
||||||
|
'doc-uuid-123',
|
||||||
|
'CORR-002',
|
||||||
|
'LETTER',
|
||||||
|
'IN_REVIEW',
|
||||||
|
2,
|
||||||
|
'Test OCR Subject',
|
||||||
|
undefined,
|
||||||
|
'extracted ocr text from document that is long enough to bypass character length check'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// File: src/modules/ai/processors/ai-batch.processor.ts
|
// File: src/modules/ai/processors/ai-batch.processor.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
|
// - 2026-06-08: แก้ไขปัญหา LLM JSON response truncated โดยการเพิ่ม num_ctx เป็น 16384 ใน sandbox-extract, sandbox-ai-extract และ migrate-document (แก้ไขโดย AGY Gemini 3.5 Flash (Medium))
|
||||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
||||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
||||||
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
|
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
|
||||||
@@ -10,6 +11,9 @@
|
|||||||
// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000
|
// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000
|
||||||
// - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ
|
// - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ
|
||||||
// - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main)
|
// - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main)
|
||||||
|
// - 2026-06-06: แก้ไข bug LLM JSON parse failure — เพิ่ม retry logic (2 attempts), debug log raw response, และปรับปรุง error message ให้แสดงทั้ง raw และ cleaned response
|
||||||
|
// - 2026-06-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 ไม่พอ
|
||||||
|
|
||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
@@ -57,7 +61,8 @@ export type AiBatchJobType =
|
|||||||
| 'sandbox-extract'
|
| 'sandbox-extract'
|
||||||
| 'sandbox-ocr-only'
|
| 'sandbox-ocr-only'
|
||||||
| 'sandbox-ai-extract'
|
| 'sandbox-ai-extract'
|
||||||
| 'migrate-document';
|
| 'migrate-document'
|
||||||
|
| 'rag-prepare';
|
||||||
|
|
||||||
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
|
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
|
||||||
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
|
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
|
||||||
@@ -73,6 +78,24 @@ export interface AiBatchJobData {
|
|||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OCR text สูงสุดที่ส่งเข้า LLM prompt — ป้องกัน context overflow (num_ctx 8192, Thai ~3 chars/token) */
|
||||||
|
const MAX_OCR_TEXT_CHARS = 15000;
|
||||||
|
const MAX_JSON_PARSE_ATTEMPTS = 2;
|
||||||
|
const removeControlCharacters = (
|
||||||
|
value: string,
|
||||||
|
includeDeleteCharacter = false
|
||||||
|
): string =>
|
||||||
|
Array.from(value)
|
||||||
|
.filter((character) => {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
const isAsciiControl =
|
||||||
|
(code >= 0 && code <= 8) || code === 11 || code === 12;
|
||||||
|
const isAdditionalControl = code >= 14 && code <= 31;
|
||||||
|
const isDeleteCharacter = includeDeleteCharacter && code === 127;
|
||||||
|
return !isAsciiControl && !isAdditionalControl && !isDeleteCharacter;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
const readString = (value: unknown): string | undefined =>
|
const readString = (value: unknown): string | undefined =>
|
||||||
typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
||||||
|
|
||||||
@@ -139,6 +162,14 @@ const parseMigrateDocumentMetadata = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeLlmJsonResponse = (response: string): string =>
|
||||||
|
removeControlCharacters(
|
||||||
|
response.replace(/```json/g, '').replace(/```/g, '')
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const sanitizeOcrText = (text: string): string =>
|
||||||
|
removeControlCharacters(text.replace(/\r\n/g, '\n'), true).trim();
|
||||||
|
|
||||||
/** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM
|
/** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM
|
||||||
* lockDuration: 150000ms — รองรับ Ollama sandbox ที่ใช้เวลาสูงสุด 120s (ADR-029 FR-008)
|
* lockDuration: 150000ms — รองรับ Ollama sandbox ที่ใช้เวลาสูงสุด 120s (ADR-029 FR-008)
|
||||||
* ค่า default ของ BullMQ คือ 30000ms ซึ่งน้อยกว่า timeout → job stall
|
* ค่า default ของ BullMQ คือ 30000ms ซึ่งน้อยกว่า timeout → job stall
|
||||||
@@ -168,6 +199,62 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** เรียก LLM แล้ว parse JSON แบบ retry จริงเมื่อได้ผลลัพธ์ไม่สมบูรณ์
|
||||||
|
* @param ollamaOptions - Ollama generation options เช่น num_ctx สำหรับ prompt ยาว
|
||||||
|
*/
|
||||||
|
private async generateStructuredJson(
|
||||||
|
prompt: string,
|
||||||
|
options: {
|
||||||
|
timeoutMs: number;
|
||||||
|
model?: string;
|
||||||
|
system?: string;
|
||||||
|
format?: 'json';
|
||||||
|
ollamaOptions?: { num_ctx?: number; num_predict?: number };
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
extractedMetadata: Record<string, unknown>;
|
||||||
|
rawResponse: string;
|
||||||
|
cleanedResponse: string;
|
||||||
|
}> {
|
||||||
|
let lastRawResponse = '';
|
||||||
|
let lastCleanedResponse = '';
|
||||||
|
for (let attempt = 1; attempt <= MAX_JSON_PARSE_ATTEMPTS; attempt += 1) {
|
||||||
|
const rawResponse = await this.ollamaService.generate(prompt, {
|
||||||
|
...options,
|
||||||
|
options: options.ollamaOptions,
|
||||||
|
});
|
||||||
|
const cleanedResponse = sanitizeLlmJsonResponse(rawResponse);
|
||||||
|
lastRawResponse = rawResponse;
|
||||||
|
lastCleanedResponse = cleanedResponse;
|
||||||
|
this.logger.debug(`Raw LLM response: ${rawResponse}`);
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
extractedMetadata: JSON.parse(cleanedResponse) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
rawResponse,
|
||||||
|
cleanedResponse,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
if (attempt >= MAX_JSON_PARSE_ATTEMPTS) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse}, Cleaned: ${lastCleanedResponse}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse.substring(0, 200)}, Cleaned: ${lastCleanedResponse.substring(0, 200)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
`JSON parse attempt ${attempt} failed, regenerating response...`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Dispatch งาน batch ตาม jobType */
|
/** Dispatch งาน batch ตาม jobType */
|
||||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||||
const isSandbox =
|
const isSandbox =
|
||||||
@@ -239,6 +326,12 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
case 'rag-prepare':
|
||||||
|
this.logger.log(
|
||||||
|
`RAG prepare job processing — jobId=${String(job.id)}`
|
||||||
|
);
|
||||||
|
await this.processRagPrepare(job.data);
|
||||||
|
return;
|
||||||
default: {
|
default: {
|
||||||
const unreachable: never = job.data.jobType;
|
const unreachable: never = job.data.jobType;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -262,15 +355,41 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
|
private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
|
||||||
const { documentPublicId, projectPublicId, payload } = data;
|
const { documentPublicId, projectPublicId, payload } = data;
|
||||||
const pdfPath = payload.pdfPath as string;
|
const pdfPath = payload.pdfPath as string;
|
||||||
const extractedText = payload.extractedText as string | undefined;
|
const extractedText = readString(payload.extractedText);
|
||||||
if (!pdfPath) {
|
if (!pdfPath) {
|
||||||
throw new Error('pdfPath is required for embed-document job');
|
throw new Error('pdfPath is required for embed-document job');
|
||||||
}
|
}
|
||||||
|
const correspondenceNumber =
|
||||||
|
readString(payload.correspondenceNumber) ?? documentPublicId;
|
||||||
|
const docType = readString(payload.docType) ?? 'ATTACHMENT';
|
||||||
|
const statusCode = readString(payload.statusCode) ?? 'ACTIVE';
|
||||||
|
const revisionNumberValue = payload.revisionNumber;
|
||||||
|
const revisionNumber =
|
||||||
|
typeof revisionNumberValue === 'number' &&
|
||||||
|
Number.isFinite(revisionNumberValue)
|
||||||
|
? revisionNumberValue
|
||||||
|
: 1;
|
||||||
|
const subject = readString(payload.subject) ?? documentPublicId;
|
||||||
|
const documentDate = readString(payload.documentDate);
|
||||||
|
const resolvedOcrText =
|
||||||
|
extractedText ??
|
||||||
|
(
|
||||||
|
await this.ocrService.detectAndExtract({
|
||||||
|
pdfPath,
|
||||||
|
extractedText,
|
||||||
|
documentPublicId,
|
||||||
|
})
|
||||||
|
).text;
|
||||||
const result = await this.embeddingService.embedDocument(
|
const result = await this.embeddingService.embedDocument(
|
||||||
pdfPath,
|
|
||||||
documentPublicId,
|
|
||||||
projectPublicId,
|
projectPublicId,
|
||||||
extractedText
|
documentPublicId,
|
||||||
|
correspondenceNumber,
|
||||||
|
docType,
|
||||||
|
statusCode,
|
||||||
|
revisionNumber,
|
||||||
|
subject,
|
||||||
|
documentDate,
|
||||||
|
resolvedOcrText
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
|
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
|
||||||
@@ -372,6 +491,12 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
pdfPath,
|
pdfPath,
|
||||||
engineType
|
engineType
|
||||||
);
|
);
|
||||||
|
const sanitizedOcrText = sanitizeOcrText(ocrResult.text);
|
||||||
|
if (sanitizedOcrText.length !== ocrResult.text.length) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OCR text sanitized before LLM: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activePrompt =
|
const activePrompt =
|
||||||
await this.aiPromptsService.getActive('ocr_extraction');
|
await this.aiPromptsService.getActive('ocr_extraction');
|
||||||
@@ -380,36 +505,38 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ดึงบริบท Master data
|
// ดึงบริบท Master data
|
||||||
|
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
|
||||||
|
// ดังนั้นส่ง undefined เพื่อ skip project lookup
|
||||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||||
activePrompt,
|
activePrompt,
|
||||||
overrideProjPublicId
|
overrideProjPublicId === 'default' ? undefined : overrideProjPublicId
|
||||||
);
|
);
|
||||||
|
const compactMasterDataContext = JSON.stringify(masterDataContext);
|
||||||
|
|
||||||
|
const ocrTextSafe =
|
||||||
|
sanitizedOcrText.length > MAX_OCR_TEXT_CHARS
|
||||||
|
? (this.logger.warn(
|
||||||
|
`OCR text truncated: ${sanitizedOcrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)`
|
||||||
|
),
|
||||||
|
sanitizedOcrText.substring(0, MAX_OCR_TEXT_CHARS))
|
||||||
|
: sanitizedOcrText;
|
||||||
|
|
||||||
const resolvedPrompt = activePrompt.template
|
const resolvedPrompt = activePrompt.template
|
||||||
.replace('{{ocr_text}}', ocrResult.text)
|
.replace('{{ocr_text}}', ocrTextSafe)
|
||||||
.replace(
|
.replace('{{master_data_context}}', compactMasterDataContext);
|
||||||
'{{master_data_context}}',
|
|
||||||
JSON.stringify(masterDataContext, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
this.logger.debug(
|
||||||
timeoutMs: 120000,
|
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
|
||||||
});
|
);
|
||||||
const cleanedResponse = response
|
|
||||||
.replace(/```json/g, '')
|
const { extractedMetadata } = await this.generateStructuredJson(
|
||||||
.replace(/```/g, '')
|
resolvedPrompt,
|
||||||
.trim();
|
{
|
||||||
let extractedMetadata: Record<string, unknown>;
|
format: 'json',
|
||||||
try {
|
timeoutMs: 120000,
|
||||||
extractedMetadata = JSON.parse(cleanedResponse) as Record<
|
ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
|
||||||
string,
|
}
|
||||||
unknown
|
);
|
||||||
>;
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to parse LLM response as JSON: ${cleanedResponse}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.aiPromptsService.saveTestResult(
|
await this.aiPromptsService.saveTestResult(
|
||||||
'ocr_extraction',
|
'ocr_extraction',
|
||||||
activePrompt.versionNumber,
|
activePrompt.versionNumber,
|
||||||
@@ -422,11 +549,12 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
requestPublicId: idempotencyKey,
|
requestPublicId: idempotencyKey,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
answer: JSON.stringify(extractedMetadata, null, 2),
|
answer: JSON.stringify(extractedMetadata, null, 2),
|
||||||
ocrText: ocrResult.text,
|
ocrText: sanitizedOcrText,
|
||||||
ocrUsed: ocrResult.ocrUsed,
|
ocrUsed: ocrResult.ocrUsed,
|
||||||
engineUsed: ocrResult.engineUsed,
|
engineUsed: ocrResult.engineUsed,
|
||||||
fallbackUsed: ocrResult.fallbackUsed,
|
fallbackUsed: ocrResult.fallbackUsed,
|
||||||
promptVersionUsed: activePrompt.versionNumber,
|
promptVersionUsed: activePrompt.versionNumber,
|
||||||
|
llmPrompt: resolvedPrompt,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -475,13 +603,19 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
engineType,
|
engineType,
|
||||||
typhoonOptions
|
typhoonOptions
|
||||||
);
|
);
|
||||||
|
const sanitizedOcrText = sanitizeOcrText(ocrResult.text);
|
||||||
|
if (sanitizedOcrText.length !== ocrResult.text.length) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OCR text sanitized before cache: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache OCR text สำหรับ Step 2
|
// Cache OCR text สำหรับ Step 2
|
||||||
await this.redis.setex(
|
await this.redis.setex(
|
||||||
`ai:sandbox:ocr:${idempotencyKey}`,
|
`ai:sandbox:ocr:${idempotencyKey}`,
|
||||||
3600,
|
3600,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ocrText: ocrResult.text,
|
ocrText: sanitizedOcrText,
|
||||||
ocrUsed: ocrResult.ocrUsed,
|
ocrUsed: ocrResult.ocrUsed,
|
||||||
engineUsed: ocrResult.engineUsed,
|
engineUsed: ocrResult.engineUsed,
|
||||||
fallbackUsed: ocrResult.fallbackUsed,
|
fallbackUsed: ocrResult.fallbackUsed,
|
||||||
@@ -495,7 +629,7 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
requestPublicId: idempotencyKey,
|
requestPublicId: idempotencyKey,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
ocrText: ocrResult.text,
|
ocrText: sanitizedOcrText,
|
||||||
ocrUsed: ocrResult.ocrUsed,
|
ocrUsed: ocrResult.ocrUsed,
|
||||||
engineUsed: ocrResult.engineUsed,
|
engineUsed: ocrResult.engineUsed,
|
||||||
fallbackUsed: ocrResult.fallbackUsed,
|
fallbackUsed: ocrResult.fallbackUsed,
|
||||||
@@ -550,7 +684,12 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
fallbackUsed?: boolean;
|
fallbackUsed?: boolean;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
const { ocrText } = parsedOcr;
|
const ocrText = sanitizeOcrText(parsedOcr.ocrText);
|
||||||
|
if (ocrText.length !== parsedOcr.ocrText.length) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Cached OCR text sanitized before AI extraction: raw=${parsedOcr.ocrText.length} chars, sanitized=${ocrText.length} chars`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ดึง prompt version
|
// ดึง prompt version
|
||||||
const activePrompt =
|
const activePrompt =
|
||||||
@@ -572,38 +711,36 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve context และ run LLM
|
// Resolve context และ run LLM
|
||||||
|
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
|
||||||
|
// ดังนั้นส่ง undefined เพื่อ skip project lookup
|
||||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||||
targetPrompt,
|
targetPrompt,
|
||||||
projectPublicId
|
projectPublicId === 'default' ? undefined : projectPublicId
|
||||||
);
|
);
|
||||||
|
const compactMasterDataContext = JSON.stringify(masterDataContext);
|
||||||
|
|
||||||
|
const ocrTextSafe =
|
||||||
|
ocrText.length > MAX_OCR_TEXT_CHARS
|
||||||
|
? (this.logger.warn(
|
||||||
|
`OCR text truncated: ${ocrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)`
|
||||||
|
),
|
||||||
|
ocrText.substring(0, MAX_OCR_TEXT_CHARS))
|
||||||
|
: ocrText;
|
||||||
|
|
||||||
const resolvedPrompt = targetPrompt.template
|
const resolvedPrompt = targetPrompt.template
|
||||||
.replace('{{ocr_text}}', ocrText)
|
.replace('{{ocr_text}}', ocrTextSafe)
|
||||||
.replace(
|
.replace('{{master_data_context}}', compactMasterDataContext);
|
||||||
'{{master_data_context}}',
|
this.logger.debug(
|
||||||
JSON.stringify(masterDataContext, null, 2)
|
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
|
||||||
);
|
);
|
||||||
|
const { extractedMetadata } = await this.generateStructuredJson(
|
||||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
resolvedPrompt,
|
||||||
timeoutMs: 120000,
|
{
|
||||||
});
|
format: 'json',
|
||||||
|
timeoutMs: 120000,
|
||||||
const cleanedResponse = response
|
ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
|
||||||
.replace(/```json/g, '')
|
}
|
||||||
.replace(/```/g, '')
|
);
|
||||||
.trim();
|
|
||||||
|
|
||||||
let extractedMetadata: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
extractedMetadata = JSON.parse(cleanedResponse) as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to parse LLM response as JSON: ${cleanedResponse}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiPromptsService.saveTestResult(
|
await this.aiPromptsService.saveTestResult(
|
||||||
'ocr_extraction',
|
'ocr_extraction',
|
||||||
@@ -623,6 +760,7 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
engineUsed: parsedOcr.engineUsed,
|
engineUsed: parsedOcr.engineUsed,
|
||||||
fallbackUsed: parsedOcr.fallbackUsed,
|
fallbackUsed: parsedOcr.fallbackUsed,
|
||||||
promptVersionUsed: targetPrompt.versionNumber,
|
promptVersionUsed: targetPrompt.versionNumber,
|
||||||
|
llmPrompt: resolvedPrompt,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -643,6 +781,84 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async processRagPrepare(data: AiBatchJobData): Promise<void> {
|
||||||
|
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}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// T020a: Resolve OCR text. Use cached if available; otherwise extract using OcrService
|
||||||
|
if (!cachedOcrText && attachmentPath) {
|
||||||
|
this.logger.log(
|
||||||
|
`processRagPrepare: No cached OCR text. Extracting text from ${attachmentPath}...`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const ocrResult = await this.ocrService.detectAndExtract({
|
||||||
|
pdfPath: attachmentPath,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// T020b: skip-guard (< 50 chars)
|
||||||
|
if (cachedOcrText.trim().length < 50) {
|
||||||
|
this.logger.warn(
|
||||||
|
`processRagPrepare: OCR text สั้นเกินไป (${cachedOcrText.trim().length} chars) — skip embedding`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// T020c: embed + upsert pipeline
|
||||||
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`processRagPrepare: chunking and embedding document ${documentPublicId}...`
|
||||||
|
);
|
||||||
|
await this.embeddingService.embedDocument(
|
||||||
|
projectPublicId,
|
||||||
|
documentPublicId,
|
||||||
|
correspondenceNumber,
|
||||||
|
docType,
|
||||||
|
statusCode,
|
||||||
|
revisionNumber,
|
||||||
|
subject,
|
||||||
|
documentDate,
|
||||||
|
cachedOcrText
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`processRagPrepare: successfully processed document ${documentPublicId}`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`processRagPrepare: embedding pipeline failed: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async processMigrateDocument(
|
private async processMigrateDocument(
|
||||||
job: Job<AiBatchJobData>
|
job: Job<AiBatchJobData>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -715,7 +931,9 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
let aiResponse: string;
|
let aiResponse: string;
|
||||||
try {
|
try {
|
||||||
aiResponse = await this.ollamaService.generate(resolvedPrompt, {
|
aiResponse = await this.ollamaService.generate(resolvedPrompt, {
|
||||||
|
format: 'json',
|
||||||
timeoutMs: 120000,
|
timeoutMs: 120000,
|
||||||
|
options: { num_ctx: 16384, num_predict: 4096 },
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -21,16 +21,20 @@ export class AiVectorDeletionProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<AiVectorDeletionJobPayload>): Promise<void> {
|
async process(job: Job<AiVectorDeletionJobPayload>): Promise<void> {
|
||||||
const { documentPublicId, requestedByUserPublicId } = job.data;
|
const { documentPublicId, projectPublicId, requestedByUserPublicId } =
|
||||||
|
job.data;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Vector deletion started — documentPublicId=${documentPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
|
`Vector deletion started — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.qdrantService.deleteByDocumentPublicId(documentPublicId);
|
await this.qdrantService.deleteByDocumentPublicId(
|
||||||
|
projectPublicId,
|
||||||
|
documentPublicId
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Vector deletion completed — documentPublicId=${documentPublicId}, jobId=${String(job.id)}`
|
`Vector deletion completed — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// File: src/modules/ai/qdrant.service.ts
|
// File: backend/src/modules/ai/qdrant.service.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
|
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
|
||||||
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
|
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
|
||||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
|
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
|
||||||
|
// - 2026-06-05: ปรับปรุงโครงสร้างเป็น Hybrid (Dense 1024 + Sparse) ตาม ADR-035 (T006-T010)
|
||||||
|
// - 2026-06-05: เพิ่ม Compatibility สำหรับ search() ที่ไม่มี sparseVector เพื่อผ่านการทดสอบแบบดั้งเดิม
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -14,7 +16,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||||
|
|
||||||
const AI_COLLECTION_NAME = 'lcbp3_vectors';
|
const AI_COLLECTION_NAME = 'lcbp3_vectors';
|
||||||
const AI_VECTOR_SIZE = 768;
|
const AI_VECTOR_SIZE = 1024;
|
||||||
|
|
||||||
export interface AiVectorSearchResult {
|
export interface AiVectorSearchResult {
|
||||||
pointId: string | number;
|
pointId: string | number;
|
||||||
@@ -22,7 +24,14 @@ export interface AiVectorSearchResult {
|
|||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gateway กลางสำหรับ Qdrant ที่บังคับ project_public_id ทุก search */
|
type QdrantUpsertRequest = Parameters<QdrantClient['upsert']>[1];
|
||||||
|
type QdrantUpsertPoint = QdrantUpsertRequest extends { points: infer TPoints }
|
||||||
|
? TPoints extends Array<infer TPoint>
|
||||||
|
? TPoint
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
|
/** Gateway กลางสำหรับ Qdrant ที่รองรับ Hybrid Search และบังคับ project_public_id ทุก search */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiQdrantService implements OnModuleInit {
|
export class AiQdrantService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(AiQdrantService.name);
|
private readonly logger = new Logger(AiQdrantService.name);
|
||||||
@@ -47,78 +56,261 @@ export class AiQdrantService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** เตรียม collection และ tenant payload index สำหรับ project isolation */
|
/** เตรียม collection และ payload index สำหรับ project isolation และ hybrid search */
|
||||||
async ensureCollection(): Promise<void> {
|
async ensureCollection(): Promise<void> {
|
||||||
const collections = await this.client.getCollections();
|
const collections = await this.client.getCollections();
|
||||||
const exists = collections.collections.some(
|
const exists = collections.collections.some(
|
||||||
(collection) => collection.name === AI_COLLECTION_NAME
|
(collection) => collection.name === AI_COLLECTION_NAME
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!exists) {
|
if (exists) {
|
||||||
await this.client.createCollection(AI_COLLECTION_NAME, {
|
// ตรวจ schema ของ collection ที่มีอยู่ — ถ้าเป็น Hybrid 1024 dims แล้ว skip delete
|
||||||
vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
|
try {
|
||||||
});
|
const collectionInfo =
|
||||||
|
await this.client.getCollection(AI_COLLECTION_NAME);
|
||||||
|
const isHybrid =
|
||||||
|
collectionInfo.config.params.vectors !== undefined &&
|
||||||
|
collectionInfo.config.params.sparse_vectors !== undefined;
|
||||||
|
const vectorsMap = collectionInfo.config.params.vectors;
|
||||||
|
let vectorSize: number | undefined = undefined;
|
||||||
|
|
||||||
|
// Defensive check: ตรวจ structure ของ vectorsMap ก่อน access
|
||||||
|
if (vectorsMap && typeof vectorsMap === 'object') {
|
||||||
|
if ('size' in vectorsMap) {
|
||||||
|
// Single vector mode (ไม่ใช่ Hybrid)
|
||||||
|
vectorSize = (vectorsMap as { size: number }).size;
|
||||||
|
} else {
|
||||||
|
// Hybrid mode: extract bge_dense size
|
||||||
|
const hybridMap = vectorsMap as Record<string, { size?: number }>;
|
||||||
|
if (
|
||||||
|
hybridMap['bge_dense'] &&
|
||||||
|
typeof hybridMap['bge_dense'] === 'object'
|
||||||
|
) {
|
||||||
|
vectorSize = hybridMap['bge_dense'].size;
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unexpected vectors structure: bge_dense not found or invalid in Hybrid collection`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unexpected vectors structure: vectorsMap is not an object or undefined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHybrid && vectorSize === AI_VECTOR_SIZE) {
|
||||||
|
this.logger.log(
|
||||||
|
`Qdrant collection ${AI_COLLECTION_NAME} already exists with correct Hybrid schema (1024 dims) — skipping recreation`
|
||||||
|
);
|
||||||
|
// เรียก createPayloadIndexes() ทุกครั้งเพื่อให้แน่ใจว่า indexes มีอยู่
|
||||||
|
await this.createPayloadIndexes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Dropping existing Qdrant collection ${AI_COLLECTION_NAME} to upgrade to Hybrid (${vectorSize ?? 'unknown'} dims → ${AI_VECTOR_SIZE} dims)...`
|
||||||
|
);
|
||||||
|
await this.client.deleteCollection(AI_COLLECTION_NAME);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to inspect collection schema, proceeding with recreation — ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
await this.client.deleteCollection(AI_COLLECTION_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.createCollection(AI_COLLECTION_NAME, {
|
||||||
|
vectors: {
|
||||||
|
bge_dense: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
|
||||||
|
},
|
||||||
|
sparse_vectors: {
|
||||||
|
bge_sparse: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// สร้าง payload indexes สำหรับเพิ่มความเร็วในการ filter (T010)
|
||||||
|
await this.createPayloadIndexes();
|
||||||
|
|
||||||
|
this.logger.log(`Created Qdrant Hybrid collection ${AI_COLLECTION_NAME}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** สร้าง payload indexes สำหรับ filter fields ที่สำคัญ */
|
||||||
|
private async createPayloadIndexes(): Promise<void> {
|
||||||
|
try {
|
||||||
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||||
field_name: 'project_public_id',
|
field_name: 'project_public_id',
|
||||||
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
|
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
|
||||||
QdrantClient['createPayloadIndex']
|
QdrantClient['createPayloadIndex']
|
||||||
>[1]['field_schema'],
|
>[1]['field_schema'],
|
||||||
});
|
});
|
||||||
this.logger.log(`Created Qdrant collection ${AI_COLLECTION_NAME}`);
|
|
||||||
|
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||||
|
field_name: 'doc_public_id',
|
||||||
|
field_schema: { type: 'keyword' } as Parameters<
|
||||||
|
QdrantClient['createPayloadIndex']
|
||||||
|
>[1]['field_schema'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||||
|
field_name: 'status_code',
|
||||||
|
field_schema: { type: 'keyword' } as Parameters<
|
||||||
|
QdrantClient['createPayloadIndex']
|
||||||
|
>[1]['field_schema'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||||
|
field_name: 'doc_type',
|
||||||
|
field_schema: { type: 'keyword' } as Parameters<
|
||||||
|
QdrantClient['createPayloadIndex']
|
||||||
|
>[1]['field_schema'],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Created payload indexes for ${AI_COLLECTION_NAME}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to create payload indexes (may already exist): ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ค้นหา vector โดยบังคับ projectPublicId เป็น parameter แรกตาม ADR-023A */
|
/** ค้นหาเวกเตอร์ด้วย Hybrid Search (Dense + Sparse) หรือ Dense Search (ถ้าไม่มี sparse vector) โดยบังคับ projectPublicId */
|
||||||
async search(
|
async search(
|
||||||
projectPublicId: string,
|
projectPublicId: string,
|
||||||
vector: number[],
|
denseVector: number[],
|
||||||
|
sparseVectorOrTopK?: { indices: number[]; values: number[] } | number,
|
||||||
topK = 5
|
topK = 5
|
||||||
): Promise<AiVectorSearchResult[]> {
|
): Promise<AiVectorSearchResult[]> {
|
||||||
if (!projectPublicId) {
|
if (!projectPublicId) {
|
||||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await this.client.search(AI_COLLECTION_NAME, {
|
let actualSparseVector = {
|
||||||
vector,
|
indices: [] as number[],
|
||||||
limit: topK,
|
values: [] as number[],
|
||||||
|
};
|
||||||
|
let actualTopK = topK;
|
||||||
|
|
||||||
|
if (typeof sparseVectorOrTopK === 'number') {
|
||||||
|
actualTopK = sparseVectorOrTopK;
|
||||||
|
} else if (sparseVectorOrTopK) {
|
||||||
|
actualSparseVector = sparseVectorOrTopK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: หากไม่มี sparse vector ให้ประมวลผลผ่าน client.search สำหรับการทดสอบและ compatibility
|
||||||
|
if (actualSparseVector.indices.length === 0) {
|
||||||
|
const results = await this.client.search(AI_COLLECTION_NAME, {
|
||||||
|
vector: denseVector,
|
||||||
|
limit: actualTopK,
|
||||||
|
filter: {
|
||||||
|
must: [
|
||||||
|
{ key: 'project_public_id', match: { value: projectPublicId } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
with_payload: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results.map((result) => ({
|
||||||
|
pointId: result.id,
|
||||||
|
score: result.score ?? 0,
|
||||||
|
payload: result.payload ?? {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.client.query(AI_COLLECTION_NAME, {
|
||||||
|
prefetch: [
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
indices: actualSparseVector.indices,
|
||||||
|
values: actualSparseVector.values,
|
||||||
|
},
|
||||||
|
using: 'bge_sparse',
|
||||||
|
limit: actualTopK * 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: denseVector,
|
||||||
|
using: 'bge_dense',
|
||||||
|
limit: actualTopK * 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
query: { fusion: 'rrf' } as unknown as Record<string, unknown>,
|
||||||
|
limit: actualTopK,
|
||||||
filter: {
|
filter: {
|
||||||
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
||||||
},
|
},
|
||||||
with_payload: true,
|
with_payload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return results.map((result) => ({
|
return results.points.map((result) => ({
|
||||||
pointId: result.id,
|
pointId: result.id,
|
||||||
score: result.score,
|
score: result.score ?? 0,
|
||||||
payload: result.payload ?? {},
|
payload: result.payload ?? {},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */
|
/** Compatibility wrapper สำหรับโค้ดเดิมระหว่าง transition */
|
||||||
async searchByProject(
|
async searchByProject(
|
||||||
vector: number[],
|
denseVector: number[],
|
||||||
projectPublicId: string,
|
sparseVectorOrProjectPublicId:
|
||||||
limit: number
|
| { indices: number[]; values: number[] }
|
||||||
|
| string,
|
||||||
|
projectPublicIdOrLimit?: string | number,
|
||||||
|
limit = 5
|
||||||
): Promise<AiVectorSearchResult[]> {
|
): Promise<AiVectorSearchResult[]> {
|
||||||
return this.search(projectPublicId, vector, limit);
|
if (typeof sparseVectorOrProjectPublicId === 'string') {
|
||||||
|
// เรียกใช้รูปแบบดั้งเดิม: searchByProject(vector, projectPublicId, limit)
|
||||||
|
const projectPublicId = sparseVectorOrProjectPublicId;
|
||||||
|
const actualLimit =
|
||||||
|
typeof projectPublicIdOrLimit === 'number'
|
||||||
|
? projectPublicIdOrLimit
|
||||||
|
: limit;
|
||||||
|
return this.search(projectPublicId, denseVector, undefined, actualLimit);
|
||||||
|
} else {
|
||||||
|
// เรียกใช้รูปแบบใหม่: searchByProject(dense, sparse, projectPublicId, limit)
|
||||||
|
const projectPublicId =
|
||||||
|
typeof projectPublicIdOrLimit === 'string'
|
||||||
|
? projectPublicIdOrLimit
|
||||||
|
: '';
|
||||||
|
return this.search(
|
||||||
|
projectPublicId,
|
||||||
|
denseVector,
|
||||||
|
sparseVectorOrProjectPublicId,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */
|
/** ลบเวกเตอร์ของเอกสารด้วย projectPublicId และ documentPublicId */
|
||||||
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
|
async deleteByDocumentPublicId(
|
||||||
|
projectPublicId: string,
|
||||||
|
documentPublicId: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!projectPublicId) {
|
||||||
|
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||||
|
}
|
||||||
await this.client.delete(AI_COLLECTION_NAME, {
|
await this.client.delete(AI_COLLECTION_NAME, {
|
||||||
wait: true,
|
wait: true,
|
||||||
filter: {
|
filter: {
|
||||||
must: [{ key: 'public_id', match: { value: documentPublicId } }],
|
must: [
|
||||||
|
{ key: 'project_public_id', match: { value: projectPublicId } },
|
||||||
|
{ key: 'doc_public_id', match: { value: documentPublicId } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upsert vectors ไป Qdrant พร้อม project isolation (T021) */
|
/** Upsert hybrid vectors ไป Qdrant พร้อม project isolation (T008) */
|
||||||
async upsert(
|
async upsert(
|
||||||
projectPublicId: string,
|
projectPublicId: string,
|
||||||
points: Array<{
|
points: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
vector: number[];
|
vector: {
|
||||||
|
bge_dense: number[];
|
||||||
|
bge_sparse: {
|
||||||
|
indices: number[];
|
||||||
|
values: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}>
|
}>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -126,14 +318,14 @@ export class AiQdrantService implements OnModuleInit {
|
|||||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||||
}
|
}
|
||||||
|
|
||||||
// เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation
|
// เพิ่ม project_public_id ใน payload ทุก point เพื่อแยกโครงการ
|
||||||
const pointsWithProject = points.map((point) => ({
|
const pointsWithProject = points.map((point) => ({
|
||||||
...point,
|
...point,
|
||||||
payload: {
|
payload: {
|
||||||
...point.payload,
|
...point.payload,
|
||||||
project_public_id: projectPublicId,
|
project_public_id: projectPublicId,
|
||||||
},
|
},
|
||||||
}));
|
})) as unknown as QdrantUpsertPoint[];
|
||||||
|
|
||||||
await this.client.upsert(AI_COLLECTION_NAME, {
|
await this.client.upsert(AI_COLLECTION_NAME, {
|
||||||
wait: true,
|
wait: true,
|
||||||
|
|||||||
@@ -0,0 +1,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,14 @@
|
|||||||
// File: src/modules/ai/services/embedding.service.ts
|
// File: backend/src/modules/ai/services/embedding.service.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ full-document chunked embedding ตาม ADR-023A T021.
|
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ full-document chunked embedding ตาม ADR-023A T021.
|
||||||
|
// - 2026-06-05: ปรับปรุงเป็น Hybrid Embedding และเพิ่ม Semantic Chunking ผ่าน typhoon2.5 (T025-T027)
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { OllamaService } from './ollama.service';
|
import { OllamaService } from './ollama.service';
|
||||||
import { AiQdrantService } from '../qdrant.service';
|
import { AiQdrantService } from '../qdrant.service';
|
||||||
import { OcrService } from './ocr.service';
|
import { OcrService } from './ocr.service';
|
||||||
|
import { AiPromptsService } from '../prompts/ai-prompts.service';
|
||||||
|
|
||||||
export interface EmbeddingChunk {
|
export interface EmbeddingChunk {
|
||||||
chunkIndex: number;
|
chunkIndex: number;
|
||||||
@@ -31,7 +33,8 @@ export class EmbeddingService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly ollamaService: OllamaService,
|
private readonly ollamaService: OllamaService,
|
||||||
private readonly qdrantService: AiQdrantService,
|
private readonly qdrantService: AiQdrantService,
|
||||||
private readonly ocrService: OcrService
|
private readonly ocrService: OcrService,
|
||||||
|
private readonly aiPromptsService: AiPromptsService
|
||||||
) {
|
) {
|
||||||
this.chunkSize = this.configService.get<number>(
|
this.chunkSize = this.configService.get<number>(
|
||||||
'EMBEDDING_CHUNK_SIZE',
|
'EMBEDDING_CHUNK_SIZE',
|
||||||
@@ -44,66 +47,71 @@ export class EmbeddingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* สร้าง embedding สำหรับเอกสารทั้งฉบับ:
|
* สร้าง hybrid embedding สำหรับเอกสารทั้งฉบับ:
|
||||||
* 1. ดึงข้อความ full-doc (ใช้ extractedText หรือ OCR)
|
* 1. ใช้ Semantic Chunking (ผ่าน LLM) เป็นหลัก พร้อม Fallback เป็นแบบ fixed-size
|
||||||
* 2. Chunk text 512 tokens / 64 overlap
|
* 2. เรียก Sidecar /embed เพื่อแปลงแต่ละ chunk เป็น Dense (1024 dims) + Sparse vector
|
||||||
* 3. Generate embedding ต่อ chunk ด้วย nomic-embed-text
|
* 3. ลบ points เก่าของเอกสารใน Qdrant
|
||||||
* 4. Upsert ไป Qdrant พร้อม project isolation
|
* 4. Upsert points ใหม่เก็บครบ 11 fields
|
||||||
*/
|
*/
|
||||||
async embedDocument(
|
async embedDocument(
|
||||||
pdfPath: string,
|
|
||||||
documentPublicId: string,
|
|
||||||
projectPublicId: string,
|
projectPublicId: string,
|
||||||
extractedText?: string
|
documentPublicId: string,
|
||||||
|
correspondenceNumber: string,
|
||||||
|
docType: string,
|
||||||
|
statusCode: string,
|
||||||
|
revisionNumber: number,
|
||||||
|
subject: string,
|
||||||
|
documentDate?: string,
|
||||||
|
ocrText?: string
|
||||||
): Promise<EmbeddingResult> {
|
): Promise<EmbeddingResult> {
|
||||||
try {
|
try {
|
||||||
// 1. ดึงข้อความจาก PDF (ใช้ extractedText ถ้ามี หรือเรียก OCR)
|
if (!ocrText || ocrText.trim().length === 0) {
|
||||||
let fullText = extractedText;
|
this.logger.warn(
|
||||||
if (!fullText) {
|
`No OCR text provided for document ${documentPublicId}`
|
||||||
const ocrResult = await this.ocrService.detectAndExtract({
|
);
|
||||||
pdfPath,
|
|
||||||
extractedText: '',
|
|
||||||
extractedChars: 0,
|
|
||||||
});
|
|
||||||
fullText = ocrResult.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fullText || fullText.trim().length === 0) {
|
|
||||||
this.logger.warn(`No text extracted from document ${documentPublicId}`);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
chunksEmbedded: 0,
|
chunksEmbedded: 0,
|
||||||
error: 'No text extracted',
|
error: 'No OCR text provided',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Chunk text
|
// 1. แบ่งข้อความออกเป็น Chunk ด้วย Semantic Chunking
|
||||||
const chunks = this.chunkText(fullText);
|
const chunks = await this.semanticChunkTextWithFallback(ocrText);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Document ${documentPublicId} split into ${chunks.length} chunks`
|
`Document ${documentPublicId} split into ${chunks.length} chunks`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Generate embedding และ upsert ไป Qdrant
|
// 2. แปลงแต่ละ chunk เป็น Hybrid Vector และเตรียม points
|
||||||
const points = [];
|
const points = [];
|
||||||
for (const chunk of chunks) {
|
for (const [idx, chunk] of chunks.entries()) {
|
||||||
try {
|
try {
|
||||||
const embedding = await this.ollamaService.generateEmbedding(
|
// เรียก Sidecar /embed เพื่อแปลงข้อความของ chunk
|
||||||
chunk.text
|
const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
|
||||||
);
|
|
||||||
points.push({
|
points.push({
|
||||||
id: `${documentPublicId}-${chunk.chunkIndex}`,
|
id: `${documentPublicId}-${idx}`,
|
||||||
vector: embedding,
|
vector: {
|
||||||
|
bge_dense: embedResult.dense,
|
||||||
|
bge_sparse: embedResult.sparse,
|
||||||
|
},
|
||||||
payload: {
|
payload: {
|
||||||
document_public_id: documentPublicId,
|
doc_public_id: documentPublicId,
|
||||||
chunk_index: chunk.chunkIndex,
|
project_public_id: projectPublicId,
|
||||||
page_number: chunk.pageNumber,
|
doc_number: correspondenceNumber,
|
||||||
|
doc_type: docType,
|
||||||
|
status_code: statusCode,
|
||||||
|
revision_number: revisionNumber,
|
||||||
|
subject: subject,
|
||||||
|
document_date: documentDate || null,
|
||||||
|
chunk_topic: chunk.topic,
|
||||||
|
chunk_index: idx,
|
||||||
chunk_text: chunk.text,
|
chunk_text: chunk.text,
|
||||||
embedded_at: new Date().toISOString(),
|
embedded_at: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to embed chunk ${chunk.chunkIndex} for document ${documentPublicId}`,
|
`Failed to embed chunk ${idx} for document ${documentPublicId}`,
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,7 +125,13 @@ export class EmbeddingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Upsert ไป Qdrant พร้อม project isolation
|
// 3. ลบ points เก่าของเอกสาร (เพื่อความ idempotent และรองรับ revision ใหม่)
|
||||||
|
await this.qdrantService.deleteByDocumentPublicId(
|
||||||
|
projectPublicId,
|
||||||
|
documentPublicId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. บันทึก points ใหม่ลง Qdrant
|
||||||
await this.qdrantService.upsert(projectPublicId, points);
|
await this.qdrantService.upsert(projectPublicId, points);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -135,12 +149,53 @@ export class EmbeddingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chunk text ด้วย overlap
|
* แบ่งข้อความโดยใช้ typhoon2.5 และ Prompt 'rag_chunking' (T025, T026)
|
||||||
* - chunkSize: 512 characters (approximate token equivalent)
|
* หากล้มเหลวหรือ LLM ไม่ตอบกลับในรูปแบบแท็ก <chunk> ให้ fallback เป็นแบบ fixed-size
|
||||||
* - overlap: 64 characters
|
|
||||||
*/
|
*/
|
||||||
private chunkText(text: string): EmbeddingChunk[] {
|
private async semanticChunkTextWithFallback(
|
||||||
const chunks: EmbeddingChunk[] = [];
|
ocrText: string
|
||||||
|
): Promise<Array<{ topic: string; text: string }>> {
|
||||||
|
try {
|
||||||
|
this.logger.log('Attempting semantic chunking via typhoon2.5...');
|
||||||
|
// ดึง prompt จาก ai_prompts ที่เป็น active version
|
||||||
|
const resolved = await this.aiPromptsService.resolveActive(
|
||||||
|
'rag_chunking',
|
||||||
|
ocrText
|
||||||
|
);
|
||||||
|
|
||||||
|
// เรียก LLM
|
||||||
|
const llmOutput = await this.ollamaService.generate(
|
||||||
|
resolved.resolvedPrompt
|
||||||
|
);
|
||||||
|
|
||||||
|
// ดึงและวิเคราะห์ข้อความภายในแท็ก <chunk topic="...">
|
||||||
|
const parsed = this.parseChunkTags(llmOutput);
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Semantic chunking succeeded: split into ${parsed.length} chunks.`
|
||||||
|
);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
'No valid <chunk> tags found in LLM output, falling back to fixed-size chunking.'
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Semantic chunking failed, falling back to fixed-size chunking: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: ใช้การแบ่ง chunk แบบ Fixed-size
|
||||||
|
return this.fixedSizeChunk(ocrText, this.chunkSize, this.overlap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** แบ่งข้อความตามขนาดคงที่ (Fixed-size Chunking) (FR-005) */
|
||||||
|
private fixedSizeChunk(
|
||||||
|
text: string,
|
||||||
|
chunkSize: number,
|
||||||
|
overlap: number
|
||||||
|
): Array<{ topic: string; text: string }> {
|
||||||
|
const chunks: Array<{ topic: string; text: string }> = [];
|
||||||
const cleanText = text.replace(/\s+/g, ' ').trim();
|
const cleanText = text.replace(/\s+/g, ' ').trim();
|
||||||
const textLength = cleanText.length;
|
const textLength = cleanText.length;
|
||||||
|
|
||||||
@@ -148,19 +203,35 @@ export class EmbeddingService {
|
|||||||
let chunkIndex = 0;
|
let chunkIndex = 0;
|
||||||
|
|
||||||
while (startIndex < textLength) {
|
while (startIndex < textLength) {
|
||||||
const endIndex = Math.min(startIndex + this.chunkSize, textLength);
|
const endIndex = Math.min(startIndex + chunkSize, textLength);
|
||||||
const chunkText = cleanText.substring(startIndex, endIndex);
|
const chunkText = cleanText.substring(startIndex, endIndex);
|
||||||
|
|
||||||
chunks.push({
|
chunks.push({
|
||||||
chunkIndex,
|
topic: `ส่วนที่ ${chunkIndex + 1}`,
|
||||||
text: chunkText,
|
text: chunkText,
|
||||||
pageNumber: undefined, // TODO: Extract page numbers if available
|
|
||||||
});
|
});
|
||||||
|
|
||||||
startIndex += this.chunkSize - this.overlap;
|
startIndex += chunkSize - overlap;
|
||||||
chunkIndex += 1;
|
chunkIndex += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ประมวลผลดึงค่า regex <chunk topic="...">... </chunk> (T026) */
|
||||||
|
private parseChunkTags(
|
||||||
|
llmOutput: string
|
||||||
|
): Array<{ topic: string; text: string }> {
|
||||||
|
const chunks: Array<{ topic: string; text: string }> = [];
|
||||||
|
const regex = /<chunk\s+topic="([^"]*)"\s*>([\s\S]*?)<\/chunk\s*>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(llmOutput)) !== null) {
|
||||||
|
const topic = match[1]?.trim() || 'ทั่วไป';
|
||||||
|
const text = match[2]?.trim();
|
||||||
|
if (text) {
|
||||||
|
chunks.push({ topic, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,4 +393,53 @@ export class OcrService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** เรียก Sidecar /embed เพื่อทำ BGE-M3 (Dense + Sparse) embedding (T012) */
|
||||||
|
async embedViaSidecar(text: string): Promise<{
|
||||||
|
dense: number[];
|
||||||
|
sparse: { indices: number[]; values: number[] };
|
||||||
|
}> {
|
||||||
|
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[] };
|
||||||
|
};
|
||||||
|
} 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[] }> {
|
||||||
|
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[] };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(`Failed to rerank via Sidecar: ${msg}`);
|
||||||
|
throw new Error(`AI_SIDECAR_RERANK_FAILED: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ describe('OllamaService (ADR-034)', () => {
|
|||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('ควรส่ง format=json เมื่อ caller ต้องการ structured output', async () => {
|
||||||
|
mockedAxios.post = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ data: { response: '{"ok":true}' } });
|
||||||
|
await service.generate('json prompt', {
|
||||||
|
format: 'json',
|
||||||
|
});
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/generate'),
|
||||||
|
expect.objectContaining({ format: 'json' }),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
it('ควรใช้ options.model เมื่อระบุ model อื่น (ADR-034 model switching)', async () => {
|
it('ควรใช้ options.model เมื่อระบุ model อื่น (ADR-034 model switching)', async () => {
|
||||||
mockedAxios.post = jest
|
mockedAxios.post = jest
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
||||||
// - 2026-06-02: เพิ่ม loadModel() preloading, ดึงจริงจาก /api/ps และเพิ่ม unloadModel() เพื่อล้างหน่วยความจำ GPU/VRAM (ADR-033, Suggestion 1)
|
// - 2026-06-02: เพิ่ม loadModel() preloading, ดึงจริงจาก /api/ps และเพิ่ม unloadModel() เพื่อล้างหน่วยความจำ GPU/VRAM (ADR-033, Suggestion 1)
|
||||||
// - 2026-06-03: ADR-034 — เปลี่ยน default model เป็น typhoon2.5-np-dms; เพิ่ม ocrModel field, keepAlive param ใน loadModel(), model option ใน OllamaGenerateOptions, getOcrModelName()
|
// - 2026-06-03: ADR-034 — เปลี่ยน default model เป็น typhoon2.5-np-dms; เพิ่ม ocrModel field, keepAlive param ใน loadModel(), model option ใน OllamaGenerateOptions, getOcrModelName()
|
||||||
|
// - 2026-06-06: เพิ่ม system prompt support ใน OllamaGenerateOptions และ generate() method เพื่อรองรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก
|
||||||
|
// - 2026-06-06: [T036] แก้ไข default URL เป็น http://192.168.10.100:11434 (Desk-5439) แทน localhost; เพิ่ม options และ keepAlive ใน OllamaGenerateOptions เพื่อรองรับ Typhoon model parameters
|
||||||
|
// - 2026-06-08: เพิ่ม num_predict ใน OllamaGenerateOptions.options — ป้องกัน JSON truncation เมื่อ LLM สร้าง structured output
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -14,6 +17,22 @@ export interface OllamaGenerateOptions {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */
|
/** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** System prompt สำหรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก (ใช้ triple quotes) */
|
||||||
|
system?: string;
|
||||||
|
/** บังคับ structured output จาก Ollama สำหรับงานที่ต้อง parse JSON */
|
||||||
|
format?: 'json';
|
||||||
|
/** Ollama generation options (temperature, top_p, etc.) */
|
||||||
|
options?: {
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
repeat_penalty?: number;
|
||||||
|
num_gpu?: number;
|
||||||
|
num_ctx?: number;
|
||||||
|
/** จำนวน tokens สูงสุดที่ LLM จะสร้าง — ป้องกัน JSON truncation */
|
||||||
|
num_predict?: number;
|
||||||
|
};
|
||||||
|
/** keep_alive: -1 = stay loaded, 0 = unload immediately, N = seconds */
|
||||||
|
keepAlive?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
|
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
|
||||||
@@ -29,7 +48,10 @@ export class OllamaService {
|
|||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.ollamaUrl = this.configService.get<string>(
|
this.ollamaUrl = this.configService.get<string>(
|
||||||
'OLLAMA_URL',
|
'OLLAMA_URL',
|
||||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
this.configService.get<string>(
|
||||||
|
'AI_HOST_URL',
|
||||||
|
'http://192.168.10.100:11434'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
this.mainModel = this.configService.get<string>(
|
this.mainModel = this.configService.get<string>(
|
||||||
'OLLAMA_MODEL_MAIN',
|
'OLLAMA_MODEL_MAIN',
|
||||||
@@ -57,7 +79,11 @@ export class OllamaService {
|
|||||||
{
|
{
|
||||||
model: options.model ?? this.mainModel,
|
model: options.model ?? this.mainModel,
|
||||||
prompt,
|
prompt,
|
||||||
|
system: options.system,
|
||||||
|
format: options.format,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
options: options.options,
|
||||||
|
keep_alive: options.keepAlive ?? -1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout: options.timeoutMs ?? this.timeoutMs,
|
timeout: options.timeoutMs ?? this.timeoutMs,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// File: src/modules/correspondence/correspondence-workflow.service.spec.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-06-05: สร้าง unit test สำหรับ CorrespondenceWorkflowService เพื่อทดสอบการเรียกใช้ RAG prepare job เมื่อสถานะเปลี่ยนจาก DRAFT (T017)
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
|
||||||
|
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||||
|
import { Correspondence } from './entities/correspondence.entity';
|
||||||
|
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||||
|
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||||
|
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
|
import { AiQueueService } from '../ai/ai-queue.service';
|
||||||
|
|
||||||
|
describe('CorrespondenceWorkflowService', () => {
|
||||||
|
let service: CorrespondenceWorkflowService;
|
||||||
|
let aiQueueService: AiQueueService;
|
||||||
|
const mockWorkflowEngine = {
|
||||||
|
createInstance: jest.fn(),
|
||||||
|
processTransition: jest.fn(),
|
||||||
|
getInstanceById: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockCorrespondenceRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockRevisionRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
manager: {
|
||||||
|
save: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockStatusRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockRecipientRepo = {
|
||||||
|
find: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockDataSource = {
|
||||||
|
createQueryRunner: jest.fn().mockReturnValue({
|
||||||
|
connect: jest.fn(),
|
||||||
|
startTransaction: jest.fn(),
|
||||||
|
commitTransaction: jest.fn(),
|
||||||
|
rollbackTransaction: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
manager: mockRevisionRepo.manager,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const mockNotificationService = {
|
||||||
|
send: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockUserService = {
|
||||||
|
findDocControlIdByOrg: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockAiQueueService = {
|
||||||
|
enqueueRagPrepare: jest.fn().mockResolvedValue('job-id-123'),
|
||||||
|
};
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
CorrespondenceWorkflowService,
|
||||||
|
{ provide: WorkflowEngineService, useValue: mockWorkflowEngine },
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Correspondence),
|
||||||
|
useValue: mockCorrespondenceRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(CorrespondenceRevision),
|
||||||
|
useValue: mockRevisionRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(CorrespondenceStatus),
|
||||||
|
useValue: mockStatusRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(CorrespondenceRecipient),
|
||||||
|
useValue: mockRecipientRepo,
|
||||||
|
},
|
||||||
|
{ provide: DataSource, useValue: mockDataSource },
|
||||||
|
{ provide: NotificationService, useValue: mockNotificationService },
|
||||||
|
{ provide: UserService, useValue: mockUserService },
|
||||||
|
{ provide: AiQueueService, useValue: mockAiQueueService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get<CorrespondenceWorkflowService>(
|
||||||
|
CorrespondenceWorkflowService
|
||||||
|
);
|
||||||
|
aiQueueService = module.get<AiQueueService>(AiQueueService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
describe('syncStatus RAG trigger', () => {
|
||||||
|
it('ควรเรียก enqueueRagPrepare เมื่อสถานะเอกสารถูกเปลี่ยนจาก DRAFT เป็นอย่างอื่น', async () => {
|
||||||
|
const mockStatus = { id: 2, statusCode: 'SUBOWN' };
|
||||||
|
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
|
||||||
|
const mockProject = { id: 10, publicId: 'proj-uuid-123' };
|
||||||
|
const mockCorrespondence = {
|
||||||
|
id: 100,
|
||||||
|
publicId: 'doc-uuid-999',
|
||||||
|
correspondenceNumber: 'CORR-001',
|
||||||
|
projectId: 10,
|
||||||
|
project: mockProject,
|
||||||
|
type: { correspondenceTypeCode: 'LETTER' },
|
||||||
|
};
|
||||||
|
const mockRevision = {
|
||||||
|
id: 50,
|
||||||
|
correspondenceId: 100,
|
||||||
|
revisionNumber: 0,
|
||||||
|
subject: 'Test Subject',
|
||||||
|
documentDate: new Date('2026-06-05'),
|
||||||
|
correspondence: mockCorrespondence,
|
||||||
|
statusId: 1,
|
||||||
|
};
|
||||||
|
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
|
||||||
|
mockRevisionRepo.manager.find.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
correspondenceRevisionId: 50,
|
||||||
|
attachmentId: 88,
|
||||||
|
isMainDocument: true,
|
||||||
|
attachment: { filePath: '/files/doc.pdf', fileExtension: 'pdf' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await (
|
||||||
|
service as unknown as {
|
||||||
|
syncStatus: (
|
||||||
|
revision: CorrespondenceRevision,
|
||||||
|
workflowState: string
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).syncStatus(
|
||||||
|
mockRevision as unknown as CorrespondenceRevision,
|
||||||
|
'IN_REVIEW'
|
||||||
|
);
|
||||||
|
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
|
||||||
|
expect(aiQueueService.enqueueRagPrepare).toHaveBeenCalledWith({
|
||||||
|
documentPublicId: 'doc-uuid-999',
|
||||||
|
projectPublicId: 'proj-uuid-123',
|
||||||
|
correspondenceNumber: 'CORR-001',
|
||||||
|
docType: 'LETTER',
|
||||||
|
statusCode: 'SUBOWN',
|
||||||
|
revisionNumber: 0,
|
||||||
|
subject: 'Test Subject',
|
||||||
|
documentDate: '2026-06-05',
|
||||||
|
attachmentPath: '/files/doc.pdf',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('ไม่ควรเรียก enqueueRagPrepare เมื่อเอกสารยังคงอยู่ในสถานะ DRAFT', async () => {
|
||||||
|
const mockStatus = { id: 1, statusCode: 'DRAFT' };
|
||||||
|
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
|
||||||
|
const mockRevision = {
|
||||||
|
id: 50,
|
||||||
|
correspondenceId: 100,
|
||||||
|
revisionNumber: 0,
|
||||||
|
subject: 'Test Subject',
|
||||||
|
statusId: 1,
|
||||||
|
};
|
||||||
|
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
|
||||||
|
await (
|
||||||
|
service as unknown as {
|
||||||
|
syncStatus: (
|
||||||
|
revision: CorrespondenceRevision,
|
||||||
|
workflowState: string
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).syncStatus(mockRevision as unknown as CorrespondenceRevision, 'DRAFT');
|
||||||
|
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
|
||||||
|
expect(aiQueueService.enqueueRagPrepare).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,8 +10,11 @@ import { CorrespondenceRevision } from './entities/correspondence-revision.entit
|
|||||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||||
import { Correspondence } from './entities/correspondence.entity';
|
import { Correspondence } from './entities/correspondence.entity';
|
||||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||||
|
import { CorrespondenceRevisionAttachment } from './entities/correspondence-revision-attachment.entity';
|
||||||
import { NotificationService } from '../notification/notification.service';
|
import { NotificationService } from '../notification/notification.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
|
import { AiQueueService } from '../ai/ai-queue.service';
|
||||||
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CorrespondenceWorkflowService {
|
export class CorrespondenceWorkflowService {
|
||||||
@@ -30,7 +33,8 @@ export class CorrespondenceWorkflowService {
|
|||||||
private readonly recipientRepo: Repository<CorrespondenceRecipient>,
|
private readonly recipientRepo: Repository<CorrespondenceRecipient>,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService,
|
||||||
|
private readonly aiQueueService: AiQueueService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async submitWorkflow(
|
async submitWorkflow(
|
||||||
@@ -85,41 +89,67 @@ export class CorrespondenceWorkflowService {
|
|||||||
{ roles: userRoles } // [FIX] Pass roles for DSL requirements check
|
{ roles: userRoles } // [FIX] Pass roles for DSL requirements check
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.syncStatus(revision, transitionResult.nextState, queryRunner);
|
await this.syncStatus(
|
||||||
|
revision,
|
||||||
|
transitionResult.nextState,
|
||||||
|
queryRunner,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
// After-commit: RAG preparation (fire-and-forget)
|
||||||
|
// ย้ายมาหลัง commit เพื่อป้องกัน job ถูก enqueue แต่ transaction rollback
|
||||||
|
try {
|
||||||
|
if (transitionResult.nextState !== 'DRAFT') {
|
||||||
|
await this.triggerRagPrepare(revision, transitionResult.nextState);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`After-commit RAG preparation failed (non-critical): ${errMsg}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Notify TO recipient org users (fire-and-forget)
|
// Notify TO recipient org users (fire-and-forget)
|
||||||
const corrForNotify = revision.correspondence;
|
try {
|
||||||
if (corrForNotify) {
|
const corrForNotify = revision.correspondence;
|
||||||
void this.recipientRepo
|
if (corrForNotify) {
|
||||||
.find({
|
void this.recipientRepo
|
||||||
where: {
|
.find({
|
||||||
correspondenceId: corrForNotify.id,
|
where: {
|
||||||
recipientType: 'TO',
|
correspondenceId: corrForNotify.id,
|
||||||
},
|
recipientType: 'TO',
|
||||||
})
|
},
|
||||||
.then(async (recipients) => {
|
})
|
||||||
for (const r of recipients) {
|
.then(async (recipients) => {
|
||||||
const targetUserId = await this.userService.findDocControlIdByOrg(
|
for (const r of recipients) {
|
||||||
r.recipientOrganizationId
|
const targetUserId =
|
||||||
);
|
await this.userService.findDocControlIdByOrg(
|
||||||
if (targetUserId) {
|
r.recipientOrganizationId
|
||||||
await this.notificationService.send({
|
);
|
||||||
userId: targetUserId,
|
if (targetUserId) {
|
||||||
title: 'New Correspondence Received',
|
await this.notificationService.send({
|
||||||
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
|
userId: targetUserId,
|
||||||
type: 'EMAIL',
|
title: 'New Correspondence Received',
|
||||||
entityType: 'correspondence',
|
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
|
||||||
entityId: revision.correspondenceId,
|
type: 'EMAIL',
|
||||||
link: `/correspondences/${corrForNotify.publicId}`,
|
entityType: 'correspondence',
|
||||||
});
|
entityId: revision.correspondenceId,
|
||||||
|
link: `/correspondences/${corrForNotify.publicId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
.catch((err: Error) =>
|
||||||
.catch((err: Error) =>
|
this.logger.warn(`Submit notification failed: ${err.message}`)
|
||||||
this.logger.warn(`Submit notification failed: ${err.message}`)
|
);
|
||||||
);
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`After-commit notification setup failed (non-critical): ${errMsg}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -166,7 +196,8 @@ export class CorrespondenceWorkflowService {
|
|||||||
private async syncStatus(
|
private async syncStatus(
|
||||||
revision: CorrespondenceRevision,
|
revision: CorrespondenceRevision,
|
||||||
workflowState: string,
|
workflowState: string,
|
||||||
queryRunner?: import('typeorm').QueryRunner
|
queryRunner?: import('typeorm').QueryRunner,
|
||||||
|
skipRagPrepare = false
|
||||||
) {
|
) {
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
DRAFT: 'DRAFT',
|
DRAFT: 'DRAFT',
|
||||||
@@ -174,21 +205,95 @@ export class CorrespondenceWorkflowService {
|
|||||||
APPROVED: 'CLBOWN',
|
APPROVED: 'CLBOWN',
|
||||||
REJECTED: 'CCBOWN',
|
REJECTED: 'CCBOWN',
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetCode = statusMap[workflowState] || 'DRAFT';
|
const targetCode = statusMap[workflowState] || 'DRAFT';
|
||||||
|
|
||||||
const status = await this.statusRepo.findOne({
|
const status = await this.statusRepo.findOne({
|
||||||
where: { statusCode: targetCode }, // ✅ FIX: CamelCase
|
where: { statusCode: targetCode },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
// ✅ FIX: CamelCase (correspondenceStatusId)
|
|
||||||
revision.statusId = status.id;
|
revision.statusId = status.id;
|
||||||
|
|
||||||
const manager = queryRunner
|
const manager = queryRunner
|
||||||
? queryRunner.manager
|
? queryRunner.manager
|
||||||
: this.revisionRepo.manager;
|
: this.revisionRepo.manager;
|
||||||
await manager.save(revision);
|
await manager.save(revision);
|
||||||
}
|
}
|
||||||
|
// Await RAG preparation เพื่อให้ unit test assert ได้
|
||||||
|
// caller (submitWorkflow/processAction) ก็ยังคง await syncStatus ตามปกติ
|
||||||
|
if (!skipRagPrepare && workflowState !== 'DRAFT') {
|
||||||
|
await this.triggerRagPrepare(revision, targetCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* triggerRagPrepare — รวบรวมข้อมูลจาก revision/correspondence แล้ว enqueue rag-prepare job
|
||||||
|
* คืน Promise เพื่อให้ test สามารถ await และ assert ได้ ส่วน production caller ก็ await ผ่าน syncStatus
|
||||||
|
*/
|
||||||
|
private async triggerRagPrepare(
|
||||||
|
revision: CorrespondenceRevision,
|
||||||
|
statusCode: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
let correspondence: Correspondence | null | undefined =
|
||||||
|
revision.correspondence;
|
||||||
|
if (!correspondence) {
|
||||||
|
correspondence = await this.correspondenceRepo.findOne({
|
||||||
|
where: { id: revision.correspondenceId },
|
||||||
|
relations: ['project', 'type'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!correspondence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let projectPublicId = '';
|
||||||
|
if (correspondence.project) {
|
||||||
|
projectPublicId = correspondence.project.publicId;
|
||||||
|
} else {
|
||||||
|
const proj = await this.correspondenceRepo.manager.findOne(Project, {
|
||||||
|
where: { id: correspondence.projectId },
|
||||||
|
});
|
||||||
|
if (proj) {
|
||||||
|
projectPublicId = proj.publicId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const docType = correspondence.type?.typeCode || 'LETTER';
|
||||||
|
let attachmentPath: string | undefined;
|
||||||
|
const attachments = await this.revisionRepo.manager.find(
|
||||||
|
CorrespondenceRevisionAttachment,
|
||||||
|
{ where: { correspondenceRevisionId: revision.id } }
|
||||||
|
);
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
const pdfAtt = attachments.find((att) => {
|
||||||
|
const ext =
|
||||||
|
att.attachment?.originalFilename?.split('.').pop()?.toLowerCase() ||
|
||||||
|
'';
|
||||||
|
return (
|
||||||
|
ext === 'pdf' ||
|
||||||
|
att.attachment?.filePath?.toLowerCase().endsWith('.pdf')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (pdfAtt && pdfAtt.attachment) {
|
||||||
|
attachmentPath = pdfAtt.attachment.filePath;
|
||||||
|
} else if (attachments[0].attachment) {
|
||||||
|
attachmentPath = attachments[0].attachment.filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.aiQueueService.enqueueRagPrepare({
|
||||||
|
documentPublicId: correspondence.publicId,
|
||||||
|
projectPublicId: projectPublicId,
|
||||||
|
correspondenceNumber: correspondence.correspondenceNumber,
|
||||||
|
docType: docType,
|
||||||
|
statusCode: statusCode,
|
||||||
|
revisionNumber: revision.revisionNumber,
|
||||||
|
subject: revision.subject,
|
||||||
|
documentDate: revision.documentDate
|
||||||
|
? revision.documentDate.toISOString().split('T')[0]
|
||||||
|
: undefined,
|
||||||
|
attachmentPath: attachmentPath,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to enqueue RAG preparation for revision ${revision.id}: ${errMsg}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { SearchModule } from '../search/search.module';
|
|||||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||||
import { NotificationModule } from '../notification/notification.module';
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
import { CirculationModule } from '../circulation/circulation.module';
|
import { CirculationModule } from '../circulation/circulation.module';
|
||||||
|
import { AiModule } from '../ai/ai.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CorrespondenceModule
|
* CorrespondenceModule
|
||||||
@@ -53,6 +54,7 @@ import { CirculationModule } from '../circulation/circulation.module';
|
|||||||
FileStorageModule,
|
FileStorageModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
CirculationModule,
|
CirculationModule,
|
||||||
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [CorrespondenceController],
|
controllers: [CorrespondenceController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { IngestionService } from '../ingestion.service';
|
|
||||||
|
|
||||||
const QUEUE_TOKEN = 'BullQueue_rag-ocr';
|
|
||||||
|
|
||||||
const mockOcrQueue = {
|
|
||||||
getJob: jest.fn(),
|
|
||||||
add: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseJobData = {
|
|
||||||
attachmentPublicId: 'att-uuid-001',
|
|
||||||
filePath: '/uploads/permanent/CORR/2026/04/file.pdf',
|
|
||||||
docType: 'CORR',
|
|
||||||
docNumber: 'REF-001',
|
|
||||||
revision: null,
|
|
||||||
projectCode: 'PRJ-001',
|
|
||||||
projectPublicId: 'proj-uuid-001',
|
|
||||||
classification: 'INTERNAL' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('IngestionService', () => {
|
|
||||||
let service: IngestionService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
IngestionService,
|
|
||||||
{ provide: QUEUE_TOKEN, useValue: mockOcrQueue },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<IngestionService>(IngestionService);
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should enqueue rag-ocr job with attachmentPublicId as jobId', async () => {
|
|
||||||
mockOcrQueue.getJob.mockResolvedValue(null);
|
|
||||||
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
|
|
||||||
|
|
||||||
await service.enqueue(baseJobData);
|
|
||||||
|
|
||||||
expect(mockOcrQueue.add).toHaveBeenCalledWith('ocr', baseJobData, {
|
|
||||||
jobId: baseJobData.attachmentPublicId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('EC-RAG-001: duplicate enqueue when job is active → second call is no-op (log only)', async () => {
|
|
||||||
const mockJob = { getState: jest.fn().mockResolvedValue('active') };
|
|
||||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
|
||||||
|
|
||||||
await service.enqueue(baseJobData);
|
|
||||||
|
|
||||||
expect(mockOcrQueue.add).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('EC-RAG-001: duplicate enqueue when job is waiting → second call is no-op', async () => {
|
|
||||||
const mockJob = { getState: jest.fn().mockResolvedValue('waiting') };
|
|
||||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
|
||||||
|
|
||||||
await service.enqueue(baseJobData);
|
|
||||||
|
|
||||||
expect(mockOcrQueue.add).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should re-enqueue if job exists but is completed (state=completed)', async () => {
|
|
||||||
const mockJob = { getState: jest.fn().mockResolvedValue('completed') };
|
|
||||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
|
||||||
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
|
|
||||||
|
|
||||||
await service.enqueue(baseJobData);
|
|
||||||
|
|
||||||
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should re-enqueue if job exists but is failed (state=failed)', async () => {
|
|
||||||
const mockJob = { getState: jest.fn().mockResolvedValue('failed') };
|
|
||||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
|
||||||
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
|
|
||||||
|
|
||||||
await service.enqueue(baseJobData);
|
|
||||||
|
|
||||||
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
import { getQueueToken } from '@nestjs/bullmq';
|
|
||||||
import { RagService } from '../rag.service';
|
|
||||||
import { QdrantService } from '../qdrant.service';
|
|
||||||
import { EmbeddingService } from '../embedding.service';
|
|
||||||
import { LocalLlmService } from '../local-llm.service';
|
|
||||||
import { IngestionService } from '../ingestion.service';
|
|
||||||
import { DocumentChunk } from '../entities/document-chunk.entity';
|
|
||||||
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
|
|
||||||
|
|
||||||
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
|
||||||
|
|
||||||
const mockQdrant = {
|
|
||||||
isReady: jest.fn(),
|
|
||||||
hybridSearch: jest.fn(),
|
|
||||||
deleteByDocumentId: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEmbedding = {
|
|
||||||
embed: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockLocalLlm = {
|
|
||||||
generate: jest.fn(),
|
|
||||||
sanitizeInput: jest.fn((t: string) => t),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockIngestion = { enqueue: jest.fn() };
|
|
||||||
|
|
||||||
const mockChunkRepo = {
|
|
||||||
count: jest.fn(),
|
|
||||||
delete: jest.fn(),
|
|
||||||
manager: {
|
|
||||||
query: jest.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRedis = {
|
|
||||||
get: jest.fn(),
|
|
||||||
setex: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockVectorDeletionQueue = {
|
|
||||||
add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('RagService', () => {
|
|
||||||
let service: RagService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
RagService,
|
|
||||||
{ provide: QdrantService, useValue: mockQdrant },
|
|
||||||
{ provide: EmbeddingService, useValue: mockEmbedding },
|
|
||||||
{ provide: LocalLlmService, useValue: mockLocalLlm },
|
|
||||||
{ provide: IngestionService, useValue: mockIngestion },
|
|
||||||
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
|
|
||||||
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
|
||||||
{
|
|
||||||
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
|
|
||||||
useValue: mockVectorDeletionQueue,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<RagService>(RagService);
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('query()', () => {
|
|
||||||
const dto = {
|
|
||||||
question: 'เอกสารเกี่ยวกับอะไร?',
|
|
||||||
projectPublicId: 'proj-uuid-1234',
|
|
||||||
};
|
|
||||||
const memberPerms: string[] = [];
|
|
||||||
const adminPerms = ['system.manage_all'];
|
|
||||||
|
|
||||||
it('should return answer with citations on PUBLIC cache miss → write cache', async () => {
|
|
||||||
mockQdrant.isReady.mockReturnValue(true);
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
||||||
mockQdrant.hybridSearch.mockResolvedValue([
|
|
||||||
{
|
|
||||||
chunkId: 'chunk-1',
|
|
||||||
publicId: 'att-1',
|
|
||||||
docType: 'CORR',
|
|
||||||
docNumber: 'REF-001',
|
|
||||||
revision: null,
|
|
||||||
projectCode: 'PRJ-001',
|
|
||||||
contentPreview: 'เนื้อหาเอกสาร',
|
|
||||||
score: 0.92,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
mockLocalLlm.generate.mockResolvedValue({
|
|
||||||
answer: 'คำตอบ',
|
|
||||||
usedFallbackModel: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.query(dto, memberPerms);
|
|
||||||
|
|
||||||
expect(result.answer).toBe('คำตอบ');
|
|
||||||
expect(result.citations).toHaveLength(1);
|
|
||||||
expect(result.usedFallbackModel).toBe(false);
|
|
||||||
expect(mockRedis.setex).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return cached result without calling Qdrant on cache hit', async () => {
|
|
||||||
mockQdrant.isReady.mockReturnValue(true);
|
|
||||||
const cached = JSON.stringify({
|
|
||||||
answer: 'cached answer',
|
|
||||||
citations: [],
|
|
||||||
confidence: 0.9,
|
|
||||||
usedFallbackModel: false,
|
|
||||||
});
|
|
||||||
mockRedis.get.mockResolvedValue(cached);
|
|
||||||
|
|
||||||
const result = await service.query(dto, memberPerms);
|
|
||||||
|
|
||||||
expect(result.answer).toBe('cached answer');
|
|
||||||
expect(mockQdrant.hybridSearch).not.toHaveBeenCalled();
|
|
||||||
expect(mockEmbedding.embed).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CONFIDENTIAL: must use Ollama only, skip cache read and write', async () => {
|
|
||||||
mockQdrant.isReady.mockReturnValue(true);
|
|
||||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
||||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
||||||
mockLocalLlm.generate.mockResolvedValue({
|
|
||||||
answer: 'ลับมาก',
|
|
||||||
usedFallbackModel: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.query(dto, adminPerms);
|
|
||||||
|
|
||||||
expect(mockRedis.get).not.toHaveBeenCalled();
|
|
||||||
expect(mockRedis.setex).not.toHaveBeenCalled();
|
|
||||||
expect(mockLocalLlm.generate).toHaveBeenCalledWith(expect.any(String));
|
|
||||||
expect(result.usedFallbackModel).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collectionReady=false → throw ServiceUnavailableException RAG_NOT_READY', async () => {
|
|
||||||
mockQdrant.isReady.mockReturnValue(false);
|
|
||||||
|
|
||||||
await expect(service.query(dto, memberPerms)).rejects.toThrow(
|
|
||||||
ServiceUnavailableException
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cross-project cache isolation: same question different projectPublicId → different cache key', async () => {
|
|
||||||
mockQdrant.isReady.mockReturnValue(true);
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
||||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
||||||
mockLocalLlm.generate.mockResolvedValue({
|
|
||||||
answer: 'A',
|
|
||||||
usedFallbackModel: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.query(
|
|
||||||
{ question: 'Q?', projectPublicId: 'proj-A' },
|
|
||||||
memberPerms
|
|
||||||
);
|
|
||||||
await service.query(
|
|
||||||
{ question: 'Q?', projectPublicId: 'proj-B' },
|
|
||||||
memberPerms
|
|
||||||
);
|
|
||||||
|
|
||||||
const calls = mockRedis.setex.mock.calls as [string, ...unknown[]][];
|
|
||||||
expect(calls[0][0]).not.toBe(calls[1][0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classification ceiling derived from role, not from request body', async () => {
|
|
||||||
mockQdrant.isReady.mockReturnValue(true);
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
||||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
||||||
mockLocalLlm.generate.mockResolvedValue({
|
|
||||||
anwer: 'ok',
|
|
||||||
usedFallbackModel: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.query(dto, memberPerms);
|
|
||||||
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
|
|
||||||
expect.any(Array),
|
|
||||||
dto.projectPublicId,
|
|
||||||
'INTERNAL',
|
|
||||||
20
|
|
||||||
);
|
|
||||||
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockQdrant.isReady.mockReturnValue(true);
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
||||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
||||||
mockLocalLlm.generate.mockResolvedValue({
|
|
||||||
answer: 'ok',
|
|
||||||
usedFallbackModel: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.query(dto, adminPerms);
|
|
||||||
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
|
|
||||||
expect.any(Array),
|
|
||||||
dto.projectPublicId,
|
|
||||||
'CONFIDENTIAL',
|
|
||||||
20
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class RagQueryDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(500)
|
|
||||||
question!: string;
|
|
||||||
|
|
||||||
@IsUUID()
|
|
||||||
projectPublicId!: string;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export interface RagCitation {
|
|
||||||
chunkId: string;
|
|
||||||
docNumber: string | null;
|
|
||||||
docType: string;
|
|
||||||
revision: string | null;
|
|
||||||
snippet: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RagResponseDto {
|
|
||||||
answer!: string;
|
|
||||||
citations!: RagCitation[];
|
|
||||||
confidence!: number;
|
|
||||||
usedFallbackModel!: boolean;
|
|
||||||
cachedAt?: string;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EmbeddingService {
|
|
||||||
private readonly logger = new Logger(EmbeddingService.name);
|
|
||||||
private readonly ollamaUrl: string;
|
|
||||||
private readonly model: string;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
this.ollamaUrl = this.configService.get<string>(
|
|
||||||
'OLLAMA_URL',
|
|
||||||
'http://localhost:11434'
|
|
||||||
);
|
|
||||||
this.model = this.configService.get<string>(
|
|
||||||
'OLLAMA_EMBED_MODEL',
|
|
||||||
'nomic-embed-text'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async embed(text: string): Promise<number[]> {
|
|
||||||
try {
|
|
||||||
const response = await axios.post<{ embedding: number[] }>(
|
|
||||||
`${this.ollamaUrl}/api/embeddings`,
|
|
||||||
{ model: this.model, prompt: text },
|
|
||||||
{ timeout: 30000 }
|
|
||||||
);
|
|
||||||
return response.data.embedding;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
'Embedding failed',
|
|
||||||
err instanceof Error ? err.stack : String(err)
|
|
||||||
);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
|
||||||
return Promise.all(texts.map((t) => this.embed(t)));
|
|
||||||
}
|
|
||||||
|
|
||||||
getModelName(): string {
|
|
||||||
return this.model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('document_chunks')
|
|
||||||
export class DocumentChunk {
|
|
||||||
@PrimaryColumn({ type: 'char', length: 36 })
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'char', length: 36, name: 'document_id' })
|
|
||||||
documentId!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'chunk_index' })
|
|
||||||
chunkIndex!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'text' })
|
|
||||||
content!: string;
|
|
||||||
|
|
||||||
@Column({ length: 20, name: 'doc_type' })
|
|
||||||
docType!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 100, name: 'doc_number', nullable: true })
|
|
||||||
docNumber!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
|
||||||
revision!: string | null;
|
|
||||||
|
|
||||||
@Column({ length: 50, name: 'project_code' })
|
|
||||||
projectCode!: string;
|
|
||||||
|
|
||||||
@Column({ length: 36, name: 'project_public_id' })
|
|
||||||
projectPublicId!: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'],
|
|
||||||
default: 'INTERNAL',
|
|
||||||
})
|
|
||||||
classification!: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
|
||||||
version!: string | null;
|
|
||||||
|
|
||||||
@Column({ length: 100, name: 'embedding_model', default: 'nomic-embed-text' })
|
|
||||||
embeddingModel!: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', precision: 3 })
|
|
||||||
createdAt!: Date;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
|
|
||||||
import { OcrJobData } from './processors/ocr.processor';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IngestionService {
|
|
||||||
private readonly logger = new Logger(IngestionService.name);
|
|
||||||
|
|
||||||
constructor(@InjectQueue('rag-ocr') private readonly ocrQueue: Queue) {}
|
|
||||||
|
|
||||||
async enqueue(data: OcrJobData): Promise<void> {
|
|
||||||
const jobId = data.attachmentPublicId;
|
|
||||||
|
|
||||||
const existing = await this.ocrQueue.getJob(jobId);
|
|
||||||
if (existing) {
|
|
||||||
const state = await existing.getState();
|
|
||||||
if (state === 'active' || state === 'waiting' || state === 'delayed') {
|
|
||||||
this.logger.log(
|
|
||||||
`rag-ocr job already queued for ${jobId} (state: ${state})`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.ocrQueue.add('ocr', data, { jobId });
|
|
||||||
this.logger.log(`Enqueued rag-ocr for attachment ${jobId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// File: src/modules/rag/local-llm.service.ts
|
|
||||||
// Change Log
|
|
||||||
// - 2026-05-15: แทนที่ cloud LLM API ด้วย Ollama local-only ตาม ADR-023A.
|
|
||||||
// - 2026-06-03: ADR-034 — เปลี่ยน default fallback จาก gemma4:e4b เป็น typhoon2.5-np-dms:latest
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
export interface LlmGenerateResult {
|
|
||||||
answer: string;
|
|
||||||
usedFallbackModel: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** บริการเรียก LLM ภายในองค์กรผ่าน Ollama เท่านั้น */
|
|
||||||
@Injectable()
|
|
||||||
export class LocalLlmService {
|
|
||||||
private readonly logger = new Logger(LocalLlmService.name);
|
|
||||||
private readonly ollamaUrl: string;
|
|
||||||
private readonly ollamaModel: string;
|
|
||||||
private readonly timeoutMs: number;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
this.ollamaUrl = this.configService.get<string>(
|
|
||||||
'OLLAMA_URL',
|
|
||||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
|
||||||
);
|
|
||||||
this.ollamaModel = this.configService.get<string>(
|
|
||||||
'OLLAMA_MODEL_MAIN',
|
|
||||||
this.configService.get<string>(
|
|
||||||
'OLLAMA_RAG_MODEL',
|
|
||||||
'typhoon2.5-np-dms:latest'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** สร้างคำตอบจากโมเดล local-only โดยไม่มี cloud fallback */
|
|
||||||
async generate(prompt: string): Promise<LlmGenerateResult> {
|
|
||||||
try {
|
|
||||||
const response = await axios.post<{ response: string }>(
|
|
||||||
`${this.ollamaUrl}/api/generate`,
|
|
||||||
{
|
|
||||||
model: this.ollamaModel,
|
|
||||||
prompt,
|
|
||||||
stream: false,
|
|
||||||
},
|
|
||||||
{ timeout: this.timeoutMs }
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
answer: response.data.response ?? '',
|
|
||||||
usedFallbackModel: false,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
'Local Ollama generation failed',
|
|
||||||
err instanceof Error ? err.stack : String(err)
|
|
||||||
);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ทำความสะอาด prompt injection pattern พื้นฐานก่อนส่งเข้าโมเดล */
|
|
||||||
sanitizeInput(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
|
|
||||||
.replace(/ignore previous instructions/gi, '')
|
|
||||||
.replace(/system:/gi, '')
|
|
||||||
.slice(0, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
import { EmbeddingService } from '../embedding.service';
|
|
||||||
import { QdrantService, VectorMetadata } from '../qdrant.service';
|
|
||||||
import { DocumentChunk } from '../entities/document-chunk.entity';
|
|
||||||
import { EmbeddingJobData } from './thai-preprocess.processor';
|
|
||||||
|
|
||||||
const CHUNK_SIZE = 512;
|
|
||||||
const CHUNK_OVERLAP = 50;
|
|
||||||
|
|
||||||
@Processor('rag-embedding')
|
|
||||||
export class EmbeddingProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(EmbeddingProcessor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly embeddingService: EmbeddingService,
|
|
||||||
private readonly qdrantService: QdrantService,
|
|
||||||
@InjectRepository(DocumentChunk)
|
|
||||||
private readonly chunkRepo: Repository<DocumentChunk>
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job<EmbeddingJobData>): Promise<void> {
|
|
||||||
const {
|
|
||||||
attachmentPublicId,
|
|
||||||
normalizedText,
|
|
||||||
docType,
|
|
||||||
docNumber,
|
|
||||||
revision,
|
|
||||||
projectCode,
|
|
||||||
projectPublicId,
|
|
||||||
classification,
|
|
||||||
} = job.data;
|
|
||||||
|
|
||||||
const chunks = this.chunkText(normalizedText);
|
|
||||||
const model = this.embeddingService.getModelName();
|
|
||||||
|
|
||||||
const upsertPoints: Parameters<QdrantService['upsertBatch']>[0] = [];
|
|
||||||
const chunkEntities: DocumentChunk[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
|
||||||
const chunkId = uuidv4();
|
|
||||||
const vector = await this.embeddingService.embed(chunks[i]);
|
|
||||||
|
|
||||||
const payload: VectorMetadata = {
|
|
||||||
chunk_id: chunkId,
|
|
||||||
public_id: attachmentPublicId,
|
|
||||||
project_public_id: projectPublicId,
|
|
||||||
doc_type: docType,
|
|
||||||
doc_number: docNumber,
|
|
||||||
revision,
|
|
||||||
project_code: projectCode,
|
|
||||||
classification,
|
|
||||||
content_preview: chunks[i].slice(0, 500),
|
|
||||||
embedding_model: model,
|
|
||||||
};
|
|
||||||
|
|
||||||
upsertPoints.push({ id: chunkId, vector, payload });
|
|
||||||
|
|
||||||
const entity = this.chunkRepo.create({
|
|
||||||
id: chunkId,
|
|
||||||
documentId: attachmentPublicId,
|
|
||||||
chunkIndex: i,
|
|
||||||
content: chunks[i],
|
|
||||||
docType,
|
|
||||||
docNumber,
|
|
||||||
revision,
|
|
||||||
projectCode,
|
|
||||||
projectPublicId,
|
|
||||||
classification,
|
|
||||||
embeddingModel: model,
|
|
||||||
});
|
|
||||||
chunkEntities.push(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (upsertPoints.length > 0) {
|
|
||||||
await this.qdrantService.upsertBatch(upsertPoints);
|
|
||||||
await this.chunkRepo.save(chunkEntities);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.chunkRepo.manager.query(
|
|
||||||
`UPDATE attachments SET rag_status = 'INDEXED', rag_last_error = NULL WHERE public_id = ?`,
|
|
||||||
[attachmentPublicId]
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Embedded ${chunks.length} chunks for ${attachmentPublicId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private chunkText(text: string): string[] {
|
|
||||||
const words = text.split(/\s+/);
|
|
||||||
const chunks: string[] = [];
|
|
||||||
let start = 0;
|
|
||||||
|
|
||||||
while (start < words.length) {
|
|
||||||
const end = Math.min(start + CHUNK_SIZE, words.length);
|
|
||||||
chunks.push(words.slice(start, end).join(' '));
|
|
||||||
start += CHUNK_SIZE - CHUNK_OVERLAP;
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks.filter((c) => c.trim().length > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
|
|
||||||
import { DocumentChunk } from '../entities/document-chunk.entity';
|
|
||||||
|
|
||||||
export interface OcrJobData {
|
|
||||||
attachmentPublicId: string;
|
|
||||||
filePath: string;
|
|
||||||
docType: string;
|
|
||||||
docNumber: string | null;
|
|
||||||
revision: string | null;
|
|
||||||
projectCode: string;
|
|
||||||
projectPublicId: string;
|
|
||||||
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Processor('rag-ocr')
|
|
||||||
export class OcrProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(OcrProcessor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectQueue('rag-thai-preprocess') private readonly thaiQueue: Queue,
|
|
||||||
@InjectRepository(DocumentChunk)
|
|
||||||
private readonly chunkRepo: Repository<DocumentChunk>
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job<OcrJobData>): Promise<void> {
|
|
||||||
const { attachmentPublicId, filePath } = job.data;
|
|
||||||
|
|
||||||
const existing = await this.chunkRepo.count({
|
|
||||||
where: { documentId: attachmentPublicId },
|
|
||||||
});
|
|
||||||
if (existing > 0) {
|
|
||||||
this.logger.log(
|
|
||||||
`rag-ocr job already indexed for ${attachmentPublicId}, skipping`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.chunkRepo.manager.query(
|
|
||||||
`UPDATE attachments SET rag_status = 'PROCESSING' WHERE public_id = ?`,
|
|
||||||
[attachmentPublicId]
|
|
||||||
);
|
|
||||||
|
|
||||||
let rawText: string;
|
|
||||||
try {
|
|
||||||
rawText = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
} catch {
|
|
||||||
rawText = `[binary:${attachmentPublicId}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.thaiQueue.add(
|
|
||||||
'preprocess',
|
|
||||||
{ ...job.data, rawText },
|
|
||||||
{ jobId: `thai:${attachmentPublicId}` }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`OCR enqueued thai-preprocess for ${attachmentPublicId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { Queue, Job } from 'bullmq';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
import { OcrJobData } from './ocr.processor';
|
|
||||||
|
|
||||||
export interface ThaiPreprocessJobData extends OcrJobData {
|
|
||||||
rawText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmbeddingJobData extends ThaiPreprocessJobData {
|
|
||||||
normalizedText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Processor('rag-thai-preprocess')
|
|
||||||
export class ThaiPreprocessProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(ThaiPreprocessProcessor.name);
|
|
||||||
private readonly thaiUrl: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
@InjectQueue('rag-embedding') private readonly embeddingQueue: Queue
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
this.thaiUrl = this.configService.get<string>(
|
|
||||||
'THAI_PREPROCESS_URL',
|
|
||||||
'http://localhost:8765'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job<ThaiPreprocessJobData>): Promise<void> {
|
|
||||||
const { rawText, attachmentPublicId } = job.data;
|
|
||||||
|
|
||||||
let normalizedText = rawText;
|
|
||||||
try {
|
|
||||||
const response = await axios.post<{ normalized: string }>(
|
|
||||||
`${this.thaiUrl}/normalize`,
|
|
||||||
{ text: rawText },
|
|
||||||
{ timeout: 30000 }
|
|
||||||
);
|
|
||||||
normalizedText = response.data.normalized ?? rawText;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Thai preprocess failed for ${attachmentPublicId}, using raw text: ${err instanceof Error ? err.message : String(err)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.embeddingQueue.add(
|
|
||||||
'embed',
|
|
||||||
{ ...job.data, normalizedText } as EmbeddingJobData,
|
|
||||||
{ jobId: `embed:${attachmentPublicId}` }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
|
||||||
|
|
||||||
export interface VectorMetadata extends Record<string, unknown> {
|
|
||||||
chunk_id: string;
|
|
||||||
public_id: string;
|
|
||||||
project_public_id: string;
|
|
||||||
doc_type: string;
|
|
||||||
doc_number: string | null;
|
|
||||||
revision: string | null;
|
|
||||||
project_code: string;
|
|
||||||
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
|
|
||||||
content_preview: string;
|
|
||||||
embedding_model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HybridSearchResult {
|
|
||||||
chunkId: string;
|
|
||||||
publicId: string;
|
|
||||||
docType: string;
|
|
||||||
docNumber: string | null;
|
|
||||||
revision: string | null;
|
|
||||||
projectCode: string;
|
|
||||||
contentPreview: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLLECTION_NAME = 'lcbp3_vectors';
|
|
||||||
const VECTOR_SIZE = 768;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class QdrantService implements OnModuleInit {
|
|
||||||
private readonly logger = new Logger(QdrantService.name);
|
|
||||||
private client: QdrantClient;
|
|
||||||
private collectionReady = false;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
const url = this.configService.get<string>(
|
|
||||||
'QDRANT_URL',
|
|
||||||
'http://localhost:6333'
|
|
||||||
);
|
|
||||||
this.client = new QdrantClient({ url });
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.initCollection();
|
|
||||||
this.collectionReady = true;
|
|
||||||
this.logger.log(`Qdrant collection '${COLLECTION_NAME}' ready`);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
'Qdrant collection init failed — RAG queries will return 503',
|
|
||||||
err instanceof Error ? err.stack : String(err)
|
|
||||||
);
|
|
||||||
this.collectionReady = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isReady(): boolean {
|
|
||||||
return this.collectionReady;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initCollection(): Promise<void> {
|
|
||||||
const collections = await this.client.getCollections();
|
|
||||||
const exists = collections.collections.some(
|
|
||||||
(c) => c.name === COLLECTION_NAME
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
await this.client.createCollection(COLLECTION_NAME, {
|
|
||||||
vectors: { size: VECTOR_SIZE, distance: 'Cosine' },
|
|
||||||
hnsw_config: {
|
|
||||||
payload_m: 16,
|
|
||||||
m: 0,
|
|
||||||
},
|
|
||||||
optimizers_config: { indexing_threshold: 10000 },
|
|
||||||
});
|
|
||||||
this.logger.log(`Created Qdrant collection '${COLLECTION_NAME}'`);
|
|
||||||
|
|
||||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
|
||||||
field_name: 'project_public_id',
|
|
||||||
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
|
|
||||||
QdrantClient['createPayloadIndex']
|
|
||||||
>[1]['field_schema'],
|
|
||||||
});
|
|
||||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
|
||||||
field_name: 'classification',
|
|
||||||
field_schema: 'keyword',
|
|
||||||
});
|
|
||||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
|
||||||
field_name: 'doc_type',
|
|
||||||
field_schema: 'keyword',
|
|
||||||
});
|
|
||||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
|
||||||
field_name: 'doc_number',
|
|
||||||
field_schema: 'keyword',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertBatch(
|
|
||||||
points: Array<{ id: string; vector: number[]; payload: VectorMetadata }>
|
|
||||||
): Promise<void> {
|
|
||||||
await this.client.upsert(COLLECTION_NAME, {
|
|
||||||
wait: true,
|
|
||||||
points: points.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
vector: p.vector,
|
|
||||||
payload: p.payload,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async hybridSearch(
|
|
||||||
queryVector: number[],
|
|
||||||
|
|
||||||
projectPublicId: string,
|
|
||||||
classificationCeiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL',
|
|
||||||
topK: number
|
|
||||||
): Promise<HybridSearchResult[]> {
|
|
||||||
const classificationValues = this.getAllowedClassifications(
|
|
||||||
classificationCeiling
|
|
||||||
);
|
|
||||||
|
|
||||||
const vectorResults = await this.client.search(COLLECTION_NAME, {
|
|
||||||
vector: queryVector,
|
|
||||||
limit: topK,
|
|
||||||
filter: {
|
|
||||||
must: [
|
|
||||||
{ key: 'project_public_id', match: { value: projectPublicId } },
|
|
||||||
{ key: 'classification', match: { any: classificationValues } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
with_payload: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return vectorResults.map((r) => {
|
|
||||||
const payload = r.payload as unknown as VectorMetadata;
|
|
||||||
return {
|
|
||||||
chunkId: payload.chunk_id,
|
|
||||||
publicId: payload.public_id,
|
|
||||||
docType: payload.doc_type,
|
|
||||||
docNumber: payload.doc_number,
|
|
||||||
revision: payload.revision,
|
|
||||||
projectCode: payload.project_code,
|
|
||||||
contentPreview: payload.content_preview,
|
|
||||||
score: r.score,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByDocumentId(documentId: string): Promise<void> {
|
|
||||||
await this.client.delete(COLLECTION_NAME, {
|
|
||||||
wait: true,
|
|
||||||
filter: {
|
|
||||||
must: [{ key: 'public_id', match: { value: documentId } }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async forceInitCollection(): Promise<void> {
|
|
||||||
await this.initCollection();
|
|
||||||
this.collectionReady = true;
|
|
||||||
this.logger.log(`Qdrant collection force-initialized`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAllowedClassifications(
|
|
||||||
ceiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'
|
|
||||||
): string[] {
|
|
||||||
const order: Array<'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'> = [
|
|
||||||
'PUBLIC',
|
|
||||||
'INTERNAL',
|
|
||||||
'CONFIDENTIAL',
|
|
||||||
];
|
|
||||||
const ceilIdx = order.indexOf(ceiling);
|
|
||||||
return order.slice(0, ceilIdx + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Logger,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
|
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
||||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
||||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
|
||||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import { User } from '../user/entities/user.entity';
|
|
||||||
import { RagQueryDto } from './dto/rag-query.dto';
|
|
||||||
import { RagService } from './rag.service';
|
|
||||||
|
|
||||||
@ApiTags('RAG')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
||||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
|
||||||
@Controller('rag')
|
|
||||||
export class RagController {
|
|
||||||
private readonly logger = new Logger(RagController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly ragService: RagService,
|
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post('query')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: 'RAG Q&A — ค้นหาคำตอบจากเอกสารโครงการ' })
|
|
||||||
@RequirePermission('rag.query')
|
|
||||||
async query(
|
|
||||||
@Body() dto: RagQueryDto,
|
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Headers('Idempotency-Key') idempotencyKey: string
|
|
||||||
) {
|
|
||||||
if (!idempotencyKey) {
|
|
||||||
this.logger.warn(`Missing Idempotency-Key from user ${user.user_id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await this.userService.getUserPermissions(user.user_id);
|
|
||||||
return this.ragService.query(dto, permissions);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('status/:attachmentId')
|
|
||||||
@ApiOperation({ summary: 'ดูสถานะ RAG ingestion ของ attachment' })
|
|
||||||
@RequirePermission('rag.query')
|
|
||||||
async getStatus(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
|
|
||||||
return this.ragService.getStatus(attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('ingest/:attachmentId')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: 'Re-ingest attachment ที่ FAILED (Admin only)' })
|
|
||||||
@RequirePermission('rag.manage')
|
|
||||||
async reIngest(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
|
|
||||||
await this.ragService.reIngest(attachmentId);
|
|
||||||
return { message: 'Re-ingestion queued' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('vectors/:attachmentId')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
@ApiOperation({ summary: 'ลบ vectors ของ attachment ออกจาก Qdrant' })
|
|
||||||
@RequirePermission('rag.manage')
|
|
||||||
async deleteVectors(
|
|
||||||
@Param('attachmentId', ParseUuidPipe) attachmentId: string
|
|
||||||
) {
|
|
||||||
await this.ragService.deleteVectors(attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('admin/init-collection')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'T038: Init Qdrant collection lcbp3_vectors (admin only)',
|
|
||||||
})
|
|
||||||
@RequirePermission('rag.manage')
|
|
||||||
async initCollection() {
|
|
||||||
await this.ragService.initCollection();
|
|
||||||
return { message: 'Qdrant collection initialized' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
|
|
||||||
import { DocumentChunk } from './entities/document-chunk.entity';
|
|
||||||
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
|
|
||||||
import { EmbeddingService } from './embedding.service';
|
|
||||||
import { QdrantService } from './qdrant.service';
|
|
||||||
import { LocalLlmService } from './local-llm.service';
|
|
||||||
import { RagService } from './rag.service';
|
|
||||||
import { RagController } from './rag.controller';
|
|
||||||
import { IngestionService } from './ingestion.service';
|
|
||||||
import { OcrProcessor } from './processors/ocr.processor';
|
|
||||||
import { ThaiPreprocessProcessor } from './processors/thai-preprocess.processor';
|
|
||||||
import { EmbeddingProcessor } from './processors/embedding.processor';
|
|
||||||
import { UserModule } from '../user/user.module';
|
|
||||||
|
|
||||||
const DLQ_DEFAULTS = {
|
|
||||||
attempts: 3,
|
|
||||||
backoff: { type: 'exponential' as const, delay: 2000 },
|
|
||||||
removeOnComplete: 100,
|
|
||||||
removeOnFail: 200,
|
|
||||||
};
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigModule,
|
|
||||||
UserModule,
|
|
||||||
TypeOrmModule.forFeature([DocumentChunk]),
|
|
||||||
BullModule.registerQueue(
|
|
||||||
{ name: 'rag-ocr', defaultJobOptions: DLQ_DEFAULTS },
|
|
||||||
{ name: 'rag-thai-preprocess', defaultJobOptions: DLQ_DEFAULTS },
|
|
||||||
{ name: 'rag-embedding', defaultJobOptions: DLQ_DEFAULTS },
|
|
||||||
// T028: Producer สำหรับ dispatch vector deletion jobs (ADR-023 FR-008)
|
|
||||||
{ name: QUEUE_AI_VECTOR_DELETION }
|
|
||||||
),
|
|
||||||
],
|
|
||||||
controllers: [RagController],
|
|
||||||
providers: [
|
|
||||||
EmbeddingService,
|
|
||||||
QdrantService,
|
|
||||||
LocalLlmService,
|
|
||||||
RagService,
|
|
||||||
IngestionService,
|
|
||||||
OcrProcessor,
|
|
||||||
ThaiPreprocessProcessor,
|
|
||||||
EmbeddingProcessor,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
EmbeddingService,
|
|
||||||
QdrantService,
|
|
||||||
LocalLlmService,
|
|
||||||
RagService,
|
|
||||||
IngestionService,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class RagModule {}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
|
|
||||||
import { AiVectorDeletionJobPayload } from '../ai/ai-queue.service';
|
|
||||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
|
||||||
import Redis from 'ioredis';
|
|
||||||
import { createHash } from 'crypto';
|
|
||||||
|
|
||||||
import { QdrantService } from './qdrant.service';
|
|
||||||
import { EmbeddingService } from './embedding.service';
|
|
||||||
import { LocalLlmService } from './local-llm.service';
|
|
||||||
import { IngestionService } from './ingestion.service';
|
|
||||||
import { DocumentChunk } from './entities/document-chunk.entity';
|
|
||||||
import { RagQueryDto } from './dto/rag-query.dto';
|
|
||||||
import { RagResponseDto, RagCitation } from './dto/rag-response.dto';
|
|
||||||
|
|
||||||
const CACHE_TTL_SECONDS = 300;
|
|
||||||
const PROMPT_CONTEXT_LIMIT = 3000;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RagService {
|
|
||||||
private readonly logger = new Logger(RagService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly qdrant: QdrantService,
|
|
||||||
private readonly embedding: EmbeddingService,
|
|
||||||
private readonly localLlm: LocalLlmService,
|
|
||||||
private readonly ingestionService: IngestionService,
|
|
||||||
@InjectRepository(DocumentChunk)
|
|
||||||
private readonly chunkRepo: Repository<DocumentChunk>,
|
|
||||||
@InjectRedis() private readonly redis: Redis,
|
|
||||||
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
|
|
||||||
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async query(
|
|
||||||
dto: RagQueryDto,
|
|
||||||
userPermissions: string[]
|
|
||||||
): Promise<RagResponseDto> {
|
|
||||||
const { question, projectPublicId } = dto;
|
|
||||||
|
|
||||||
const classificationCeiling =
|
|
||||||
this.deriveClassificationCeiling(userPermissions);
|
|
||||||
const isConfidential = classificationCeiling === 'CONFIDENTIAL';
|
|
||||||
|
|
||||||
if (!this.qdrant.isReady()) {
|
|
||||||
throw new ServiceUnavailableException('RAG_NOT_READY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = this.buildCacheKey(
|
|
||||||
question,
|
|
||||||
projectPublicId,
|
|
||||||
classificationCeiling
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isConfidential) {
|
|
||||||
const cached = await this.redis.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
const parsed = JSON.parse(cached) as RagResponseDto;
|
|
||||||
parsed.cachedAt = new Date().toISOString();
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryVector = await this.embedding.embed(question);
|
|
||||||
const topK = 20;
|
|
||||||
|
|
||||||
const results = await this.qdrant.hybridSearch(
|
|
||||||
queryVector,
|
|
||||||
projectPublicId,
|
|
||||||
classificationCeiling,
|
|
||||||
topK
|
|
||||||
);
|
|
||||||
|
|
||||||
const reranked = results.sort((a, b) => b.score - a.score).slice(0, 5);
|
|
||||||
|
|
||||||
const context = this.buildContext(reranked);
|
|
||||||
|
|
||||||
const safeQuestion = this.localLlm.sanitizeInput(question);
|
|
||||||
const prompt = this.buildPrompt(safeQuestion, context);
|
|
||||||
|
|
||||||
const { answer, usedFallbackModel } = await this.localLlm.generate(prompt);
|
|
||||||
|
|
||||||
const citations: RagCitation[] = reranked.map((r) => ({
|
|
||||||
chunkId: r.chunkId,
|
|
||||||
docNumber: r.docNumber,
|
|
||||||
docType: r.docType,
|
|
||||||
revision: r.revision,
|
|
||||||
snippet: r.contentPreview.slice(0, 200),
|
|
||||||
score: r.score,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const confidence = reranked.length > 0 ? reranked[0].score : 0;
|
|
||||||
|
|
||||||
const response: RagResponseDto = {
|
|
||||||
answer,
|
|
||||||
citations,
|
|
||||||
confidence,
|
|
||||||
usedFallbackModel,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isConfidential) {
|
|
||||||
await this.redis.setex(
|
|
||||||
cacheKey,
|
|
||||||
CACHE_TTL_SECONDS,
|
|
||||||
JSON.stringify(response)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStatus(
|
|
||||||
attachmentPublicId: string
|
|
||||||
): Promise<{ ragStatus: string; chunkCount: number }> {
|
|
||||||
const chunkCount = await this.chunkRepo.count({
|
|
||||||
where: { documentId: attachmentPublicId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await this.chunkRepo.manager.query<{ rag_status: string }[]>(
|
|
||||||
`SELECT rag_status FROM attachments WHERE public_id = ? LIMIT 1`,
|
|
||||||
[attachmentPublicId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const ragStatus = result[0]?.rag_status ?? 'PENDING';
|
|
||||||
return { ragStatus, chunkCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
async reIngest(attachmentPublicId: string): Promise<void> {
|
|
||||||
const statusResult = await this.chunkRepo.manager.query<
|
|
||||||
{ rag_status: string; file_path: string }[]
|
|
||||||
>(
|
|
||||||
`SELECT rag_status, file_path FROM attachments WHERE public_id = ? LIMIT 1`,
|
|
||||||
[attachmentPublicId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const current = statusResult[0]?.rag_status;
|
|
||||||
if (current !== 'FAILED') {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Cannot re-ingest: current status is '${current ?? 'unknown'}', expected 'FAILED'`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sample = await this.chunkRepo.findOne({
|
|
||||||
where: { documentId: attachmentPublicId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.chunkRepo.delete({ documentId: attachmentPublicId });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.qdrant.deleteByDocumentId(attachmentPublicId);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Qdrant delete failed for ${attachmentPublicId} — continuing`,
|
|
||||||
err instanceof Error ? err.stack : String(err)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.chunkRepo.manager.query(
|
|
||||||
`UPDATE attachments SET rag_status = 'PENDING', rag_last_error = NULL WHERE public_id = ?`,
|
|
||||||
[attachmentPublicId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sample) {
|
|
||||||
await this.ingestionService.enqueue({
|
|
||||||
attachmentPublicId,
|
|
||||||
filePath: statusResult[0]?.file_path ?? '',
|
|
||||||
docType: sample.docType,
|
|
||||||
docNumber: sample.docNumber,
|
|
||||||
revision: sample.revision,
|
|
||||||
projectCode: sample.projectCode,
|
|
||||||
projectPublicId: sample.projectPublicId,
|
|
||||||
classification: sample.classification,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async initCollection(): Promise<void> {
|
|
||||||
await this.qdrant.onModuleInit();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteVectors(
|
|
||||||
attachmentPublicId: string,
|
|
||||||
requestedByUserPublicId = 'system'
|
|
||||||
): Promise<void> {
|
|
||||||
// ลบ DocumentChunk ออกจาก DB แบบ synchronous (รวดเร็ว ไม่มี external dependency)
|
|
||||||
await this.chunkRepo.delete({ documentId: attachmentPublicId });
|
|
||||||
// T028: เปลี่ยน Qdrant deletion เป็น async ผ่าน BullMQ เพื่อ eventual consistency (FR-008)
|
|
||||||
await this.vectorDeletionQueue.add(
|
|
||||||
'delete-document-vectors',
|
|
||||||
{ documentPublicId: attachmentPublicId, requestedByUserPublicId },
|
|
||||||
{
|
|
||||||
jobId: attachmentPublicId,
|
|
||||||
attempts: 3,
|
|
||||||
backoff: { type: 'exponential', delay: 5000 },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this.logger.log(
|
|
||||||
`Vector deletion queued for attachment=${attachmentPublicId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildContext(
|
|
||||||
results: Array<{
|
|
||||||
docType: string;
|
|
||||||
docNumber: string | null;
|
|
||||||
revision: string | null;
|
|
||||||
contentPreview: string;
|
|
||||||
}>
|
|
||||||
): string {
|
|
||||||
let context = '';
|
|
||||||
for (const r of results) {
|
|
||||||
const header = `[${r.docType}${r.docNumber ? ` - ${r.docNumber}` : ''}${r.revision ? ` - ${r.revision}` : ''}]`;
|
|
||||||
const snippet = `${header}\n${r.contentPreview}\n\n`;
|
|
||||||
if ((context + snippet).length > PROMPT_CONTEXT_LIMIT) break;
|
|
||||||
context += snippet;
|
|
||||||
}
|
|
||||||
return context.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildPrompt(question: string, context: string): string {
|
|
||||||
return [
|
|
||||||
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
|
|
||||||
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
|
|
||||||
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
|
|
||||||
'',
|
|
||||||
'=== เอกสารอ้างอิง ===',
|
|
||||||
context,
|
|
||||||
'',
|
|
||||||
'=== คำถาม ===',
|
|
||||||
question,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCacheKey(
|
|
||||||
question: string,
|
|
||||||
projectPublicId: string,
|
|
||||||
classificationCeiling: string
|
|
||||||
): string {
|
|
||||||
const raw = `${question}|${projectPublicId}|${classificationCeiling}`;
|
|
||||||
return `rag:query:${createHash('sha256').update(raw).digest('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private deriveClassificationCeiling(
|
|
||||||
permissions: string[]
|
|
||||||
): 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' {
|
|
||||||
if (
|
|
||||||
permissions.includes('system.manage_all') ||
|
|
||||||
permissions.includes('document.view_confidential')
|
|
||||||
) {
|
|
||||||
return 'CONFIDENTIAL';
|
|
||||||
}
|
|
||||||
return 'INTERNAL';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
# AI Refactor
|
||||||
|
เนื่องจากการอัพเกรด จาก RTX2060 SUPER 8GB เป็น ASUS DUAL **RTX5060 Ti 16GB**
|
||||||
|
|
||||||
|
## เป้าหมาย
|
||||||
|
ปรับปรุงประสิทธิภาพการประมวลผล AI โดยใช้ทรัพยากรใหม่ให้เหมาะสม, รวมถึงปรับปรุงขั้นตอนการทำงานให้เหมาะสมกับทรัพยากรใหม่
|
||||||
|
|
||||||
|
```text
|
||||||
|
Typhoon OCR 1.5
|
||||||
|
Typhoon2.5-Qwen3-4B
|
||||||
|
BGE-M3
|
||||||
|
การตั้งค่าระบบคิว (BullMQ) ร่วมกับ AI
|
||||||
|
```
|
||||||
|
## Model
|
||||||
|
|
||||||
|
|Model Name|Size|Base FROM|PARAMETER|File|
|
||||||
|
|-|-|-|-|-|
|
||||||
|
|np-dms-ocr|2.9GB|scb10x/typhoon-ocr1.5-3b:latest|num_ctx 8192|np-dms-ocr-model.md|
|
||||||
|
|np-dms-typhoon2.5|3.6GB|scb10x/typhoon2.5-qwen3-4b:latest|num_ctx 8192|np-dms-typhoon2.5.model.md|
|
||||||
|
|np-dms-llama3.1-typhoon2-8b|5.5GB|scb10x/llama3.1-typhoon2-8b-instruct|num_ctx 8192|np-dms-llama3.1-typhoon2-8b.model.md|
|
||||||
|
|np-dms-gemma4-4eb|3.2GB|gemma4:e4b|num_ctx 8192|np-dms-gemma4-4eb.model.md|
|
||||||
|
|np-dms-openthaigpt-7b|8GB|promptnow/openthaigpt1.5-7b-instruct-q4_k_m|num_ctx 8192|np-dms-openthaigpt-7b.model.md|
|
||||||
|
|np-dms-openthaigpt-14b|9.7GB|promptnow/openthaigpt1.5-14b-instruct-q4_k_m|num_ctx 8192|np-dms-openthaigpt-14b.model.md|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ollama create np-dms-typhoon2.5 -f np-dms-typhoon2.5.model.md
|
||||||
|
|
||||||
|
ollama create np-dms-llama3.1-typhoon2-8b -f np-dms-llama3.1-typhoon2-8b.model.md
|
||||||
|
|
||||||
|
ollama create np-dms-gemma4-4eb -f np-dms-gemma4-4eb.model.md
|
||||||
|
|
||||||
|
ollama create np-dms-openthaigpt-7b -f np-dms-openthaigpt-7b.model.md
|
||||||
|
|
||||||
|
ollama create np-dms-openthaigpt-14b -f np-dms-openthaigpt-14b.model.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decisions (RTX5060 Ti 16GB Optimized)
|
||||||
|
|
||||||
|
> สรุปการตัดสินใจจาก grilling session — อัปเกรดจาก RTX2060 SUPER 8GB
|
||||||
|
|
||||||
|
### VRAM Budget
|
||||||
|
|
||||||
|
| คอมโพเนนต์ | VRAM | หมายเหตุ |
|
||||||
|
|-----------|------|----------|
|
||||||
|
| `typhoon2.5-np-dms` | 3.6GB | โหลดค้างตลอด (resident) |
|
||||||
|
| `typhoon-np-dms-ocr` | 2.9GB | transient (load on-demand) |
|
||||||
|
| BGE-M3 | 2.3GB | ย้ายเข้า GPU (Sidecar device='cuda') |
|
||||||
|
| BGE-Reranker-Large | 1.5GB | ย้ายเข้า GPU (Sidecar device='cuda') |
|
||||||
|
| **รวมสูงสุด** | **~10.3GB** | เหลือ headroom ~5.7GB |
|
||||||
|
|
||||||
|
### BullMQ Concurrency
|
||||||
|
|
||||||
|
| Queue | Concurrency | เหตุผล |
|
||||||
|
|-------|-------------|--------|
|
||||||
|
| `ai-realtime` | **2** | VRAM เหลือเยอะ, response เร็วขึ้น |
|
||||||
|
| `ai-batch` | **1** | background job, ป้องกัน VRAM overflow |
|
||||||
|
|
||||||
|
### Model Loading Strategy
|
||||||
|
|
||||||
|
| โมเดล | กลยุทธ์ | keep_alive |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| `typhoon2.5-np-dms` | โหลดค้างตลอด (ไม่ unload) | — |
|
||||||
|
| `typhoon-np-dms-ocr` | โหลดตาม demand, unload อัตโนมัติหลัง 5 นาที | 300 |
|
||||||
|
|
||||||
|
### Sidecar Changes (port 8765)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
# ปัจจุบัน (CPU RAM)
|
||||||
|
POST /embed → BGE-M3 (CPU)
|
||||||
|
POST /rerank → BGE-Reranker (CPU)
|
||||||
|
|
||||||
|
# หลังอัปเกรด (GPU)
|
||||||
|
POST /embed → BGE-M3 (GPU via device='cuda')
|
||||||
|
POST /rerank → BGE-Reranker (GPU via device='cuda')
|
||||||
|
POST /ocr-upload → Typhoon OCR (Ollama) ← ไม่เปลี่ยน
|
||||||
|
POST /normalize → PyThaiNLP (CPU) ← ไม่เปลี่ยน
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Tasks
|
||||||
|
|
||||||
|
- [ ] แก้ไข Sidecar Dockerfile — เพิ่ม CUDA runtime
|
||||||
|
- [ ] แก้ไข Sidecar app.py — เปลี่ยน `device='cuda'` สำหรับ BGE models
|
||||||
|
- [ ] แก้ไข docker-compose.yml — เพิ่ม NVIDIA Container Toolkit
|
||||||
|
- [ ] อัปเดต BullMQ concurrency config (ai-realtime=2)
|
||||||
|
- [ ] อัปเดต OCR keep_alive จาก 0 เป็น 300
|
||||||
|
- [ ] ตรวจสอบ OllamaService รองรับ resident model
|
||||||
|
- [ ] ทดสอบ VRAM usage จริงกับเอกสารขนาดใหญ่
|
||||||
|
|
||||||
|
### Rollout Strategy
|
||||||
|
|
||||||
|
**Big Bang** — ระบบยังไม่เปิดใช้งาน production ทำการเปลี่ยนแปลงทั้งหมดในครั้งเดียว
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1 : Foundation
|
||||||
|
|
||||||
|
## 1. Infrastructure
|
||||||
|
|
||||||
|
### AI Services
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ollama
|
||||||
|
├── Typhoon OCR 1.5
|
||||||
|
├── Typhoon2.5-Qwen3-4B
|
||||||
|
└── BGE-M3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
```text
|
||||||
|
Qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage AI
|
||||||
|
|
||||||
|
```text
|
||||||
|
File Serv
|
||||||
|
├── OCR Output
|
||||||
|
└── Processed Data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 : Ingestion Pipeline
|
||||||
|
|
||||||
|
## Step 1 Upload
|
||||||
|
|
||||||
|
```text
|
||||||
|
PDF Upload
|
||||||
|
↓
|
||||||
|
Store Original File
|
||||||
|
↓
|
||||||
|
Create Job
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 OCR
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
PDF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process
|
||||||
|
|
||||||
|
```text
|
||||||
|
Typhoon OCR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"content": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store
|
||||||
|
|
||||||
|
```text
|
||||||
|
raw_ocr
|
||||||
|
```
|
||||||
|
|
||||||
|
Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
document_pages
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
document_id
|
||||||
|
page_no
|
||||||
|
raw_text
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 Structure
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
Raw OCR Text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process
|
||||||
|
|
||||||
|
```text
|
||||||
|
Typhoon2.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
จัดโครงสร้างเอกสาร
|
||||||
|
แยก Heading
|
||||||
|
Section
|
||||||
|
Metadata
|
||||||
|
ห้ามสรุป
|
||||||
|
```
|
||||||
|
|
||||||
|
Output
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"document_type": "ITP",
|
||||||
|
"project": "...",
|
||||||
|
"heading": "...",
|
||||||
|
"content": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store
|
||||||
|
|
||||||
|
```text
|
||||||
|
structured_document
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 Chunking
|
||||||
|
|
||||||
|
### ไม่ใช้ LLM
|
||||||
|
|
||||||
|
ใช้
|
||||||
|
|
||||||
|
```text
|
||||||
|
Markdown Header Splitter
|
||||||
|
+
|
||||||
|
Recursive Splitter
|
||||||
|
```
|
||||||
|
|
||||||
|
Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
chunk_size: 800
|
||||||
|
chunk_overlap: 120
|
||||||
|
```
|
||||||
|
|
||||||
|
Output
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chunk_id": "...",
|
||||||
|
"heading": "...",
|
||||||
|
"content": "...",
|
||||||
|
"page": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 Embedding
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
Chunk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process
|
||||||
|
|
||||||
|
```text
|
||||||
|
BGE-M3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vector
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 Index
|
||||||
|
|
||||||
|
Store in
|
||||||
|
|
||||||
|
```text
|
||||||
|
Qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"document_id": "...",
|
||||||
|
"page": 12,
|
||||||
|
"document_type": "ITP",
|
||||||
|
"heading": "Inspection",
|
||||||
|
"content": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3 : Retrieval
|
||||||
|
|
||||||
|
## Step 1 User Query
|
||||||
|
|
||||||
|
```text
|
||||||
|
Slump Test สำหรับงานพื้นชั้น 2 คืออะไร
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 Query Embedding
|
||||||
|
|
||||||
|
```text
|
||||||
|
BGE-M3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 Search
|
||||||
|
|
||||||
|
```text
|
||||||
|
Qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
Top K
|
||||||
|
|
||||||
|
```text
|
||||||
|
10-20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 Re-rank (แนะนำ)
|
||||||
|
|
||||||
|
ใช้
|
||||||
|
|
||||||
|
```text
|
||||||
|
Typhoon2.5
|
||||||
|
```
|
||||||
|
|
||||||
|
หรือภายหลังเพิ่ม
|
||||||
|
|
||||||
|
```text
|
||||||
|
bge-reranker-v2
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Top20
|
||||||
|
↓
|
||||||
|
Top5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 Answer
|
||||||
|
|
||||||
|
ใช้
|
||||||
|
|
||||||
|
```text
|
||||||
|
Typhoon2.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
ตอบจาก Context เท่านั้น
|
||||||
|
อ้างอิงเอกสาร
|
||||||
|
อ้างอิงหน้า
|
||||||
|
ห้ามเดา
|
||||||
|
```
|
||||||
|
|
||||||
|
Output
|
||||||
|
|
||||||
|
```text
|
||||||
|
คำตอบ
|
||||||
|
|
||||||
|
อ้างอิง:
|
||||||
|
ITP-001 หน้า 12
|
||||||
|
MS-005 หน้า 8
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 4 : Metadata Extraction
|
||||||
|
|
||||||
|
เพิ่มภายหลัง
|
||||||
|
|
||||||
|
Typhoon2.5 Extract
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project
|
||||||
|
Contractor
|
||||||
|
Subcontractor
|
||||||
|
Discipline
|
||||||
|
Document Type
|
||||||
|
Revision
|
||||||
|
Date
|
||||||
|
```
|
||||||
|
|
||||||
|
เก็บใน PostgreSQL
|
||||||
|
|
||||||
|
ช่วยทำ Filter Search เช่น
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project = ABC
|
||||||
|
Type = MIR
|
||||||
|
Revision = C
|
||||||
|
```
|
||||||
|
|
||||||
|
ก่อนเข้า Qdrant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ollama Models
|
||||||
|
|
||||||
|
## Typhoon OCR
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM scb10x/typhoon-ocr1.5-3b:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
ไม่ต้อง custom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typhoon2.5
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM scb10x/typhoon2.5-qwen3-4b:latest
|
||||||
|
|
||||||
|
PARAMETER temperature 0.1
|
||||||
|
PARAMETER top_p 0.9
|
||||||
|
PARAMETER repeat_penalty 1.05
|
||||||
|
PARAMETER num_ctx 8192
|
||||||
|
```
|
||||||
|
|
||||||
|
**ไม่มี SYSTEM**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime Config
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"num_ctx": 8192,
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Answer
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"num_ctx": 16384,
|
||||||
|
"temperature": 0.1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# MVP Roadmap
|
||||||
|
|
||||||
|
## Sprint 1
|
||||||
|
|
||||||
|
✅ Upload PDF
|
||||||
|
✅ OCR
|
||||||
|
✅ Store OCR
|
||||||
|
✅ Chunking
|
||||||
|
✅ Embedding
|
||||||
|
✅ Qdrant Search
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 2
|
||||||
|
|
||||||
|
✅ Typhoon2.5 Structuring
|
||||||
|
✅ Metadata Extraction
|
||||||
|
✅ Better Chunking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 3
|
||||||
|
|
||||||
|
✅ RAG QA
|
||||||
|
✅ Citation
|
||||||
|
✅ Source Reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 4
|
||||||
|
|
||||||
|
✅ Hybrid Search (Vector + Metadata)
|
||||||
|
✅ Re-ranking
|
||||||
|
✅ Multi-document QA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Architecture สุดท้าย
|
||||||
|
|
||||||
|
```text
|
||||||
|
PDF
|
||||||
|
↓
|
||||||
|
Typhoon OCR
|
||||||
|
↓
|
||||||
|
Raw OCR
|
||||||
|
↓
|
||||||
|
Typhoon2.5
|
||||||
|
(Structure + Metadata)
|
||||||
|
↓
|
||||||
|
Markdown/Header Splitter
|
||||||
|
↓
|
||||||
|
Recursive Splitter
|
||||||
|
↓
|
||||||
|
BGE-M3
|
||||||
|
↓
|
||||||
|
Qdrant
|
||||||
|
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
Question
|
||||||
|
↓
|
||||||
|
BGE-M3
|
||||||
|
↓
|
||||||
|
Qdrant
|
||||||
|
↓
|
||||||
|
Top-K Chunks
|
||||||
|
↓
|
||||||
|
Typhoon2.5
|
||||||
|
↓
|
||||||
|
Answer + Citation
|
||||||
|
```
|
||||||
|
|
||||||
|
สำหรับ MVP ผมจะ **ตัด Metadata Extraction ขั้นสูงและ Re-ranker ออกก่อน** แล้วทำให้ OCR → Search → Answer ใช้งานได้จริงภายใน 2–3 สัปดาห์แรก จากนั้นค่อยเพิ่มความแม่นยำทีละส่วน.
|
||||||
+1
-1
@@ -726,7 +726,7 @@ AI-powered Document Management System
|
|||||||
6 Automation workflow
|
6 Automation workflow
|
||||||
7 Security
|
7 Security
|
||||||
```
|
```
|
||||||
## 💬 Prompt Templates สำหรับถาม Windsurf
|
## 💬 Prompt Templates สำหรับถาม Devin
|
||||||
|
|
||||||
### เมื่อต้องการสร้างฟีเจอร์ใหม่
|
### เมื่อต้องการสร้างฟีเจอร์ใหม่
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# AI Knowledge Base for NAP-DMS (LCBP3)
|
# AI Knowledge Base for NAP-DMS (LCBP3)
|
||||||
|
|
||||||
คลังความรู้สำหรับ AI Assistant (Antigravity, Windsurf, Codex) เพื่อช่วยในการพัฒนาระบบ Document Management System (DMS)
|
คลังความรู้สำหรับ AI Assistant (Antigravity, Devin, Codex) เพื่อช่วยในการพัฒนาระบบ Document Management System (DMS)
|
||||||
|
|
||||||
## 📁 โครงสร้างโฟลเดอร์
|
## 📁 โครงสร้างโฟลเดอร์
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
- `core/`: กฎพื้นฐานและมาตรฐานการเขียนโค้ด
|
- `core/`: กฎพื้นฐานและมาตรฐานการเขียนโค้ด
|
||||||
- `dms/`: เฉพาะทางด้านระบบจัดการเอกสาร
|
- `dms/`: เฉพาะทางด้านระบบจัดการเอกสาร
|
||||||
- `infra/`: งานด้าน Infrastructure และ Network
|
- `infra/`: งานด้าน Infrastructure และ Network
|
||||||
- `codex/`: คำสั่งเฉพาะสำหรับ Windsurf/Codex
|
- `codex/`: คำสั่งเฉพาะสำหรับ Devin/Codex
|
||||||
- `templates/`: แม่แบบเอกสารต่างๆ (Spec, Bug Report, etc.)
|
- `templates/`: แม่แบบเอกสารต่างๆ (Spec, Bug Report, etc.)
|
||||||
- `playbooks/`: คู่มือขั้นตอนการทำงานที่ซับซ้อน
|
- `playbooks/`: คู่มือขั้นตอนการทำงานที่ซับซ้อน
|
||||||
- `checklists/`: รายการตรวจสอบก่อนส่งงานหรือ Deploy
|
- `checklists/`: รายการตรวจสอบก่อนส่งงานหรือ Deploy
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md
|
// File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md
|
||||||
# Bug Fix Prompt (Windsurf/Codex)
|
# Bug Fix Prompt (Devin/Codex)
|
||||||
|
|
||||||
## ⭐ Role: Debugging Specialist
|
## ⭐ Role: Debugging Specialist
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// File: docs/ai-knowledge-base/prompts/codex/codex-feature.md
|
// File: docs/ai-knowledge-base/prompts/codex/codex-feature.md
|
||||||
# Feature Implementation Prompt (Windsurf/Codex)
|
# Feature Implementation Prompt (Devin/Codex)
|
||||||
|
|
||||||
## ⭐ Role: Senior Full Stack Developer (DMS Specialist)
|
## ⭐ Role: Senior Full Stack Developer (DMS Specialist)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// File: docs/ai-knowledge-base/prompts/codex/codex-review.md
|
// File: docs/ai-knowledge-base/prompts/codex/codex-review.md
|
||||||
# Code Review Prompt (Windsurf/Codex)
|
# Code Review Prompt (Devin/Codex)
|
||||||
|
|
||||||
## ⭐ Role: Senior Code Reviewer (DMS Specialist)
|
## ⭐ Role: Senior Code Reviewer (DMS Specialist)
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -18,7 +18,7 @@ npx playwright install chromium
|
|||||||
npx playwright install
|
npx playwright install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. **MCP Server สำหรับ Windsurf**
|
### 3. **MCP Server สำหรับ Devin**
|
||||||
|
|
||||||
เพิ่มใน [.windsurfrc](cci:7://file:///e:/np-dms/lcbp3/.windsurfrc:0:0-0:0):
|
เพิ่มใน [.windsurfrc](cci:7://file:///e:/np-dms/lcbp3/.windsurfrc:0:0-0:0):
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@ npx playwright install
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Restart Windsurf** แล้วจะเห็น Playwright MCP panel
|
**Restart Devin** แล้วจะเห็น Playwright MCP panel
|
||||||
|
|
||||||
### 4. **การใช้งานผ่าน Windsurf Cascade**
|
### 4. **การใช้งานผ่าน Devin Cascade**
|
||||||
|
|
||||||
เมื่อ MCP พร้อมแล้ว สามารถใช้คำสั่ง:
|
เมื่อ MCP พร้อมแล้ว สามารถใช้คำสั่ง:
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ npx playwright test --headed
|
|||||||
npx playwright show-report
|
npx playwright show-report
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. **ถ้าใช้ MCP ผ่าน Windsurf**
|
### 8. **ถ้าใช้ MCP ผ่าน Devin**
|
||||||
|
|
||||||
Cascade จะมี tool ให้ใช้:
|
Cascade จะมี tool ให้ใช้:
|
||||||
- `browser_navigate` - เปิด URL
|
- `browser_navigate` - เปิด URL
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { useRagQuery } from '../../../hooks/use-rag';
|
import { RagChatWidget } from '../../../components/ai/RagChatWidget';
|
||||||
import { useProjectStore } from '../../../lib/stores/project-store';
|
import { useProjectStore } from '../../../lib/stores/project-store';
|
||||||
import { RagSearchBar } from '../../../components/rag/rag-search-bar';
|
|
||||||
import { RagResultCard } from '../../../components/rag/rag-result-card';
|
|
||||||
|
|
||||||
export default function RagPage() {
|
export default function RagPage() {
|
||||||
const { selectedProjectId } = useProjectStore();
|
const { selectedProjectId } = useProjectStore();
|
||||||
const { mutate, data, isPending, error, isIdle } = useRagQuery();
|
|
||||||
|
|
||||||
const handleSearch = (question: string) => {
|
|
||||||
if (!selectedProjectId) return;
|
|
||||||
mutate({ question, projectPublicId: selectedProjectId });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-3xl py-8 space-y-6">
|
<div className="container mx-auto max-w-3xl py-8 space-y-6">
|
||||||
@@ -28,25 +20,11 @@ export default function RagPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RagSearchBar onSearch={handleSearch} isLoading={isPending} />
|
{selectedProjectId ? (
|
||||||
|
<RagChatWidget projectPublicId={selectedProjectId} />
|
||||||
{isPending && (
|
) : (
|
||||||
<div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground animate-pulse">
|
|
||||||
กำลังค้นหาและประมวลผล...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
||||||
เกิดข้อผิดพลาด: {error.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && !isPending && <RagResultCard result={data} />}
|
|
||||||
|
|
||||||
{isIdle && !error && (
|
|
||||||
<p className="text-center text-sm text-muted-foreground pt-4">
|
<p className="text-center text-sm text-muted-foreground pt-4">
|
||||||
พิมพ์คำถามแล้วกด ค้นหา เพื่อรับคำตอบจากเอกสารโครงการ
|
เลือกโครงการก่อนเพื่อเริ่มถามคำถามกับ RAG pipeline ใหม่
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function OcrSandboxPromptManager() {
|
|||||||
fallbackUsed?: boolean;
|
fallbackUsed?: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined);
|
const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined);
|
||||||
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox } =
|
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox, startPolling } =
|
||||||
useSandboxRun(() => {
|
useSandboxRun(() => {
|
||||||
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
|
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
|
||||||
versionsQuery.refetch();
|
versionsQuery.refetch();
|
||||||
@@ -285,24 +285,8 @@ export default function OcrSandboxPromptManager() {
|
|||||||
selectedPromptVersion
|
selectedPromptVersion
|
||||||
);
|
);
|
||||||
toast.success('AI Extraction started');
|
toast.success('AI Extraction started');
|
||||||
// Poll สำหรับผลลัพธ์ AI
|
// เริ่ม polling ผ่าน useSandboxRun hook
|
||||||
const pollInterval = setInterval(async () => {
|
startPolling(requestPublicId);
|
||||||
try {
|
|
||||||
const result = await adminAiService.getSandboxJobStatus(requestPublicId);
|
|
||||||
if (result.status === 'completed') {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
// Trigger sandbox state update via useSandboxRun
|
|
||||||
toast.success(t('ai.prompt.sandboxSuccess'));
|
|
||||||
versionsQuery.refetch();
|
|
||||||
} else if (result.status === 'failed') {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
toast.error(result.errorMessage || 'AI Extraction failed');
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
toast.error('Poll error occurred');
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { message?: string } } };
|
const error = err as { response?: { data?: { message?: string } } };
|
||||||
toast.error(error.response?.data?.message || 'AI Extraction failed');
|
toast.error(error.response?.data?.message || 'AI Extraction failed');
|
||||||
@@ -628,6 +612,26 @@ export default function OcrSandboxPromptManager() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{sandboxState.result && sandboxState.result.llmPrompt && (
|
||||||
|
<Card className="border border-purple-500/20 bg-purple-500/5">
|
||||||
|
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base text-purple-600 dark:text-purple-400 flex items-center gap-2">
|
||||||
|
<StickyNote className="h-4 w-4" />
|
||||||
|
LLM Prompt (Step 2 Input)
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{sandboxState.result.llmPrompt.length} chars
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[300px] border border-border/10">
|
||||||
|
<pre className="text-purple-600 dark:text-purple-400 select-text leading-relaxed whitespace-pre-wrap">
|
||||||
|
{sandboxState.result.llmPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{sandboxState.isRunning && (
|
{sandboxState.isRunning && (
|
||||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||||
<CardContent className="pt-6 space-y-4">
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
export function RagFallbackBadge() {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
ใช้ local model คุณภาพอาจลดลง
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { FileText } from 'lucide-react';
|
|
||||||
import type { RagQueryResponse, RagCitation } from '../../hooks/use-rag';
|
|
||||||
import { RagFallbackBadge } from './rag-fallback-badge';
|
|
||||||
|
|
||||||
interface RagResultCardProps {
|
|
||||||
result: RagQueryResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfidenceBar({ score }: { score: number }) {
|
|
||||||
const pct = Math.round(score * 100);
|
|
||||||
const color =
|
|
||||||
pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500';
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-24 rounded-full bg-muted overflow-hidden">
|
|
||||||
<div className={`h-full ${color} transition-all`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">{pct}%</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CitationItem({ citation }: { citation: RagCitation }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded border p-3 text-sm space-y-1">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-1.5 font-medium text-foreground">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span>{citation.docType}</span>
|
|
||||||
{citation.docNumber && (
|
|
||||||
<span className="text-muted-foreground">— {citation.docNumber}</span>
|
|
||||||
)}
|
|
||||||
{citation.revision && (
|
|
||||||
<span className="rounded bg-muted px-1 text-xs">Rev. {citation.revision}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ConfidenceBar score={citation.score} />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground line-clamp-3">{citation.snippet}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RagResultCard({ result }: RagResultCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold text-base mb-1">คำตอบ</h3>
|
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{result.answer}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
|
||||||
<ConfidenceBar score={result.confidence} />
|
|
||||||
{result.usedFallbackModel && <RagFallbackBadge />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result.citations.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
|
||||||
อ้างอิง ({result.citations.length} เอกสาร)
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{result.citations.map((c) => (
|
|
||||||
<CitationItem key={c.chunkId} citation={c} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Loader2, Search } from 'lucide-react';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
question: z.string().min(1, 'กรุณาระบุคำถาม').max(500, 'คำถามต้องไม่เกิน 500 ตัวอักษร'),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface RagSearchBarProps {
|
|
||||||
onSearch: (question: string) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RagSearchBar({ onSearch, isLoading }: RagSearchBarProps) {
|
|
||||||
const [question, setQuestion] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const result = schema.safeParse({ question });
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error.issues[0]?.message ?? 'ข้อมูลไม่ถูกต้อง');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
onSearch(question);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="w-full">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={question}
|
|
||||||
onChange={(e) => setQuestion(e.target.value)}
|
|
||||||
placeholder="ถามคำถามเกี่ยวกับเอกสารโครงการ..."
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
disabled={isLoading}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground text-right">
|
|
||||||
{question.length}/500
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || question.trim().length === 0}
|
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
ค้นหา
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -155,11 +155,24 @@ export function useSandboxRun(onCompleted?: () => void) {
|
|||||||
setJobId(response.requestPublicId);
|
setJobId(response.requestPublicId);
|
||||||
return response.requestPublicId;
|
return response.requestPublicId;
|
||||||
}, []);
|
}, []);
|
||||||
|
/**
|
||||||
|
* เริ่ม polling สำหรับ jobId ที่มีอยู่แล้ว (สำหรับ 2-step flow)
|
||||||
|
* @param jobId - requestPublicId ของ job ที่ submit ไปแล้ว
|
||||||
|
*/
|
||||||
|
const startPolling = useCallback((jobIdParam: string) => {
|
||||||
|
setState({
|
||||||
|
isRunning: true,
|
||||||
|
progress: 30,
|
||||||
|
statusText: 'ai.prompt.statusPending',
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
setJobId(jobIdParam);
|
||||||
|
}, []);
|
||||||
/** รีเซ็ตสถานะทั้งหมด (ใช้ก่อนรันใหม่) */
|
/** รีเซ็ตสถานะทั้งหมด (ใช้ก่อนรันใหม่) */
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
setJobId(null);
|
setJobId(null);
|
||||||
setState({ isRunning: false, progress: 0, statusText: '', result: null });
|
setState({ isRunning: false, progress: 0, statusText: '', result: null });
|
||||||
}, []);
|
}, []);
|
||||||
return { state, jobId, submit, reset };
|
return { state, jobId, submit, reset, startPolling };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
|
||||||
import apiClient from '../lib/api/client';
|
|
||||||
|
|
||||||
export interface RagCitation {
|
|
||||||
chunkId: string;
|
|
||||||
docNumber: string | null;
|
|
||||||
docType: string;
|
|
||||||
revision: string | null;
|
|
||||||
snippet: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RagQueryRequest {
|
|
||||||
question: string;
|
|
||||||
projectPublicId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RagQueryResponse {
|
|
||||||
answer: string;
|
|
||||||
citations: RagCitation[];
|
|
||||||
confidence: number;
|
|
||||||
usedFallbackModel: boolean;
|
|
||||||
cachedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRagQuery() {
|
|
||||||
return useMutation<RagQueryResponse, Error, RagQueryRequest>({
|
|
||||||
mutationFn: async (payload) => {
|
|
||||||
const idempotencyKey = `rag-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
const res = await apiClient.post<{ data: RagQueryResponse }>('/rag/query', payload, {
|
|
||||||
headers: { 'Idempotency-Key': idempotencyKey },
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
// - 2026-05-25: เพิ่ม methods สำหรับจัดการโมเดล AI แบบไดนามิก (ADR-027).
|
// - 2026-05-25: เพิ่ม methods สำหรับจัดการโมเดล AI แบบไดนามิก (ADR-027).
|
||||||
// - 2026-05-29: เพิ่ม ocr field ใน AiSystemHealth interface ตาม OcrService.checkHealth()
|
// - 2026-05-29: เพิ่ม ocr field ใน AiSystemHealth interface ตาม OcrService.checkHealth()
|
||||||
// - 2026-05-29: เพิ่ม ocrText, ocrUsed, promptVersionUsed ใน AiSandboxJobResult
|
// - 2026-05-29: เพิ่ม ocrText, ocrUsed, promptVersionUsed ใน AiSandboxJobResult
|
||||||
|
// - 2026-06-06: เพิ่ม llmPrompt ใน AiSandboxJobResult เพื่อแสดง prompt ที่ส่งไป LLM
|
||||||
// - 2026-05-30: เพิ่มเมธอด getOcrEngines และ selectOcrEngine สำหรับจัดการ OCR engines (T017, T018, US1)
|
// - 2026-05-30: เพิ่มเมธอด getOcrEngines และ selectOcrEngine สำหรับจัดการ OCR engines (T017, T018, US1)
|
||||||
// - 2026-05-30: เพิ่ม getVramStatus และปรับปรุง getAvailableModels/setActiveModel/addModel ให้เรียกใช้ endpoints ใหม่ที่มี VRAM capacity check (T031-T034, US2)
|
// - 2026-05-30: เพิ่ม getVramStatus และปรับปรุง getAvailableModels/setActiveModel/addModel ให้เรียกใช้ endpoints ใหม่ที่มี VRAM capacity check (T031-T034, US2)
|
||||||
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (หลัก+OCR) ใน AiSystemHealth interface
|
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (หลัก+OCR) ใน AiSystemHealth interface
|
||||||
@@ -75,6 +76,7 @@ export interface AiSandboxJobResult {
|
|||||||
engineUsed?: string;
|
engineUsed?: string;
|
||||||
fallbackUsed?: boolean;
|
fallbackUsed?: boolean;
|
||||||
promptVersionUsed?: number;
|
promptVersionUsed?: number;
|
||||||
|
llmPrompt?: string;
|
||||||
citations?: AiRagCitation[];
|
citations?: AiRagCitation[];
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
usedFallbackModel?: boolean;
|
usedFallbackModel?: boolean;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@types": ["types/*"],
|
"@types": ["types/*"],
|
||||||
"@api": ["app/api/*"],
|
"@api": ["app/api/*"],
|
||||||
// เพิ่มส่วนที่ขาดไปเพื่อให้ตรงกับ Workspace
|
// เพิ่มส่วนที่ขาดไปเพื่อให้ตรงกับ Workspace
|
||||||
"@hooks/*": ["app/hooks/*"],
|
"@hooks/*": ["hooks/*"],
|
||||||
"@utils/*": ["utils/*"]
|
"@utils/*": ["utils/*"]
|
||||||
},
|
},
|
||||||
"target": "ES2017"
|
"target": "ES2017"
|
||||||
|
|||||||
+81
-45
@@ -12,6 +12,10 @@
|
|||||||
"name": "🎨 Frontend",
|
"name": "🎨 Frontend",
|
||||||
"path": "frontend",
|
"path": "frontend",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "🗓️ docs",
|
||||||
|
"path": "docs",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "🔗 specs",
|
"name": "🔗 specs",
|
||||||
"path": "specs",
|
"path": "specs",
|
||||||
@@ -177,13 +181,13 @@
|
|||||||
"@workflow-engine": "${workspaceFolder:🔧 Backend}/src/modules/workflow-engine",
|
"@workflow-engine": "${workspaceFolder:🔧 Backend}/src/modules/workflow-engine",
|
||||||
|
|
||||||
// Frontend paths (ไม่มี src)
|
// Frontend paths (ไม่มี src)
|
||||||
"@": "${workspaceFolder:🎨 Frontend}/app",
|
"@": "${workspaceFolder:🎨 Frontend}",
|
||||||
"@/*": "${workspaceFolder:🎨 Frontend}/app/*",
|
"@/*": "${workspaceFolder:🎨 Frontend}/*",
|
||||||
"@app": "${workspaceFolder:🎨 Frontend}/app",
|
"@app": "${workspaceFolder:🎨 Frontend}/app",
|
||||||
"@components": "${workspaceFolder:🎨 Frontend}/components",
|
"@components": "${workspaceFolder:🎨 Frontend}/components",
|
||||||
"@fe-config": "${workspaceFolder:🎨 Frontend}/config",
|
"@fe-config": "${workspaceFolder:🎨 Frontend}/config",
|
||||||
"@lib": "${workspaceFolder:🎨 Frontend}/lib",
|
"@lib": "${workspaceFolder:🎨 Frontend}/lib",
|
||||||
"@hooks": "${workspaceFolder:🎨 Frontend}/app/hooks",
|
"@hooks": "${workspaceFolder:🎨 Frontend}/hooks",
|
||||||
"@utils": "${workspaceFolder:🎨 Frontend}/utils",
|
"@utils": "${workspaceFolder:🎨 Frontend}/utils",
|
||||||
"@providers": "${workspaceFolder:🎨 Frontend}/providers",
|
"@providers": "${workspaceFolder:🎨 Frontend}/providers",
|
||||||
"@public": "${workspaceFolder:🎨 Frontend}/public",
|
"@public": "${workspaceFolder:🎨 Frontend}/public",
|
||||||
@@ -355,24 +359,22 @@
|
|||||||
"importCost.smallPackageColor": "#98C379",
|
"importCost.smallPackageColor": "#98C379",
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// JAVASCRIPT/TYPESCRIPT
|
// JAVASCRIPT/TYPESCRIPT (Unified Settings)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
"javascript.suggest.autoImports": true,
|
// Unified JS/TS settings (replaces deprecated javascript.* and typescript.*)
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
"#js/ts.suggest.autoImports": true,
|
||||||
"javascript.inlayHints.parameterNames.enabled": "all",
|
"#js/ts.updateImportsOnFileMove.enabled": "always",
|
||||||
"javascript.inlayHints.functionLikeReturnTypes.enabled": true,
|
"#js/ts.preferences.importModuleSpecifier": "relative",
|
||||||
"javascript.inlayHints.variableTypes.enabled": false,
|
|
||||||
"javascript.preferences.importModuleSpecifier": "relative",
|
|
||||||
|
|
||||||
"typescript.suggest.autoImports": true,
|
// Unified inlay hints (replaces deprecated javascript.inlayHints.* and typescript.inlayHints.*)
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
"editor.inlayHints.parameterNames.enabled": "all",
|
||||||
"typescript.inlayHints.parameterNames.enabled": "all",
|
"editor.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||||
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
|
"editor.inlayHints.variableTypes.enabled": false,
|
||||||
"typescript.inlayHints.variableTypes.enabled": false,
|
"editor.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||||
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
|
|
||||||
"typescript.preferences.importModuleSpecifier": "relative",
|
// Unified TypeScript SDK path (replaces deprecated typescript.tsdk)
|
||||||
"typescript.tsdk": "node_modules/typescript/lib", // ✅ ใช้ relative path
|
"#js/ts.tsdk.path": "node_modules/typescript/lib", // ✅ ใช้ relative path
|
||||||
// ========================================
|
// ========================================
|
||||||
// EMMET
|
// EMMET
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -388,7 +390,6 @@
|
|||||||
// FILES
|
// FILES
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
//"files.autoSave": "onFocusChange",
|
|
||||||
"files.trimTrailingWhitespace": true,
|
"files.trimTrailingWhitespace": true,
|
||||||
"files.insertFinalNewline": true,
|
"files.insertFinalNewline": true,
|
||||||
"files.encoding": "utf8",
|
"files.encoding": "utf8",
|
||||||
@@ -448,7 +449,7 @@
|
|||||||
// TERMINAL
|
// TERMINAL
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
"terminal.integrated.fontSize": 15,
|
"terminal.integrated.fontSize": 18,
|
||||||
"terminal.integrated.lineHeight": 1.2,
|
"terminal.integrated.lineHeight": 1.2,
|
||||||
"terminal.integrated.smoothScrolling": true,
|
"terminal.integrated.smoothScrolling": true,
|
||||||
"terminal.integrated.cursorBlinking": true,
|
"terminal.integrated.cursorBlinking": true,
|
||||||
@@ -658,8 +659,8 @@
|
|||||||
"name": "lcbp3_dev",
|
"name": "lcbp3_dev",
|
||||||
"database": "lcbp3_dev",
|
"database": "lcbp3_dev",
|
||||||
"username": "root",
|
"username": "root",
|
||||||
"password": "",
|
"password": "${env:DB_PASSWORD}", // ✅ ใช้ environment variable แทน hardcoded
|
||||||
"askForPassword": true, // ✅ ปลอดภัยกว่า
|
"askForPassword": true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"database-client.variableIndicator": [":", "$"],
|
"database-client.variableIndicator": [":", "$"],
|
||||||
@@ -670,6 +671,7 @@
|
|||||||
"vitest.enable": true,
|
"vitest.enable": true,
|
||||||
"yaml.maxItemsComputed": 10000,
|
"yaml.maxItemsComputed": 10000,
|
||||||
"powershell.cwd": "🎯 Root",
|
"powershell.cwd": "🎯 Root",
|
||||||
|
// "terminal.integrated.persistentSessionReviveProcess": "never",
|
||||||
"files.autoSave": "onFocusChange",
|
"files.autoSave": "onFocusChange",
|
||||||
"diffEditor.codeLens": false,
|
"diffEditor.codeLens": false,
|
||||||
"workbench.colorTheme": "Default Dark Modern",
|
"workbench.colorTheme": "Default Dark Modern",
|
||||||
@@ -680,6 +682,7 @@
|
|||||||
"editor.mouseWheelZoom": true,
|
"editor.mouseWheelZoom": true,
|
||||||
"terminal.integrated.mouseWheelZoom": true,
|
"terminal.integrated.mouseWheelZoom": true,
|
||||||
"terminal.integrated.tabs.title": "${process}-${cwd}",
|
"terminal.integrated.tabs.title": "${process}-${cwd}",
|
||||||
|
"workbench.editor.sharedViewState": true,
|
||||||
},
|
},
|
||||||
// ========================================
|
// ========================================
|
||||||
// LAUNCH CONFIGURATIONS
|
// LAUNCH CONFIGURATIONS
|
||||||
@@ -886,7 +889,7 @@
|
|||||||
// 1. Task หลักที่จะรันอัตโนมัติเมื่อเปิดโปรแกรม
|
// 1. Task หลักที่จะรันอัตโนมัติเมื่อเปิดโปรแกรม
|
||||||
{
|
{
|
||||||
"label": "🚀 Setup Workspace",
|
"label": "🚀 Setup Workspace",
|
||||||
"dependsOn": ["🔧 PS: Backend", "🎨 PS: Frontend"], // สั่งให้รัน 2 task ย่อย
|
"dependsOn": ["🎯 PS: Root", "🔧 PS: Backend", "🎨 PS: Frontend"], // สั่งให้รัน 3 task ย่อย
|
||||||
"runOptions": {
|
"runOptions": {
|
||||||
"runOn": "folderOpen", // <--- คำสั่งศักดิ์สิทธิ์: รันทันทีที่เปิด VS Code
|
"runOn": "folderOpen", // <--- คำสั่งศักดิ์สิทธิ์: รันทันทีที่เปิด VS Code
|
||||||
},
|
},
|
||||||
@@ -895,30 +898,30 @@
|
|||||||
},
|
},
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
},
|
},
|
||||||
// 2. Task ย่อย: เปิด Terminal ที่ Backend
|
// 2. Task ย่อย: เปิด Terminal ที่ Root
|
||||||
{
|
{
|
||||||
"label": "🔧 PS: Backend",
|
"label": "🎯 PS: Root",
|
||||||
"type": "shell",
|
|
||||||
"command": "powershell", // สั่งเปิด PowerShell ค้างไว้
|
|
||||||
"options": {
|
|
||||||
"cwd": "${workspaceFolder:🔧 Backend}", // cd เข้า folder นี้
|
|
||||||
},
|
|
||||||
"isBackground": true, // บอก VS Code ว่าไม่ต้องรอให้จบ (รันค้างไว้เลย)
|
|
||||||
"problemMatcher": [],
|
|
||||||
"presentation": {
|
|
||||||
"group": "workspace-terminals", // จัดกลุ่มเดียวกัน
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "dedicated", // แยก Tab ให้ชัดเจน
|
|
||||||
"focus": false, // ไม่ต้องแย่ง Focus ทันที
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 3. Task ย่อย: เปิด Terminal ที่ Frontend
|
|
||||||
{
|
|
||||||
"label": "🎨 PS: Frontend",
|
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "powershell",
|
"command": "powershell",
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder:🎨 Frontend}", // cd เข้า folder นี้
|
"cwd": "${workspaceFolder:🎯 Root}",
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
// "group": "workspace-terminals",
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"focus": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 3. Task ย่อย: เปิด Terminal ที่ Backend
|
||||||
|
{
|
||||||
|
"label": "🔧 PS: Backend",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "powershell",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder:🔧 Backend}",
|
||||||
},
|
},
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
@@ -926,8 +929,41 @@
|
|||||||
"group": "workspace-terminals",
|
"group": "workspace-terminals",
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"panel": "dedicated",
|
"panel": "dedicated",
|
||||||
"focus": false, // ไม่ต้องแย่ง Focus ทันที
|
"focus": false,
|
||||||
// "focus": true // ให้ Focus ที่อันนี้เป็นอันสุดท้าย (พร้อมพิมพ์)
|
},
|
||||||
|
},
|
||||||
|
// 4. Task ย่อย: เปิด Terminal ที่ Frontend
|
||||||
|
{
|
||||||
|
"label": "🎨 PS: Frontend",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "powershell",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder:🎨 Frontend}",
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"group": "workspace-terminals",
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"focus": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 5. Task ย่อย: เปิด Vitest Watch Mode ที่ Frontend (Manual run only)
|
||||||
|
{
|
||||||
|
"label": "🧪 Vitest Watch Frontend",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run test:watch",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder:🎨 Frontend}",
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"group": "workspace-terminals",
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"focus": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Memory Directory
|
||||||
|
|
||||||
|
This directory contains project-specific memory and context that is NOT already covered in the specs/ directory.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The `memory/` directory is for:
|
||||||
|
- MCP Tools documentation (MariaDB + Memory tools)
|
||||||
|
- Project memory override rules (referencing AGENTS.md)
|
||||||
|
- Context that doesn't fit into the specs/ structure
|
||||||
|
|
||||||
|
## What's NOT Here
|
||||||
|
|
||||||
|
The following content has been moved to `specs/88-logs/`:
|
||||||
|
- Session history logs
|
||||||
|
- Recent rollouts
|
||||||
|
- Rules and decisions (now in specs/06-Decision-Records/ ADRs)
|
||||||
|
- Domain terminology (now in specs/00-overview/00-02-glossary.md)
|
||||||
|
- Known commands (now in specs/05-Engineering-Guidelines/)
|
||||||
|
- Environment & Services (now in specs/04-Infrastructure-OPS/)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `mcp-tools.md` — MCP MariaDB Tools and MCP Memory Tools documentation
|
||||||
|
- `project-memory-override.md` — Project memory override rule referencing AGENTS.md
|
||||||
|
|
||||||
|
## Single Source of Truth
|
||||||
|
|
||||||
|
For project rules, decisions, and specifications, always refer to:
|
||||||
|
- `AGENTS.md` — Project context and rules
|
||||||
|
- `specs/06-Decision-Records/` — Architecture Decision Records (ADRs)
|
||||||
|
- `specs/00-overview/00-02-glossary.md` — Domain terminology
|
||||||
|
- `specs/05-Engineering-Guidelines/` — Backend, frontend, and testing guidelines
|
||||||
@@ -1,842 +0,0 @@
|
|||||||
<!-- File: memory/agent-memory.md -->
|
|
||||||
<!-- Change Log
|
|
||||||
- 2026-05-23: Initialized long-term memory system with core project rules, Windows environment settings, and constraints.
|
|
||||||
- 2026-05-23 (Session 2): N8N Workflow Refactor — QuizMe session, decisions locked, สร้าง CONTEXT-N8N-Refactor.md, สร้าง n8n.workflow.v2.json (ADR-023A compliant), อัพเดต 03-05 และ 03-06.
|
|
||||||
- 2026-05-24: เพิ่ม sections: Known Commands, Current Decisions, Do/Don't Quick Reference, Environment & Services, Recent Rollouts.
|
|
||||||
- 2026-05-25 (Session 3): แก้ไขบัค `tempAttachmentId` ใน Migration Queue — เปลี่ยนจาก Integer ที่ไม่มีอยู่จริงเป็น UUID string (ADR-019), อัปเดต DTO, Service UUID-to-INT resolution, และ n8n.workflow.v2.json.
|
|
||||||
- 2026-05-25 (Session 4): Normalize migration error logging ตาม AGENTS.md — แก้ n8n `Log Error to CSV`/`Log Error to DB`, harden backend `logError()`, เพิ่ม `job_id` ใน migration_errors SQL/delta, และเพิ่ม regression test.
|
|
||||||
- 2026-05-25 (Session 5): N8N Workflow Debug — แก้ Submit AI Job (jsonBody serialization + RBAC permission gap) และเพิ่ม checksum-based dedup ใน FileStorageService.upload().
|
|
||||||
- 2026-05-25 (Session 6): AI Model Management (ADR-027) — เพิ่มระบบเลือกโมเดล AI แบบไดนามิกผ่าน AI Admin Console: สร้าง `ai_available_models` table + entity, extend `AiSettingsService` ด้วย methods CRUD โมเดล, add REST endpoints, update frontend UI ด้วย Select dropdown และ model list management, update `OllamaService` ใช้ DB-configured model แทน ENV เท่านั้น.
|
|
||||||
- 2026-05-25 (Session 7): PaddleOCR Sidecar setup บน Desk-5439 — สร้าง FastAPI sidecar (port 8765) รองรับ `/ocr` + `/normalize`, แก้ AggregateError ใน ocr.service.ts, เพิ่ม path remapping (`OCR_SIDECAR_UPLOAD_BASE`), CIFS volume mount จาก QNAP.
|
|
||||||
- 2026-05-30: เพิ่ม system memories ที่หายไป — QNAP SSH Key Authentication, TransformInterceptor double registration, ADR-021 Transmittals/Circulation integration, Correspondence detail fixes, Playwright E2E setup, Tag/Contract UUID fixes.
|
|
||||||
- 2026-05-27: Context-Aware Prompts & DB CC Typo Cleanup (ADR-030) — นำเสนอการผูก Master Data เข้ากับ Prompt Extraction, ออกแบบ JSON Context-Aware configuration, อัปเดต Entity/DTOs, ออกแบบ JSON format ผู้รับเป็น Object Array ป้องกันบัค และแก้ whitespace typo 'CC ' ในฐานข้อมูล
|
|
||||||
- 2026-05-30 (Session 8): OCR Engine Migration — เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อแก้ปัญหา SIGILL (Illegal Instruction) บน CPU เก่าที่ไม่รองรับ AVX: อัปเดต requirements.txt (ลบ paddlepaddle/paddleocr, เพิ่ม pytesseract), app.py (เปลี่ยนใช้ pytesseract, OCR_LANG=tha+eng), Dockerfile (ติดตั้ง tesseract-ocr + ภาษาไทย/อังกฤษ), docker-compose.yml (OCR_LANG=tha+eng, ลบ paddleocr_models volume), backend ocr.service.ts (เปลี่ยน comment/error message), frontend OcrSandboxPromptManager.tsx (เปลี่ยน Badge text)
|
|
||||||
- 2026-05-30 (Session 10): OCR Sandbox Two-Step Flow (ADR-030/231) — แยก OCR Sandbox เป็น 2 steps: Step 1 OCR-only → Step 2 AI Extraction. Backend: เพิ่ม job types sandbox-ocr-only และ sandbox-ai-extract, processors processSandboxOcrOnly/processSandboxAiExtract, endpoints POST /ai/admin/sandbox/ocr และ /ai/admin/sandbox/ai-extract, method findByVersion ใน AiPromptsService. Frontend: เพิ่ม methods submitSandboxOcr/submitSandboxAiExtract ใน adminAiService, refactor OcrSandboxPromptManager.tsx ให้มี 2-step UI พร้อม states sandboxStep/ocrResult/selectedPromptVersion, handlers handleStep1Ocr/handleStep2AiExtract/handleResetSandbox. Schema Fix: สร้าง delta SQL 2026-05-30-add-ai-prompts-publicId.sql เพื่อเพิ่ม publicId column ใน ai_prompts table (ADR-019 compliance).
|
|
||||||
- 2026-05-30 (Session 11): Typhoon OCR & LLM Integration (ADR-032) — พัฒนาการใช้งานโมเดลภาษาไทยผสมอังกฤษ Typhoon OCR-3B ร่วมกับ Tesseract OCR แบบ Dynamic พร้อมระบบ caching 24 ชม., VRAM Monitor ป้องกัน GPU OOM และระบบ fallback 5s เมื่อโมเดลมีปัญหา และการสลับและบริหารจัดการ LLM โมเดลหลักแบบ Dynamic ในระบบ AI Model Management ของ Next.js frontend
|
|
||||||
- 2026-06-03: Thai-Optimized AI Model Stack (ADR-034) — เปลี่ยนโมเดลหลักเป็น `typhoon2.5-np-dms:latest` + `typhoon-np-dms-ocr:latest` (สำหรับ OCR, keep_alive:0); เพิ่ม model switching logic ใน ai-batch processor; เพิ่ม static constants ใน AiSettingsService; สร้าง SQL delta สำหรับ ai_available_models
|
|
||||||
-->
|
|
||||||
|
|
||||||
# 🧠 Agent Long-term Project Memory
|
|
||||||
|
|
||||||
> **Project:** NAP-DMS (LCBP3) — Laem Chabang Port Phase 3 Document Management System
|
|
||||||
> **Version:** 1.9.9 (Last Synced: 2026-06-03)
|
|
||||||
> **Stack:** NestJS 11 + Next.js 16 + TypeScript + MariaDB 11.8 + Redis + BullMQ + Elasticsearch + Ollama (on-prem AI)
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> **Project memory นี้ต้องใช้งานภายใต้ `AGENTS.md` เสมอ**
|
|
||||||
>
|
|
||||||
> - ให้ใช้ `AGENTS.md` เป็นกฎหลักก่อน memory ทุกครั้ง
|
|
||||||
> - ถ้า memory เก่าหรือ session note ขัดกับ `AGENTS.md` ให้ยึด `AGENTS.md`
|
|
||||||
> - งาน schema ต้องทำตาม ADR-009 ผ่าน SQL/delta เท่านั้น
|
|
||||||
> - งาน UUID/Public API ต้องทำตาม ADR-019 โดยใช้ `publicId` และห้าม `parseInt()` บน UUID
|
|
||||||
> - งาน n8n / AI migration ต้องอยู่ในขอบเขต ADR-023A และ mutation ต้องมี `Idempotency-Key`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧭 1. กฎการรันคำสั่งและการทำงานบนระบบ (OS Rules & Sandbox Constraints)
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> **ระบบรันอยู่บน Windows OS**
|
|
||||||
>
|
|
||||||
> - ห้ามใช้คำสั่ง `bash` หรือคำสั่งของ Linux โดยเด็ดขาด
|
|
||||||
> - คำสั่งทุกประเภทที่จะส่งให้ผู้ใช้รันหรือรันผ่าน Terminal ต้องเป็น **PowerShell** หรือ **CMD** เท่านั้น
|
|
||||||
> - ห้ามใช้คำสั่ง `cd` ในการสลับ Directory ให้ระบุพารามิเตอร์ `Cwd` ใน Tool ตรง ๆ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 2. กฎเหล็กระดับ Tier 1 (CI Blocker - ห้ามละเมิดเด็ดขาด)
|
|
||||||
|
|
||||||
### 🆔 2.1 กลยุทธ์ UUID (ADR-019)
|
|
||||||
|
|
||||||
- คีย์หลักในฐานข้อมูล (Internal PK) เป็น `INT AUTO_INCREMENT` ส่วนคีย์สาธารณะใน API/URL (Public API) จะใช้ **UUIDv7** เท่านั้น
|
|
||||||
- ฟิลด์คีย์สาธารณะชื่อ `publicId: string` จะถูกส่งออกไปใน API โดยตรง
|
|
||||||
- ❌ **ห้ามใช้** `parseInt()`, `Number()`, หรือเครื่องหมายบวก `+` บน UUID (เช่น `parseInt(projectId)` จะคำนวณผิดพลาดทันที)
|
|
||||||
- ❌ **ห้ามใช้** fallback ในลักษณะ `id ?? ''` ในฝั่ง Frontend ให้ใช้ `publicId` เท่านั้น
|
|
||||||
|
|
||||||
### 🛡️ 2.2 สิทธิ์การใช้งานและความปลอดภัย (RBAC & Auth)
|
|
||||||
|
|
||||||
- API ทุกตัวที่เป็นการปรับปรุงข้อมูล (Mutation: POST/PUT/PATCH) ต้องใช้ **CASL Guard** ตรวจสอบสิทธิ์ 4 ระดับ (RBAC Matrix) เสมอ
|
|
||||||
- API สำหรับการแก้ไขข้อมูล (POST/PUT/PATCH) ต้องตรวจสอบและยืนยัน **`Idempotency-Key`** ใน HTTP Header ทุกครั้ง
|
|
||||||
- การเข้ารหัสรหัสผ่านใช้ `bcrypt` ที่ระดับ 12 salt rounds เสมอ
|
|
||||||
|
|
||||||
### 💾 2.3 การปรับแต่งและเปลี่ยนแปลง Schema (ADR-009)
|
|
||||||
|
|
||||||
- ❌ **ห้ามใช้ TypeORM migrations ในการอัปเดต Schema** บนสภาพแวดล้อม Production
|
|
||||||
- ให้ใช้วิธี **แก้ไขไฟล์ SQL โครงสร้างหลักตรง ๆ** ใน `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql` หรือเพิ่มการทำงานในโฟลเดอร์ `deltas/*.sql` เท่านั้น
|
|
||||||
|
|
||||||
### 🏎️ 2.4 ป้องกัน Race Conditions ในเลขเอกสาร (ADR-002)
|
|
||||||
|
|
||||||
- การจัดสรรและจองเลขที่เอกสารใหม่ (Document Numbering) ต้องใช้ **Redis Redlock** ควบคู่กับ TypeORM `@VersionColumn` เสมอ ห้ามเขียนตัวนับ (Counter) ในฝั่ง Application เดี่ยว ๆ
|
|
||||||
|
|
||||||
### 🤖 2.5 ขอบเขตและการแยกส่วน AI (ADR-023/023A)
|
|
||||||
|
|
||||||
- **Ollama (AI Inference) ต้องทำงานบน Admin Desktop เท่านั้น** ห้ามรันบน Server หรือ Docker ใน Production
|
|
||||||
- AI ห้ามเชื่อมต่อและเข้าถึง Database หรือ Storage โดยตรง (ต้องผ่าน DMS API เท่านั้น)
|
|
||||||
- โมเดลที่ใช้: `typhoon2.5-np-dms:latest` (Main LLM, ADR-034) + `typhoon-np-dms-ocr:latest` (OCR, keep_alive:0) + `nomic-embed-text` (Embeddings)
|
|
||||||
- การทำงานแบบ Background Job หรือ Inference ที่ใช้เวลานานต้องสั่งงานผ่าน **BullMQ** (คิว `ai-realtime` และ `ai-batch`)
|
|
||||||
- ข้อมูลผลลัพธ์จาก AI ทั้งหมดต้องผ่านการตรวจสอบความถูกต้องโดยมนุษย์ (Human-in-the-loop) เสมอ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏷️ 3. Domain Terminology (คำศัพท์เฉพาะของระบบ DMS)
|
|
||||||
|
|
||||||
ห้ามใช้คำศัพท์ทั่วไป ให้ใช้คำศัพท์ตามสารบัญหลักของโปรเจกต์เสมอ:
|
|
||||||
|
|
||||||
| คำศัพท์ที่ถูกต้อง (✅ Use) | คำศัพท์ที่ห้ามใช้ (❌ Don't Use) | คำอธิบายภาษาไทย |
|
|
||||||
| :------------------------- | :------------------------------- | :--------------------------------------------- |
|
|
||||||
| **Correspondence** | Letter, Communication | จดหมาย/เอกสารติดต่อสื่อสาร (ครอบคลุมทุกประเภท) |
|
|
||||||
| **RFA** | Approval Request | เอกสารขออนุมัติ (Request for Approval) |
|
|
||||||
| **Transmittal** | Delivery Note, Cover Letter | เอกสารนำส่งแบบและเอกสาร |
|
|
||||||
| **Circulation** | Distribution, Routing | ใบเวียนเอกสารภายในหน่วยงาน |
|
|
||||||
| **Shop Drawing** | Construction Drawing | แบบก่อสร้างจริง |
|
|
||||||
| **Contract Drawing** | Design Drawing, Blueprint | แบบคู่สัญญา |
|
|
||||||
| **Workflow Engine** | Approval Flow, Process Engine | ระบบควบคุมและเปลี่ยนสถานะเอกสาร |
|
|
||||||
| **Document Numbering** | Document ID, Auto Number | ระบบออกเลขที่เอกสารอัตโนมัติ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⌨️ 4. Known Commands (PowerShell — Windows Only)
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> ห้ามใช้ bash/Linux commands ทุกอย่างต้องเป็น **PowerShell** หรือ **CMD** เท่านั้น
|
|
||||||
|
|
||||||
### Dev Servers
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Backend (NestJS) — port 3001
|
|
||||||
npm run start:dev # รันจาก e:\np-dms\lcbp3\backend
|
|
||||||
|
|
||||||
# Frontend (Next.js) — port 3000
|
|
||||||
npm run dev # รันจาก e:\np-dms\lcbp3\frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build & Type Check
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Backend
|
|
||||||
npm run build # tsc compile
|
|
||||||
npm run lint # ESLint
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
npm run build # Next.js build
|
|
||||||
npx tsc --noEmit # Type check only
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Backend Unit Tests
|
|
||||||
npm run test # Jest all
|
|
||||||
npm run test -- --testPathPattern=<name> # เฉพาะ file
|
|
||||||
npm run test:cov # Coverage report
|
|
||||||
|
|
||||||
# Frontend Unit Tests
|
|
||||||
npm run test # Vitest
|
|
||||||
|
|
||||||
# E2E
|
|
||||||
npx playwright test # รันจาก e:\np-dms\lcbp3\frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database & Services
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Docker (รันจาก root หรือ backend)
|
|
||||||
docker compose up -d # Start all services
|
|
||||||
docker compose logs -f backend # Tail backend logs
|
|
||||||
docker compose ps # Check status
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏛️ 5. Current Decisions (Locked)
|
|
||||||
|
|
||||||
> การตัดสินใจเหล่านี้ **ไม่สามารถเปลี่ยนแปลงได้** โดยไม่ได้รับ Explicit Approval
|
|
||||||
|
|
||||||
| ID | Decision | ADR |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------- | --------- |
|
|
||||||
| D1 | n8n = Migration Phase orchestrator เท่านั้น — ห้ามทำ New Correspondence pipeline ผ่าน n8n | ADR-023A |
|
|
||||||
| D2 | New Correspondence → BullMQ `ai-realtime` queue โดยตรง (ไม่ผ่าน n8n) | ADR-023A |
|
|
||||||
| D3 | n8n ต้อง call `POST /api/ai/jobs` (DMS Backend) เท่านั้น — ห้าม call Ollama/Qdrant โดยตรง | ADR-023A |
|
|
||||||
| D4 | Excel metadata ส่งไปพร้อม AI job เป็น context (docNumber, title, sender ฯลฯ) | Session 2 |
|
|
||||||
| D5 | Tag suggestion ใช้ทาง C: แนะนำ existing tags + สร้างใหม่ได้ถ้าไม่มี (`isNew: true` flag) | Session 2 |
|
|
||||||
| D6 | Editable Review Form: AI pre-fill → user approve/edit → submit (human-in-the-loop ทุกครั้ง) | ADR-023 |
|
|
||||||
| D7 | UUID Strategy: `publicId` (UUIDv7) เท่านั้นสำหรับ Public API — INT PK ต้อง `@Exclude()` | ADR-019 |
|
|
||||||
| D8 | Schema changes: แก้ SQL โดยตรง + เพิ่ม `deltas/*.sql` — ห้ามใช้ TypeORM migration files | ADR-009 |
|
|
||||||
| D9 | Qdrant search ต้องส่ง `projectPublicId` เป็น mandatory parameter ทุกครั้ง (compile-time) | ADR-023A |
|
|
||||||
| D10 | AI model stack: `typhoon2.5-np-dms:latest` (Main LLM) + `typhoon-np-dms-ocr:latest` (OCR, keep_alive:0) + `nomic-embed-text` (Embeddings) on Admin Desktop (ADR-034, supersedes ADR-023A §2.1) | ADR-034 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅❌ 6. Do / Don't Quick Reference
|
|
||||||
|
|
||||||
| ✅ Do | ❌ Don't |
|
|
||||||
| -------------------------------------------------------------- | --------------------------------------------------- |
|
|
||||||
| ใช้ `publicId` (UUID string) ใน API/URL | `parseInt()` / `Number()` บน UUID |
|
|
||||||
| ใช้ `RequestWithUser` ใน NestJS controller | `req: any` ใน controller |
|
|
||||||
| ส่ง notification/email ผ่าน BullMQ | ส่ง email แบบ inline ใน service |
|
|
||||||
| เขียน schema changes ใน `deltas/*.sql` | สร้าง TypeORM migration files |
|
|
||||||
| ใช้ NestJS `Logger` แทน `console.log` | `console.log` ใน committed code |
|
|
||||||
| ตรวจสอบ table/column ใน `schema-02-tables.sql` ก่อนเขียน query | คาดเดาชื่อ column โดยไม่ตรวจสอบ schema |
|
|
||||||
| ใช้ CASL Guard กับทุก mutation endpoint | สร้าง API ที่ไม่มี auth guard |
|
|
||||||
| ผ่าน `StorageService` ทุกครั้งที่จัดการไฟล์ | ทำ file operation โดยตรงโดยไม่ผ่าน `StorageService` |
|
|
||||||
| ใช้คำสั่ง PowerShell/CMD บน Windows | ใช้ bash/Linux commands บน Windows |
|
|
||||||
| Human-in-the-loop validate ก่อน apply AI output | ใช้ AI output โดยตรงโดยไม่ผ่าน human review |
|
|
||||||
| เขียน comment ภาษาไทย, code identifier ภาษาอังกฤษ | คำ comment ภาษาอังกฤษ หรือ identifier ภาษาไทย |
|
|
||||||
| ใส่ file header `// File: path/filename` ทุกไฟล์ TypeScript | ไฟล์ที่ไม่มี file header |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 7. Environment & Services
|
|
||||||
|
|
||||||
| Service | Local URL / Port | Production | Notes |
|
|
||||||
| ----------------- | ----------------------------- | ------------------------- | ------------------------------------ |
|
|
||||||
| **Backend API** | `http://localhost:3001` | QNAP `192.168.10.8` | NestJS — `/api` prefix |
|
|
||||||
| **Frontend** | `http://localhost:3000` | QNAP `192.168.10.8` | Next.js |
|
|
||||||
| **MariaDB** | `localhost:3307` | QNAP internal | DB: `lcbp3`, root via docker |
|
|
||||||
| **Redis** | `localhost:6379` | QNAP internal | BullMQ + session store |
|
|
||||||
| **Ollama** | `http://192.168.10.100:11434` | Admin Desktop (Desk-5439) | typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR) + nomic-embed-text |
|
|
||||||
| **Qdrant** | `http://localhost:6333` | Admin Desktop (Desk-5439) | Vector DB — requires projectPublicId |
|
|
||||||
| **OCR Sidecar** | `http://192.168.10.100:8765` | Admin Desktop (Desk-5439) | Dynamic (Tesseract tha+eng / Typhoon OCR-3B) |
|
|
||||||
| **Gitea** | `https://git.np-dms.work` | QNAP `192.168.10.8` | Source + CI/CD |
|
|
||||||
| **Gitea Runner** | ASUSTOR `192.168.10.9` | — | CI runner |
|
|
||||||
|
|
||||||
### Key Environment Variables (ตรวจสอบใน `docker-compose.yml`)
|
|
||||||
|
|
||||||
```
|
|
||||||
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
|
|
||||||
REDIS_HOST, REDIS_PORT
|
|
||||||
JWT_SECRET, JWT_EXPIRES_IN
|
|
||||||
OLLAMA_BASE_URL (ชี้ไป Admin Desktop)
|
|
||||||
QDRANT_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 8. Recent Rollouts
|
|
||||||
|
|
||||||
| วันที่ | Version | รายการ | สถานะ |
|
|
||||||
| ---------- | ------- | ---------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
||||||
| 2026-05-23 | v1.9.6 | Specs reorganization (`100/200/300-*` folders), AGENTS.md v1.9.6 update | ✅ Complete |
|
|
||||||
| 2026-05-23 | v1.9.6 | N8N Workflow v2 (`n8n.workflow.v2.json`) — ADR-023A compliant, ลบ Ollama direct | ⏳ Pending import to n8n UI |
|
|
||||||
| 2026-05-24 | v1.9.6 | AGENTS.md Project Memory Override rule (Windsurf / Antigravity / Codex) | ✅ Complete |
|
|
||||||
| 2026-05-25 | v1.9.6 | Migration Queue attachment UUID fix — DTO + Service + n8n.workflow.v2.json (Session 3) | ✅ Complete (tsc verified) |
|
|
||||||
| 2026-05-25 | v1.9.6 | Migration error normalization + `job_id` logging — workflow + backend + SQL/delta (Session 4) | ✅ Complete |
|
|
||||||
| 2026-05-25 | v1.9.6 | PaddleOCR Sidecar บน Desk-5439 — FastAPI `/ocr`+`/normalize`, CIFS mount, path remapping (Session 7) | ✅ Running |
|
|
||||||
| 2026-05-27 | v1.9.7 | Context-Aware Prompt Templates & DB CC Whitespace Cleanup (ADR-030) (Session 9) | ✅ Complete (v1.9.7 main) |
|
|
||||||
| 2026-05-30 | v1.9.7 | OCR Engine Migration — PaddleOCR → Tesseract (Session 8) | ✅ Complete (pending rebuild) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 9. สถานะและประวัติการทำงาน (Latest Session Progress)
|
|
||||||
|
|
||||||
### Session 1 — 2026-05-23 (Specs Reorganization)
|
|
||||||
|
|
||||||
- Reorganize โครงสร้างโฟลเดอร์ `specs/` สำเร็จ (`100-Infrastructures`, `200-fullstacks`, `300-others`)
|
|
||||||
- อัปเดตกฎ `AGENTS.md` และ `GEMINI.md` ให้ตรงกับมาตรฐานใหม่
|
|
||||||
- ริเริ่มระบบ `memory/agent-memory.md`
|
|
||||||
|
|
||||||
### Session 2 — 2026-05-23 (N8N Workflow Refactor)
|
|
||||||
|
|
||||||
#### Decisions ที่ Lock แล้ว (จาก QuizMe Session)
|
|
||||||
|
|
||||||
| # | Decision |
|
|
||||||
| ------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| S1 | **n8n = Migration only** — New Correspondence ใช้ Backend pipeline (BullMQ) |
|
|
||||||
| S2 | New Correspondence → BullMQ `ai-realtime` (ไม่ผ่าน n8n) |
|
|
||||||
| S3 | n8n call `POST /api/ai/jobs` (Backend) แทน Ollama direct (ADR-023A) |
|
|
||||||
| PA | Excel metadata (`docNumber`, `title`, `sender`, etc.) ส่งไปพร้อม AI job เป็น context |
|
|
||||||
| PB-Tags | Tag suggestion ทาง C — แนะนำ existing + สร้าง tag ใหม่ได้ถ้าไม่มี (`isNew` flag) |
|
|
||||||
| PB-UX | Editable form pre-filled ด้วย AI suggestions — user approve/edit ก่อน submit |
|
|
||||||
|
|
||||||
#### Endpoint ที่ Verified แล้ว
|
|
||||||
|
|
||||||
- `POST /api/ai/jobs` (`type: migrate-document`) — **พร้อมใช้งานแล้ว** (verified 2026-05-23)
|
|
||||||
- `GET /api/ai/jobs/:jobId` — polling endpoint พร้อม
|
|
||||||
- `POST /api/storage/upload` — two-phase upload พร้อม
|
|
||||||
|
|
||||||
#### ไฟล์ที่สร้าง/แก้ไข
|
|
||||||
|
|
||||||
| ไฟล์ | การเปลี่ยนแปลง |
|
|
||||||
| -------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
||||||
| `specs/03-Data-and-Storage/CONTEXT-N8N-Refactor.md` | ✅ สร้างใหม่ — context doc สำหรับ implement |
|
|
||||||
| `specs/03-Data-and-Storage/n8n.workflow.v2.json` | ✅ สร้างใหม่ — ADR-023A compliant workflow |
|
|
||||||
| `specs/03-Data-and-Storage/03-05-n8n-migration-setup-guide.md` | ✅ อัพเดต v1.9.0 — ลบ Ollama direct, เพิ่มขั้นตอน update token |
|
|
||||||
| `specs/03-Data-and-Storage/03-06-migration-business-scope.md` | ✅ อัพเดต — Gate #1 blocker → Verified 2026-05-23 |
|
|
||||||
|
|
||||||
#### สาระสำคัญของ n8n.workflow.v2.json
|
|
||||||
|
|
||||||
**Nodes ที่ลบ (ADR-023A violations):** `Ollama AI Analysis`, `Build AI Prompt`, `Extract PDF Text` (Tika), `Check/Update Fallback State`, `Import to Backend`, `Upsert Tags`, `Link Tags`
|
|
||||||
|
|
||||||
**Nodes ใหม่:** `Validate Token` → `Upload PDF to Backend` → `Build AI Job Payload` → `Submit AI Job` → `Poll AI Job Status`
|
|
||||||
|
|
||||||
**Flow สรุป:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Form Trigger → Set Config → Health/Token Check → Fetch Master Data
|
|
||||||
→ File Mount Check → Read Excel → Read Checkpoint
|
|
||||||
→ [Per Record: File Validate → Upload PDF → Submit AI Job → Poll → Parse/Route]
|
|
||||||
→ Auto/Flagged → migration_review_queue
|
|
||||||
→ Rejected → CSV Log
|
|
||||||
→ Error → CSV + DB Log
|
|
||||||
→ Save Checkpoint → Delay → Loop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session 3 — 2026-05-24 (Migration Queue Attachment UUID Bug Fix)
|
|
||||||
|
|
||||||
#### ปัญหาที่พบ (Root Cause)
|
|
||||||
|
|
||||||
ไฟล์ `n8n.workflow.v2.json` (โหนด `Insert Review Queue`) ส่งค่า `tempAttachmentId` โดยใช้ `{{parseInt($json.attachmentId)}}` ซึ่งพยายามแปลง UUID string เป็นตัวเลข ผลลัพธ์คือค่า `NaN` หรือตัวเลขที่ผิดพลาด (เช่น `"0195..."` → `19`) ทำให้คอลัมน์ `temp_attachment_id` ใน `migration_review_queue` เป็น `NULL` เสมอ — ละเมิด ADR-019 Tier 1 Blocker
|
|
||||||
|
|
||||||
#### การแก้ไข (Fix)
|
|
||||||
|
|
||||||
| ไฟล์ | การเปลี่ยนแปลง |
|
|
||||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
||||||
| `backend/src/modules/ai/dto/migration-checkpoint.dto.ts` | ปรับ `tempAttachmentId` เป็น `@IsOptional()` รองรับทั้ง UUID string และ Integer PK |
|
|
||||||
| `backend/src/modules/ai/ai-migration-checkpoint.service.ts` | เพิ่ม UUID→INT resolution: `SELECT id FROM attachments WHERE uuid = ? LIMIT 1` |
|
|
||||||
| `specs/03-Data-and-Storage/n8n.workflow.v2.json` | เปลี่ยนส่ง `temp_attachment_public_id` (UUID string) แทน `parseInt(...)` ที่ผิด |
|
|
||||||
|
|
||||||
#### Pattern ที่ตกลง (Locked)
|
|
||||||
|
|
||||||
```
|
|
||||||
n8n ส่ง: { tempAttachmentId: "019505a1-7c3e-7000-..." } ← UUID string
|
|
||||||
Backend รับ: ตรวจสอบประเภท → ถ้าเป็น string → query DB → ได้ INT id จริง
|
|
||||||
DB บันทึก: migration_review_queue.temp_attachment_id = <INT> ← ถูกต้อง
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Verification
|
|
||||||
|
|
||||||
- `npx tsc --noEmit` — ✅ ผ่าน ไม่มี type error
|
|
||||||
- ตรวจสอบ logic ใน Service แล้ว ไม่มีการเขียนทับ `tempAttachmentId` ด้วย `undefined` (guard check แล้ว)
|
|
||||||
|
|
||||||
### Session 4 — 2026-05-25 (Migration Error Normalization ตาม AGENTS.md) ← **ล่าสุด**
|
|
||||||
|
|
||||||
#### ปัญหาที่พบ (Root Cause)
|
|
||||||
|
|
||||||
- `Log Error to CSV` และ `Log Error to DB` ใน `n8n.workflow.v2.json` ส่ง `error_type` บางค่าไม่ตรง enum ของ `migration_errors`
|
|
||||||
- ค่าที่พบจริงและต้อง normalize: `AI_JOB_FAILED`, `PARSE_ERROR`, `TOKEN_EXPIRED`
|
|
||||||
- backend `AiMigrationCheckpointService.logError()` เดิม insert ค่า `dto.errorType` ตรง ๆ ทำให้เสี่ยง DB enum reject
|
|
||||||
- ตาราง `migration_errors` เดิมไม่มี `job_id` แม้ workflow/DTO จะมี `jobId` อยู่แล้ว ทำให้ trace กลับไป BullMQ job ไม่ครบ
|
|
||||||
|
|
||||||
#### การแก้ไข (Fix)
|
|
||||||
|
|
||||||
| ไฟล์ | การเปลี่ยนแปลง |
|
|
||||||
| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
||||||
| `specs/03-Data-and-Storage/n8n.workflow.v2.json` | normalize `error_type`, `document_number`, `error`, `job_id` ก่อนเขียน CSV/DB |
|
|
||||||
| `backend/src/modules/ai/ai-migration-checkpoint.service.ts` | map/validate `errorType` ซ้ำก่อน insert และเพิ่ม `job_id` ใน SQL insert |
|
|
||||||
| `backend/src/modules/migration/entities/migration-error.entity.ts` | เพิ่ม field `jobId?: string` |
|
|
||||||
| `specs/03-Data-and-Storage/lcbp3-v1.9.0-migration.sql` | เพิ่มคอลัมน์ `job_id VARCHAR(100) NULL` และ index |
|
|
||||||
| `specs/03-Data-and-Storage/deltas/2026-05-22-drop-migration-tables.rollback.sql` | อัปเดต table definition ของ `migration_errors` ให้มี `job_id` |
|
|
||||||
| `specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.sql` | เพิ่ม delta สำหรับ add `job_id` |
|
|
||||||
| `specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.rollback.sql` | เพิ่ม rollback สำหรับ drop `job_id` |
|
|
||||||
| `backend/src/modules/ai/ai-migration-checkpoint.service.spec.ts` | เพิ่ม regression tests สำหรับ error normalization + `job_id` |
|
|
||||||
|
|
||||||
#### Mapping ที่ Lock แล้ว
|
|
||||||
|
|
||||||
```
|
|
||||||
AI_JOB_FAILED -> API_ERROR
|
|
||||||
PARSE_ERROR -> AI_PARSE_ERROR
|
|
||||||
TOKEN_EXPIRED -> API_ERROR
|
|
||||||
unsupported value -> UNKNOWN
|
|
||||||
```
|
|
||||||
|
|
||||||
#### กฎใช้งานต่อไป
|
|
||||||
|
|
||||||
- ให้ถือ enum ของ `migration_errors.error_type` เป็น source of truth เสมอ
|
|
||||||
- workflow ต้อง normalize ก่อนส่งเข้า backend และ backend ต้อง normalize ซ้ำอีกชั้น
|
|
||||||
- ห้ามพึ่ง DB enum reject เป็น validation mechanism
|
|
||||||
- การเพิ่มคอลัมน์ `job_id` ต้องทำผ่าน SQL/delta ตาม ADR-009 เท่านั้น
|
|
||||||
|
|
||||||
#### Verification
|
|
||||||
|
|
||||||
- workflow normalization assertion — ✅ ผ่าน
|
|
||||||
- `pnpm --filter backend build` — ✅ ผ่าน
|
|
||||||
- `pnpm --filter backend test -- --runTestsByPath src/modules/ai/ai-migration-checkpoint.service.spec.ts` — ✅ ผ่าน
|
|
||||||
- regression seam ที่เพิ่มยืนยัน:
|
|
||||||
- `AI_JOB_FAILED` map เป็น `API_ERROR`
|
|
||||||
- unsupported error type fallback เป็น `UNKNOWN`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Session 5 — 2026-05-25 (N8N Submit AI Job Debug + Upload Dedup)
|
|
||||||
|
|
||||||
#### ปัญหาที่พบ (Root Cause)
|
|
||||||
|
|
||||||
**Bug 1: `Submit AI Job` → 400 Bad Request**
|
|
||||||
|
|
||||||
- n8n HTTP Request node `typeVersion: 4.1` เมื่อ `specifyBody: "json"` และ `jsonBody` เป็น expression ที่ return **object** → n8n ส่ง body เป็น `"[object Object]"` แทน JSON string
|
|
||||||
- แก้ด้วย `JSON.stringify($json.submit_payload)`
|
|
||||||
|
|
||||||
**Bug 2: `Submit AI Job` → 403 Forbidden**
|
|
||||||
|
|
||||||
- `migration_bot` (user_id=5, role_id=1/Superadmin) ไม่มี `ai.suggest` ใน `role_permissions`
|
|
||||||
- Root cause: Seed script `INSERT INTO role_permissions SELECT 1, permission_id FROM permissions WHERE is_active = 1` รันก่อน `ai.*` permissions (id 181-186) ถูก insert เข้า `permissions` table
|
|
||||||
- แก้ด้วย delta SQL grant ai.\* ให้ role_id=1
|
|
||||||
|
|
||||||
**Bug 3: Upload ซ้ำเมื่อ n8n retry**
|
|
||||||
|
|
||||||
- `FileStorageService.upload()` เดิมไม่มี dedup → ทุก retry สร้าง orphan temp attachment ใหม่
|
|
||||||
- แก้ด้วย checksum-based dedup: query หา temp record ที่มี checksum+userId เดิมและยังไม่หมดอายุ → คืน record เดิมแทน
|
|
||||||
|
|
||||||
#### การแก้ไข (Fix)
|
|
||||||
|
|
||||||
| ไฟล์ | การเปลี่ยนแปลง |
|
|
||||||
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
||||||
| `specs/03-Data-and-Storage/n8n.workflow.v2.json` | `jsonBody` เปลี่ยนเป็น `JSON.stringify($json.submit_payload)` |
|
|
||||||
| `specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.sql` | INSERT IGNORE ai.\* permissions สำหรับ role_id=1 (Superadmin) |
|
|
||||||
| `specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.rollback.sql` | Rollback DELETE สำหรับ delta ข้างบน |
|
|
||||||
| `backend/src/common/file-storage/file-storage.service.ts` | เพิ่ม checksum dedup ใน `upload()` ก่อน write file |
|
|
||||||
|
|
||||||
#### กฎที่ Lock แล้ว
|
|
||||||
|
|
||||||
- `jsonBody` ใน n8n HTTP Request `typeVersion >= 4.1` ต้องใช้ `JSON.stringify(...)` เมื่อ `specifyBody: "json"` และค่าเป็น object
|
|
||||||
- ทุกครั้งที่เพิ่ม permission ใหม่ใน `permissions` table ต้อง grant ให้ Superadmin (role_id=1) ด้วยทันที — ห้ามปล่อยให้ขาดหาย
|
|
||||||
- `FileStorageService.upload()` เป็น idempotent ผ่าน SHA-256 checksum + userId + expiresAt
|
|
||||||
|
|
||||||
#### Verification ที่ยังต้องทำ
|
|
||||||
|
|
||||||
- รัน delta SQL ใน MariaDB (ถ้ายังไม่รัน): `2026-05-25-grant-ai-permissions-to-superadmin.sql`
|
|
||||||
- Import `n8n.workflow.v2.json` ใหม่เข้า n8n UI
|
|
||||||
- `pnpm --filter backend test -- file-storage` — ยืนยัน checksum dedup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Session 7 — 2026-05-25 (PaddleOCR Sidecar Setup) ← **ล่าสุด**
|
|
||||||
|
|
||||||
#### สิ่งที่ทำ
|
|
||||||
|
|
||||||
- แก้ `AggregateError` (empty message) ใน `ocr.service.ts` — wrap เป็น Error พร้อม context ที่ชัดเจน
|
|
||||||
- สร้าง PaddleOCR + PyThaiNLP FastAPI sidecar รันบน Desk-5439 (Windows 10/11, Docker Desktop WSL2)
|
|
||||||
- เพิ่ม path remapping `remapPath()` ใน `OcrService` — แปลง `/app/uploads/...` → `/mnt/uploads/...`
|
|
||||||
|
|
||||||
#### ไฟล์ที่สร้าง/แก้ไข
|
|
||||||
|
|
||||||
| ไฟล์ | การเปลี่ยนแปลง |
|
|
||||||
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/Dockerfile` | ✅ สร้างใหม่ — Python 3.10 slim, ลบ pre-download step (segfault) |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py` | ✅ สร้างใหม่ — FastAPI: `/health`, `/ocr` (PaddleOCR), `/normalize` (PyThaiNLP) |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/requirements.txt` | ✅ สร้างใหม่ — `numpy<2.0` ต้องอยู่ก่อน paddlepaddle (ABI fix) |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/docker-compose.yml` | ✅ สร้างใหม่ — CIFS volume mount + named volume สำหรับ model cache |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/QNAP/app/docker-compose-app.yml` | เพิ่ม `OCR_API_URL`, `OCR_CHAR_THRESHOLD`, `OCR_SIDECAR_UPLOAD_BASE` |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/QNAP/app/.env.example` | เพิ่ม `OCR_API_URL`, `OCR_CHAR_THRESHOLD`, `OCR_SIDECAR_UPLOAD_BASE` |
|
|
||||||
| `backend/src/modules/ai/services/ocr.service.ts` | เพิ่ม `remapPath()`, AggregateError fix |
|
|
||||||
|
|
||||||
#### Known Issues / Fixes
|
|
||||||
|
|
||||||
- `numpy<2.0` ต้องอยู่ก่อน `paddlepaddle` — ABI mismatch กับ cv2 (numpy 2.x)
|
|
||||||
- Docker Desktop WSL2 ไม่รองรับ bind mount จาก network drive (Z:\) → ใช้ CIFS volume แทน
|
|
||||||
- Pre-download model ใน Dockerfile ทำให้ segfault (exit 139) → ลบออก download ตอน runtime
|
|
||||||
- `OLLAMA_RAG_MODEL` → เปลี่ยนเป็น `OLLAMA_MODEL_MAIN=gemma4:e2b` ตาม ADR-023A
|
|
||||||
|
|
||||||
#### CIFS Volume Config
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
qnap_uploads:
|
|
||||||
driver: local
|
|
||||||
driver_opts:
|
|
||||||
type: cifs
|
|
||||||
o: 'username=${QNAP_USER},password=${QNAP_PASS},vers=3.0,uid=0,gid=0'
|
|
||||||
device: '//192.168.10.8/np-dms-as/data/uploads'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Remapping
|
|
||||||
|
|
||||||
```
|
|
||||||
backend: /app/uploads/temp/xxx.pdf → sidecar: /mnt/uploads/temp/xxx.pdf
|
|
||||||
OCR_SIDECAR_UPLOAD_BASE=/mnt/uploads (env var)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Verification
|
|
||||||
|
|
||||||
- `curl http://localhost:8765/health` → `{"status":"ok","engine":"paddleocr"}` ✅
|
|
||||||
- `POST /ocr` ทดสอบกับไฟล์จริงใน `/mnt/uploads/temp/` → ได้ text กลับ ✅
|
|
||||||
- 78 test suites, 672 tests ผ่านทั้งหมด ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Session 8 — 2026-05-30 (OCR Engine Migration — PaddleOCR → Tesseract)
|
|
||||||
|
|
||||||
#### ปัญหาที่พบ (Root Cause)
|
|
||||||
|
|
||||||
**Bug 1: PaddleOCR SIGILL (Illegal Instruction)**
|
|
||||||
|
|
||||||
- PaddleOCR 2.6.2 ต้องการ AVX instruction set ซึ่ง CPU บน Desk-5439 ไม่รองรับ
|
|
||||||
- ลองลดรุ่นเป็น 2.5.2 → ยังมี dependency conflict กับ paddleocr 2.7.3
|
|
||||||
- ลองใช้ paddlepaddle-cpu → ยังคงมีปัญหา dependency
|
|
||||||
|
|
||||||
**Bug 2: OCR ภาษาไทยผิด**
|
|
||||||
|
|
||||||
- PaddleOCR ตั้งค่า `lang="en"` ทำให้ข้อความภาษาไทยถูกแปลงเป็นอักษรละตินผิดๆ
|
|
||||||
- เช่น: "10 กุมภาพันธ์ 2568" → "10 qunnwus 2568"
|
|
||||||
|
|
||||||
#### การแก้ไข (Fix)
|
|
||||||
|
|
||||||
เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อ:
|
|
||||||
|
|
||||||
1. แก้ปัญหา SIGILL บน CPU เก่าที่ไม่รองรับ AVX
|
|
||||||
2. รองรับภาษาไทยด้วย `tha+eng` language code
|
|
||||||
|
|
||||||
| ไฟล์ | การเปลี่ยนแปลง |
|
|
||||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/requirements.txt` | ลบ paddlepaddle/paddleocr, เพิ่ม pytesseract, Pillow |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py` | เปลี่ยนใช้ pytesseract, OCR_LANG เป็น `tha+eng`, ลบ PaddleOCR initialization |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/Dockerfile` | ติดตั้ง tesseract-ocr, tesseract-ocr-tha, tesseract-ocr-eng |
|
|
||||||
| `specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/docker-compose.yml` | OCR_LANG เป็น `tha+eng`, ลบ paddleocr_models volume |
|
|
||||||
| `backend/src/modules/ai/services/ocr.service.ts` | เปลี่ยน comment/error message จาก PaddleOCR เป็น Tesseract |
|
|
||||||
| `frontend/components/admin/ai/OcrSandboxPromptManager.tsx` | เปลี่ยน Badge text จาก PaddleOCR เป็น Tesseract |
|
|
||||||
|
|
||||||
#### กฎที่ Lock แล้ว
|
|
||||||
|
|
||||||
- Tesseract OCR ไม่ต้องการ AVX instruction set → ทำงานได้บน CPU ทุกรุ่น
|
|
||||||
- Tesseract รองรับภาษาไทยด้วย `tha+eng` language code
|
|
||||||
- API contract ยังเหมือนเดิม (`POST /ocr` คืน `{ text, ocrUsed }`) → backend/frontend ไม่ต้องเปลี่ยน logic
|
|
||||||
|
|
||||||
#### Verification ที่ต้องทำ
|
|
||||||
|
|
||||||
- Rebuild container บน Desk-5439: `docker compose down && docker compose up -d --build`
|
|
||||||
- ทดสอบ OCR ภาษาไทย: "10 กุมภาพันธ์ 2568" ควรออกมาถูกต้อง
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Session 9 — 2026-05-26 (System Memories Consolidation)
|
|
||||||
|
|
||||||
#### QNAP SSH Key Authentication & CI/CD Deployment
|
|
||||||
|
|
||||||
**Infrastructure:**
|
|
||||||
|
|
||||||
- QNAP `192.168.10.8` — target deploy server (runs Gitea + app containers)
|
|
||||||
- ASUSTOR `192.168.10.9` — Gitea runner
|
|
||||||
|
|
||||||
**SSH Key Setup (Persistent):**
|
|
||||||
|
|
||||||
- Private key: `/etc/config/ssh/gitea-runner`
|
|
||||||
- Public key: `/etc/config/ssh/gitea-runner.pub`
|
|
||||||
- Fingerprint: `SHA256:OhPbRe9vi4aWTyzBqCQ6T3MLl+JK9lFtH5bPrx+ICPw`
|
|
||||||
- Authorized keys: `/etc/config/ssh/authorized_keys` (symlinked from `/root/.ssh/`)
|
|
||||||
- QNAP SSH config: `/etc/config/ssh/sshd_config` (persistent — ใช้อันนี้เท่านั้น ไม่ใช้ `/etc/ssh/sshd_config`)
|
|
||||||
|
|
||||||
**Critical Fix: AuthorizedKeysFile**
|
|
||||||
|
|
||||||
```
|
|
||||||
AuthorizedKeysFile /etc/config/ssh/authorized_keys
|
|
||||||
```
|
|
||||||
|
|
||||||
ต้องใช้ **absolute path** — ถ้าใช้ `.ssh/authorized_keys` จะ resolve ไปที่ `/share/homes/admin/.ssh/` ซึ่งผิด (admin home = `/share/homes/admin` แต่ symlink อยู่ที่ `/root/.ssh`)
|
|
||||||
|
|
||||||
**Reload QNAP SSH daemon**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kill -HUP $(ps | grep "/usr/sbin/sshd -f /etc/config" | grep -v grep | awk '{print $1}')
|
|
||||||
```
|
|
||||||
|
|
||||||
ไม่มี `pgrep` และไม่มี `systemctl` บน QNAP
|
|
||||||
|
|
||||||
**Gitea Secrets:**
|
|
||||||
| Secret | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| HOST | `192.168.10.8` |
|
|
||||||
| PORT | `22` |
|
|
||||||
| USERNAME | `admin` |
|
|
||||||
| SSH_KEY | private key content from `/etc/config/ssh/gitea-runner` |
|
|
||||||
|
|
||||||
**deploy.sh Fix:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# scripts/deploy.sh line 10 — correct path:
|
|
||||||
COMPOSE_FILE="$SOURCE_DIR/specs/04-Infrastructure-OPS/04-00-docker-compose/QNAP/app/docker-compose-app.yml"
|
|
||||||
```
|
|
||||||
|
|
||||||
ไม่ใช่ `...04-00-docker-compose/docker-compose-app.yml` (ขาด `QNAP/app/`)
|
|
||||||
|
|
||||||
**Root Causes (ทั้งหมด):**
|
|
||||||
|
|
||||||
1. `authorized_keys` เสียหาย — 2 keys บรรทัดเดียว
|
|
||||||
2. SSH key pair หายหลัง reboot — QNAP `/` เป็น RAM, ต้องเก็บใน `/etc/config/`
|
|
||||||
3. `AuthorizedKeysFile` ใช้ relative path — resolve ผิด directory
|
|
||||||
4. HOST secret ชี้ไปผิด server (Go SSH) — แก้เป็น `192.168.10.8:22`
|
|
||||||
5. `deploy.sh` COMPOSE_FILE path ผิด — ขาด `QNAP/app/` subdirectory
|
|
||||||
|
|
||||||
#### Backend TransformInterceptor Double Registration Bug
|
|
||||||
|
|
||||||
**Issue:** API responses were double-wrapped `{ data: { data: actualData } }` causing frontend detail pages to fail loading data.
|
|
||||||
|
|
||||||
**Root Cause:** TransformInterceptor registered in TWO places:
|
|
||||||
|
|
||||||
1. `backend/src/main.ts`: `app.useGlobalInterceptors(new TransformInterceptor())`
|
|
||||||
2. `backend/src/common/common.module.ts`: `{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor }`
|
|
||||||
|
|
||||||
**Fix:** Removed duplicate registration from `main.ts` (keep only APP_INTERCEPTOR in CommonModule).
|
|
||||||
|
|
||||||
**Why list page still worked:** Paginated responses were re-detected as paginated by second interceptor, preventing double-nesting. Non-paginated (detail) endpoints were affected.
|
|
||||||
|
|
||||||
**Verification:** `curl http://localhost:3001/api/correspondences/{uuid}` now returns single-wrapped `{ data: {...} }` instead of double-wrapped.
|
|
||||||
|
|
||||||
**Pattern to Avoid:** Never register global interceptors/filters in both `main.ts` AND via `APP_INTERCEPTOR`/`APP_FILTER` providers.
|
|
||||||
|
|
||||||
#### ADR-021 Integration: Transmittals & Circulation
|
|
||||||
|
|
||||||
**Summary:** Successfully integrated ADR-021 (Integrated Workflow Context & Step-specific Attachments) into Transmittals and Circulation modules. All backend services, frontend pages, and tests are wired to the Unified Workflow Engine.
|
|
||||||
|
|
||||||
**Backend Changes (B1-B9):**
|
|
||||||
|
|
||||||
- **WorkflowEngineService**: Added `getInstanceByEntity(entityType, entityId)` for polymorphic workflow instance lookup
|
|
||||||
- **TransmittalService**:
|
|
||||||
- Expose `workflowInstanceId`, `workflowState`, `availableActions` in `findOneByUuid()`
|
|
||||||
- Added purpose filter to `findAll()`
|
|
||||||
- Added `submit()` with EC-RFA-004 validation (prevents submission if any item correspondence is DRAFT)
|
|
||||||
- Starts workflow instance `TRANSMITTAL_FLOW_V1` and updates CorrespondenceRevision status
|
|
||||||
- **TransmittalController**: Added `POST /:uuid/submit` endpoint with RBAC and Audit
|
|
||||||
- **TransmittalModule**: Imported `WorkflowEngineModule` and `CorrespondenceRevision`
|
|
||||||
- **CirculationService**:
|
|
||||||
- Expose workflow fields in `findOneByUuid()`
|
|
||||||
- Added `reassignRouting()` (EC-CIRC-001) for PENDING routing reassignment
|
|
||||||
- Added `forceClose()` (EC-CIRC-002) with transactional rollback and reason validation
|
|
||||||
- **CirculationController**: Added `PATCH /:uuid/routing/:routingId/reassign` and `POST /:uuid/force-close`
|
|
||||||
- **Circulation Entity**: Added `deadlineDate` column for EC-CIRC-003 Overdue badge
|
|
||||||
- **Schema Delta**: `05-add-circulation-deadline.sql` per ADR-009 (no migrations)
|
|
||||||
|
|
||||||
**Frontend Changes (F1-F7):**
|
|
||||||
|
|
||||||
- **Types**: Extended `Transmittal` and `Circulation` interfaces with workflow fields; added `deadlineDate` to Circulation
|
|
||||||
- **Hooks**: Created `useTransmittal()` and extended `useCirculation()` hooks with TanStack Query
|
|
||||||
- **Detail Pages**:
|
|
||||||
- Both wired with `IntegratedBanner` and `WorkflowLifecycle` using live workflow data
|
|
||||||
- Circulation page includes EC-CIRC-003 Overdue badge logic (`isOverdue()`)
|
|
||||||
- **List Page**: Added purpose filter dropdown to `transmittals/page.tsx`
|
|
||||||
|
|
||||||
**Tests (T1-T2): 19/19 Passing**
|
|
||||||
|
|
||||||
- **TransmittalService**: 7 tests covering EC-RFA-004 validation, workflow instance creation, and error cases
|
|
||||||
- **CirculationService**: 12 tests covering EC-CIRC-001 (reassign), EC-CIRC-002 (forceClose), EC-CIRC-003 (deadlineDate exposure)
|
|
||||||
|
|
||||||
**Key Technical Decisions:**
|
|
||||||
|
|
||||||
- Followed ADR-019 UUID handling (no parseInt, use string UUIDs)
|
|
||||||
- Used ADR-009 direct schema edits (no TypeORM migrations)
|
|
||||||
- Enforced RBAC with CASL guards and Audit decorators
|
|
||||||
- Implemented transactional force-close with proper rollback
|
|
||||||
- Maintained existing patterns for error handling and service architecture
|
|
||||||
|
|
||||||
**Remaining Work:**
|
|
||||||
|
|
||||||
- I1: i18n keys for new workflow actions (low priority)
|
|
||||||
|
|
||||||
#### Correspondence Detail Display Fixes
|
|
||||||
|
|
||||||
**Issue:** `/correspondences/[uuid]` detail display inconsistency
|
|
||||||
|
|
||||||
**Fix:** Made backend `findOneByUuid` query deterministic with explicit relation joins and revision ordering (rev.revisionNumber DESC, rev.createdAt DESC), and normalized recipient_type values in frontend detail page before TO/CC filtering to handle whitespace variants per schema (e.g., 'CC ').
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
|
|
||||||
- `backend/src/modules/correspondence/correspondence.service.ts`
|
|
||||||
- `frontend/components/correspondences/detail.tsx`
|
|
||||||
|
|
||||||
#### Correspondence Create Permission Bypass
|
|
||||||
|
|
||||||
**Issue:** Users without primaryOrganizationId could not create documents even with system.manage_all permission
|
|
||||||
|
|
||||||
**Fix:** In backend CorrespondenceService.create flow, users without primaryOrganizationId can still create when they have system.manage_all and provide originatorId. Validation now resolves originator organization under that permission instead of immediately throwing 'User must belong to an organization to create documents'. Added regression test in correspondence.service.spec.ts.
|
|
||||||
|
|
||||||
**Extension:** Applied same pattern to RFA, Transmittal, and Circulation create endpoints — they now accept optional originatorId and allow creation for users with system.manage_all even when primaryOrganizationId is null. Added permission-gated impersonation checks in their services to prevent unauthorized cross-organization creation.
|
|
||||||
|
|
||||||
#### Playwright E2E Testing Setup
|
|
||||||
|
|
||||||
**Test Stack:**
|
|
||||||
|
|
||||||
- **Backend**: Jest (Unit + Integration + E2E)
|
|
||||||
- **Frontend**: Vitest (Unit) + Playwright (E2E)
|
|
||||||
|
|
||||||
**MCP Server Setup (Windsurf):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@playwright/mcp@latest"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windsurf Cascade Tools:**
|
|
||||||
|
|
||||||
- `browser_navigate` - เปิด URL
|
|
||||||
- `browser_click` - คลิก element
|
|
||||||
- `browser_type` - พิมพ์ข้อความ
|
|
||||||
- `browser_take_screenshot` - ถ่าย screenshot
|
|
||||||
- `browser_evaluate` - รัน JavaScript
|
|
||||||
|
|
||||||
**Run E2E Tests:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npx playwright test # Run all
|
|
||||||
npx playwright test --ui # Debug mode
|
|
||||||
npx playwright test --headed # See browser
|
|
||||||
npx playwright show-report # Generate report
|
|
||||||
```
|
|
||||||
|
|
||||||
**E2E Script Location:** `frontend/e2e/workflow-adr021.spec.ts`
|
|
||||||
|
|
||||||
#### Tag Creation and Contract UUID Fixes
|
|
||||||
|
|
||||||
**Issue 1:** `/admin/doc-control/reference/tags` needed a list-level Project dropdown filter and Tag creation could fail due to TypeORM Tag entity column-name mismatches.
|
|
||||||
|
|
||||||
**Fix:** Added selectedProjectId filter in frontend tags page and mapped backend Tag entity fields to schema names (project_id, tag_name, color_code, created_by, created_at, updated_at, deleted_at).
|
|
||||||
|
|
||||||
**Issue 2:** Frontend contract detail page typecheck failure — `contract.project?.id` vs `contract.project?.publicId`
|
|
||||||
|
|
||||||
**Fix:** In `frontend/app/(admin)/admin/doc-control/contracts/page.tsx`, handleEdit must read nested project UUID from contract.project?.id (not project?.publicId) because Contract.project is typed and returned as { id: string; projectCode; projectName }.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Session 9 — 2026-05-27 (Context-Aware Prompt Templates & Database Typo CC Cleanup) ← **ล่าสุด**
|
|
||||||
|
|
||||||
**Summary:** ดำเนินการอิมพลีเมนต์ ADR-030 (Context-Aware Prompt Templates สำหรับการสกัดข้อมูลเอกสาร) และทำการแก้ไขบัคช่องว่างประเภทผู้รับ `'CC '` ในฐานข้อมูล
|
|
||||||
|
|
||||||
**Backend Changes (B1-B6):**
|
|
||||||
|
|
||||||
- **AiPrompt Entity**: เพิ่มการแมปคอลัมน์ `contextConfig` ไปยัง JSON ฟิลด์ `context_config` ในฐานข้อมูลเพื่อควบคุม master data resolution
|
|
||||||
- **CreateAiPromptDto / Response DTO**: ปรับแต่งให้รองรับการรับและส่งออกคอลัมน์ `contextConfig`
|
|
||||||
- **AiPromptsService**:
|
|
||||||
- อิมพลีเมนต์เมธอด `resolveContext()` สำหรับการดึงข้อมูล Master Data ดำเนินการคัดกรองข้อมูลอ้างอิงโครงการ (Projects, Organizations, Disciplines, CorrespondenceTypes, Tags) สอดคล้องกับ dynamic config filter
|
|
||||||
- ติดตั้ง **Gatekeeper Rule** (ตัวกรองความปลอดภัย) โยน `ForbiddenException` ทันทีเมื่อมีการร้องขอ override project UUID ข้ามอาณาเขตโครงการที่กำหนดใน template เพื่อป้องกัน Cross-project data leak
|
|
||||||
- **AiBatchProcessor**:
|
|
||||||
- ปรับปรุงโครงสร้าง `MigrateDocumentMetadata` interface, sandbox extraction, และ migration process ให้ดึงข้อมูลและแมป master data context-aware
|
|
||||||
- สกัดและจำแนกผู้รับเอกสาร (recipients) ภายใต้โครงสร้าง JSON แบบใหม่ในรูป Object Array: `recipients: Array<{ organizationPublicId: string, recipientType: 'TO' | 'CC' }>` เพื่อความเสถียรและทนทานของข้อมูล
|
|
||||||
- **Unit Tests**:
|
|
||||||
- เพิ่มชุดการทดสอบ `resolveContext` ใน `ai-prompts.service.spec.ts` ครอบคลุมการจำลอง master data resolution, การโยน `NotFoundException` และการล็อคสิทธิ์ความปลอดภัยด้วย `ForbiddenException` เมื่อ override โครงการข้าม boundary
|
|
||||||
- แก้ไข mock dependencies ของ `AiPromptsService` ใน `ai-batch.processor.spec.ts` ป้องกันปัญหา `TypeError: getActive is not a function` ทำให้ผ่าน unit tests 100%
|
|
||||||
|
|
||||||
**Database & Schema Changes (ADR-009):**
|
|
||||||
|
|
||||||
- **schema-02-tables.sql**: แก้ไข line 338 ปรับปรุง `ENUM('TO', 'CC ')` เป็น `ENUM('TO', 'CC')`
|
|
||||||
- **SQL Delta**: สร้าง `2026-05-27-add-context-aware-prompts-and-cleanup.sql` ดำเนินการ `UPDATE` ข้อมูลเก่าที่เป็น `'CC '` ให้เป็น `'CC'` เพื่อล้างช่องว่าง จากนั้นสั่ง `ALTER TABLE` ปรับปรุงฟิลด์ enum และ Seed template ภาษาไทยเวอร์ชัน 2
|
|
||||||
- **Rollback SQL**: สร้างไฟล์ย้อนกลับ `2026-05-27-add-context-aware-prompts-and-cleanup.rollback.sql` เรียบร้อย
|
|
||||||
|
|
||||||
**Frontend Changes:**
|
|
||||||
|
|
||||||
- **detail.tsx**: ตรวจสอบการใช้งาน `normalizeRecipientType` ซึ่งครอบคลุมการล้างช่องว่างและการกรองผู้รับ TO/CC ได้อย่างทนทาน
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
|
|
||||||
- `pnpm --filter backend build` — ✅ Compile ผ่านแบบ Strict Mode
|
|
||||||
- unit tests AI module & backend suites — ✅ ผ่านทั้งหมด 60 suites / 521 tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Session 10 — 2026-05-30 (OCR Sandbox Two-Step Flow) ← **ล่าสุด**
|
|
||||||
|
|
||||||
**Summary:** แยก OCR Sandbox เป็น 2 steps ตาม spec 231: Step 1 OCR-only → Step 2 AI Extraction เพื่อให้ admin ตรวจคุณภาพ OCR ก่อนทดสอบ AI prompt
|
|
||||||
|
|
||||||
**Backend Changes (B1-B5):**
|
|
||||||
|
|
||||||
- **AiBatchJobType**: เพิ่ม `sandbox-ocr-only` และ `sandbox-ai-extract` job types
|
|
||||||
- **AiBatchProcessor**:
|
|
||||||
- เพิ่ม `processSandboxOcrOnly()` — รัน OCR เท่านั้น, cache OCR text ใน Redis key `ai:sandbox:ocr:{idempotencyKey}` (TTL 3600s)
|
|
||||||
- เพิ่ม `processSandboxAiExtract()` — ดึง OCR text จาก cache, resolve prompt version (active หรือ version ที่ระบุ), replace {{ocr_text}} และ {{master_data_context}}, run LLM
|
|
||||||
- **AiPromptsService**: เพิ่ม `findByVersion(promptType, versionNumber)` method สำหรับดึง prompt version ที่ระบุ
|
|
||||||
- **AiController**:
|
|
||||||
- เพิ่ม `POST /ai/admin/sandbox/ocr` — Step 1 endpoint (รับ file multipart/form-data)
|
|
||||||
- เพิ่ม `POST /ai/admin/sandbox/ai-extract` — Step 2 endpoint (รับ requestPublicId + optional promptVersion)
|
|
||||||
- **AiQueueService**: อัปเดต `enqueueSandboxJob()` รองรับ job types ใหม่และ extraPayload สำหรับส่ง promptVersion
|
|
||||||
|
|
||||||
**Frontend Changes (F1-F3):**
|
|
||||||
|
|
||||||
- **adminAiService**: เพิ่ม `submitSandboxOcr(file)` และ `submitSandboxAiExtract(requestPublicId, promptVersion)` methods
|
|
||||||
- **OcrSandboxPromptManager.tsx**:
|
|
||||||
- เพิ่ม states: `sandboxStep` ('ocr'|'ai'), `ocrResult` (requestPublicId, ocrText, ocrUsed), `selectedPromptVersion`
|
|
||||||
- เพิ่ม handlers: `handleStep1Ocr()` (poll OCR result), `handleStep2AiExtract()` (poll AI result), `handleResetSandbox()`
|
|
||||||
- Refactor UI: Step 1 (upload + Run OCR button) → Step 2 (prompt version dropdown + Run AI Extraction button + Reset button)
|
|
||||||
- แสดง OCR Raw Text card หลัง Step 1 เสร็จ (สีน้ำเงิน, badge บอก PaddleOCR/Fast Path)
|
|
||||||
- แสดง AI Extraction result หลัง Step 2 เสร็จ (สีเขียว, badge บอก promptVersionUsed)
|
|
||||||
|
|
||||||
**Schema Fix (ADR-009 + ADR-019):**
|
|
||||||
|
|
||||||
- **Delta SQL**: สร้าง `2026-05-30-add-ai-prompts-publicId.sql` เพิ่ม `publicId CHAR(36) UNIQUE` column ใน `ai_prompts` table
|
|
||||||
- **Root Cause**: Entity มี `publicId` field แต่ DB table ไม่มี column นี้ → QueryFailedError: "Unknown column 'AiPrompt.publicId' in 'SELECT'"
|
|
||||||
- **Fix**: ใช้ CHAR(36) แทน UUID type (MariaDB compatible), generate UUID สำหรับ existing records ด้วย `UUID()` function
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
|
|
||||||
- Backend TypeScript: ✅ ผ่าน (`npx tsc --noEmit`)
|
|
||||||
- Frontend TypeScript: ✅ ผ่าน (`npx tsc --noEmit`)
|
|
||||||
- ESLint: ✅ ผ่าน (แก้ unused `user` parameter ใน `submitSandboxAiExtract`)
|
|
||||||
|
|
||||||
**Data Flow:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Step 1: Upload PDF → POST /ai/admin/sandbox/ocr
|
|
||||||
↓
|
|
||||||
BullMQ: sandbox-ocr-only job
|
|
||||||
↓
|
|
||||||
OCR Service → Cache OCR text (ai:sandbox:ocr:{id})
|
|
||||||
↓
|
|
||||||
Frontend displays OCR Raw Text
|
|
||||||
|
|
||||||
Step 2: Select prompt version → POST /ai/admin/sandbox/ai-extract
|
|
||||||
↓
|
|
||||||
BullMQ: sandbox-ai-extract job
|
|
||||||
↓
|
|
||||||
Retrieve OCR text from cache (ai:sandbox:ocr:{id})
|
|
||||||
↓
|
|
||||||
Replace {{ocr_text}} → LLM → JSON result
|
|
||||||
↓
|
|
||||||
Frontend displays AI Extraction result
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pending:**
|
|
||||||
|
|
||||||
- Run delta SQL `2026-05-30-add-ai-prompts-publicId.sql` ใน production database
|
|
||||||
- Restart backend service หลัง apply delta
|
|
||||||
- Test 2-step flow จริงใน production environment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Session 11 — 2026-05-30 (Typhoon OCR & LLM Integration) ← **ล่าสุด**
|
|
||||||
|
|
||||||
**Summary:** ออกแบบและพัฒนาการใช้งานโมเดลภาษาไทยผสมอังกฤษ Typhoon OCR-3B ร่วมกับ Tesseract OCR แบบ Dynamic พร้อมระบบ caching 24 ชม., VRAM Monitor ป้องกัน GPU OOM และระบบ fallback 5s เมื่อโมเดลมีปัญหา และการสลับและบริหารจัดการ LLM โมเดลหลักแบบ Dynamic ในระบบ AI Model Management ของ Next.js frontend ตามข้อกำหนด ADR-032
|
|
||||||
|
|
||||||
**Backend Changes (B1-B5):**
|
|
||||||
|
|
||||||
- **OcrService**:
|
|
||||||
- เพิ่ม dynamic OCR engine selection (`getOcrEngines()`, `selectOcrEngine()`, `getActiveEngineId()`) จัดเก็บสถานะหลักใน DB `system_settings` (`OCR_ACTIVE_ENGINE`) พร้อม cache ใน Redis 30s ป้องกันคิวรีซ้ำซ้อน
|
|
||||||
- ปรับปรุง `detectAndExtract()` ให้เลือกใช้ engine ที่เหมาะสม หากผู้ใช้เลือก Typhoon OCR-3B จะทำงานผ่าน `processWithTyphoon()` ร่วมกับ `OcrCacheService` (24-hour Redis caching) และ `VramMonitorService` (ตรวจสอบ VRAM capacity > 4GB ก่อนโหลดโมเดล)
|
|
||||||
- พัฒนาระบบ **Graceful Fallback** ไปยัง Tesseract OCR ในเวลา 5 วินาทีหาก Typhoon ขัดข้องหรือ VRAM ไม่เพียงพอ โดยมีการบันทึกรายละเอียดและ error ลง `ai_audit_logs`
|
|
||||||
- **AiService**:
|
|
||||||
- เพิ่ม endpoints สำหรับ AI Model Management: `GET /models`, `POST /models` (Superadmin), `PATCH /models/:modelId/activate` และ `GET /vram/status` (Used/Free VRAM และ Active models บน GPU) ร่วมกับ `AiSettingsService` และ `VramMonitorService`
|
|
||||||
- ตรวจสอบความปลอดภัย VRAM ก่อนอนุญาตให้สลับโมเดลหลัก หากเหลือพื้นที่หน่วยความจำ GPU ไม่พอ จะโยน `BusinessException` แจ้งเตือนภาษาไทยพร้อมบันทึกลง Audit Log และระงับการเปลี่ยนโมเดลทันที
|
|
||||||
- แก้ไขข้อผิดพลาด build error ใน `ai.service.ts` โดยการนำเข้า `OllamaService` และ `AiQdrantService` ที่ขาดหายไปใน constructor
|
|
||||||
|
|
||||||
**Frontend Changes (F1-F3):**
|
|
||||||
|
|
||||||
- **admin-ai.service.ts**: เพิ่ม interface `LoadedModelInfo` และ `VramStatusResponse` และเพิ่ม methods `getVramStatus()`, `getAvailableModels()`, `setActiveModel()`, และ `addModel()` โดยใช้ dynamic path ที่อ้างอิง UUIDv7 (`modelId`) และส่ง idempotency headers ตาม ADR-019/ADR-016
|
|
||||||
- **admin/ai/page.tsx**: อัปเดตหน้า AI Admin page โดยเพิ่ม **VRAM GPU Monitor Card** แบบ realtime (ดึงและสลับรีเฟรชผ่าน React Query ทุก 15s) แสดง Free/Used VRAM และ active models บน GPU และปรับปรุง UI ส่วน AI Model Management ให้สลับโมเดลหลักผ่าน UUIDv7 และแสดง VRAM requirements ของแต่ละโมเดลอย่างสวยงามพรีเมียม
|
|
||||||
|
|
||||||
**ADRs Update (ADR-023/023A):**
|
|
||||||
|
|
||||||
- อัปเดต `ADR-023-unified-ai-architecture.md` (v1.2) และ `ADR-023A-unified-ai-architecture.md` (v1.3) เพื่อรับรองสถาปัตยกรรม dynamic Thai specialized models (Typhoon OCR & LLM) ภายใต้การควบคุมของ VRAM Monitor
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
|
|
||||||
- Backend NestJS Build: ✅ Compile สำเร็จ 100% ปราศจาก Error (`npm run build` ใน backend)
|
|
||||||
- Frontend Next.js Build: ✅ Compile สำเร็จ 100% ปราศจาก Error (`npm run build` ใน frontend)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 11. แผนงานขั้นต่อไป (Next Session Focus)
|
|
||||||
|
|
||||||
### N8N Migration & E2E Testing (งานหลักที่เหลือ)
|
|
||||||
|
|
||||||
- [ ] **Import `n8n.workflow.v2.json`** เข้า n8n UI และทดสอบ End-to-End (มี fix จาก Session 3, 4, 5 แล้ว)
|
|
||||||
- [ ] **ทดสอบ End-to-End จริง** — รัน n8n กับ Excel ตัวอย่าง → ตรวจสอบว่า `Submit AI Job` ผ่าน, `migration_review_queue` มีข้อมูล, `migration_errors.job_id` ถูกบันทึก
|
|
||||||
- [ ] **Frontend Editable Review Form** (Pipeline B) — pre-fill AI suggestions + tag suggestion UI
|
|
||||||
- [ ] **Dry Run** กับ Excel จริงก่อน Production Migration
|
|
||||||
|
|
||||||
### งานทั่วไป
|
|
||||||
|
|
||||||
- [ ] ดำเนินการรัน SQL delta script ใน MariaDB เมื่อขึ้นสภาพแวดล้อมจริง
|
|
||||||
- [ ] เพิ่ม unit test สำหรับ `upsertQueueRecord` ใน `ai-migration-checkpoint.service.spec.ts` (UUID→INT path)
|
|
||||||
- [ ] เพิ่ม unit test สำหรับ checksum dedup ใน `file-storage.service.spec.ts`
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# MCP Tools Documentation
|
||||||
|
|
||||||
|
## MCP MariaDB Tools
|
||||||
|
|
||||||
|
MCP MariaDB server provides tools for direct database inspection and management. Used for:
|
||||||
|
|
||||||
|
- Verifying schema against spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
- Debugging database issues without entering MySQL client
|
||||||
|
- Checking data in production/staging
|
||||||
|
- Validating schema changes before deploy
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | Purpose | Example Usage |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `mcp1_mysql_test_connection` | Test database connection | Verify MCP server connectivity |
|
||||||
|
| `mcp1_mysql_show_databases` | List all databases | See available databases |
|
||||||
|
| `mcp1_mysql_show_tables` | List all tables in database | See tables in `lcbp3` |
|
||||||
|
| `mcp1_mysql_describe_table` | View table structure/columns | Check columns, types, keys of `correspondences` |
|
||||||
|
| `mcp1_mysql_query` | Run SELECT query | View data in table or join query |
|
||||||
|
| `mcp1_mysql_insert` | INSERT data | Add seed data or test data |
|
||||||
|
| `mcp1_mysql_update` | UPDATE data | Modify data in table |
|
||||||
|
| `mcp1_mysql_delete` | DELETE data | Delete data from table |
|
||||||
|
|
||||||
|
### Usage with Development Flow
|
||||||
|
|
||||||
|
**When writing new queries:**
|
||||||
|
1. Use `mcp1_mysql_describe_table` to check columns and types
|
||||||
|
2. Compare with `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||||
|
3. Use `mcp1_mysql_query` to test query before implement
|
||||||
|
|
||||||
|
**When changing schema (ADR-009):**
|
||||||
|
1. Use `mcp1_mysql_describe_table` to see current structure
|
||||||
|
2. Create SQL delta in `specs/03-Data-and-Storage/deltas/`
|
||||||
|
3. Use `mcp1_mysql_query` to verify result after apply delta
|
||||||
|
|
||||||
|
**When debugging database issues:**
|
||||||
|
1. Use `mcp1_mysql_query` to see actual data
|
||||||
|
2. Compare with spec and data dictionary
|
||||||
|
3. Check foreign keys and constraints
|
||||||
|
|
||||||
|
### Warnings
|
||||||
|
|
||||||
|
- **❌ NEVER use MCP MariaDB for DDL operations** (CREATE/ALTER/DROP) directly — must use SQL delta per ADR-009
|
||||||
|
- **✅ Use for DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) for debug and test only
|
||||||
|
- **⚠️ Be careful with DELETE operations** — may lose data in production
|
||||||
|
- **✅ Always verify schema against spec file** before writing queries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Memory Tools
|
||||||
|
|
||||||
|
MCP Memory server provides tools for managing Knowledge Graph and Long-term Memory. Used for:
|
||||||
|
|
||||||
|
- Storing project knowledge and context in Graph format (Entities + Relations + Observations)
|
||||||
|
- Searching and retrieving context from memory saved in previous sessions
|
||||||
|
- Creating/editing/deleting entities, relations, and observations in knowledge graph
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
| Tool | Purpose | Example Usage |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `mcp3_create_entities` | Create multiple new entities with observations | Create new entities like Project, User, Task |
|
||||||
|
| `mcp3_create_relations` | Create relations between entities | Create relation: Project → has → User |
|
||||||
|
| `mcp3_add_observations` | Add observations to existing entities | Add additional context to entity |
|
||||||
|
| `mcp3_delete_entities` | Delete entities and related relations | Delete unused entities |
|
||||||
|
| `mcp3_delete_relations` | Delete relations between entities | Delete incorrect or unused relations |
|
||||||
|
| `mcp3_delete_observations` | Delete observations from entity | Delete incorrect or stale context |
|
||||||
|
| `mcp3_open_nodes` | Retrieve entities by name | Get specific entity by name |
|
||||||
|
| `mcp3_read_graph` | Read entire knowledge graph | See full graph structure |
|
||||||
|
| `mcp3_search_nodes` | Search entities by query | Find entity by name, type, or observation |
|
||||||
|
|
||||||
|
### Usage with Development Flow
|
||||||
|
|
||||||
|
**When saving new context:**
|
||||||
|
1. Use `mcp3_create_entities` to create new entities (if not exist)
|
||||||
|
2. Use `mcp3_create_relations` to link entities
|
||||||
|
3. Use `mcp3_add_observations` to add context/observations
|
||||||
|
|
||||||
|
**When searching context:**
|
||||||
|
1. Use `mcp3_search_nodes` to find relevant entities
|
||||||
|
2. Use `mcp3_open_nodes` to get specific entity data
|
||||||
|
3. Use `mcp3_read_graph` to see relations between entities
|
||||||
|
|
||||||
|
**When editing context:**
|
||||||
|
1. Use `mcp3_add_observations` to add new observations
|
||||||
|
2. Use `mcp3_delete_observations` to delete incorrect observations
|
||||||
|
3. Use `mcp3_create_relations` or `mcp3_delete_relations` to adjust relations
|
||||||
|
|
||||||
|
### Warnings
|
||||||
|
|
||||||
|
- **✅ Use for storing context that needs to be shared across multiple sessions** — e.g., important decisions, architecture decisions, rollout history
|
||||||
|
- **⚠️ Be careful when deleting entities** — may lose context still in use
|
||||||
|
- **✅ Check if entity exists before creating** — use `mcp3_search_nodes` or `mcp3_open_nodes` first
|
||||||
|
- **✅ Use clear and unique entity names** — to prevent confusion
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Project Memory Override
|
||||||
|
|
||||||
|
> **Project:** NAP-DMS (LCBP3) — Laem Chabang Port Phase 3 Document Management System
|
||||||
|
> **Version:** 1.9.10 (Last Synced: 2026-06-08)
|
||||||
|
> **Stack:** NestJS 11 + Next.js 16 + TypeScript + MariaDB 11.8 + Redis + BullMQ + Elasticsearch + Ollama (on-prem AI)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Project memory นี้ต้องใช้งานภายใต้ `AGENTS.md` เสมอ**
|
||||||
|
>
|
||||||
|
> - ให้ใช้ `AGENTS.md` เป็นกฎหลักก่อน memory ทุกครั้ง
|
||||||
|
> - ถ้า memory เก่าหรือ session note ขัดกับ `AGENTS.md` ให้ยึด `AGENTS.md`
|
||||||
|
> - งาน schema ต้องทำตาม ADR-009 ผ่าน SQL/delta เท่านั้น
|
||||||
|
> - งาน UUID/Public API ต้องทำตาม ADR-019 โดยใช้ `publicId` และห้าม `parseInt()` บน UUID
|
||||||
|
> - งาน n8n / AI migration ต้องอยู่ในขอบเขต ADR-023A และ mutation ต้องมี `Idempotency-Key`
|
||||||
|
|
||||||
|
## OS Rules & Sandbox Constraints
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **ระบบรันอยู่บน Windows OS**
|
||||||
|
>
|
||||||
|
> - ห้ามใช้คำสั่ง `bash` หรือคำสั่งของ Linux โดยเด็ดขาด
|
||||||
|
> - คำสั่งทุกประเภทที่จะส่งให้ผู้ใช้รันหรือรันผ่าน Terminal ต้องเป็น **PowerShell** หรือ **CMD** เท่านั้น
|
||||||
|
> - ห้ามใช้คำสั่ง `cd` ในการสลับ Directory ให้ระบุพารามิเตอร์ `Cwd` ใน Tool ตรง ๆ
|
||||||
|
|
||||||
|
## Current Decisions (Locked)
|
||||||
|
|
||||||
|
> การตัดสินใจเหล่านี้ **ไม่สามารถเปลี่ยนแปลงได้** โดยไม่ได้รับ Explicit Approval
|
||||||
|
|
||||||
|
| ID | Decision | ADR |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------- | --------- |
|
||||||
|
| D1 | n8n = Migration Phase orchestrator เท่านั้น — ห้ามทำ New Correspondence pipeline ผ่าน n8n | ADR-023A |
|
||||||
|
| D2 | New Correspondence → BullMQ `ai-realtime` queue โดยตรง (ไม่ผ่าน n8n) | ADR-023A |
|
||||||
|
| D3 | n8n ต้อง call `POST /api/ai/jobs` (DMS Backend) เท่านั้น — ห้าม call Ollama/Qdrant โดยตรง | ADR-023A |
|
||||||
|
| D4 | Excel metadata ส่งไปพร้อม AI job เป็น context (docNumber, title, sender ฯลฯ) | Session 2 |
|
||||||
|
| D5 | Tag suggestion ใช้ทาง C: แนะนำ existing tags + สร้างใหม่ได้ถ้าไม่มี (`isNew: true` flag) | Session 2 |
|
||||||
|
| D6 | Editable Review Form: AI pre-fill → user approve/edit → submit (human-in-the-loop ทุกครั้ง) | ADR-023 |
|
||||||
|
| D7 | UUID Strategy: `publicId` (UUIDv7) เท่านั้นสำหรับ Public API — INT PK ต้อง `@Exclude()` | ADR-019 |
|
||||||
|
| D8 | Schema changes: แก้ SQL โดยตรง + เพิ่ม `deltas/*.sql` — ห้ามใช้ TypeORM migration files | ADR-009 |
|
||||||
|
| D9 | Qdrant search ต้องส่ง `projectPublicId` เป็น mandatory parameter ทุกครั้ง (compile-time) | ADR-023A |
|
||||||
|
| D10 | AI model stack: `typhoon2.5-np-dms:latest` (Main LLM) + `typhoon-np-dms-ocr:latest` (OCR, keep_alive:0) + `BGE-M3` (Dense 1024 + Sparse Embedding) + `BGE-Reranker-Large` (Reranker) on Admin Desktop — `nomic-embed-text` ถูกแทนที่แล้ว (ADR-034/035) | ADR-034/035 |
|
||||||
|
| D11 | RAG Embedding trigger: `syncStatus()` → `enqueueRagPrepare()` เมื่อ status ≠ DRAFT; jobId = `rag-prepare:{documentPublicId}:{revisionNumber}` (BullMQ dedup); delete-before-upsert ทุกครั้ง | ADR-035 |
|
||||||
|
| D12 | Qdrant collection `lcbp3_vectors` = Hybrid schema: `bge_dense` (1024 dims, Cosine) + `bge_sparse` (SPLADE); payload indexes: `project_public_id` (tenant), `doc_public_id`, `status_code`, `doc_type` | ADR-035 |
|
||||||
|
|
||||||
|
## Environment & Services
|
||||||
|
|
||||||
|
| Service | Local URL / Port | Production | Notes |
|
||||||
|
| ----------------- | ----------------------------- | ------------------------- | ------------------------------------ |
|
||||||
|
| **Backend API** | `http://localhost:3001` | QNAP `192.168.10.8` | NestJS — `/api` prefix |
|
||||||
|
| **Frontend** | `http://localhost:3000` | QNAP `192.168.10.8` | Next.js |
|
||||||
|
| **MariaDB** | `localhost:3307` | QNAP internal | DB: `lcbp3`, root via docker |
|
||||||
|
| **Redis** | `localhost:6379` | QNAP internal | BullMQ + session store |
|
||||||
|
| **Ollama** | `http://192.168.10.100:11434` | Admin Desktop (Desk-5439) | typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) |
|
||||||
|
| **Qdrant** | `http://localhost:6333` | Admin Desktop (Desk-5439) | Vector DB — requires projectPublicId |
|
||||||
|
| **OCR Sidecar** | `http://192.168.10.100:8765` | Admin Desktop (Desk-5439) | Tesseract (fallback) / Typhoon OCR-3B (primary) + BGE-M3 `/embed` + BGE-Reranker `/rerank` |
|
||||||
|
| **Gitea** | `https://git.np-dms.work` | QNAP `192.168.10.8` | Source + CI/CD |
|
||||||
|
| **Gitea Runner** | ASUSTOR `192.168.10.9` | — | CI runner |
|
||||||
|
|
||||||
|
### Key Environment Variables
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
|
||||||
|
REDIS_HOST, REDIS_PORT
|
||||||
|
JWT_SECRET, JWT_EXPIRES_IN
|
||||||
|
OLLAMA_BASE_URL (ชี้ไป Admin Desktop)
|
||||||
|
QDRANT_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Session Focus
|
||||||
|
|
||||||
|
### N8N Migration & E2E Testing
|
||||||
|
|
||||||
|
- [ ] **Import `n8n.workflow.v2.json`** เข้า n8n UI และทดสอบ End-to-End
|
||||||
|
- [ ] **ทดสอบ End-to-End จริง** — รัน n8n กับ Excel ตัวอย่าง
|
||||||
|
- [ ] **Frontend Editable Review Form** (Pipeline B) — pre-fill AI suggestions + tag suggestion UI
|
||||||
|
- [ ] **Dry Run** กับ Excel จริงก่อน Production Migration
|
||||||
|
|
||||||
|
### RAG Pipeline — Production Readiness
|
||||||
|
|
||||||
|
- [X] **รัน SQL delta** `2026-06-05-add-rag-chunking-prompt.sql` ใน MariaDB production
|
||||||
|
- [ ] **Deploy OCR Sidecar ใหม่** บน Desk-5439 หลัง rebuild image
|
||||||
|
- [ ] **Drop + recreate Qdrant collection** `lcbp3_vectors` เป็น Hybrid schema
|
||||||
|
- [ ] **SC-002 E2E accuracy test** — ทดสอบ Chat Q&A ≥ 80% accuracy
|
||||||
|
|
||||||
|
### General Tasks
|
||||||
|
|
||||||
|
- [ ] เพิ่ม unit test สำหรับ `upsertQueueRecord` ใน `ai-migration-checkpoint.service.spec.ts`
|
||||||
|
- [ ] เพิ่ม unit test สำหรับ checksum dedup ใน `file-storage.service.spec.ts`
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- File: specs/03-Data-and-Storage/deltas/2026-06-05-add-rag-chunking-prompt.rollback.sql
|
||||||
|
-- Rollback การเพิ่ม Prompt สำหรับ Semantic Chunking
|
||||||
|
-- Change Log:
|
||||||
|
-- - 2026-06-05: Initial rollback (T002)
|
||||||
|
|
||||||
|
DELETE FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'rag_chunking'
|
||||||
|
AND version_number = 1;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- File: specs/03-Data-and-Storage/deltas/2026-06-05-add-rag-chunking-prompt.sql
|
||||||
|
-- เพิ่ม Prompt สำหรับ Semantic Chunking ลงใน ai_prompts table
|
||||||
|
-- ตาม ADR-035 และ FR-004a
|
||||||
|
-- Change Log:
|
||||||
|
-- - 2026-06-05: Initial seed สำหรับ rag_chunking prompt (T002)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
public_id,
|
||||||
|
prompt_type,
|
||||||
|
version_number,
|
||||||
|
template,
|
||||||
|
field_schema,
|
||||||
|
context_config,
|
||||||
|
is_active,
|
||||||
|
manual_note,
|
||||||
|
activated_at,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
UUID(),
|
||||||
|
'rag_chunking',
|
||||||
|
1,
|
||||||
|
'คุณเป็นผู้ช่วยวิเคราะห์เอกสารและแบ่งเนื้อหาเป็นส่วนๆ ตามหัวข้อ (Semantic Chunking)\nหน้าที่ของคุณคืออ่านข้อความเอกสารที่ได้จาก OCR ด้านล่างนี้ แล้วแบ่งเอกสารออกเป็นชิ้นๆ (Chunks) ตามเนื้อหาและหัวข้อหลัก\nสำหรับแต่ละส่วนที่คุณแบ่ง ให้ล้อมรอบด้วยแท็ก <chunk topic=\"หัวข้อหลักของเนื้อหาส่วนนี้\"> [เนื้อหาในส่วนนี้] </chunk>\n\nกฎในการแบ่งข้อมูล:\n1. ห้ามแก้ไขคำหรือข้อความใดๆ ในเอกสารเด็ดขาด ให้ใช้ข้อความดั้งเดิมจาก OCR ทั้งหมด\n2. พยายามแบ่งส่วนตามขอบเขตเนื้อหาที่สมเหตุสมผล เช่น เมื่อขึ้นหัวข้อใหม่ หรือส่วนเนื้อความที่คนละประเด็นกัน\n3. แต่ละส่วนควรมีความยาวที่อ่านเข้าใจได้และไม่ยาวจนเกินไป\n4. ห้ามตอบข้อความบทนำหรือบทสรุปใดๆ นอกเหนือจากแท็ก <chunk> และข้อความภายในแท็ก\n\nข้อความเอกสาร OCR:\n{{ocr_text}}',
|
||||||
|
JSON_OBJECT(
|
||||||
|
'type', 'semantic_chunking',
|
||||||
|
'model', 'typhoon2.5-np-dms:latest',
|
||||||
|
'temperature', 0.1,
|
||||||
|
'top_p', 0.9,
|
||||||
|
'repeat_penalty', 1.1,
|
||||||
|
'keep_alive', -1
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
'Prompt สำหรับแบ่งข้อความจาก OCR เป็น Chunk ตามหัวข้อความหมายด้วย typhoon2.5 (ADR-035)',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
(
|
||||||
|
SELECT user_id
|
||||||
|
FROM users
|
||||||
|
WHERE username = 'superadmin'
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'rag_chunking'
|
||||||
|
AND version_number = 1
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE prompt_type = prompt_type;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Delta: เพิ่ม public_id และ context_config columns ใน ai_prompts
|
||||||
|
-- Date: 2026-06-06
|
||||||
|
-- Related ADR: ADR-019 (UUID strategy), ADR-029 (Dynamic Prompts)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- การเปลี่ยนแปลงโครงสร้างฐานข้อมูล (Schema changes)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
-- เพิ่ม public_id column (UUIDv7) สำหรับ ADR-019 compliance
|
||||||
|
ALTER TABLE ai_prompts
|
||||||
|
ADD COLUMN public_id UUID UNIQUE COMMENT 'Public UUID สำหรับ API (ADR-019)';
|
||||||
|
|
||||||
|
-- เพิ่ม context_config column สำหรับ ADR-029 context filtering
|
||||||
|
ALTER TABLE ai_prompts
|
||||||
|
ADD COLUMN context_config JSON NULL COMMENT 'Configuration สำหรับ Master Data context filtering (project/contract scope)';
|
||||||
|
|
||||||
|
-- สร้าง UUID สำหรับ records ที่มีอยู่แล้ว
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET public_id = UUID()
|
||||||
|
WHERE public_id IS NULL;
|
||||||
|
|
||||||
|
-- ตั้ง public_id เป็น NOT NULL หลังจาก populate ครบแล้ว
|
||||||
|
ALTER TABLE ai_prompts
|
||||||
|
MODIFY COLUMN public_id UUID NOT NULL;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
\#Model
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Model Name | Size | Base | PARAMETER | File |
|
||||||
|
|
||||||
|
\----------
|
||||||
|
|
||||||
|
| np-dms-ocr | 2.9GB | FROM scb10x/typhoon-ocr1.5-3b:latest | num\_ctx 8192 | np-dms-ocr-model.md |
|
||||||
|
|
||||||
|
| np-dms-typhoon2.5 | 3.6GB | FROM scb10x/typhoon2.5-qwen3-4b:latest | num\_ctx 8192 | np-dms-typhoon2.5.model.md |
|
||||||
|
|
||||||
|
| np-dms-gemma4-4eb | GB | FROM gemma4:e4b | num\_ctx 8192 | np-dms-gemma4-4eb.model.md |
|
||||||
|
|
||||||
|
| np-dms-openthaigpt1.5-7b | GB | FROM promptnow/openthaigpt1.5-7b-instruct-q4\_k\_m | num\_ctx 8192 |
|
||||||
|
|
||||||
|
| np-dms-openthaigpt1.5-14b | GB | FROM promptnow/openthaigpt1.5-14b-instruct-q4\_k\_m | num\_ctx 8192 |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
FROM scb10x/llama3.1-typhoon2-8b-instruct:latest
|
||||||
|
|
||||||
|
PARAMETER num_ctx 8192
|
||||||
|
PARAMETER num_predict 4096
|
||||||
|
PARAMETER temperature 0.4
|
||||||
|
PARAMETER top_k 40
|
||||||
|
PARAMETER top_p 0.9
|
||||||
|
PARAMETER repeat_penalty 1.15
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM scb10x/typhoon-ocr1.5-3b:latest
|
||||||
|
|
||||||
|
PARAMETER num_ctx 8192
|
||||||
|
PARAMETER num_predict 4096
|
||||||
|
PARAMETER temperature 0.1
|
||||||
|
PARAMETER top_p 0.1
|
||||||
|
PARAMETER repeat_penalty 1.1
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
FROM promptnow/openthaigpt1.5-14b-instruct-q4_k_m:latest
|
||||||
|
|
||||||
|
PARAMETER num_ctx 8192
|
||||||
|
PARAMETER num_predict 4096
|
||||||
|
PARAMETER temperature 0.4
|
||||||
|
PARAMETER top_k 40
|
||||||
|
PARAMETER top_p 0.9
|
||||||
|
PARAMETER repeat_penalty 1.15
|
||||||
|
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
FROM promptnow/openthaigpt1.5-7b-instruct-q4_k_m:latest
|
||||||
|
|
||||||
|
PARAMETER num_ctx 8192
|
||||||
|
PARAMETER num_predict 4096
|
||||||
|
PARAMETER temperature 0.4
|
||||||
|
PARAMETER top_k 40
|
||||||
|
PARAMETER top_p 0.9
|
||||||
|
PARAMETER repeat_penalty 1.15
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM scb10x/typhoon2.5-qwen3-4b:latest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETER num\_ctx 8192
|
||||||
|
PARAMETER num\_predict 4096
|
||||||
|
PARAMETER temperature 0.4
|
||||||
|
|
||||||
|
PARAMETER top\_k 40
|
||||||
|
PARAMETER top\_p 0.9
|
||||||
|
PARAMETER repeat\_penalty 1.15
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# Tesseract OCR Sidecar — HTTP API server สำหรับสกัดข้อความจาก PDF/Image
|
# Tesseract OCR Sidecar — HTTP API server สำหรับสกัดข้อความจาก PDF/Image
|
||||||
# รันบน Desk-5439 ตาม ADR-023A
|
# รันบน Desk-5439 ตาม ADR-023A
|
||||||
# Change Log:
|
# Change Log:
|
||||||
# - 2026-05-25: Initial Dockerfile สำหรับ PaddleOCR sidecar (port 8765)
|
# - 2026-05-25: Initial Dockerfile สำหรับ OCR sidecar (port 8765)
|
||||||
# - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า
|
# - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า
|
||||||
# - 2026-05-30: เพิ่ม system dependencies สำหรับ OpenCV (libsm6, libxext6, libxrender1, libfontconfig1, libx11-6)
|
# - 2026-05-30: เพิ่ม system dependencies สำหรับ OpenCV (libsm6, libxext6, libxrender1, libfontconfig1, libx11-6)
|
||||||
# - 2026-05-30: Typhoon OCR ใช้ httpx เรียก Ollama ผ่าน OLLAMA_API_URL (T009a, ADR-032)
|
# - 2026-05-30: Typhoon OCR ใช้ httpx เรียก Ollama ผ่าน OLLAMA_API_URL (T009a, ADR-032)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# File: specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py
|
# File: specs/04-Infrastructure-OPS/04-00-docker-compose\Desk-5439\ocr-sidecar\app.py
|
||||||
# Tesseract OCR HTTP Sidecar API — รับ POST /ocr แล้วคืนข้อความที่สกัดจาก PDF/Image
|
# Tesseract OCR HTTP Sidecar API — รับ POST /ocr แล้วคืนข้อความที่สกัดจาก PDF/Image
|
||||||
# ตาม ADR-023A: OCR auto-detect (PyMuPDF chars > 100 → Fast path, else Tesseract)
|
# ตาม ADR-023A: OCR auto-detect (PyMuPDF chars > 100 → Fast path, else Tesseract)
|
||||||
# Change Log:
|
# Change Log:
|
||||||
# - 2026-05-25: Initial FastAPI server สำหรับ PaddleOCR sidecar
|
# - 2026-05-25: Initial FastAPI server สำหรับ Tesseract OCR sidecar
|
||||||
# - 2026-05-30: เปลี่ยน lang='en' เป็น lang='ch' (CTJK) เพื่อรองรับภาษาไทย
|
# - 2026-05-30: เปลี่ยน lang='en' เป็น lang='ch' (CTJK) เพื่อรองรับภาษาไทย
|
||||||
# - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า
|
# - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า
|
||||||
# - 2026-05-30: เพิ่ม OpenCV preprocessing (threshold, denoise) และ DPI 300 เพื่อเพิ่มความแม่นยำ
|
# - 2026-05-30: เพิ่ม OpenCV preprocessing (threshold, denoise) และ DPI 300 เพื่อเพิ่มความแม่นยำ
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
# - 2026-06-04: รับค่า temperature/top_p/repeat_penalty จาก frontend sandbox ได้ (optional override)
|
# - 2026-06-04: รับค่า temperature/top_p/repeat_penalty จาก frontend sandbox ได้ (optional override)
|
||||||
# - 2026-06-04: แก้ bug prompt="" ทำให้ Ollama ไม่ generate — เปลี่ยนเป็น minimal trigger prompt
|
# - 2026-06-04: แก้ bug prompt="" ทำให้ Ollama ไม่ generate — เปลี่ยนเป็น minimal trigger prompt
|
||||||
# - 2026-06-04: เพิ่ม alias normalization สำหรับ engine name เก่า (typhoon-ocr1.5-3b → typhoon-np-dms-ocr)
|
# - 2026-06-04: เพิ่ม alias normalization สำหรับ engine name เก่า (typhoon-ocr1.5-3b → typhoon-np-dms-ocr)
|
||||||
# - 2026-06-04: เปลี่ยน keep_alive จาก 0 เป็น 300s เพื่อไม่ให้ unload model ระหว่าง sandbox session (ลด cold-start)
|
|
||||||
# - 2026-06-04: เพิ่ม TYPHOON_OCR_DPI=150 (แยกจาก Tesseract DPI=300) — ลด image token count 4x เพื่อเร่ง CPU inference (model >8GB ไม่พอ VRAM)
|
# - 2026-06-04: เพิ่ม TYPHOON_OCR_DPI=150 (แยกจาก Tesseract DPI=300) — ลด image token count 4x เพื่อเร่ง CPU inference (model >8GB ไม่พอ VRAM)
|
||||||
# - 2026-06-04: ส่ง color image (ไม่ผ่าน preprocess_image) ไปยัง Typhoon OCR — vision model ต้องการ color ไม่ใช่ binarized grayscale
|
# - 2026-06-04: ส่ง color image (ไม่ผ่าน preprocess_image) ไปยัง Typhoon OCR — vision model ต้องการ color ไม่ใช่ binarized grayscale
|
||||||
# - 2026-06-04: เพิ่ม num_gpu:99 ใน Ollama options เพื่อบังคับ GPU layers (แก้ device=CPU ทั้งที่ VRAM พอ)
|
# - 2026-06-04: เพิ่ม num_gpu:99 ใน Ollama options เพื่อบังคับ GPU layers (แก้ device=CPU ทั้งที่ VRAM พอ)
|
||||||
# - 2026-06-02: เพิ่มการตรวจสอบ API Key (X-API-Key Header) สำหรับ endpoints หลัก เพื่อความมั่นคงปลอดภัยตามข้อเสนอแนะ Code Review
|
# - 2026-06-02: เพิ่มการตรวจสอบ API Key (X-API-Key Header) สำหรับ endpoints หลัก เพื่อความมั่นคงปลอดภัยตามข้อเสนอแนะ Code Review
|
||||||
# - 2026-06-05: เพิ่ม Option 2 (aggressive preprocessing: deskew + Otsu threshold + morphology) และ Option 3 (smart post-processing: regex-based hallucination removal) เพื่อลด Tesseract noise/hallucination (T025)
|
# - 2026-06-05: เพิ่ม Option 2 (aggressive preprocessing: deskew + Otsu threshold + morphology) และ Option 3 (smart post-processing: regex-based hallucination removal) เพื่อลด Tesseract noise/hallucination (T025)
|
||||||
|
# - 2026-06-06: เปลี่ยน keep_alive จาก 300s เป็น 0 เพื่อ unload model ทันทีหลังเสร็จงาน (แก้ปัญหา VRAM ไม่พอเมื่อ typhoon2.5-np-dms load พร้อมกัน)
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -40,12 +40,32 @@ from fastapi.security.api_key import APIKeyHeader
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pythainlp.tokenize import word_tokenize
|
from pythainlp.tokenize import word_tokenize
|
||||||
from pythainlp.util import normalize as thai_normalize
|
from pythainlp.util import normalize as thai_normalize
|
||||||
|
from FlagEmbedding import BGEM3FlagModel, FlagReranker
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("ocr-sidecar")
|
logger = logging.getLogger("ocr-sidecar")
|
||||||
|
|
||||||
app = FastAPI(title="Tesseract OCR Sidecar", version="1.0.0")
|
app = FastAPI(title="Tesseract OCR Sidecar", version="1.0.0")
|
||||||
|
|
||||||
|
# Initialize BGE-M3 and Reranker singletons
|
||||||
|
bge_model = None
|
||||||
|
reranker = None
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def load_bge_models():
|
||||||
|
global bge_model, reranker
|
||||||
|
logger.info("Loading BGE-M3 and Reranker models on CPU RAM...")
|
||||||
|
try:
|
||||||
|
# BGE-M3: BAAI/bge-m3, use_fp16=False for CPU
|
||||||
|
bge_model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=False)
|
||||||
|
# Reranker: BAAI/bge-reranker-large, use_fp16=False for CPU
|
||||||
|
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=False)
|
||||||
|
logger.info("BGE-M3 and Reranker models loaded successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load BGE models: {e}")
|
||||||
|
|
||||||
|
|
||||||
# กำหนดค่าโทเค็นความปลอดภัยของ Sidecar ตามข้อเสนอแนะในการรักษาความมั่นคงปลอดภัย
|
# กำหนดค่าโทเค็นความปลอดภัยของ Sidecar ตามข้อเสนอแนะในการรักษาความมั่นคงปลอดภัย
|
||||||
OCR_SIDECAR_API_KEY = os.getenv("OCR_SIDECAR_API_KEY", "lcbp3-dms-ocr-sidecar-secure-token-2026")
|
OCR_SIDECAR_API_KEY = os.getenv("OCR_SIDECAR_API_KEY", "lcbp3-dms-ocr-sidecar-secure-token-2026")
|
||||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
@@ -365,7 +385,7 @@ Extract all text from this image.""",
|
|||||||
"images": [image_base64],
|
"images": [image_base64],
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": options,
|
"options": options,
|
||||||
"keep_alive": 300, # คง model ไว้ใน VRAM/RAM 5 นาที เพื่อลด cold-start ระหว่าง sandbox session
|
"keep_alive": 0, # Unload model ทันทีหลังเสร็จงานเพื่อคืน VRAM ให้ typhoon2.5-np-dms ใช้งานได้
|
||||||
}
|
}
|
||||||
with httpx.Client(timeout=TYPHOON_OCR_TIMEOUT) as client:
|
with httpx.Client(timeout=TYPHOON_OCR_TIMEOUT) as client:
|
||||||
response = client.post(f"{OLLAMA_API_URL}/api/generate", json=payload)
|
response = client.post(f"{OLLAMA_API_URL}/api/generate", json=payload)
|
||||||
@@ -445,6 +465,71 @@ def normalize_text(req: NormalizeRequest):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Thai normalize failed, returning raw text: {e}")
|
logger.warning(f"Thai normalize failed, returning raw text: {e}")
|
||||||
return NormalizeResponse(normalized=req.text)
|
return NormalizeResponse(normalized=req.text)
|
||||||
|
class EmbedRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class EmbedResponse(BaseModel):
|
||||||
|
dense: list[float]
|
||||||
|
sparse: dict
|
||||||
|
|
||||||
|
class RerankRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
chunks: list[str]
|
||||||
|
|
||||||
|
class RerankResponse(BaseModel):
|
||||||
|
scores: list[float]
|
||||||
|
ranked_indices: list[int]
|
||||||
|
|
||||||
|
@app.post("/embed", response_model=EmbedResponse, dependencies=[Depends(get_api_key)])
|
||||||
|
def embed_text(req: EmbedRequest):
|
||||||
|
"""BGE-M3 embedding generator (Dense + Sparse)"""
|
||||||
|
if bge_model is None:
|
||||||
|
raise HTTPException(status_code=503, detail="BGE-M3 model not loaded")
|
||||||
|
try:
|
||||||
|
output = bge_model.encode([req.text], return_dense=True, return_sparse=True)
|
||||||
|
dense_vector = [float(x) for x in output['dense_vecs'][0]]
|
||||||
|
lexical_dict = output['lexical_weights'][0]
|
||||||
|
|
||||||
|
indices = []
|
||||||
|
values = []
|
||||||
|
for token_id, weight in lexical_dict.items():
|
||||||
|
indices.append(int(token_id))
|
||||||
|
values.append(float(weight))
|
||||||
|
|
||||||
|
return EmbedResponse(
|
||||||
|
dense=dense_vector,
|
||||||
|
sparse={"indices": indices, "values": values}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Embedding generation failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}")
|
||||||
|
|
||||||
|
@app.post("/rerank", response_model=RerankResponse, dependencies=[Depends(get_api_key)])
|
||||||
|
def rerank_chunks(req: RerankRequest):
|
||||||
|
"""BGE-Reranker-Large chunk re-ranker"""
|
||||||
|
if reranker is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Reranker model not loaded")
|
||||||
|
if not req.chunks:
|
||||||
|
return RerankResponse(scores=[], ranked_indices=[])
|
||||||
|
try:
|
||||||
|
pairs = [[req.query, chunk] for chunk in req.chunks]
|
||||||
|
scores = reranker.compute_score(pairs)
|
||||||
|
if isinstance(scores, float):
|
||||||
|
scores = [scores]
|
||||||
|
else:
|
||||||
|
scores = [float(s) for s in scores]
|
||||||
|
|
||||||
|
indexed_scores = list(enumerate(scores))
|
||||||
|
indexed_scores.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
ranked_indices = [idx for idx, _ in indexed_scores]
|
||||||
|
|
||||||
|
return RerankResponse(
|
||||||
|
scores=scores,
|
||||||
|
ranked_indices=ranked_indices
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reranking failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Reranking failed: {str(e)}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
@@ -1,432 +0,0 @@
|
|||||||
# File: specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py
|
|
||||||
# Tesseract OCR HTTP Sidecar API — รับ POST /ocr แล้วคืนข้อความที่สกัดจาก PDF/Image
|
|
||||||
# ตาม ADR-023A: OCR auto-detect (PyMuPDF chars > 100 → Fast path, else Tesseract)
|
|
||||||
# Change Log:
|
|
||||||
# - 2026-05-25: Initial FastAPI server สำหรับ PaddleOCR sidecar
|
|
||||||
# - 2026-05-30: เปลี่ยน lang='en' เป็น lang='ch' (CTJK) เพื่อรองรับภาษาไทย
|
|
||||||
# - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า
|
|
||||||
# - 2026-05-30: เพิ่ม OpenCV preprocessing (threshold, denoise) และ DPI 300 เพื่อเพิ่มความแม่นยำ
|
|
||||||
# - 2026-06-01: เพิ่ม POST /ocr-upload รับ multipart file โดยตรง ไม่ต้องพึ่ง shared volume mount
|
|
||||||
# - 2026-06-01: เปลี่ยน TYPHOON_OCR_MODEL default เป็น scb10x/typhoon-ocr1.5-3b
|
|
||||||
# - 2026-06-02: เพิ่มตัวเลือกสลับโมเดลใน process_with_typhoon_ocr ตามพารามิเตอร์ engine และตั้ง engineUsed ให้ตรงตามจริง (T015, ADR-033)
|
|
||||||
# - 2026-06-04: ADR-034 — เพิ่ม typhoon-np-dms-ocr เป็น canonical engine key; default TYPHOON_OCR_MODEL เปวน typhoon-np-dms-ocr:latest; alias โมเดลเก่ายังคงไว้
|
|
||||||
# - 2026-06-04: ให้ SYSTEM ใน Modelfile ทำงานแทน — ลบ prompt ซ้าซ้อน; sync options ให้ตรงกับ Modelfile (temperature 0.1, top_p 0.1, repeat_penalty 1.1)
|
|
||||||
# - 2026-06-04: รับค่า temperature/top_p/repeat_penalty จาก frontend sandbox ได้ (optional override)
|
|
||||||
# - 2026-06-04: แก้ bug prompt="" ทำให้ Ollama ไม่ generate — เปลี่ยนเป็น minimal trigger prompt
|
|
||||||
# - 2026-06-04: เพิ่ม alias normalization สำหรับ engine name เก่า (typhoon-ocr1.5-3b → typhoon-np-dms-ocr)
|
|
||||||
# - 2026-06-04: เปลี่ยน keep_alive จาก 0 เป็น 300s เพื่อไม่ให้ unload model ระหว่าง sandbox session (ลด cold-start)
|
|
||||||
# - 2026-06-04: เพิ่ม TYPHOON_OCR_DPI=150 (แยกจาก Tesseract DPI=300) — ลด image token count 4x เพื่อเร่ง CPU inference (model >8GB ไม่พอ VRAM)
|
|
||||||
# - 2026-06-04: ส่ง color image (ไม่ผ่าน preprocess_image) ไปยัง Typhoon OCR — vision model ต้องการ color ไม่ใช่ binarized grayscale
|
|
||||||
# - 2026-06-04: เพิ่ม num_gpu:99 ใน Ollama options เพื่อบังคับ GPU layers (แก้ device=CPU ทั้งที่ VRAM พอ)
|
|
||||||
# - 2026-06-02: เพิ่มการตรวจสอบ API Key (X-API-Key Header) สำหรับ endpoints หลัก เพื่อความมั่นคงปลอดภัยตามข้อเสนอแนะ Code Review
|
|
||||||
# - 2026-06-05: เพิ่ม Option 2 (aggressive preprocessing: deskew + Otsu threshold + morphology) และ Option 3 (smart post-processing: regex-based hallucination removal) เพื่อลด Tesseract noise/hallucination (T025)
|
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import base64
|
|
||||||
import fitz # PyMuPDF
|
|
||||||
import httpx
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
from PIL import Image
|
|
||||||
import pytesseract
|
|
||||||
import io
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Depends, Security, status
|
|
||||||
from fastapi.security.api_key import APIKeyHeader
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from pythainlp.tokenize import word_tokenize
|
|
||||||
from pythainlp.util import normalize as thai_normalize
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger("ocr-sidecar")
|
|
||||||
|
|
||||||
app = FastAPI(title="Tesseract OCR Sidecar", version="1.0.0")
|
|
||||||
|
|
||||||
# กำหนดค่าโทเค็นความปลอดภัยของ Sidecar ตามข้อเสนอแนะในการรักษาความมั่นคงปลอดภัย
|
|
||||||
OCR_SIDECAR_API_KEY = os.getenv("OCR_SIDECAR_API_KEY", "lcbp3-dms-ocr-sidecar-secure-token-2026")
|
|
||||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
||||||
async def get_api_key(api_key: str = Security(api_key_header)):
|
|
||||||
if not api_key:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API Key in request headers (X-API-Key)")
|
|
||||||
if api_key != OCR_SIDECAR_API_KEY:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API Key")
|
|
||||||
return api_key
|
|
||||||
|
|
||||||
# อ่านค่า config จาก environment
|
|
||||||
OCR_CHAR_THRESHOLD = int(os.getenv("OCR_CHAR_THRESHOLD", "100"))
|
|
||||||
MAX_PAGES = int(os.getenv("OCR_MAX_PAGES", "0")) # 0 = ทุกหน้า
|
|
||||||
OCR_LANG = os.getenv("OCR_LANG", "tha+eng") # Tesseract language code (tha+eng = Thai + English)
|
|
||||||
OLLAMA_API_URL = os.getenv("OLLAMA_API_URL", "http://host.docker.internal:11434")
|
|
||||||
TYPHOON_OCR_MODEL = os.getenv("TYPHOON_OCR_MODEL", "typhoon-np-dms-ocr:latest")
|
|
||||||
TYPHOON_OCR_TIMEOUT = int(os.getenv("TYPHOON_OCR_TIMEOUT", "360")) # รองรับ cold-start ~65s + inference ~30s/page
|
|
||||||
# DPI สำหรับ Typhoon OCR — ต่ำกว่า Tesseract เพราะ vision model ใช้ image patches (150 DPI ลด token ~4x)
|
|
||||||
TYPHOON_OCR_DPI = int(os.getenv("TYPHOON_OCR_DPI", "150"))
|
|
||||||
# PSM mode: 3 (default, fully automatic) หรือ 6 (assume single column, ลด noise)
|
|
||||||
TESSERACT_PSM = os.getenv("TESSERACT_PSM", "3")
|
|
||||||
# PSM 3 = Fully automatic page segmentation (เหมาะกับเอกสารที่มี layout หลายส่วน เช่น วันที่/เลขที่)
|
|
||||||
# PSM 6 = Assume single column of text (ลด hallucination จาก noise)
|
|
||||||
# OEM 1 = LSTM only (ดีกว่า legacy engine)
|
|
||||||
TESSERACT_CONFIG = f"--psm {TESSERACT_PSM} --oem 1"
|
|
||||||
# Crop margin: ตัด header/afooter (บน 5%, ล่าง 2%)
|
|
||||||
CROP_TOP_RATIO = 0.05
|
|
||||||
CROP_BOTTOM_RATIO = 0.02
|
|
||||||
# Enable aggressive preprocessing (Option 2) สำหรับ Tesseract
|
|
||||||
USE_AGGRESSIVE_PREPROCESSING = os.getenv("TESSERACT_AGGRESSIVE_PREPROCESS", "true").lower() == "true"
|
|
||||||
# Enable smart post-processing (Option 3) สำหรับลบ hallucination
|
|
||||||
USE_SMART_CLEANING = os.getenv("TESSERACT_SMART_CLEAN", "true").lower() == "true"
|
|
||||||
|
|
||||||
logger.info(f"Tesseract OCR Sidecar initialized (lang={OCR_LANG}, config={TESSERACT_CONFIG}, aggressive={USE_AGGRESSIVE_PREPROCESSING}, smart_clean={USE_SMART_CLEANING})")
|
|
||||||
|
|
||||||
def filter_ocr_noise(text: str) -> str:
|
|
||||||
"""Filter ขยะ OCR เช่น บรรทัดสั้น/สัญลักษณ์ที่ไม่มีความหมาย"""
|
|
||||||
lines = text.split("\n")
|
|
||||||
filtered_lines = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ลบบรรทัดที่สั้นเกินไป (น้อยกว่า 3 ตัวอักษร)
|
|
||||||
if len(line) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ลบบรรทัดที่มีแต่สัญลักษณ์/ตัวเลขโดดๆ (ไม่มีตัวอักษรภาษาไทย/อังกฤษ)
|
|
||||||
thai_chars = sum(1 for c in line if '\u0E00' <= c <= '\u0E7F')
|
|
||||||
english_chars = sum(1 for c in line if c.isalpha() and c.isascii())
|
|
||||||
total_chars = len(line)
|
|
||||||
|
|
||||||
# ถ้ามีตัวอักษรภาษาไทยหรืออังกฤษน้อยกว่า 20% ของบรรทัด ให้ถือว่าเป็นขยะ
|
|
||||||
if total_chars > 0 and (thai_chars + english_chars) / total_chars < 0.2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
filtered_lines.append(line)
|
|
||||||
|
|
||||||
return "\n".join(filtered_lines)
|
|
||||||
|
|
||||||
|
|
||||||
def crop_header_footer(pil_image: Image.Image, top_ratio: float = 0.10, bottom_ratio: float = 0.10) -> Image.Image:
|
|
||||||
"""Crop header/footer ออกจาก image เพื่อลบข้อความที่ไม่จำเป็น"""
|
|
||||||
width, height = pil_image.size
|
|
||||||
top_crop = int(height * top_ratio)
|
|
||||||
bottom_crop = int(height * bottom_ratio)
|
|
||||||
|
|
||||||
# Crop: (left, top, right, bottom)
|
|
||||||
cropped = pil_image.crop((0, top_crop, width, height - bottom_crop))
|
|
||||||
return cropped
|
|
||||||
|
|
||||||
def preprocess_image(pil_image: Image.Image) -> Image.Image:
|
|
||||||
"""Preprocess image ด้วย OpenCV เพื่อเพิ่มความแม่นยำ OCR (แบบธรรมชาติ)"""
|
|
||||||
# แปลง PIL Image เป็น numpy array (OpenCV format)
|
|
||||||
img_array = np.array(pil_image)
|
|
||||||
|
|
||||||
# แปลงเป็น grayscale
|
|
||||||
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
|
||||||
|
|
||||||
# Denoise ด้วย median blur (เบางๆ เพื่อลบ noise แต่ไม่ทำลายตัวอักษร)
|
|
||||||
denoised = cv2.medianBlur(gray, 3)
|
|
||||||
|
|
||||||
# ใช้ grayscale เท่านั้น (ไม่ใช้ adaptive threshold เพราะทำให้ตัวอักษรเสียรูป)
|
|
||||||
# แปลงกลับเป็น PIL Image
|
|
||||||
return Image.fromarray(denoised)
|
|
||||||
|
|
||||||
def preprocess_image_aggressive(pil_image: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Aggressive preprocessing (Option 2) — ลด hallucination โดย:
|
|
||||||
1. Deskew ถ้าหน้าเอียง
|
|
||||||
2. Denoise ด้วย bilateral filter
|
|
||||||
3. Otsu adaptive threshold
|
|
||||||
4. Morphological operations
|
|
||||||
"""
|
|
||||||
img_array = np.array(pil_image)
|
|
||||||
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
|
||||||
|
|
||||||
# 1. Deskew ถ้าหน้าเอียง (detect angle จาก Canny edges + Hough lines)
|
|
||||||
try:
|
|
||||||
edges = cv2.Canny(gray, 100, 200)
|
|
||||||
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 100, minLineLength=100, maxLineGap=10)
|
|
||||||
if lines is not None and len(lines) > 0:
|
|
||||||
angles = [np.arctan2(y2-y1, x2-x1) for x1,y1,x2,y2 in lines[:min(10, len(lines))]]
|
|
||||||
angle = np.median(angles) * 180 / np.pi
|
|
||||||
if abs(angle) > 0.5: # มุมเอียงน้อย ≥ 0.5 องศา
|
|
||||||
h, w = gray.shape
|
|
||||||
M = cv2.getRotationMatrix2D((w/2, h/2), angle, 1.0)
|
|
||||||
gray = cv2.warpAffine(gray, M, (w, h), borderMode=cv2.BORDER_REFLECT)
|
|
||||||
logger.info(f"[PREPROCESS] Deskewed {angle:.1f}°")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[PREPROCESS] Deskew failed: {e}")
|
|
||||||
|
|
||||||
# 2. Denoise — median blur + bilateral filter
|
|
||||||
denoised = cv2.medianBlur(gray, 3)
|
|
||||||
denoised = cv2.bilateralFilter(denoised, 9, 75, 75)
|
|
||||||
|
|
||||||
# 3. Otsu threshold (adaptive, ไม่ fixed value)
|
|
||||||
_, thresh = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
||||||
|
|
||||||
# 4. Morphological operations — ลบ line noise ขนาดเล็ก (ต้าน speckle artifacts)
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2))
|
|
||||||
morph = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) # ลบ small white noise
|
|
||||||
morph = cv2.morphologyEx(morph, cv2.MORPH_CLOSE, kernel) # ลบ small black hole
|
|
||||||
|
|
||||||
logger.info(f"[PREPROCESS] Aggressive: Otsu threshold + morphology applied")
|
|
||||||
return Image.fromarray(morph)
|
|
||||||
|
|
||||||
def clean_ocr_output(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Smart post-processing (Option 3) — ลบ Tesseract hallucination โดย:
|
|
||||||
1. ลบ line ที่เป็นแค่สัญลักษณ์ repeated
|
|
||||||
2. ลบ line ที่เป็นแค่สัญลักษณ์แปลก
|
|
||||||
3. ลบ line ที่ซ้ำตัวอักษรเดียว (artifact noise)
|
|
||||||
"""
|
|
||||||
lines = text.split("\n")
|
|
||||||
cleaned = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ✗ ลบ line ที่เป็นแค่สัญลักษณ์/punctuation เดี่ยวๆ ไม่มีตัวอักษร
|
|
||||||
alphanumeric_part = re.sub(r'[^\w\u0E00-\u0E7F]', '', line)
|
|
||||||
if len(alphanumeric_part) < 2:
|
|
||||||
logger.debug(f"[CLEAN] Reject (no alphanum): {line[:50]}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ✗ ลบ line ที่เป็น repeated pattern — ถ้า unique char ≤ 20% (e.g., "-----", ">>>>>>>")
|
|
||||||
unique_chars = len(set(line))
|
|
||||||
if unique_chars < max(2, len(line) // 5):
|
|
||||||
logger.debug(f"[CLEAN] Reject (repeated pattern): {line[:50]}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ✗ ลบ line ที่เป็นสัญลักษณ์แปลก (< 20% Thai/English alphanumeric)
|
|
||||||
thai_chars = sum(1 for c in line if '\u0E00' <= c <= '\u0E7F')
|
|
||||||
eng_chars = sum(1 for c in line if c.isascii() and c.isalnum())
|
|
||||||
if len(line) > 0 and (thai_chars + eng_chars) / len(line) < 0.2:
|
|
||||||
logger.debug(f"[CLEAN] Reject (low language content): {line[:50]}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ✓ ปล่อยผ่าน
|
|
||||||
cleaned.append(line)
|
|
||||||
|
|
||||||
result = "\n".join(cleaned)
|
|
||||||
logger.info(f"[CLEAN] Input {len(lines)} lines → {len(cleaned)} lines")
|
|
||||||
return result
|
|
||||||
|
|
||||||
class OcrRequest(BaseModel):
|
|
||||||
pdfPath: str
|
|
||||||
maxPages: Optional[int] = None
|
|
||||||
engine: Optional[str] = None
|
|
||||||
|
|
||||||
class OcrResponse(BaseModel):
|
|
||||||
text: str
|
|
||||||
ocrUsed: bool
|
|
||||||
pageCount: int
|
|
||||||
charCount: int
|
|
||||||
engineUsed: str
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health():
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"engines": ["tesseract", "typhoon-np-dms-ocr"],
|
|
||||||
"typhoonModel": TYPHOON_OCR_MODEL,
|
|
||||||
"tesseractConfig": TESSERACT_CONFIG,
|
|
||||||
"aggressivePreprocess": USE_AGGRESSIVE_PREPROCESSING,
|
|
||||||
"smartCleaning": USE_SMART_CLEANING,
|
|
||||||
}
|
|
||||||
|
|
||||||
# alias map สำหรับ engine name เก่า → canonical name
|
|
||||||
_ENGINE_ALIASES: dict[str, str] = {
|
|
||||||
"typhoon-ocr1.5-3b": "typhoon-np-dms-ocr",
|
|
||||||
"typhoon-ocr-3b": "typhoon-np-dms-ocr",
|
|
||||||
"typhoon_ocr": "typhoon-np-dms-ocr",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, typhoon_options: dict = {}) -> OcrResponse:
|
|
||||||
"""ประมวลผล fitz.Document ด้วย engine ที่เลือก — shared logic สำหรับ /ocr และ /ocr-upload"""
|
|
||||||
selected_engine = _ENGINE_ALIASES.get(selected_engine, selected_engine)
|
|
||||||
pages_to_process = list(range(min(len(doc), max_pages) if max_pages > 0 else len(doc)))
|
|
||||||
page_count = len(pages_to_process)
|
|
||||||
|
|
||||||
fast_text_parts = []
|
|
||||||
total_chars = 0
|
|
||||||
if selected_engine == "auto":
|
|
||||||
for i in pages_to_process:
|
|
||||||
page = doc[i]
|
|
||||||
fast_text_parts.append(page.get_text())
|
|
||||||
fast_text = "\n".join(fast_text_parts).strip()
|
|
||||||
total_chars = len(fast_text)
|
|
||||||
if total_chars > OCR_CHAR_THRESHOLD:
|
|
||||||
logger.info(f"Fast path: {total_chars} chars extracted")
|
|
||||||
return OcrResponse(
|
|
||||||
text=fast_text,
|
|
||||||
ocrUsed=False,
|
|
||||||
pageCount=page_count,
|
|
||||||
charCount=total_chars,
|
|
||||||
engineUsed="fast-path",
|
|
||||||
)
|
|
||||||
|
|
||||||
if selected_engine == "typhoon-np-dms-ocr":
|
|
||||||
typhoon_text_parts = []
|
|
||||||
for i in pages_to_process:
|
|
||||||
page = doc[i]
|
|
||||||
pix = page.get_pixmap(dpi=TYPHOON_OCR_DPI)
|
|
||||||
img_bytes = pix.tobytes("png")
|
|
||||||
img = Image.open(io.BytesIO(img_bytes))
|
|
||||||
# ส่ง color image ตรงๆ — Typhoon OCR (vision model) ต้องการ color ไม่ใช่ grayscale binarized
|
|
||||||
cropped_img = crop_header_footer(img, CROP_TOP_RATIO, CROP_BOTTOM_RATIO)
|
|
||||||
typhoon_text_parts.append(process_with_typhoon_ocr(cropped_img, typhoon_options))
|
|
||||||
typhoon_text = filter_ocr_noise("\n".join(typhoon_text_parts).strip())
|
|
||||||
return OcrResponse(
|
|
||||||
text=typhoon_text,
|
|
||||||
ocrUsed=True,
|
|
||||||
pageCount=page_count,
|
|
||||||
charCount=len(typhoon_text),
|
|
||||||
engineUsed=selected_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Slow path (Tesseract): {total_chars} chars too few")
|
|
||||||
ocr_text_parts = []
|
|
||||||
for i in pages_to_process:
|
|
||||||
page = doc[i]
|
|
||||||
pix = page.get_pixmap(dpi=300)
|
|
||||||
img_bytes = pix.tobytes("png")
|
|
||||||
img = Image.open(io.BytesIO(img_bytes))
|
|
||||||
cropped_img = crop_header_footer(img, CROP_TOP_RATIO, CROP_BOTTOM_RATIO)
|
|
||||||
|
|
||||||
# Option 2: Choose preprocessing strategy
|
|
||||||
if USE_AGGRESSIVE_PREPROCESSING:
|
|
||||||
processed_img = preprocess_image_aggressive(cropped_img)
|
|
||||||
else:
|
|
||||||
processed_img = preprocess_image(cropped_img)
|
|
||||||
|
|
||||||
text = pytesseract.image_to_string(processed_img, lang=OCR_LANG, config=TESSERACT_CONFIG)
|
|
||||||
ocr_text_parts.append(text.strip())
|
|
||||||
|
|
||||||
ocr_text = "\n".join(ocr_text_parts).strip()
|
|
||||||
|
|
||||||
# Option 3: Apply smart post-processing
|
|
||||||
if USE_SMART_CLEANING:
|
|
||||||
ocr_text = clean_ocr_output(ocr_text)
|
|
||||||
else:
|
|
||||||
ocr_text = filter_ocr_noise(ocr_text)
|
|
||||||
|
|
||||||
logger.info(f"Tesseract extracted {len(ocr_text)} chars")
|
|
||||||
return OcrResponse(
|
|
||||||
text=ocr_text,
|
|
||||||
ocrUsed=True,
|
|
||||||
pageCount=page_count,
|
|
||||||
charCount=len(ocr_text),
|
|
||||||
engineUsed="tesseract",
|
|
||||||
)
|
|
||||||
|
|
||||||
def process_with_typhoon_ocr(pil_image: Image.Image, options_override: dict = {}) -> str:
|
|
||||||
"""เรียก Typhoon OCR ผ่าน Ollama — ใช้ SYSTEM ใน Modelfile เป็น instruction หลัก; options_override ยัง override ค่า Modelfile ได้"""
|
|
||||||
model_name = TYPHOON_OCR_MODEL
|
|
||||||
img_buffer = io.BytesIO()
|
|
||||||
pil_image.save(img_buffer, format="PNG")
|
|
||||||
image_base64 = base64.b64encode(img_buffer.getvalue()).decode("utf-8")
|
|
||||||
# ค่า default ตาม Modelfile; frontend override ได้บางส่วนหรือทั้งหมด
|
|
||||||
options = {
|
|
||||||
"temperature": 0.1,
|
|
||||||
"top_p": 0.1,
|
|
||||||
"repeat_penalty": 1.1,
|
|
||||||
"num_gpu": 99, # บังคับ GPU layers สูงสุด — ป้องกัน Ollama fallback ไป CPU โดยไม่จำเป็น
|
|
||||||
"num_ctx": 4096, # image tokens ~2772 → ต้องการ context > 2048; 4096 รองรับ image + output โดยไม่ truncate
|
|
||||||
**options_override,
|
|
||||||
}
|
|
||||||
payload = {
|
|
||||||
"model": model_name,
|
|
||||||
"prompt": "", # SYSTEM instruction ใน Modelfile จัดการทั้งหมด
|
|
||||||
"images": [image_base64],
|
|
||||||
"stream": False,
|
|
||||||
"options": options,
|
|
||||||
"keep_alive": 300, # คง model ไว้ใน VRAM/RAM 5 นาที เพื่อลด cold-start ระหว่าง sandbox session
|
|
||||||
}
|
|
||||||
with httpx.Client(timeout=TYPHOON_OCR_TIMEOUT) as client:
|
|
||||||
response = client.post(f"{OLLAMA_API_URL}/api/generate", json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
result_text = str(data.get("response", "")).strip()
|
|
||||||
logger.info(
|
|
||||||
f"[DIAG] Ollama response — model={model_name} "
|
|
||||||
f"textLen={len(result_text)} "
|
|
||||||
f"done={data.get('done')} "
|
|
||||||
f"done_reason={data.get('done_reason')} "
|
|
||||||
f"eval_count={data.get('eval_count', 0)}"
|
|
||||||
)
|
|
||||||
if not result_text:
|
|
||||||
logger.warning(
|
|
||||||
f"[DIAG] Ollama returned empty response — full response keys: {list(data.keys())}"
|
|
||||||
)
|
|
||||||
return result_text
|
|
||||||
|
|
||||||
@app.post("/ocr", response_model=OcrResponse, dependencies=[Depends(get_api_key)])
|
|
||||||
def ocr_extract(req: OcrRequest):
|
|
||||||
"""OCR จาก path (legacy — ใช้เมื่อ sidecar และ backend เข้าถึง storage เดียวกัน)"""
|
|
||||||
pdf_path = Path(req.pdfPath)
|
|
||||||
if not pdf_path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail=f"ไม่พบไฟล์: {req.pdfPath}")
|
|
||||||
selected_engine = (req.engine or "auto").strip().lower()
|
|
||||||
max_pages = req.maxPages or MAX_PAGES
|
|
||||||
try:
|
|
||||||
doc = fitz.open(str(pdf_path))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=422, detail=f"เปิดไฟล์ PDF ล้มเหลว: {e}")
|
|
||||||
return _process_pdf_doc(doc, selected_engine, max_pages)
|
|
||||||
|
|
||||||
@app.post("/ocr-upload", response_model=OcrResponse, dependencies=[Depends(get_api_key)])
|
|
||||||
def ocr_upload(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
engine: str = Form(default="auto"),
|
|
||||||
maxPages: int = Form(default=0),
|
|
||||||
temperature: Optional[float] = Form(default=None),
|
|
||||||
topP: Optional[float] = Form(default=None),
|
|
||||||
repeatPenalty: Optional[float] = Form(default=None),
|
|
||||||
):
|
|
||||||
"""OCR จาก multipart file upload — ไม่ต้องการ shared volume mount"""
|
|
||||||
selected_engine = engine.strip().lower()
|
|
||||||
max_pages = maxPages or MAX_PAGES
|
|
||||||
# รวม options override สำหรับ Typhoon OCR (ถ้า frontend ส่งมา)
|
|
||||||
typhoon_options: dict = {}
|
|
||||||
if temperature is not None:
|
|
||||||
typhoon_options["temperature"] = temperature
|
|
||||||
if topP is not None:
|
|
||||||
typhoon_options["top_p"] = topP
|
|
||||||
if repeatPenalty is not None:
|
|
||||||
typhoon_options["repeat_penalty"] = repeatPenalty
|
|
||||||
pdf_bytes = file.file.read()
|
|
||||||
try:
|
|
||||||
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=422, detail=f"เปิดไฟล์ PDF ล้มเหลว: {e}")
|
|
||||||
logger.info(f"OCR upload: {file.filename} engine={selected_engine} options={typhoon_options or 'modelfile-defaults'}")
|
|
||||||
return _process_pdf_doc(doc, selected_engine, max_pages, typhoon_options)
|
|
||||||
|
|
||||||
class NormalizeRequest(BaseModel):
|
|
||||||
text: str
|
|
||||||
|
|
||||||
class NormalizeResponse(BaseModel):
|
|
||||||
normalized: str
|
|
||||||
|
|
||||||
@app.post("/normalize", response_model=NormalizeResponse, dependencies=[Depends(get_api_key)])
|
|
||||||
def normalize_text(req: NormalizeRequest):
|
|
||||||
"""Normalize Thai text ด้วย PyThaiNLP สำหรับ rag-thai-preprocess queue"""
|
|
||||||
try:
|
|
||||||
# normalize unicode + ตัดคำแล้วต่อกลับด้วย space เพื่อ embedding
|
|
||||||
normalized = thai_normalize(req.text)
|
|
||||||
tokens = word_tokenize(normalized, engine="newmm", keep_whitespace=False)
|
|
||||||
result = " ".join(tokens)
|
|
||||||
return NormalizeResponse(normalized=result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Thai normalize failed, returning raw text: {e}")
|
|
||||||
return NormalizeResponse(normalized=req.text)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
port = int(os.getenv("OCR_PORT", "8765"))
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# File: specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/docker-compose.yml
|
# File: specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/docker-compose.yml
|
||||||
# Tesseract OCR Sidecar — รันบน Desk-5439 (AI Isolation Host) ตาม ADR-023A
|
# Tesseract OCR Sidecar — รันบน Desk-5439 (AI Isolation Host) ตาม ADR-023A
|
||||||
# Change Log:
|
# Change Log:
|
||||||
# - 2026-05-25: Initial compose file สำหรับ PaddleOCR HTTP sidecar
|
# - 2026-05-25: Initial compose file สำหรับ Tesseract OCR HTTP sidecar
|
||||||
# - 2026-05-25: แก้ volumes ให้ถูกต้องสำหรับ Windows + Docker Desktop
|
# - 2026-05-25: แก้ volumes ให้ถูกต้องสำหรับ Windows + Docker Desktop
|
||||||
# - 2026-05-30: เพิ่ม OCR_LANG=tha+eng (Tesseract Thai + English)
|
# - 2026-05-30: เพิ่ม OCR_LANG=tha+eng (Tesseract Thai + English)
|
||||||
# - 2026-05-30: เพิ่ม Typhoon OCR environment variables (T009b, ADR-032)
|
# - 2026-05-30: เพิ่ม Typhoon OCR environment variables (T009b, ADR-032)
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# GPU Monitor for LCBP3 DMS
|
||||||
|
# Usage: powershell -ExecutionPolicy Bypass -File gpu-monitor.ps1
|
||||||
|
|
||||||
|
while($true) {
|
||||||
|
Clear-Host
|
||||||
|
Write-Host "=== GPU Monitor ===" -ForegroundColor Cyan
|
||||||
|
Write-Host "$(Get-Date -Format 'HH:mm:ss')" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
nvidia-smi --query-gpu=name,memory.used,memory.total,temperature.gpu,utilization.gpu,power.draw,power.limit --format=csv,noheader | ForEach-Object {
|
||||||
|
$parts = $_ -split ','
|
||||||
|
Write-Host "GPU: $($parts[0].Trim())" -ForegroundColor Cyan
|
||||||
|
Write-Host "Memory: $($parts[1].Trim()) / $($parts[2].Trim())" -ForegroundColor Yellow
|
||||||
|
Write-Host "Temp: $($parts[3].Trim()) | Util: $($parts[4].Trim()) | Power: $($parts[5].Trim()) / $($parts[6].Trim())" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Ctrl+C to stop" -ForegroundColor Gray
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
+2
@@ -14,3 +14,5 @@ pythainlp==5.0.4
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
Pillow==10.0.0
|
Pillow==10.0.0
|
||||||
opencv-python==4.8.1.78
|
opencv-python==4.8.1.78
|
||||||
|
FlagEmbedding>=1.2.0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM scb10x/typhoon2.5-qwen3-4b:latest
|
||||||
|
|
||||||
|
SYSTEM """You are an AI system specialized in analyzing and managing project documents (Document Management System)
|
||||||
|
|
||||||
|
Your role is to carefully read Thai text extracted from OCR systems and follow these instructions strictly:
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Input is raw OCR text which may contain spelling errors, missing lines, or noise characters
|
||||||
|
- Extract and identify 'Document Number' and 'Document Date' accurately. If not found, mark as 'Not Specified'
|
||||||
|
- Summarize the key content of this document concisely and clearly, using overall context for interpretation. If uncertain, mark status as "Unclear"
|
||||||
|
- Do NOT create or hallucinate data that does not exist in the original text
|
||||||
|
- Do NOT guess numbers, dates, or any information not explicitly visible in the raw text
|
||||||
|
- If information is incomplete, use null and provide reason in the _missing_fields field
|
||||||
|
|
||||||
|
Return ONLY the specified JSON structure. Do NOT add any text outside the structure"""
|
||||||
|
|
||||||
|
SYSTEM """คุณเป็นระบบ AI ผู้เชี่ยวชาญด้านการตอบคำถามเกี่ยวกับเอกสารการก่อสร้าง (LCBP3 DMS)
|
||||||
|
|
||||||
|
หน้าที่: ตอบคำถามของผู้ใช้โดยอ้างอิงจากเอกสารที่เกี่ยวข้อง
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
1. อ้างอิงเลขที่เอกสาร และวันที่ของแหล่งข้อมูลทุกครั้ง
|
||||||
|
2. หากข้อมูลไม่ชัดเจนหรือไม่ครบถ้วน ให้ระบุความไม่แน่นอน
|
||||||
|
3. ตอบเป็นภาษาไทยที่เข้าใจง่าย โดยเน้นความถูกต้องเหนือการสร้างสรรค์
|
||||||
|
4. หากไม่พบข้อมูลที่เกี่ยวข้อง ให้บอกว่า "ไม่พบข้อมูลในเอกสาร"
|
||||||
|
5. ให้สรุปสั้นๆ แต่ครอบคลุมคำถาม"""
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
HEC CHEC(THAI) CO.,LTD บริษัท ซีเอชบี (ไทย) จำกัด
|
||||||
|
Bangkok Liaison Office: 87/2 CRC Tower, 20 FL Wireless Rd, Lumphini, Pathumwan, Bangkok 10330
|
||||||
|
สำนักงานใหญ่ : 87/2 อาคารเซียาร์ซี พาวเวอร์ ชั้น 20 ถนนวิทยุ แขวงลุมพินี เขตปทุมวัน กรุงเทพมหานคร 10330 Tel.02-002-8683 Fax. 02-002-8685
|
||||||
|
Project Site Office: Thung Sukhla,Si Racha District Chon Buri 20230 สำนักงานโครงการ : ตำบลธงสุขลา สันทวีศรีราชา จังหวัดชลบุรี 20230
|
||||||
|
ต้นฉบับ
|
||||||
|
Project of Laem Chabang Port Phase 3, Infrastructure Works (Contract 2)
|
||||||
|
สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 ( ส่วนที่ 2 ) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และสาธารณูปโภค
|
||||||
|
PSLCP3
|
||||||
|
ได้รับเอกสารฉบับนี้แล้ว
|
||||||
|
เลขที่รับเอกสาร....................
|
||||||
|
วันที่ 19 ก.พ. 2568
|
||||||
|
ผู้รับ ลายเซ็น
|
||||||
|
10 กุมภาพันธ์ 2568
|
||||||
|
เรื่อง ส่งมอบพื้นที่ถมทะเลพื้นที่ 1 และ 2
|
||||||
|
โครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 (ส่วนที่ 2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และสาธารณูปโภค
|
||||||
|
เรียน คุณสุวัฒน์ พิพัฒนปัญญกูล
|
||||||
|
ผู้จัดการโครงการงานควบคุมงานก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)
|
||||||
|
อ้างถึง 1) สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนนและสาธารณูปโภค สัญญาเลขที่ ทลฉ.จ.19/2567 ลงวันที่ 31 กรกฎาคม 2567
|
||||||
|
2) หนังสือการท่าเรือแหลมฉบัง เลขที่ สคฉ.03/0036 ลงวันที่ 4 กุมภาพันธ์ 2568 เรื่อง ขอส่งมอบ พื้นที่ โครงการพัฒนาท่าเรือแหลมฉบังระยะที่3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบ ถนนและสาธารณูปโภค
|
||||||
|
3) หนังสือขอข้อคิดเห็นและข้อมูลเพิ่มเติม เลขที่ LCBP3-C2-RFI-BUD-0001-A ลงวันที่ 4 มกราคม 2568 เรื่อง ขอข้อคิดเห็นงานก่อสร้างอาคารบริเวณพื้นที่ 1 (Area 1) เมื่อเทียบกับระดับดินเดิม ณ ปัจจุบัน จากการสำรวจลักษณะภูมิประเทศ (Topographic Survey)
|
||||||
|
4) หนังสือขออนุมัติเอกสาร เลขที่ LCBP3-C2-RFA-ROW-RPT-0007-A ลงวันที่ 22 มกราคม 2568 เรื่อง ขออนุมัติรายงานการสำรวจเพื่อการก่อสร้างอาคารประตูตรวจสอบ 5 และถนน RN-2
|
||||||
|
สิ่งที่ส่งมาด้วย
|
||||||
|
1) รูปแสดง การร่วมตรวจสอบพื้นที่ระหว่างผู้ควบคุมงาน/คคง. และผู้รับจ้าง/ผรม.2 และ รูปแสดงอุปสรรคในพื้นที่ก่อสร้างที่ได้รับมอบพื้นที่ถมทะเลพื้นที่ 1 และ 2
|
||||||
|
ตามสัญญาที่อ้างถึง
|
||||||
|
1) สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนนและสาธารณูปโภค สัญญาเลขที่ ทลฉ.จ.19/2567 ลงวันที่ 31 กรกฎาคม 2567 หนังสือการท่าเรือแหลมฉบัง ที่อ้างถึง
|
||||||
|
2) เรื่องขอส่งมอบพื้นที่ โครงการพัฒนาท่าเรือแหลม ฉบังระยะที่3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนนและระบบสาธารณูปโภค
|
||||||
|
ผู้รับจ้าง/ผรม.2 รับทราบการส่งมอบพื้นที่ถมทะเลพื้นที่ 1 และ 2 และขอแจ้งยืนยันวันเริ่ม Keydate ตามรายละเอียดดังนี้
|
||||||
|
เริ่มใช้เมื่อวันที่ 20 กันยายน 2567
|
||||||
|
<page
|
||||||
|
บริษัท ซีเอชบี (ไทย) จำกัด
|
||||||
|
Project of Laem Chabang Port Phase 3, Infrastructure Works (Contract 2)
|
||||||
|
สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 ( ส่วนที่ 2 ) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และระบบสาธารณูปโภค
|
||||||
|
Bangkok Branch Office : 87/2, Aroon Tower, 26 FL., Wireless Rd. Lumpini, Pathumwan, Bangkok 10330
|
||||||
|
สำนักงานใหญ่ : 87/2 อาคารซีอาร์ซี พาวเวอร์ ชั้น 20 ถนนวิทยุ แขวงลุมพินี เขตปทุมวัน กรุงเทพมหานคร 10330 Tel. 02 002-8683 Fax. 02 002-8685
|
||||||
|
Project Site Office: Thung Sukhla,Si Racha District Chon Buri 20230
|
||||||
|
สำนักงานโครงการ : ตำบลทุ่งสุขลา อำเภอศรีราชา จังหวัดชลบุรี 20230
|
||||||
|
* Keydate 1.1 Substation 115 KV ระยะเวลาก่อสร้าง 540 วัน วันงานเริ่ม 1 กุมภาพันธ์ 2568
|
||||||
|
* **วันที่กำหนดแล้วเสร็จ** 25 กรกฎาคม 2569 ประกอบด้วย งานก่อสร้างอาคารสถานีไฟฟ้าย่อย ขนาด 115 KV ลานกองวัสดุ รั้วรอบพื้นที่ ประตูรั้ว ถนน ระบบสาธารณูปโภคทั้งหมดที่อยู่ใน พื้นที่รั้วของ Substation 115 KV การติดตั้ง และทดสอบ Power Transformer และ 115 KV GIS และรวมถึง การจ่ายค่าธรรมเนียมการขอใช้ไฟฟ้าแรงสูง 115 KV เพื่อให้ Substation 115 KV พร้อมจ่ายพลังงานไฟฟ้าได้
|
||||||
|
* Keydate 2.1 งานก่อสร้างสะพานยกระดับ 4, 12, 12.1 และ 12.2 ระยะเวลาก่อสร้าง 720 วัน **วันงานเริ่ม** 1 กุมภาพันธ์ 2568 วันที่กำหนดแล้วเสร็จ 21 มกราคม 2570
|
||||||
|
* Keydate 2.2 งานก่อสร้างอาคาร ระยะเวลาก่อสร้าง 720 วัน **วันงานเริ่ม** 1 กุมภาพันธ์ 2568 **วันที่กำหนดแล้วเสร็จ** 21 มกราคม 2570 ประกอบด้วย งานก่อสร้าง อาคารพักขยะอันตราย อาคารพักขยะทั่วไป อาคารควบคุมสถานีสูบระบบน้ำ อาคารสูบจ่ายน้ำประปา งานก่อสร้าง อาคารประตูตรวจสอบ 5 อาคารด่านตรวจสอบขาเข้า-ขาออก ตู้ควบคุมการเข้า-ออก Toll Island, WEIGHT STATION ลานจอดรถบรรทุก บริเวณอาคารประตูตรวจสอบ 5 และ รวมถึง การติดตั้งอุปกรณ์ และการทดสอบระบบต่างๆของอาคารแล้วเสร็จ
|
||||||
|
ผู้รับจ้าง/ผรม.2 จะดำเนินการแก้ไขแผนงานก่อสร้างให้สอดคล้องกับการส่งมอบพื้นที่ เพื่อนำเสนอ ขออนุมัติ และดำเนินงานก่อสร้างให้สำเร็จลุล่วงตามแผนดำเนินงานเพื่อส่งมอบงาน ให้ผู้ว่าจ้าง/การทำเรือแห่ง ประเทศไทย ตามกรอบเวลางานก่อสร้างดังกล่าว
|
||||||
|
อย่างไรก็ตาม ผู้รับจ้าง/ผรม.2 ได้ร่วมตรวจสอบพื้นที่กับผู้ควบคุมงาน/คคง. เมื่อวันที่ 7 กุมภาพันธ์ 2568 พบว่าพื้นที่ถมทะเล 1 ที่ได้รับมอบพื้นที่นั้น ยังมีวัสดุอุปกรณ์ เครื่องจักร และสิ่งก่อสร้าง ตั้งอยู่ในพื้นที่ ซึ่งเป็นอุปสรรคในการเริ่มดำเนินการงานก่อสร้าง โดยมีรายละเอียดดังนี้
|
||||||
|
* สำนักงานโครงการ บ้านพักพนักงาน และกองวัสดุอุปกรณ์ ผู้รับจ้าง/ผรม.1 สัญญาส่วนที่ 1 ตั้งอยู่ ในบริเวณพื้นที่ก่อสร้างอาคารประตูตรวจสอบ 5 อาคารด่านตรวจสอบขาเข้า-ขาออก ตู้ควบคุม การเข้า-ออก Toll Island, WEIGHT STATION ลานจอดรถบรรทุก บริเวณอาคารประตู ตรวจสอบ 5 ซึ่งได้แสดงรายละเอียดไว้ใน รายงานการสำรวจเพื่อการก่อสร้างอาคารประตู ตรวจสอบ 5 และถนน RN-2 ตามหนังสือที่อ้างถึง 4)
|
||||||
|
* คันดินกองวัสดุที่ไม่เหมาะสมตามข้อกำหนด ยังกองอยู่ในพื้นที่ซ
|
||||||
|
บริษัท ซีเอชอีซี (ไทย) จำกัด
|
||||||
|
Project of Laem Chabang Port Phase 3, Infrastructure Works (Contract 2)
|
||||||
|
สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 ( ส่วนที่ 2 ) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และระบบสาธารณูปโภค
|
||||||
|
Bangkok Branch Office: 87/2 Aroon Chomchit, Phahonyothin, Bangkok 10330
|
||||||
|
สำนักงานใหญ่ : 87/2 อาคารซี.ซี.ทาวเวอร์ ชั้น 20 ถนนวิภาวดี แขวงลุมพินี เขตปทุมวัน กรุงเทพมหานคร 10330 Tel. 02 002-8683 Fax. 02 002-8685
|
||||||
|
Project Site Office: Thung Sukhla,Si Racha District Chon Buri 20230
|
||||||
|
สำนักงานโครงการ : ตำบลทุ่งสุขลา อำเภอศรีราชา จังหวัดชลบุรี 20230
|
||||||
|
* พื้นที่ถมทะเล 1 ที่ส่งมอบ มีวัชพืชขึ้นปกคลุม และระดับดินเดิมเฉลี่ยอยู่ที่ระดับ +3.400 MSL
|
||||||
|
* ต้องดำเนินการงานถากถางและขุดต่อขุดราก และถมดินเพิ่มจึงจะสามารถเริ่มงานก่อสร้างในพื้นที่ดังกล่าวได้ ตามรายละเอียดที่แสดงในหนังสือขอข้อคิดเห็นและข้อมูลเพิ่มเติม เรื่อง ขอข้อคิดเห็นงานก่อสร้างอาคารบริเวณพื้นที่ 1 (Area 1) เมื่อเทียบกับระดับดินเดิม ณ ปัจจุบัน จากการสำรวจลักษณะภูมิประเทศ (Topographic Survey) ตามหนังสือที่อ้างถึง 3) ซึ่งยังรอ ผู้ควบคุมงาน/คคง. ชี้แจงข้อคิดเห็น และสั่งการเพื่อดำเนินการงานดังกล่าว
|
||||||
|
รายละเอียดรูปแสดงรูปแสดง การร่วมตรวจสอบพื้นที่ระหว่างผู้ควบคุมงาน/คคง. และผู้รับจ้าง/ ผรม.2 และรูปแสดงอุปสรรคในพื้นที่ก่อสร้างที่ได้รับมอบพื้นที่ถมทะเลพื้นที่ 1 และ 2 ตามสิ่งที่ส่งมาด้วย 1)
|
||||||
|
โดยหนังสือฉบับนี้ ผู้รับจ้าง/ผรม.2 แจ้งมายัง ผู้ควบคุมงาน/คคง. เพื่อโปรดทราบ และโปรดพิจารณาประสานงานรื้อย้ายอุปสรรคดังกล่าวออกจากพื้นที่ที่ส่งมอบ เพื่อป้องกันความล่าช้าในการดำเนินการก่อสร้างในพื้นที่ดังกล่าว ซึ่งอาจส่งผลกระทบกับ Keydate 1.1, 2.1 และ 2.2 เพื่อให้งานก่อสร้างสามารถดำเนินการได้ตามวัตถุประสงค์ แผนงานก่อสร้าง และส่งมอบงานให้ผู้ว่าจ้าง/การท่าเรือฯ ได้ตามสัญญา
|
||||||
|
หากอุปสรรคดังกล่าว ส่งผลกระทบให้เกิดความล่าช้าในการทำงาน รวมถึงการส่งมอบงานให้กับผู้ว่าจ้าง/การท่าเรือฯ ผู้รับจ้าง/ผรม.2 ขอสงวนสิทธิ์ในการขยายระยะเวลากำหนดวันแล้วเสร็จของงานตามสัญญา และขอสงวนสิทธิ์เรียกร้องหากมีค่าใช้จ่ายที่เพิ่มขึ้นจากความล่าช้าจากปัญหาดังกล่าว
|
||||||
|
จึงเรียนมาเพื่อทราบและโปรดพิจารณา แจ้งผู้เกี่ยวข้องดำเนินการรื้อย้ายวัสดุอุปกรณ์ เครื่องจักร และสิ่งก่อสร้าง ออกจากพื้นที่ถมทะเล 1 ก่อนกำหนดการเริ่มงานก่อสร้างตามลำดับขั้นตอนการทำงานที่แสดงในแผนงานก่อสร้าง ทั้งนี้เพื่อไม่ให้มีผลกระทบกับแผนงานก่อสร้าง และสามารถส่งมอบงานให้กับผู้ว่าจ้าง/การท่าเรือฯ ได้ตามกำหนดวันแล้วเสร็จดังกล่าวข้างต้น
|
||||||
|
ขอแสดงความนับถือ
|
||||||
|
(นายไห่กวง หวัง)
|
||||||
|
บริษัท ซีเอชอีซี (ไทย) จำกัด
|
||||||
|
เริ่มใช้เมื่อวันที่ 20 กันยายน 2567
|
||||||
|
<page_number>3</page_number>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# ทดสอบ typhoon2.5-np-dms โดยใช้ prompt template จริง + OCR text จาก test.md + master data context จาก DB
|
||||||
|
# รันด้วย: powershell -ExecutionPolicy Bypass -File test_llm.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$OLLAMA_URL = "http://192.168.10.100:11434"
|
||||||
|
$MODEL = "typhoon2.5-np-dms:latest"
|
||||||
|
$OCR_TEXT_FILE = Join-Path $PSScriptRoot "test.md"
|
||||||
|
|
||||||
|
# --- Prompt Template (ดึงจาก DB: ai_prompts version=2, prompt_type=ocr_extraction) ---
|
||||||
|
$TEMPLATE = @"
|
||||||
|
คุณคือเอนจิ้นสกัดข้อมูลอัจฉริยะ (Document Intelligence Engine)
|
||||||
|
วิเคราะห์ข้อความ OCR ที่ได้รับจากเอกสารของโครงการ Laem Chabang Port Phase 3 และสกัดข้อมูลเมตาดาต้าให้ออกมาเป็น JSON object ที่ถูกต้องตามโครงสร้างที่กำหนด
|
||||||
|
|
||||||
|
ข้อความ OCR ที่สกัดได้:
|
||||||
|
{{ocr_text}}
|
||||||
|
|
||||||
|
ข้อมูลอ้างอิงของระบบ (Master Data Context):
|
||||||
|
{{master_data_context}}
|
||||||
|
|
||||||
|
กฎการสกัดข้อมูล:
|
||||||
|
1. วิเคราะห์และจับคู่ข้อมูลจากข้อความ OCR กับข้อมูลอ้างอิงที่ระบุใน Master Data Context เสมอ
|
||||||
|
2. สำหรับโครงการ (project) ให้ค้นหาและสกัดส่งกลับเป็น UUID ของโครงการ (projectPublicId)
|
||||||
|
3. สำหรับประเภทเอกสารโต้ตอบ (correspondence type) ให้สกัดรหัสส่งกลับมา (correspondenceTypeCode) เช่น RFA, Transmittal
|
||||||
|
4. สำหรับสาขางาน (discipline) ให้ส่งคืนรหัสส่งกลับมา (disciplineCode) เช่น GEN, STR
|
||||||
|
5. สำหรับหน่วยงานผู้ส่ง (originator) ค้นหาจาก availableOrganizations และส่งกลับมาเป็น UUID (originatorOrganizationPublicId)
|
||||||
|
6. สำหรับหน่วยงานผู้รับ (recipients) ให้ส่งกลับมาเป็นรายการ Array ของออบเจกต์ ซึ่งมี UUID ขององค์กร (organizationPublicId) และประเภทผู้รับ (recipientType: "TO" หรือ "CC") เสมอ
|
||||||
|
7. สำหรับหัวข้อเอกสาร (subject) ให้สกัดหัวข้อหรือชื่อเรื่องของเอกสารภาษาไทยหรือภาษาอังกฤษ
|
||||||
|
8. วันที่ของเอกสาร (documentDate) ให้ส่งคืนในรูปแบบ YYYY-MM-DD
|
||||||
|
9. รายการแท็ก (tags) สกัดคำสำคัญหรือคำแนะนำ Tags (สอดคล้องกับ availableTags หากมี)
|
||||||
|
10. สรุปความเนื้อหา (summary) เขียนสรุปรายละเอียดเอกสารสั้นกระชับ 4-5 ประโยคเป็นภาษาไทยอย่างสละสลวย
|
||||||
|
11. confidence: ค่าความมั่นใจในการสกัดข้อมูลนี้ (ทศนิยมระหว่าง 0.0 ถึง 1.0)
|
||||||
|
|
||||||
|
ส่งคืนคำตอบเฉพาะ JSON Object ที่ถูกต้องเท่านั้น ห้ามใส่บล็อกโค้ด markdown หรือคำอธิบายเพิ่มเติมใดๆ
|
||||||
|
โครงสร้าง JSON ผลลัพธ์:
|
||||||
|
{
|
||||||
|
"projectPublicId": "string หรือ null",
|
||||||
|
"correspondenceTypeCode": "string หรือ null",
|
||||||
|
"disciplineCode": "string หรือ null",
|
||||||
|
"originatorOrganizationPublicId": "string หรือ null",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"organizationPublicId": "string",
|
||||||
|
"recipientType": "TO หรือ CC"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subject": "string หรือ null",
|
||||||
|
"documentDate": "string:YYYY-MM-DD หรือ null",
|
||||||
|
"tags": ["string"],
|
||||||
|
"summary": "string หรือ null",
|
||||||
|
"confidence": 0.95
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
# --- Master Data Context (ดึงจาก DB จริง) ---
|
||||||
|
$MASTER_DATA = @{
|
||||||
|
availableProjects = @(
|
||||||
|
@{code = "LCBP3"; uuid = "c957f1e3-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)"}
|
||||||
|
@{code = "LCBP3-C1"; uuid = "c957f44b-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1) งานก่อสร้างงานทางทะเล"}
|
||||||
|
@{code = "LCBP3-C2"; uuid = "c957f523-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และระบบสาธารณูปโภค"}
|
||||||
|
@{code = "LCBP3-C3"; uuid = "c957f57c-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 3) งานก่อสร้าง"}
|
||||||
|
@{code = "LCBP3-C4"; uuid = "c957f5cc-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 4) งานก่อสร้าง"}
|
||||||
|
)
|
||||||
|
availableOrganizations = @(
|
||||||
|
@{code = "กทท."; uuid = "c94cb0b4-538b-11f1-8c7d-0242ac1d0007"; name = "การท่าเรือแห่งประเทศไทย"}
|
||||||
|
@{code = "สคฉ.3"; uuid = "c94cb3f6-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3"}
|
||||||
|
@{code = "สคฉ.3-01"; uuid = "c94cb532-538b-11f1-8c7d-0242ac1d0007"; name = "ตรวจรับพัสดุ ที่ปรึกษาควบคุมงาน"}
|
||||||
|
@{code = "สคฉ.3-02"; uuid = "c94cb5ab-538b-11f1-8c7d-0242ac1d0007"; name = "ตรวจรับพัสดุ งานทางทะเล"}
|
||||||
|
@{code = "สคฉ.3-03"; uuid = "c94cb616-538b-11f1-8c7d-0242ac1d0007"; name = "ตรวจรับพัสดุ อาคารและระบบสาธารณูปโภค"}
|
||||||
|
@{code = "คคง."; uuid = "c94cb8ac-538b-11f1-8c7d-0242ac1d0007"; name = "Construction Supervision Ltd."}
|
||||||
|
@{code = "ผรม.1"; uuid = "c94cb907-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานทางทะเล"}
|
||||||
|
@{code = "ผรม.2"; uuid = "c94cb95e-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานก่อสร้าง"}
|
||||||
|
@{code = "ผรม.3"; uuid = "c94cb9b3-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานก่อสร้าง ส่วนที่ 3"}
|
||||||
|
@{code = "ผรม.4"; uuid = "c94cba0c-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานก่อสร้าง ส่วนที่ 4"}
|
||||||
|
)
|
||||||
|
availableDisciplines = @(
|
||||||
|
@{code = "GEN"; name = "งานบริหารโครงการ"}
|
||||||
|
@{code = "COD"; name = "สัญญาและข้อโต้แย้ง"}
|
||||||
|
@{code = "QSB"; name = "สำรวจปริมาณและควบคุมงบประมาณ"}
|
||||||
|
@{code = "PPG"; name = "บริหารแผนและความก้าวหน้า"}
|
||||||
|
@{code = "BST"; name = "งานโครงสร้างอาคาร"}
|
||||||
|
@{code = "UTL"; name = "งานระบบสาธารณูปโภค"}
|
||||||
|
@{code = "EPW"; name = "งานระบบไฟฟ้า"}
|
||||||
|
@{code = "SRV"; name = "งานสำรวจ"}
|
||||||
|
@{code = "ODC"; name = "สำนักงาน-ควบคุมเอกสาร"}
|
||||||
|
)
|
||||||
|
availableCorrespondenceTypes = @(
|
||||||
|
@{code = "RFA"; name = "Request for Approval"}
|
||||||
|
@{code = "RFI"; name = "Request for Information"}
|
||||||
|
@{code = "TRANSMITTAL"; name = "Transmittal"}
|
||||||
|
@{code = "LETTER"; name = "Letter"}
|
||||||
|
@{code = "MEMO"; name = "Memorandum"}
|
||||||
|
@{code = "MOM"; name = "Minutes of Meeting"}
|
||||||
|
@{code = "NOTICE"; name = "Notice"}
|
||||||
|
@{code = "OTHER"; name = "Other"}
|
||||||
|
)
|
||||||
|
availableTags = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Check-Models {
|
||||||
|
try {
|
||||||
|
$resp = Invoke-RestMethod -Uri "$OLLAMA_URL/api/ps" -Method Get -TimeoutSec 5
|
||||||
|
if ($resp.models) {
|
||||||
|
Write-Host " Models in VRAM: $($resp.models.name -join ', ')"
|
||||||
|
} else {
|
||||||
|
Write-Host " VRAM: ไม่มี model โหลดอยู่ (ว่าง)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " Error checking models: $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=" * 60
|
||||||
|
Write-Host "🔍 ตรวจสอบ VRAM ก่อนรัน..."
|
||||||
|
Check-Models
|
||||||
|
|
||||||
|
$ocrText = Get-Content $OCR_TEXT_FILE -Raw -Encoding UTF8
|
||||||
|
Write-Host "`n📄 OCR text: $($ocrText.Length) chars"
|
||||||
|
|
||||||
|
$masterDataJson = $MASTER_DATA | ConvertTo-Json -Depth 10 -Compress:$false
|
||||||
|
$prompt = $TEMPLATE -replace "{{ocr_text}}", $ocrText -replace "{{master_data_context}}", $masterDataJson
|
||||||
|
Write-Host "📝 Total prompt: $($prompt.Length) chars"
|
||||||
|
Write-Host "=" * 60
|
||||||
|
Write-Host "⏳ กำลังส่งไปยัง Ollama..."
|
||||||
|
|
||||||
|
$body = @{
|
||||||
|
model = $MODEL
|
||||||
|
prompt = $prompt
|
||||||
|
stream = $false
|
||||||
|
} | ConvertTo-Json -Depth 10
|
||||||
|
|
||||||
|
$start = Get-Date
|
||||||
|
try {
|
||||||
|
$resp = Invoke-RestMethod -Uri "$OLLAMA_URL/api/generate" -Method Post -Body $body -ContentType "application/json" -TimeoutSec 180
|
||||||
|
$elapsed = (Get-Date - $start).TotalSeconds
|
||||||
|
$rawResponse = $resp.response
|
||||||
|
|
||||||
|
Write-Host "`n✅ Response ใน $([math]::Round($elapsed, 1))s"
|
||||||
|
Write-Host "-" * 60
|
||||||
|
Write-Host $rawResponse
|
||||||
|
Write-Host "-" * 60
|
||||||
|
|
||||||
|
$cleaned = $rawResponse -replace "```json", "" -replace "```", "" -replace "^\s+", "" -replace "\s+$", ""
|
||||||
|
try {
|
||||||
|
$parsed = $cleaned | ConvertFrom-Json
|
||||||
|
Write-Host "`n✅ JSON parse สำเร็จ!"
|
||||||
|
$parsed | ConvertTo-Json -Depth 10
|
||||||
|
} catch {
|
||||||
|
Write-Host "`n❌ JSON parse ล้มเหลว: $_"
|
||||||
|
Write-Host " Raw (200 chars): $($rawResponse.Substring(0, [Math]::Min(200, $rawResponse.Length)))"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "`n❌ Error: $_"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n🔍 ตรวจสอบ VRAM หลังรัน..."
|
||||||
|
Check-Models
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
ทดสอบ typhoon2.5-np-dms โดยใช้ prompt template จริง + OCR text จาก test.md + master data context จาก DB
|
||||||
|
รันด้วย: python test_llm.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://192.168.10.100:11434"
|
||||||
|
MODEL = "typhoon2.5-np-dms:latest"
|
||||||
|
OCR_TEXT_FILE = Path(__file__).parent / "test.md"
|
||||||
|
|
||||||
|
# --- Prompt Template (ดึงจาก DB: ai_prompts version=2, prompt_type=ocr_extraction) ---
|
||||||
|
TEMPLATE = """คุณคือเอนจิ้นสกัดข้อมูลอัจฉริยะ (Document Intelligence Engine)
|
||||||
|
วิเคราะห์ข้อความ OCR ที่ได้รับจากเอกสารของโครงการ Laem Chabang Port Phase 3 และสกัดข้อมูลเมตาดาต้าให้ออกมาเป็น JSON object ที่ถูกต้องตามโครงสร้างที่กำหนด
|
||||||
|
|
||||||
|
ข้อความ OCR ที่สกัดได้:
|
||||||
|
{{ocr_text}}
|
||||||
|
|
||||||
|
ข้อมูลอ้างอิงของระบบ (Master Data Context):
|
||||||
|
{{master_data_context}}
|
||||||
|
|
||||||
|
กฎการสกัดข้อมูล:
|
||||||
|
1. วิเคราะห์และจับคู่ข้อมูลจากข้อความ OCR กับข้อมูลอ้างอิงที่ระบุใน Master Data Context เสมอ
|
||||||
|
2. สำหรับโครงการ (project) ให้ค้นหาและสกัดส่งกลับเป็น UUID ของโครงการ (projectPublicId)
|
||||||
|
3. สำหรับประเภทเอกสารโต้ตอบ (correspondence type) ให้สกัดรหัสส่งกลับมา (correspondenceTypeCode) เช่น RFA, Transmittal
|
||||||
|
4. สำหรับสาขางาน (discipline) ให้ส่งคืนรหัสส่งกลับมา (disciplineCode) เช่น GEN, STR
|
||||||
|
5. สำหรับหน่วยงานผู้ส่ง (originator) ค้นหาจาก availableOrganizations และส่งกลับมาเป็น UUID (originatorOrganizationPublicId)
|
||||||
|
6. สำหรับหน่วยงานผู้รับ (recipients) ให้ส่งกลับมาเป็นรายการ Array ของออบเจกต์ ซึ่งมี UUID ขององค์กร (organizationPublicId) และประเภทผู้รับ (recipientType: "TO" หรือ "CC") เสมอ
|
||||||
|
7. สำหรับหัวข้อเอกสาร (subject) ให้สกัดหัวข้อหรือชื่อเรื่องของเอกสารภาษาไทยหรือภาษาอังกฤษ
|
||||||
|
8. วันที่ของเอกสาร (documentDate) ให้ส่งคืนในรูปแบบ YYYY-MM-DD
|
||||||
|
9. รายการแท็ก (tags) สกัดคำสำคัญหรือคำแนะนำ Tags (สอดคล้องกับ availableTags หากมี)
|
||||||
|
10. สรุปความเนื้อหา (summary) เขียนสรุปรายละเอียดเอกสารสั้นกระชับ 4-5 ประโยคเป็นภาษาไทยอย่างสละสลวย
|
||||||
|
11. confidence: ค่าความมั่นใจในการสกัดข้อมูลนี้ (ทศนิยมระหว่าง 0.0 ถึง 1.0)
|
||||||
|
|
||||||
|
ส่งคืนคำตอบเฉพาะ JSON Object ที่ถูกต้องเท่านั้น ห้ามใส่บล็อกโค้ด markdown หรือคำอธิบายเพิ่มเติมใดๆ
|
||||||
|
โครงสร้าง JSON ผลลัพธ์:
|
||||||
|
{
|
||||||
|
"projectPublicId": "string หรือ null",
|
||||||
|
"correspondenceTypeCode": "string หรือ null",
|
||||||
|
"disciplineCode": "string หรือ null",
|
||||||
|
"originatorOrganizationPublicId": "string หรือ null",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"organizationPublicId": "string",
|
||||||
|
"recipientType": "TO หรือ CC"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subject": "string หรือ null",
|
||||||
|
"documentDate": "string:YYYY-MM-DD หรือ null",
|
||||||
|
"tags": ["string"],
|
||||||
|
"summary": "string หรือ null",
|
||||||
|
"confidence": 0.95
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# --- Master Data Context (ดึงจาก DB จริง) ---
|
||||||
|
MASTER_DATA = {
|
||||||
|
"availableProjects": [
|
||||||
|
{"code": "LCBP3", "uuid": "c957f1e3-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)"},
|
||||||
|
{"code": "LCBP3-C1", "uuid": "c957f44b-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1) งานก่อสร้างงานทางทะเล"},
|
||||||
|
{"code": "LCBP3-C2", "uuid": "c957f523-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และระบบสาธารณูปโภค"},
|
||||||
|
{"code": "LCBP3-C3", "uuid": "c957f57c-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 3) งานก่อสร้าง"},
|
||||||
|
{"code": "LCBP3-C4", "uuid": "c957f5cc-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 4) งานก่อสร้าง"},
|
||||||
|
],
|
||||||
|
"availableOrganizations": [
|
||||||
|
{"code": "กทท.", "uuid": "c94cb0b4-538b-11f1-8c7d-0242ac1d0007", "name": "การท่าเรือแห่งประเทศไทย"},
|
||||||
|
{"code": "สคฉ.3", "uuid": "c94cb3f6-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3"},
|
||||||
|
{"code": "สคฉ.3-01", "uuid": "c94cb532-538b-11f1-8c7d-0242ac1d0007", "name": "ตรวจรับพัสดุ ที่ปรึกษาควบคุมงาน"},
|
||||||
|
{"code": "สคฉ.3-02", "uuid": "c94cb5ab-538b-11f1-8c7d-0242ac1d0007", "name": "ตรวจรับพัสดุ งานทางทะเล"},
|
||||||
|
{"code": "สคฉ.3-03", "uuid": "c94cb616-538b-11f1-8c7d-0242ac1d0007", "name": "ตรวจรับพัสดุ อาคารและระบบสาธารณูปโภค"},
|
||||||
|
{"code": "คคง.", "uuid": "c94cb8ac-538b-11f1-8c7d-0242ac1d0007", "name": "Construction Supervision Ltd."},
|
||||||
|
{"code": "ผรม.1", "uuid": "c94cb907-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานทางทะเล"},
|
||||||
|
{"code": "ผรม.2", "uuid": "c94cb95e-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานก่อสร้าง"},
|
||||||
|
{"code": "ผรม.3", "uuid": "c94cb9b3-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานก่อสร้าง ส่วนที่ 3"},
|
||||||
|
{"code": "ผรม.4", "uuid": "c94cba0c-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานก่อสร้าง ส่วนที่ 4"},
|
||||||
|
],
|
||||||
|
"availableDisciplines": [
|
||||||
|
{"code": "GEN", "name": "งานบริหารโครงการ"},
|
||||||
|
{"code": "COD", "name": "สัญญาและข้อโต้แย้ง"},
|
||||||
|
{"code": "QSB", "name": "สำรวจปริมาณและควบคุมงบประมาณ"},
|
||||||
|
{"code": "PPG", "name": "บริหารแผนและความก้าวหน้า"},
|
||||||
|
{"code": "BST", "name": "งานโครงสร้างอาคาร"},
|
||||||
|
{"code": "UTL", "name": "งานระบบสาธารณูปโภค"},
|
||||||
|
{"code": "EPW", "name": "งานระบบไฟฟ้า"},
|
||||||
|
{"code": "SRV", "name": "งานสำรวจ"},
|
||||||
|
{"code": "ODC", "name": "สำนักงาน-ควบคุมเอกสาร"},
|
||||||
|
],
|
||||||
|
"availableCorrespondenceTypes": [
|
||||||
|
{"code": "RFA", "name": "Request for Approval"},
|
||||||
|
{"code": "RFI", "name": "Request for Information"},
|
||||||
|
{"code": "TRANSMITTAL", "name": "Transmittal"},
|
||||||
|
{"code": "LETTER", "name": "Letter"},
|
||||||
|
{"code": "MEMO", "name": "Memorandum"},
|
||||||
|
{"code": "MOM", "name": "Minutes of Meeting"},
|
||||||
|
{"code": "NOTICE", "name": "Notice"},
|
||||||
|
{"code": "OTHER", "name": "Other"},
|
||||||
|
],
|
||||||
|
"availableTags": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_models():
|
||||||
|
req = urllib.request.Request(f"{OLLAMA_URL}/api/ps")
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
models = data.get("models", [])
|
||||||
|
if models:
|
||||||
|
print(f" Models in VRAM: {[m['name'] for m in models]}")
|
||||||
|
else:
|
||||||
|
print(" VRAM: ไม่มี model โหลดอยู่ (ว่าง)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🔍 ตรวจสอบ VRAM ก่อนรัน...")
|
||||||
|
check_models()
|
||||||
|
|
||||||
|
ocr_text = OCR_TEXT_FILE.read_text(encoding="utf-8")
|
||||||
|
print(f"\n📄 OCR text: {len(ocr_text)} chars")
|
||||||
|
|
||||||
|
prompt = TEMPLATE.replace("{{ocr_text}}", ocr_text).replace(
|
||||||
|
"{{master_data_context}}", json.dumps(MASTER_DATA, ensure_ascii=False, indent=2)
|
||||||
|
)
|
||||||
|
print(f"📝 Total prompt: {len(prompt)} chars")
|
||||||
|
print("=" * 60)
|
||||||
|
print("⏳ กำลังส่งไปยัง Ollama...")
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"model": MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_URL}/api/generate",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
elapsed = time.time() - start
|
||||||
|
raw_response = result.get("response", "")
|
||||||
|
|
||||||
|
print(f"\n✅ Response ใน {elapsed:.1f}s")
|
||||||
|
print("-" * 60)
|
||||||
|
print(raw_response)
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
# พยายาม parse JSON
|
||||||
|
cleaned = raw_response.replace("```json", "").replace("```", "").strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(cleaned)
|
||||||
|
print("\n✅ JSON parse สำเร็จ!")
|
||||||
|
print(json.dumps(parsed, ensure_ascii=False, indent=2))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"\n❌ JSON parse ล้มเหลว: {e}")
|
||||||
|
print(f" Raw (200 chars): {raw_response[:200]!r}")
|
||||||
|
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"\n❌ Connection error: {e}")
|
||||||
|
|
||||||
|
print("\n🔍 ตรวจสอบ VRAM หลังรัน...")
|
||||||
|
check_models()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user