Compare commits
19 Commits
4a808dd9c4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71c5e88181 | |||
| cd7d20ccd4 | |||
| 75d07b5ac9 | |||
| 52b96d01de | |||
| a0f77ad121 | |||
| 16aab2279c | |||
| 15dec6c3fc | |||
| 33c3935164 | |||
| 6bcd1a5c58 | |||
| de4201d7d3 | |||
| e3e0de66e9 | |||
| 866fea7946 | |||
| 85c7415b8a | |||
| ed1b302274 | |||
| 26cc71ce60 | |||
| 285c007dff | |||
| 03aa4efcf0 | |||
| 4f90ed688f | |||
| 548dba6476 |
@@ -1,7 +1,7 @@
|
||||
# NAP-DMS Project Context & Rules
|
||||
|
||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- Version: 1.9.6 | Last synced from repo: 2026-05-22
|
||||
- For: Devin Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- Version: 1.9.10 | Last synced from repo: 2026-06-06
|
||||
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
||||
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
||||
|
||||
|
||||
@@ -112,3 +112,99 @@
|
||||
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
||||
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
||||
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP MariaDB Tools
|
||||
|
||||
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
|
||||
|
||||
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
|
||||
- ตรวจสอบ data ใน production/staging
|
||||
- Validate การเปลี่ยนแปลง schema ก่อน deploy
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||
|------|----------|------------------|
|
||||
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
|
||||
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
|
||||
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
|
||||
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
|
||||
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
|
||||
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
|
||||
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
|
||||
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
|
||||
|
||||
### การใช้งานร่วมกับ Development Flow
|
||||
|
||||
**เมื่อเขียน query ใหม่:**
|
||||
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
|
||||
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
|
||||
|
||||
**เมื่อเปลี่ยน schema (ADR-009):**
|
||||
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
|
||||
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
|
||||
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
|
||||
|
||||
**เมื่อ debug ปัญหา database:**
|
||||
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
|
||||
2. เปรียบเทียบกับ spec และ data dictionary
|
||||
3. ตรวจสอบ foreign keys และ constraints
|
||||
|
||||
### ข้อควรระวัง
|
||||
|
||||
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
|
||||
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
|
||||
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
|
||||
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
|
||||
|
||||
---
|
||||
|
||||
## 🧠 MCP Memory Tools
|
||||
|
||||
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
|
||||
|
||||
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
|
||||
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
|
||||
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||
|------|----------|------------------|
|
||||
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
|
||||
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
|
||||
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
|
||||
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
|
||||
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
|
||||
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
|
||||
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
|
||||
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
|
||||
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
|
||||
|
||||
### การใช้งานร่วมกับ Development Flow
|
||||
|
||||
**เมื่อบันทึก context ใหม่:**
|
||||
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
|
||||
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
|
||||
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
|
||||
|
||||
**เมื่อค้นหา context:**
|
||||
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
|
||||
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
|
||||
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
|
||||
|
||||
**เมื่อแก้ไข context:**
|
||||
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
|
||||
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
|
||||
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
|
||||
|
||||
### ข้อควรระวัง
|
||||
|
||||
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
|
||||
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
|
||||
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
|
||||
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
|
||||
|
||||
@@ -27,7 +27,7 @@ n8n (Migration) → DMS API → BullMQ → Admin Desktop (Ollama) → Backend Va
|
||||
| **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging |
|
||||
| **BullMQ Queues** | Backend (NestJS) | ai-realtime (RAG/Suggest), ai-batch (OCR/Extract/Embed) |
|
||||
| **Ollama Engine** | Admin Desktop (Desk-5439) | typhoon2.5-np-dms:latest (Main LLM) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding) |
|
||||
| **OCR Engine** | Admin Desktop (Desk-5439) | PaddleOCR + PyThaiNLP (Thai/English text extraction) |
|
||||
| **OCR Engine** | Admin Desktop (Desk-5439) | Tesseract OCR + Typhoon OCR (via Ollama) + PyThaiNLP (Thai/English text extraction) |
|
||||
| **Orchestrator** | QNAP NAS (n8n) | Migration Phase orchestrator only (calls DMS API, never Ollama directly) |
|
||||
|
||||
## Backend Implementation (NestJS)
|
||||
@@ -118,7 +118,7 @@ const DocumentReviewForm = ({ document, aiSuggestions }) => {
|
||||
- **3-Model Config:** typhoon2.5-np-dms:latest (Main) + typhoon-np-dms-ocr:latest (OCR, keep_alive:0) + nomic-embed-text (Embedding)
|
||||
- **PDF 3-Page Limit:** Classification/Tagging uses first 3 pages only (NOT RAG embedding)
|
||||
- **RAG Embedding:** Full document chunked at 512 tokens/64 tokens overlap
|
||||
- **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else PaddleOCR
|
||||
- **OCR Auto-Detect:** PyMuPDF chars > 100 → Fast path, else Tesseract OCR (with Typhoon OCR option)
|
||||
- **Embed Auto-Trigger:** AUTO after commit (parallel), gap covered by DB search
|
||||
- **Threshold Recalibration:** After 100-500 docs, based on ai_audit_logs analysis
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ init_agent_registry() {
|
||||
[qwen]="Qwen Code"
|
||||
[opencode]="opencode"
|
||||
[codex]="Codex CLI"
|
||||
[windsurf]="Windsurf"
|
||||
[devin]="Devin"
|
||||
[kilocode]="Kilo Code"
|
||||
[auggie]="Auggie CLI"
|
||||
[roo]="Roo Code"
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Devin, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|bob|qoder
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|devin|kilocode|auggie|shai|q|bob|qoder
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
@@ -609,8 +609,8 @@ update_specific_agent() {
|
||||
codex)
|
||||
update_agent_file "$AGENTS_FILE" "Codex CLI"
|
||||
;;
|
||||
windsurf)
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
devin)
|
||||
update_agent_file "$DEVIN_FILE" "Devin"
|
||||
;;
|
||||
kilocode)
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
@@ -681,8 +681,8 @@ update_all_existing_agents() {
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$WINDSURF_FILE" ]]; then
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
if [[ -f "$DEVIN_FILE" ]]; then
|
||||
update_agent_file "$DEVIN_FILE" "Devin"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
|
||||
+10
-11
@@ -1,8 +1,8 @@
|
||||
# `.agents/skills/` — LCBP3 Agent Skill Pack
|
||||
|
||||
**Version:** 1.9.0 | **Last Updated:** 2026-05-17 | **Total Skills:** 23
|
||||
**Version:** 1.9.0 | **Last Updated:** 2026-06-07 | **Total Skills:** 24
|
||||
|
||||
Agent skills for AI-assisted development in **Windsurf IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
||||
Agent skills for AI-assisted development in **Devin IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,7 @@ Agent skills for AI-assisted development in **Windsurf IDE** (and compatible age
|
||||
├── skills.md # Overview + dependency matrix + health monitoring
|
||||
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
||||
├── README.md # (this file)
|
||||
├── save-memory/ # Session log & project memory update
|
||||
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
||||
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
||||
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
|
||||
@@ -30,12 +31,10 @@ Each skill directory contains:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How Windsurf Invokes These Skills
|
||||
## 🚀 How Devin Invokes These Skills
|
||||
|
||||
Windsurf exposes two entry points:
|
||||
|
||||
1. **Skill tool** — Windsurf discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
||||
2. **Slash commands** — `.windsurf/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
||||
1. **Skill tool** — Devin discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
||||
2. **Slash commands** — `.devin/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
||||
|
||||
Both paths end up executing the same `SKILL.md` instructions.
|
||||
|
||||
@@ -66,13 +65,13 @@ Use `/00-speckit.all` to run specify → clarify → plan → tasks → analyze
|
||||
From repo root:
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
|
||||
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
|
||||
| `./.agents/scripts/bash/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` |
|
||||
| `./.agents/scripts/bash/update-agent-context.sh devin` | Append tech entries to `AGENTS.md` |
|
||||
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
||||
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
||||
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.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.
|
||||
|
||||
@@ -97,7 +96,7 @@ To add a new skill:
|
||||
|
||||
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
|
||||
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
|
||||
3. Wrap with `.windsurf/workflows/NAME.md` so it becomes a slash command.
|
||||
3. Wrap with `.devin/workflows/NAME.md` so it becomes a slash command.
|
||||
4. Update [`skills.md`](./skills.md) dependency matrix.
|
||||
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
||||
|
||||
|
||||
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
file_id INT,
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||
confidence DECIMAL(4,3),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
output_summary JSON,
|
||||
|
||||
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
file_id INT,
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||
confidence DECIMAL(4,3),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
output_summary JSON,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: save-memory
|
||||
description: บันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||
version: 1.9.0
|
||||
scope: project-management
|
||||
depends-on: []
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# บันทึก Memory (Save Memory)
|
||||
|
||||
Skill นี้ใช้สำหรับบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่ที่ reorganization แล้ว
|
||||
|
||||
## โครงสร้าง Memory ใหม่
|
||||
|
||||
```
|
||||
memory/
|
||||
├── README.md (index + overview)
|
||||
├── mcp-tools.md (MCP MariaDB + Memory Tools)
|
||||
└── project-memory-override.md (OS rules, Current Decisions, Environment, Next Session Focus)
|
||||
|
||||
specs/88-logs/
|
||||
├── rollouts.md (Recent rollouts table)
|
||||
└── session-YYYY-MM-DD-[topic].md (Session logs)
|
||||
```
|
||||
|
||||
## ขั้นตอนการบันทึก Memory
|
||||
|
||||
### 1. สร้าง Session Log (ถ้ามีงาน session ใหม่)
|
||||
|
||||
เมื่อทำงาน session ใหม่ให้:
|
||||
|
||||
1. **สร้างไฟล์ session log ใหม่** ใน `specs/88-logs/`
|
||||
- ชื่อไฟล์: `session-YYYY-MM-DD-[topic].md`
|
||||
- ตัวอย่าง: `session-2026-06-07-memory-reorganization.md`
|
||||
|
||||
2. **บันทึกเนื้อหาใน session log**:
|
||||
|
||||
```markdown
|
||||
# Session [N] — YYYY-MM-DD ([Topic])
|
||||
|
||||
## Summary
|
||||
|
||||
[สรุปสิ่งที่ทำใน session นี้]
|
||||
|
||||
## ปัญหาที่พบ (Root Cause)
|
||||
|
||||
[อธิบายปัญหาและสาเหตุ]
|
||||
|
||||
## การแก้ไข (Fix)
|
||||
|
||||
| ไฟล์ | การเปลี่ยนแปลง |
|
||||
| -------------- | ---------------------- |
|
||||
| [path/to/file] | [อธิบายการเปลี่ยนแปลง] |
|
||||
|
||||
## กฎที่ Lock แล้ว
|
||||
|
||||
[บันทึก pattern หรือ decision ที่ตกลง]
|
||||
|
||||
## Verification
|
||||
|
||||
[วิธีตรวจสอบว่างานสำเร็จ]
|
||||
```
|
||||
|
||||
3. **อัปเดต `specs/88-logs/rollouts.md`**
|
||||
- เพิ่ม entry ใหม่ในตาราง Recent Rollouts
|
||||
- รูปแบบ: `| วันที่ | Version | รายการ | สถานะ |`
|
||||
|
||||
### 2. อัปเดต Project Memory (ถ้ามี decision ใหม่)
|
||||
|
||||
เมื่อมีการตัดสินใจสำคัญใหม่ให้:
|
||||
|
||||
1. **เปิดไฟล์ `memory/project-memory-override.md`**
|
||||
|
||||
2. **อัปเดตตาราง "Current Decisions (Locked)"**
|
||||
- เพิ่ม entry ใหม่ถ้ามี decision ใหม่
|
||||
- รูปแบบ: `| ID | Decision | ADR |`
|
||||
|
||||
3. **อัปเดต "Next Session Focus"**
|
||||
- เพิ่มงานใหม่ถ้ามี
|
||||
- ทำเครื่องหมาย `[ ]` สำหรับงานที่ยังไม่เสร็จ
|
||||
- ทำเครื่องหมาย `[X]` สำหรับงานที่เสร็จแล้ว
|
||||
|
||||
4. **อัปเดต "Environment & Services"** (ถ้ามีการเปลี่ยนแปลง)
|
||||
- อัปเดต URL, port, หรือ notes ถ้ามีการเปลี่ยน infrastructure
|
||||
|
||||
### 3. อัปเดต MCP Tools (ถ้ามี tools ใหม่)
|
||||
|
||||
เมื่อมี MCP tools ใหม่ให้:
|
||||
|
||||
1. **เปิดไฟล์ `memory/mcp-tools.md`**
|
||||
|
||||
2. **เพิ่ม tool ใหม่ในตาราง "Available Tools"**
|
||||
- รูปแบบ: `| Tool | Purpose | Example Usage |`
|
||||
|
||||
3. **เพิ่ม usage example และ warnings** ถ้าจำเป็น
|
||||
|
||||
### 4. อัปเดต Root Documentation (ถ้ามีการเปลี่ยนแปลง)
|
||||
|
||||
เมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อเอกสารระดับ root ให้:
|
||||
|
||||
1. **ARCHITECTURE.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน architecture หลัก
|
||||
- เพิ่ม/ลบ component สำคัญ
|
||||
- เปลี่ยน data flow หรือ integration pattern
|
||||
|
||||
2. **CHANGELOG.md** — อัปเดตเมื่อ:
|
||||
- Deploy version ใหม่
|
||||
- เพิ่ม feature หรือ breaking change สำคัญ
|
||||
- รูปแบบ: `## [version] (YYYY-MM-DD)` → `### feat(scope): description`
|
||||
|
||||
3. **CONTEXT.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน domain terminology หลัก
|
||||
- เพิ่ม concept ใหม่ที่ใช้ทั่ว project
|
||||
- อัปเดต glossary หรือ business rules
|
||||
|
||||
4. **CONTRIBUTING.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน workflow การทำงาน
|
||||
- เพิ่ม/เปลี่ยน coding standards
|
||||
- อัปเดต CI/CD process
|
||||
|
||||
5. **README.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน project structure
|
||||
- เพิ่ม/เปลี่ยน installation steps
|
||||
- อัปเดต feature overview หรือ tech stack
|
||||
|
||||
## Template สำหรับ Session Log
|
||||
|
||||
```markdown
|
||||
# Session [N] — YYYY-MM-DD ([Topic])
|
||||
|
||||
## Summary
|
||||
|
||||
[สรุปสิ่งที่ทำใน session นี้ใน 1-2 ประโยค]
|
||||
|
||||
## ปัญหาที่พบ (Root Cause)
|
||||
|
||||
[อธิบายปัญหาและสาเหตุหลัก]
|
||||
|
||||
## การแก้ไข (Fix)
|
||||
|
||||
| ไฟล์ | การเปลี่ยนแปลง |
|
||||
| -------------- | ---------------------- |
|
||||
| `path/to/file` | [อธิบายการเปลี่ยนแปลง] |
|
||||
|
||||
## กฎที่ Lock แล้ว
|
||||
|
||||
[บันทึก pattern หรือ decision ที่ตกลงและไม่ควรเปลี่ยน]
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] [check 1]
|
||||
- [ ] [check 2]
|
||||
```
|
||||
|
||||
## ข้อควรระวัง
|
||||
|
||||
- **ห้าม** บันทึก rules ที่ซ้ำกับ specs/ (ADRs, glossary, guidelines)
|
||||
- **ห้าม** บันทึก commands ที่ซ้ำกับ specs/05-Engineering-Guidelines/
|
||||
- **ห้าม** บันทึก environment ที่ซ้ำกับ specs/04-Infrastructure-OPS/
|
||||
- **ใช้** `specs/88-logs/` สำหรับ session history และ rollouts
|
||||
- **ใช้** `memory/project-memory-override.md` สำหรับ OS rules, decisions, environment ที่ไม่มีใน specs
|
||||
- **ใช้** `memory/mcp-tools.md` สำหรับ MCP tools documentation
|
||||
- **อัปเดต Root Documentation** (ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, README.md) เฉพาะเมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อ project architecture, version, terminology, workflow หรือ structure
|
||||
|
||||
## ตัวอย่างการใช้งาน
|
||||
|
||||
### กรณีที่ 1: ทำงาน session ใหม่
|
||||
|
||||
```
|
||||
1. สร้างไฟล์ specs/88-logs/session-2026-06-07-bug-fix.md
|
||||
2. บันทึกปัญหา, การแก้ไข, verification
|
||||
3. อัปเดต specs/88-logs/rollouts.md
|
||||
```
|
||||
|
||||
### กรณีที่ 2: มี decision ใหม่
|
||||
|
||||
```
|
||||
1. เปิด memory/project-memory-override.md
|
||||
2. เพิ่ม entry ใหม่ในตาราง Current Decisions
|
||||
3. อัปเดต Next Session Focus
|
||||
```
|
||||
|
||||
### กรณีที่ 3: เปลี่ยน infrastructure
|
||||
|
||||
```
|
||||
1. เปิด memory/project-memory-override.md
|
||||
2. อัปเดตตาราง Environment & Services
|
||||
3. อัปเดต Key Environment Variables ถ้าจำเป็น
|
||||
```
|
||||
|
||||
### กรณีที่ 4: อัปเดต Root Documentation
|
||||
|
||||
```
|
||||
1. ตรวจสอบว่ามีการเปลี่ยนแปลงที่ส่งผลต่อ ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, หรือ README.md
|
||||
2. อัปเดตไฟล์ที่เกี่ยวข้องตามรูปแบบที่กำหนด
|
||||
3. ตรวจสอบว่าการเปลี่ยนแปลงสอดคล้องกับ specs/ และ ADRs
|
||||
```
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
|
||||
|
||||
**Status**: Production Ready | **Last Updated**: 2026-05-17 | **Total Skills**: 23
|
||||
**Status**: Production Ready | **Last Updated**: 2026-06-07 | **Total Skills**: 24
|
||||
|
||||
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
| **speckit-status** | None | None | Progress tracking |
|
||||
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
||||
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
||||
| **save-memory** | None | None | Session log & memory update |
|
||||
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
||||
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
||||
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
||||
@@ -99,7 +100,7 @@
|
||||
|
||||
### Health Metrics
|
||||
|
||||
- **Total Skills**: 23 implemented
|
||||
- **Total Skills**: 24 implemented
|
||||
- **Version Alignment**: v1.9.0 across all skills
|
||||
- **Template Coverage**: 100% for skills requiring templates
|
||||
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: บันทึก session log และอัปเดต project memory
|
||||
---
|
||||
|
||||
# บันทึก Memory
|
||||
|
||||
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||
|
||||
```bash
|
||||
skill save-memory
|
||||
```
|
||||
@@ -4,8 +4,8 @@ trigger: always_on
|
||||
|
||||
# NAP-DMS Project Context & Rules
|
||||
|
||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- Version: 1.9.6 | Last synced from repo: 2026-05-22
|
||||
- For: Devin Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- Version: 1.9.10 | Last synced from repo: 2026-06-06
|
||||
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
||||
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
||||
|
||||
|
||||
@@ -116,3 +116,99 @@ trigger: always_on
|
||||
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
||||
| "ตรวจแอปจริง" | `.windsurf/workflows/check-real-app.md` | ตรวจ endpoint/UI/console หลัง build pass — No Fake Evidence |
|
||||
| "งานค้าง / resume" | `.windsurf/workflows/resume-pending-work.md` | อ่าน checkpoint เดิม → ตรวจ build → วางแผนต่อโดยไม่ทำงานซ้ำ |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP MariaDB Tools
|
||||
|
||||
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
|
||||
|
||||
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
|
||||
- ตรวจสอบ data ใน production/staging
|
||||
- Validate การเปลี่ยนแปลง schema ก่อน deploy
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||
|------|----------|------------------|
|
||||
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
|
||||
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
|
||||
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
|
||||
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
|
||||
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
|
||||
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
|
||||
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
|
||||
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
|
||||
|
||||
### การใช้งานร่วมกับ Development Flow
|
||||
|
||||
**เมื่อเขียน query ใหม่:**
|
||||
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
|
||||
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
|
||||
|
||||
**เมื่อเปลี่ยน schema (ADR-009):**
|
||||
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
|
||||
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
|
||||
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
|
||||
|
||||
**เมื่อ debug ปัญหา database:**
|
||||
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
|
||||
2. เปรียบเทียบกับ spec และ data dictionary
|
||||
3. ตรวจสอบ foreign keys และ constraints
|
||||
|
||||
### ข้อควรระวัง
|
||||
|
||||
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
|
||||
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
|
||||
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
|
||||
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
|
||||
|
||||
---
|
||||
|
||||
## 🧠 MCP Memory Tools
|
||||
|
||||
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
|
||||
|
||||
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
|
||||
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
|
||||
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||
|------|----------|------------------|
|
||||
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
|
||||
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
|
||||
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
|
||||
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
|
||||
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
|
||||
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
|
||||
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
|
||||
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
|
||||
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
|
||||
|
||||
### การใช้งานร่วมกับ Development Flow
|
||||
|
||||
**เมื่อบันทึก context ใหม่:**
|
||||
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
|
||||
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
|
||||
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
|
||||
|
||||
**เมื่อค้นหา context:**
|
||||
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
|
||||
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
|
||||
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
|
||||
|
||||
**เมื่อแก้ไข context:**
|
||||
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
|
||||
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
|
||||
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
|
||||
|
||||
### ข้อควรระวัง
|
||||
|
||||
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
|
||||
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
|
||||
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
|
||||
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
|
||||
|
||||
+10
-11
@@ -1,8 +1,8 @@
|
||||
# `.agents/skills/` — LCBP3 Agent Skill Pack
|
||||
|
||||
**Version:** 1.9.0 | **Last Updated:** 2026-05-17 | **Total Skills:** 23
|
||||
**Version:** 1.9.0 | **Last Updated:** 2026-06-07 | **Total Skills:** 24
|
||||
|
||||
Agent skills for AI-assisted development in **Windsurf IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
||||
Agent skills for AI-assisted development in **Devin IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,7 @@ Agent skills for AI-assisted development in **Windsurf IDE** (and compatible age
|
||||
├── skills.md # Overview + dependency matrix + health monitoring
|
||||
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
||||
├── README.md # (this file)
|
||||
├── save-memory/ # Session log & project memory update
|
||||
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
||||
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
||||
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
|
||||
@@ -30,12 +31,10 @@ Each skill directory contains:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How Windsurf Invokes These Skills
|
||||
## 🚀 How Devin Invokes These Skills
|
||||
|
||||
Windsurf exposes two entry points:
|
||||
|
||||
1. **Skill tool** — Windsurf discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
||||
2. **Slash commands** — `.windsurf/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
||||
1. **Skill tool** — Devin discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
||||
2. **Slash commands** — `.devin/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
||||
|
||||
Both paths end up executing the same `SKILL.md` instructions.
|
||||
|
||||
@@ -66,13 +65,13 @@ Use `/00-speckit.all` to run specify → clarify → plan → tasks → analyze
|
||||
From repo root:
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
|
||||
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
|
||||
| `./.agents/scripts/bash/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` |
|
||||
| `./.agents/scripts/bash/update-agent-context.sh devin` | Append tech entries to `AGENTS.md` |
|
||||
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
||||
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
||||
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.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.
|
||||
|
||||
@@ -97,7 +96,7 @@ To add a new skill:
|
||||
|
||||
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
|
||||
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
|
||||
3. Wrap with `.windsurf/workflows/NAME.md` so it becomes a slash command.
|
||||
3. Wrap with `.devin/workflows/NAME.md` so it becomes a slash command.
|
||||
4. Update [`skills.md`](./skills.md) dependency matrix.
|
||||
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
||||
|
||||
|
||||
@@ -6454,7 +6454,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
file_id INT,
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||
confidence DECIMAL(4,3),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
output_summary JSON,
|
||||
|
||||
@@ -137,7 +137,7 @@ CREATE TABLE ai_audit_log (
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
file_id INT,
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'typhoon-np-dms-ocr', 'tesseract-ocr'
|
||||
confidence DECIMAL(4,3),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
output_summary JSON,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: save-memory
|
||||
description: บันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||
version: 1.9.0
|
||||
scope: project-management
|
||||
depends-on: []
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# บันทึก Memory (Save Memory)
|
||||
|
||||
Skill นี้ใช้สำหรับบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่ที่ reorganization แล้ว
|
||||
|
||||
## โครงสร้าง Memory ใหม่
|
||||
|
||||
```
|
||||
memory/
|
||||
├── README.md (index + overview)
|
||||
├── mcp-tools.md (MCP MariaDB + Memory Tools)
|
||||
└── project-memory-override.md (OS rules, Current Decisions, Environment, Next Session Focus)
|
||||
|
||||
specs/88-logs/
|
||||
├── rollouts.md (Recent rollouts table)
|
||||
└── session-YYYY-MM-DD-[topic].md (Session logs)
|
||||
```
|
||||
|
||||
## ขั้นตอนการบันทึก Memory
|
||||
|
||||
### 1. สร้าง Session Log (ถ้ามีงาน session ใหม่)
|
||||
|
||||
เมื่อทำงาน session ใหม่ให้:
|
||||
|
||||
1. **สร้างไฟล์ session log ใหม่** ใน `specs/88-logs/`
|
||||
- ชื่อไฟล์: `session-YYYY-MM-DD-[topic].md`
|
||||
- ตัวอย่าง: `session-2026-06-07-memory-reorganization.md`
|
||||
|
||||
2. **บันทึกเนื้อหาใน session log**:
|
||||
|
||||
```markdown
|
||||
# Session [N] — YYYY-MM-DD ([Topic])
|
||||
|
||||
## Summary
|
||||
|
||||
[สรุปสิ่งที่ทำใน session นี้]
|
||||
|
||||
## ปัญหาที่พบ (Root Cause)
|
||||
|
||||
[อธิบายปัญหาและสาเหตุ]
|
||||
|
||||
## การแก้ไข (Fix)
|
||||
|
||||
| ไฟล์ | การเปลี่ยนแปลง |
|
||||
| -------------- | ---------------------- |
|
||||
| [path/to/file] | [อธิบายการเปลี่ยนแปลง] |
|
||||
|
||||
## กฎที่ Lock แล้ว
|
||||
|
||||
[บันทึก pattern หรือ decision ที่ตกลง]
|
||||
|
||||
## Verification
|
||||
|
||||
[วิธีตรวจสอบว่างานสำเร็จ]
|
||||
```
|
||||
|
||||
3. **อัปเดต `specs/88-logs/rollouts.md`**
|
||||
- เพิ่ม entry ใหม่ในตาราง Recent Rollouts
|
||||
- รูปแบบ: `| วันที่ | Version | รายการ | สถานะ |`
|
||||
|
||||
### 2. อัปเดต Project Memory (ถ้ามี decision ใหม่)
|
||||
|
||||
เมื่อมีการตัดสินใจสำคัญใหม่ให้:
|
||||
|
||||
1. **เปิดไฟล์ `memory/project-memory-override.md`**
|
||||
|
||||
2. **อัปเดตตาราง "Current Decisions (Locked)"**
|
||||
- เพิ่ม entry ใหม่ถ้ามี decision ใหม่
|
||||
- รูปแบบ: `| ID | Decision | ADR |`
|
||||
|
||||
3. **อัปเดต "Next Session Focus"**
|
||||
- เพิ่มงานใหม่ถ้ามี
|
||||
- ทำเครื่องหมาย `[ ]` สำหรับงานที่ยังไม่เสร็จ
|
||||
- ทำเครื่องหมาย `[X]` สำหรับงานที่เสร็จแล้ว
|
||||
|
||||
4. **อัปเดต "Environment & Services"** (ถ้ามีการเปลี่ยนแปลง)
|
||||
- อัปเดต URL, port, หรือ notes ถ้ามีการเปลี่ยน infrastructure
|
||||
|
||||
### 3. อัปเดต MCP Tools (ถ้ามี tools ใหม่)
|
||||
|
||||
เมื่อมี MCP tools ใหม่ให้:
|
||||
|
||||
1. **เปิดไฟล์ `memory/mcp-tools.md`**
|
||||
|
||||
2. **เพิ่ม tool ใหม่ในตาราง "Available Tools"**
|
||||
- รูปแบบ: `| Tool | Purpose | Example Usage |`
|
||||
|
||||
3. **เพิ่ม usage example และ warnings** ถ้าจำเป็น
|
||||
|
||||
### 4. อัปเดต Root Documentation (ถ้ามีการเปลี่ยนแปลง)
|
||||
|
||||
เมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อเอกสารระดับ root ให้:
|
||||
|
||||
1. **ARCHITECTURE.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน architecture หลัก
|
||||
- เพิ่ม/ลบ component สำคัญ
|
||||
- เปลี่ยน data flow หรือ integration pattern
|
||||
|
||||
2. **CHANGELOG.md** — อัปเดตเมื่อ:
|
||||
- Deploy version ใหม่
|
||||
- เพิ่ม feature หรือ breaking change สำคัญ
|
||||
- รูปแบบ: `## [version] (YYYY-MM-DD)` → `### feat(scope): description`
|
||||
|
||||
3. **CONTEXT.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน domain terminology หลัก
|
||||
- เพิ่ม concept ใหม่ที่ใช้ทั่ว project
|
||||
- อัปเดต glossary หรือ business rules
|
||||
|
||||
4. **CONTRIBUTING.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน workflow การทำงาน
|
||||
- เพิ่ม/เปลี่ยน coding standards
|
||||
- อัปเดต CI/CD process
|
||||
|
||||
5. **README.md** — อัปเดตเมื่อ:
|
||||
- เปลี่ยน project structure
|
||||
- เพิ่ม/เปลี่ยน installation steps
|
||||
- อัปเดต feature overview หรือ tech stack
|
||||
|
||||
## Template สำหรับ Session Log
|
||||
|
||||
```markdown
|
||||
# Session [N] — YYYY-MM-DD ([Topic])
|
||||
|
||||
## Summary
|
||||
|
||||
[สรุปสิ่งที่ทำใน session นี้ใน 1-2 ประโยค]
|
||||
|
||||
## ปัญหาที่พบ (Root Cause)
|
||||
|
||||
[อธิบายปัญหาและสาเหตุหลัก]
|
||||
|
||||
## การแก้ไข (Fix)
|
||||
|
||||
| ไฟล์ | การเปลี่ยนแปลง |
|
||||
| -------------- | ---------------------- |
|
||||
| `path/to/file` | [อธิบายการเปลี่ยนแปลง] |
|
||||
|
||||
## กฎที่ Lock แล้ว
|
||||
|
||||
[บันทึก pattern หรือ decision ที่ตกลงและไม่ควรเปลี่ยน]
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] [check 1]
|
||||
- [ ] [check 2]
|
||||
```
|
||||
|
||||
## ข้อควรระวัง
|
||||
|
||||
- **ห้าม** บันทึก rules ที่ซ้ำกับ specs/ (ADRs, glossary, guidelines)
|
||||
- **ห้าม** บันทึก commands ที่ซ้ำกับ specs/05-Engineering-Guidelines/
|
||||
- **ห้าม** บันทึก environment ที่ซ้ำกับ specs/04-Infrastructure-OPS/
|
||||
- **ใช้** `specs/88-logs/` สำหรับ session history และ rollouts
|
||||
- **ใช้** `memory/project-memory-override.md` สำหรับ OS rules, decisions, environment ที่ไม่มีใน specs
|
||||
- **ใช้** `memory/mcp-tools.md` สำหรับ MCP tools documentation
|
||||
- **อัปเดต Root Documentation** (ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, README.md) เฉพาะเมื่อมีการเปลี่ยนแปลงที่ส่งผลต่อ project architecture, version, terminology, workflow หรือ structure
|
||||
|
||||
## ตัวอย่างการใช้งาน
|
||||
|
||||
### กรณีที่ 1: ทำงาน session ใหม่
|
||||
|
||||
```
|
||||
1. สร้างไฟล์ specs/88-logs/session-2026-06-07-bug-fix.md
|
||||
2. บันทึกปัญหา, การแก้ไข, verification
|
||||
3. อัปเดต specs/88-logs/rollouts.md
|
||||
```
|
||||
|
||||
### กรณีที่ 2: มี decision ใหม่
|
||||
|
||||
```
|
||||
1. เปิด memory/project-memory-override.md
|
||||
2. เพิ่ม entry ใหม่ในตาราง Current Decisions
|
||||
3. อัปเดต Next Session Focus
|
||||
```
|
||||
|
||||
### กรณีที่ 3: เปลี่ยน infrastructure
|
||||
|
||||
```
|
||||
1. เปิด memory/project-memory-override.md
|
||||
2. อัปเดตตาราง Environment & Services
|
||||
3. อัปเดต Key Environment Variables ถ้าจำเป็น
|
||||
```
|
||||
|
||||
### กรณีที่ 4: อัปเดต Root Documentation
|
||||
|
||||
```
|
||||
1. ตรวจสอบว่ามีการเปลี่ยนแปลงที่ส่งผลต่อ ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, หรือ README.md
|
||||
2. อัปเดตไฟล์ที่เกี่ยวข้องตามรูปแบบที่กำหนด
|
||||
3. ตรวจสอบว่าการเปลี่ยนแปลงสอดคล้องกับ specs/ และ ADRs
|
||||
```
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
|
||||
|
||||
**Status**: Production Ready | **Last Updated**: 2026-05-17 | **Total Skills**: 23
|
||||
**Status**: Production Ready | **Last Updated**: 2026-06-07 | **Total Skills**: 24
|
||||
|
||||
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
| **speckit-status** | None | None | Progress tracking |
|
||||
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
||||
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
||||
| **save-memory** | None | None | Session log & memory update |
|
||||
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
||||
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
||||
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
||||
@@ -99,7 +100,7 @@
|
||||
|
||||
### Health Metrics
|
||||
|
||||
- **Total Skills**: 23 implemented
|
||||
- **Total Skills**: 24 implemented
|
||||
- **Version Alignment**: v1.9.0 across all skills
|
||||
- **Template Coverage**: 100% for skills requiring templates
|
||||
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: บันทึก session log และอัปเดต project memory
|
||||
---
|
||||
|
||||
# บันทึก Memory
|
||||
|
||||
ใช้ skill `save-memory` เพื่อบันทึก session log และอัปเดต project memory ตามโครงสร้างใหม่
|
||||
|
||||
```bash
|
||||
skill save-memory
|
||||
```
|
||||
@@ -377,7 +377,7 @@ function Update-SpecificAgent {
|
||||
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
|
||||
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
||||
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
|
||||
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
|
||||
'devin' { Update-AgentFile -TargetFile $DEVIN_FILE -AgentName 'Devin' }
|
||||
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
|
||||
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
||||
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
||||
@@ -386,7 +386,7 @@ function Update-SpecificAgent {
|
||||
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
|
||||
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
|
||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|devin|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ function Update-AllExistingAgents {
|
||||
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $DEVIN_FILE) { if (-not (Update-AgentFile -TargetFile $DEVIN_FILE -AgentName 'Devin')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
# File: .vscode/setup-terminal.ps1
|
||||
# Change Log:
|
||||
# - 2026-06-07: Initial creation - bypass PSReadline history restoration
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$TargetPath
|
||||
)
|
||||
|
||||
# Disable PSReadline history for this session
|
||||
Set-PSReadlineOption -HistorySaveStyle SaveNothing
|
||||
|
||||
# Change to target directory
|
||||
Set-Location $TargetPath
|
||||
@@ -1,7 +1,7 @@
|
||||
# NAP-DMS Project Context & Rules
|
||||
|
||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||
- Version: 1.9.9 | Last synced from repo: 2026-06-03
|
||||
- Version: 1.9.10 | Last synced from repo: 2026-06-06
|
||||
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
||||
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
||||
|
||||
@@ -460,7 +460,7 @@ Full glossary: `specs/00-overview/00-02-glossary.md`
|
||||
When user asks about... check these files:
|
||||
|
||||
| Request | Status | Files to Check | Expected Response |
|
||||
| --------------------------- | ------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| ------------------------------ | ------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| "สร้าง API ใหม่" | ✅ | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard |
|
||||
| "แก้ฟอร์ม frontend" | ✅ | `05-03-frontend-guidelines.md`, `01-06-edge-cases-and-rules.md` | RHF+Zod + TanStack Query + Thai comments |
|
||||
| "เพิ่ม field ใหม่" | ✅ | `ADR-009`, `03-01-data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity |
|
||||
@@ -487,7 +487,7 @@ When user asks about... check these files:
|
||||
| "AI Admin Console" | ✅ | `ADR-027`, `specs/200-fullstacks/227-ai-admin-console/` | Dynamic model/prompt/intent control; admin-only CASL endpoints |
|
||||
| "Migration refactor" | ✅ | `ADR-028`, `specs/200-fullstacks/228-migration-arch-refactor/` | Staging Queue; post-migration cleanup; validation gates |
|
||||
| "Dynamic Prompt / Prompt" | ✅ | `ADR-029`, `specs/06-Decision-Records/ADR-029-dynamic-prompt-management.md` | ai_prompts table; Redis cache `ai:prompt:active:{type}` TTL 60s |
|
||||
| "AI Model / OCR Active Switch"| ✅ | `ADR-032`, `ADR-033`, `specs/200-fullstacks/233-ai-model-ocr-runner-management/` | Synchronous LLM switches, VRAM Release, sidecar API Key protection |
|
||||
| "AI Model / OCR Active Switch" | ✅ | `ADR-032`, `ADR-033`, `specs/200-fullstacks/233-ai-model-ocr-runner-management/` | Synchronous LLM switches, VRAM Release, sidecar API Key protection |
|
||||
| "จัดการ document numbering" | ✅ | `ADR-002`, `specs/03-Data-and-Storage/03-04-document-numbering.md` | Redis Redlock + template system + preview/override workflows |
|
||||
| "Audit ความปลอดภัย" | ✅ | `ADR-016`, `ADR-019`, `ADR-023`, `ADR-023A` | ตรวจสอบ UUID pattern, CASL Guard, AI Boundary และ Qdrant multi-tenancy |
|
||||
| "แก้ bug / bugfix" | ✅ | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
||||
@@ -501,6 +501,110 @@ When user asks about... check these files:
|
||||
- 🔄 In development
|
||||
- ❌ Not yet started
|
||||
|
||||
---
|
||||
|
||||
## 🔌 MCP MariaDB Tools
|
||||
|
||||
MCP MariaDB server ให้เครื่องมือสำหรับตรวจสอบและจัดการ database โดยตรง ใช้สำหรับ:
|
||||
|
||||
- ตรวจสอบ schema กับ spec file `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||
- Debug ปัญหา database โดยไม่ต้องเข้า MySQL client
|
||||
- ตรวจสอบ data ใน production/staging
|
||||
- Validate การเปลี่ยนแปลง schema ก่อน deploy
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||
| ---------------------------- | ------------------------------ | -------------------------------------------------- |
|
||||
| `mcp1_mysql_test_connection` | ทดสอบ connection กับ database | ตรวจสอบว่า MCP server เชื่อมต่อได้ |
|
||||
| `mcp1_mysql_show_databases` | แสดง databases ทั้งหมด | ดูว่ามี database อะไรบ้าง |
|
||||
| `mcp1_mysql_show_tables` | แสดง tables ทั้งหมดใน database | ดูรายชื่อ tables ใน `lcbp3` |
|
||||
| `mcp1_mysql_describe_table` | ดู structure/columns ของ table | ตรวจสอบ columns, types, keys ของ `correspondences` |
|
||||
| `mcp1_mysql_query` | รัน SELECT query | ดู data ใน table หรือ join query |
|
||||
| `mcp1_mysql_insert` | INSERT data | เพิ่ม seed data หรือ test data |
|
||||
| `mcp1_mysql_update` | UPDATE data | แก้ไข data ใน table |
|
||||
| `mcp1_mysql_delete` | DELETE data | ลบ data ใน table |
|
||||
|
||||
### การใช้งานร่วมกับ Development Flow
|
||||
|
||||
**เมื่อเขียน query ใหม่:**
|
||||
|
||||
1. ใช้ `mcp1_mysql_describe_table` เพื่อตรวจสอบ columns และ types
|
||||
2. เปรียบเทียบกับ `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||
3. ใช้ `mcp1_mysql_query` เพื่อทดสอบ query ก่อน implement
|
||||
|
||||
**เมื่อเปลี่ยน schema (ADR-009):**
|
||||
|
||||
1. ใช้ `mcp1_mysql_describe_table` เพื่อดู structure ปัจจุบัน
|
||||
2. สร้าง SQL delta ใน `specs/03-Data-and-Storage/deltas/`
|
||||
3. ใช้ `mcp1_mysql_query` เพื่อตรวจสอบผลลัพธ์หลัง apply delta
|
||||
|
||||
**เมื่อ debug ปัญหา database:**
|
||||
|
||||
1. ใช้ `mcp1_mysql_query` เพื่อดู data จริง
|
||||
2. เปรียบเทียบกับ spec และ data dictionary
|
||||
3. ตรวจสอบ foreign keys และ constraints
|
||||
|
||||
### ข้อควรระวัง
|
||||
|
||||
- **❌ ห้ามใช้ MCP MariaDB สำหรับ DDL operations** (CREATE/ALTER/DROP) โดยตรง — ต้องใช้ SQL delta ตาม ADR-009
|
||||
- **✅ ใช้สำหรับ DQL/DML operations** (SELECT/INSERT/UPDATE/DELETE) เพื่อ debug และ test เท่านั้น
|
||||
- **⚠️ ระวัง DELETE operations** — อาจทำให้เสีย data ใน production
|
||||
- **✅ ตรวจสอบ schema กับ spec file เสมอ** ก่อนเขียน query
|
||||
|
||||
---
|
||||
|
||||
## 🧠 MCP Memory Tools
|
||||
|
||||
MCP Memory server ให้เครื่องมือสำหรับจัดการ Knowledge Graph และ Long-term Memory ใช้สำหรับ:
|
||||
|
||||
- จัดเก็บความรู้และ context ของโปรเจกต์ในรูปแบบ Graph (Entities + Relations + Observations)
|
||||
- ค้นหาและดึงข้อมูล context จาก memory ที่บันทึกไว้ใน session ก่อนหน้า
|
||||
- สร้าง/แก้ไข/ลบ entities, relations, และ observations ใน knowledge graph
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | หน้าที่ | ตัวอย่างการใช้งาน |
|
||||
| -------------------------- | -------------------------------------------- | -------------------------------------------- |
|
||||
| `mcp3_create_entities` | สร้าง entities ใหม่หลายตัวพร้อม observations | สร้าง entity ใหม่เช่น Project, User, Task |
|
||||
| `mcp3_create_relations` | สร้าง relations ระหว่าง entities | สร้าง relation: Project → has → User |
|
||||
| `mcp3_add_observations` | เพิ่ม observations ให้ entity ที่มีอยู่แล้ว | เพิ่ม context เพิ่มเติมให้ entity |
|
||||
| `mcp3_delete_entities` | ลบ entities และ relations ที่เกี่ยวข้อง | ลบ entity ที่ไม่ใช้แล้ว |
|
||||
| `mcp3_delete_relations` | ลบ relations ระหว่าง entities | ลบ relation ที่ผิดหรือไม่ใช้แล้ว |
|
||||
| `mcp3_delete_observations` | ลบ observations จาก entity | ลบ context ที่ผิดหรือล้าสุด |
|
||||
| `mcp3_open_nodes` | ดึงข้อมูล entities ตามชื่อ | ดึง entity ที่ระบุชื่อ |
|
||||
| `mcp3_read_graph` | อ่าน knowledge graph ทั้งหมด | ดูทั้ง graph structure |
|
||||
| `mcp3_search_nodes` | ค้นหา entities ตาม query | ค้นหา entity จากชื่อ, type, หรือ observation |
|
||||
|
||||
### การใช้งานร่วมกับ Development Flow
|
||||
|
||||
**เมื่อบันทึก context ใหม่:**
|
||||
|
||||
1. ใช้ `mcp3_create_entities` เพื่อสร้าง entities ใหม่ (ถ้ายังไม่มี)
|
||||
2. ใช้ `mcp3_create_relations` เพื่อเชื่อมโยง entities
|
||||
3. ใช้ `mcp3_add_observations` เพื่อเพิ่ม context/observations
|
||||
|
||||
**เมื่อค้นหา context:**
|
||||
|
||||
1. ใช้ `mcp3_search_nodes` เพื่อค้นหา entities ที่เกี่ยวข้อง
|
||||
2. ใช้ `mcp3_open_nodes` เพื่อดึงข้อมูล entities ที่ต้องการ
|
||||
3. ใช้ `mcp3_read_graph` เพื่อดู relations ระหว่าง entities
|
||||
|
||||
**เมื่อแก้ไข context:**
|
||||
|
||||
1. ใช้ `mcp3_add_observations` เพื่อเพิ่ม observations ใหม่
|
||||
2. ใช้ `mcp3_delete_observations` เพื่อลบ observations ที่ผิด
|
||||
3. ใช้ `mcp3_create_relations` หรือ `mcp3_delete_relations` เพื่อปรับ relations
|
||||
|
||||
### ข้อควรระวัง
|
||||
|
||||
- **✅ ใช้สำหรับบันทึก context ที่ต้องใช้ร่วมกันหลาย session** — เช่น การตัดสินใจสำคัญ, architecture decisions, rollout history
|
||||
- **⚠️ ระวังการลบ entities** — อาจทำให้เสีย context ที่ยังใช้งานอยู่
|
||||
- **✅ ตรวจสอบว่า entity มีอยู่แล้วก่อนสร้าง** — ใช้ `mcp3_search_nodes` หรือ `mcp3_open_nodes` ก่อน
|
||||
- **✅ ใช้ชื่อ entity ที่ชัดเจนและไม่ซ้ำกัน** — เพื่อป้องกันความสับสน
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Final Checklists
|
||||
|
||||
### 🔴 Tier 1 — CRITICAL (CI BLOCKER)
|
||||
@@ -612,7 +716,8 @@ This file is a **quick reference**. For detailed information:
|
||||
## 🔄 Change Log
|
||||
|
||||
| Version | Date | Changes | Updated By |
|
||||
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
|
||||
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
||||
| 1.9.10 | 2026-06-06 | Added MCP MariaDB Tools section with available tools (test_connection, show_databases, show_tables, describe_table, query, insert, update, delete), usage guidelines for development flow, and safety warnings for DDL operations; Added MCP Memory Tools section with Knowledge Graph management tools (create_entities, create_relations, add_observations, delete_entities, delete_relations, delete_observations, open_nodes, read_graph, search_nodes) for long-term context storage | Windsurf AI |
|
||||
| 1.9.9 | 2026-06-03 | ADR-034 Thai-Optimized AI Model Stack: typhoon2.5-np-dms:latest (main) + typhoon-np-dms-ocr:latest (OCR); model switching in ai-batch processor; AiSettingsService static constants; SQL delta; updated Key Spec Files + AI isolation rule | Windsurf AI |
|
||||
| 1.9.8 | 2026-06-02 | Added ADR-033 Active Model & OCR Runner Management; implemented Synchronous LLM switches, GPU Memory Auto-release, sidecar `X-API-Key` headers protection; updated Key Spec Files & Specialized Work AI runtime sections | Windsurf AI |
|
||||
| 1.9.7 | 2026-05-25 | Added ADR-029 Dynamic Prompt Management to Key Spec Files table; fixed gemma4 model name e2b→e4b Q8_0; added Dynamic Prompt context trigger; added ADR-029 to Tier 3 AI checklist; bumped last synced date | Windsurf AI |
|
||||
|
||||
+32
-1
@@ -1,5 +1,36 @@
|
||||
# Version History
|
||||
|
||||
## 1.9.10 (2026-06-08)
|
||||
|
||||
### bugfix(ai): Fix LLM JSON Response Truncation in OCR Sandbox & Migration
|
||||
|
||||
#### Summary
|
||||
|
||||
แก้ไขปัญหา LLM JSON Response Truncation ใน OCR Sandbox Step 2 และ Migration Pipeline โดยการขยายขนาดหน้าต่างบริบท `num_ctx` ของ Ollama เป็น `16384` สำหรับงานสกัดข้อมูล (ดำเนินการแก้ไขโดย AGY Gemini 3.5 Flash (Medium))
|
||||
|
||||
#### Changes
|
||||
|
||||
- **Ollama Context Window Expansion**: เพิ่มพารามิเตอร์ `num_ctx: 16384` ใน `processSandboxExtract` และ `processSandboxAiExtract` สำหรับงานสกัดข้อมูลใน Sandbox เพื่อรองรับข้อมูลขนาดใหญ่ (สูงสุด 15,000 ตัวอักษร)
|
||||
- **Migration Pipeline Hardening**: อัปเดต `processMigrateDocument` ให้บังคับส่ง `format: 'json'` และ `options: { num_ctx: 16384, num_predict: 4096 }` ให้ตรงกับพฤติกรรมของ Sandbox
|
||||
- **Regression Tests**: ปรับปรุง Unit Test ใน `ai-batch.processor.spec.ts` เพื่อให้สอดคล้องกับพารามิเตอร์การเรียก Ollama แบบใหม่
|
||||
|
||||
---
|
||||
|
||||
## 1.9.9 (2026-06-06)
|
||||
|
||||
### feat(ai): LLM JSON Parse Failure & VRAM Fix (ADR-035-135)
|
||||
|
||||
#### Summary
|
||||
|
||||
แก้ไขข้อผิดพลาด JSON Parse และหน่วยความจำ VRAM โดยเพิ่มระบบ retry logic และปรับปรุง VRAM switching
|
||||
|
||||
#### Changes
|
||||
|
||||
- **JSON Parse Retry**: เพิ่มระบบ retry logic (2 attempts) สำหรับกรณี JSON parse fail พร้อมแสดงรายละเอียด log
|
||||
- **VRAM limit**: ปรับแต่งค่า `keep_alive=0` สำหรับ OCR model และแก้ปัญหาความจำรั่วไหลใน Node.js/ESLint heap
|
||||
|
||||
---
|
||||
|
||||
## 1.9.8 (2026-06-02)
|
||||
|
||||
### feat(ai): AI Model Swapping, GPU Unloading & OCR Security (ADR-033)
|
||||
@@ -168,7 +199,7 @@
|
||||
|
||||
#### Summary
|
||||
|
||||
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
||||
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Devin/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
||||
|
||||
#### Changes
|
||||
|
||||
|
||||
+36
@@ -200,6 +200,24 @@ _Avoid_: Throw exception from tool, Untyped error
|
||||
| **Thai-Optimized Model** | โมเดล AI ที่ถูก fine-tune มาสำหรับภาษาไทยโดยเฉพาะ (เช่น Typhoon series จาก SCB10X) | Generic model, English-only model |
|
||||
| **Model Unload/Load** | กระบวนการยกเลิกโหลดโมเดลจาก VRAM และโหลดโมเดลใหม่เข้าไปแทน เพื่อสลับการใช้งานระหว่างโมเดลต่างๆ | Model switching (ambiguous), Hot swap |
|
||||
| **Cold Start Penalty** | ความล่าช้า 5-15 วินาทีที่เกิดจากการโหลดโมเดล weights เข้า VRAM หลังจากโมเดลถูก unload (keep_alive: 0) | Initial delay, First-run latency |
|
||||
| **Canonical AI Model Identity** | ชื่อโมเดลหลักที่ระบบ backend, admin console และเอกสารสถาปัตยกรรมใช้อ้างอิงร่วมกันเป็น source of truth เดียว | Alias-only model name, temporary deploy tag |
|
||||
| **Adaptive OCR Residency** | นโยบาย keep_alive ของ OCR model ที่ปรับตาม VRAM headroom และ active model ขณะนั้น แทนการค้างหรือ unload แบบตายตัว | Fixed keep_alive, always-resident OCR |
|
||||
| **Execution Profile** | สัญญาณเชิงนโยบายที่ caller ส่งมาเพื่อบอกระดับความเร็ว/ความแม่นยำ/บริบทที่ต้องการ โดย backend map ต่อไปเป็น model และ parameters ที่อนุญาต | Free-form model key, direct model override |
|
||||
| **Canonical Profile Set** | ชุดค่า `Execution Profile` มาตรฐานที่คงที่ระดับ contract เช่น `fast`, `balanced`, `thai-accurate`, `large-context` แทนการแตก profile ตาม internal pipeline | Job-specific routing key, per-endpoint profile taxonomy |
|
||||
| **Policy-Enforced Profile Override** | กฎที่ backend มีสิทธิ์บังคับ profile สำหรับงานที่มีผลต่อข้อมูลหรือ metadata โดยไม่ยึดค่าที่ caller ส่งมา | Caller-controlled quality for write-affecting jobs, advisory-only governance |
|
||||
| **LLM-First GPU Ownership** | นโยบายจัดลำดับสิทธิ์ VRAM ที่ให้ main LLM และ OCR path มาก่อน embedding/reranking; retrieval side ใช้ GPU ได้เฉพาะเมื่อมี headroom ผ่าน policy | Flat shared GPU pool, equal-priority GPU consumers |
|
||||
| **CPU Fallback Retrieval** | พฤติกรรม degrade ของ embedding/reranking ที่สลับกลับไปใช้ CPU ทันทีเมื่อ GPU headroom ไม่พอ โดยไม่รอคิว GPU | GPU wait queue for retrieval, hard failure on low VRAM |
|
||||
| **Selective Realtime Concurrency** | นโยบายเพิ่ม concurrency ของ `ai-realtime` ได้เฉพาะ job type ที่ไม่แตะ OCR path หรือ model switching; pause/resume coordination หลักยังคงอยู่ | Global realtime concurrency uplift, scheduler rewrite |
|
||||
| **Lightweight Realtime Job** | งานใน `ai-realtime` ที่ไม่เรียก OCR, ไม่บังคับ model switch, และไม่พึ่ง GPU-heavy generation path จึงมีสิทธิ์อยู่ใน concurrency uplift set | RAG query, OCR-triggering job, GPU-heavy generation |
|
||||
| **Generation-Centric RAG Query** | การจัดประเภท `rag-query` ว่าเป็นงาน generation เป็นหลัก โดย retrieval ทำหน้าที่เตรียม context และยอม degrade ได้ | Retrieval-first RAG, search-only job |
|
||||
| **Restricted Large-Context Profile** | โปรไฟล์ `large-context` เป็นความสามารถพิเศษที่จำกัดใช้เฉพาะ admin หรือ special workflows ที่ backend อนุญาต ไม่ใช่ตัวเลือกทั่วไปของ `rag-query` | Public long-context option, caller-driven context inflation |
|
||||
| **Big Bang AI Runtime Rollout** | การเปลี่ยน runtime policy, model identity, และ GPU scheduling หลายส่วนพร้อมกันในรอบ deploy เดียว เพราะระบบยังไม่เปิด production | Phase-gated rollout, incremental policy cutover |
|
||||
| **Big Bang Cutover Gate** | เกณฑ์ผ่านก่อน cutover ที่บังคับให้ policy contract, model switching, adaptive OCR residency, และ RAG fallback ต้องผ่านครบทั้งชุด ไม่รับ partial success | Best-effort rollout, partial completion gate |
|
||||
| **Executable-First Verification** | เกณฑ์ยืนยันผลหลักของ AI runtime rollout ต้องอิง test, log, metric, หรือ trace ที่รันซ้ำได้ แต่แต่ละแกนต้องมี manual validation path สำหรับยืนยันพฤติกรรมเชิงใช้งานจริงประกบเสมอ | Manual-only signoff, unverifiable smoke check |
|
||||
| **Single-Name Canonical Model Policy** | เมื่อประกาศ canonical model identity ใหม่ ชื่อเดียวกันต้องถูกใช้สอดคล้องกันทุกชั้นของระบบที่ผู้ใช้และนักพัฒนาเห็น ส่วนชื่อ base runtime จริงเป็น implementation detail ใน ops/runtime internals เท่านั้น | Dual naming, mixed canonical and base model labels |
|
||||
| **Canonical OCR Identity** | OCR model ต้องใช้ชื่อ canonical เดียวทุกชั้นของระบบเช่น `np-dms-ocr` โดยไม่เปิดชื่อ runtime เดิมเป็น public/internal contract หลัก | Legacy OCR runtime label as primary name, mixed OCR naming |
|
||||
| **Profile-Only Parameter Governance** | API caller ส่งได้เพียง `Execution Profile`; ค่า temperature, top_p, max tokens และ runtime parameters จริงถูกกำหนดโดย backend policy เท่านั้น | Caller parameter override, free-form runtime tuning |
|
||||
| **Integrated Retrieval Acceleration Policy** | การเร่งความเร็ว retrieval เช่น BGE embedding/reranking บน GPU เป็นส่วนหนึ่งของ AI runtime resource policy เดียวกับ main model และ OCR ไม่ใช่งาน optimization แยกอิสระ | Standalone retrieval tuning, separate GPU policy for RAG only |
|
||||
|
||||
---
|
||||
|
||||
@@ -226,6 +244,24 @@ _Avoid_: Throw exception from tool, Untyped error
|
||||
- **"AI = Document Controller"** — resolved: ใช้ **AI Document Assistant** (Suggest + Insight) แทน เพื่อกัน scope creep ไปทาง autonomous agent
|
||||
- **OpenRAG vs ADR-023A** — resolved: **ADR-023A เป็น canonical source** — ใช้ Qdrant + nomic-embed-text สำหรับ vector search; Elasticsearch ใช้สำหรับ keyword/full-text เท่านั้น; `specs/03-Data-and-Storage/03-07-OpenRAG.md` เป็นเอกสาร reference แต่ไม่ใช่ active spec
|
||||
- **".agents/ กับ Production AI"** — resolved: `.agents/` คือ Dev AI toolkit (ช่วยเขียนโค้ด); Production AI คือ AI Gateway + n8n + Ollama — เป็นคนละ layer กัน
|
||||
- **"np-dms-ai" vs `typhoon2.5-np-dms:latest`** — resolved: ถ้าเดินตาม AI refactor ใหม่ `np-dms-ai` คือ **Canonical AI Model Identity** ใหม่ของระบบ ไม่ใช่แค่ deploy alias
|
||||
- **"OCR keep_alive"** — resolved: policy ใหม่ควรถูกอธิบายเป็น **Adaptive OCR Residency** ตาม VRAM headroom และ active model ไม่ใช่ fixed `0` หรือ fixed `300`
|
||||
- **"`model.key` ใน API job request"** — resolved: caller ไม่ควรเลือกชื่อโมเดลตรง ๆ; ควรส่ง **Execution Profile** แล้วให้ backend policy เป็นคน map ไป model/parameters ที่อนุญาต
|
||||
- **"profile names"** — resolved: ใช้ **Canonical Profile Set** แบบเล็กและเสถียร (`fast`, `balanced`, `thai-accurate`, `large-context`) แทนการแตกชื่อ profile ตาม job ภายใน
|
||||
- **"profile สำหรับ migrate-document / auto-fill-document / OCR extraction"** — resolved: ใช้ **Policy-Enforced Profile Override**; backend บังคับ profile เองสำหรับงานที่มีผลต่อข้อมูล ไม่เปิดให้ caller เลือกคุณภาพอย่างอิสระ
|
||||
- **"BGE-M3 / Reranker บน GPU"** — resolved: ถ้าย้ายขึ้น GPU ต้องอยู่ใต้ **LLM-First GPU Ownership**; LLM/OCR มี priority สูงกว่า retrieval path เสมอ
|
||||
- **"embed/rerank ตอน VRAM ไม่พอ"** — resolved: ใช้ **CPU Fallback Retrieval**; retrieval path ต้อง degrade ไป CPU ทันที ไม่รอ GPU queue
|
||||
- **"`ai-realtime = 2`"** — resolved: ใช้ **Selective Realtime Concurrency**; เพิ่มได้เฉพาะงาน realtime ที่ไม่ชนกับ OCR/model switching และยังคง pause/resume model เดิมเป็นแกนหลัก
|
||||
- **"งานไหนได้สิทธิ์ realtime concurrency 2"** — resolved: จำกัดเฉพาะ **Lightweight Realtime Job**; ไม่รวม `rag-query`
|
||||
- **"`rag-query` ควรถูกมองเป็นอะไร"** — resolved: ใช้ **Generation-Centric RAG Query**; main model path เป็น policy หลัก ส่วน retrieval เป็นขั้นเตรียม context ที่ fallback CPU ได้
|
||||
- **"`large-context` ใช้กับอะไร"** — resolved: ใช้ **Restricted Large-Context Profile**; จำกัดเฉพาะ admin/special workflows ไม่เปิดเป็นตัวเลือกทั่วไปของ `rag-query`
|
||||
- **"rollout ของ AI refactor"** — resolved: ใช้ **Big Bang AI Runtime Rollout** แม้มีหลาย runtime policy changes พร้อมกัน เพราะระบบยังไม่เปิด production
|
||||
- **"อะไรคือเกณฑ์ผ่านของ big bang"** — resolved: ใช้ **Big Bang Cutover Gate**; ต้องผ่านครบทั้ง policy contract, model switching, adaptive OCR residency และ RAG fallback
|
||||
- **"evidence แบบไหนนับว่าผ่าน gate"** — resolved: ใช้ **Executable-First Verification** เป็นหลัก แต่ต้องมี manual validation path ควบคู่ในแต่ละแกน
|
||||
- **"`np-dms-ai` ควรตั้งชื่ออย่างไรในระบบ"** — resolved: ใช้ **Single-Name Canonical Model Policy**; `np-dms-ai` เป็นชื่อเดียวทุกชั้นที่ผู้ใช้และนักพัฒนาเห็น
|
||||
- **"`np-dms-ocr` ควรเดินตาม naming policy เดียวกันไหม"** — resolved: ใช้ **Canonical OCR Identity**; `np-dms-ocr` เป็นชื่อ canonical เดียวทุกชั้นเหมือน `np-dms-ai`
|
||||
- **"`temperature/topP/maxTokens` ใครคุม"** — resolved: ใช้ **Profile-Only Parameter Governance**; caller ส่งได้แค่ profile ส่วน runtime parameters จริงให้ backend policy คุมทั้งหมด
|
||||
- **"BGE GPU uplift อยู่ใน scope เดียวกันไหม"** — resolved: ใช้ **Integrated Retrieval Acceleration Policy**; retrieval acceleration เป็นส่วนหนึ่งของ runtime resource policy เดียวกัน
|
||||
|
||||
## ADRs ที่เกี่ยวข้องกับ AI Runtime Layer
|
||||
|
||||
|
||||
+3
-3
@@ -722,19 +722,19 @@ Create `.markdownlint.json`:
|
||||
|
||||
## 🤖 AI-Assisted Contributions
|
||||
|
||||
โปรเจกต์นี้รองรับ AI agents (Windsurf Cascade, Codex CLI, opencode, Amp, Antigravity) ในการเขียน / review / refactor โค้ด — ผ่านคู่มือกลางคือ [`AGENTS.md`](./AGENTS.md) และชุดทักษะใน [`.agents/skills/`](./.agents/skills/)
|
||||
โปรเจกต์นี้รองรับ AI agents (Devin Cascade, Codex CLI, opencode, Amp, Antigravity) ในการเขียน / review / refactor โค้ด — ผ่านคู่มือกลางคือ [`AGENTS.md`](./AGENTS.md) และชุดทักษะใน [`.agents/skills/`](./.agents/skills/)
|
||||
|
||||
### Canonical Rule Sources (อ่านตามลำดับนี้)
|
||||
|
||||
1. **[`AGENTS.md`](./AGENTS.md)** — quick-reference rules + change log (supersedes legacy `GEMINI.md`)
|
||||
2. **[`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)** — shared context loaded by every speckit-\* skill
|
||||
3. **[`.agents/skills/README.md`](./.agents/skills/README.md)** — skill-pack layout + Windsurf invocation guide
|
||||
3. **[`.agents/skills/README.md`](./.agents/skills/README.md)** — skill-pack layout + Devin invocation guide
|
||||
4. `specs/06-Decision-Records/` (โดยเฉพาะ ADR-019 — UUID **March 2026 pattern**)
|
||||
5. `specs/05-Engineering-Guidelines/` (backend / frontend / testing / i18n / git conventions)
|
||||
|
||||
### Invocation (v1.9.0 Unified)
|
||||
|
||||
ใช้ slash commands ผ่านโฟลเดอร์หลักคือ [`.agents/workflows/`](./.agents/workflows/) (ซึ่งถูก Mirror ไปยัง `.windsurf/workflows/` อัตโนมัติ):
|
||||
ใช้ slash commands ผ่านโฟลเดอร์หลักคือ [`.agents/workflows/`](./.agents/workflows/) (ซึ่งถูก Mirror ไปยัง `.devin/workflows/` อัตโนมัติ):
|
||||
|
||||
- `/00-speckit.all` → Full Pipeline (Specify → Validate)
|
||||
- `/102-speckit.specify` → สร้าง spec.md (ต้องระบุหมวดหมู่ 100/200/300)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
> v1.9.7 (ADR-029 + sidecar) May 25; v1.9.8 (ADR-033 Model/OCR Sync & Security) June 2.
|
||||
|
||||
| Area | Status | หมายเหตุ |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------------ |
|
||||
| ---------------------- | ------------------------ | -------------------------------------------------------------- |
|
||||
| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities |
|
||||
| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 |
|
||||
| 💾 **Database** | ✅ Schema v1.9.0 Stable | MariaDB 11.8, No-migration Policy |
|
||||
@@ -297,7 +297,7 @@ lcbp3-dms/
|
||||
│ ├── scripts/ # Audit & Sync scripts
|
||||
│ └── archive/ # Archived outdated tools
|
||||
│
|
||||
├── .windsurf/ # Windsurf-specific (Mirrored from .agents)
|
||||
├── .devin/ # Devin-specific (Mirrored from .agents)
|
||||
│
|
||||
├── .github/ # GitHub Actions workflows
|
||||
├── AGENTS.md # AI agent rules & project context (v1.9.0) [★ primary]
|
||||
@@ -315,7 +315,7 @@ lcbp3-dms/
|
||||
### เอกสารหลัก (specs/ folder)
|
||||
|
||||
| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก |
|
||||
| ----------------------- | -------------------------------------------------------- | --------- | --------------------------------------- |
|
||||
| ----------------------- | ----------------------------------------------------------------- | --------- | --------------------------------------- |
|
||||
| **Product Vision** | Vision, Strategic Pillars, Guardrails | Gap 1 ✅ | `00-03-product-vision.md` |
|
||||
| **User Stories** | 27 Stories, 8 Epics, MoSCoW | Gap 2 ✅ | `01-04-user-stories.md` |
|
||||
| **Acceptance Criteria** | UAT Criteria, Sign-off Process | Gap 3 ✅ | `01-05-acceptance-criteria.md` |
|
||||
@@ -366,7 +366,7 @@ lcbp3-dms/
|
||||
- Development Process
|
||||
- Pull Request Process
|
||||
- Coding Standards
|
||||
- **AI-Assisted Contributions** (AGENTS.md + `.agents/skills/` skill pack + Windsurf slash commands)
|
||||
- **AI-Assisted Contributions** (AGENTS.md + `.agents/skills/` skill pack + Devin slash commands)
|
||||
|
||||
### 🤖 For AI Agents
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"lint:ci": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\" --cache",
|
||||
"lint:ci": "node --max-old-space-size=4096 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\" --cache",
|
||||
"test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance",
|
||||
"test:debug-handles": "jest --config jest.config.js --detectOpenHandles",
|
||||
"test:watch": "jest --config jest.config.js --watch",
|
||||
|
||||
@@ -51,7 +51,6 @@ import { SearchModule } from './modules/search/search.module';
|
||||
import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { RagModule } from './modules/rag/rag.module';
|
||||
import { ReviewTeamModule } from './modules/review-team/review-team.module';
|
||||
import { ResponseCodeModule } from './modules/response-code/response-code.module';
|
||||
import { DelegationModule } from './modules/delegation/delegation.module';
|
||||
@@ -192,7 +191,6 @@ import { TagsModule } from './modules/tags/tags.module';
|
||||
AuditLogModule,
|
||||
MigrationModule,
|
||||
AiModule,
|
||||
RagModule,
|
||||
ReviewTeamModule,
|
||||
ResponseCodeModule,
|
||||
DelegationModule,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// File: backend/src/modules/ai/ai-qdrant.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-05: สร้าง unit test สำหรับ AiQdrantService ครอบคลุม deleteByDocumentPublicId (T4)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AiQdrantService } from './qdrant.service';
|
||||
|
||||
describe('AiQdrantService', () => {
|
||||
let service: AiQdrantService;
|
||||
let mockConfigService: jest.Mocked<ConfigService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockConfigService = {
|
||||
get: jest.fn(),
|
||||
} as unknown as jest.Mocked<ConfigService>;
|
||||
|
||||
mockConfigService.get.mockImplementation((key: string) => {
|
||||
if (key === 'AI_QDRANT_URL' || key === 'QDRANT_URL') {
|
||||
return 'http://localhost:6333';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiQdrantService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AiQdrantService>(AiQdrantService);
|
||||
});
|
||||
|
||||
it('ควรถูกสร้างขึ้นสำเร็จ', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('deleteByDocumentPublicId', () => {
|
||||
it('ควร throw error ถ้า projectPublicId ว่าง', async () => {
|
||||
await expect(
|
||||
service.deleteByDocumentPublicId('', 'doc-uuid-123')
|
||||
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
});
|
||||
|
||||
it('ควร throw error ถ้า projectPublicId เป็น undefined', async () => {
|
||||
await expect(
|
||||
service.deleteByDocumentPublicId(
|
||||
undefined as unknown as string,
|
||||
'doc-uuid-123'
|
||||
)
|
||||
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
});
|
||||
|
||||
it('ควรเรียก Qdrant delete ด้วย filter ที่ถูกต้อง (project_public_id + doc_public_id)', async () => {
|
||||
// Mock QdrantClient.delete method
|
||||
const mockDelete = jest.fn().mockResolvedValue(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||
(service as any).client.delete = mockDelete;
|
||||
|
||||
await service.deleteByDocumentPublicId('proj-uuid-456', 'doc-uuid-123');
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('lcbp3_vectors', {
|
||||
wait: true,
|
||||
filter: {
|
||||
must: [
|
||||
{ key: 'project_public_id', match: { value: 'proj-uuid-456' } },
|
||||
{ key: 'doc_public_id', match: { value: 'doc-uuid-123' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,24 @@ export interface AiRagJobPayload {
|
||||
/** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */
|
||||
export interface AiVectorDeletionJobPayload {
|
||||
documentPublicId: string;
|
||||
projectPublicId: string;
|
||||
requestedByUserPublicId: string;
|
||||
}
|
||||
|
||||
/** Payload สำหรับงาน RAG Prepare เมื่อผู้ใช้ submit workflow */
|
||||
export interface RagPrepareJobPayload {
|
||||
documentPublicId: string;
|
||||
projectPublicId: string;
|
||||
correspondenceNumber: string;
|
||||
docType: string;
|
||||
statusCode: string;
|
||||
revisionNumber: number;
|
||||
subject: string;
|
||||
documentDate?: string;
|
||||
cachedOcrText?: string;
|
||||
attachmentPath?: string;
|
||||
}
|
||||
|
||||
/** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */
|
||||
@Injectable()
|
||||
export class AiQueueService {
|
||||
@@ -92,7 +107,7 @@ export class AiQueueService {
|
||||
payload,
|
||||
{
|
||||
...this.defaultOptions,
|
||||
jobId: payload.documentPublicId,
|
||||
jobId: `${payload.projectPublicId}:${payload.documentPublicId}`,
|
||||
}
|
||||
);
|
||||
return String(job.id);
|
||||
@@ -158,4 +173,23 @@ export class AiQueueService {
|
||||
const waiting = await this.batchQueue.getWaitingCount();
|
||||
return active + waiting;
|
||||
}
|
||||
|
||||
/**
|
||||
* ส่งงาน RAG Prepare เข้า queue เพื่อเตรียมหั่นข้อมูลและทำ embedding ในเบื้องหลัง
|
||||
* @idempotency `jobId = rag-prepare:${documentPublicId}:${revisionNumber}` — ป้องกันการรันซ้ำสำหรับ revision เดียวกัน
|
||||
*/
|
||||
async enqueueRagPrepare(payload: RagPrepareJobPayload): Promise<string> {
|
||||
const job = await this.batchQueue.add(
|
||||
'rag-prepare',
|
||||
{
|
||||
jobType: 'rag-prepare',
|
||||
...payload,
|
||||
},
|
||||
{
|
||||
...this.defaultOptions,
|
||||
jobId: `rag-prepare:${payload.documentPublicId}:${payload.revisionNumber}`,
|
||||
}
|
||||
);
|
||||
return String(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// File: backend/src/modules/ai/ai-rag-pipeline.integration.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-05: สร้าง integration test สำหรับ RAG Pipeline end-to-end (SC-002, Gap fix)
|
||||
// ครอบคลุม: enqueueRagPrepare jobId dedup, EmbeddingService pipeline, project isolation
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { AiQueueService, RagPrepareJobPayload } from './ai-queue.service';
|
||||
import { EmbeddingService } from './services/embedding.service';
|
||||
import { OllamaService } from './services/ollama.service';
|
||||
import { OcrService } from './services/ocr.service';
|
||||
import { AiQdrantService } from './qdrant.service';
|
||||
import { AiPromptsService } from './prompts/ai-prompts.service';
|
||||
import {
|
||||
QUEUE_AI_INGEST,
|
||||
QUEUE_AI_RAG,
|
||||
QUEUE_AI_VECTOR_DELETION,
|
||||
QUEUE_AI_BATCH,
|
||||
} from '../common/constants/queue.constants';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Mock helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
/** สร้าง mock BullMQ Queue ที่ track jobId เพื่อ verify deduplication */
|
||||
const createMockQueue = () => {
|
||||
return {
|
||||
add: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(name: string, data: unknown, opts: { jobId?: string } = {}) =>
|
||||
Promise.resolve({ id: opts.jobId ?? 'auto-id' })
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** สร้าง mock EmbeddingService dependencies */
|
||||
const buildEmbeddingModule = async (
|
||||
ollamaGenerateResponse: string,
|
||||
chunkSize = 512,
|
||||
chunkOverlap = 64
|
||||
) => {
|
||||
const mockOllamaService = {
|
||||
generate: jest.fn().mockResolvedValue(ollamaGenerateResponse),
|
||||
};
|
||||
const mockAiPromptsService = {
|
||||
resolveActive: jest.fn().mockResolvedValue({
|
||||
resolvedPrompt: 'แบ่ง OCR text ออกเป็น chunks',
|
||||
versionNumber: 1,
|
||||
}),
|
||||
};
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string, def?: unknown) => {
|
||||
const vals: Record<string, unknown> = {
|
||||
EMBEDDING_CHUNK_SIZE: chunkSize,
|
||||
EMBEDDING_CHUNK_OVERLAP: chunkOverlap,
|
||||
};
|
||||
return vals[key] ?? def;
|
||||
}),
|
||||
};
|
||||
const mockEmbedViaSidecar = jest.fn().mockResolvedValue({
|
||||
dense: Array(1024).fill(0.1),
|
||||
sparse: { indices: [10, 20], values: [0.8, 0.4] },
|
||||
});
|
||||
const mockDeleteByDocumentPublicId = jest.fn().mockResolvedValue(undefined);
|
||||
const mockUpsert = jest.fn().mockResolvedValue(undefined);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmbeddingService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: OllamaService, useValue: mockOllamaService },
|
||||
{
|
||||
provide: AiQdrantService,
|
||||
useValue: {
|
||||
deleteByDocumentPublicId: mockDeleteByDocumentPublicId,
|
||||
upsert: mockUpsert,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OcrService,
|
||||
useValue: { embedViaSidecar: mockEmbedViaSidecar },
|
||||
},
|
||||
{ provide: AiPromptsService, useValue: mockAiPromptsService },
|
||||
],
|
||||
}).compile();
|
||||
return {
|
||||
service: module.get<EmbeddingService>(EmbeddingService),
|
||||
mockEmbedViaSidecar,
|
||||
mockDeleteByDocumentPublicId,
|
||||
mockUpsert,
|
||||
mockOllamaService,
|
||||
};
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
describe('RAG Pipeline — Integration (SC-002 / Gap fixes)', () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test Group 1: BullMQ Job Deduplication (Gap 1 verify)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('enqueueRagPrepare — jobId deduplication', () => {
|
||||
let queueService: AiQueueService;
|
||||
let mockBatchQueue: ReturnType<typeof createMockQueue>;
|
||||
beforeEach(async () => {
|
||||
mockBatchQueue = createMockQueue();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiQueueService,
|
||||
{
|
||||
provide: getQueueToken(QUEUE_AI_INGEST),
|
||||
useValue: { add: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getQueueToken(QUEUE_AI_RAG),
|
||||
useValue: { add: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
|
||||
useValue: { add: jest.fn() },
|
||||
},
|
||||
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockBatchQueue },
|
||||
],
|
||||
}).compile();
|
||||
queueService = module.get<AiQueueService>(AiQueueService);
|
||||
});
|
||||
it('ควรสร้าง jobId = rag-prepare:{documentPublicId}:{revisionNumber} (SC-004 dedup)', async () => {
|
||||
const payload: RagPrepareJobPayload = {
|
||||
documentPublicId: 'doc-uuid-001',
|
||||
projectPublicId: 'proj-uuid-abc',
|
||||
correspondenceNumber: 'CORR-2026-001',
|
||||
docType: 'LETTER',
|
||||
statusCode: 'SUBOWN',
|
||||
revisionNumber: 1,
|
||||
subject: 'เอกสารทดสอบ Dedup',
|
||||
};
|
||||
await queueService.enqueueRagPrepare(payload);
|
||||
const calls = mockBatchQueue.add.mock.calls as [
|
||||
string,
|
||||
unknown,
|
||||
{ jobId?: string },
|
||||
][];
|
||||
expect(calls[0][2]?.jobId).toBe('rag-prepare:doc-uuid-001:1');
|
||||
});
|
||||
it('ควร enqueue ด้วยชื่อ job rag-prepare และ payload ครบ', async () => {
|
||||
const payload: RagPrepareJobPayload = {
|
||||
documentPublicId: 'doc-uuid-002',
|
||||
projectPublicId: 'proj-uuid-xyz',
|
||||
correspondenceNumber: 'CORR-2026-002',
|
||||
docType: 'RFA',
|
||||
statusCode: 'CLBOWN',
|
||||
revisionNumber: 0,
|
||||
subject: 'RFA Test',
|
||||
documentDate: '2026-06-05',
|
||||
attachmentPath: '/files/rfa.pdf',
|
||||
};
|
||||
await queueService.enqueueRagPrepare(payload);
|
||||
expect(mockBatchQueue.add).toHaveBeenCalledWith(
|
||||
'rag-prepare',
|
||||
expect.objectContaining({
|
||||
jobType: 'rag-prepare',
|
||||
documentPublicId: 'doc-uuid-002',
|
||||
revisionNumber: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
jobId: 'rag-prepare:doc-uuid-002:0',
|
||||
attempts: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
it('ควรคืน jobId เดิมเมื่อ enqueue revision เดียวกัน 2 ครั้ง (idempotency)', async () => {
|
||||
const payload: RagPrepareJobPayload = {
|
||||
documentPublicId: 'doc-same',
|
||||
projectPublicId: 'proj-same',
|
||||
correspondenceNumber: 'CORR-SAME',
|
||||
docType: 'LETTER',
|
||||
statusCode: 'SUBOWN',
|
||||
revisionNumber: 3,
|
||||
subject: 'Idempotency Test',
|
||||
};
|
||||
const id1 = await queueService.enqueueRagPrepare(payload);
|
||||
const id2 = await queueService.enqueueRagPrepare(payload);
|
||||
// jobId เหมือนกัน — BullMQ จะ deduplicate ที่ server side
|
||||
expect(id1).toBe(id2);
|
||||
const calls = mockBatchQueue.add.mock.calls as [
|
||||
string,
|
||||
unknown,
|
||||
{ jobId?: string },
|
||||
][];
|
||||
expect(calls[0][2]?.jobId).toBe(calls[1][2]?.jobId);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test Group 2: processRagPrepare → EmbeddingService pipeline (SC-002)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('EmbeddingService.embedDocument — full pipeline (SC-002)', () => {
|
||||
const semanticLlmResponse =
|
||||
'<chunk topic="บทนำ">เนื้อหาบทนำของเอกสารที่มีความยาวเพียงพอสำหรับการทดสอบ</chunk>' +
|
||||
'<chunk topic="รายละเอียด">เนื้อหารายละเอียดของเอกสารฉบับนี้ครอบคลุมหัวข้อสำคัญ</chunk>';
|
||||
const ocrText =
|
||||
'เนื้อหาเอกสารที่มีความยาวเกิน 50 ตัวอักษร สำหรับทดสอบ RAG pipeline integration test ครบ pipeline';
|
||||
it('SC-002: ควรเรียก Sidecar /embed และ Qdrant upsert สำหรับ semantic chunks', async () => {
|
||||
const {
|
||||
service,
|
||||
mockEmbedViaSidecar,
|
||||
mockDeleteByDocumentPublicId,
|
||||
mockUpsert,
|
||||
} = await buildEmbeddingModule(semanticLlmResponse);
|
||||
const result = await service.embedDocument(
|
||||
'proj-uuid-123',
|
||||
'doc-uuid-456',
|
||||
'CORR-2026-001',
|
||||
'LETTER',
|
||||
'SUBOWN',
|
||||
1,
|
||||
'Test Subject',
|
||||
'2026-06-05',
|
||||
ocrText
|
||||
);
|
||||
// ตรวจสอบว่า Sidecar /embed ถูกเรียกสำหรับแต่ละ semantic chunk (2 chunks)
|
||||
expect(mockEmbedViaSidecar).toHaveBeenCalledTimes(2);
|
||||
// ตรวจสอบว่าลบ points เก่าก่อน upsert (delete-before-upsert)
|
||||
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
|
||||
'proj-uuid-123',
|
||||
'doc-uuid-456'
|
||||
);
|
||||
// ตรวจสอบ upsert payload ครบ 11 fields
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
'proj-uuid-123',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
doc_public_id: 'doc-uuid-456',
|
||||
project_public_id: 'proj-uuid-123',
|
||||
doc_number: 'CORR-2026-001',
|
||||
doc_type: 'LETTER',
|
||||
status_code: 'SUBOWN',
|
||||
revision_number: 1,
|
||||
subject: 'Test Subject',
|
||||
document_date: '2026-06-05',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.chunksEmbedded).toBe(2);
|
||||
});
|
||||
it('SC-003: project isolation — upsert และ delete ต้องใช้ projectPublicId ที่ถูกต้อง', async () => {
|
||||
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
|
||||
await buildEmbeddingModule(semanticLlmResponse);
|
||||
await service.embedDocument(
|
||||
'proj-ISOLATED-999',
|
||||
'doc-iso',
|
||||
'CORR-ISO',
|
||||
'LETTER',
|
||||
'SUBOWN',
|
||||
0,
|
||||
'Subject',
|
||||
undefined,
|
||||
ocrText
|
||||
);
|
||||
// deleteByDocumentPublicId ต้องใช้ projectPublicId ที่ถูกต้อง
|
||||
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
|
||||
'proj-ISOLATED-999',
|
||||
'doc-iso'
|
||||
);
|
||||
// upsert ต้องส่ง projectPublicId ที่ถูกต้องเป็น arg แรก
|
||||
const upsertCalls = mockUpsert.mock.calls as [string, unknown][];
|
||||
expect(upsertCalls[0][0]).toBe('proj-ISOLATED-999');
|
||||
});
|
||||
it('SC-006: ลำดับ delete → upsert ต้องถูกต้องเสมอ (ป้องกัน stale chunks)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
|
||||
await buildEmbeddingModule(semanticLlmResponse);
|
||||
mockDeleteByDocumentPublicId.mockImplementationOnce(() => {
|
||||
callOrder.push('delete');
|
||||
});
|
||||
mockUpsert.mockImplementationOnce(() => {
|
||||
callOrder.push('upsert');
|
||||
});
|
||||
await service.embedDocument(
|
||||
'proj-x',
|
||||
'doc-stale',
|
||||
'CORR-X',
|
||||
'LETTER',
|
||||
'SUBOWN',
|
||||
2,
|
||||
'Sub',
|
||||
undefined,
|
||||
ocrText
|
||||
);
|
||||
// ตรวจสอบลำดับ: delete ต้องเกิดก่อน upsert เสมอ (SC-006)
|
||||
expect(callOrder).toEqual(['delete', 'upsert']);
|
||||
});
|
||||
it('ควรคืน success=false เมื่อ ocrText ว่าง (edge case — skip guard)', async () => {
|
||||
const { service } = await buildEmbeddingModule(semanticLlmResponse);
|
||||
const result = await service.embedDocument(
|
||||
'proj-x',
|
||||
'doc-empty',
|
||||
'CORR-X',
|
||||
'LETTER',
|
||||
'SUBOWN',
|
||||
1,
|
||||
'Sub',
|
||||
undefined,
|
||||
''
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No OCR text');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test Group 3: Semantic Chunking fallback → fixed-size (FR-005)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Semantic Chunking fallback (FR-005)', () => {
|
||||
it('ควร fallback เป็น fixed-size และยังคง embed ได้ เมื่อ LLM output ไม่มี <chunk> tag', async () => {
|
||||
const { service, mockEmbedViaSidecar, mockUpsert } =
|
||||
await buildEmbeddingModule(
|
||||
'ไม่มี tag chunk เลย — plain text output',
|
||||
60,
|
||||
0
|
||||
);
|
||||
const ocrText = 'ก'.repeat(80); // 80 chars → 2 chunks (60 + 20 chars)
|
||||
const result = await service.embedDocument(
|
||||
'proj-fallback',
|
||||
'doc-fallback',
|
||||
'CORR-FB',
|
||||
'LETTER',
|
||||
'SUBOWN',
|
||||
1,
|
||||
'Fallback',
|
||||
undefined,
|
||||
ocrText
|
||||
);
|
||||
// fallback ยังต้อง embed ได้
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.chunksEmbedded).toBeGreaterThan(0);
|
||||
expect(mockEmbedViaSidecar).toHaveBeenCalled();
|
||||
// ตรวจสอบว่า chunk_topic มาจาก fixed-size (ขึ้นต้นด้วย "ส่วนที่")
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const upsertPoints = mockUpsert.mock.calls[0]?.[1] as Array<{
|
||||
payload: { chunk_topic: string };
|
||||
}>;
|
||||
|
||||
expect(upsertPoints[0]?.payload.chunk_topic).toMatch(/ส่วนที่/);
|
||||
});
|
||||
it('ควร fallback ทันทีเมื่อ LLM throw error', async () => {
|
||||
const { service, mockUpsert, mockOllamaService } =
|
||||
await buildEmbeddingModule('', 60, 0);
|
||||
mockOllamaService.generate.mockRejectedValueOnce(
|
||||
new Error('Ollama timeout')
|
||||
);
|
||||
const ocrText = 'ก'.repeat(80);
|
||||
const result = await service.embedDocument(
|
||||
'proj-err',
|
||||
'doc-err',
|
||||
'CORR-ERR',
|
||||
'LETTER',
|
||||
'SUBOWN',
|
||||
1,
|
||||
'Sub',
|
||||
undefined,
|
||||
ocrText
|
||||
);
|
||||
// ถึงแม้ LLM throw แต่ fallback ยังทำงาน
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockUpsert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// File: backend/src/modules/ai/ai-rag.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-05: สร้าง unit test สำหรับ AiRagService เพื่อทดสอบกระบวนการทำ RAG query ด้วย Hybrid Search และ Reranker (T011)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { AiRagService } from './ai-rag.service';
|
||||
import { AiQdrantService } from './qdrant.service';
|
||||
import { OcrService } from './services/ocr.service';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
||||
|
||||
describe('AiRagService (US1 — Chat Q&A)', () => {
|
||||
let service: AiRagService;
|
||||
let qdrantService: AiQdrantService;
|
||||
let ocrService: OcrService;
|
||||
|
||||
const mockRedis = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
|
||||
const values: Record<string, unknown> = {
|
||||
OLLAMA_URL: 'http://localhost:11434',
|
||||
OLLAMA_RAG_MODEL: 'typhoon2.5-np-dms:latest',
|
||||
RAG_TIMEOUT_MS: 30000,
|
||||
RAG_CONTEXT_LIMIT_CHARS: 3000,
|
||||
};
|
||||
return values[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
const mockQdrantService = {
|
||||
searchByProject: jest.fn(),
|
||||
};
|
||||
|
||||
const mockOcrService = {
|
||||
embedViaSidecar: jest.fn(),
|
||||
rerankViaSidecar: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiRagService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: AiQdrantService, useValue: mockQdrantService },
|
||||
{ provide: OcrService, useValue: mockOcrService },
|
||||
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AiRagService>(AiRagService);
|
||||
qdrantService = module.get<AiQdrantService>(AiQdrantService);
|
||||
ocrService = module.get<OcrService>(OcrService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('processQuery()', () => {
|
||||
it('ควรเรียกใช้ embedViaSidecar, searchByProject, rerankViaSidecar และจบด้วยการสร้างคำตอบด้วย LLM', async () => {
|
||||
// Setup mock data
|
||||
const mockDenseVector = Array(1024).fill(0.1);
|
||||
const mockSparseVector = { indices: [1, 2], values: [0.5, 0.6] };
|
||||
|
||||
mockOcrService.embedViaSidecar.mockResolvedValueOnce({
|
||||
dense: mockDenseVector,
|
||||
sparse: mockSparseVector,
|
||||
});
|
||||
|
||||
const mockQdrantResults = [
|
||||
{
|
||||
pointId: 'point-1',
|
||||
score: 0.85,
|
||||
payload: {
|
||||
doc_type: 'LETTER',
|
||||
doc_number: 'CORR-001',
|
||||
chunk_text: 'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
|
||||
},
|
||||
},
|
||||
{
|
||||
pointId: 'point-2',
|
||||
score: 0.72,
|
||||
payload: {
|
||||
doc_type: 'LETTER',
|
||||
doc_number: 'CORR-002',
|
||||
chunk_text: 'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockQdrantService.searchByProject.mockResolvedValueOnce(
|
||||
mockQdrantResults
|
||||
);
|
||||
|
||||
mockOcrService.rerankViaSidecar.mockResolvedValueOnce({
|
||||
scores: [0.95, 0.45],
|
||||
ranked_indices: [0, 1],
|
||||
});
|
||||
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
response: 'คำตอบที่ได้รับความช่วยเหลือจาก LLM อ้างอิงเอกสาร CORR-001',
|
||||
},
|
||||
});
|
||||
|
||||
// Run query
|
||||
await service.processQuery(
|
||||
'req-123',
|
||||
'ต้องการอนุมัติโครงการอย่างไร?',
|
||||
'proj-456',
|
||||
'user-789'
|
||||
);
|
||||
|
||||
// Verify pipeline calls
|
||||
expect(ocrService.embedViaSidecar).toHaveBeenCalledWith(
|
||||
'ต้องการอนุมัติโครงการอย่างไร?'
|
||||
);
|
||||
expect(qdrantService.searchByProject).toHaveBeenCalledWith(
|
||||
mockDenseVector,
|
||||
mockSparseVector,
|
||||
'proj-456',
|
||||
15
|
||||
);
|
||||
expect(ocrService.rerankViaSidecar).toHaveBeenCalledWith(
|
||||
'ต้องการอนุมัติโครงการอย่างไร?',
|
||||
[
|
||||
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
|
||||
'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
|
||||
]
|
||||
);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({
|
||||
model: 'typhoon2.5-np-dms:latest',
|
||||
prompt: expect.stringContaining(
|
||||
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline'
|
||||
),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Verify saving job status
|
||||
expect(mockRedis.setex).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ai:rag:result:req-123'),
|
||||
expect.any(Number),
|
||||
expect.stringContaining('completed')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
// File: src/modules/ai/ai-rag.service.ts
|
||||
// File: backend/src/modules/ai/ai-rag.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
|
||||
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
|
||||
// - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1).
|
||||
// Service จัดการ RAG query ผ่าน Ollama + AiQdrantService (project-isolated)
|
||||
// - 2026-06-05: ปรับปรุงใช้ Hybrid Search + Reranker ผ่าน Sidecar ตาม ADR-035 (T015, T030)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -11,6 +11,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import axios from 'axios';
|
||||
import { AiQdrantService } from './qdrant.service';
|
||||
import { OcrService } from './services/ocr.service';
|
||||
|
||||
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
|
||||
export interface AiRagCitation {
|
||||
@@ -44,7 +45,6 @@ export class AiRagService {
|
||||
private readonly logger = new Logger(AiRagService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly ollamaModel: string;
|
||||
private readonly ollamaEmbedModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
|
||||
private readonly promptContextLimit: number;
|
||||
@@ -52,6 +52,7 @@ export class AiRagService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly qdrantService: AiQdrantService,
|
||||
private readonly ocrService: OcrService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
@@ -62,10 +63,6 @@ export class AiRagService {
|
||||
'OLLAMA_RAG_MODEL',
|
||||
'gemma2'
|
||||
);
|
||||
this.ollamaEmbedModel = this.configService.get<string>(
|
||||
'OLLAMA_EMBED_MODEL',
|
||||
'nomic-embed-text'
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
||||
this.promptContextLimit = this.configService.get<number>(
|
||||
'RAG_CONTEXT_LIMIT_CHARS',
|
||||
@@ -159,10 +156,11 @@ export class AiRagService {
|
||||
|
||||
/**
|
||||
* ประมวลผล RAG query:
|
||||
* 1. Embed คำถาม
|
||||
* 2. ค้นหา Qdrant ด้วย project isolation (T020 — enforced in AiQdrantService.searchByProject)
|
||||
* 3. Build prompt จาก context
|
||||
* 4. Generate คำตอบผ่าน Ollama (รองรับ AbortSignal สำหรับ T022)
|
||||
* 1. Embed คำถามด้วย BGE-M3 (Dense + Sparse) ผ่าน Sidecar /embed (T015)
|
||||
* 2. ค้นหา Qdrant ด้วย Hybrid Search + project isolation (T015)
|
||||
* 3. Rerank ด้วย BGE-Reranker-Large ผ่าน Sidecar /rerank (T015)
|
||||
* 4. Build prompt จาก context
|
||||
* 5. Generate คำตอบผ่าน Ollama
|
||||
*/
|
||||
async processQuery(
|
||||
requestPublicId: string,
|
||||
@@ -182,8 +180,8 @@ export class AiRagService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. สร้าง embedding สำหรับคำถาม
|
||||
const queryVector = await this.embed(question, signal);
|
||||
// 1. สร้าง embedding สำหรับคำถามด้วย BGE-M3 ผ่าน Sidecar
|
||||
const embedResult = await this.ocrService.embedViaSidecar(question);
|
||||
|
||||
// ตรวจสอบ cancel อีกครั้งหลัง embed
|
||||
if (
|
||||
@@ -195,17 +193,15 @@ export class AiRagService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002)
|
||||
// 2. ค้นหา Qdrant ด้วย Hybrid search และกรองตาม project
|
||||
const searchResults = await this.qdrantService.searchByProject(
|
||||
queryVector,
|
||||
embedResult.dense,
|
||||
embedResult.sparse,
|
||||
projectPublicId,
|
||||
10
|
||||
15 // topK=15 ตาม FR-014
|
||||
);
|
||||
|
||||
// 3. สร้าง context จาก search results
|
||||
const context = this.buildContext(searchResults);
|
||||
|
||||
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
|
||||
// ตรวจสอบ cancel หลัง search
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(await this.redis.get(this.cancelKey(requestPublicId)))
|
||||
@@ -215,25 +211,74 @@ export class AiRagService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Generate คำตอบผ่าน Ollama (ส่ง signal เพื่อรองรับ T022)
|
||||
// 3. Rerank ผลลัพธ์การค้นหา
|
||||
let finalResults = searchResults;
|
||||
const rawChunks = searchResults
|
||||
.map(
|
||||
(r) =>
|
||||
(r.payload['chunk_text'] as string) ||
|
||||
(r.payload['content_preview'] as string) ||
|
||||
''
|
||||
)
|
||||
.filter((c) => c.trim().length > 0);
|
||||
|
||||
if (rawChunks.length > 0) {
|
||||
this.logger.log(
|
||||
`Calling Sidecar /rerank for ${rawChunks.length} candidates...`
|
||||
);
|
||||
const rerankResult = await this.ocrService.rerankViaSidecar(
|
||||
question,
|
||||
rawChunks
|
||||
);
|
||||
|
||||
// เลือก top 3-5 chunks ที่ได้คะแนนสูงสุด
|
||||
const topN = Math.min(5, rerankResult.ranked_indices.length);
|
||||
finalResults = [];
|
||||
for (let i = 0; i < topN; i++) {
|
||||
const originalIndex = rerankResult.ranked_indices[i];
|
||||
finalResults.push(searchResults[originalIndex]);
|
||||
}
|
||||
|
||||
// Log รายละเอียดการจัดอันดับ (T030)
|
||||
this.logger.log(
|
||||
`Reranking completed: candidates input ${searchResults.length} -> output ${finalResults.length}. ` +
|
||||
`Top-1 score: ${rerankResult.scores[rerankResult.ranked_indices[0]]?.toFixed(4) ?? 'N/A'}`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. สร้าง context จาก search results
|
||||
const context = this.buildContext(finalResults);
|
||||
|
||||
// ตรวจสอบ cancel ก่อนเรียก LLM
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(await this.redis.get(this.cancelKey(requestPublicId)))
|
||||
) {
|
||||
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
|
||||
await this.clearActiveJob(userPublicId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Generate คำตอบผ่าน Ollama
|
||||
const { answer, usedFallback } = await this.generateAnswer(
|
||||
this.sanitizeInput(question),
|
||||
context,
|
||||
signal
|
||||
);
|
||||
|
||||
const citations: AiRagCitation[] = searchResults.map((r) => ({
|
||||
const citations: AiRagCitation[] = finalResults.map((r) => ({
|
||||
pointId: r.pointId,
|
||||
score: r.score,
|
||||
docType: r.payload['doc_type'] as string | undefined,
|
||||
docNumber: r.payload['doc_number'] as string | undefined,
|
||||
snippet: (r.payload['content_preview'] as string | undefined)?.slice(
|
||||
0,
|
||||
200
|
||||
),
|
||||
snippet: (
|
||||
(r.payload['chunk_text'] as string) ||
|
||||
(r.payload['content_preview'] as string) ||
|
||||
''
|
||||
).slice(0, 200),
|
||||
}));
|
||||
|
||||
const confidence = searchResults.length > 0 ? searchResults[0].score : 0;
|
||||
const confidence = finalResults.length > 0 ? finalResults[0].score : 0;
|
||||
|
||||
await this.saveJobResult({
|
||||
requestPublicId,
|
||||
@@ -266,17 +311,7 @@ export class AiRagService {
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** สร้าง embedding vector สำหรับข้อความ */
|
||||
private async embed(text: string, signal?: AbortSignal): Promise<number[]> {
|
||||
const response = await axios.post<{ embedding: number[] }>(
|
||||
`${this.ollamaUrl}/api/embeddings`,
|
||||
{ model: this.ollamaEmbedModel, prompt: text },
|
||||
{ timeout: this.timeoutMs, signal }
|
||||
);
|
||||
return response.data.embedding;
|
||||
}
|
||||
|
||||
/** Generate คำตอบจาก Ollama (รองรับ AbortSignal สำหรับ T022 FR-011) */
|
||||
/** Generate คำตอบจาก Ollama */
|
||||
private async generateAnswer(
|
||||
question: string,
|
||||
context: string,
|
||||
@@ -291,7 +326,6 @@ export class AiRagService {
|
||||
);
|
||||
return { answer: response.data.response ?? '', usedFallback: false };
|
||||
} catch (err: unknown) {
|
||||
// ถ้าเป็น cancellation error ให้ re-throw เพื่อให้ processQuery จัดการ
|
||||
if (
|
||||
axios.isCancel(err) ||
|
||||
(err instanceof Error && err.name === 'CanceledError')
|
||||
@@ -313,7 +347,10 @@ export class AiRagService {
|
||||
for (const r of results) {
|
||||
const docType = (r.payload['doc_type'] as string) ?? '';
|
||||
const docNumber = (r.payload['doc_number'] as string) ?? '';
|
||||
const preview = (r.payload['content_preview'] as string) ?? '';
|
||||
const preview =
|
||||
(r.payload['chunk_text'] as string) ??
|
||||
(r.payload['content_preview'] as string) ??
|
||||
'';
|
||||
const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
|
||||
const snippet = `${header}\n${preview}\n\n`;
|
||||
if ((context + snippet).length > this.promptContextLimit) break;
|
||||
|
||||
@@ -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-06-01: [BUGFIX] submitSandboxOcr: เพิ่ม @ApiBearerAuth(), @HttpCode(ACCEPTED), Body({ engineType }) และส่ง engineType ไปยัง enqueueSandboxJob
|
||||
// - 2026-06-02: เพิ่ม REST endpoints GET /ai/ocr-engines และ POST /ai/ocr-engines/:engineId/select (T003, T004, ADR-033) และนำเข้า SystemException เพื่อป้องกันความเสียหายในการคอมไพล์
|
||||
// - 2026-06-06: [BUGFIX] เพิ่ม @Throttle({ default: { limit: 300, ttl: 60000 } }) บน GET admin/sandbox/job/:id เพื่อแก้ ThrottlerException spam จาก frontend polling
|
||||
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
||||
|
||||
import {
|
||||
@@ -452,6 +453,7 @@ export class AiController {
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@Throttle({ default: { limit: 300, ttl: 60000 } }) // 300 req/min — รองรับ admin polling ทุก 200ms
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// File: src/modules/ai/processors/ai-batch.processor.spec.ts
|
||||
// Change Log
|
||||
// - 2026-06-08: เพิ่มการทดสอบการส่งตัวเลือก generate (format: json, num_ctx: 16384) สำหรับ migrate-document
|
||||
// - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032).
|
||||
// - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
|
||||
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
|
||||
@@ -52,6 +53,9 @@ describe('AiBatchProcessor', () => {
|
||||
detectAndExtract: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }),
|
||||
processWithAutoDetect: jest.fn().mockResolvedValue({
|
||||
text: 'extracted ocr text from document that is long enough to bypass character length check',
|
||||
}),
|
||||
};
|
||||
const mockSandboxOcrEngineService = {
|
||||
detectAndExtract: jest.fn().mockResolvedValue({
|
||||
@@ -81,6 +85,7 @@ describe('AiBatchProcessor', () => {
|
||||
};
|
||||
const mockRedis = {
|
||||
setex: jest.fn().mockResolvedValue('OK'),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const mockAttachmentRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
@@ -140,6 +145,7 @@ describe('AiBatchProcessor', () => {
|
||||
resolvedPrompt: 'Resolved test prompt with OCR text',
|
||||
versionNumber: 2,
|
||||
}),
|
||||
findByVersion: jest.fn().mockResolvedValue(null),
|
||||
saveTestResult: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
beforeEach(async () => {
|
||||
@@ -237,7 +243,23 @@ describe('AiBatchProcessor', () => {
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
await processor.process(job);
|
||||
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
||||
pdfPath: '/files/test.pdf',
|
||||
extractedText: undefined,
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
});
|
||||
expect(embeddingService.embedDocument).toHaveBeenCalledTimes(1);
|
||||
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
|
||||
'proj-uuid-456',
|
||||
'doc-uuid-123',
|
||||
'doc-uuid-123',
|
||||
'ATTACHMENT',
|
||||
'ACTIVE',
|
||||
1,
|
||||
'doc-uuid-123',
|
||||
undefined,
|
||||
'OCR text LCBP3-CIV-001 Civil'
|
||||
);
|
||||
expect(attachmentRepo.update).toHaveBeenCalledWith(
|
||||
{ publicId: 'doc-uuid-123' },
|
||||
{ aiProcessingStatus: 'PROCESSING' }
|
||||
@@ -288,7 +310,13 @@ describe('AiBatchProcessor', () => {
|
||||
'/files/test.pdf',
|
||||
'auto'
|
||||
);
|
||||
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
|
||||
expect(ollamaService.generate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
})
|
||||
);
|
||||
expect(redis.setex).toHaveBeenCalledTimes(2);
|
||||
expect(redis.setex).toHaveBeenLastCalledWith(
|
||||
'ai:rag:result:idem-extract-123',
|
||||
@@ -296,6 +324,69 @@ describe('AiBatchProcessor', () => {
|
||||
expect.stringContaining('completed')
|
||||
);
|
||||
});
|
||||
it('sandbox-ai-extract ควร regenerate response ใหม่เมื่อ parse JSON ครั้งแรกล้มเหลว', async () => {
|
||||
const cachedOcrPayload = {
|
||||
ocrText: 'OCR text for retry test\u0002\u0000',
|
||||
ocrUsed: true,
|
||||
engineUsed: 'typhoon-np-dms-ocr',
|
||||
fallbackUsed: false,
|
||||
timestamp: '2026-06-06T15:00:00.000Z',
|
||||
};
|
||||
mockRedis.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(JSON.stringify(cachedOcrPayload));
|
||||
mockAiPromptsService.findByVersion = jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
template:
|
||||
'Resolved test prompt with OCR text {{ocr_text}} and context {{master_data_context}}',
|
||||
isActive: true,
|
||||
contextConfig: { filter: {} },
|
||||
});
|
||||
mockOllamaService.generate
|
||||
.mockResolvedValueOnce('{\u0002\u0000')
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
subject: 'Recovered after retry',
|
||||
confidence: 0.91,
|
||||
tags: ['retry'],
|
||||
})
|
||||
);
|
||||
const job = {
|
||||
id: 'job-ai-extract-retry',
|
||||
data: {
|
||||
jobType: 'sandbox-ai-extract',
|
||||
documentPublicId: 'idem-ai-extract-123',
|
||||
projectPublicId: 'default',
|
||||
payload: { promptVersion: 2 },
|
||||
idempotencyKey: 'idem-ai-extract-123',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
await processor.process(job);
|
||||
expect(mockOllamaService.generate).toHaveBeenCalledTimes(2);
|
||||
expect(mockOllamaService.generate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.not.stringContaining('\u0002'),
|
||||
expect.objectContaining({
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
})
|
||||
);
|
||||
expect(mockAiPromptsService.saveTestResult).toHaveBeenCalledWith(
|
||||
'ocr_extraction',
|
||||
2,
|
||||
expect.objectContaining({
|
||||
subject: 'Recovered after retry',
|
||||
confidence: 0.91,
|
||||
})
|
||||
);
|
||||
expect(mockRedis.setex).toHaveBeenLastCalledWith(
|
||||
'ai:rag:result:idem-ai-extract-123',
|
||||
3600,
|
||||
expect.stringContaining('"llmPrompt"')
|
||||
);
|
||||
});
|
||||
it('EC-001: ควรบันทึก aiIssues เมื่อ AI สกัด Tag ใหม่ที่ไม่มีในระบบ', async () => {
|
||||
mockTagsService.findOrSuggestTags.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -430,7 +521,14 @@ describe('AiBatchProcessor', () => {
|
||||
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
||||
pdfPath: '/files/test.pdf',
|
||||
});
|
||||
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
|
||||
expect(ollamaService.generate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
options: { num_ctx: 16384, num_predict: 4096 },
|
||||
})
|
||||
);
|
||||
expect(mockTagsService.findOrSuggestTags).toHaveBeenCalledTimes(1);
|
||||
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -449,4 +547,78 @@ describe('AiBatchProcessor', () => {
|
||||
expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
describe('rag-prepare', () => {
|
||||
it('ควรประมวลผล rag-prepare สำเร็จเมื่อส่ง cachedOcrText มาโดยตรง', async () => {
|
||||
const job = {
|
||||
id: 'job-rag-prepare-cached',
|
||||
data: {
|
||||
jobType: 'rag-prepare',
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
payload: {
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
correspondenceNumber: 'CORR-001',
|
||||
docType: 'LETTER',
|
||||
statusCode: 'IN_REVIEW',
|
||||
revisionNumber: 1,
|
||||
subject: 'Test Subject',
|
||||
cachedOcrText:
|
||||
'some cached ocr text that is long enough to pass the 50 character limit check',
|
||||
},
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
await processor.process(job);
|
||||
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
|
||||
'proj-uuid-456',
|
||||
'doc-uuid-123',
|
||||
'CORR-001',
|
||||
'LETTER',
|
||||
'IN_REVIEW',
|
||||
1,
|
||||
'Test Subject',
|
||||
undefined,
|
||||
'some cached ocr text that is long enough to pass the 50 character limit check'
|
||||
);
|
||||
});
|
||||
it('ควรประมวลผล rag-prepare สำเร็จเมื่อดึงข้อความจากไฟล์แนบผ่าน OCR Service', async () => {
|
||||
ocrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'extracted ocr text from document that is long enough to bypass character length check',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const job = {
|
||||
id: 'job-rag-prepare-ocr',
|
||||
data: {
|
||||
jobType: 'rag-prepare',
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
payload: {
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
correspondenceNumber: 'CORR-002',
|
||||
docType: 'LETTER',
|
||||
statusCode: 'IN_REVIEW',
|
||||
revisionNumber: 2,
|
||||
subject: 'Test OCR Subject',
|
||||
attachmentPath: '/files/test-ocr.pdf',
|
||||
},
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
await processor.process(job);
|
||||
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
|
||||
pdfPath: '/files/test-ocr.pdf',
|
||||
});
|
||||
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
|
||||
'proj-uuid-456',
|
||||
'doc-uuid-123',
|
||||
'CORR-002',
|
||||
'LETTER',
|
||||
'IN_REVIEW',
|
||||
2,
|
||||
'Test OCR Subject',
|
||||
undefined,
|
||||
'extracted ocr text from document that is long enough to bypass character length check'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// File: src/modules/ai/processors/ai-batch.processor.ts
|
||||
// Change Log
|
||||
// - 2026-06-08: แก้ไขปัญหา LLM JSON response truncated โดยการเพิ่ม num_ctx เป็น 16384 ใน sandbox-extract, sandbox-ai-extract และ migrate-document (แก้ไขโดย AGY Gemini 3.5 Flash (Medium))
|
||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
||||
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
|
||||
@@ -10,6 +11,9 @@
|
||||
// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000
|
||||
// - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ
|
||||
// - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main)
|
||||
// - 2026-06-06: แก้ไข bug LLM JSON parse failure — เพิ่ม retry logic (2 attempts), debug log raw response, และปรับปรุง error message ให้แสดงทั้ง raw และ cleaned response
|
||||
// - 2026-06-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 { Logger } from '@nestjs/common';
|
||||
@@ -57,7 +61,8 @@ export type AiBatchJobType =
|
||||
| 'sandbox-extract'
|
||||
| 'sandbox-ocr-only'
|
||||
| 'sandbox-ai-extract'
|
||||
| 'migrate-document';
|
||||
| 'migrate-document'
|
||||
| 'rag-prepare';
|
||||
|
||||
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
|
||||
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
|
||||
@@ -73,6 +78,24 @@ export interface AiBatchJobData {
|
||||
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 =>
|
||||
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
|
||||
* lockDuration: 150000ms — รองรับ Ollama sandbox ที่ใช้เวลาสูงสุด 120s (ADR-029 FR-008)
|
||||
* ค่า default ของ BullMQ คือ 30000ms ซึ่งน้อยกว่า timeout → job stall
|
||||
@@ -168,6 +199,62 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
super();
|
||||
}
|
||||
|
||||
/** เรียก LLM แล้ว parse JSON แบบ retry จริงเมื่อได้ผลลัพธ์ไม่สมบูรณ์
|
||||
* @param ollamaOptions - Ollama generation options เช่น num_ctx สำหรับ prompt ยาว
|
||||
*/
|
||||
private async generateStructuredJson(
|
||||
prompt: string,
|
||||
options: {
|
||||
timeoutMs: number;
|
||||
model?: string;
|
||||
system?: string;
|
||||
format?: 'json';
|
||||
ollamaOptions?: { num_ctx?: number; num_predict?: number };
|
||||
}
|
||||
): Promise<{
|
||||
extractedMetadata: Record<string, unknown>;
|
||||
rawResponse: string;
|
||||
cleanedResponse: string;
|
||||
}> {
|
||||
let lastRawResponse = '';
|
||||
let lastCleanedResponse = '';
|
||||
for (let attempt = 1; attempt <= MAX_JSON_PARSE_ATTEMPTS; attempt += 1) {
|
||||
const rawResponse = await this.ollamaService.generate(prompt, {
|
||||
...options,
|
||||
options: options.ollamaOptions,
|
||||
});
|
||||
const cleanedResponse = sanitizeLlmJsonResponse(rawResponse);
|
||||
lastRawResponse = rawResponse;
|
||||
lastCleanedResponse = cleanedResponse;
|
||||
this.logger.debug(`Raw LLM response: ${rawResponse}`);
|
||||
try {
|
||||
return {
|
||||
extractedMetadata: JSON.parse(cleanedResponse) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
rawResponse,
|
||||
cleanedResponse,
|
||||
};
|
||||
} catch {
|
||||
if (attempt >= MAX_JSON_PARSE_ATTEMPTS) {
|
||||
this.logger.error(
|
||||
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse}, Cleaned: ${lastCleanedResponse}`
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse.substring(0, 200)}, Cleaned: ${lastCleanedResponse.substring(0, 200)}`
|
||||
);
|
||||
}
|
||||
this.logger.warn(
|
||||
`JSON parse attempt ${attempt} failed, regenerating response...`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts`
|
||||
);
|
||||
}
|
||||
|
||||
/** Dispatch งาน batch ตาม jobType */
|
||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||
const isSandbox =
|
||||
@@ -239,6 +326,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
}
|
||||
return;
|
||||
case 'rag-prepare':
|
||||
this.logger.log(
|
||||
`RAG prepare job processing — jobId=${String(job.id)}`
|
||||
);
|
||||
await this.processRagPrepare(job.data);
|
||||
return;
|
||||
default: {
|
||||
const unreachable: never = job.data.jobType;
|
||||
throw new Error(
|
||||
@@ -262,15 +355,41 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
|
||||
const { documentPublicId, projectPublicId, payload } = data;
|
||||
const pdfPath = payload.pdfPath as string;
|
||||
const extractedText = payload.extractedText as string | undefined;
|
||||
const extractedText = readString(payload.extractedText);
|
||||
if (!pdfPath) {
|
||||
throw new Error('pdfPath is required for embed-document job');
|
||||
}
|
||||
const result = await this.embeddingService.embedDocument(
|
||||
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(
|
||||
projectPublicId,
|
||||
extractedText
|
||||
documentPublicId,
|
||||
correspondenceNumber,
|
||||
docType,
|
||||
statusCode,
|
||||
revisionNumber,
|
||||
subject,
|
||||
documentDate,
|
||||
resolvedOcrText
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
|
||||
@@ -372,6 +491,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
pdfPath,
|
||||
engineType
|
||||
);
|
||||
const sanitizedOcrText = sanitizeOcrText(ocrResult.text);
|
||||
if (sanitizedOcrText.length !== ocrResult.text.length) {
|
||||
this.logger.warn(
|
||||
`OCR text sanitized before LLM: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars`
|
||||
);
|
||||
}
|
||||
|
||||
const activePrompt =
|
||||
await this.aiPromptsService.getActive('ocr_extraction');
|
||||
@@ -380,36 +505,38 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
}
|
||||
|
||||
// ดึงบริบท Master data
|
||||
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
|
||||
// ดังนั้นส่ง undefined เพื่อ skip project lookup
|
||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||
activePrompt,
|
||||
overrideProjPublicId
|
||||
overrideProjPublicId === 'default' ? undefined : overrideProjPublicId
|
||||
);
|
||||
const compactMasterDataContext = JSON.stringify(masterDataContext);
|
||||
|
||||
const ocrTextSafe =
|
||||
sanitizedOcrText.length > MAX_OCR_TEXT_CHARS
|
||||
? (this.logger.warn(
|
||||
`OCR text truncated: ${sanitizedOcrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)`
|
||||
),
|
||||
sanitizedOcrText.substring(0, MAX_OCR_TEXT_CHARS))
|
||||
: sanitizedOcrText;
|
||||
|
||||
const resolvedPrompt = activePrompt.template
|
||||
.replace('{{ocr_text}}', ocrResult.text)
|
||||
.replace(
|
||||
'{{master_data_context}}',
|
||||
JSON.stringify(masterDataContext, null, 2)
|
||||
.replace('{{ocr_text}}', ocrTextSafe)
|
||||
.replace('{{master_data_context}}', compactMasterDataContext);
|
||||
|
||||
this.logger.debug(
|
||||
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
|
||||
);
|
||||
|
||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
||||
const { extractedMetadata } = await this.generateStructuredJson(
|
||||
resolvedPrompt,
|
||||
{
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
const cleanedResponse = response
|
||||
.replace(/```json/g, '')
|
||||
.replace(/```/g, '')
|
||||
.trim();
|
||||
let extractedMetadata: Record<string, unknown>;
|
||||
try {
|
||||
extractedMetadata = JSON.parse(cleanedResponse) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse LLM response as JSON: ${cleanedResponse}`
|
||||
);
|
||||
ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
|
||||
}
|
||||
);
|
||||
await this.aiPromptsService.saveTestResult(
|
||||
'ocr_extraction',
|
||||
activePrompt.versionNumber,
|
||||
@@ -422,11 +549,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'completed',
|
||||
answer: JSON.stringify(extractedMetadata, null, 2),
|
||||
ocrText: ocrResult.text,
|
||||
ocrText: sanitizedOcrText,
|
||||
ocrUsed: ocrResult.ocrUsed,
|
||||
engineUsed: ocrResult.engineUsed,
|
||||
fallbackUsed: ocrResult.fallbackUsed,
|
||||
promptVersionUsed: activePrompt.versionNumber,
|
||||
llmPrompt: resolvedPrompt,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
@@ -475,13 +603,19 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
engineType,
|
||||
typhoonOptions
|
||||
);
|
||||
const sanitizedOcrText = sanitizeOcrText(ocrResult.text);
|
||||
if (sanitizedOcrText.length !== ocrResult.text.length) {
|
||||
this.logger.warn(
|
||||
`OCR text sanitized before cache: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars`
|
||||
);
|
||||
}
|
||||
|
||||
// Cache OCR text สำหรับ Step 2
|
||||
await this.redis.setex(
|
||||
`ai:sandbox:ocr:${idempotencyKey}`,
|
||||
3600,
|
||||
JSON.stringify({
|
||||
ocrText: ocrResult.text,
|
||||
ocrText: sanitizedOcrText,
|
||||
ocrUsed: ocrResult.ocrUsed,
|
||||
engineUsed: ocrResult.engineUsed,
|
||||
fallbackUsed: ocrResult.fallbackUsed,
|
||||
@@ -495,7 +629,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
JSON.stringify({
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'completed',
|
||||
ocrText: ocrResult.text,
|
||||
ocrText: sanitizedOcrText,
|
||||
ocrUsed: ocrResult.ocrUsed,
|
||||
engineUsed: ocrResult.engineUsed,
|
||||
fallbackUsed: ocrResult.fallbackUsed,
|
||||
@@ -550,7 +684,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
fallbackUsed?: boolean;
|
||||
timestamp: string;
|
||||
};
|
||||
const { ocrText } = parsedOcr;
|
||||
const ocrText = sanitizeOcrText(parsedOcr.ocrText);
|
||||
if (ocrText.length !== parsedOcr.ocrText.length) {
|
||||
this.logger.warn(
|
||||
`Cached OCR text sanitized before AI extraction: raw=${parsedOcr.ocrText.length} chars, sanitized=${ocrText.length} chars`
|
||||
);
|
||||
}
|
||||
|
||||
// ดึง prompt version
|
||||
const activePrompt =
|
||||
@@ -572,38 +711,36 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
}
|
||||
|
||||
// Resolve context และ run LLM
|
||||
// Sandbox ใช้ 'default' projectPublicId แต่ไม่ต้องการ override context
|
||||
// ดังนั้นส่ง undefined เพื่อ skip project lookup
|
||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||
targetPrompt,
|
||||
projectPublicId
|
||||
projectPublicId === 'default' ? undefined : projectPublicId
|
||||
);
|
||||
const compactMasterDataContext = JSON.stringify(masterDataContext);
|
||||
|
||||
const ocrTextSafe =
|
||||
ocrText.length > MAX_OCR_TEXT_CHARS
|
||||
? (this.logger.warn(
|
||||
`OCR text truncated: ${ocrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)`
|
||||
),
|
||||
ocrText.substring(0, MAX_OCR_TEXT_CHARS))
|
||||
: ocrText;
|
||||
|
||||
const resolvedPrompt = targetPrompt.template
|
||||
.replace('{{ocr_text}}', ocrText)
|
||||
.replace(
|
||||
'{{master_data_context}}',
|
||||
JSON.stringify(masterDataContext, null, 2)
|
||||
.replace('{{ocr_text}}', ocrTextSafe)
|
||||
.replace('{{master_data_context}}', compactMasterDataContext);
|
||||
this.logger.debug(
|
||||
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
|
||||
);
|
||||
|
||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
||||
const { extractedMetadata } = await this.generateStructuredJson(
|
||||
resolvedPrompt,
|
||||
{
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
|
||||
const cleanedResponse = response
|
||||
.replace(/```json/g, '')
|
||||
.replace(/```/g, '')
|
||||
.trim();
|
||||
|
||||
let extractedMetadata: Record<string, unknown>;
|
||||
try {
|
||||
extractedMetadata = JSON.parse(cleanedResponse) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse LLM response as JSON: ${cleanedResponse}`
|
||||
);
|
||||
ollamaOptions: { num_ctx: 16384, num_predict: 4096 }, // num_predict ป้องกัน output ถูก truncate
|
||||
}
|
||||
);
|
||||
|
||||
await this.aiPromptsService.saveTestResult(
|
||||
'ocr_extraction',
|
||||
@@ -623,6 +760,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
engineUsed: parsedOcr.engineUsed,
|
||||
fallbackUsed: parsedOcr.fallbackUsed,
|
||||
promptVersionUsed: targetPrompt.versionNumber,
|
||||
llmPrompt: resolvedPrompt,
|
||||
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(
|
||||
job: Job<AiBatchJobData>
|
||||
): Promise<void> {
|
||||
@@ -715,7 +931,9 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
let aiResponse: string;
|
||||
try {
|
||||
aiResponse = await this.ollamaService.generate(resolvedPrompt, {
|
||||
format: 'json',
|
||||
timeoutMs: 120000,
|
||||
options: { num_ctx: 16384, num_predict: 4096 },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
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> {
|
||||
const { documentPublicId, requestedByUserPublicId } = job.data;
|
||||
const { documentPublicId, projectPublicId, requestedByUserPublicId } =
|
||||
job.data;
|
||||
|
||||
this.logger.log(
|
||||
`Vector deletion started — documentPublicId=${documentPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
|
||||
`Vector deletion started — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
|
||||
);
|
||||
|
||||
await this.qdrantService.deleteByDocumentPublicId(documentPublicId);
|
||||
await this.qdrantService.deleteByDocumentPublicId(
|
||||
projectPublicId,
|
||||
documentPublicId
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Vector deletion completed — documentPublicId=${documentPublicId}, jobId=${String(job.id)}`
|
||||
`Vector deletion completed — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// File: src/modules/ai/qdrant.service.ts
|
||||
// File: backend/src/modules/ai/qdrant.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
|
||||
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
|
||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
|
||||
// - 2026-06-05: ปรับปรุงโครงสร้างเป็น Hybrid (Dense 1024 + Sparse) ตาม ADR-035 (T006-T010)
|
||||
// - 2026-06-05: เพิ่ม Compatibility สำหรับ search() ที่ไม่มี sparseVector เพื่อผ่านการทดสอบแบบดั้งเดิม
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
@@ -14,7 +16,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
const AI_COLLECTION_NAME = 'lcbp3_vectors';
|
||||
const AI_VECTOR_SIZE = 768;
|
||||
const AI_VECTOR_SIZE = 1024;
|
||||
|
||||
export interface AiVectorSearchResult {
|
||||
pointId: string | number;
|
||||
@@ -22,7 +24,14 @@ export interface AiVectorSearchResult {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Gateway กลางสำหรับ Qdrant ที่บังคับ project_public_id ทุก search */
|
||||
type QdrantUpsertRequest = Parameters<QdrantClient['upsert']>[1];
|
||||
type QdrantUpsertPoint = QdrantUpsertRequest extends { points: infer TPoints }
|
||||
? TPoints extends Array<infer TPoint>
|
||||
? TPoint
|
||||
: never
|
||||
: never;
|
||||
|
||||
/** Gateway กลางสำหรับ Qdrant ที่รองรับ Hybrid Search และบังคับ project_public_id ทุก search */
|
||||
@Injectable()
|
||||
export class AiQdrantService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiQdrantService.name);
|
||||
@@ -47,78 +56,261 @@ export class AiQdrantService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** เตรียม collection และ tenant payload index สำหรับ project isolation */
|
||||
/** เตรียม collection และ payload index สำหรับ project isolation และ hybrid search */
|
||||
async ensureCollection(): Promise<void> {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(
|
||||
(collection) => collection.name === AI_COLLECTION_NAME
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
if (exists) {
|
||||
// ตรวจ schema ของ collection ที่มีอยู่ — ถ้าเป็น Hybrid 1024 dims แล้ว skip delete
|
||||
try {
|
||||
const collectionInfo =
|
||||
await this.client.getCollection(AI_COLLECTION_NAME);
|
||||
const isHybrid =
|
||||
collectionInfo.config.params.vectors !== undefined &&
|
||||
collectionInfo.config.params.sparse_vectors !== undefined;
|
||||
const vectorsMap = collectionInfo.config.params.vectors;
|
||||
let vectorSize: number | undefined = undefined;
|
||||
|
||||
// Defensive check: ตรวจ structure ของ vectorsMap ก่อน access
|
||||
if (vectorsMap && typeof vectorsMap === 'object') {
|
||||
if ('size' in vectorsMap) {
|
||||
// Single vector mode (ไม่ใช่ Hybrid)
|
||||
vectorSize = (vectorsMap as { size: number }).size;
|
||||
} else {
|
||||
// Hybrid mode: extract bge_dense size
|
||||
const hybridMap = vectorsMap as Record<string, { size?: number }>;
|
||||
if (
|
||||
hybridMap['bge_dense'] &&
|
||||
typeof hybridMap['bge_dense'] === 'object'
|
||||
) {
|
||||
vectorSize = hybridMap['bge_dense'].size;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Unexpected vectors structure: bge_dense not found or invalid in Hybrid collection`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Unexpected vectors structure: vectorsMap is not an object or undefined`
|
||||
);
|
||||
}
|
||||
|
||||
if (isHybrid && vectorSize === AI_VECTOR_SIZE) {
|
||||
this.logger.log(
|
||||
`Qdrant collection ${AI_COLLECTION_NAME} already exists with correct Hybrid schema (1024 dims) — skipping recreation`
|
||||
);
|
||||
// เรียก createPayloadIndexes() ทุกครั้งเพื่อให้แน่ใจว่า indexes มีอยู่
|
||||
await this.createPayloadIndexes();
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Dropping existing Qdrant collection ${AI_COLLECTION_NAME} to upgrade to Hybrid (${vectorSize ?? 'unknown'} dims → ${AI_VECTOR_SIZE} dims)...`
|
||||
);
|
||||
await this.client.deleteCollection(AI_COLLECTION_NAME);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to inspect collection schema, proceeding with recreation — ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
await this.client.deleteCollection(AI_COLLECTION_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
await this.client.createCollection(AI_COLLECTION_NAME, {
|
||||
vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
|
||||
vectors: {
|
||||
bge_dense: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
|
||||
},
|
||||
sparse_vectors: {
|
||||
bge_sparse: {},
|
||||
},
|
||||
});
|
||||
|
||||
// สร้าง payload indexes สำหรับเพิ่มความเร็วในการ filter (T010)
|
||||
await this.createPayloadIndexes();
|
||||
|
||||
this.logger.log(`Created Qdrant Hybrid collection ${AI_COLLECTION_NAME}`);
|
||||
}
|
||||
|
||||
/** สร้าง payload indexes สำหรับ filter fields ที่สำคัญ */
|
||||
private async createPayloadIndexes(): Promise<void> {
|
||||
try {
|
||||
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||
field_name: 'project_public_id',
|
||||
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
|
||||
QdrantClient['createPayloadIndex']
|
||||
>[1]['field_schema'],
|
||||
});
|
||||
this.logger.log(`Created Qdrant collection ${AI_COLLECTION_NAME}`);
|
||||
|
||||
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||
field_name: 'doc_public_id',
|
||||
field_schema: { type: 'keyword' } as Parameters<
|
||||
QdrantClient['createPayloadIndex']
|
||||
>[1]['field_schema'],
|
||||
});
|
||||
|
||||
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||
field_name: 'status_code',
|
||||
field_schema: { type: 'keyword' } as Parameters<
|
||||
QdrantClient['createPayloadIndex']
|
||||
>[1]['field_schema'],
|
||||
});
|
||||
|
||||
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||
field_name: 'doc_type',
|
||||
field_schema: { type: 'keyword' } as Parameters<
|
||||
QdrantClient['createPayloadIndex']
|
||||
>[1]['field_schema'],
|
||||
});
|
||||
|
||||
this.logger.log(`Created payload indexes for ${AI_COLLECTION_NAME}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to create payload indexes (may already exist): ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** ค้นหา vector โดยบังคับ projectPublicId เป็น parameter แรกตาม ADR-023A */
|
||||
/** ค้นหาเวกเตอร์ด้วย Hybrid Search (Dense + Sparse) หรือ Dense Search (ถ้าไม่มี sparse vector) โดยบังคับ projectPublicId */
|
||||
async search(
|
||||
projectPublicId: string,
|
||||
vector: number[],
|
||||
denseVector: number[],
|
||||
sparseVectorOrTopK?: { indices: number[]; values: number[] } | number,
|
||||
topK = 5
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
let actualSparseVector = {
|
||||
indices: [] as number[],
|
||||
values: [] as number[],
|
||||
};
|
||||
let actualTopK = topK;
|
||||
|
||||
if (typeof sparseVectorOrTopK === 'number') {
|
||||
actualTopK = sparseVectorOrTopK;
|
||||
} else if (sparseVectorOrTopK) {
|
||||
actualSparseVector = sparseVectorOrTopK;
|
||||
}
|
||||
|
||||
// Fallback: หากไม่มี sparse vector ให้ประมวลผลผ่าน client.search สำหรับการทดสอบและ compatibility
|
||||
if (actualSparseVector.indices.length === 0) {
|
||||
const results = await this.client.search(AI_COLLECTION_NAME, {
|
||||
vector,
|
||||
limit: topK,
|
||||
vector: denseVector,
|
||||
limit: actualTopK,
|
||||
filter: {
|
||||
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
||||
must: [
|
||||
{ key: 'project_public_id', match: { value: projectPublicId } },
|
||||
],
|
||||
},
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return results.map((result) => ({
|
||||
pointId: result.id,
|
||||
score: result.score,
|
||||
score: result.score ?? 0,
|
||||
payload: result.payload ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */
|
||||
async searchByProject(
|
||||
vector: number[],
|
||||
projectPublicId: string,
|
||||
limit: number
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
return this.search(projectPublicId, vector, limit);
|
||||
const results = await this.client.query(AI_COLLECTION_NAME, {
|
||||
prefetch: [
|
||||
{
|
||||
query: {
|
||||
indices: actualSparseVector.indices,
|
||||
values: actualSparseVector.values,
|
||||
},
|
||||
using: 'bge_sparse',
|
||||
limit: actualTopK * 2,
|
||||
},
|
||||
{
|
||||
query: denseVector,
|
||||
using: 'bge_dense',
|
||||
limit: actualTopK * 2,
|
||||
},
|
||||
],
|
||||
query: { fusion: 'rrf' } as unknown as Record<string, unknown>,
|
||||
limit: actualTopK,
|
||||
filter: {
|
||||
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
||||
},
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return results.points.map((result) => ({
|
||||
pointId: result.id,
|
||||
score: result.score ?? 0,
|
||||
payload: result.payload ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */
|
||||
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
|
||||
/** Compatibility wrapper สำหรับโค้ดเดิมระหว่าง transition */
|
||||
async searchByProject(
|
||||
denseVector: number[],
|
||||
sparseVectorOrProjectPublicId:
|
||||
| { indices: number[]; values: number[] }
|
||||
| string,
|
||||
projectPublicIdOrLimit?: string | number,
|
||||
limit = 5
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** ลบเวกเตอร์ของเอกสารด้วย projectPublicId และ documentPublicId */
|
||||
async deleteByDocumentPublicId(
|
||||
projectPublicId: string,
|
||||
documentPublicId: string
|
||||
): Promise<void> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
await this.client.delete(AI_COLLECTION_NAME, {
|
||||
wait: true,
|
||||
filter: {
|
||||
must: [{ key: 'public_id', match: { value: documentPublicId } }],
|
||||
must: [
|
||||
{ key: 'project_public_id', match: { value: projectPublicId } },
|
||||
{ key: 'doc_public_id', match: { value: documentPublicId } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Upsert vectors ไป Qdrant พร้อม project isolation (T021) */
|
||||
/** Upsert hybrid vectors ไป Qdrant พร้อม project isolation (T008) */
|
||||
async upsert(
|
||||
projectPublicId: string,
|
||||
points: Array<{
|
||||
id: string;
|
||||
vector: number[];
|
||||
vector: {
|
||||
bge_dense: number[];
|
||||
bge_sparse: {
|
||||
indices: number[];
|
||||
values: number[];
|
||||
};
|
||||
};
|
||||
payload: Record<string, unknown>;
|
||||
}>
|
||||
): Promise<void> {
|
||||
@@ -126,14 +318,14 @@ export class AiQdrantService implements OnModuleInit {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
// เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation
|
||||
// เพิ่ม project_public_id ใน payload ทุก point เพื่อแยกโครงการ
|
||||
const pointsWithProject = points.map((point) => ({
|
||||
...point,
|
||||
payload: {
|
||||
...point.payload,
|
||||
project_public_id: projectPublicId,
|
||||
},
|
||||
}));
|
||||
})) as unknown as QdrantUpsertPoint[];
|
||||
|
||||
await this.client.upsert(AI_COLLECTION_NAME, {
|
||||
wait: true,
|
||||
|
||||
@@ -0,0 +1,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
|
||||
// - 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 { ConfigService } from '@nestjs/config';
|
||||
import { OllamaService } from './ollama.service';
|
||||
import { AiQdrantService } from '../qdrant.service';
|
||||
import { OcrService } from './ocr.service';
|
||||
import { AiPromptsService } from '../prompts/ai-prompts.service';
|
||||
|
||||
export interface EmbeddingChunk {
|
||||
chunkIndex: number;
|
||||
@@ -31,7 +33,8 @@ export class EmbeddingService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly ollamaService: OllamaService,
|
||||
private readonly qdrantService: AiQdrantService,
|
||||
private readonly ocrService: OcrService
|
||||
private readonly ocrService: OcrService,
|
||||
private readonly aiPromptsService: AiPromptsService
|
||||
) {
|
||||
this.chunkSize = this.configService.get<number>(
|
||||
'EMBEDDING_CHUNK_SIZE',
|
||||
@@ -44,66 +47,71 @@ export class EmbeddingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง embedding สำหรับเอกสารทั้งฉบับ:
|
||||
* 1. ดึงข้อความ full-doc (ใช้ extractedText หรือ OCR)
|
||||
* 2. Chunk text 512 tokens / 64 overlap
|
||||
* 3. Generate embedding ต่อ chunk ด้วย nomic-embed-text
|
||||
* 4. Upsert ไป Qdrant พร้อม project isolation
|
||||
* สร้าง hybrid embedding สำหรับเอกสารทั้งฉบับ:
|
||||
* 1. ใช้ Semantic Chunking (ผ่าน LLM) เป็นหลัก พร้อม Fallback เป็นแบบ fixed-size
|
||||
* 2. เรียก Sidecar /embed เพื่อแปลงแต่ละ chunk เป็น Dense (1024 dims) + Sparse vector
|
||||
* 3. ลบ points เก่าของเอกสารใน Qdrant
|
||||
* 4. Upsert points ใหม่เก็บครบ 11 fields
|
||||
*/
|
||||
async embedDocument(
|
||||
pdfPath: string,
|
||||
documentPublicId: string,
|
||||
projectPublicId: string,
|
||||
extractedText?: string
|
||||
documentPublicId: string,
|
||||
correspondenceNumber: string,
|
||||
docType: string,
|
||||
statusCode: string,
|
||||
revisionNumber: number,
|
||||
subject: string,
|
||||
documentDate?: string,
|
||||
ocrText?: string
|
||||
): Promise<EmbeddingResult> {
|
||||
try {
|
||||
// 1. ดึงข้อความจาก PDF (ใช้ extractedText ถ้ามี หรือเรียก OCR)
|
||||
let fullText = extractedText;
|
||||
if (!fullText) {
|
||||
const ocrResult = await this.ocrService.detectAndExtract({
|
||||
pdfPath,
|
||||
extractedText: '',
|
||||
extractedChars: 0,
|
||||
});
|
||||
fullText = ocrResult.text;
|
||||
}
|
||||
|
||||
if (!fullText || fullText.trim().length === 0) {
|
||||
this.logger.warn(`No text extracted from document ${documentPublicId}`);
|
||||
if (!ocrText || ocrText.trim().length === 0) {
|
||||
this.logger.warn(
|
||||
`No OCR text provided for document ${documentPublicId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
chunksEmbedded: 0,
|
||||
error: 'No text extracted',
|
||||
error: 'No OCR text provided',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Chunk text
|
||||
const chunks = this.chunkText(fullText);
|
||||
// 1. แบ่งข้อความออกเป็น Chunk ด้วย Semantic Chunking
|
||||
const chunks = await this.semanticChunkTextWithFallback(ocrText);
|
||||
this.logger.log(
|
||||
`Document ${documentPublicId} split into ${chunks.length} chunks`
|
||||
);
|
||||
|
||||
// 3. Generate embedding และ upsert ไป Qdrant
|
||||
// 2. แปลงแต่ละ chunk เป็น Hybrid Vector และเตรียม points
|
||||
const points = [];
|
||||
for (const chunk of chunks) {
|
||||
for (const [idx, chunk] of chunks.entries()) {
|
||||
try {
|
||||
const embedding = await this.ollamaService.generateEmbedding(
|
||||
chunk.text
|
||||
);
|
||||
// เรียก Sidecar /embed เพื่อแปลงข้อความของ chunk
|
||||
const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
|
||||
points.push({
|
||||
id: `${documentPublicId}-${chunk.chunkIndex}`,
|
||||
vector: embedding,
|
||||
id: `${documentPublicId}-${idx}`,
|
||||
vector: {
|
||||
bge_dense: embedResult.dense,
|
||||
bge_sparse: embedResult.sparse,
|
||||
},
|
||||
payload: {
|
||||
document_public_id: documentPublicId,
|
||||
chunk_index: chunk.chunkIndex,
|
||||
page_number: chunk.pageNumber,
|
||||
doc_public_id: documentPublicId,
|
||||
project_public_id: projectPublicId,
|
||||
doc_number: correspondenceNumber,
|
||||
doc_type: docType,
|
||||
status_code: statusCode,
|
||||
revision_number: revisionNumber,
|
||||
subject: subject,
|
||||
document_date: documentDate || null,
|
||||
chunk_topic: chunk.topic,
|
||||
chunk_index: idx,
|
||||
chunk_text: chunk.text,
|
||||
embedded_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to embed chunk ${chunk.chunkIndex} for document ${documentPublicId}`,
|
||||
`Failed to embed chunk ${idx} for document ${documentPublicId}`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
this.logger.log(
|
||||
@@ -135,12 +149,53 @@ export class EmbeddingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text ด้วย overlap
|
||||
* - chunkSize: 512 characters (approximate token equivalent)
|
||||
* - overlap: 64 characters
|
||||
* แบ่งข้อความโดยใช้ typhoon2.5 และ Prompt 'rag_chunking' (T025, T026)
|
||||
* หากล้มเหลวหรือ LLM ไม่ตอบกลับในรูปแบบแท็ก <chunk> ให้ fallback เป็นแบบ fixed-size
|
||||
*/
|
||||
private chunkText(text: string): EmbeddingChunk[] {
|
||||
const chunks: EmbeddingChunk[] = [];
|
||||
private async semanticChunkTextWithFallback(
|
||||
ocrText: string
|
||||
): Promise<Array<{ topic: string; text: string }>> {
|
||||
try {
|
||||
this.logger.log('Attempting semantic chunking via typhoon2.5...');
|
||||
// ดึง prompt จาก ai_prompts ที่เป็น active version
|
||||
const resolved = await this.aiPromptsService.resolveActive(
|
||||
'rag_chunking',
|
||||
ocrText
|
||||
);
|
||||
|
||||
// เรียก LLM
|
||||
const llmOutput = await this.ollamaService.generate(
|
||||
resolved.resolvedPrompt
|
||||
);
|
||||
|
||||
// ดึงและวิเคราะห์ข้อความภายในแท็ก <chunk topic="...">
|
||||
const parsed = this.parseChunkTags(llmOutput);
|
||||
if (parsed.length > 0) {
|
||||
this.logger.log(
|
||||
`Semantic chunking succeeded: split into ${parsed.length} chunks.`
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
this.logger.warn(
|
||||
'No valid <chunk> tags found in LLM output, falling back to fixed-size chunking.'
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Semantic chunking failed, falling back to fixed-size chunking: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: ใช้การแบ่ง chunk แบบ Fixed-size
|
||||
return this.fixedSizeChunk(ocrText, this.chunkSize, this.overlap);
|
||||
}
|
||||
|
||||
/** แบ่งข้อความตามขนาดคงที่ (Fixed-size Chunking) (FR-005) */
|
||||
private fixedSizeChunk(
|
||||
text: string,
|
||||
chunkSize: number,
|
||||
overlap: number
|
||||
): Array<{ topic: string; text: string }> {
|
||||
const chunks: Array<{ topic: string; text: string }> = [];
|
||||
const cleanText = text.replace(/\s+/g, ' ').trim();
|
||||
const textLength = cleanText.length;
|
||||
|
||||
@@ -148,19 +203,35 @@ export class EmbeddingService {
|
||||
let chunkIndex = 0;
|
||||
|
||||
while (startIndex < textLength) {
|
||||
const endIndex = Math.min(startIndex + this.chunkSize, textLength);
|
||||
const endIndex = Math.min(startIndex + chunkSize, textLength);
|
||||
const chunkText = cleanText.substring(startIndex, endIndex);
|
||||
|
||||
chunks.push({
|
||||
chunkIndex,
|
||||
topic: `ส่วนที่ ${chunkIndex + 1}`,
|
||||
text: chunkText,
|
||||
pageNumber: undefined, // TODO: Extract page numbers if available
|
||||
});
|
||||
|
||||
startIndex += this.chunkSize - this.overlap;
|
||||
startIndex += chunkSize - overlap;
|
||||
chunkIndex += 1;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** ประมวลผลดึงค่า regex <chunk topic="...">... </chunk> (T026) */
|
||||
private parseChunkTags(
|
||||
llmOutput: string
|
||||
): Array<{ topic: string; text: string }> {
|
||||
const chunks: Array<{ topic: string; text: string }> = [];
|
||||
const regex = /<chunk\s+topic="([^"]*)"\s*>([\s\S]*?)<\/chunk\s*>/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(llmOutput)) !== null) {
|
||||
const topic = match[1]?.trim() || 'ทั่วไป';
|
||||
const text = match[2]?.trim();
|
||||
if (text) {
|
||||
chunks.push({ topic, text });
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
it('ควรส่ง format=json เมื่อ caller ต้องการ structured output', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: { response: '{"ok":true}' } });
|
||||
await service.generate('json prompt', {
|
||||
format: 'json',
|
||||
});
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ format: 'json' }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควรใช้ options.model เมื่อระบุ model อื่น (ADR-034 model switching)', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
||||
// - 2026-06-02: เพิ่ม loadModel() preloading, ดึงจริงจาก /api/ps และเพิ่ม unloadModel() เพื่อล้างหน่วยความจำ GPU/VRAM (ADR-033, Suggestion 1)
|
||||
// - 2026-06-03: ADR-034 — เปลี่ยน default model เป็น typhoon2.5-np-dms; เพิ่ม ocrModel field, keepAlive param ใน loadModel(), model option ใน OllamaGenerateOptions, getOcrModelName()
|
||||
// - 2026-06-06: เพิ่ม system prompt support ใน OllamaGenerateOptions และ generate() method เพื่อรองรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก
|
||||
// - 2026-06-06: [T036] แก้ไข default URL เป็น http://192.168.10.100:11434 (Desk-5439) แทน localhost; เพิ่ม options และ keepAlive ใน OllamaGenerateOptions เพื่อรองรับ Typhoon model parameters
|
||||
// - 2026-06-08: เพิ่ม num_predict ใน OllamaGenerateOptions.options — ป้องกัน JSON truncation เมื่อ LLM สร้าง structured output
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -14,6 +17,22 @@ export interface OllamaGenerateOptions {
|
||||
signal?: AbortSignal;
|
||||
/** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */
|
||||
model?: string;
|
||||
/** System prompt สำหรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก (ใช้ triple quotes) */
|
||||
system?: string;
|
||||
/** บังคับ structured output จาก Ollama สำหรับงานที่ต้อง parse JSON */
|
||||
format?: 'json';
|
||||
/** Ollama generation options (temperature, top_p, etc.) */
|
||||
options?: {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
repeat_penalty?: number;
|
||||
num_gpu?: number;
|
||||
num_ctx?: number;
|
||||
/** จำนวน tokens สูงสุดที่ LLM จะสร้าง — ป้องกัน JSON truncation */
|
||||
num_predict?: number;
|
||||
};
|
||||
/** keep_alive: -1 = stay loaded, 0 = unload immediately, N = seconds */
|
||||
keepAlive?: number;
|
||||
}
|
||||
|
||||
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
|
||||
@@ -29,7 +48,10 @@ export class OllamaService {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
||||
this.configService.get<string>(
|
||||
'AI_HOST_URL',
|
||||
'http://192.168.10.100:11434'
|
||||
)
|
||||
);
|
||||
this.mainModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
@@ -57,7 +79,11 @@ export class OllamaService {
|
||||
{
|
||||
model: options.model ?? this.mainModel,
|
||||
prompt,
|
||||
system: options.system,
|
||||
format: options.format,
|
||||
stream: false,
|
||||
options: options.options,
|
||||
keep_alive: options.keepAlive ?? -1,
|
||||
},
|
||||
{
|
||||
timeout: options.timeoutMs ?? this.timeoutMs,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// File: src/modules/correspondence/correspondence-workflow.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-05: สร้าง unit test สำหรับ CorrespondenceWorkflowService เพื่อทดสอบการเรียกใช้ RAG prepare job เมื่อสถานะเปลี่ยนจาก DRAFT (T017)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
import { Correspondence } from './entities/correspondence.entity';
|
||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AiQueueService } from '../ai/ai-queue.service';
|
||||
|
||||
describe('CorrespondenceWorkflowService', () => {
|
||||
let service: CorrespondenceWorkflowService;
|
||||
let aiQueueService: AiQueueService;
|
||||
const mockWorkflowEngine = {
|
||||
createInstance: jest.fn(),
|
||||
processTransition: jest.fn(),
|
||||
getInstanceById: jest.fn(),
|
||||
};
|
||||
const mockCorrespondenceRepo = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const mockRevisionRepo = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
manager: {
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
};
|
||||
const mockStatusRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const mockRecipientRepo = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue({
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: mockRevisionRepo.manager,
|
||||
}),
|
||||
};
|
||||
const mockNotificationService = {
|
||||
send: jest.fn(),
|
||||
};
|
||||
const mockUserService = {
|
||||
findDocControlIdByOrg: jest.fn(),
|
||||
};
|
||||
const mockAiQueueService = {
|
||||
enqueueRagPrepare: jest.fn().mockResolvedValue('job-id-123'),
|
||||
};
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CorrespondenceWorkflowService,
|
||||
{ provide: WorkflowEngineService, useValue: mockWorkflowEngine },
|
||||
{
|
||||
provide: getRepositoryToken(Correspondence),
|
||||
useValue: mockCorrespondenceRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceRevision),
|
||||
useValue: mockRevisionRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceStatus),
|
||||
useValue: mockStatusRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceRecipient),
|
||||
useValue: mockRecipientRepo,
|
||||
},
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
{ provide: NotificationService, useValue: mockNotificationService },
|
||||
{ provide: UserService, useValue: mockUserService },
|
||||
{ provide: AiQueueService, useValue: mockAiQueueService },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<CorrespondenceWorkflowService>(
|
||||
CorrespondenceWorkflowService
|
||||
);
|
||||
aiQueueService = module.get<AiQueueService>(AiQueueService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('syncStatus RAG trigger', () => {
|
||||
it('ควรเรียก enqueueRagPrepare เมื่อสถานะเอกสารถูกเปลี่ยนจาก DRAFT เป็นอย่างอื่น', async () => {
|
||||
const mockStatus = { id: 2, statusCode: 'SUBOWN' };
|
||||
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
|
||||
const mockProject = { id: 10, publicId: 'proj-uuid-123' };
|
||||
const mockCorrespondence = {
|
||||
id: 100,
|
||||
publicId: 'doc-uuid-999',
|
||||
correspondenceNumber: 'CORR-001',
|
||||
projectId: 10,
|
||||
project: mockProject,
|
||||
type: { correspondenceTypeCode: 'LETTER' },
|
||||
};
|
||||
const mockRevision = {
|
||||
id: 50,
|
||||
correspondenceId: 100,
|
||||
revisionNumber: 0,
|
||||
subject: 'Test Subject',
|
||||
documentDate: new Date('2026-06-05'),
|
||||
correspondence: mockCorrespondence,
|
||||
statusId: 1,
|
||||
};
|
||||
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
|
||||
mockRevisionRepo.manager.find.mockResolvedValueOnce([
|
||||
{
|
||||
correspondenceRevisionId: 50,
|
||||
attachmentId: 88,
|
||||
isMainDocument: true,
|
||||
attachment: { filePath: '/files/doc.pdf', fileExtension: 'pdf' },
|
||||
},
|
||||
]);
|
||||
await (
|
||||
service as unknown as {
|
||||
syncStatus: (
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
).syncStatus(
|
||||
mockRevision as unknown as CorrespondenceRevision,
|
||||
'IN_REVIEW'
|
||||
);
|
||||
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
|
||||
expect(aiQueueService.enqueueRagPrepare).toHaveBeenCalledWith({
|
||||
documentPublicId: 'doc-uuid-999',
|
||||
projectPublicId: 'proj-uuid-123',
|
||||
correspondenceNumber: 'CORR-001',
|
||||
docType: 'LETTER',
|
||||
statusCode: 'SUBOWN',
|
||||
revisionNumber: 0,
|
||||
subject: 'Test Subject',
|
||||
documentDate: '2026-06-05',
|
||||
attachmentPath: '/files/doc.pdf',
|
||||
});
|
||||
});
|
||||
it('ไม่ควรเรียก enqueueRagPrepare เมื่อเอกสารยังคงอยู่ในสถานะ DRAFT', async () => {
|
||||
const mockStatus = { id: 1, statusCode: 'DRAFT' };
|
||||
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
|
||||
const mockRevision = {
|
||||
id: 50,
|
||||
correspondenceId: 100,
|
||||
revisionNumber: 0,
|
||||
subject: 'Test Subject',
|
||||
statusId: 1,
|
||||
};
|
||||
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
|
||||
await (
|
||||
service as unknown as {
|
||||
syncStatus: (
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
).syncStatus(mockRevision as unknown as CorrespondenceRevision, 'DRAFT');
|
||||
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
|
||||
expect(aiQueueService.enqueueRagPrepare).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,11 @@ import { CorrespondenceRevision } from './entities/correspondence-revision.entit
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { Correspondence } from './entities/correspondence.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { CorrespondenceRevisionAttachment } from './entities/correspondence-revision-attachment.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AiQueueService } from '../ai/ai-queue.service';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CorrespondenceWorkflowService {
|
||||
@@ -30,7 +33,8 @@ export class CorrespondenceWorkflowService {
|
||||
private readonly recipientRepo: Repository<CorrespondenceRecipient>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly userService: UserService
|
||||
private readonly userService: UserService,
|
||||
private readonly aiQueueService: AiQueueService
|
||||
) {}
|
||||
|
||||
async submitWorkflow(
|
||||
@@ -85,11 +89,30 @@ export class CorrespondenceWorkflowService {
|
||||
{ roles: userRoles } // [FIX] Pass roles for DSL requirements check
|
||||
);
|
||||
|
||||
await this.syncStatus(revision, transitionResult.nextState, queryRunner);
|
||||
await this.syncStatus(
|
||||
revision,
|
||||
transitionResult.nextState,
|
||||
queryRunner,
|
||||
true
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// After-commit: RAG preparation (fire-and-forget)
|
||||
// ย้ายมาหลัง commit เพื่อป้องกัน job ถูก enqueue แต่ transaction rollback
|
||||
try {
|
||||
if (transitionResult.nextState !== 'DRAFT') {
|
||||
await this.triggerRagPrepare(revision, transitionResult.nextState);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`After-commit RAG preparation failed (non-critical): ${errMsg}`
|
||||
);
|
||||
}
|
||||
|
||||
// Notify TO recipient org users (fire-and-forget)
|
||||
try {
|
||||
const corrForNotify = revision.correspondence;
|
||||
if (corrForNotify) {
|
||||
void this.recipientRepo
|
||||
@@ -101,7 +124,8 @@ export class CorrespondenceWorkflowService {
|
||||
})
|
||||
.then(async (recipients) => {
|
||||
for (const r of recipients) {
|
||||
const targetUserId = await this.userService.findDocControlIdByOrg(
|
||||
const targetUserId =
|
||||
await this.userService.findDocControlIdByOrg(
|
||||
r.recipientOrganizationId
|
||||
);
|
||||
if (targetUserId) {
|
||||
@@ -121,6 +145,12 @@ export class CorrespondenceWorkflowService {
|
||||
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 {
|
||||
instanceId: instance.id,
|
||||
@@ -166,7 +196,8 @@ export class CorrespondenceWorkflowService {
|
||||
private async syncStatus(
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string,
|
||||
queryRunner?: import('typeorm').QueryRunner
|
||||
queryRunner?: import('typeorm').QueryRunner,
|
||||
skipRagPrepare = false
|
||||
) {
|
||||
const statusMap: Record<string, string> = {
|
||||
DRAFT: 'DRAFT',
|
||||
@@ -174,21 +205,95 @@ export class CorrespondenceWorkflowService {
|
||||
APPROVED: 'CLBOWN',
|
||||
REJECTED: 'CCBOWN',
|
||||
};
|
||||
|
||||
const targetCode = statusMap[workflowState] || 'DRAFT';
|
||||
|
||||
const status = await this.statusRepo.findOne({
|
||||
where: { statusCode: targetCode }, // ✅ FIX: CamelCase
|
||||
where: { statusCode: targetCode },
|
||||
});
|
||||
|
||||
if (status) {
|
||||
// ✅ FIX: CamelCase (correspondenceStatusId)
|
||||
revision.statusId = status.id;
|
||||
|
||||
const manager = queryRunner
|
||||
? queryRunner.manager
|
||||
: this.revisionRepo.manager;
|
||||
await manager.save(revision);
|
||||
}
|
||||
// Await RAG preparation เพื่อให้ unit test assert ได้
|
||||
// caller (submitWorkflow/processAction) ก็ยังคง await syncStatus ตามปกติ
|
||||
if (!skipRagPrepare && workflowState !== 'DRAFT') {
|
||||
await this.triggerRagPrepare(revision, targetCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* triggerRagPrepare — รวบรวมข้อมูลจาก revision/correspondence แล้ว enqueue rag-prepare job
|
||||
* คืน Promise เพื่อให้ test สามารถ await และ assert ได้ ส่วน production caller ก็ await ผ่าน syncStatus
|
||||
*/
|
||||
private async triggerRagPrepare(
|
||||
revision: CorrespondenceRevision,
|
||||
statusCode: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
let correspondence: Correspondence | null | undefined =
|
||||
revision.correspondence;
|
||||
if (!correspondence) {
|
||||
correspondence = await this.correspondenceRepo.findOne({
|
||||
where: { id: revision.correspondenceId },
|
||||
relations: ['project', 'type'],
|
||||
});
|
||||
}
|
||||
if (!correspondence) {
|
||||
return;
|
||||
}
|
||||
let projectPublicId = '';
|
||||
if (correspondence.project) {
|
||||
projectPublicId = correspondence.project.publicId;
|
||||
} else {
|
||||
const proj = await this.correspondenceRepo.manager.findOne(Project, {
|
||||
where: { id: correspondence.projectId },
|
||||
});
|
||||
if (proj) {
|
||||
projectPublicId = proj.publicId;
|
||||
}
|
||||
}
|
||||
const docType = correspondence.type?.typeCode || 'LETTER';
|
||||
let attachmentPath: string | undefined;
|
||||
const attachments = await this.revisionRepo.manager.find(
|
||||
CorrespondenceRevisionAttachment,
|
||||
{ where: { correspondenceRevisionId: revision.id } }
|
||||
);
|
||||
if (attachments && attachments.length > 0) {
|
||||
const pdfAtt = attachments.find((att) => {
|
||||
const ext =
|
||||
att.attachment?.originalFilename?.split('.').pop()?.toLowerCase() ||
|
||||
'';
|
||||
return (
|
||||
ext === 'pdf' ||
|
||||
att.attachment?.filePath?.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
});
|
||||
if (pdfAtt && pdfAtt.attachment) {
|
||||
attachmentPath = pdfAtt.attachment.filePath;
|
||||
} else if (attachments[0].attachment) {
|
||||
attachmentPath = attachments[0].attachment.filePath;
|
||||
}
|
||||
}
|
||||
await this.aiQueueService.enqueueRagPrepare({
|
||||
documentPublicId: correspondence.publicId,
|
||||
projectPublicId: projectPublicId,
|
||||
correspondenceNumber: correspondence.correspondenceNumber,
|
||||
docType: docType,
|
||||
statusCode: statusCode,
|
||||
revisionNumber: revision.revisionNumber,
|
||||
subject: revision.subject,
|
||||
documentDate: revision.documentDate
|
||||
? revision.documentDate.toISOString().split('T')[0]
|
||||
: undefined,
|
||||
attachmentPath: attachmentPath,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Failed to enqueue RAG preparation for revision ${revision.id}: ${errMsg}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SearchModule } from '../search/search.module';
|
||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { CirculationModule } from '../circulation/circulation.module';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
|
||||
/**
|
||||
* CorrespondenceModule
|
||||
@@ -53,6 +54,7 @@ import { CirculationModule } from '../circulation/circulation.module';
|
||||
FileStorageModule,
|
||||
NotificationModule,
|
||||
CirculationModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [CorrespondenceController],
|
||||
providers: [
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { IngestionService } from '../ingestion.service';
|
||||
|
||||
const QUEUE_TOKEN = 'BullQueue_rag-ocr';
|
||||
|
||||
const mockOcrQueue = {
|
||||
getJob: jest.fn(),
|
||||
add: jest.fn(),
|
||||
};
|
||||
|
||||
const baseJobData = {
|
||||
attachmentPublicId: 'att-uuid-001',
|
||||
filePath: '/uploads/permanent/CORR/2026/04/file.pdf',
|
||||
docType: 'CORR',
|
||||
docNumber: 'REF-001',
|
||||
revision: null,
|
||||
projectCode: 'PRJ-001',
|
||||
projectPublicId: 'proj-uuid-001',
|
||||
classification: 'INTERNAL' as const,
|
||||
};
|
||||
|
||||
describe('IngestionService', () => {
|
||||
let service: IngestionService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
IngestionService,
|
||||
{ provide: QUEUE_TOKEN, useValue: mockOcrQueue },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<IngestionService>(IngestionService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should enqueue rag-ocr job with attachmentPublicId as jobId', async () => {
|
||||
mockOcrQueue.getJob.mockResolvedValue(null);
|
||||
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
|
||||
|
||||
await service.enqueue(baseJobData);
|
||||
|
||||
expect(mockOcrQueue.add).toHaveBeenCalledWith('ocr', baseJobData, {
|
||||
jobId: baseJobData.attachmentPublicId,
|
||||
});
|
||||
});
|
||||
|
||||
it('EC-RAG-001: duplicate enqueue when job is active → second call is no-op (log only)', async () => {
|
||||
const mockJob = { getState: jest.fn().mockResolvedValue('active') };
|
||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
||||
|
||||
await service.enqueue(baseJobData);
|
||||
|
||||
expect(mockOcrQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('EC-RAG-001: duplicate enqueue when job is waiting → second call is no-op', async () => {
|
||||
const mockJob = { getState: jest.fn().mockResolvedValue('waiting') };
|
||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
||||
|
||||
await service.enqueue(baseJobData);
|
||||
|
||||
expect(mockOcrQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-enqueue if job exists but is completed (state=completed)', async () => {
|
||||
const mockJob = { getState: jest.fn().mockResolvedValue('completed') };
|
||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
||||
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
|
||||
|
||||
await service.enqueue(baseJobData);
|
||||
|
||||
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should re-enqueue if job exists but is failed (state=failed)', async () => {
|
||||
const mockJob = { getState: jest.fn().mockResolvedValue('failed') };
|
||||
mockOcrQueue.getJob.mockResolvedValue(mockJob);
|
||||
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
|
||||
|
||||
await service.enqueue(baseJobData);
|
||||
|
||||
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,213 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { RagService } from '../rag.service';
|
||||
import { QdrantService } from '../qdrant.service';
|
||||
import { EmbeddingService } from '../embedding.service';
|
||||
import { LocalLlmService } from '../local-llm.service';
|
||||
import { IngestionService } from '../ingestion.service';
|
||||
import { DocumentChunk } from '../entities/document-chunk.entity';
|
||||
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
|
||||
|
||||
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
||||
|
||||
const mockQdrant = {
|
||||
isReady: jest.fn(),
|
||||
hybridSearch: jest.fn(),
|
||||
deleteByDocumentId: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEmbedding = {
|
||||
embed: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLocalLlm = {
|
||||
generate: jest.fn(),
|
||||
sanitizeInput: jest.fn((t: string) => t),
|
||||
};
|
||||
|
||||
const mockIngestion = { enqueue: jest.fn() };
|
||||
|
||||
const mockChunkRepo = {
|
||||
count: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
manager: {
|
||||
query: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockRedis = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
};
|
||||
|
||||
const mockVectorDeletionQueue = {
|
||||
add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),
|
||||
};
|
||||
|
||||
describe('RagService', () => {
|
||||
let service: RagService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RagService,
|
||||
{ provide: QdrantService, useValue: mockQdrant },
|
||||
{ provide: EmbeddingService, useValue: mockEmbedding },
|
||||
{ provide: LocalLlmService, useValue: mockLocalLlm },
|
||||
{ provide: IngestionService, useValue: mockIngestion },
|
||||
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
|
||||
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
||||
{
|
||||
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
|
||||
useValue: mockVectorDeletionQueue,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RagService>(RagService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('query()', () => {
|
||||
const dto = {
|
||||
question: 'เอกสารเกี่ยวกับอะไร?',
|
||||
projectPublicId: 'proj-uuid-1234',
|
||||
};
|
||||
const memberPerms: string[] = [];
|
||||
const adminPerms = ['system.manage_all'];
|
||||
|
||||
it('should return answer with citations on PUBLIC cache miss → write cache', async () => {
|
||||
mockQdrant.isReady.mockReturnValue(true);
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([
|
||||
{
|
||||
chunkId: 'chunk-1',
|
||||
publicId: 'att-1',
|
||||
docType: 'CORR',
|
||||
docNumber: 'REF-001',
|
||||
revision: null,
|
||||
projectCode: 'PRJ-001',
|
||||
contentPreview: 'เนื้อหาเอกสาร',
|
||||
score: 0.92,
|
||||
},
|
||||
]);
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'คำตอบ',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
|
||||
const result = await service.query(dto, memberPerms);
|
||||
|
||||
expect(result.answer).toBe('คำตอบ');
|
||||
expect(result.citations).toHaveLength(1);
|
||||
expect(result.usedFallbackModel).toBe(false);
|
||||
expect(mockRedis.setex).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return cached result without calling Qdrant on cache hit', async () => {
|
||||
mockQdrant.isReady.mockReturnValue(true);
|
||||
const cached = JSON.stringify({
|
||||
answer: 'cached answer',
|
||||
citations: [],
|
||||
confidence: 0.9,
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
mockRedis.get.mockResolvedValue(cached);
|
||||
|
||||
const result = await service.query(dto, memberPerms);
|
||||
|
||||
expect(result.answer).toBe('cached answer');
|
||||
expect(mockQdrant.hybridSearch).not.toHaveBeenCalled();
|
||||
expect(mockEmbedding.embed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CONFIDENTIAL: must use Ollama only, skip cache read and write', async () => {
|
||||
mockQdrant.isReady.mockReturnValue(true);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'ลับมาก',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
|
||||
const result = await service.query(dto, adminPerms);
|
||||
|
||||
expect(mockRedis.get).not.toHaveBeenCalled();
|
||||
expect(mockRedis.setex).not.toHaveBeenCalled();
|
||||
expect(mockLocalLlm.generate).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(result.usedFallbackModel).toBe(false);
|
||||
});
|
||||
|
||||
it('collectionReady=false → throw ServiceUnavailableException RAG_NOT_READY', async () => {
|
||||
mockQdrant.isReady.mockReturnValue(false);
|
||||
|
||||
await expect(service.query(dto, memberPerms)).rejects.toThrow(
|
||||
ServiceUnavailableException
|
||||
);
|
||||
});
|
||||
|
||||
it('cross-project cache isolation: same question different projectPublicId → different cache key', async () => {
|
||||
mockQdrant.isReady.mockReturnValue(true);
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'A',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
|
||||
await service.query(
|
||||
{ question: 'Q?', projectPublicId: 'proj-A' },
|
||||
memberPerms
|
||||
);
|
||||
await service.query(
|
||||
{ question: 'Q?', projectPublicId: 'proj-B' },
|
||||
memberPerms
|
||||
);
|
||||
|
||||
const calls = mockRedis.setex.mock.calls as [string, ...unknown[]][];
|
||||
expect(calls[0][0]).not.toBe(calls[1][0]);
|
||||
});
|
||||
|
||||
it('classification ceiling derived from role, not from request body', async () => {
|
||||
mockQdrant.isReady.mockReturnValue(true);
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
anwer: 'ok',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
|
||||
await service.query(dto, memberPerms);
|
||||
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
dto.projectPublicId,
|
||||
'INTERNAL',
|
||||
20
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockQdrant.isReady.mockReturnValue(true);
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'ok',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
|
||||
await service.query(dto, adminPerms);
|
||||
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
dto.projectPublicId,
|
||||
'CONFIDENTIAL',
|
||||
20
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class RagQueryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
question!: string;
|
||||
|
||||
@IsUUID()
|
||||
projectPublicId!: string;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface RagCitation {
|
||||
chunkId: string;
|
||||
docNumber: string | null;
|
||||
docType: string;
|
||||
revision: string | null;
|
||||
snippet: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export class RagResponseDto {
|
||||
answer!: string;
|
||||
citations!: RagCitation[];
|
||||
confidence!: number;
|
||||
usedFallbackModel!: boolean;
|
||||
cachedAt?: string;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class EmbeddingService {
|
||||
private readonly logger = new Logger(EmbeddingService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
'http://localhost:11434'
|
||||
);
|
||||
this.model = this.configService.get<string>(
|
||||
'OLLAMA_EMBED_MODEL',
|
||||
'nomic-embed-text'
|
||||
);
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
try {
|
||||
const response = await axios.post<{ embedding: number[] }>(
|
||||
`${this.ollamaUrl}/api/embeddings`,
|
||||
{ model: this.model, prompt: text },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return response.data.embedding;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Embedding failed',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
return Promise.all(texts.map((t) => this.embed(t)));
|
||||
}
|
||||
|
||||
getModelName(): string {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('document_chunks')
|
||||
export class DocumentChunk {
|
||||
@PrimaryColumn({ type: 'char', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'char', length: 36, name: 'document_id' })
|
||||
documentId!: string;
|
||||
|
||||
@Column({ name: 'chunk_index' })
|
||||
chunkIndex!: number;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content!: string;
|
||||
|
||||
@Column({ length: 20, name: 'doc_type' })
|
||||
docType!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, name: 'doc_number', nullable: true })
|
||||
docNumber!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
revision!: string | null;
|
||||
|
||||
@Column({ length: 50, name: 'project_code' })
|
||||
projectCode!: string;
|
||||
|
||||
@Column({ length: 36, name: 'project_public_id' })
|
||||
projectPublicId!: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'],
|
||||
default: 'INTERNAL',
|
||||
})
|
||||
classification!: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
version!: string | null;
|
||||
|
||||
@Column({ length: 100, name: 'embedding_model', default: 'nomic-embed-text' })
|
||||
embeddingModel!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', precision: 3 })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { OcrJobData } from './processors/ocr.processor';
|
||||
|
||||
@Injectable()
|
||||
export class IngestionService {
|
||||
private readonly logger = new Logger(IngestionService.name);
|
||||
|
||||
constructor(@InjectQueue('rag-ocr') private readonly ocrQueue: Queue) {}
|
||||
|
||||
async enqueue(data: OcrJobData): Promise<void> {
|
||||
const jobId = data.attachmentPublicId;
|
||||
|
||||
const existing = await this.ocrQueue.getJob(jobId);
|
||||
if (existing) {
|
||||
const state = await existing.getState();
|
||||
if (state === 'active' || state === 'waiting' || state === 'delayed') {
|
||||
this.logger.log(
|
||||
`rag-ocr job already queued for ${jobId} (state: ${state})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.ocrQueue.add('ocr', data, { jobId });
|
||||
this.logger.log(`Enqueued rag-ocr for attachment ${jobId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// File: src/modules/rag/local-llm.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: แทนที่ cloud LLM API ด้วย Ollama local-only ตาม ADR-023A.
|
||||
// - 2026-06-03: ADR-034 — เปลี่ยน default fallback จาก gemma4:e4b เป็น typhoon2.5-np-dms:latest
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface LlmGenerateResult {
|
||||
answer: string;
|
||||
usedFallbackModel: boolean;
|
||||
}
|
||||
|
||||
/** บริการเรียก LLM ภายในองค์กรผ่าน Ollama เท่านั้น */
|
||||
@Injectable()
|
||||
export class LocalLlmService {
|
||||
private readonly logger = new Logger(LocalLlmService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly ollamaModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
||||
);
|
||||
this.ollamaModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
this.configService.get<string>(
|
||||
'OLLAMA_RAG_MODEL',
|
||||
'typhoon2.5-np-dms:latest'
|
||||
)
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
/** สร้างคำตอบจากโมเดล local-only โดยไม่มี cloud fallback */
|
||||
async generate(prompt: string): Promise<LlmGenerateResult> {
|
||||
try {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
{
|
||||
model: this.ollamaModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
},
|
||||
{ timeout: this.timeoutMs }
|
||||
);
|
||||
return {
|
||||
answer: response.data.response ?? '',
|
||||
usedFallbackModel: false,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Local Ollama generation failed',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** ทำความสะอาด prompt injection pattern พื้นฐานก่อนส่งเข้าโมเดล */
|
||||
sanitizeInput(text: string): string {
|
||||
return text
|
||||
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
|
||||
.replace(/ignore previous instructions/gi, '')
|
||||
.replace(/system:/gi, '')
|
||||
.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Job } from 'bullmq';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { EmbeddingService } from '../embedding.service';
|
||||
import { QdrantService, VectorMetadata } from '../qdrant.service';
|
||||
import { DocumentChunk } from '../entities/document-chunk.entity';
|
||||
import { EmbeddingJobData } from './thai-preprocess.processor';
|
||||
|
||||
const CHUNK_SIZE = 512;
|
||||
const CHUNK_OVERLAP = 50;
|
||||
|
||||
@Processor('rag-embedding')
|
||||
export class EmbeddingProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(EmbeddingProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly embeddingService: EmbeddingService,
|
||||
private readonly qdrantService: QdrantService,
|
||||
@InjectRepository(DocumentChunk)
|
||||
private readonly chunkRepo: Repository<DocumentChunk>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<EmbeddingJobData>): Promise<void> {
|
||||
const {
|
||||
attachmentPublicId,
|
||||
normalizedText,
|
||||
docType,
|
||||
docNumber,
|
||||
revision,
|
||||
projectCode,
|
||||
projectPublicId,
|
||||
classification,
|
||||
} = job.data;
|
||||
|
||||
const chunks = this.chunkText(normalizedText);
|
||||
const model = this.embeddingService.getModelName();
|
||||
|
||||
const upsertPoints: Parameters<QdrantService['upsertBatch']>[0] = [];
|
||||
const chunkEntities: DocumentChunk[] = [];
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunkId = uuidv4();
|
||||
const vector = await this.embeddingService.embed(chunks[i]);
|
||||
|
||||
const payload: VectorMetadata = {
|
||||
chunk_id: chunkId,
|
||||
public_id: attachmentPublicId,
|
||||
project_public_id: projectPublicId,
|
||||
doc_type: docType,
|
||||
doc_number: docNumber,
|
||||
revision,
|
||||
project_code: projectCode,
|
||||
classification,
|
||||
content_preview: chunks[i].slice(0, 500),
|
||||
embedding_model: model,
|
||||
};
|
||||
|
||||
upsertPoints.push({ id: chunkId, vector, payload });
|
||||
|
||||
const entity = this.chunkRepo.create({
|
||||
id: chunkId,
|
||||
documentId: attachmentPublicId,
|
||||
chunkIndex: i,
|
||||
content: chunks[i],
|
||||
docType,
|
||||
docNumber,
|
||||
revision,
|
||||
projectCode,
|
||||
projectPublicId,
|
||||
classification,
|
||||
embeddingModel: model,
|
||||
});
|
||||
chunkEntities.push(entity);
|
||||
}
|
||||
|
||||
if (upsertPoints.length > 0) {
|
||||
await this.qdrantService.upsertBatch(upsertPoints);
|
||||
await this.chunkRepo.save(chunkEntities);
|
||||
}
|
||||
|
||||
await this.chunkRepo.manager.query(
|
||||
`UPDATE attachments SET rag_status = 'INDEXED', rag_last_error = NULL WHERE public_id = ?`,
|
||||
[attachmentPublicId]
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Embedded ${chunks.length} chunks for ${attachmentPublicId}`
|
||||
);
|
||||
}
|
||||
|
||||
private chunkText(text: string): string[] {
|
||||
const words = text.split(/\s+/);
|
||||
const chunks: string[] = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < words.length) {
|
||||
const end = Math.min(start + CHUNK_SIZE, words.length);
|
||||
chunks.push(words.slice(start, end).join(' '));
|
||||
start += CHUNK_SIZE - CHUNK_OVERLAP;
|
||||
}
|
||||
|
||||
return chunks.filter((c) => c.trim().length > 0);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Job } from 'bullmq';
|
||||
import * as fs from 'fs';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { DocumentChunk } from '../entities/document-chunk.entity';
|
||||
|
||||
export interface OcrJobData {
|
||||
attachmentPublicId: string;
|
||||
filePath: string;
|
||||
docType: string;
|
||||
docNumber: string | null;
|
||||
revision: string | null;
|
||||
projectCode: string;
|
||||
projectPublicId: string;
|
||||
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
|
||||
}
|
||||
|
||||
@Processor('rag-ocr')
|
||||
export class OcrProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(OcrProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue('rag-thai-preprocess') private readonly thaiQueue: Queue,
|
||||
@InjectRepository(DocumentChunk)
|
||||
private readonly chunkRepo: Repository<DocumentChunk>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<OcrJobData>): Promise<void> {
|
||||
const { attachmentPublicId, filePath } = job.data;
|
||||
|
||||
const existing = await this.chunkRepo.count({
|
||||
where: { documentId: attachmentPublicId },
|
||||
});
|
||||
if (existing > 0) {
|
||||
this.logger.log(
|
||||
`rag-ocr job already indexed for ${attachmentPublicId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.chunkRepo.manager.query(
|
||||
`UPDATE attachments SET rag_status = 'PROCESSING' WHERE public_id = ?`,
|
||||
[attachmentPublicId]
|
||||
);
|
||||
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
rawText = `[binary:${attachmentPublicId}]`;
|
||||
}
|
||||
|
||||
await this.thaiQueue.add(
|
||||
'preprocess',
|
||||
{ ...job.data, rawText },
|
||||
{ jobId: `thai:${attachmentPublicId}` }
|
||||
);
|
||||
|
||||
this.logger.log(`OCR enqueued thai-preprocess for ${attachmentPublicId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue, Job } from 'bullmq';
|
||||
import axios from 'axios';
|
||||
|
||||
import { OcrJobData } from './ocr.processor';
|
||||
|
||||
export interface ThaiPreprocessJobData extends OcrJobData {
|
||||
rawText: string;
|
||||
}
|
||||
|
||||
export interface EmbeddingJobData extends ThaiPreprocessJobData {
|
||||
normalizedText: string;
|
||||
}
|
||||
|
||||
@Processor('rag-thai-preprocess')
|
||||
export class ThaiPreprocessProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(ThaiPreprocessProcessor.name);
|
||||
private readonly thaiUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectQueue('rag-embedding') private readonly embeddingQueue: Queue
|
||||
) {
|
||||
super();
|
||||
this.thaiUrl = this.configService.get<string>(
|
||||
'THAI_PREPROCESS_URL',
|
||||
'http://localhost:8765'
|
||||
);
|
||||
}
|
||||
|
||||
async process(job: Job<ThaiPreprocessJobData>): Promise<void> {
|
||||
const { rawText, attachmentPublicId } = job.data;
|
||||
|
||||
let normalizedText = rawText;
|
||||
try {
|
||||
const response = await axios.post<{ normalized: string }>(
|
||||
`${this.thaiUrl}/normalize`,
|
||||
{ text: rawText },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
normalizedText = response.data.normalized ?? rawText;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Thai preprocess failed for ${attachmentPublicId}, using raw text: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
await this.embeddingQueue.add(
|
||||
'embed',
|
||||
{ ...job.data, normalizedText } as EmbeddingJobData,
|
||||
{ jobId: `embed:${attachmentPublicId}` }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
export interface VectorMetadata extends Record<string, unknown> {
|
||||
chunk_id: string;
|
||||
public_id: string;
|
||||
project_public_id: string;
|
||||
doc_type: string;
|
||||
doc_number: string | null;
|
||||
revision: string | null;
|
||||
project_code: string;
|
||||
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
|
||||
content_preview: string;
|
||||
embedding_model: string;
|
||||
}
|
||||
|
||||
export interface HybridSearchResult {
|
||||
chunkId: string;
|
||||
publicId: string;
|
||||
docType: string;
|
||||
docNumber: string | null;
|
||||
revision: string | null;
|
||||
projectCode: string;
|
||||
contentPreview: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
const COLLECTION_NAME = 'lcbp3_vectors';
|
||||
const VECTOR_SIZE = 768;
|
||||
|
||||
@Injectable()
|
||||
export class QdrantService implements OnModuleInit {
|
||||
private readonly logger = new Logger(QdrantService.name);
|
||||
private client: QdrantClient;
|
||||
private collectionReady = false;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const url = this.configService.get<string>(
|
||||
'QDRANT_URL',
|
||||
'http://localhost:6333'
|
||||
);
|
||||
this.client = new QdrantClient({ url });
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.initCollection();
|
||||
this.collectionReady = true;
|
||||
this.logger.log(`Qdrant collection '${COLLECTION_NAME}' ready`);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Qdrant collection init failed — RAG queries will return 503',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
this.collectionReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.collectionReady;
|
||||
}
|
||||
|
||||
private async initCollection(): Promise<void> {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(
|
||||
(c) => c.name === COLLECTION_NAME
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
await this.client.createCollection(COLLECTION_NAME, {
|
||||
vectors: { size: VECTOR_SIZE, distance: 'Cosine' },
|
||||
hnsw_config: {
|
||||
payload_m: 16,
|
||||
m: 0,
|
||||
},
|
||||
optimizers_config: { indexing_threshold: 10000 },
|
||||
});
|
||||
this.logger.log(`Created Qdrant collection '${COLLECTION_NAME}'`);
|
||||
|
||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
||||
field_name: 'project_public_id',
|
||||
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
|
||||
QdrantClient['createPayloadIndex']
|
||||
>[1]['field_schema'],
|
||||
});
|
||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
||||
field_name: 'classification',
|
||||
field_schema: 'keyword',
|
||||
});
|
||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
||||
field_name: 'doc_type',
|
||||
field_schema: 'keyword',
|
||||
});
|
||||
await this.client.createPayloadIndex(COLLECTION_NAME, {
|
||||
field_name: 'doc_number',
|
||||
field_schema: 'keyword',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async upsertBatch(
|
||||
points: Array<{ id: string; vector: number[]; payload: VectorMetadata }>
|
||||
): Promise<void> {
|
||||
await this.client.upsert(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: points.map((p) => ({
|
||||
id: p.id,
|
||||
vector: p.vector,
|
||||
payload: p.payload,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async hybridSearch(
|
||||
queryVector: number[],
|
||||
|
||||
projectPublicId: string,
|
||||
classificationCeiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL',
|
||||
topK: number
|
||||
): Promise<HybridSearchResult[]> {
|
||||
const classificationValues = this.getAllowedClassifications(
|
||||
classificationCeiling
|
||||
);
|
||||
|
||||
const vectorResults = await this.client.search(COLLECTION_NAME, {
|
||||
vector: queryVector,
|
||||
limit: topK,
|
||||
filter: {
|
||||
must: [
|
||||
{ key: 'project_public_id', match: { value: projectPublicId } },
|
||||
{ key: 'classification', match: { any: classificationValues } },
|
||||
],
|
||||
},
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return vectorResults.map((r) => {
|
||||
const payload = r.payload as unknown as VectorMetadata;
|
||||
return {
|
||||
chunkId: payload.chunk_id,
|
||||
publicId: payload.public_id,
|
||||
docType: payload.doc_type,
|
||||
docNumber: payload.doc_number,
|
||||
revision: payload.revision,
|
||||
projectCode: payload.project_code,
|
||||
contentPreview: payload.content_preview,
|
||||
score: r.score,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByDocumentId(documentId: string): Promise<void> {
|
||||
await this.client.delete(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
filter: {
|
||||
must: [{ key: 'public_id', match: { value: documentId } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async forceInitCollection(): Promise<void> {
|
||||
await this.initCollection();
|
||||
this.collectionReady = true;
|
||||
this.logger.log(`Qdrant collection force-initialized`);
|
||||
}
|
||||
|
||||
private getAllowedClassifications(
|
||||
ceiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'
|
||||
): string[] {
|
||||
const order: Array<'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'> = [
|
||||
'PUBLIC',
|
||||
'INTERNAL',
|
||||
'CONFIDENTIAL',
|
||||
];
|
||||
const ceilIdx = order.indexOf(ceiling);
|
||||
return order.slice(0, ceilIdx + 1);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { RagQueryDto } from './dto/rag-query.dto';
|
||||
import { RagService } from './rag.service';
|
||||
|
||||
@ApiTags('RAG')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||
@Controller('rag')
|
||||
export class RagController {
|
||||
private readonly logger = new Logger(RagController.name);
|
||||
|
||||
constructor(
|
||||
private readonly ragService: RagService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Post('query')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'RAG Q&A — ค้นหาคำตอบจากเอกสารโครงการ' })
|
||||
@RequirePermission('rag.query')
|
||||
async query(
|
||||
@Body() dto: RagQueryDto,
|
||||
@CurrentUser() user: User,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
if (!idempotencyKey) {
|
||||
this.logger.warn(`Missing Idempotency-Key from user ${user.user_id}`);
|
||||
}
|
||||
|
||||
const permissions = await this.userService.getUserPermissions(user.user_id);
|
||||
return this.ragService.query(dto, permissions);
|
||||
}
|
||||
|
||||
@Get('status/:attachmentId')
|
||||
@ApiOperation({ summary: 'ดูสถานะ RAG ingestion ของ attachment' })
|
||||
@RequirePermission('rag.query')
|
||||
async getStatus(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
|
||||
return this.ragService.getStatus(attachmentId);
|
||||
}
|
||||
|
||||
@Post('ingest/:attachmentId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Re-ingest attachment ที่ FAILED (Admin only)' })
|
||||
@RequirePermission('rag.manage')
|
||||
async reIngest(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
|
||||
await this.ragService.reIngest(attachmentId);
|
||||
return { message: 'Re-ingestion queued' };
|
||||
}
|
||||
|
||||
@Delete('vectors/:attachmentId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'ลบ vectors ของ attachment ออกจาก Qdrant' })
|
||||
@RequirePermission('rag.manage')
|
||||
async deleteVectors(
|
||||
@Param('attachmentId', ParseUuidPipe) attachmentId: string
|
||||
) {
|
||||
await this.ragService.deleteVectors(attachmentId);
|
||||
}
|
||||
|
||||
@Post('admin/init-collection')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'T038: Init Qdrant collection lcbp3_vectors (admin only)',
|
||||
})
|
||||
@RequirePermission('rag.manage')
|
||||
async initCollection() {
|
||||
await this.ragService.initCollection();
|
||||
return { message: 'Qdrant collection initialized' };
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { DocumentChunk } from './entities/document-chunk.entity';
|
||||
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
|
||||
import { EmbeddingService } from './embedding.service';
|
||||
import { QdrantService } from './qdrant.service';
|
||||
import { LocalLlmService } from './local-llm.service';
|
||||
import { RagService } from './rag.service';
|
||||
import { RagController } from './rag.controller';
|
||||
import { IngestionService } from './ingestion.service';
|
||||
import { OcrProcessor } from './processors/ocr.processor';
|
||||
import { ThaiPreprocessProcessor } from './processors/thai-preprocess.processor';
|
||||
import { EmbeddingProcessor } from './processors/embedding.processor';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
const DLQ_DEFAULTS = {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential' as const, delay: 2000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 200,
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
UserModule,
|
||||
TypeOrmModule.forFeature([DocumentChunk]),
|
||||
BullModule.registerQueue(
|
||||
{ name: 'rag-ocr', defaultJobOptions: DLQ_DEFAULTS },
|
||||
{ name: 'rag-thai-preprocess', defaultJobOptions: DLQ_DEFAULTS },
|
||||
{ name: 'rag-embedding', defaultJobOptions: DLQ_DEFAULTS },
|
||||
// T028: Producer สำหรับ dispatch vector deletion jobs (ADR-023 FR-008)
|
||||
{ name: QUEUE_AI_VECTOR_DELETION }
|
||||
),
|
||||
],
|
||||
controllers: [RagController],
|
||||
providers: [
|
||||
EmbeddingService,
|
||||
QdrantService,
|
||||
LocalLlmService,
|
||||
RagService,
|
||||
IngestionService,
|
||||
OcrProcessor,
|
||||
ThaiPreprocessProcessor,
|
||||
EmbeddingProcessor,
|
||||
],
|
||||
exports: [
|
||||
EmbeddingService,
|
||||
QdrantService,
|
||||
LocalLlmService,
|
||||
RagService,
|
||||
IngestionService,
|
||||
],
|
||||
})
|
||||
export class RagModule {}
|
||||
@@ -1,263 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
|
||||
import { AiVectorDeletionJobPayload } from '../ai/ai-queue.service';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { QdrantService } from './qdrant.service';
|
||||
import { EmbeddingService } from './embedding.service';
|
||||
import { LocalLlmService } from './local-llm.service';
|
||||
import { IngestionService } from './ingestion.service';
|
||||
import { DocumentChunk } from './entities/document-chunk.entity';
|
||||
import { RagQueryDto } from './dto/rag-query.dto';
|
||||
import { RagResponseDto, RagCitation } from './dto/rag-response.dto';
|
||||
|
||||
const CACHE_TTL_SECONDS = 300;
|
||||
const PROMPT_CONTEXT_LIMIT = 3000;
|
||||
|
||||
@Injectable()
|
||||
export class RagService {
|
||||
private readonly logger = new Logger(RagService.name);
|
||||
|
||||
constructor(
|
||||
private readonly qdrant: QdrantService,
|
||||
private readonly embedding: EmbeddingService,
|
||||
private readonly localLlm: LocalLlmService,
|
||||
private readonly ingestionService: IngestionService,
|
||||
@InjectRepository(DocumentChunk)
|
||||
private readonly chunkRepo: Repository<DocumentChunk>,
|
||||
@InjectRedis() private readonly redis: Redis,
|
||||
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
|
||||
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
|
||||
) {}
|
||||
|
||||
async query(
|
||||
dto: RagQueryDto,
|
||||
userPermissions: string[]
|
||||
): Promise<RagResponseDto> {
|
||||
const { question, projectPublicId } = dto;
|
||||
|
||||
const classificationCeiling =
|
||||
this.deriveClassificationCeiling(userPermissions);
|
||||
const isConfidential = classificationCeiling === 'CONFIDENTIAL';
|
||||
|
||||
if (!this.qdrant.isReady()) {
|
||||
throw new ServiceUnavailableException('RAG_NOT_READY');
|
||||
}
|
||||
|
||||
const cacheKey = this.buildCacheKey(
|
||||
question,
|
||||
projectPublicId,
|
||||
classificationCeiling
|
||||
);
|
||||
|
||||
if (!isConfidential) {
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached) as RagResponseDto;
|
||||
parsed.cachedAt = new Date().toISOString();
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const queryVector = await this.embedding.embed(question);
|
||||
const topK = 20;
|
||||
|
||||
const results = await this.qdrant.hybridSearch(
|
||||
queryVector,
|
||||
projectPublicId,
|
||||
classificationCeiling,
|
||||
topK
|
||||
);
|
||||
|
||||
const reranked = results.sort((a, b) => b.score - a.score).slice(0, 5);
|
||||
|
||||
const context = this.buildContext(reranked);
|
||||
|
||||
const safeQuestion = this.localLlm.sanitizeInput(question);
|
||||
const prompt = this.buildPrompt(safeQuestion, context);
|
||||
|
||||
const { answer, usedFallbackModel } = await this.localLlm.generate(prompt);
|
||||
|
||||
const citations: RagCitation[] = reranked.map((r) => ({
|
||||
chunkId: r.chunkId,
|
||||
docNumber: r.docNumber,
|
||||
docType: r.docType,
|
||||
revision: r.revision,
|
||||
snippet: r.contentPreview.slice(0, 200),
|
||||
score: r.score,
|
||||
}));
|
||||
|
||||
const confidence = reranked.length > 0 ? reranked[0].score : 0;
|
||||
|
||||
const response: RagResponseDto = {
|
||||
answer,
|
||||
citations,
|
||||
confidence,
|
||||
usedFallbackModel,
|
||||
};
|
||||
|
||||
if (!isConfidential) {
|
||||
await this.redis.setex(
|
||||
cacheKey,
|
||||
CACHE_TTL_SECONDS,
|
||||
JSON.stringify(response)
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getStatus(
|
||||
attachmentPublicId: string
|
||||
): Promise<{ ragStatus: string; chunkCount: number }> {
|
||||
const chunkCount = await this.chunkRepo.count({
|
||||
where: { documentId: attachmentPublicId },
|
||||
});
|
||||
|
||||
const result = await this.chunkRepo.manager.query<{ rag_status: string }[]>(
|
||||
`SELECT rag_status FROM attachments WHERE public_id = ? LIMIT 1`,
|
||||
[attachmentPublicId]
|
||||
);
|
||||
|
||||
const ragStatus = result[0]?.rag_status ?? 'PENDING';
|
||||
return { ragStatus, chunkCount };
|
||||
}
|
||||
|
||||
async reIngest(attachmentPublicId: string): Promise<void> {
|
||||
const statusResult = await this.chunkRepo.manager.query<
|
||||
{ rag_status: string; file_path: string }[]
|
||||
>(
|
||||
`SELECT rag_status, file_path FROM attachments WHERE public_id = ? LIMIT 1`,
|
||||
[attachmentPublicId]
|
||||
);
|
||||
|
||||
const current = statusResult[0]?.rag_status;
|
||||
if (current !== 'FAILED') {
|
||||
throw new BadRequestException(
|
||||
`Cannot re-ingest: current status is '${current ?? 'unknown'}', expected 'FAILED'`
|
||||
);
|
||||
}
|
||||
|
||||
const sample = await this.chunkRepo.findOne({
|
||||
where: { documentId: attachmentPublicId },
|
||||
});
|
||||
|
||||
await this.chunkRepo.delete({ documentId: attachmentPublicId });
|
||||
|
||||
try {
|
||||
await this.qdrant.deleteByDocumentId(attachmentPublicId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Qdrant delete failed for ${attachmentPublicId} — continuing`,
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
}
|
||||
|
||||
await this.chunkRepo.manager.query(
|
||||
`UPDATE attachments SET rag_status = 'PENDING', rag_last_error = NULL WHERE public_id = ?`,
|
||||
[attachmentPublicId]
|
||||
);
|
||||
|
||||
if (sample) {
|
||||
await this.ingestionService.enqueue({
|
||||
attachmentPublicId,
|
||||
filePath: statusResult[0]?.file_path ?? '',
|
||||
docType: sample.docType,
|
||||
docNumber: sample.docNumber,
|
||||
revision: sample.revision,
|
||||
projectCode: sample.projectCode,
|
||||
projectPublicId: sample.projectPublicId,
|
||||
classification: sample.classification,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async initCollection(): Promise<void> {
|
||||
await this.qdrant.onModuleInit();
|
||||
}
|
||||
|
||||
async deleteVectors(
|
||||
attachmentPublicId: string,
|
||||
requestedByUserPublicId = 'system'
|
||||
): Promise<void> {
|
||||
// ลบ DocumentChunk ออกจาก DB แบบ synchronous (รวดเร็ว ไม่มี external dependency)
|
||||
await this.chunkRepo.delete({ documentId: attachmentPublicId });
|
||||
// T028: เปลี่ยน Qdrant deletion เป็น async ผ่าน BullMQ เพื่อ eventual consistency (FR-008)
|
||||
await this.vectorDeletionQueue.add(
|
||||
'delete-document-vectors',
|
||||
{ documentPublicId: attachmentPublicId, requestedByUserPublicId },
|
||||
{
|
||||
jobId: attachmentPublicId,
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
}
|
||||
);
|
||||
this.logger.log(
|
||||
`Vector deletion queued for attachment=${attachmentPublicId}`
|
||||
);
|
||||
}
|
||||
|
||||
buildContext(
|
||||
results: Array<{
|
||||
docType: string;
|
||||
docNumber: string | null;
|
||||
revision: string | null;
|
||||
contentPreview: string;
|
||||
}>
|
||||
): string {
|
||||
let context = '';
|
||||
for (const r of results) {
|
||||
const header = `[${r.docType}${r.docNumber ? ` - ${r.docNumber}` : ''}${r.revision ? ` - ${r.revision}` : ''}]`;
|
||||
const snippet = `${header}\n${r.contentPreview}\n\n`;
|
||||
if ((context + snippet).length > PROMPT_CONTEXT_LIMIT) break;
|
||||
context += snippet;
|
||||
}
|
||||
return context.trim();
|
||||
}
|
||||
|
||||
private buildPrompt(question: string, context: string): string {
|
||||
return [
|
||||
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
|
||||
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
|
||||
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
|
||||
'',
|
||||
'=== เอกสารอ้างอิง ===',
|
||||
context,
|
||||
'',
|
||||
'=== คำถาม ===',
|
||||
question,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private buildCacheKey(
|
||||
question: string,
|
||||
projectPublicId: string,
|
||||
classificationCeiling: string
|
||||
): string {
|
||||
const raw = `${question}|${projectPublicId}|${classificationCeiling}`;
|
||||
return `rag:query:${createHash('sha256').update(raw).digest('hex')}`;
|
||||
}
|
||||
|
||||
private deriveClassificationCeiling(
|
||||
permissions: string[]
|
||||
): 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' {
|
||||
if (
|
||||
permissions.includes('system.manage_all') ||
|
||||
permissions.includes('document.view_confidential')
|
||||
) {
|
||||
return 'CONFIDENTIAL';
|
||||
}
|
||||
return 'INTERNAL';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# AI Runtime Policy Refactor for RTX 5060 Ti 16GB
|
||||
|
||||
ระบบ AI runtime ของ LCBP3-DMS จะเปลี่ยนไปใช้ canonical identities `np-dms-ai` และ `np-dms-ocr`, ใช้ `executionProfile` เป็น policy-level contract แทน model key/parameter overrides, และรวม GPU scheduling ของ main model, OCR, embedding, และ reranking ไว้ใต้ policy เดียวกัน. การตัดสินใจนี้รองรับการอัปเกรดเป็น RTX 5060 Ti 16GB โดยยังรักษา AI governance เดิมของระบบ: backend policy เป็นผู้ตัดสิน model/parameters จริง, `rag-query` เป็น generation-centric job, retrieval ใช้ GPU ได้ภายใต้ LLM-first ownership เท่านั้นและต้อง fallback CPU ได้, ส่วน rollout ใช้ big bang cutover พร้อม executable-first verification และ manual validation path สำหรับทุกแกนสำคัญ.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- เก็บชื่อ canonical เดิม (`typhoon2.5-np-dms:latest` / `typhoon-np-dms-ocr:latest`) แล้วใช้ alias เฉพาะ deploy
|
||||
- เปิดให้ caller ส่ง `model.key` และ runtime parameters มาใน job request
|
||||
- ใช้ shared GPU pool แบบสิทธิ์เท่ากันระหว่าง LLM, OCR, embed, rerank
|
||||
- phase-gated rollout แยก naming, residency, retrieval acceleration, queue policy เป็นหลายรอบ
|
||||
|
||||
เราไม่เลือกแนวทางเหล่านี้เพราะทำให้ governance ซ้ำซ้อน, เปิดช่อง bypass policy กลาง, หรือแยก resource policy ที่จริงผูกกันอยู่ให้กลายเป็นคนละเรื่อง. สำหรับ refactor รอบนี้ ระบบจะใช้ single-name canonical model policy, profile-only parameter governance, adaptive OCR residency, LLM-first GPU ownership, CPU fallback retrieval, selective realtime concurrency เฉพาะ lightweight realtime jobs และ big bang cutover gate ที่ต้องผ่านครบทั้ง contract, model switching, OCR residency, และ RAG fallback.
|
||||
@@ -0,0 +1,315 @@
|
||||
# AI Runtime Refactor
|
||||
|
||||
เอกสารนี้สรุปผล grilling session สำหรับการ refactor AI runtime หลังอัปเกรด GPU จาก RTX 2060 SUPER 8GB เป็น ASUS DUAL RTX 5060 Ti 16GB
|
||||
|
||||
เอกสารอ้างอิง:
|
||||
- [ADR-033](../specs/06-Decision-Records/ADR-033-active-model-and-ocr-management.md)
|
||||
- [ADR-034](../specs/06-Decision-Records/ADR-034-AI-model-change.md)
|
||||
- [ADR ใหม่: AI Runtime Policy Refactor](./adr/0001-ai-runtime-policy-refactor.md)
|
||||
- [CONTEXT.md](../CONTEXT.md)
|
||||
|
||||
## เป้าหมาย
|
||||
|
||||
- เปลี่ยนชื่อโมเดลหลักและ OCR ไปเป็น canonical identities ใหม่
|
||||
- ย้ายสัญญา API จาก caller-driven model selection ไปเป็น policy-driven `executionProfile`
|
||||
- รวมการจัดการ VRAM ของ main model, OCR, embedding, และ reranking ไว้ใน policy เดียว
|
||||
- ใช้ big bang rollout แบบมีกติกา cutover และ verification ที่รันซ้ำได้
|
||||
|
||||
## Decision Summary
|
||||
|
||||
### 1. Canonical naming
|
||||
|
||||
- ใช้ `np-dms-ai` เป็น canonical model identity เดียวทุกชั้นที่ผู้ใช้และนักพัฒนาเห็น
|
||||
- ใช้ `np-dms-ocr` เป็น canonical OCR identity เดียวทุกชั้น
|
||||
- ชื่อ runtime/base model จริงเป็น implementation detail ใน Modelfile, deploy script, หรือ ops internals เท่านั้น
|
||||
|
||||
### 2. API contract
|
||||
|
||||
- caller ส่งได้เพียง `executionProfile`
|
||||
- caller ห้ามส่ง `model.key`
|
||||
- caller ห้าม override `temperature`, `top_p`, `maxTokens`, หรือ runtime parameters อื่นโดยตรง
|
||||
- backend policy เป็นผู้ map `executionProfile` ไปยัง canonical model, runtime parameters, และ keep_alive policy
|
||||
|
||||
### 3. Canonical profile set
|
||||
|
||||
โปรไฟล์ระดับ contract มีแค่:
|
||||
|
||||
- `fast`
|
||||
- `balanced`
|
||||
- `thai-accurate`
|
||||
- `large-context`
|
||||
|
||||
กฎเพิ่ม:
|
||||
|
||||
- `large-context` จำกัดเฉพาะ admin/special workflows
|
||||
- งานที่มีผลต่อข้อมูล เช่น `migrate-document`, `auto-fill-document`, OCR extraction ใช้ backend override profile เอง
|
||||
|
||||
### 4. Runtime resource policy
|
||||
|
||||
- `np-dms-ai` เป็น workload หลักของ generation path
|
||||
- `np-dms-ocr` ใช้ adaptive residency แทน fixed `keep_alive`
|
||||
- retrieval acceleration (`BGE-M3`, `BGE-Reranker-Large`) อยู่ใน policy เดียวกับ main/OCR
|
||||
- GPU ownership ใช้หลัก LLM-first
|
||||
- ถ้า VRAM headroom ไม่พอ retrieval ต้อง fallback CPU ทันที
|
||||
|
||||
### 5. Queue policy
|
||||
|
||||
- คงโครง `ai-realtime` / `ai-batch` และ pause/resume coordination เดิมเป็นแกน
|
||||
- อนุญาต `ai-realtime = 2` ได้เฉพาะ lightweight realtime jobs
|
||||
- `rag-query` ไม่ใช่ lightweight realtime job
|
||||
- `rag-query` เป็น generation-centric job: retrieval เป็นขั้นเตรียม context และ fallback CPU ได้
|
||||
|
||||
### 6. Rollout policy
|
||||
|
||||
- rollout ใช้ `Big Bang`
|
||||
- cutover จะถือว่าสำเร็จต่อเมื่อผ่านครบทั้ง:
|
||||
- policy contract
|
||||
- model switching
|
||||
- adaptive OCR residency
|
||||
- RAG fallback
|
||||
|
||||
## Canonical Models
|
||||
|
||||
| Canonical Name | บทบาท | Residency policy | หมายเหตุ |
|
||||
|---|---|---|---|
|
||||
| `np-dms-ai` | main generation model | resident by default | backend policy คุม runtime parameters |
|
||||
| `np-dms-ocr` | OCR model | adaptive | ใช้ policy ตาม VRAM headroom และ active workload |
|
||||
|
||||
หมายเหตุ:
|
||||
- เอกสารนี้ไม่บังคับว่าฐานจริงต้องเป็น model family ใดเสมอไป
|
||||
- การเปลี่ยน base runtime model ในอนาคตไม่ควรเปลี่ยน canonical API/UI name ถ้า semantics เดิมยังอยู่
|
||||
|
||||
## Execution Profile Contract
|
||||
|
||||
### Request DTO
|
||||
|
||||
```typescript
|
||||
interface CreateAiJobRequest {
|
||||
type: 'auto-fill-document' | 'migrate-document' | 'rag-query';
|
||||
documentId?: string;
|
||||
attachmentId?: string;
|
||||
executionProfile?: 'fast' | 'balanced' | 'thai-accurate' | 'large-context';
|
||||
}
|
||||
```
|
||||
|
||||
### Policy rules
|
||||
|
||||
- `migrate-document`: backend override เป็น profile ที่ deterministic สูงเสมอ
|
||||
- `auto-fill-document`: backend override ได้ตาม data-affecting policy
|
||||
- `rag-query`: ปกติใช้ `balanced` หรือ policy ที่ backend กำหนด
|
||||
- `large-context`: ใช้ได้เฉพาะ admin/special workflows ที่ backend whitelist
|
||||
|
||||
### Forbidden contract
|
||||
|
||||
สิ่งต่อไปนี้ต้องไม่มีใน public contract:
|
||||
|
||||
```typescript
|
||||
model: {
|
||||
key: string;
|
||||
parameters: {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
เหตุผล:
|
||||
- caller bypass governance ได้
|
||||
- verification matrix โตเกินจำเป็น
|
||||
- profile abstraction หมดความหมายทันที
|
||||
|
||||
## Adaptive OCR Residency
|
||||
|
||||
หลักการ:
|
||||
|
||||
- `np-dms-ocr` ไม่ใช้ fixed `keep_alive: 0` หรือ fixed `keep_alive: 300` ตายตัว
|
||||
- backend policy คำนวณ residency จาก VRAM headroom และ active model/workload ปัจจุบัน
|
||||
- ถ้า active workload กิน VRAM สูง หรือ profile ปัจจุบันเสี่ยงชน headroom ให้ fallback เป็น `keep_alive: 0`
|
||||
- ถ้า headroom เหลือและไม่มี contention สำคัญ อนุญาต residency window ชั่วคราวได้
|
||||
|
||||
ตัวอย่าง policy:
|
||||
|
||||
```text
|
||||
if active_profile == 'large-context' => OCR keep_alive = 0
|
||||
if active_main_model_pressure == high => OCR keep_alive = 0
|
||||
if headroom >= policy threshold => OCR keep_alive = short residency window
|
||||
```
|
||||
|
||||
## LLM-First GPU Ownership
|
||||
|
||||
ลำดับสิทธิ์ VRAM:
|
||||
|
||||
1. `np-dms-ai`
|
||||
2. `np-dms-ocr`
|
||||
3. `BGE-M3`
|
||||
4. `BGE-Reranker-Large`
|
||||
|
||||
ผลเชิงพฤติกรรม:
|
||||
|
||||
- retrieval path ใช้ GPU ได้เฉพาะเมื่อ policy ระบุว่ามี headroom จริง
|
||||
- retrieval path ไม่มีสิทธิ์บังคับรอ GPU เพื่อแย่ง resource จาก main/OCR path
|
||||
- หาก headroom ไม่พอ `embed` และ `rerank` ต้อง fallback CPU ทันที
|
||||
|
||||
## Retrieval Acceleration
|
||||
|
||||
### Scope
|
||||
|
||||
เอกสารนี้ถือว่า retrieval acceleration เป็นส่วนหนึ่งของ runtime resource policy เดียวกัน ไม่ใช่ tuning แยก
|
||||
|
||||
### Sidecar policy
|
||||
|
||||
ปัจจุบัน:
|
||||
|
||||
```text
|
||||
POST /embed -> CPU
|
||||
POST /rerank -> CPU
|
||||
```
|
||||
|
||||
เป้าหมาย:
|
||||
|
||||
```text
|
||||
POST /embed -> GPU เมื่อ headroom ผ่าน policy, ไม่เช่นนั้นใช้ CPU
|
||||
POST /rerank -> GPU เมื่อ headroom ผ่าน policy, ไม่เช่นนั้นใช้ CPU
|
||||
POST /ocr-upload -> OCR path ตาม adaptive OCR residency
|
||||
POST /normalize -> CPU
|
||||
```
|
||||
|
||||
### Retrieval fallback rule
|
||||
|
||||
- ห้าม queue รอ GPU เพื่อให้ retrieval ได้ acceleration
|
||||
- ห้าม fail hard เพียงเพราะ GPU ไม่พอ
|
||||
- ให้ degrade ไป CPU แล้วตอบงานต่อ
|
||||
|
||||
## Queue and Scheduling
|
||||
|
||||
### Baseline
|
||||
|
||||
- `ai-batch` ยังสามารถถูก pause/resume โดย realtime path ตาม coordination model เดิม
|
||||
- `ai-realtime = 1` ยังคงเป็น baseline สำหรับงาน generation-heavy
|
||||
|
||||
### Selective realtime uplift
|
||||
|
||||
อนุญาต `ai-realtime = 2` เฉพาะกลุ่มงานที่เป็น lightweight realtime jobs เช่น:
|
||||
|
||||
- intent classification ที่ไม่เรียก OCR
|
||||
- tool-only suggestion path ที่ไม่บังคับ model switching
|
||||
- metadata-free chat steps ที่ไม่ใช้ GPU-heavy generation
|
||||
|
||||
ไม่รวม:
|
||||
|
||||
- `rag-query`
|
||||
- OCR-triggering jobs
|
||||
- งานที่บังคับ model switching
|
||||
- generation-heavy jobs
|
||||
|
||||
## Big Bang Rollout
|
||||
|
||||
### Decision
|
||||
|
||||
refactor รอบนี้ใช้ big bang rollout เพราะระบบยังไม่เปิด production
|
||||
|
||||
### Consequence
|
||||
|
||||
ห้ามใช้เกณฑ์ partial success แบบ "บางแกนผ่านก็ถือว่าปล่อยได้"
|
||||
|
||||
### Cutover gate
|
||||
|
||||
ต้องผ่านครบทุกแกน:
|
||||
|
||||
1. policy contract ใหม่ทำงานจริง
|
||||
2. canonical naming ใหม่ทำงานจริง
|
||||
3. model switching และ OCR residency ตรง policy ใหม่
|
||||
4. retrieval GPU/CPU fallback ทำงานจริง
|
||||
|
||||
## Verification
|
||||
|
||||
ใช้แนวทาง executable-first แต่ทุกแกนต้องมี manual validation path ประกบ
|
||||
|
||||
### 1. Policy contract
|
||||
|
||||
Executable:
|
||||
|
||||
- unit/integration tests สำหรับ DTO และ policy mapping
|
||||
- tests ว่า caller ส่ง `model.key` หรือ parameter overrides ไม่ได้
|
||||
- tests ว่า data-affecting jobs ถูก backend override profile จริง
|
||||
|
||||
Manual:
|
||||
|
||||
- ยิง request จาก admin/sandbox แล้วตรวจว่า UI/API ไม่ expose free-form model selection
|
||||
|
||||
### 2. Canonical naming
|
||||
|
||||
Executable:
|
||||
|
||||
- search-based checks ว่า public-facing contract ใช้ `np-dms-ai` / `np-dms-ocr`
|
||||
- tests สำหรับ settings/service/controller ที่คืนชื่อ canonical
|
||||
|
||||
Manual:
|
||||
|
||||
- เปิด AI Admin Console และ OCR sandbox ตรวจ label/option/log surface ที่ผู้ใช้เห็น
|
||||
|
||||
### 3. Adaptive OCR residency
|
||||
|
||||
Executable:
|
||||
|
||||
- tests ว่า residency policy ให้ `keep_alive` ต่างกันตาม headroom scenario
|
||||
- logs/trace ว่า OCR requests ใช้ residency decision ตาม policy
|
||||
|
||||
Manual:
|
||||
|
||||
- รัน OCR ซ้ำหลายงานในเงื่อนไข headroom ต่างกันและตรวจ behavior จริง
|
||||
|
||||
### 4. Retrieval fallback
|
||||
|
||||
Executable:
|
||||
|
||||
- tests ว่า `/embed` และ `/rerank` fallback CPU เมื่อ GPU threshold ไม่ผ่าน
|
||||
- trace/log ว่า `rag-query` ยังตอบได้เมื่อ GPU retrieval path ถูกปิด
|
||||
|
||||
Manual:
|
||||
|
||||
- ทดลอง RAG query ภายใต้ภาระ GPU สูงและยืนยันว่าคำตอบยังออกได้แม้ช้าลง
|
||||
|
||||
## Implementation Workstreams
|
||||
|
||||
### Workstream A: Contract and naming
|
||||
|
||||
- เปลี่ยน public contract ให้ใช้ `executionProfile`
|
||||
- ลบ `model.key` และ parameter override จาก API docs/DTO ที่เกี่ยวข้อง
|
||||
- เปลี่ยน public-facing names เป็น `np-dms-ai` และ `np-dms-ocr`
|
||||
|
||||
### Workstream B: Runtime policy
|
||||
|
||||
- สร้าง policy mapping profile -> runtime configuration
|
||||
- เพิ่ม adaptive OCR residency logic
|
||||
- แยก policy ของ data-affecting jobs ออกจาก caller input
|
||||
|
||||
### Workstream C: Retrieval acceleration
|
||||
|
||||
- เพิ่ม GPU eligibility check สำหรับ `embed` และ `rerank`
|
||||
- เพิ่ม CPU fallback path ที่ explicit
|
||||
- บันทึก telemetry/log สำหรับ fallback decisions
|
||||
|
||||
### Workstream D: Queue policy
|
||||
|
||||
- คง pause/resume coordination เดิม
|
||||
- แยก lightweight realtime jobs ออกจาก generation-heavy jobs
|
||||
- ใช้ selective concurrency uplift เฉพาะ job ที่ allowed
|
||||
|
||||
### Workstream E: Verification
|
||||
|
||||
- เพิ่ม automated tests ตาม cutover gate
|
||||
- เพิ่ม manual validation checklist สำหรับ admin console, OCR sandbox, และ RAG path
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- ไม่เปิดให้ caller เลือก runtime parameters เอง
|
||||
- ไม่เปลี่ยน `rag-query` ให้เป็น retrieval-first job
|
||||
- ไม่ยกเลิก pause/resume coordination เดิมทั้งหมด
|
||||
- ไม่แยก retrieval acceleration ออกเป็น policy คนละชุดกับ main/OCR
|
||||
- ไม่ใช้ phased rollout ในเอกสารฉบับนี้
|
||||
|
||||
## Migration Note for Current Repo
|
||||
|
||||
repo ปัจจุบันยังมีจุดที่อิงชื่อและ policy เดิม เช่น `typhoon2.5-np-dms:latest`, `typhoon-np-dms-ocr:latest`, และ `keep_alive: 0` ในหลาย service/spec. เอกสารนี้จึงเป็น target architecture/policy ใหม่ และต้องมีการอัปเดตโค้ด, tests, cross-spec docs, และ admin UI ให้สอดคล้องก่อนจะถือว่า cutover สำเร็จ.
|
||||
+1
-1
@@ -726,7 +726,7 @@ AI-powered Document Management System
|
||||
6 Automation workflow
|
||||
7 Security
|
||||
```
|
||||
## 💬 Prompt Templates สำหรับถาม Windsurf
|
||||
## 💬 Prompt Templates สำหรับถาม Devin
|
||||
|
||||
### เมื่อต้องการสร้างฟีเจอร์ใหม่
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI Knowledge Base for NAP-DMS (LCBP3)
|
||||
|
||||
คลังความรู้สำหรับ AI Assistant (Antigravity, Windsurf, Codex) เพื่อช่วยในการพัฒนาระบบ Document Management System (DMS)
|
||||
คลังความรู้สำหรับ AI Assistant (Antigravity, Devin, Codex) เพื่อช่วยในการพัฒนาระบบ Document Management System (DMS)
|
||||
|
||||
## 📁 โครงสร้างโฟลเดอร์
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- `core/`: กฎพื้นฐานและมาตรฐานการเขียนโค้ด
|
||||
- `dms/`: เฉพาะทางด้านระบบจัดการเอกสาร
|
||||
- `infra/`: งานด้าน Infrastructure และ Network
|
||||
- `codex/`: คำสั่งเฉพาะสำหรับ Windsurf/Codex
|
||||
- `codex/`: คำสั่งเฉพาะสำหรับ Devin/Codex
|
||||
- `templates/`: แม่แบบเอกสารต่างๆ (Spec, Bug Report, etc.)
|
||||
- `playbooks/`: คู่มือขั้นตอนการทำงานที่ซับซ้อน
|
||||
- `checklists/`: รายการตรวจสอบก่อนส่งงานหรือ Deploy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md
|
||||
# Bug Fix Prompt (Windsurf/Codex)
|
||||
# Bug Fix Prompt (Devin/Codex)
|
||||
|
||||
## ⭐ Role: Debugging Specialist
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// File: docs/ai-knowledge-base/prompts/codex/codex-feature.md
|
||||
# Feature Implementation Prompt (Windsurf/Codex)
|
||||
# Feature Implementation Prompt (Devin/Codex)
|
||||
|
||||
## ⭐ Role: Senior Full Stack Developer (DMS Specialist)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// File: docs/ai-knowledge-base/prompts/codex/codex-review.md
|
||||
# Code Review Prompt (Windsurf/Codex)
|
||||
# Code Review Prompt (Devin/Codex)
|
||||
|
||||
## ⭐ Role: Senior Code Reviewer (DMS Specialist)
|
||||
|
||||
|
||||
+4
-4
@@ -18,7 +18,7 @@ npx playwright install chromium
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### 3. **MCP Server สำหรับ Windsurf**
|
||||
### 3. **MCP Server สำหรับ Devin**
|
||||
|
||||
เพิ่มใน [.windsurfrc](cci:7://file:///e:/np-dms/lcbp3/.windsurfrc:0:0-0:0):
|
||||
|
||||
@@ -33,9 +33,9 @@ npx playwright install
|
||||
}
|
||||
```
|
||||
|
||||
**Restart Windsurf** แล้วจะเห็น Playwright MCP panel
|
||||
**Restart Devin** แล้วจะเห็น Playwright MCP panel
|
||||
|
||||
### 4. **การใช้งานผ่าน Windsurf Cascade**
|
||||
### 4. **การใช้งานผ่าน Devin Cascade**
|
||||
|
||||
เมื่อ MCP พร้อมแล้ว สามารถใช้คำสั่ง:
|
||||
|
||||
@@ -101,7 +101,7 @@ npx playwright test --headed
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
### 8. **ถ้าใช้ MCP ผ่าน Windsurf**
|
||||
### 8. **ถ้าใช้ MCP ผ่าน Devin**
|
||||
|
||||
Cascade จะมี tool ให้ใช้:
|
||||
- `browser_navigate` - เปิด URL
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useRagQuery } from '../../../hooks/use-rag';
|
||||
import { RagChatWidget } from '../../../components/ai/RagChatWidget';
|
||||
import { useProjectStore } from '../../../lib/stores/project-store';
|
||||
import { RagSearchBar } from '../../../components/rag/rag-search-bar';
|
||||
import { RagResultCard } from '../../../components/rag/rag-result-card';
|
||||
|
||||
export default function RagPage() {
|
||||
const { selectedProjectId } = useProjectStore();
|
||||
const { mutate, data, isPending, error, isIdle } = useRagQuery();
|
||||
|
||||
const handleSearch = (question: string) => {
|
||||
if (!selectedProjectId) return;
|
||||
mutate({ question, projectPublicId: selectedProjectId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl py-8 space-y-6">
|
||||
@@ -28,25 +20,11 @@ export default function RagPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RagSearchBar onSearch={handleSearch} isLoading={isPending} />
|
||||
|
||||
{isPending && (
|
||||
<div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground animate-pulse">
|
||||
กำลังค้นหาและประมวลผล...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
เกิดข้อผิดพลาด: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !isPending && <RagResultCard result={data} />}
|
||||
|
||||
{isIdle && !error && (
|
||||
{selectedProjectId ? (
|
||||
<RagChatWidget projectPublicId={selectedProjectId} />
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted-foreground pt-4">
|
||||
พิมพ์คำถามแล้วกด ค้นหา เพื่อรับคำตอบจากเอกสารโครงการ
|
||||
เลือกโครงการก่อนเพื่อเริ่มถามคำถามกับ RAG pipeline ใหม่
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function OcrSandboxPromptManager() {
|
||||
fallbackUsed?: boolean;
|
||||
} | null>(null);
|
||||
const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined);
|
||||
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox } =
|
||||
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox, startPolling } =
|
||||
useSandboxRun(() => {
|
||||
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
|
||||
versionsQuery.refetch();
|
||||
@@ -285,24 +285,8 @@ export default function OcrSandboxPromptManager() {
|
||||
selectedPromptVersion
|
||||
);
|
||||
toast.success('AI Extraction started');
|
||||
// Poll สำหรับผลลัพธ์ AI
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const result = await adminAiService.getSandboxJobStatus(requestPublicId);
|
||||
if (result.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
// Trigger sandbox state update via useSandboxRun
|
||||
toast.success(t('ai.prompt.sandboxSuccess'));
|
||||
versionsQuery.refetch();
|
||||
} else if (result.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
toast.error(result.errorMessage || 'AI Extraction failed');
|
||||
}
|
||||
} catch (_err) {
|
||||
clearInterval(pollInterval);
|
||||
toast.error('Poll error occurred');
|
||||
}
|
||||
}, 1000);
|
||||
// เริ่ม polling ผ่าน useSandboxRun hook
|
||||
startPolling(requestPublicId);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || 'AI Extraction failed');
|
||||
@@ -628,6 +612,26 @@ export default function OcrSandboxPromptManager() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sandboxState.result && sandboxState.result.llmPrompt && (
|
||||
<Card className="border border-purple-500/20 bg-purple-500/5">
|
||||
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base text-purple-600 dark:text-purple-400 flex items-center gap-2">
|
||||
<StickyNote className="h-4 w-4" />
|
||||
LLM Prompt (Step 2 Input)
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sandboxState.result.llmPrompt.length} chars
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[300px] border border-border/10">
|
||||
<pre className="text-purple-600 dark:text-purple-400 select-text leading-relaxed whitespace-pre-wrap">
|
||||
{sandboxState.result.llmPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sandboxState.isRunning && (
|
||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
export function RagFallbackBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
ใช้ local model คุณภาพอาจลดลง
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { RagQueryResponse, RagCitation } from '../../hooks/use-rag';
|
||||
import { RagFallbackBadge } from './rag-fallback-badge';
|
||||
|
||||
interface RagResultCardProps {
|
||||
result: RagQueryResponse;
|
||||
}
|
||||
|
||||
function ConfidenceBar({ score }: { score: number }) {
|
||||
const pct = Math.round(score * 100);
|
||||
const color =
|
||||
pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-24 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full ${color} transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CitationItem({ citation }: { citation: RagCitation }) {
|
||||
return (
|
||||
<div className="rounded border p-3 text-sm space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{citation.docType}</span>
|
||||
{citation.docNumber && (
|
||||
<span className="text-muted-foreground">— {citation.docNumber}</span>
|
||||
)}
|
||||
{citation.revision && (
|
||||
<span className="rounded bg-muted px-1 text-xs">Rev. {citation.revision}</span>
|
||||
)}
|
||||
</div>
|
||||
<ConfidenceBar score={citation.score} />
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-3">{citation.snippet}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RagResultCard({ result }: RagResultCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-base mb-1">คำตอบ</h3>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{result.answer}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<ConfidenceBar score={result.confidence} />
|
||||
{result.usedFallbackModel && <RagFallbackBadge />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.citations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
อ้างอิง ({result.citations.length} เอกสาร)
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.citations.map((c) => (
|
||||
<CitationItem key={c.chunkId} citation={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
question: z.string().min(1, 'กรุณาระบุคำถาม').max(500, 'คำถามต้องไม่เกิน 500 ตัวอักษร'),
|
||||
});
|
||||
|
||||
interface RagSearchBarProps {
|
||||
onSearch: (question: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function RagSearchBar({ onSearch, isLoading }: RagSearchBarProps) {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const result = schema.safeParse({ question });
|
||||
if (!result.success) {
|
||||
setError(result.error.issues[0]?.message ?? 'ข้อมูลไม่ถูกต้อง');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onSearch(question);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="ถามคำถามเกี่ยวกับเอกสารโครงการ..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={isLoading}
|
||||
maxLength={500}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
|
||||
<p className="mt-1 text-xs text-muted-foreground text-right">
|
||||
{question.length}/500
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || question.trim().length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
ค้นหา
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -155,11 +155,24 @@ export function useSandboxRun(onCompleted?: () => void) {
|
||||
setJobId(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(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setJobId(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-29: เพิ่ม ocr field ใน AiSystemHealth interface ตาม OcrService.checkHealth()
|
||||
// - 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: เพิ่ม getVramStatus และปรับปรุง getAvailableModels/setActiveModel/addModel ให้เรียกใช้ endpoints ใหม่ที่มี VRAM capacity check (T031-T034, US2)
|
||||
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (หลัก+OCR) ใน AiSystemHealth interface
|
||||
@@ -75,6 +76,7 @@ export interface AiSandboxJobResult {
|
||||
engineUsed?: string;
|
||||
fallbackUsed?: boolean;
|
||||
promptVersionUsed?: number;
|
||||
llmPrompt?: string;
|
||||
citations?: AiRagCitation[];
|
||||
confidence?: number;
|
||||
usedFallbackModel?: boolean;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types": ["types/*"],
|
||||
"@api": ["app/api/*"],
|
||||
// เพิ่มส่วนที่ขาดไปเพื่อให้ตรงกับ Workspace
|
||||
"@hooks/*": ["app/hooks/*"],
|
||||
"@hooks/*": ["hooks/*"],
|
||||
"@utils/*": ["utils/*"]
|
||||
},
|
||||
"target": "ES2017"
|
||||
|
||||
+81
-45
@@ -12,6 +12,10 @@
|
||||
"name": "🎨 Frontend",
|
||||
"path": "frontend",
|
||||
},
|
||||
{
|
||||
"name": "🗓️ docs",
|
||||
"path": "docs",
|
||||
},
|
||||
{
|
||||
"name": "🔗 specs",
|
||||
"path": "specs",
|
||||
@@ -177,13 +181,13 @@
|
||||
"@workflow-engine": "${workspaceFolder:🔧 Backend}/src/modules/workflow-engine",
|
||||
|
||||
// Frontend paths (ไม่มี src)
|
||||
"@": "${workspaceFolder:🎨 Frontend}/app",
|
||||
"@/*": "${workspaceFolder:🎨 Frontend}/app/*",
|
||||
"@": "${workspaceFolder:🎨 Frontend}",
|
||||
"@/*": "${workspaceFolder:🎨 Frontend}/*",
|
||||
"@app": "${workspaceFolder:🎨 Frontend}/app",
|
||||
"@components": "${workspaceFolder:🎨 Frontend}/components",
|
||||
"@fe-config": "${workspaceFolder:🎨 Frontend}/config",
|
||||
"@lib": "${workspaceFolder:🎨 Frontend}/lib",
|
||||
"@hooks": "${workspaceFolder:🎨 Frontend}/app/hooks",
|
||||
"@hooks": "${workspaceFolder:🎨 Frontend}/hooks",
|
||||
"@utils": "${workspaceFolder:🎨 Frontend}/utils",
|
||||
"@providers": "${workspaceFolder:🎨 Frontend}/providers",
|
||||
"@public": "${workspaceFolder:🎨 Frontend}/public",
|
||||
@@ -355,24 +359,22 @@
|
||||
"importCost.smallPackageColor": "#98C379",
|
||||
|
||||
// ========================================
|
||||
// JAVASCRIPT/TYPESCRIPT
|
||||
// JAVASCRIPT/TYPESCRIPT (Unified Settings)
|
||||
// ========================================
|
||||
|
||||
"javascript.suggest.autoImports": true,
|
||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||
"javascript.inlayHints.parameterNames.enabled": "all",
|
||||
"javascript.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||
"javascript.inlayHints.variableTypes.enabled": false,
|
||||
"javascript.preferences.importModuleSpecifier": "relative",
|
||||
// Unified JS/TS settings (replaces deprecated javascript.* and typescript.*)
|
||||
"#js/ts.suggest.autoImports": true,
|
||||
"#js/ts.updateImportsOnFileMove.enabled": "always",
|
||||
"#js/ts.preferences.importModuleSpecifier": "relative",
|
||||
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.inlayHints.parameterNames.enabled": "all",
|
||||
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||
"typescript.inlayHints.variableTypes.enabled": false,
|
||||
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.tsdk": "node_modules/typescript/lib", // ✅ ใช้ relative path
|
||||
// Unified inlay hints (replaces deprecated javascript.inlayHints.* and typescript.inlayHints.*)
|
||||
"editor.inlayHints.parameterNames.enabled": "all",
|
||||
"editor.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||
"editor.inlayHints.variableTypes.enabled": false,
|
||||
"editor.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||
|
||||
// Unified TypeScript SDK path (replaces deprecated typescript.tsdk)
|
||||
"#js/ts.tsdk.path": "node_modules/typescript/lib", // ✅ ใช้ relative path
|
||||
// ========================================
|
||||
// EMMET
|
||||
// ========================================
|
||||
@@ -388,7 +390,6 @@
|
||||
// FILES
|
||||
// ========================================
|
||||
|
||||
//"files.autoSave": "onFocusChange",
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.encoding": "utf8",
|
||||
@@ -448,7 +449,7 @@
|
||||
// TERMINAL
|
||||
// ========================================
|
||||
|
||||
"terminal.integrated.fontSize": 15,
|
||||
"terminal.integrated.fontSize": 18,
|
||||
"terminal.integrated.lineHeight": 1.2,
|
||||
"terminal.integrated.smoothScrolling": true,
|
||||
"terminal.integrated.cursorBlinking": true,
|
||||
@@ -658,8 +659,8 @@
|
||||
"name": "lcbp3_dev",
|
||||
"database": "lcbp3_dev",
|
||||
"username": "root",
|
||||
"password": "",
|
||||
"askForPassword": true, // ✅ ปลอดภัยกว่า
|
||||
"password": "${env:DB_PASSWORD}", // ✅ ใช้ environment variable แทน hardcoded
|
||||
"askForPassword": true,
|
||||
},
|
||||
],
|
||||
"database-client.variableIndicator": [":", "$"],
|
||||
@@ -670,6 +671,7 @@
|
||||
"vitest.enable": true,
|
||||
"yaml.maxItemsComputed": 10000,
|
||||
"powershell.cwd": "🎯 Root",
|
||||
// "terminal.integrated.persistentSessionReviveProcess": "never",
|
||||
"files.autoSave": "onFocusChange",
|
||||
"diffEditor.codeLens": false,
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
@@ -680,6 +682,7 @@
|
||||
"editor.mouseWheelZoom": true,
|
||||
"terminal.integrated.mouseWheelZoom": true,
|
||||
"terminal.integrated.tabs.title": "${process}-${cwd}",
|
||||
"workbench.editor.sharedViewState": true,
|
||||
},
|
||||
// ========================================
|
||||
// LAUNCH CONFIGURATIONS
|
||||
@@ -886,7 +889,7 @@
|
||||
// 1. Task หลักที่จะรันอัตโนมัติเมื่อเปิดโปรแกรม
|
||||
{
|
||||
"label": "🚀 Setup Workspace",
|
||||
"dependsOn": ["🔧 PS: Backend", "🎨 PS: Frontend"], // สั่งให้รัน 2 task ย่อย
|
||||
"dependsOn": ["🎯 PS: Root", "🔧 PS: Backend", "🎨 PS: Frontend"], // สั่งให้รัน 3 task ย่อย
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen", // <--- คำสั่งศักดิ์สิทธิ์: รันทันทีที่เปิด VS Code
|
||||
},
|
||||
@@ -895,30 +898,30 @@
|
||||
},
|
||||
"problemMatcher": [],
|
||||
},
|
||||
// 2. Task ย่อย: เปิด Terminal ที่ Backend
|
||||
// 2. Task ย่อย: เปิด Terminal ที่ Root
|
||||
{
|
||||
"label": "🔧 PS: Backend",
|
||||
"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",
|
||||
"label": "🎯 PS: Root",
|
||||
"type": "shell",
|
||||
"command": "powershell",
|
||||
"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,
|
||||
"problemMatcher": [],
|
||||
@@ -926,8 +929,41 @@
|
||||
"group": "workspace-terminals",
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"focus": false, // ไม่ต้องแย่ง Focus ทันที
|
||||
// "focus": true // ให้ Focus ที่อันนี้เป็นอันสุดท้าย (พร้อมพิมพ์)
|
||||
"focus": false,
|
||||
},
|
||||
},
|
||||
// 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
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
# File: specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/Dockerfile
|
||||
# Tesseract OCR Sidecar — HTTP API server สำหรับสกัดข้อความจาก PDF/Image
|
||||
# Typhoon OCR Sidecar — HTTP API server สำหรับสกัดข้อความจาก PDF ผ่าน np-dms-ocr (Ollama)
|
||||
# รันบน Desk-5439 ตาม ADR-023A
|
||||
# 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: เพิ่ม system dependencies สำหรับ OpenCV (libsm6, libxext6, libxrender1, libfontconfig1, libx11-6)
|
||||
# - 2026-05-30: Typhoon OCR ใช้ httpx เรียก Ollama ผ่าน OLLAMA_API_URL (T009a, ADR-032)
|
||||
# Container รันบน CPU เท่านั้น ไม่ต้องการ CUDA/GPU ใน container
|
||||
# - 2026-06-11: เพิ่ม typhoon-ocr ใน requirements.txt — poppler-utils มีอยู่แล้ว (ใช้โดย prepare_ocr_messages)
|
||||
# - 2026-06-11: ตัด tesseract-ocr, tesseract-ocr-tha, tesseract-ocr-eng, libsm6, libxext6, libxrender1, libfontconfig1, libx11-6 — ไม่ใช้ Tesseract อีกต่อไป
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
# ติดตั้ง system dependencies สำหรับ PDF processing, Tesseract OCR, ภาษาไทย และ OpenCV
|
||||
# ติดตั้ง system dependencies สำหรับ PDF processing และ PyMuPDF
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libgomp1 \
|
||||
poppler-utils \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-tha \
|
||||
tesseract-ocr-eng \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
libfontconfig1 \
|
||||
libx11-6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# File: specs/04-Infrastructure-OPS/04-00-docker-compose\Desk-5439\ocr-sidecar\app.py
|
||||
# Typhoon OCR HTTP Sidecar API — รับ POST /ocr แล้วคืนข้อความที่สกัดจาก PDF/Image
|
||||
# ตาม ADR-023A (revised 2026-06-11): ใช้ typhoon_ocr library + np-dms-ocr (Ollama) แทน Tesseract
|
||||
# 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: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า
|
||||
# - 2026-05-30: เพิ่ม OpenCV preprocessing (threshold, denoise) และ DPI 300 เพื่อเพิ่มความแม่นยำ
|
||||
@@ -14,37 +14,58 @@
|
||||
# - 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)
|
||||
# - 2026-06-06: เปลี่ยน keep_alive จาก 300s เป็น 0 เพื่อ unload model ทันทีหลังเสร็จงาน (แก้ปัญหา VRAM ไม่พอเมื่อ typhoon2.5-np-dms load พร้อมกัน)
|
||||
# - 2026-06-11: เปลี่ยน process_with_typhoon_ocr ให้ใช้ prepare_ocr_messages จาก typhoon_ocr library + inject DMS tags; เปลี่ยน endpoint เป็น /v1/chat/completions
|
||||
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
import base64
|
||||
import fitz # PyMuPDF
|
||||
import json
|
||||
import tempfile
|
||||
import fitz # PyMuPDF (ใช้สำหรับ page count + fast-path text extraction)
|
||||
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 typhoon_ocr import prepare_ocr_messages
|
||||
|
||||
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
|
||||
from FlagEmbedding import BGEM3FlagModel, FlagReranker
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("ocr-sidecar")
|
||||
|
||||
app = FastAPI(title="Tesseract OCR Sidecar", version="1.0.0")
|
||||
app = FastAPI(title="Typhoon OCR Sidecar", version="2.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 ตามข้อเสนอแนะในการรักษาความมั่นคงปลอดภัย
|
||||
OCR_SIDECAR_API_KEY = os.getenv("OCR_SIDECAR_API_KEY", "lcbp3-dms-ocr-sidecar-secure-token-2026")
|
||||
@@ -59,162 +80,25 @@ async def get_api_key(api_key: str = Security(api_key_header)):
|
||||
# อ่านค่า 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})")
|
||||
logger.info(f"Typhoon OCR Sidecar initialized (model={TYPHOON_OCR_MODEL}, ollama={OLLAMA_API_URL})")
|
||||
|
||||
def filter_ocr_noise(text: str) -> str:
|
||||
"""Filter ขยะ OCR เช่น บรรทัดสั้น/สัญลักษณ์ที่ไม่มีความหมาย"""
|
||||
"""กรองสัญลักษณ์ที่ไม่มีความหมายออกจาก Markdown output"""
|
||||
lines = text.split("\n")
|
||||
filtered_lines = []
|
||||
|
||||
filtered = []
|
||||
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
|
||||
filtered.append(line)
|
||||
return "\n".join(filtered)
|
||||
|
||||
class OcrRequest(BaseModel):
|
||||
pdfPath: str
|
||||
@@ -232,11 +116,9 @@ class OcrResponse(BaseModel):
|
||||
def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"engines": ["tesseract", "typhoon-np-dms-ocr"],
|
||||
"engine": "typhoon-np-dms-ocr",
|
||||
"typhoonModel": TYPHOON_OCR_MODEL,
|
||||
"tesseractConfig": TESSERACT_CONFIG,
|
||||
"aggressivePreprocess": USE_AGGRESSIVE_PREPROCESSING,
|
||||
"smartCleaning": USE_SMART_CLEANING,
|
||||
"ollamaUrl": OLLAMA_API_URL,
|
||||
}
|
||||
|
||||
# alias map สำหรับ engine name เก่า → canonical name
|
||||
@@ -246,7 +128,7 @@ _ENGINE_ALIASES: dict[str, str] = {
|
||||
"typhoon_ocr": "typhoon-np-dms-ocr",
|
||||
}
|
||||
|
||||
def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, typhoon_options: dict = {}) -> OcrResponse:
|
||||
def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, typhoon_options: dict = {}, pdf_path: str | None = None) -> 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)))
|
||||
@@ -271,15 +153,13 @@ def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, t
|
||||
)
|
||||
|
||||
if selected_engine == "typhoon-np-dms-ocr":
|
||||
# ใช้ prepare_ocr_messages รับ PDF path โดยตรง — ไม่ต้องแปลง PIL Image อีกต่อไป
|
||||
resolved_path = pdf_path or (str(doc.name) if hasattr(doc, 'name') and doc.name else None)
|
||||
if not resolved_path:
|
||||
raise ValueError("ไม่สามารถหา PDF path — ต้องส่ง pdf_path เข้ามาด้วย")
|
||||
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_parts.append(process_with_typhoon_ocr(resolved_path, page_num=i + 1, options_override=typhoon_options))
|
||||
typhoon_text = filter_ocr_noise("\n".join(typhoon_text_parts).strip())
|
||||
return OcrResponse(
|
||||
text=typhoon_text,
|
||||
@@ -289,89 +169,65 @@ def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, t
|
||||
engineUsed=selected_engine,
|
||||
)
|
||||
|
||||
logger.info(f"Slow path (Tesseract): {total_chars} chars too few")
|
||||
ocr_text_parts = []
|
||||
# ถ้าไม่ใช่ engine ที่รู้จัก ให้ใช้ typhoon-np-dms-ocr เป็น fallback
|
||||
logger.warning(f"Unknown engine '{selected_engine}' — fallback to typhoon-np-dms-ocr")
|
||||
resolved_path = pdf_path or (str(doc.name) if hasattr(doc, 'name') and doc.name else None)
|
||||
if not resolved_path:
|
||||
raise ValueError("ไม่สามารถหา PDF path — ต้องส่ง pdf_path เข้ามาด้วย")
|
||||
fallback_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")
|
||||
fallback_parts.append(process_with_typhoon_ocr(resolved_path, page_num=i + 1, options_override=typhoon_options))
|
||||
fallback_text = filter_ocr_noise("\n".join(fallback_parts).strip())
|
||||
return OcrResponse(
|
||||
text=ocr_text,
|
||||
text=fallback_text,
|
||||
ocrUsed=True,
|
||||
pageCount=page_count,
|
||||
charCount=len(ocr_text),
|
||||
engineUsed="tesseract",
|
||||
charCount=len(fallback_text),
|
||||
engineUsed="typhoon-np-dms-ocr",
|
||||
)
|
||||
|
||||
def process_with_typhoon_ocr(pil_image: Image.Image, options_override: dict = {}) -> str:
|
||||
"""เรียก Typhoon OCR ผ่าน Ollama — ใช้ SYSTEM ใน Modelfile เป็น instruction หลัก; options_override ยัง override ค่า Modelfile ได้"""
|
||||
def process_with_typhoon_ocr(pdf_path: str, page_num: int = 1, options_override: dict = {}) -> str:
|
||||
"""เรียก Typhoon OCR ผ่าน Ollama /v1/chat/completions — รับ PDF path โดยตรง ไม่ต้องแปลง PIL Image"""
|
||||
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,
|
||||
}
|
||||
# prepare_ocr_messages จัดการ PDF → image ผ่าน poppler/pdftoppm ภายใน
|
||||
messages = prepare_ocr_messages(pdf_path, task_type="structure", page_num=page_num)
|
||||
# inject DMS-specific extraction tags ต่อท้าย content
|
||||
messages[0]["content"].append({
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Additionally:\n"
|
||||
"- Wrap document number with <document_number>...</document_number>\n"
|
||||
"- Wrap document date with <document_date>...</document_date>\n"
|
||||
"- Wrap received date with <received_date>...</received_date>\n"
|
||||
"If a field is not found, omit the tag."
|
||||
),
|
||||
})
|
||||
# ค่า default ตาม official; options_override ยัง override ได้บางส่วน
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"prompt": """You are an expert in structuring Thai documents
|
||||
|
||||
Task: Extract the information from the image in the most correct and organized format.
|
||||
|
||||
Output Rules:
|
||||
- Return ONLY clean Markdown output
|
||||
- Include ALL information visible on the page
|
||||
- Preserve document structure and hierarchy
|
||||
- Do NOT add explanations or interpretations
|
||||
- Do NOT include these instructions in your response
|
||||
|
||||
Formatting:
|
||||
- Tables: Use HTML <table> tags
|
||||
- Math: $inline$ and $$block$$ LaTeX
|
||||
- Figures: <figure>Thai description</figure>
|
||||
- Pages: <page_number>N</page_number>
|
||||
- Boxes: ☐ / ☑
|
||||
- Unclear: [unclear: context]
|
||||
- Signatures/Stamps: Describe location and context
|
||||
|
||||
Extract all text from this image.""",
|
||||
"images": [image_base64],
|
||||
"messages": messages,
|
||||
"max_tokens": 16000,
|
||||
"stream": False,
|
||||
"options": options,
|
||||
"keep_alive": 300, # คง model ไว้ใน VRAM/RAM 5 นาที เพื่อลด cold-start ระหว่าง sandbox session
|
||||
"repetition_penalty": options_override.get("repeat_penalty", 1.2),
|
||||
"temperature": options_override.get("temperature", 0.1),
|
||||
"top_p": options_override.get("top_p", 0.6),
|
||||
"keep_alive": 0, # Unload model ทันทีหลังเสร็จงานเพื่อคืน VRAM ให้ np-dms-ai ใช้งานได้
|
||||
}
|
||||
# ใช้ Ollama OpenAI-compatible endpoint (/v1/chat/completions)
|
||||
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}/v1/chat/completions",
|
||||
json=payload,
|
||||
headers={"Authorization": "Bearer ollama"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
result_text = str(data.get("response", "")).strip()
|
||||
raw_text = str(data.get("choices", [{}])[0].get("message", {}).get("content", "")).strip()
|
||||
# parse JSON output จาก model (format: {"natural_text": "..."})
|
||||
try:
|
||||
result_text = json.loads(raw_text).get("natural_text", raw_text)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
result_text = raw_text
|
||||
logger.info(
|
||||
f"[DIAG] Ollama response — model={model_name} "
|
||||
f"textLen={len(result_text)} "
|
||||
@@ -420,12 +276,22 @@ def ocr_upload(
|
||||
if repeatPenalty is not None:
|
||||
typhoon_options["repeat_penalty"] = repeatPenalty
|
||||
pdf_bytes = file.file.read()
|
||||
import tempfile
|
||||
tmp_pdf_path: str | None = None
|
||||
try:
|
||||
# บันทึก PDF เป็น temp file เพื่อให้ prepare_ocr_messages อ่านได้ผ่าน path
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||
tmp.write(pdf_bytes)
|
||||
tmp_pdf_path = tmp.name
|
||||
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)
|
||||
return _process_pdf_doc(doc, selected_engine, max_pages, typhoon_options, pdf_path=tmp_pdf_path)
|
||||
finally:
|
||||
if tmp_pdf_path:
|
||||
Path(tmp_pdf_path).unlink(missing_ok=True)
|
||||
|
||||
class NormalizeRequest(BaseModel):
|
||||
text: str
|
||||
@@ -445,6 +311,71 @@ def normalize_text(req: NormalizeRequest):
|
||||
except Exception as e:
|
||||
logger.warning(f"Thai normalize failed, returning raw text: {e}")
|
||||
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__":
|
||||
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
|
||||
# Tesseract OCR Sidecar — รันบน Desk-5439 (AI Isolation Host) ตาม ADR-023A
|
||||
# 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-30: เพิ่ม OCR_LANG=tha+eng (Tesseract Thai + English)
|
||||
# - 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
|
||||
}
|
||||
+6
-4
@@ -1,16 +1,18 @@
|
||||
# OCR Sidecar Requirements (Tesseract-based)
|
||||
# OCR Sidecar Requirements (Typhoon OCR via Ollama)
|
||||
# Change Log:
|
||||
# - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า (ไม่ต้องการ AVX)
|
||||
# - 2026-05-30: ลบ paddlepaddle/paddleocr dependencies เนื่องจาก SIGILL บน CPU ที่ไม่รองรับ AVX
|
||||
# - 2026-05-30: เพิ่ม opencv-python สำหรับ image preprocessing (threshold, denoise) เพื่อเพิ่มความแม่นยำ OCR
|
||||
# - 2026-06-11: เพิ่ม typhoon-ocr สำหรับ prepare_ocr_messages (official prompt builder สำหรับ typhoon-ocr1.5-3b)
|
||||
# - 2026-06-11: ตัด pytesseract, opencv-python, numpy ออก — ไม่ใช้ Tesseract อีกต่อไป
|
||||
|
||||
numpy<2.0
|
||||
PyMuPDF==1.24.0
|
||||
pytesseract==0.3.13
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
python-multipart==0.0.9
|
||||
pythainlp==5.0.4
|
||||
httpx==0.27.0
|
||||
Pillow==10.0.0
|
||||
opencv-python==4.8.1.78
|
||||
FlagEmbedding>=1.2.0
|
||||
typhoon-ocr>=0.4.1
|
||||
|
||||
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user