From 82a0444013c48316cc20963967fb16f02edb6c80 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 25 May 2026 23:27:33 +0700 Subject: [PATCH] 690525:2327 ADR-023-229 dynamic prompt #01 --- .agents/tests/skill-integration.test.js | 413 +++++++++-------- .agents/tests/workflow-validation.test.js | 382 ++++++++-------- CONTEXT.md | 2 +- backend/src/modules/ai/ai.module.ts | 5 + .../ai/processors/ai-batch.processor.spec.ts | 9 + .../ai/processors/ai-batch.processor.ts | 80 ++-- .../ai/processors/ai-realtime.processor.ts | 4 +- .../ai/prompts/ai-prompts.controller.ts | 140 ++++++ .../modules/ai/prompts/ai-prompts.entity.ts | 57 +++ .../modules/ai/prompts/ai-prompts.module.ts | 21 + .../ai/prompts/ai-prompts.service.spec.ts | 216 +++++++++ .../modules/ai/prompts/ai-prompts.service.ts | 294 +++++++++++++ .../ai/prompts/dto/ai-prompt-response.dto.ts | 39 ++ .../ai/prompts/dto/create-ai-prompt.dto.ts | 16 + .../ai/prompts/dto/update-prompt-note.dto.ts | 15 + .../src/modules/ai/services/ollama.service.ts | 106 +++-- frontend/app/(admin)/admin/ai/page.tsx | 245 +---------- .../admin/ai/OcrSandboxPromptManager.tsx | 415 ++++++++++++++++++ .../admin/ai/PromptVersionHistory.tsx | 141 ++++++ frontend/eslint.config.mjs | 5 + frontend/hooks/use-ai-prompts.ts | 165 +++++++ frontend/lib/services/ai-prompts.service.ts | 66 +++ frontend/public/locales/en/common.json | 54 ++- frontend/public/locales/th/common.json | 54 ++- frontend/types/ai-prompts.ts | 29 ++ package.json | 3 +- pnpm-lock.yaml | 25 +- .../229-dynamic-prompt-management/tasks.md | 66 +-- .../validation-report.md | 171 ++++++++ 29 files changed, 2468 insertions(+), 770 deletions(-) create mode 100644 backend/src/modules/ai/prompts/ai-prompts.controller.ts create mode 100644 backend/src/modules/ai/prompts/ai-prompts.entity.ts create mode 100644 backend/src/modules/ai/prompts/ai-prompts.module.ts create mode 100644 backend/src/modules/ai/prompts/ai-prompts.service.spec.ts create mode 100644 backend/src/modules/ai/prompts/ai-prompts.service.ts create mode 100644 backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts create mode 100644 backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts create mode 100644 backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts create mode 100644 frontend/components/admin/ai/OcrSandboxPromptManager.tsx create mode 100644 frontend/components/admin/ai/PromptVersionHistory.tsx create mode 100644 frontend/hooks/use-ai-prompts.ts create mode 100644 frontend/lib/services/ai-prompts.service.ts create mode 100644 frontend/types/ai-prompts.ts create mode 100644 specs/200-fullstacks/229-dynamic-prompt-management/validation-report.md diff --git a/.agents/tests/skill-integration.test.js b/.agents/tests/skill-integration.test.js index 71c93e15..d8fb07e2 100644 --- a/.agents/tests/skill-integration.test.js +++ b/.agents/tests/skill-integration.test.js @@ -15,220 +15,249 @@ const WORKFLOWS_DIR = path.join(BASE_DIR, '.windsurf', 'workflows'); // Test utilities class SkillTestSuite { - constructor() { - this.results = { - passed: 0, - failed: 0, - errors: [] - }; + constructor() { + this.results = { + passed: 0, + failed: 0, + errors: [], + }; + } + + log(message, type = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + reset: '\x1b[0m', + }; + + const color = colors[type] || colors.info; + console.log(`${color}${message}${colors.reset}`); + } + + assert(condition, message) { + if (condition) { + this.log(` PASS: ${message}`, 'pass'); + this.results.passed++; + return true; + } else { + this.log(` FAIL: ${message}`, 'fail'); + this.results.failed++; + this.results.errors.push(message); + return false; + } + } + + testDirectoryExists(dirPath, description) { + const exists = fs.existsSync(dirPath); + this.assert(exists, `${description} exists at ${dirPath}`); + return exists; + } + + testFileExists(filePath, description) { + const exists = fs.existsSync(filePath); + this.assert(exists, `${description} exists at ${filePath}`); + return exists; + } + + testFileContent(filePath, pattern, description) { + if (!fs.existsSync(filePath)) { + this.assert(false, `${description} - file not found: ${filePath}`); + return false; } - log(message, type = 'info') { - const colors = { - info: '\x1b[36m', // Cyan - pass: '\x1b[32m', // Green - fail: '\x1b[31m', // Red - warn: '\x1b[33m', // Yellow - reset: '\x1b[0m' - }; - - const color = colors[type] || colors.info; - console.log(`${color}${message}${colors.reset}`); + try { + const content = fs.readFileSync(filePath, 'utf8'); + const matches = content.match(pattern); + this.assert(matches !== null, `${description} - pattern found in ${filePath}`); + return matches !== null; + } catch (error) { + this.assert(false, `${description} - error reading file: ${error.message}`); + return false; } + } - assert(condition, message) { - if (condition) { - this.log(` PASS: ${message}`, 'pass'); - this.results.passed++; - return true; - } else { - this.log(` FAIL: ${message}`, 'fail'); - this.results.failed++; - this.results.errors.push(message); - return false; - } - } - - testDirectoryExists(dirPath, description) { - const exists = fs.existsSync(dirPath); - this.assert(exists, `${description} exists at ${dirPath}`); - return exists; - } - - testFileExists(filePath, description) { - const exists = fs.existsSync(filePath); - this.assert(exists, `${description} exists at ${filePath}`); - return exists; - } - - testFileContent(filePath, pattern, description) { - if (!fs.existsSync(filePath)) { - this.assert(false, `${description} - file not found: ${filePath}`); - return false; - } - - try { - const content = fs.readFileSync(filePath, 'utf8'); - const matches = content.match(pattern); - this.assert(matches !== null, `${description} - pattern found in ${filePath}`); - return matches !== null; - } catch (error) { - this.assert(false, `${description} - error reading file: ${error.message}`); - return false; - } - } - - runScript(scriptPath, description) { - try { - const output = execSync(scriptPath, { encoding: 'utf8', cwd: BASE_DIR }); - this.log(` SCRIPT: ${description} executed successfully`, 'pass'); - return { success: true, output }; - } catch (error) { - this.log(` SCRIPT: ${description} failed - ${error.message}`, 'fail'); - this.results.failed++; - this.results.errors.push(`${description}: ${error.message}`); - return { success: false, error: error.message }; - } + runScript(scriptPath, description) { + try { + const output = execSync(scriptPath, { encoding: 'utf8', cwd: BASE_DIR }); + this.log(` SCRIPT: ${description} executed successfully`, 'pass'); + return { success: true, output }; + } catch (error) { + this.log(` SCRIPT: ${description} failed - ${error.message}`, 'fail'); + this.results.failed++; + this.results.errors.push(`${description}: ${error.message}`); + return { success: false, error: error.message }; } + } } // Test suite implementation const testSuite = new SkillTestSuite(); function runAllTests() { - testSuite.log('=== .agents Integration Test Suite ===', 'info'); - testSuite.log(`Base directory: ${BASE_DIR}`, 'info'); - testSuite.log(`Started: ${new Date().toISOString()}`, 'info'); - testSuite.log(''); + testSuite.log('=== .agents Integration Test Suite ===', 'info'); + testSuite.log(`Base directory: ${BASE_DIR}`, 'info'); + testSuite.log(`Started: ${new Date().toISOString()}`, 'info'); + testSuite.log(''); - // Test 1: Directory Structure - testSuite.log('Test 1: Directory Structure', 'info'); - testSuite.testDirectoryExists(AGENTS_DIR, '.agents directory'); - testSuite.testDirectoryExists(SKILLS_DIR, 'skills directory'); - testSuite.testDirectoryExists(WORKFLOWS_DIR, 'workflows directory'); - testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'scripts'), 'scripts directory'); - testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'rules'), 'rules directory'); - testSuite.log(''); + // Test 1: Directory Structure + testSuite.log('Test 1: Directory Structure', 'info'); + testSuite.testDirectoryExists(AGENTS_DIR, '.agents directory'); + testSuite.testDirectoryExists(SKILLS_DIR, 'skills directory'); + testSuite.testDirectoryExists(WORKFLOWS_DIR, 'workflows directory'); + testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'scripts'), 'scripts directory'); + testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'rules'), 'rules directory'); + testSuite.log(''); - // Test 2: Core Files - testSuite.log('Test 2: Core Files', 'info'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'README.md'), 'README.md'); - testSuite.testFileExists(path.join(SKILLS_DIR, 'VERSION'), 'skills VERSION file'); - testSuite.testFileExists(path.join(SKILLS_DIR, 'skills.md'), 'skills.md documentation'); - testSuite.log(''); + // Test 2: Core Files + testSuite.log('Test 2: Core Files', 'info'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'README.md'), 'README.md'); + testSuite.testFileExists(path.join(SKILLS_DIR, 'VERSION'), 'skills VERSION file'); + testSuite.testFileExists(path.join(SKILLS_DIR, 'skills.md'), 'skills.md documentation'); + testSuite.log(''); - // Test 3: Script Files - testSuite.log('Test 3: Validation Scripts', 'info'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'), 'bash validate-versions.sh'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'audit-skills.sh'), 'bash audit-skills.sh'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'sync-workflows.sh'), 'bash sync-workflows.sh'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'validate-versions.ps1'), 'powershell validate-versions.ps1'); - testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'audit-skills.ps1'), 'powershell audit-skills.ps1'); - testSuite.log(''); + // Test 3: Script Files + testSuite.log('Test 3: Validation Scripts', 'info'); + testSuite.testFileExists( + path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'), + 'bash validate-versions.sh' + ); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'audit-skills.sh'), 'bash audit-skills.sh'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'sync-workflows.sh'), 'bash sync-workflows.sh'); + testSuite.testFileExists( + path.join(AGENTS_DIR, 'scripts', 'powershell', 'validate-versions.ps1'), + 'powershell validate-versions.ps1' + ); + testSuite.testFileExists( + path.join(AGENTS_DIR, 'scripts', 'powershell', 'audit-skills.ps1'), + 'powershell audit-skills.ps1' + ); + testSuite.log(''); - // Test 4: Version Consistency - testSuite.log('Test 4: Version Consistency', 'info'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /v1\.8\.6/, 'README.md version'); - testSuite.testFileContent(path.join(SKILLS_DIR, 'VERSION'), /version: 1\.8\.6/, 'skills VERSION file'); - testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /v1\.8\.6/, 'skills.md version'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'rules', '00-project-context.md'), /v1\.8\.6/, 'project context version'); - testSuite.log(''); + // Test 4: Version Consistency + testSuite.log('Test 4: Version Consistency', 'info'); + testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /v1\.8\.6/, 'README.md version'); + testSuite.testFileContent(path.join(SKILLS_DIR, 'VERSION'), /version: 1\.8\.6/, 'skills VERSION file'); + testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /v1\.8\.6/, 'skills.md version'); + testSuite.testFileContent( + path.join(AGENTS_DIR, 'rules', '00-project-context.md'), + /v1\.8\.6/, + 'project context version' + ); + testSuite.log(''); - // Test 5: Skills Structure - testSuite.log('Test 5: Skills Structure', 'info'); - const skillDirs = fs.readdirSync(SKILLS_DIR).filter(item => { - const itemPath = path.join(SKILLS_DIR, item); - return fs.statSync(itemPath).isDirectory() && item.startsWith('speckit-') || item === 'nestjs-best-practices' || item === 'next-best-practices'; - }); + // Test 5: Skills Structure + testSuite.log('Test 5: Skills Structure', 'info'); + const skillDirs = fs.readdirSync(SKILLS_DIR).filter((item) => { + const itemPath = path.join(SKILLS_DIR, item); + return ( + (fs.statSync(itemPath).isDirectory() && item.startsWith('speckit-')) || + item === 'nestjs-best-practices' || + item === 'next-best-practices' + ); + }); - testSuite.assert(skillDirs.length >= 20, `Found at least 20 skill directories (found ${skillDirs.length})`); - - // Test a few key skills - const keySkills = ['speckit-plan', 'speckit-implement', 'speckit-specify', 'speckit-validate']; - keySkills.forEach(skill => { - const skillPath = path.join(SKILLS_DIR, skill); - const skillMdPath = path.join(skillPath, 'SKILL.md'); - testSuite.testDirectoryExists(skillPath, `${skill} directory`); - testSuite.testFileExists(skillMdPath, `${skill} SKILL.md`); - - if (fs.existsSync(skillMdPath)) { - testSuite.testFileContent(skillMdPath, /^name:/, `${skill} has name field`); - testSuite.testFileContent(skillMdPath, /^description:/, `${skill} has description field`); - testSuite.testFileContent(skillMdPath, /^version:/, `${skill} has version field`); - testSuite.testFileContent(skillMdPath, /^## Role$/, `${skill} has Role section`); - testSuite.testFileContent(skillMdPath, /^## Task$/, `${skill} has Task section`); - } - }); - testSuite.log(''); + testSuite.assert(skillDirs.length >= 20, `Found at least 20 skill directories (found ${skillDirs.length})`); - // Test 6: Workflows Structure - testSuite.log('Test 6: Workflows Structure', 'info'); - const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter(item => item.endsWith('.md')); - testSuite.assert(workflowFiles.length >= 20, `Found at least 20 workflow files (found ${workflowFiles.length})`); - - // Test key workflows - const keyWorkflows = ['00-speckit.all.md', '02-speckit.specify.md', '04-speckit.plan.md', '07-speckit.implement.md']; - keyWorkflows.forEach(workflow => { - const workflowPath = path.join(WORKFLOWS_DIR, workflow); - testSuite.testFileExists(workflowPath, `${workflow} file`); - }); - testSuite.log(''); + // Test a few key skills + const keySkills = ['speckit-plan', 'speckit-implement', 'speckit-specify', 'speckit-validate']; + keySkills.forEach((skill) => { + const skillPath = path.join(SKILLS_DIR, skill); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + testSuite.testDirectoryExists(skillPath, `${skill} directory`); + testSuite.testFileExists(skillMdPath, `${skill} SKILL.md`); - // Test 7: Rules Structure - testSuite.log('Test 7: Rules Structure', 'info'); - const rulesDir = path.join(AGENTS_DIR, 'rules'); - const ruleFiles = fs.readdirSync(rulesDir).filter(item => item.endsWith('.md')); - testSuite.assert(ruleFiles.length >= 10, `Found at least 10 rule files (found ${ruleFiles.length})`); - - // Test key rules - const keyRules = ['00-project-context.md', '01-adr-019-uuid.md', '02-security.md']; - keyRules.forEach(rule => { - const rulePath = path.join(rulesDir, rule); - testSuite.testFileExists(rulePath, `${rule} file`); - }); - testSuite.log(''); + if (fs.existsSync(skillMdPath)) { + testSuite.testFileContent(skillMdPath, /^name:/, `${skill} has name field`); + testSuite.testFileContent(skillMdPath, /^description:/, `${skill} has description field`); + testSuite.testFileContent(skillMdPath, /^version:/, `${skill} has version field`); + testSuite.testFileContent(skillMdPath, /^## Role$/, `${skill} has Role section`); + testSuite.testFileContent(skillMdPath, /^## Task$/, `${skill} has Task section`); + } + }); + testSuite.log(''); - // Test 8: Script Execution (if on Unix-like system) - if (process.platform !== 'win32') { - testSuite.log('Test 8: Script Execution', 'info'); - - // Test version validation script - const versionScript = path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'); - if (fs.existsSync(versionScript)) { - try { - // Make executable - fs.chmodSync(versionScript, '755'); - testSuite.runScript(versionScript, 'Version validation script'); - } catch (error) { - testSuite.log(` SKIP: Cannot execute version script - ${error.message}`, 'warn'); - } - } - - testSuite.log(''); + // Test 6: Workflows Structure + testSuite.log('Test 6: Workflows Structure', 'info'); + const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter((item) => item.endsWith('.md')); + testSuite.assert(workflowFiles.length >= 20, `Found at least 20 workflow files (found ${workflowFiles.length})`); + + // Test key workflows + const keyWorkflows = ['00-speckit.all.md', '02-speckit.specify.md', '04-speckit.plan.md', '07-speckit.implement.md']; + keyWorkflows.forEach((workflow) => { + const workflowPath = path.join(WORKFLOWS_DIR, workflow); + testSuite.testFileExists(workflowPath, `${workflow} file`); + }); + testSuite.log(''); + + // Test 7: Rules Structure + testSuite.log('Test 7: Rules Structure', 'info'); + const rulesDir = path.join(AGENTS_DIR, 'rules'); + const ruleFiles = fs.readdirSync(rulesDir).filter((item) => item.endsWith('.md')); + testSuite.assert(ruleFiles.length >= 10, `Found at least 10 rule files (found ${ruleFiles.length})`); + + // Test key rules + const keyRules = ['00-project-context.md', '01-adr-019-uuid.md', '02-security.md']; + keyRules.forEach((rule) => { + const rulePath = path.join(rulesDir, rule); + testSuite.testFileExists(rulePath, `${rule} file`); + }); + testSuite.log(''); + + // Test 8: Script Execution (if on Unix-like system) + if (process.platform !== 'win32') { + testSuite.log('Test 8: Script Execution', 'info'); + + // Test version validation script + const versionScript = path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'); + if (fs.existsSync(versionScript)) { + try { + // Make executable + fs.chmodSync(versionScript, '755'); + testSuite.runScript(versionScript, 'Version validation script'); + } catch (error) { + testSuite.log(` SKIP: Cannot execute version script - ${error.message}`, 'warn'); + } } - // Test 9: Documentation Quality - testSuite.log('Test 9: Documentation Quality', 'info'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /## Troubleshooting/, 'README.md has troubleshooting section'); - testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /## Skill Dependency Matrix/, 'skills.md has dependency matrix'); - testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /## Architecture/, 'README.md has architecture section'); testSuite.log(''); + } - // Results Summary - testSuite.log('=== Test Results Summary ===', 'info'); - testSuite.log(`Passed: ${testSuite.results.passed}`, 'pass'); - testSuite.log(`Failed: ${testSuite.results.failed}`, testSuite.results.failed > 0 ? 'fail' : 'pass'); - - if (testSuite.results.errors.length > 0) { - testSuite.log('Errors:', 'fail'); - testSuite.results.errors.forEach(error => { - testSuite.log(` - ${error}`, 'fail'); - }); - } - - testSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); - - return testSuite.results.failed === 0; + // Test 9: Documentation Quality + testSuite.log('Test 9: Documentation Quality', 'info'); + testSuite.testFileContent( + path.join(AGENTS_DIR, 'README.md'), + /## Troubleshooting/, + 'README.md has troubleshooting section' + ); + testSuite.testFileContent( + path.join(SKILLS_DIR, 'skills.md'), + /## Skill Dependency Matrix/, + 'skills.md has dependency matrix' + ); + testSuite.testFileContent( + path.join(AGENTS_DIR, 'README.md'), + /## Architecture/, + 'README.md has architecture section' + ); + testSuite.log(''); + + // Results Summary + testSuite.log('=== Test Results Summary ===', 'info'); + testSuite.log(`Passed: ${testSuite.results.passed}`, 'pass'); + testSuite.log(`Failed: ${testSuite.results.failed}`, testSuite.results.failed > 0 ? 'fail' : 'pass'); + + if (testSuite.results.errors.length > 0) { + testSuite.log('Errors:', 'fail'); + testSuite.results.errors.forEach((error) => { + testSuite.log(` - ${error}`, 'fail'); + }); + } + + testSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); + + return testSuite.results.failed === 0; } // Export for use in other modules @@ -236,6 +265,6 @@ module.exports = { SkillTestSuite, runAllTests }; // Run tests if called directly if (require.main === module) { - const success = runAllTests(); - process.exit(success ? 0 : 1); + const success = runAllTests(); + process.exit(success ? 0 : 1); } diff --git a/.agents/tests/workflow-validation.test.js b/.agents/tests/workflow-validation.test.js index 368911d5..f2ff09f6 100644 --- a/.agents/tests/workflow-validation.test.js +++ b/.agents/tests/workflow-validation.test.js @@ -13,216 +13,218 @@ const AGENTS_DIR = path.join(BASE_DIR, '.agents'); // Test utilities class WorkflowTestSuite { - constructor() { - this.results = { - passed: 0, - failed: 0, - errors: [] - }; + constructor() { + this.results = { + passed: 0, + failed: 0, + errors: [], + }; + } + + log(message, type = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + reset: '\x1b[0m', + }; + + const color = colors[type] || colors.info; + console.log(`${color}${message}${colors.reset}`); + } + + assert(condition, message) { + if (condition) { + this.log(` PASS: ${message}`, 'pass'); + this.results.passed++; + return true; + } else { + this.log(` FAIL: ${message}`, 'fail'); + this.results.failed++; + this.results.errors.push(message); + return false; + } + } + + testWorkflowFile(filePath, expectedName) { + if (!fs.existsSync(filePath)) { + this.assert(false, `Workflow file exists: ${expectedName}`); + return false; } - log(message, type = 'info') { - const colors = { - info: '\x1b[36m', // Cyan - pass: '\x1b[32m', // Green - fail: '\x1b[31m', // Red - warn: '\x1b[33m', // Yellow - reset: '\x1b[0m' - }; - - const color = colors[type] || colors.info; - console.log(`${color}${message}${colors.reset}`); + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Basic structure checks + this.assert(content.length > 0, `${expectedName} has content`); + this.assert(content.includes('#'), `${expectedName} has markdown headers`); + + // Check for workflow-specific patterns + if (expectedName.includes('speckit-')) { + this.assert(content.includes('speckit-'), `${expectedName} contains speckit reference`); + } + + // Check for proper markdown formatting + const lines = content.split('\n'); + const nonEmptyLines = lines.filter((line) => line.trim().length > 0); + this.assert(nonEmptyLines.length >= 5, `${expectedName} has sufficient content`); + + return true; + } catch (error) { + this.assert(false, `${expectedName} - error reading file: ${error.message}`); + return false; + } + } + + validateWorkflowDependency(workflowName, workflowContent) { + // Check if workflow references existing skills + const skillReferences = workflowContent.match(/@speckit-\w+/g) || []; + const skillsDir = path.join(AGENTS_DIR, 'skills'); + + for (const skillRef of skillReferences) { + const skillName = skillRef.replace('@', ''); + const skillPath = path.join(skillsDir, skillName); + + if (!fs.existsSync(skillPath)) { + this.assert(false, `${workflowName} references non-existent skill: ${skillRef}`); + return false; + } } - assert(condition, message) { - if (condition) { - this.log(` PASS: ${message}`, 'pass'); - this.results.passed++; - return true; - } else { - this.log(` FAIL: ${message}`, 'fail'); - this.results.failed++; - this.results.errors.push(message); - return false; - } - } - - testWorkflowFile(filePath, expectedName) { - if (!fs.existsSync(filePath)) { - this.assert(false, `Workflow file exists: ${expectedName}`); - return false; - } - - try { - const content = fs.readFileSync(filePath, 'utf8'); - - // Basic structure checks - this.assert(content.length > 0, `${expectedName} has content`); - this.assert(content.includes('#'), `${expectedName} has markdown headers`); - - // Check for workflow-specific patterns - if (expectedName.includes('speckit-')) { - this.assert(content.includes('speckit-'), `${expectedName} contains speckit reference`); - } - - // Check for proper markdown formatting - const lines = content.split('\n'); - const nonEmptyLines = lines.filter(line => line.trim().length > 0); - this.assert(nonEmptyLines.length >= 5, `${expectedName} has sufficient content`); - - return true; - } catch (error) { - this.assert(false, `${expectedName} - error reading file: ${error.message}`); - return false; - } - } - - validateWorkflowDependency(workflowName, workflowContent) { - // Check if workflow references existing skills - const skillReferences = workflowContent.match(/@speckit-\w+/g) || []; - const skillsDir = path.join(AGENTS_DIR, 'skills'); - - for (const skillRef of skillReferences) { - const skillName = skillRef.replace('@', ''); - const skillPath = path.join(skillsDir, skillName); - - if (!fs.existsSync(skillPath)) { - this.assert(false, `${workflowName} references non-existent skill: ${skillRef}`); - return false; - } - } - - return true; - } + return true; + } } // Expected workflows mapping const expectedWorkflows = { - '00-speckit.all.md': 'Full pipeline workflow', - '01-speckit.constitution.md': 'Constitution workflow', - '02-speckit.specify.md': 'Specification workflow', - '03-speckit.clarify.md': 'Clarification workflow', - '04-speckit.plan.md': 'Planning workflow', - '05-speckit.tasks.md': 'Task breakdown workflow', - '06-speckit.analyze.md': 'Analysis workflow', - '07-speckit.implement.md': 'Implementation workflow', - '08-speckit.checker.md': 'Static analysis workflow', - '09-speckit.tester.md': 'Testing workflow', - '10-speckit.reviewer.md': 'Code review workflow', - '11-speckit.validate.md': 'Validation workflow', - 'speckit.prepare.md': 'Preparation workflow', - 'schema-change.md': 'Schema change workflow', - 'create-backend-module.md': 'Backend module creation', - 'create-frontend-page.md': 'Frontend page creation', - 'deploy.md': 'Deployment workflow', - 'review.md': 'Code review workflow', - 'util-speckit.checklist.md': 'Checklist utility', - 'util-speckit.diff.md': 'Diff utility', - 'util-speckit.migrate.md': 'Migration utility', - 'util-speckit.quizme.md': 'Quiz utility', - 'util-speckit.status.md': 'Status utility', - 'util-speckit.taskstoissues.md': 'Task to issues utility' + '00-speckit.all.md': 'Full pipeline workflow', + '01-speckit.constitution.md': 'Constitution workflow', + '02-speckit.specify.md': 'Specification workflow', + '03-speckit.clarify.md': 'Clarification workflow', + '04-speckit.plan.md': 'Planning workflow', + '05-speckit.tasks.md': 'Task breakdown workflow', + '06-speckit.analyze.md': 'Analysis workflow', + '07-speckit.implement.md': 'Implementation workflow', + '08-speckit.checker.md': 'Static analysis workflow', + '09-speckit.tester.md': 'Testing workflow', + '10-speckit.reviewer.md': 'Code review workflow', + '11-speckit.validate.md': 'Validation workflow', + 'speckit.prepare.md': 'Preparation workflow', + 'schema-change.md': 'Schema change workflow', + 'create-backend-module.md': 'Backend module creation', + 'create-frontend-page.md': 'Frontend page creation', + 'deploy.md': 'Deployment workflow', + 'review.md': 'Code review workflow', + 'util-speckit.checklist.md': 'Checklist utility', + 'util-speckit.diff.md': 'Diff utility', + 'util-speckit.migrate.md': 'Migration utility', + 'util-speckit.quizme.md': 'Quiz utility', + 'util-speckit.status.md': 'Status utility', + 'util-speckit.taskstoissues.md': 'Task to issues utility', }; // Test suite implementation const workflowTestSuite = new WorkflowTestSuite(); function runWorkflowTests() { - workflowTestSuite.log('=== Workflow Validation Test Suite ===', 'info'); - workflowTestSuite.log(`Workflows directory: ${WORKFLOWS_DIR}`, 'info'); - workflowTestSuite.log(`Started: ${new Date().toISOString()}`, 'info'); - workflowTestSuite.log(''); + workflowTestSuite.log('=== Workflow Validation Test Suite ===', 'info'); + workflowTestSuite.log(`Workflows directory: ${WORKFLOWS_DIR}`, 'info'); + workflowTestSuite.log(`Started: ${new Date().toISOString()}`, 'info'); + workflowTestSuite.log(''); - // Test 1: Workflows directory exists - workflowTestSuite.log('Test 1: Directory Structure', 'info'); - workflowTestSuite.assert(fs.existsSync(WORKFLOWS_DIR), 'Workflows directory exists'); - workflowTestSuite.log(''); + // Test 1: Workflows directory exists + workflowTestSuite.log('Test 1: Directory Structure', 'info'); + workflowTestSuite.assert(fs.existsSync(WORKFLOWS_DIR), 'Workflows directory exists'); + workflowTestSuite.log(''); - // Test 2: Expected workflow files exist - workflowTestSuite.log('Test 2: Expected Workflow Files', 'info'); - let foundWorkflows = 0; - - for (const [filename, description] of Object.entries(expectedWorkflows)) { - const filePath = path.join(WORKFLOWS_DIR, filename); - workflowTestSuite.testWorkflowFile(filePath, description); - if (fs.existsSync(filePath)) { - foundWorkflows++; - } - } - - workflowTestSuite.assert(foundWorkflows >= 20, `Found at least 20 workflows (found ${foundWorkflows})`); - workflowTestSuite.log(''); + // Test 2: Expected workflow files exist + workflowTestSuite.log('Test 2: Expected Workflow Files', 'info'); + let foundWorkflows = 0; - // Test 3: Workflow content validation - workflowTestSuite.log('Test 3: Content Validation', 'info'); - - for (const [filename, description] of Object.entries(expectedWorkflows)) { - const filePath = path.join(WORKFLOWS_DIR, filename); - - if (fs.existsSync(filePath)) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - - // Check for proper workflow structure - workflowTestSuite.assert(content.includes('#'), `${filename} has markdown headers`); - workflowTestSuite.assert(content.length > 100, `${filename} has substantial content`); - - // Validate skill dependencies - workflowTestSuite.validateWorkflowDependency(filename, content); - - } catch (error) { - workflowTestSuite.assert(false, `${filename} - content validation error: ${error.message}`); - } - } + for (const [filename, description] of Object.entries(expectedWorkflows)) { + const filePath = path.join(WORKFLOWS_DIR, filename); + workflowTestSuite.testWorkflowFile(filePath, description); + if (fs.existsSync(filePath)) { + foundWorkflows++; } - workflowTestSuite.log(''); + } - // Test 4: Workflow naming consistency - workflowTestSuite.log('Test 4: Naming Consistency', 'info'); - const actualFiles = fs.readdirSync(WORKFLOWS_DIR).filter(file => file.endsWith('.md')); - - for (const actualFile of actualFiles) { - if (!expectedWorkflows[actualFile]) { - workflowTestSuite.log(` UNEXPECTED: ${actualFile} not in expected list`, 'warn'); - } - } - - for (const expectedFile of Object.keys(expectedWorkflows)) { - if (!actualFiles.includes(expectedFile)) { - workflowTestSuite.assert(false, `Missing expected workflow: ${expectedFile}`); - } - } - workflowTestSuite.log(''); + workflowTestSuite.assert(foundWorkflows >= 20, `Found at least 20 workflows (found ${foundWorkflows})`); + workflowTestSuite.log(''); - // Test 5: Cross-reference validation - workflowTestSuite.log('Test 5: Cross-Reference Validation', 'info'); - - // Check if README.md references workflows correctly - const readmePath = path.join(AGENTS_DIR, 'README.md'); - if (fs.existsSync(readmePath)) { - const readmeContent = fs.readFileSync(readmePath, 'utf8'); - workflowTestSuite.assert( - readmeContent.includes('.windsurf/workflows'), - 'README.md references correct workflows path' - ); - } - workflowTestSuite.log(''); + // Test 3: Workflow content validation + workflowTestSuite.log('Test 3: Content Validation', 'info'); - // Results Summary - workflowTestSuite.log('=== Workflow Test Results Summary ===', 'info'); - workflowTestSuite.log(`Passed: ${workflowTestSuite.results.passed}`, 'pass'); - workflowTestSuite.log(`Failed: ${workflowTestSuite.results.failed}`, workflowTestSuite.results.failed > 0 ? 'fail' : 'pass'); - - if (workflowTestSuite.results.errors.length > 0) { - workflowTestSuite.log('Errors:', 'fail'); - workflowTestSuite.results.errors.forEach(error => { - workflowTestSuite.log(` - ${error}`, 'fail'); - }); + for (const [filename, description] of Object.entries(expectedWorkflows)) { + const filePath = path.join(WORKFLOWS_DIR, filename); + + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Check for proper workflow structure + workflowTestSuite.assert(content.includes('#'), `${filename} has markdown headers`); + workflowTestSuite.assert(content.length > 100, `${filename} has substantial content`); + + // Validate skill dependencies + workflowTestSuite.validateWorkflowDependency(filename, content); + } catch (error) { + workflowTestSuite.assert(false, `${filename} - content validation error: ${error.message}`); + } } - - workflowTestSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); - - return workflowTestSuite.results.failed === 0; + } + workflowTestSuite.log(''); + + // Test 4: Workflow naming consistency + workflowTestSuite.log('Test 4: Naming Consistency', 'info'); + const actualFiles = fs.readdirSync(WORKFLOWS_DIR).filter((file) => file.endsWith('.md')); + + for (const actualFile of actualFiles) { + if (!expectedWorkflows[actualFile]) { + workflowTestSuite.log(` UNEXPECTED: ${actualFile} not in expected list`, 'warn'); + } + } + + for (const expectedFile of Object.keys(expectedWorkflows)) { + if (!actualFiles.includes(expectedFile)) { + workflowTestSuite.assert(false, `Missing expected workflow: ${expectedFile}`); + } + } + workflowTestSuite.log(''); + + // Test 5: Cross-reference validation + workflowTestSuite.log('Test 5: Cross-Reference Validation', 'info'); + + // Check if README.md references workflows correctly + const readmePath = path.join(AGENTS_DIR, 'README.md'); + if (fs.existsSync(readmePath)) { + const readmeContent = fs.readFileSync(readmePath, 'utf8'); + workflowTestSuite.assert( + readmeContent.includes('.windsurf/workflows'), + 'README.md references correct workflows path' + ); + } + workflowTestSuite.log(''); + + // Results Summary + workflowTestSuite.log('=== Workflow Test Results Summary ===', 'info'); + workflowTestSuite.log(`Passed: ${workflowTestSuite.results.passed}`, 'pass'); + workflowTestSuite.log( + `Failed: ${workflowTestSuite.results.failed}`, + workflowTestSuite.results.failed > 0 ? 'fail' : 'pass' + ); + + if (workflowTestSuite.results.errors.length > 0) { + workflowTestSuite.log('Errors:', 'fail'); + workflowTestSuite.results.errors.forEach((error) => { + workflowTestSuite.log(` - ${error}`, 'fail'); + }); + } + + workflowTestSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); + + return workflowTestSuite.results.failed === 0; } // Export for use in other modules @@ -230,6 +232,6 @@ module.exports = { WorkflowTestSuite, runWorkflowTests }; // Run tests if called directly if (require.main === module) { - const success = runWorkflowTests(); - process.exit(success ? 0 : 1); + const success = runWorkflowTests(); + process.exit(success ? 0 : 1); } diff --git a/CONTEXT.md b/CONTEXT.md index aeae44da..61451164 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -221,7 +221,7 @@ User Chat → Intent Router (ยังไม่มี) → Server-side Intent | **AI Tool Layer** | 🟡 ADR Accepted | ADR-025 Accepted — Tool Layer Architecture รอ implementation | | **Document Chat UI** | 🟡 ADR Accepted | ADR-026 Accepted — Side-panel Chat UI รอ implementation | | **AI Admin Console** | 🟡 ADR Accepted | ADR-027 Accepted — Dynamic Control Panel รอ implementation | -| **Dynamic Prompt Mgmt** | 🟡 Spec Ready | ADR-029 Active — speckit.prepare เสร็จแล้ว (spec/plan/tasks/contracts); รอ implementation | +| **Dynamic Prompt Mgmt** | ✅ พร้อม | ADR-029 Active — พัฒนาเสร็จสมบูรณ์ทั้ง Entity, API, Sandbox Runner, Cache และ UI Playgrounds | ## Flagged ambiguities diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 58401a1b..d318a79a 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -56,6 +56,8 @@ import { RbacGuard } from '../../common/guards/rbac.guard'; import { IntentClassifierModule } from './intent-classifier/intent-classifier.module'; import { AiToolModule } from './tool/ai-tool.module'; import { CleanupTempFilesWorker } from './workers/cleanup-temp-files.worker'; +import { AiPromptsModule } from './prompts/ai-prompts.module'; +import { AiPrompt } from './prompts/ai-prompts.entity'; import { QUEUE_AI_BATCH, QUEUE_AI_INGEST, @@ -81,6 +83,7 @@ import { CorrespondenceType, ImportTransaction, MigrationReviewQueue, + AiPrompt, ]), BullModule.registerQueue( @@ -130,6 +133,8 @@ import { IntentClassifierModule, // ADR-025: AI Tool Layer (Tool Registry + CASL-enforced Tool Services) AiToolModule, + // ADR-029: Dynamic Prompt Management for OCR Extraction + AiPromptsModule, ], controllers: [AiController], providers: [ diff --git a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts index 0877c32f..10806389 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts @@ -19,6 +19,7 @@ import { Project } from '../../project/entities/project.entity'; import { AiAuditLog } from '../entities/ai-audit-log.entity'; import { TagsService } from '../../tags/tags.service'; import { MigrationService } from '../../migration/migration.service'; +import { AiPromptsService } from '../prompts/ai-prompts.service'; describe('AiBatchProcessor', () => { let processor: AiBatchProcessor; @@ -90,6 +91,13 @@ describe('AiBatchProcessor', () => { createError: jest.fn().mockResolvedValue(undefined), enqueueRecord: jest.fn().mockResolvedValue(undefined), }; + const mockAiPromptsService = { + resolveActive: jest.fn().mockResolvedValue({ + resolvedPrompt: 'Resolved test prompt with OCR text', + versionNumber: 2, + }), + saveTestResult: jest.fn().mockResolvedValue(undefined), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -113,6 +121,7 @@ describe('AiBatchProcessor', () => { }, { provide: TagsService, useValue: mockTagsService }, { provide: MigrationService, useValue: mockMigrationService }, + { provide: AiPromptsService, useValue: mockAiPromptsService }, ], }).compile(); processor = module.get(AiBatchProcessor); diff --git a/backend/src/modules/ai/processors/ai-batch.processor.ts b/backend/src/modules/ai/processors/ai-batch.processor.ts index 899bff7d..0b8fcb1f 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.ts @@ -6,6 +6,7 @@ // - 2026-05-21: พัฒนาระบบประมวลผล sandbox-extract พร้อมเชื่อมต่อ OcrService, OllamaService และ Redis cache // - 2026-05-21: แก้ไข ESLint unused variable สำหรับ parseError ใน catch block // - 2026-05-22: แก้ไข type compilation error ใน processMigrateDocument และนำช่องว่างภายในฟังก์ชันออก +// - 2026-05-25: เชื่อมต่อ AiPromptsService และเปิดใช้งาน Dynamic Prompt สำหรับ OCR extraction ใน sandbox และ migration pipeline import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Logger } from '@nestjs/common'; @@ -25,11 +26,13 @@ import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity'; import { TagsService } from '../../tags/tags.service'; import { MigrationService } from '../../migration/migration.service'; import { MigrationErrorType } from '../../migration/entities/migration-error.entity'; +import { AiPromptsService } from '../prompts/ai-prompts.service'; interface MigrateDocumentMetadata extends Record { documentNumber?: string; subject?: string; category?: string; + discipline?: string; date?: string; confidence?: number; tags?: string[]; @@ -80,6 +83,7 @@ const parseMigrateDocumentMetadata = ( documentNumber: readString(source.documentNumber), subject: readString(source.subject), category: readString(source.category), + discipline: readString(source.discipline), date: readString(source.date), confidence: typeof source.confidence === 'number' ? source.confidence : undefined, @@ -107,6 +111,7 @@ export class AiBatchProcessor extends WorkerHost { private readonly ollamaService: OllamaService, private readonly tagsService: TagsService, private readonly migrationService: MigrationService, + private readonly aiPromptsService: AiPromptsService, @InjectRedis() private readonly redis: Redis ) { super(); @@ -252,28 +257,14 @@ export class AiBatchProcessor extends WorkerHost { ); try { const ocrResult = await this.ocrService.detectAndExtract({ pdfPath }); - const prompt = `You are an expert document extraction system. -Analyze the following OCR text extracted from a project document and extract the metadata fields. - -OCR TEXT: -${ocrResult.text} - -Extract these fields: -1. documentNumber: The official document number or code. If not found, return null. -2. subject: The main subject, title, or topic of the document. If not found, return null. -3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified. -4. date: The issue date in YYYY-MM-DD format. If not found, return null. -5. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction. - -Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example: -{ - "documentNumber": "LCBP3-CIV-001", - "subject": "Foundation Inspection Report", - "discipline": "Civil", - "date": "2026-05-20", - "confidence": 0.95 -}`; - const response = await this.ollamaService.generate(prompt); + const { resolvedPrompt, versionNumber } = + await this.aiPromptsService.resolveActive( + 'ocr_extraction', + ocrResult.text + ); + const response = await this.ollamaService.generate(resolvedPrompt, { + timeoutMs: 120000, + }); const cleanedResponse = response .replace(/```json/g, '') .replace(/```/g, '') @@ -289,6 +280,11 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co `Failed to parse LLM response as JSON: ${cleanedResponse}` ); } + await this.aiPromptsService.saveTestResult( + 'ocr_extraction', + versionNumber, + extractedMetadata + ); await this.redis.setex( `ai:rag:result:${idempotencyKey}`, 3600, @@ -296,6 +292,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co requestPublicId: idempotencyKey, status: 'completed', answer: JSON.stringify(extractedMetadata, null, 2), + promptVersionUsed: versionNumber, completedAt: new Date().toISOString(), }) ); @@ -357,33 +354,13 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co }); throw err; } - const prompt = `You are a professional document intelligence engine. -Analyze the following OCR text extracted from a legacy project document and extract the metadata fields. -OCR TEXT: -${ocrResult.text} -Extract these fields: -1. documentNumber: The official document number or code. If not found, return null. -2. subject: The main subject, title, or topic of the document. If not found, return null. -3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified. -4. category: Must be exactly one of: "Correspondence", "Transmittal", "Circulation", "RFA", "Shop Drawing", "Contract Drawing", or null if not specified. -5. date: The issue/document date in YYYY-MM-DD format. If not found, return null. -6. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction. -7. tags: An array of tags/keywords (strings) that describe the document. -8. summary: A short 1-2 sentence summary of the document contents. -Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example: -{ - "documentNumber": "LCBP3-CIV-001", - "subject": "Foundation Inspection Report", - "discipline": "Civil", - "category": "Correspondence", - "date": "2026-05-20", - "confidence": 0.95, - "tags": ["foundation", "inspection", "concrete"], - "summary": "This document is a foundation inspection report for the LCBP3 project, confirming concrete strength." -}`; + const { resolvedPrompt } = await this.aiPromptsService.resolveActive( + 'ocr_extraction', + ocrResult.text + ); let aiResponse: string; try { - aiResponse = await this.ollamaService.generate(prompt); + aiResponse = await this.ollamaService.generate(resolvedPrompt); } catch (err: unknown) { const errMsg = err instanceof Error ? err.message : String(err); this.logger.error(`การวิเคราะห์ของ AI ล้มเหลว: ${errMsg}`); @@ -395,7 +372,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co }); await this.saveAiAuditLog({ documentPublicId, - aiModel: await this.ollamaService.getMainModelName(), + aiModel: this.ollamaService.getMainModelName(), status: AiAuditStatus.FAILED, errorMessage: errMsg, processingTimeMs: Date.now() - startTime, @@ -421,7 +398,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co }); await this.saveAiAuditLog({ documentPublicId, - aiModel: await this.ollamaService.getMainModelName(), + aiModel: this.ollamaService.getMainModelName(), status: AiAuditStatus.FAILED, errorMessage: errMsg, processingTimeMs: Date.now() - startTime, @@ -463,10 +440,13 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co isValid, confidence, aiJobId: String(job.id), + details: { + discipline: extractedMetadata.discipline, + }, }); await this.saveAiAuditLog({ documentPublicId, - aiModel: await this.ollamaService.getMainModelName(), + aiModel: this.ollamaService.getMainModelName(), status: AiAuditStatus.SUCCESS, aiSuggestionJson: extractedMetadata, confidenceScore: confidence, diff --git a/backend/src/modules/ai/processors/ai-realtime.processor.ts b/backend/src/modules/ai/processors/ai-realtime.processor.ts index 6a025d26..dd343a93 100644 --- a/backend/src/modules/ai/processors/ai-realtime.processor.ts +++ b/backend/src/modules/ai/processors/ai-realtime.processor.ts @@ -114,7 +114,7 @@ export class AiRealtimeProcessor extends WorkerHost { this.aiAuditLogRepo.create({ documentPublicId: job.data.documentPublicId, aiModel: 'gemma4', - modelName: await this.ollamaService.getMainModelName(), + modelName: this.ollamaService.getMainModelName(), aiSuggestionJson: normalizedSuggestion, confidenceScore: this.extractConfidence(normalizedSuggestion), processingTimeMs: Date.now() - startTime, @@ -136,7 +136,7 @@ export class AiRealtimeProcessor extends WorkerHost { this.aiAuditLogRepo.create({ documentPublicId: job.data.documentPublicId, aiModel: 'gemma4', - modelName: await this.ollamaService.getMainModelName(), + modelName: this.ollamaService.getMainModelName(), processingTimeMs: Date.now() - startTime, status: AiAuditStatus.FAILED, errorMessage: err instanceof Error ? err.message : String(err), diff --git a/backend/src/modules/ai/prompts/ai-prompts.controller.ts b/backend/src/modules/ai/prompts/ai-prompts.controller.ts new file mode 100644 index 00000000..6c346ab7 --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.controller.ts @@ -0,0 +1,140 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.controller.ts +// Change Log +// - 2026-05-25: Created AiPromptsController for dynamic prompt management (ADR-029) + +import { + Controller, + Get, + Post, + Delete, + Patch, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, + ParseIntPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +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 { AiPromptResponseDto } from './dto/ai-prompt-response.dto'; +import { plainToInstance } from 'class-transformer'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { RbacGuard } from '../../../common/guards/rbac.guard'; +import { RequirePermission } from '../../../common/decorators/require-permission.decorator'; +import { Audit } from '../../../common/decorators/audit.decorator'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { User } from '../../user/entities/user.entity'; + +/** + * Controller สำหรับจัดการ Prompt Versions ของ AI OCR (ADR-029) + */ +@ApiTags('AI Prompts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RbacGuard) +@Controller('ai/prompts') +export class AiPromptsController { + constructor(private readonly promptsService: AiPromptsService) {} + + private mapToDto(prompt: AiPrompt): AiPromptResponseDto { + return plainToInstance(AiPromptResponseDto, prompt, { + excludeExtraneousValues: true, + }); + } + + @Get(':promptType') + @RequirePermission('system.manage_all') + @ApiOperation({ + summary: 'ดึงรายการ Prompt Versions ทั้งหมดสำหรับ prompt_type ที่กำหนด', + }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + async listPromptVersions( + @Param('promptType') promptType: string + ): Promise<{ data: AiPromptResponseDto[] }> { + const list = await this.promptsService.findAll(promptType); + return { data: list.map((p) => this.mapToDto(p)) }; + } + + @Post(':promptType') + @RequirePermission('system.manage_all') + @Audit('ai_prompt.create', 'AiPrompt') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'สร้าง Prompt Version ใหม่ (เริ่มต้นเป็น inactive)', + }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + async createPromptVersion( + @Param('promptType') promptType: string, + @Body() dto: CreateAiPromptDto, + @CurrentUser() user: User + ): Promise<{ data: AiPromptResponseDto }> { + const newPrompt = await this.promptsService.create( + promptType, + dto, + user.user_id + ); + return { data: this.mapToDto(newPrompt) }; + } + + @Delete(':promptType/:versionNumber') + @RequirePermission('system.manage_all') + @Audit('ai_prompt.delete', 'AiPrompt') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'ลบ Prompt Version (ห้ามลบ active version)' }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + @ApiParam({ name: 'versionNumber', type: Number }) + async deletePromptVersion( + @Param('promptType') promptType: string, + @Param('versionNumber', ParseIntPipe) versionNumber: number, + @CurrentUser() user: User + ): Promise { + await this.promptsService.delete(promptType, versionNumber, user.user_id); + } + + @Post(':promptType/:versionNumber/activate') + @RequirePermission('system.manage_all') + @Audit('ai_prompt.activate', 'AiPrompt') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'เปิดใช้งาน Prompt Version' }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + @ApiParam({ name: 'versionNumber', type: Number }) + async activatePromptVersion( + @Param('promptType') promptType: string, + @Param('versionNumber', ParseIntPipe) versionNumber: number, + @CurrentUser() user: User + ): Promise<{ data: AiPromptResponseDto }> { + const activated = await this.promptsService.activate( + promptType, + versionNumber, + user.user_id + ); + return { data: this.mapToDto(activated) }; + } + + @Patch(':promptType/:versionNumber/note') + @RequirePermission('system.manage_all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'บันทึก Manual Note สำหรับ Prompt Version' }) + @ApiParam({ name: 'promptType', example: 'ocr_extraction' }) + @ApiParam({ name: 'versionNumber', type: Number }) + async updatePromptNote( + @Param('promptType') promptType: string, + @Param('versionNumber', ParseIntPipe) versionNumber: number, + @Body() dto: UpdatePromptNoteDto + ): Promise<{ data: AiPromptResponseDto }> { + const updated = await this.promptsService.updateNote( + promptType, + versionNumber, + dto.manualNote + ); + return { data: this.mapToDto(updated) }; + } +} diff --git a/backend/src/modules/ai/prompts/ai-prompts.entity.ts b/backend/src/modules/ai/prompts/ai-prompts.entity.ts new file mode 100644 index 00000000..fabff7ed --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.entity.ts @@ -0,0 +1,57 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.entity.ts +// Change Log +// - 2026-05-25: Created TypeORM entity for dynamic prompt management (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; + +/** + * Entity สำหรับเก็บข้อมูลประวัติและการตั้งค่า Prompt version ต่างๆ + * สำหรับการสกัดข้อมูลเอกสารผ่าน OCR และ LLM + */ +@Entity('ai_prompts') +export class AiPrompt { + @PrimaryGeneratedColumn() + @Exclude() // ADR-019: INT PK ไม่ expose ใน API + id!: number; + + @Column({ name: 'prompt_type', length: 50 }) + promptType!: string; + + @Column({ name: 'version_number' }) + versionNumber!: number; + + @Column({ type: 'text' }) + template!: string; + + @Column({ name: 'field_schema', type: 'json', nullable: true }) + fieldSchema!: Record | null; + + @Column({ name: 'is_active', type: 'tinyint', width: 1, default: 0 }) + isActive!: boolean; + + @Column({ name: 'test_result_json', type: 'json', nullable: true }) + testResultJson!: Record | null; + + @Column({ name: 'manual_note', type: 'text', nullable: true }) + manualNote!: string | null; + + @Column({ name: 'last_tested_at', type: 'timestamp', nullable: true }) + lastTestedAt!: Date | null; + + @Column({ name: 'activated_at', type: 'timestamp', nullable: true }) + activatedAt!: Date | null; + + @Column({ name: 'created_by' }) + @Exclude() // FK ไม่ expose โดยตรง + createdBy!: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/ai/prompts/ai-prompts.module.ts b/backend/src/modules/ai/prompts/ai-prompts.module.ts new file mode 100644 index 00000000..f1c784dc --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.module.ts @@ -0,0 +1,21 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.module.ts +// Change Log +// - 2026-05-25: Created AiPromptsModule for prompt versioning system (ADR-029) + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AiPrompt } from './ai-prompts.entity'; +import { AuditLog } from '../../../common/entities/audit-log.entity'; +import { AiPromptsService } from './ai-prompts.service'; +import { AiPromptsController } from './ai-prompts.controller'; + +/** + * Module สำหรับการจัดการเวอร์ชันของ AI Prompts ใน OCR Pipeline + */ +@Module({ + imports: [TypeOrmModule.forFeature([AiPrompt, AuditLog])], + controllers: [AiPromptsController], + providers: [AiPromptsService], + exports: [AiPromptsService], +}) +export class AiPromptsModule {} diff --git a/backend/src/modules/ai/prompts/ai-prompts.service.spec.ts b/backend/src/modules/ai/prompts/ai-prompts.service.spec.ts new file mode 100644 index 00000000..d36e4391 --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.service.spec.ts @@ -0,0 +1,216 @@ +// File: backend/src/modules/ai/prompts/ai-prompts.service.spec.ts +// Change Log +// - 2026-05-25: Created unit tests for AiPromptsService (T028) + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { AiPromptsService } from './ai-prompts.service'; +import { AiPrompt } from './ai-prompts.entity'; +import { AuditLog } from '../../../common/entities/audit-log.entity'; +import { + BusinessException, + ValidationException, + NotFoundException, +} from '../../../common/exceptions'; + +describe('AiPromptsService', () => { + let service: AiPromptsService; + const mockAiPromptRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + const mockAuditLogRepo = { + create: jest.fn(), + save: jest.fn(), + }; + const mockRedis = { + get: jest.fn(), + setex: jest.fn(), + del: jest.fn(), + }; + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setLock: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + }; + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + save: jest.fn(), + }, + }; + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + }; + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AiPromptsService, + { + provide: getRepositoryToken(AiPrompt), + useValue: mockAiPromptRepo, + }, + { + provide: getRepositoryToken(AuditLog), + useValue: mockAuditLogRepo, + }, + { + provide: 'default_IORedisModuleConnectionToken', + useValue: mockRedis, + }, + { + provide: DataSource, + useValue: mockDataSource, + }, + ], + }).compile(); + service = module.get(AiPromptsService); + }); + describe('create', () => { + it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder', async () => { + await expect( + service.create( + 'ocr_extraction', + { template: 'Invalid prompt structure' }, + 1 + ) + ).rejects.toThrow(ValidationException); + }); + it('ควรปฏิเสธ template ที่ตัวอักษรเกิน 4,000 ตัว', async () => { + const longTemplate = 'a'.repeat(4005) + '{{ocr_text}}'; + await expect( + service.create('ocr_extraction', { template: longTemplate }, 1) + ).rejects.toThrow(ValidationException); + }); + it('ควรบันทึกสำเร็จและรัน version number ต่อเนื่อง', async () => { + mockQueryBuilder.getRawOne.mockResolvedValue({ max: 5 }); + mockAiPromptRepo.create.mockReturnValue({ + id: 12, + promptType: 'ocr_extraction', + versionNumber: 6, + template: 'Test {{ocr_text}}', + isActive: false, + }); + mockQueryRunner.manager.save.mockResolvedValue({ + id: 12, + promptType: 'ocr_extraction', + versionNumber: 6, + template: 'Test {{ocr_text}}', + isActive: false, + }); + const result = await service.create( + 'ocr_extraction', + { template: 'Test {{ocr_text}}' }, + 1 + ); + expect(result.versionNumber).toBe(6); + expect(mockQueryRunner.manager.save).toHaveBeenCalled(); + expect(mockAuditLogRepo.save).toHaveBeenCalled(); + }); + }); + describe('activate', () => { + it('ควร activate สำเร็จ ยกเลิกตัวอื่น และลบ cache', async () => { + const activePrompt = { + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }; + const targetPrompt = { + id: 2, + promptType: 'ocr_extraction', + versionNumber: 2, + isActive: false, + }; + mockQueryRunner.manager.findOne.mockResolvedValue(targetPrompt); + mockQueryRunner.manager.find.mockResolvedValue([activePrompt]); + mockQueryRunner.manager.save.mockResolvedValue({ + ...targetPrompt, + isActive: true, + }); + const result = await service.activate('ocr_extraction', 2, 1); + expect(result.isActive).toBe(true); + expect(mockQueryRunner.manager.update).toHaveBeenCalledWith( + AiPrompt, + { promptType: 'ocr_extraction', isActive: true }, + { isActive: false } + ); + expect(mockRedis.del).toHaveBeenCalledWith( + 'ai:prompt:active:ocr_extraction' + ); + expect(mockAuditLogRepo.save).toHaveBeenCalled(); + }); + it('ควร throw error เมื่อไม่พบ prompt version ที่ต้องการ activate', async () => { + mockQueryRunner.manager.findOne.mockResolvedValue(null); + await expect(service.activate('ocr_extraction', 99, 1)).rejects.toThrow( + NotFoundException + ); + }); + }); + describe('delete', () => { + it('ควร throw error เมื่อลบ active version', async () => { + mockAiPromptRepo.findOne.mockResolvedValue({ + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }); + await expect(service.delete('ocr_extraction', 1, 1)).rejects.toThrow( + BusinessException + ); + }); + it('ควรลบ inactive version สำเร็จและบันทึก audit log', async () => { + const inactivePrompt = { + id: 2, + promptType: 'ocr_extraction', + versionNumber: 2, + isActive: false, + }; + mockAiPromptRepo.findOne.mockResolvedValue(inactivePrompt); + await service.delete('ocr_extraction', 2, 1); + expect(mockAiPromptRepo.remove).toHaveBeenCalledWith(inactivePrompt); + expect(mockAuditLogRepo.save).toHaveBeenCalled(); + }); + }); + describe('getActive', () => { + it('ควรดึงจาก Redis cache เมื่อมี cache hit', async () => { + const cachedPrompt = { + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedPrompt)); + const result = await service.getActive('ocr_extraction'); + expect(result).toEqual(cachedPrompt); + expect(mockAiPromptRepo.findOne).not.toHaveBeenCalled(); + }); + it('ควร fallback ไปหา DB เมื่อ Redis มีปัญหา', async () => { + const dbPrompt = { + id: 1, + promptType: 'ocr_extraction', + versionNumber: 1, + isActive: true, + }; + mockRedis.get.mockRejectedValue(new Error('Redis connection lost')); + mockAiPromptRepo.findOne.mockResolvedValue(dbPrompt); + const result = await service.getActive('ocr_extraction'); + expect(result).toEqual(dbPrompt); + expect(mockAiPromptRepo.findOne).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/ai/prompts/ai-prompts.service.ts b/backend/src/modules/ai/prompts/ai-prompts.service.ts new file mode 100644 index 00000000..8125c42c --- /dev/null +++ b/backend/src/modules/ai/prompts/ai-prompts.service.ts @@ -0,0 +1,294 @@ +// 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 + +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { AiPrompt } from './ai-prompts.entity'; +import { AuditLog } from '../../../common/entities/audit-log.entity'; +import { CreateAiPromptDto } from './dto/create-ai-prompt.dto'; +import { + BusinessException, + ValidationException, + NotFoundException, +} from '../../../common/exceptions'; + +/** + * 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, + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + @InjectRedis() + private readonly redis: Redis, + private readonly dataSource: DataSource + ) {} + + /** + * ดึงรายการเวอร์ชันทั้งหมดของ prompt_type ที่กำหนด + */ + async findAll(promptType: string): Promise { + return this.aiPromptRepo.find({ + where: { promptType }, + order: { versionNumber: 'DESC' }, + }); + } + + /** + * ดึง Active prompt จาก Redis cache หรือ DB fallback + */ + async getActive(promptType: string): Promise { + 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 ที่มีผลใช้งานจริง และแทนที่ placeholder {{ocr_text}} ด้วยข้อความ OCR + */ + 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 + */ + async create( + promptType: string, + dto: CreateAiPromptDto, + userId: number + ): Promise { + if (!dto.template.includes('{{ocr_text}}')) { + throw new ValidationException('template ต้องมี {{ocr_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({ + promptType, + versionNumber: nextVersion, + template: dto.template, + isActive: false, + createdBy: userId, + }); + const savedPrompt = await queryRunner.manager.save(newPrompt); + await queryRunner.commitTransaction(); + 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 เดียวกัน + */ + async activate( + promptType: string, + versionNumber: number, + userId: number + ): Promise { + 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()); + } + 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); + } 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) + */ + async delete( + promptType: string, + versionNumber: number, + userId: number + ): Promise { + 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); + await this.saveAuditLog( + 'AI_PROMPT_DELETED', + String(prompt.id), + { promptType, versionNumber, userId }, + userId + ); + } + + /** + * อัปเดต manual note ของเวอร์ชันที่กำหนด + */ + async updateNote( + promptType: string, + versionNumber: number, + note: string | null + ): Promise { + 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); + } + + /** + * บันทึกผลทดสอบของเวอร์ชันหลังจากรัน OCR Sandbox + */ + async saveTestResult( + promptType: string, + versionNumber: number, + resultJson: Record + ): Promise { + const prompt = await this.aiPromptRepo.findOne({ + where: { promptType, versionNumber }, + }); + if (prompt) { + prompt.testResultJson = resultJson; + prompt.lastTestedAt = new Date(); + await this.aiPromptRepo.save(prompt); + } + } + + /** + * บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs + */ + private async saveAuditLog( + action: string, + entityId: string, + detailsJson: Record, + userId?: number + ): Promise { + 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)}` + ); + } + } +} diff --git a/backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts b/backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts new file mode 100644 index 00000000..855014d0 --- /dev/null +++ b/backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts @@ -0,0 +1,39 @@ +// File: backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts +// Change Log +// - 2026-05-25: Created AiPromptResponseDto to exclude internal INT PK and expose clean API fields (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { Expose } from 'class-transformer'; + +/** + * Data Transfer Object สำหรับส่งออกข้อมูล Prompt version ทาง API + * โดยคัดกรองเฉพาะข้อมูลภายนอกและปิดบัง PK ดั้งเดิมตามนโยบายความปลอดภัย + */ +export class AiPromptResponseDto { + @Expose() + promptType!: string; + + @Expose() + versionNumber!: number; + + @Expose() + template!: string; + + @Expose() + isActive!: boolean; + + @Expose() + testResultJson!: Record | null; + + @Expose() + manualNote!: string | null; + + @Expose() + lastTestedAt!: Date | null; + + @Expose() + activatedAt!: Date | null; + + @Expose() + createdAt!: Date; +} diff --git a/backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts b/backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts new file mode 100644 index 00000000..77476823 --- /dev/null +++ b/backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts @@ -0,0 +1,16 @@ +// File: backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts +// Change Log +// - 2026-05-25: Created CreateAiPromptDto for prompt version creation (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** + * Data Transfer Object สำหรับการสร้าง prompt version ใหม่ + */ +export class CreateAiPromptDto { + @IsString() + @IsNotEmpty({ message: 'Template text must not be empty' }) + @MaxLength(4000, { message: 'Template exceeds 4,000 character limit' }) + template!: string; +} diff --git a/backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts b/backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts new file mode 100644 index 00000000..6129f5ca --- /dev/null +++ b/backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts @@ -0,0 +1,15 @@ +// File: backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts +// Change Log +// - 2026-05-25: Created UpdatePromptNoteDto for annotation updates (ADR-029) +// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization + +import { IsOptional, IsString } from 'class-validator'; + +/** + * Data Transfer Object สำหรับอัปเดต manual note ของ prompt version + */ +export class UpdatePromptNoteDto { + @IsString() + @IsOptional() + manualNote!: string | null; +} diff --git a/backend/src/modules/ai/services/ollama.service.ts b/backend/src/modules/ai/services/ollama.service.ts index 4f82b9a9..37850631 100644 --- a/backend/src/modules/ai/services/ollama.service.ts +++ b/backend/src/modules/ai/services/ollama.service.ts @@ -1,154 +1,176 @@ // File: src/modules/ai/services/ollama.service.ts -// Change Log -// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack. -// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama -// - 2026-05-25: เพิ่มการใช้งานโมเดลจาก DB (AiSettingsService) แทน ENV เท่านั้น (ADR-027). -import { Injectable, Logger, Optional } from '@nestjs/common'; +// Change Log + +// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack. + +// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama + +import { Injectable, Logger } from '@nestjs/common'; + import { ConfigService } from '@nestjs/config'; + import axios from 'axios'; -import { AiSettingsService } from '../ai-settings.service'; export interface OllamaGenerateOptions { timeoutMs?: number; + signal?: AbortSignal; } /** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */ + @Injectable() export class OllamaService { private readonly logger = new Logger(OllamaService.name); + private readonly ollamaUrl: string; - private readonly defaultMainModel: string; + + private readonly mainModel: string; + private readonly embedModel: string; + private readonly timeoutMs: number; - constructor( - private readonly configService: ConfigService, - @Optional() - private readonly aiSettingsService?: AiSettingsService - ) { + constructor(private readonly configService: ConfigService) { this.ollamaUrl = this.configService.get( 'OLLAMA_URL', + this.configService.get('AI_HOST_URL', 'http://localhost:11434') ); - // Default fallback model (ADR-023A: gemma4:e2b) - this.defaultMainModel = this.configService.get( + + this.mainModel = this.configService.get( 'OLLAMA_MODEL_MAIN', - 'gemma4:e2b' + + 'gemma4:e4b' ); + this.embedModel = this.configService.get( 'OLLAMA_MODEL_EMBED', + this.configService.get('OLLAMA_EMBED_MODEL', 'nomic-embed-text') ); + this.timeoutMs = this.configService.get('AI_TIMEOUT_MS', 30000); } - /** ดึงชื่อโมเดลที่ใช้งานอยู่ (จาก DB หรือ ENV fallback) */ - private async getActiveModelName(): Promise { - if (this.aiSettingsService) { - try { - return await this.aiSettingsService.getActiveModel(); - } catch (err: unknown) { - this.logger.warn( - `Failed to get active model from DB: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - return this.defaultMainModel; - } + /** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */ - /** สร้างข้อความตอบกลับจากโมเดลที่กำหนด (DB หรือ ENV fallback) */ async generate( prompt: string, + options: OllamaGenerateOptions = {} ): Promise { - const modelName = await this.getActiveModelName(); try { const response = await axios.post<{ response: string }>( `${this.ollamaUrl}/api/generate`, + { - model: modelName, + model: this.mainModel, + prompt, + stream: false, }, + { timeout: options.timeoutMs ?? this.timeoutMs, + signal: options.signal, } ); + return response.data.response ?? ''; } catch (err) { this.logger.error( - `Ollama generate failed with model ${modelName}`, + 'Ollama generate failed', + err instanceof Error ? err.stack : String(err) ); + throw err; } } /** สร้าง embedding ด้วย nomic-embed-text หรือค่า ENV ที่กำหนด */ + async generateEmbedding(text: string): Promise { try { const response = await axios.post<{ embedding: number[] }>( `${this.ollamaUrl}/api/embeddings`, + { model: this.embedModel, prompt: text }, + { timeout: this.timeoutMs } ); + return response.data.embedding; } catch (err) { this.logger.error( 'Ollama embedding failed', + err instanceof Error ? err.stack : String(err) ); + throw err; } } - /** คืนชื่อ main model สำหรับ audit log (async เพราะต้องเช็ค DB) */ - async getMainModelName(): Promise { - return this.getActiveModelName(); - } + /** คืนชื่อ main model สำหรับ audit log */ - /** คืนชื่อ main model แบบ sync (fallback สำหรับกรณีที่ไม่ต้องการ async) */ - getMainModelNameSync(): string { - return this.defaultMainModel; + getMainModelName(): string { + return this.mainModel; } /** คืนชื่อ embedding model สำหรับ audit log */ + getEmbeddingModelName(): string { return this.embedModel; } /** ตรวจสอบสุขภาพและความเร็ว (Latency) ของระบบ Ollama */ + async checkHealth(): Promise<{ status: 'HEALTHY' | 'DEGRADED' | 'DOWN'; + latencyMs: number; + models: string[]; + error?: string; }> { const startTime = Date.now(); - const activeModel = await this.getActiveModelName(); + try { await axios.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 }); + const latencyMs = Date.now() - startTime; + return { status: 'HEALTHY', + latencyMs, - models: [activeModel, this.embedModel], + + models: [this.mainModel, this.embedModel], }; } catch (err: unknown) { const latencyMs = Date.now() - startTime; + const error = err instanceof Error ? err.message : String(err); + const isTimeout = err instanceof Error && (err.message.includes('timeout') || err.message.includes('504') || err.message.includes('code ECONNABORTED')); + return { status: isTimeout ? 'DEGRADED' : 'DOWN', + latencyMs, - models: [activeModel, this.embedModel], + + models: [this.mainModel, this.embedModel], + error, }; } diff --git a/frontend/app/(admin)/admin/ai/page.tsx b/frontend/app/(admin)/admin/ai/page.tsx index f6a468f5..83d49700 100644 --- a/frontend/app/(admin)/admin/ai/page.tsx +++ b/frontend/app/(admin)/admin/ai/page.tsx @@ -23,6 +23,7 @@ import { useAiStatus, useToggleAiFeatures, useAiHealth } from '@/hooks/use-ai-st import { projectService } from '@/lib/services/project.service'; import { adminAiService, AiSandboxJobResult, AiAvailableModel } from '@/lib/services/admin-ai.service'; import { toast } from 'sonner'; +import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager'; interface SandboxProject { publicId: string; @@ -43,12 +44,7 @@ export default function AiAdminConsolePage() { const [isSandboxPolling, setIsSandboxPolling] = useState(false); const [sandboxProgress, setSandboxProgress] = useState(0); const [sandboxStatusText, setSandboxStatusText] = useState(''); - const [ocrFile, setOcrFile] = useState(null); - const [ocrJobId, setOcrJobId] = useState(null); - const [ocrJobResult, setOcrJobResult] = useState(null); - const [isOcrPolling, setIsOcrPolling] = useState(false); - const [ocrProgress, setOcrProgress] = useState(0); - const [ocrStatusText, setOcrStatusText] = useState(''); + // AI Model Management State (ADR-027) const { data: aiModelsData, refetch: refetchModels } = useQuery<{ models: AiAvailableModel[]; activeModel: string }>({ @@ -174,80 +170,7 @@ export default function AiAdminConsolePage() { clearInterval(timer); }; }, [sandboxJobId]); - const handleSubmitOcr = async (e: React.FormEvent): Promise => { - e.preventDefault(); - if (!ocrFile) { - toast.error('กรุณาเลือกไฟล์ PDF สำหรับทำ OCR'); - return; - } - if (ocrFile.size > 50 * 1024 * 1024) { - toast.error('ขนาดไฟล์เกินกว่า 50MB'); - return; - } - if (ocrFile.type !== 'application/pdf' && !ocrFile.name.toLowerCase().endsWith('.pdf')) { - toast.error('กรุณาอัปโหลดไฟล์ในรูปแบบ PDF เท่านั้น'); - return; - } - try { - setOcrJobResult(null); - setOcrProgress(10); - setOcrStatusText('กำลังอัปโหลดไฟล์ไปยังระบบเซิร์ฟเวอร์...'); - const response = await adminAiService.submitSandboxExtract(ocrFile); - setOcrJobId(response.requestPublicId); - setIsOcrPolling(true); - toast.success('อัปโหลดไฟล์สำเร็จและเข้าสู่คิว sandbox OCR'); - } catch (err) { - const error = err as { response?: { data?: { message?: string } } }; - toast.error(error.response?.data?.message || 'เกิดข้อผิดพลาดในการทำ OCR Sandbox'); - setOcrProgress(0); - setOcrStatusText(''); - } - }; - useEffect(() => { - if (!ocrJobId) return; - let timer: NodeJS.Timeout; - const pollOcrJob = async () => { - try { - const res = await adminAiService.getSandboxJobStatus(ocrJobId); - setOcrJobResult(res); - if (res.status === 'pending') { - setOcrProgress(30); - setOcrStatusText('อยู่ในคิวรอดำเนินการ (Pending in BullMQ)...'); - } else if (res.status === 'processing') { - setOcrProgress(70); - setOcrStatusText('กำลังอ่านไฟล์ PDF และสกัดข้อความด้วย OCR & LLM...'); - } else if (res.status === 'completed') { - setOcrProgress(100); - setOcrStatusText('การทำ OCR และสกัดข้อมูลเมตาดาต้าเสร็จสิ้น'); - setIsOcrPolling(false); - setOcrJobId(null); - toast.success('ทำ OCR Sandbox สำเร็จ'); - } else if (res.status === 'failed') { - setOcrProgress(100); - setOcrStatusText('การทำ OCR ล้มเหลว'); - setIsOcrPolling(false); - setOcrJobId(null); - toast.error(res.errorMessage || 'การทำ OCR Sandbox เกิดข้อผิดพลาด'); - } else if (res.status === 'cancelled') { - setOcrProgress(100); - setOcrStatusText('การทำ OCR ถูกยกเลิก'); - setIsOcrPolling(false); - setOcrJobId(null); - toast.error('OCR sandbox job ถูกยกเลิก'); - } else if (res.status === 'not_found') { - setOcrProgress(20); - setOcrStatusText('กำลังตรวจสอบสถานะคิวงาน...'); - } - } catch { - // เงียบข้อผิดพลาดตามนโยบาย UI - } - }; - pollOcrJob(); - timer = setInterval(pollOcrJob, 5000); - return () => { - clearInterval(timer); - }; - }, [ocrJobId]); + const renderStatusBadge = (status?: 'HEALTHY' | 'DEGRADED' | 'DOWN') => { if (!status) return Unknown; switch (status) { @@ -745,167 +668,7 @@ export default function AiAdminConsolePage() { )} - - - - - OCR Sandbox Playground (isolated) - -

