807 lines
30 KiB
TypeScript
807 lines
30 KiB
TypeScript
// File: backend/src/modules/ai/prompts/ai-prompts.service.ts
|
|
// Change Log
|
|
// - 2026-05-25: Created AiPromptsService for dynamic prompt management (ADR-029)
|
|
// - 2026-05-25: Fixed BusinessException and NotFoundException constructor signatures
|
|
// - 2026-05-25: Cast getRawOne() to resolve TypeScript type assertion error in ESLint
|
|
// - 2026-06-15: Added optimistic locking error handling for @VersionColumn (T067)
|
|
|
|
import {
|
|
Injectable,
|
|
Logger,
|
|
ForbiddenException,
|
|
ConflictException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, DataSource } from 'typeorm';
|
|
import { InjectRedis } from '@nestjs-modules/ioredis';
|
|
import Redis from 'ioredis';
|
|
import { randomUUID } from 'crypto';
|
|
import { AiPrompt } from './ai-prompts.entity';
|
|
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
|
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
|
import { ContextConfigDto } from '../dto/context-config.dto';
|
|
import {
|
|
BusinessException,
|
|
ValidationException,
|
|
NotFoundException,
|
|
} from '../../../common/exceptions';
|
|
import { readPromptContextScope } from './prompt-context-scope.util';
|
|
|
|
/**
|
|
* Service สำหรับจัดการ Prompt Versioning และการดึงข้อมูล Prompt ล่าสุดที่พร้อมใช้งาน
|
|
*/
|
|
@Injectable()
|
|
export class AiPromptsService {
|
|
private readonly logger = new Logger(AiPromptsService.name);
|
|
private readonly cachePrefix = 'ai:prompt:active:';
|
|
|
|
constructor(
|
|
@InjectRepository(AiPrompt)
|
|
private readonly aiPromptRepo: Repository<AiPrompt>,
|
|
@InjectRepository(AuditLog)
|
|
private readonly auditLogRepo: Repository<AuditLog>,
|
|
@InjectRedis()
|
|
private readonly redis: Redis,
|
|
private readonly dataSource: DataSource
|
|
) {}
|
|
|
|
/**
|
|
* ค้นหาและเตรียมข้อมูล Master Data อ้างอิงโครงการ (Context Resolution)
|
|
* ดึงโครงการ, องค์กร, สาขางาน, ประเภทเอกสาร, และแท็ก
|
|
* พร้อมทั้งทำหน้าที่เป็น Gatekeeper ป้องกันความสอดคล้องความปลอดภัย (ADR-030)
|
|
* @param activePrompt Prompt version ที่ใช้งานอยู่
|
|
* @param overrideProjectPublicId UUID โครงการสำหรับ override (optional)
|
|
* @param overrideContractPublicId UUID สัญญาสำหรับ override (optional)
|
|
* @returns Master data context ที่กรองแล้ว
|
|
*/
|
|
async resolveContext(
|
|
activePrompt: AiPrompt,
|
|
overrideProjectPublicId?: string,
|
|
overrideContractPublicId?: string
|
|
): Promise<Record<string, unknown>> {
|
|
const scope = readPromptContextScope(activePrompt.contextConfig);
|
|
let targetProjectId: number | null = null;
|
|
if (scope.projectPublicId) {
|
|
const foundProject = await this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select('p.id', 'id')
|
|
.from('projects', 'p')
|
|
.where('p.uuid = :uuid', { uuid: scope.projectPublicId })
|
|
.andWhere('p.deleted_at IS NULL')
|
|
.getRawOne<{ id: number }>();
|
|
if (!foundProject) {
|
|
throw new NotFoundException('Project', scope.projectPublicId);
|
|
}
|
|
targetProjectId = Number(foundProject.id);
|
|
}
|
|
|
|
let targetContractId: number | null = null;
|
|
let targetContractProjectId: number | null = null;
|
|
if (scope.contractPublicId) {
|
|
const foundContract = await this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select(['c.id as id', 'c.project_id as projectId'])
|
|
.from('contracts', 'c')
|
|
.where('c.uuid = :uuid', { uuid: scope.contractPublicId })
|
|
.getRawOne<{ id: number; projectId: number }>();
|
|
if (!foundContract) {
|
|
throw new NotFoundException('Contract', scope.contractPublicId);
|
|
}
|
|
targetContractId = Number(foundContract.id);
|
|
targetContractProjectId = Number(foundContract.projectId);
|
|
if (
|
|
targetProjectId !== null &&
|
|
targetContractProjectId !== targetProjectId
|
|
) {
|
|
throw new ForbiddenException(
|
|
`Cross-project boundary violation: Contract belongs to project ID ${targetContractProjectId} but template is restricted to project ID ${targetProjectId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 1. Logic ตรวจสอบ Override และทำหน้าที่ Gatekeeper ป้องกัน Cross-project data leak
|
|
if (overrideProjectPublicId) {
|
|
// ค้นหาโครงการเป้าหมายตาม UUID สาธารณะ
|
|
const foundProject = await this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select('p.id', 'id')
|
|
.from('projects', 'p')
|
|
.where('p.uuid = :uuid', { uuid: overrideProjectPublicId })
|
|
.andWhere('p.deleted_at IS NULL')
|
|
.getRawOne<{ id: number }>();
|
|
|
|
if (!foundProject) {
|
|
throw new NotFoundException('Project', overrideProjectPublicId);
|
|
}
|
|
|
|
const overrideProjectId = Number(foundProject.id);
|
|
|
|
// ตรวจสอบความสอดคล้องระดับโครงการ (Gatekeeper Rule)
|
|
if (targetProjectId !== null && targetProjectId !== overrideProjectId) {
|
|
throw new ForbiddenException(
|
|
`Cross-project boundary violation: Template is restricted to project ID ${targetProjectId} but requested override is ${overrideProjectId}`
|
|
);
|
|
}
|
|
|
|
// หากผ่านการคัดกรอง หรือเป็น Global template ให้ใช้ค่า override project ID นี้
|
|
targetProjectId = overrideProjectId;
|
|
}
|
|
|
|
let overrideContractProjectId: number | null = targetContractProjectId;
|
|
let overrideContractId: number | null = null;
|
|
if (overrideContractPublicId) {
|
|
const foundContract = await this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select(['c.id as id', 'c.project_id as projectId'])
|
|
.from('contracts', 'c')
|
|
.where('c.uuid = :uuid', { uuid: overrideContractPublicId })
|
|
.getRawOne<{ id: number; projectId: number }>();
|
|
|
|
if (!foundContract) {
|
|
throw new NotFoundException('Contract', overrideContractPublicId);
|
|
}
|
|
|
|
overrideContractId = Number(foundContract.id);
|
|
overrideContractProjectId = Number(foundContract.projectId);
|
|
|
|
if (
|
|
targetContractId !== null &&
|
|
targetContractId !== overrideContractId
|
|
) {
|
|
throw new ForbiddenException(
|
|
`Cross-contract boundary violation: Template is restricted to contract ID ${targetContractId} but requested override is ${overrideContractId}`
|
|
);
|
|
}
|
|
|
|
if (
|
|
targetProjectId !== null &&
|
|
overrideContractProjectId !== targetProjectId
|
|
) {
|
|
throw new ForbiddenException(
|
|
`Cross-project boundary violation: Contract belongs to project ID ${overrideContractProjectId} but requested project is ${targetProjectId}`
|
|
);
|
|
}
|
|
}
|
|
const targetContractIdResolved =
|
|
overrideContractId !== null ? overrideContractId : targetContractId;
|
|
if (targetProjectId === null && overrideContractProjectId !== null) {
|
|
targetProjectId = overrideContractProjectId;
|
|
}
|
|
|
|
// 2. ดึง Master Data ภายใต้ Project/Contract scope ที่จำกัด
|
|
const projectsQuery = this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select([
|
|
'p.project_code as projectCode',
|
|
'p.uuid as uuid',
|
|
'p.project_name as projectName',
|
|
])
|
|
.from('projects', 'p')
|
|
.where('p.deleted_at IS NULL');
|
|
if (targetProjectId) {
|
|
projectsQuery.andWhere('p.id = :projectId', {
|
|
projectId: targetProjectId,
|
|
});
|
|
}
|
|
const projects = await projectsQuery.getRawMany<{
|
|
projectCode: string;
|
|
uuid: string;
|
|
projectName: string;
|
|
}>();
|
|
|
|
const orgsQuery = this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select([
|
|
'o.organization_code as organizationCode',
|
|
'o.uuid as uuid',
|
|
'o.organization_name as organizationName',
|
|
])
|
|
.from('organizations', 'o')
|
|
.where('o.deleted_at IS NULL');
|
|
if (targetProjectId) {
|
|
// ค้นหาองค์กรที่ผูกอยู่ในโครงการนั้นๆ
|
|
orgsQuery
|
|
.innerJoin('project_organizations', 'po', 'po.organization_id = o.id')
|
|
.andWhere('po.project_id = :projectId', { projectId: targetProjectId });
|
|
}
|
|
const organizations = await orgsQuery.getRawMany<{
|
|
organizationCode: string;
|
|
uuid: string;
|
|
organizationName: string;
|
|
}>();
|
|
|
|
const disciplinesQuery = this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select([
|
|
'd.discipline_code as disciplineCode',
|
|
'd.code_name_th as codeNameTh',
|
|
])
|
|
.from('disciplines', 'd')
|
|
.where('d.is_active = 1');
|
|
if (targetContractIdResolved) {
|
|
disciplinesQuery.andWhere('d.contract_id = :contractId', {
|
|
contractId: targetContractIdResolved,
|
|
});
|
|
} else if (targetProjectId) {
|
|
// ดึงจากสัญญาทั้งหมดที่อยู่ภายใต้โครงการเป้าหมาย
|
|
disciplinesQuery
|
|
.innerJoin('contracts', 'c', 'c.id = d.contract_id')
|
|
.andWhere('c.project_id = :projectId', { projectId: targetProjectId });
|
|
}
|
|
const disciplines = await disciplinesQuery.getRawMany<{
|
|
disciplineCode: string;
|
|
codeNameTh: string;
|
|
}>();
|
|
|
|
const correspondenceTypes = await this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select(['t.type_code as typeCode', 't.type_name as typeName'])
|
|
.from('correspondence_types', 't')
|
|
.where('t.is_active = 1')
|
|
.andWhere('t.deleted_at IS NULL')
|
|
.getRawMany<{ typeCode: string; typeName: string }>();
|
|
|
|
const tagsQuery = this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select(['tg.tag_name as tagName', 'tg.color_code as colorCode'])
|
|
.from('tags', 'tg')
|
|
.where('tg.deleted_at IS NULL');
|
|
if (targetProjectId) {
|
|
tagsQuery.andWhere(
|
|
'(tg.project_id = :projectId OR tg.project_id IS NULL)',
|
|
{ projectId: targetProjectId }
|
|
);
|
|
} else {
|
|
tagsQuery.andWhere('tg.project_id IS NULL');
|
|
}
|
|
const tags = await tagsQuery.getRawMany<{
|
|
tagName: string;
|
|
colorCode: string;
|
|
}>();
|
|
|
|
return {
|
|
availableProjects: projects.map((p) => ({
|
|
code: p.projectCode,
|
|
uuid: p.uuid,
|
|
name: p.projectName,
|
|
})),
|
|
availableOrganizations: organizations.map((o) => ({
|
|
code: o.organizationCode,
|
|
uuid: o.uuid,
|
|
name: o.organizationName,
|
|
})),
|
|
availableDisciplines: disciplines.map((d) => ({
|
|
code: d.disciplineCode,
|
|
name: d.codeNameTh,
|
|
})),
|
|
availableCorrespondenceTypes: correspondenceTypes.map((t) => ({
|
|
code: t.typeCode,
|
|
name: t.typeName,
|
|
})),
|
|
availableTags: tags.map((t) => ({
|
|
name: tgName(t.tagName),
|
|
color: t.colorCode,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* ดึงรายการเวอร์ชันทั้งหมดของ prompt_type ที่กำหนด
|
|
* @param promptType ประเภทของ prompt (เช่น 'ocr_extraction')
|
|
* @returns รายการ prompt versions เรียงตาม versionNumber ล่าสุดก่อน
|
|
*/
|
|
async findAll(promptType: string): Promise<AiPrompt[]> {
|
|
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
|
|
try {
|
|
const cached = await this.redis.get(cacheKey);
|
|
if (cached) {
|
|
return JSON.parse(cached) as AiPrompt[];
|
|
}
|
|
} catch (err: unknown) {
|
|
this.logger.warn(
|
|
`Redis unavailable, falling back to DB query: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
const prompts = await this.aiPromptRepo.find({
|
|
where: { promptType },
|
|
order: { versionNumber: 'DESC' },
|
|
});
|
|
try {
|
|
await this.redis.setex(cacheKey, 60, JSON.stringify(prompts));
|
|
} catch (err: unknown) {
|
|
this.logger.warn(
|
|
`Failed to set Redis cache: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
return prompts;
|
|
}
|
|
|
|
/**
|
|
* ดึง Active prompt จาก Redis cache หรือ DB fallback
|
|
* @param promptType ประเภทของ prompt
|
|
* @returns Prompt version ที่เปิดใช้งานอยู่ หรือ null หากไม่พบ
|
|
*/
|
|
async getActive(promptType: string): Promise<AiPrompt | null> {
|
|
const cacheKey = `${this.cachePrefix}${promptType}`;
|
|
try {
|
|
const cached = await this.redis.get(cacheKey);
|
|
if (cached) {
|
|
return JSON.parse(cached) as AiPrompt;
|
|
}
|
|
} catch (err: unknown) {
|
|
this.logger.warn(
|
|
`Redis unavailable, falling back to DB query: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
const prompt = await this.aiPromptRepo.findOne({
|
|
where: { promptType, isActive: true },
|
|
});
|
|
if (prompt) {
|
|
try {
|
|
await this.redis.setex(cacheKey, 60, JSON.stringify(prompt));
|
|
} catch (err: unknown) {
|
|
this.logger.warn(
|
|
`Failed to set Redis cache: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
return prompt;
|
|
}
|
|
|
|
/**
|
|
* ดึง Prompt version ตาม versionNumber ที่ระบุ
|
|
* @param promptType ประเภทของ prompt
|
|
* @param versionNumber เลข version ที่ต้องการ
|
|
* @returns Prompt version ที่ตรงกับ versionNumber หรือ null หากไม่พบ
|
|
*/
|
|
async findByVersion(
|
|
promptType: string,
|
|
versionNumber: number
|
|
): Promise<AiPrompt | null> {
|
|
return this.aiPromptRepo.findOne({
|
|
where: { promptType, versionNumber },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* ค้นหา prompt ที่มีผลใช้งานจริง และแทนที่ placeholder {{ocr_text}} ด้วยข้อความ OCR
|
|
* @param promptType ประเภทของ prompt
|
|
* @param ocrText ข้อความที่สกัดจาก OCR
|
|
* @returns Prompt ที่แทนที่ placeholder แล้ว พร้อม version number
|
|
* @throws BusinessException หากไม่พบ active prompt
|
|
*/
|
|
async resolveActive(
|
|
promptType: string,
|
|
ocrText: string
|
|
): Promise<{ resolvedPrompt: string; versionNumber: number }> {
|
|
const prompt = await this.getActive(promptType);
|
|
if (!prompt) {
|
|
throw new BusinessException(
|
|
'NO_ACTIVE_PROMPT',
|
|
`No active prompt found for type: ${promptType}`,
|
|
'ไม่พบ Prompt Version ที่เปิดใช้งานในระบบ'
|
|
);
|
|
}
|
|
const resolvedPrompt = prompt.template.replace('{{ocr_text}}', ocrText);
|
|
return { resolvedPrompt, versionNumber: prompt.versionNumber };
|
|
}
|
|
|
|
/**
|
|
* สร้าง Prompt Version ใหม่พร้อมการตรวจสอบ placeholder และ character limit
|
|
* @param promptType ประเภทของ prompt
|
|
* @param dto ข้อมูล template และ contextConfig
|
|
* @param userId ID ของผู้สร้าง
|
|
* @returns Prompt version ที่สร้างใหม่
|
|
* @throws ValidationException หาก template ไม่มี placeholder หรือเกิน character limit
|
|
*/
|
|
async create(
|
|
promptType: string,
|
|
dto: CreateAiPromptDto,
|
|
userId: number
|
|
): Promise<AiPrompt> {
|
|
// ocr_system: free-form system prompt, no required placeholders
|
|
if (promptType === 'ocr_system') {
|
|
// No validation required - system prompt is free-form
|
|
} else if (promptType === 'ocr_extraction') {
|
|
if (!dto.template.includes('{{ocr_text}}')) {
|
|
throw new ValidationException(
|
|
'template ต้องมี {{ocr_text}} placeholder'
|
|
);
|
|
}
|
|
} else if (promptType === 'rag_query_prompt') {
|
|
if (
|
|
!dto.template.includes('{{query}}') ||
|
|
!dto.template.includes('{{context}}')
|
|
) {
|
|
throw new ValidationException(
|
|
'template ต้องมี {{query}} และ {{context}} placeholder'
|
|
);
|
|
}
|
|
} else if (promptType === 'rag_prep_prompt') {
|
|
if (!dto.template.includes('{{text}}')) {
|
|
throw new ValidationException('template ต้องมี {{text}} placeholder');
|
|
}
|
|
} else if (promptType === 'classification_prompt') {
|
|
if (!dto.template.includes('{{document_text}}')) {
|
|
throw new ValidationException(
|
|
'template ต้องมี {{document_text}} placeholder'
|
|
);
|
|
}
|
|
}
|
|
if (dto.template.length > 4000) {
|
|
throw new ValidationException('Template exceeds 4,000 character limit');
|
|
}
|
|
const queryRunner = this.dataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
try {
|
|
const maxVersionResult = await queryRunner.manager
|
|
.createQueryBuilder(AiPrompt, 'prompt')
|
|
.select('MAX(prompt.versionNumber)', 'max')
|
|
.where('prompt.promptType = :promptType', { promptType })
|
|
.setLock('pessimistic_write')
|
|
.getRawOne<{ max: number | string | null }>();
|
|
const nextVersion =
|
|
(maxVersionResult?.max ? Number(maxVersionResult.max) : 0) + 1;
|
|
const newPrompt = this.aiPromptRepo.create({
|
|
publicId: randomUUID(),
|
|
promptType,
|
|
versionNumber: nextVersion,
|
|
template: dto.template,
|
|
fieldSchema: null,
|
|
contextConfig: dto.contextConfig || null,
|
|
isActive: false,
|
|
createdBy: userId,
|
|
});
|
|
const savedPrompt = await queryRunner.manager.save(newPrompt);
|
|
await queryRunner.commitTransaction();
|
|
try {
|
|
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
|
|
await this.redis.del(cacheKey);
|
|
} catch (err: unknown) {
|
|
this.logger.warn(
|
|
`Failed to clear Redis cache after create: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
await this.saveAuditLog(
|
|
'AI_PROMPT_CREATED',
|
|
String(savedPrompt.id),
|
|
{ promptType, versionNumber: nextVersion, userId },
|
|
userId
|
|
);
|
|
return savedPrompt;
|
|
} catch (err) {
|
|
await queryRunner.rollbackTransaction();
|
|
throw err;
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* เปิดใช้งานเวอร์ชันที่กำหนด และยกเลิกการใช้งานเวอร์ชันอื่นทั้งหมดภายใต้ prompt_type เดียวกัน
|
|
* @param promptType ประเภทของ prompt
|
|
* @param versionNumber เลขเวอร์ชันที่ต้องการเปิดใช้งาน
|
|
* @param userId ID ของผู้ดำเนินการ
|
|
* @param expectedVersion เวอร์ชันที่คาดหวังสำหรับ optimistic locking (optional)
|
|
* @returns Prompt version ที่เปิดใช้งานแล้ว
|
|
* @throws NotFoundException หากไม่พบ prompt version
|
|
* @throws ConflictException หาก version mismatch (optimistic locking)
|
|
*/
|
|
async activate(
|
|
promptType: string,
|
|
versionNumber: number,
|
|
userId: number,
|
|
expectedVersion?: number
|
|
): Promise<AiPrompt> {
|
|
const queryRunner = this.dataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
try {
|
|
const promptToActivate = await queryRunner.manager.findOne(AiPrompt, {
|
|
where: { promptType, versionNumber },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!promptToActivate) {
|
|
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
|
}
|
|
|
|
// Optimistic locking check
|
|
if (
|
|
expectedVersion !== undefined &&
|
|
promptToActivate.version !== expectedVersion
|
|
) {
|
|
throw new ConflictException(
|
|
`Version mismatch: expected ${expectedVersion}, but current is ${promptToActivate.version}. Data was modified by another user.`
|
|
);
|
|
}
|
|
|
|
await queryRunner.manager.find(AiPrompt, {
|
|
where: { promptType, isActive: true },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
await queryRunner.manager.update(
|
|
AiPrompt,
|
|
{ promptType, isActive: true },
|
|
{ isActive: false }
|
|
);
|
|
promptToActivate.isActive = true;
|
|
promptToActivate.activatedAt = new Date();
|
|
const activatedPrompt = await queryRunner.manager.save(promptToActivate);
|
|
await queryRunner.commitTransaction();
|
|
try {
|
|
const cacheKey = `${this.cachePrefix}${promptType}`;
|
|
await this.redis.del(cacheKey);
|
|
const versionsCacheKey = `${this.cachePrefix}versions:${promptType}`;
|
|
await this.redis.del(versionsCacheKey);
|
|
} catch (err: unknown) {
|
|
this.logger.warn(
|
|
`Failed to clear Redis cache after activation: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
await this.saveAuditLog(
|
|
'AI_PROMPT_ACTIVATED',
|
|
String(activatedPrompt.id),
|
|
{ promptType, versionNumber, userId },
|
|
userId
|
|
);
|
|
return activatedPrompt;
|
|
} catch (err) {
|
|
await queryRunner.rollbackTransaction();
|
|
throw err;
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ลบเวอร์ชันที่ไม่ได้ใช้งาน (ห้ามลบเวอร์ชันที่เป็น active)
|
|
* @param promptType ประเภทของ prompt
|
|
* @param versionNumber เลขเวอร์ชันที่ต้องการลบ
|
|
* @param userId ID ของผู้ดำเนินการ
|
|
* @throws NotFoundException หากไม่พบ prompt version
|
|
* @throws BusinessException หากพยายามลบ active version
|
|
*/
|
|
async delete(
|
|
promptType: string,
|
|
versionNumber: number,
|
|
userId: number
|
|
): Promise<void> {
|
|
const prompt = await this.aiPromptRepo.findOne({
|
|
where: { promptType, versionNumber },
|
|
});
|
|
if (!prompt) {
|
|
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
|
}
|
|
if (prompt.isActive) {
|
|
throw new BusinessException(
|
|
'CANNOT_DELETE_ACTIVE_PROMPT',
|
|
'Cannot delete active prompt version',
|
|
'ไม่สามารถลบ active version ได้'
|
|
);
|
|
}
|
|
await this.aiPromptRepo.remove(prompt);
|
|
try {
|
|
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
|
|
await this.redis.del(cacheKey);
|
|
} catch (err: unknown) {
|
|
this.logger.warn(
|
|
`Failed to clear Redis cache after delete: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
await this.saveAuditLog(
|
|
'AI_PROMPT_DELETED',
|
|
String(prompt.id),
|
|
{ promptType, versionNumber, userId },
|
|
userId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* อัปเดต manual note ของเวอร์ชันที่กำหนด
|
|
* @param promptType ประเภทของ prompt
|
|
* @param versionNumber เลขเวอร์ชัน
|
|
* @param note ข้อความ note หรือ null หากต้องการลบ
|
|
* @returns Prompt version ที่อัปเดตแล้ว
|
|
* @throws NotFoundException หากไม่พบ prompt version
|
|
* @throws BusinessException หากเกิด optimistic locking conflict
|
|
*/
|
|
async updateNote(
|
|
promptType: string,
|
|
versionNumber: number,
|
|
note: string | null
|
|
): Promise<AiPrompt> {
|
|
try {
|
|
const prompt = await this.aiPromptRepo.findOne({
|
|
where: { promptType, versionNumber },
|
|
});
|
|
if (!prompt) {
|
|
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
|
}
|
|
prompt.manualNote = note;
|
|
return this.aiPromptRepo.save(prompt);
|
|
} catch (err: unknown) {
|
|
if (err instanceof NotFoundException) {
|
|
throw err;
|
|
}
|
|
// Handle optimistic locking conflict
|
|
if (err instanceof Error && err.message.includes('optimistic')) {
|
|
throw new BusinessException(
|
|
'OPTIMISTIC_LOCK_CONFLICT',
|
|
'This prompt version was modified by another user. Please refresh and try again.',
|
|
'ข้อมูลถูกแก้ไขโดยผู้ใช้อื่น กรุณารีเฟรชแล้วลองใหม่'
|
|
);
|
|
}
|
|
this.logger.error(
|
|
`Failed to update prompt note: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
throw new BusinessException(
|
|
'UPDATE_NOTE_FAILED',
|
|
'Failed to update prompt note',
|
|
'ไม่สามารถอัปเดต note ได้ กรุณาลองใหม่'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* บันทึกผลทดสอบของเวอร์ชันหลังจากรัน OCR Sandbox
|
|
* @param promptType ประเภทของ prompt
|
|
* @param versionNumber เลขเวอร์ชัน
|
|
* @param resultJson ผลลัพธ์การทดสอบในรูป JSON
|
|
*/
|
|
async saveTestResult(
|
|
promptType: string,
|
|
versionNumber: number,
|
|
resultJson: Record<string, unknown>
|
|
): Promise<void> {
|
|
const prompt = await this.aiPromptRepo.findOne({
|
|
where: { promptType, versionNumber },
|
|
});
|
|
if (prompt) {
|
|
prompt.testResultJson = resultJson;
|
|
prompt.lastTestedAt = new Date();
|
|
await this.aiPromptRepo.save(prompt);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ดึง Context Config ของ Prompt Version ที่กำหนด
|
|
*/
|
|
async getContextConfig(
|
|
promptType: string,
|
|
versionNumber: number
|
|
): Promise<Record<string, unknown> | null> {
|
|
const prompt = await this.aiPromptRepo.findOne({
|
|
where: { promptType, versionNumber },
|
|
});
|
|
if (!prompt) {
|
|
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
|
}
|
|
return prompt.contextConfig;
|
|
}
|
|
|
|
/**
|
|
* อัปเดต Context Config ของ Prompt Version ที่กำหนด พร้อมทั้งตรวจเช็คความถูกต้องของโครงการและสัญญาใน DB
|
|
* @throws NotFoundException หากไม่พบ prompt version
|
|
* @throws BusinessException หากเกิด optimistic locking conflict
|
|
* @throws ValidationException หาก context config ไม่ถูกต้อง (T068)
|
|
*/
|
|
async updateContextConfig(
|
|
promptType: string,
|
|
versionNumber: number,
|
|
dto: ContextConfigDto
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const prompt = await this.aiPromptRepo.findOne({
|
|
where: { promptType, versionNumber },
|
|
});
|
|
if (!prompt) {
|
|
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
|
}
|
|
|
|
// Validation (T068): ตรวจสอบค่าของ context config
|
|
if (dto.pageSize < 1 || dto.pageSize > 1000) {
|
|
throw new ValidationException('pageSize must be between 1 and 1000');
|
|
}
|
|
if (!dto.language || dto.language.trim().length === 0) {
|
|
throw new ValidationException('language is required');
|
|
}
|
|
if (!dto.outputLanguage || dto.outputLanguage.trim().length === 0) {
|
|
throw new ValidationException('outputLanguage is required');
|
|
}
|
|
|
|
// Validation (T027): ตรวจสอบโครงการ/สัญญาใน DB
|
|
if (dto.filter?.projectId) {
|
|
const projectExists = (await this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select('p.id')
|
|
.from('projects', 'p')
|
|
.where('p.uuid = :uuid', { uuid: dto.filter.projectId })
|
|
.andWhere('p.deleted_at IS NULL')
|
|
.getRawOne()) as unknown;
|
|
if (!projectExists) {
|
|
throw new NotFoundException('Project', dto.filter.projectId);
|
|
}
|
|
}
|
|
|
|
if (dto.filter?.contractId) {
|
|
const contractExists = (await this.dataSource.manager
|
|
.createQueryBuilder()
|
|
.select('c.id')
|
|
.from('contracts', 'c')
|
|
.where('c.uuid = :uuid', { uuid: dto.filter.contractId })
|
|
.getRawOne()) as unknown;
|
|
if (!contractExists) {
|
|
throw new NotFoundException('Contract', dto.filter.contractId);
|
|
}
|
|
}
|
|
|
|
// บันทึกลง DB
|
|
const newContextConfig = {
|
|
filter: dto.filter || null,
|
|
pageSize: dto.pageSize,
|
|
language: dto.language,
|
|
outputLanguage: dto.outputLanguage,
|
|
};
|
|
prompt.contextConfig = newContextConfig;
|
|
await this.aiPromptRepo.save(prompt);
|
|
|
|
return newContextConfig;
|
|
} catch (err: unknown) {
|
|
if (
|
|
err instanceof NotFoundException ||
|
|
err instanceof ValidationException
|
|
) {
|
|
throw err;
|
|
}
|
|
// Handle optimistic locking conflict
|
|
if (err instanceof Error && err.message.includes('optimistic')) {
|
|
throw new BusinessException(
|
|
'OPTIMISTIC_LOCK_CONFLICT',
|
|
'This prompt version was modified by another user. Please refresh and try again.',
|
|
'ข้อมูลถูกแก้ไขโดยผู้ใช้อื่น กรุณารีเฟรชแล้วลองใหม่'
|
|
);
|
|
}
|
|
this.logger.error(
|
|
`Failed to update context config: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
throw new BusinessException(
|
|
'UPDATE_CONTEXT_CONFIG_FAILED',
|
|
'Failed to update context config',
|
|
'ไม่สามารถอัปเดต context config ได้ กรุณาลองใหม่'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs
|
|
*/
|
|
private async saveAuditLog(
|
|
action: string,
|
|
entityId: string,
|
|
detailsJson: Record<string, unknown>,
|
|
userId?: number
|
|
): Promise<void> {
|
|
try {
|
|
const auditLog = this.auditLogRepo.create({
|
|
action,
|
|
severity: 'INFO',
|
|
entityType: 'AiPrompt',
|
|
entityId,
|
|
detailsJson,
|
|
userId,
|
|
});
|
|
await this.auditLogRepo.save(auditLog);
|
|
} catch (err: unknown) {
|
|
this.logger.error(
|
|
`Failed to save audit log: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Helper function to sanitize tag name */
|
|
function tgName(name: unknown): string {
|
|
return typeof name === 'string' ? name.trim() : '';
|
|
}
|