690618:1444 237 #02
CI / CD Pipeline / build (push) Successful in 7m5s
CI / CD Pipeline / deploy (push) Failing after 20m14s

This commit is contained in:
2026-06-18 14:44:46 +07:00
parent 037fbb65f5
commit 09e304de84
52 changed files with 4471 additions and 1038 deletions
@@ -28,6 +28,7 @@ import { AiPromptsService } from './ai-prompts.service';
import { AiPrompt } from './ai-prompts.entity';
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
import { UpdatePromptNoteDto } from './dto/update-prompt-note.dto';
import { ActivatePromptDto } from './dto/activate-prompt.dto';
import { AiPromptResponseDto } from './dto/ai-prompt-response.dto';
import { ContextConfigDto } from '../dto/context-config.dto';
import { plainToInstance } from 'class-transformer';
@@ -132,6 +133,7 @@ export class AiPromptsController {
async activatePromptVersion(
@Param('promptType') promptType: string,
@Param('versionNumber', ParseIntPipe) versionNumber: number,
@Body() dto: ActivatePromptDto,
@CurrentUser() user: User,
@Headers('idempotency-key') idempotencyKey: string
): Promise<{ data: AiPromptResponseDto }> {
@@ -139,7 +141,8 @@ export class AiPromptsController {
const activated = await this.promptsService.activate(
promptType,
versionNumber,
user.user_id
user.user_id,
dto.expectedVersion
);
return { data: this.mapToDto(activated) };
}
@@ -404,6 +404,21 @@ describe('AiPromptsService', () => {
NotFoundException
);
});
it('ควร throw ConflictException เมื่อ optimistic lock version mismatch (T046)', async () => {
const targetPrompt = {
id: 2,
publicId: 'prompt-uuid-target',
promptType: 'ocr_extraction',
versionNumber: 2,
version: 5, // Current version in DB
isActive: false,
};
mockQueryRunner.manager.findOne.mockResolvedValue(targetPrompt);
// Simulate version mismatch: expectedVersion=3 but current=5
await expect(service.activate('ocr_extraction', 2, 1, 3)).rejects.toThrow(
'Version mismatch: expected 3, but current is 5'
);
});
});
describe('delete', () => {
it('ควร throw error เมื่อลบ active version', async () => {
@@ -5,7 +5,12 @@
// - 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 } from '@nestjs/common';
import {
Injectable,
Logger,
ForbiddenException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { InjectRedis } from '@nestjs-modules/ioredis';
@@ -394,7 +399,10 @@ export class AiPromptsService {
dto: CreateAiPromptDto,
userId: number
): Promise<AiPrompt> {
if (promptType === 'ocr_extraction') {
// 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'
@@ -475,13 +483,16 @@ export class AiPromptsService {
* @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
userId: number,
expectedVersion?: number
): Promise<AiPrompt> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
@@ -494,6 +505,17 @@ export class AiPromptsService {
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' },
@@ -0,0 +1,18 @@
// File: backend/src/modules/ai/prompts/dto/activate-prompt.dto.ts
// Change Log
// - 2026-06-18: Created ActivatePromptDto for prompt activation with validation (Feature 238 code review fix)
import { Type } from 'class-transformer';
import { IsOptional, IsInt, Min } from 'class-validator';
/**
* Data Transfer Object สำหรับเปิดใช้งาน prompt version
* รองรับ expectedVersion เพื่อป้องกัน race condition ในการ activate
*/
export class ActivatePromptDto {
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'expectedVersion must be an integer' })
@Min(1, { message: 'expectedVersion must be at least 1' })
expectedVersion?: number;
}
@@ -20,6 +20,9 @@ export class AiPromptResponseDto {
@Expose()
versionNumber!: number;
@Expose()
version!: number;
@Expose()
template!: string;
@@ -8,6 +8,7 @@ import axios from 'axios';
import * as fs from 'fs';
import { SandboxOcrEngineService } from './sandbox-ocr-engine.service';
import { OcrService } from './ocr.service';
import { AiPromptsService } from '../prompts/ai-prompts.service';
jest.mock('axios');
jest.mock('fs');
@@ -20,6 +21,11 @@ const mockOcrService = {
detectAndExtract: jest.fn(),
};
/** AiPromptsService mock สำหรับ ocr_system prompt */
const mockAiPromptsService = {
getActive: jest.fn(),
};
/** ConfigService mock */
const mockConfigService = {
get: jest.fn(<T>(key: string, defaultValue?: T): T | undefined => {
@@ -41,6 +47,7 @@ describe('SandboxOcrEngineService', () => {
SandboxOcrEngineService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: OcrService, useValue: mockOcrService },
{ provide: AiPromptsService, useValue: mockAiPromptsService },
],
}).compile();
service = module.get<SandboxOcrEngineService>(SandboxOcrEngineService);
@@ -6,12 +6,14 @@
// - 2026-06-04: ADR-034 — เพิ่ม 'typhoon-np-dms-ocr' เป็น canonical SandboxOcrEngineType; legacy aliases ยังรองรับ
// - 2026-06-04: เพิ่ม OcrTyphoonOptions interface; รับ temperature/topP/repeatPenalty จาก frontend sandbox เพื่อ override Modelfile defaults
// - 2026-06-13: ADR-036 — เปลี่ยน canonical SandboxOcrEngineType เป็น np-dms-ocr และคง legacy alias
// - 2026-06-17: เพิ่ม AiPromptsService injection และส่ง systemPrompt form field จาก active ocr_system prompt (T028)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as fs from 'fs';
import { OcrService } from './ocr.service';
import { AiPromptsService } from '../prompts/ai-prompts.service';
export type SandboxOcrEngineType =
| 'auto'
@@ -47,7 +49,8 @@ export class SandboxOcrEngineService {
private readonly ocrSidecarApiKey: string;
constructor(
private readonly configService: ConfigService,
private readonly ocrService: OcrService
private readonly ocrService: OcrService,
private readonly aiPromptsService: AiPromptsService
) {
this.ocrApiUrl = this.configService.get<string>(
'OCR_API_URL',
@@ -116,6 +119,21 @@ export class SandboxOcrEngineService {
if (typhoonOptions?.repeatPenalty !== undefined) {
form.append('repeatPenalty', String(typhoonOptions.repeatPenalty));
}
// ดึง active ocr_system prompt และส่งไป sidecar
try {
const activeOcrSystemPrompt =
await this.aiPromptsService.getActive('ocr_system');
if (activeOcrSystemPrompt && activeOcrSystemPrompt.template) {
form.append('systemPrompt', activeOcrSystemPrompt.template);
this.logger.log(
`Injected active ocr_system prompt (version ${activeOcrSystemPrompt.versionNumber})`
);
}
} catch (promptErr: unknown) {
this.logger.warn(
`Failed to retrieve active ocr_system prompt, proceeding without: ${promptErr instanceof Error ? promptErr.message : String(promptErr)}`
);
}
this.logger.log(
`Sending to sidecar — engine=${engineType} options=${JSON.stringify(typhoonOptions ?? {})}`
);