Files
lcbp3/backend/src/modules/ai/ai-rag.service.ts
T
admin 6cb3ae10ee
CI / CD Pipeline / build (push) Failing after 5m36s
CI / CD Pipeline / deploy (push) Has been skipped
feat(ai): unify AI architecture, implement RAG and legacy migration
2026-05-15 11:10:44 +07:00

349 lines
13 KiB
TypeScript

// File: 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)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import axios from 'axios';
import { AiQdrantService } from './qdrant.service';
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
export interface AiRagCitation {
pointId: string | number;
score: number;
docType?: string;
docNumber?: string;
snippet?: string;
}
/** ผลลัพธ์สมบูรณ์ของ RAG job ที่เก็บใน Redis */
export interface AiRagJobResult {
requestPublicId: string;
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
answer?: string;
citations?: AiRagCitation[];
confidence?: number;
usedFallbackModel?: boolean;
errorMessage?: string;
completedAt?: string;
}
/** TTL สำหรับ Redis result key (5 นาที) */
const RAG_RESULT_TTL_SECONDS = 300;
/** TTL สำหรับ Redis active-job key ต่อ user (5 นาที) */
const RAG_ACTIVE_JOB_TTL_SECONDS = 300;
/** บริการหลักสำหรับประมวลผล RAG query ผ่าน Ollama และ Qdrant (ADR-023) */
@Injectable()
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;
constructor(
private readonly configService: ConfigService,
private readonly qdrantService: AiQdrantService,
@InjectRedis() private readonly redis: Redis
) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
'http://localhost:11434'
);
this.ollamaModel = this.configService.get<string>(
'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',
3000
);
}
// ─── Job State Management ────────────────────────────────────────────────────
/** กำหนด result key สำหรับ Redis */
private resultKey(requestPublicId: string): string {
return `ai:rag:result:${requestPublicId}`;
}
/** กำหนด active-job key ต่อ user สำหรับ FR-009 (1 active job per user) */
private activeJobKey(userPublicId: string): string {
return `ai:rag:active:${userPublicId}`;
}
/** กำหนด cancel-flag key สำหรับ T022 (AbortController) */
cancelKey(requestPublicId: string): string {
return `ai:rag:cancel:${requestPublicId}`;
}
/** ตรวจสอบว่า user มี active job อยู่ หรือไม่ (FR-009) */
async getActiveJob(userPublicId: string): Promise<string | null> {
return this.redis.get(this.activeJobKey(userPublicId));
}
/** ลงทะเบียน job ใหม่ให้ user เพื่อ enforce FR-009 */
async registerActiveJob(
userPublicId: string,
requestPublicId: string
): Promise<void> {
await this.redis.setex(
this.activeJobKey(userPublicId),
RAG_ACTIVE_JOB_TTL_SECONDS,
requestPublicId
);
await this.saveJobResult({
requestPublicId,
status: 'pending',
});
}
/** บันทึกผลลัพธ์ job ลง Redis */
async saveJobResult(result: AiRagJobResult): Promise<void> {
await this.redis.setex(
this.resultKey(result.requestPublicId),
RAG_RESULT_TTL_SECONDS,
JSON.stringify(result)
);
}
/** ดึงผลลัพธ์ job จาก Redis */
async getJobResult(requestPublicId: string): Promise<AiRagJobResult | null> {
const raw = await this.redis.get(this.resultKey(requestPublicId));
if (!raw) return null;
try {
return JSON.parse(raw) as AiRagJobResult;
} catch {
this.logger.warn(
`Corrupted RAG result in Redis — requestPublicId=${requestPublicId}`
);
return null;
}
}
/** ยกเลิก job โดยตั้ง cancel flag ใน Redis */
async cancelJob(requestPublicId: string): Promise<void> {
await this.redis.setex(
this.cancelKey(requestPublicId),
RAG_RESULT_TTL_SECONDS,
'1'
);
const current = await this.getJobResult(requestPublicId);
if (
current &&
(current.status === 'pending' || current.status === 'processing')
) {
await this.saveJobResult({ ...current, status: 'cancelled' });
}
}
/** ลบ active-job ของ user เมื่อ job เสร็จหรือถูกยกเลิก */
async clearActiveJob(userPublicId: string): Promise<void> {
await this.redis.del(this.activeJobKey(userPublicId));
}
// ─── Core Processing ─────────────────────────────────────────────────────────
/**
* ประมวลผล RAG query:
* 1. Embed คำถาม
* 2. ค้นหา Qdrant ด้วย project isolation (T020 — enforced in AiQdrantService.searchByProject)
* 3. Build prompt จาก context
* 4. Generate คำตอบผ่าน Ollama (รองรับ AbortSignal สำหรับ T022)
*/
async processQuery(
requestPublicId: string,
question: string,
projectPublicId: string,
userPublicId: string,
signal?: AbortSignal
): Promise<void> {
await this.saveJobResult({ requestPublicId, status: 'processing' });
try {
// ตรวจสอบว่าถูกยกเลิกก่อนเริ่มทำงาน
const cancelFlag = await this.redis.get(this.cancelKey(requestPublicId));
if (cancelFlag || signal?.aborted) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 1. สร้าง embedding สำหรับคำถาม
const queryVector = await this.embed(question, signal);
// ตรวจสอบ cancel อีกครั้งหลัง embed
if (
signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId)))
) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002)
const searchResults = await this.qdrantService.searchByProject(
queryVector,
projectPublicId,
10
);
// 3. สร้าง context จาก search results
const context = this.buildContext(searchResults);
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
if (
signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId)))
) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 4. Generate คำตอบผ่าน Ollama (ส่ง signal เพื่อรองรับ T022)
const { answer, usedFallback } = await this.generateAnswer(
this.sanitizeInput(question),
context,
signal
);
const citations: AiRagCitation[] = searchResults.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
),
}));
const confidence = searchResults.length > 0 ? searchResults[0].score : 0;
await this.saveJobResult({
requestPublicId,
status: 'completed',
answer,
citations,
confidence,
usedFallbackModel: usedFallback,
completedAt: new Date().toISOString(),
});
this.logger.log(
`RAG query completed — requestPublicId=${requestPublicId}, confidence=${confidence.toFixed(3)}`
);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error(
`RAG query failed — requestPublicId=${requestPublicId}: ${errMsg}`
);
await this.saveJobResult({
requestPublicId,
status: 'failed',
errorMessage: errMsg,
completedAt: new Date().toISOString(),
});
} finally {
await this.clearActiveJob(userPublicId);
}
}
// ─── 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) */
private async generateAnswer(
question: string,
context: string,
signal?: AbortSignal
): Promise<{ answer: string; usedFallback: boolean }> {
const prompt = this.buildPrompt(question, context);
try {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{ model: this.ollamaModel, prompt, stream: false },
{ timeout: this.timeoutMs, signal }
);
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')
) {
throw err;
}
this.logger.warn(
`Ollama generation failed — model=${this.ollamaModel}: ${err instanceof Error ? err.message : String(err)}`
);
return { answer: 'ไม่พบข้อมูลในเอกสารที่ระบุ', usedFallback: true };
}
}
/** สร้าง context string จาก search results ให้ไม่เกิน PROMPT_CONTEXT_LIMIT */
private buildContext(
results: Array<{ payload: Record<string, unknown> }>
): string {
let context = '';
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 header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
const snippet = `${header}\n${preview}\n\n`;
if ((context + snippet).length > this.promptContextLimit) break;
context += snippet;
}
return context.trim();
}
/** สร้าง prompt สำหรับ LLM ตาม RAG pattern ของโครงการ */
private buildPrompt(question: string, context: string): string {
return [
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
'',
'=== เอกสารอ้างอิง ===',
context,
'',
'=== คำถาม ===',
question,
].join('\n');
}
/** กรอง input เพื่อป้องกัน prompt injection */
private sanitizeInput(text: string): string {
return text
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
.replace(/ignore previous instructions/gi, '')
.replace(/system:/gi, '')
.slice(0, 500);
}
}