// File: 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 import { Injectable, Logger, OnModuleInit, ServiceUnavailableException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { QdrantClient } from '@qdrant/js-client-rest'; const AI_COLLECTION_NAME = 'lcbp3_vectors'; const AI_VECTOR_SIZE = 768; export interface AiVectorSearchResult { pointId: string | number; score: number; payload: Record; } /** Gateway กลางสำหรับ Qdrant ที่บังคับ project_public_id ทุก search */ @Injectable() export class AiQdrantService implements OnModuleInit { private readonly logger = new Logger(AiQdrantService.name); private readonly client: QdrantClient; constructor(private readonly configService: ConfigService) { const url = this.configService.get('AI_QDRANT_URL') ?? this.configService.get('QDRANT_URL') ?? 'http://localhost:6333'; this.client = new QdrantClient({ url }); } /** เรียก ensureCollection() อัตโนมัติเมื่อโมดูลถูก bootstrap */ async onModuleInit(): Promise { try { await this.ensureCollection(); } catch (err) { this.logger.error( `AiQdrantService: collection init failed — ${err instanceof Error ? err.message : String(err)}` ); } } /** เตรียม collection และ tenant payload index สำหรับ project isolation */ async ensureCollection(): Promise { const collections = await this.client.getCollections(); const exists = collections.collections.some( (collection) => collection.name === AI_COLLECTION_NAME ); if (!exists) { await this.client.createCollection(AI_COLLECTION_NAME, { vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' }, }); 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}`); } } /** ค้นหา vector โดยบังคับ projectPublicId เป็น parameter แรกตาม ADR-023A */ async search( projectPublicId: string, vector: number[], topK = 5 ): Promise { if (!projectPublicId) { throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED'); } const results = await this.client.search(AI_COLLECTION_NAME, { vector, limit: topK, filter: { must: [{ key: 'project_public_id', match: { value: projectPublicId } }], }, with_payload: true, }); return results.map((result) => ({ pointId: result.id, score: result.score, payload: result.payload ?? {}, })); } /** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */ async searchByProject( vector: number[], projectPublicId: string, limit: number ): Promise { return this.search(projectPublicId, vector, limit); } /** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */ async deleteByDocumentPublicId(documentPublicId: string): Promise { await this.client.delete(AI_COLLECTION_NAME, { wait: true, filter: { must: [{ key: 'public_id', match: { value: documentPublicId } }], }, }); } /** Upsert vectors ไป Qdrant พร้อม project isolation (T021) */ async upsert( projectPublicId: string, points: Array<{ id: string; vector: number[]; payload: Record; }> ): Promise { if (!projectPublicId) { throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED'); } // เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation const pointsWithProject = points.map((point) => ({ ...point, payload: { ...point.payload, project_public_id: projectPublicId, }, })); await this.client.upsert(AI_COLLECTION_NAME, { wait: true, points: pointsWithProject, }); } /** ตรวจสอบสุขภาพและความเร็ว (Latency) ของ Qdrant */ async checkHealth(): Promise<{ status: 'HEALTHY' | 'DEGRADED' | 'DOWN'; latencyMs: number; collections?: string[]; error?: string; }> { const startTime = Date.now(); try { const collections = await Promise.race([ this.client.getCollections(), new Promise((_, reject) => setTimeout(() => reject(new Error('Qdrant request timeout')), 5000) ), ]); const latencyMs = Date.now() - startTime; return { status: 'HEALTHY', latencyMs, collections: collections.collections.map((c) => c.name), }; } catch (err: unknown) { const latencyMs = Date.now() - startTime; const error = err instanceof Error ? err.message : String(err); const isTimeout = err instanceof Error && error.includes('timeout'); return { status: isTimeout ? 'DEGRADED' : 'DOWN', latencyMs, error, }; } } }