- พื้นที่อัปโหลดไฟล์ PDF เพื่อทำการทดสอบทำ OCR และจำลองการดึง Metadata ออกมาในรูปแบบโครงสร้าง JSON โดยไม่บันทึกข้อมูลลงฐานข้อมูลจริง -

-
- -
-
- -
{ - e.preventDefault(); - }} - onDrop={(e) => { - e.preventDefault(); - if (isOcrPolling) return; - const file = e.dataTransfer.files?.[0]; - if (file) { - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - setOcrFile(file); - } else { - toast.error('กรุณาเลือกไฟล์ PDF เท่านั้น'); - } - } - }} - > - - {ocrFile ? ( -
-

{ocrFile.name}

-

- ({(ocrFile.size / 1024 / 1024).toFixed(2)} MB) -

- -
- ) : ( -
-

- ลากและวางไฟล์ PDF หรือคลิกเพื่ออัปโหลด -

- { - const file = e.target.files?.[0]; - if (file) setOcrFile(file); - }} - className="hidden" - id="ocr-file-upload" - /> - -
- )} -
-
-
- -
-
-
-
- {isOcrPolling && ( - - -
-
- - {ocrStatusText} -
- {ocrProgress}% -
- -
- - ID คำขอ: {ocrJobId} -
-
-
- )} - {ocrJobResult && ( -
- {ocrJobResult.status === 'completed' && ( - - - - - ผลลัพธ์การสกัด Metadata แบบโครงสร้าง (JSON Output) - - - -
-
-                        {ocrJobResult.answer}
-                      
-
- {ocrJobResult.completedAt && ( -
- เสร็จสิ้นเมื่อ: {new Date(ocrJobResult.completedAt).toLocaleString()} -
- )} -
-
- )} - {ocrJobResult.status === 'failed' && ( - - - - ประมวลผล OCR Sandbox ล้มเหลว - - -

- {ocrJobResult.errorMessage || 'เกิดข้อผิดพลาดขึ้นระหว่างการอ่านไฟล์เอกสาร PDF หรือการเรียก LLM Sandbox สำหรับถอดความเมตาดาต้า กรุณาตรวจสอบสถานะสุขภาพของตัวบริการ'} -

-
-
- )} -
- )} +
diff --git a/frontend/components/admin/ai/OcrSandboxPromptManager.tsx b/frontend/components/admin/ai/OcrSandboxPromptManager.tsx new file mode 100644 index 00000000..8e1c237c --- /dev/null +++ b/frontend/components/admin/ai/OcrSandboxPromptManager.tsx @@ -0,0 +1,415 @@ +// File: frontend/components/admin/ai/OcrSandboxPromptManager.tsx +// Change Log +// - 2026-05-25: Created OcrSandboxPromptManager component for dynamic prompt editing, version control, and sandbox testing (ADR-029) +// - 2026-05-25: Extracted inline strings to i18n keys via useTranslations() (Obs #1 fix) +// - 2026-05-25: Refactored sandbox polling to useSandboxRun hook (Obs #2 fix) +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { + Brain, + Save, + AlertCircle, + Upload, + Play, + FileJson, + ScrollText, + Loader2, + StickyNote, +} from 'lucide-react'; +import { useAiPrompts, useSandboxRun } from '@/hooks/use-ai-prompts'; +import { useTranslations } from '@/hooks/use-translations'; +import PromptVersionHistory from './PromptVersionHistory'; +import { cn } from '@/lib/utils'; +import { AiPrompt } from '@/types/ai-prompts'; + +/** + * Component หลักสำหรับจัดการ Prompt versions ของ OCR sandbox และ Migration + * ประกอบไปด้วยตัวแก้ไข Prompt, รายการเวอร์ชัน, และส่วนสกัดทดสอบ (Sandbox run) + */ +export default function OcrSandboxPromptManager() { + const t = useTranslations(); + const promptType = 'ocr_extraction'; + const { + versionsQuery, + createMutation, + activateMutation, + deleteMutation, + updateNoteMutation, + } = useAiPrompts(promptType); + const versions = versionsQuery.data ?? []; + const activePrompt = versions.find((v) => v.isActive); + const [templateText, setTemplateText] = useState(''); + const [ocrFile, setOcrFile] = useState(null); + const [manualNote, setManualNote] = useState(''); + const [activeTab, setActiveTab] = useState<'editor' | 'sandbox'>('editor'); + const { state: sandboxState, jobId: sandboxJobId, submit: submitSandbox, reset: resetSandbox } = + useSandboxRun(() => { + // เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน + versionsQuery.refetch(); + toast.success(t('ai.prompt.sandboxSuccess')); + }); + useEffect(() => { + if (activePrompt && !templateText) { + setTemplateText(activePrompt.template); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activePrompt]); + const handleSaveVersion = async () => { + if (!templateText.includes('{{ocr_text}}')) { + toast.error(t('ai.prompt.placeholderError')); + return; + } + if (templateText.length > 4000) { + toast.error(t('ai.prompt.charLimitError')); + return; + } + try { + await createMutation.mutateAsync(templateText); + toast.success(t('ai.prompt.saveVersionSuccess')); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.saveVersionError')); + } + }; + const handleLoadTemplate = (version: AiPrompt) => { + setTemplateText(version.template); + setActiveTab('editor'); + toast.success(t('ai.prompt.loadSuccess', { version: String(version.versionNumber) })); + }; + const handleActivateVersion = async (versionNumber: number) => { + try { + await activateMutation.mutateAsync(versionNumber); + toast.success(t('ai.prompt.activateSuccess', { version: String(versionNumber) })); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.activateError')); + } + }; + const handleDeleteVersion = async (versionNumber: number) => { + if (!confirm(t('ai.prompt.deleteConfirm', { version: String(versionNumber) }))) return; + try { + await deleteMutation.mutateAsync(versionNumber); + toast.success(t('ai.prompt.deleteSuccess', { version: String(versionNumber) })); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.deleteError')); + } + }; + const handleSaveManualNote = async (versionNumber: number) => { + try { + await updateNoteMutation.mutateAsync({ versionNumber, note: manualNote }); + toast.success(t('ai.prompt.saveNoteSuccess')); + setManualNote(''); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.saveNoteError')); + } + }; + const handleSubmitOcr = async (e: React.FormEvent) => { + e.preventDefault(); + if (!activePrompt) { + toast.error(t('ai.prompt.noActivePrompt')); + return; + } + if (!ocrFile) { + toast.error(t('ai.prompt.noFile')); + return; + } + try { + resetSandbox(); + await submitSandbox(ocrFile); + toast.success(t('ai.prompt.uploadSuccess')); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || t('ai.prompt.uploadError')); + } + }; + // แปล status key เป็นข้อความตาม locale ปัจจุบัน + const statusLabel = sandboxState.statusText ? t(sandboxState.statusText) : ''; + return ( +
+
+
+ + +
+ {activeTab === 'editor' ? ( + + + + + {t('ai.prompt.cardTitle')} + + {activePrompt && ( + + {t('ai.prompt.activeLabel', { version: String(activePrompt.versionNumber) })} + + )} + + +
+