/** * Few-Shot Curator — auto-promotes high-quality outputs to prompt templates. * * Algorithm: * 1. Pull outputs with confidence >= 9.0 AND status='approved' * 2. Check diversity vs existing few-shot examples (TF-IDF cosine similarity) * 3. When 3+ candidates for a task_type accumulate → update YAML template * 4. Handle negative examples from rejected review_queue items */ import { readFileSync, writeFileSync, readdirSync } from 'fs'; import { fileURLToPath } from 'url'; import { join, resolve } from 'path'; import yaml from 'js-yaml'; import { query, withTransaction } from '../db/client.js'; import { logger } from '../observability/logger.js'; // ─── Constants ────────────────────────────────────────────────────────────── const _dir = fileURLToPath(new URL('.', import.meta.url)); const _defaultTemplatesDir = resolve(join(_dir, '..', '..', '..', 'gateway', 'prompts', 'templates')); const TEMPLATES_DIR = process.env['TEMPLATES_DIR'] ?? _defaultTemplatesDir; const MIN_CONFIDENCE = 9.0; const SIMILARITY_THRESHOLD = 0.7; const CANDIDATES_REQUIRED = 3; const MAX_FEW_SHOT_LENGTH = 800; // chars — too long clutters the prompt // ─── TF-IDF cosine similarity (no ML needed) ──────────────────────────────── function tokenize(text: string): string[] { return text .toLowerCase() .replace(/[^a-z0-9äöüß\s]/g, ' ') .split(/\s+/) .filter((t) => t.length > 2); } function buildTfVector(tokens: string[]): Map { const freq = new Map(); for (const t of tokens) { freq.set(t, (freq.get(t) ?? 0) + 1); } // TF = count / total const total = tokens.length; const tf = new Map(); for (const [term, count] of freq.entries()) { tf.set(term, count / total); } return tf; } function cosineSimilarity(a: Map, b: Map): number { let dot = 0; let normA = 0; let normB = 0; for (const [term, valA] of a.entries()) { const valB = b.get(term) ?? 0; dot += valA * valB; normA += valA * valA; } for (const valB of b.values()) { normB += valB * valB; } if (normA === 0 || normB === 0) return 0; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } function maxSimilarityToSet(candidate: string, existingTexts: string[]): number { if (existingTexts.length === 0) return 0; const candVec = buildTfVector(tokenize(candidate)); let maxSim = 0; for (const text of existingTexts) { const sim = cosineSimilarity(candVec, buildTfVector(tokenize(text))); if (sim > maxSim) maxSim = sim; } return maxSim; } // ─── Template YAML handling ───────────────────────────────────────────────── interface FewShotExample { user: string; assistant: string; } interface NegativeExample { input: string; bad_output: string; why_bad: string; } interface PromptTemplate { id: string; version: string; task_type?: string; system_prompt?: string; system_prompt_de?: string; user_template?: string; user_template_de?: string; few_shot_examples?: FewShotExample[]; negative_examples?: NegativeExample[]; variables?: string[]; [key: string]: unknown; } function loadTemplate(taskType: string): { template: PromptTemplate; filePath: string } | null { try { const files = readdirSync(TEMPLATES_DIR); const fileName = files.find((f) => f.replace('.yaml', '') === taskType); if (!fileName) return null; const filePath = join(TEMPLATES_DIR, fileName); const content = readFileSync(filePath, 'utf-8'); const template = yaml.load(content) as PromptTemplate; return { template, filePath }; } catch (err) { logger.error({ err, taskType }, 'Failed to load template'); return null; } } function bumpPatchVersion(version: string): string { const parts = version.split('.').map(Number); if (parts.length !== 3) return version; const [major, minor, patch] = parts; return `${major}.${minor}.${(patch ?? 0) + 1}`; } function bumpMinorVersion(version: string): string { const parts = version.split('.').map(Number); if (parts.length !== 3) return version; const [major, minor] = parts; return `${major}.${(minor ?? 0) + 1}.0`; } function writeTemplate(filePath: string, template: PromptTemplate): void { const content = yaml.dump(template, { lineWidth: 120, quotingType: '"' }); writeFileSync(filePath, content, 'utf-8'); } async function recordPromptVersion( template: PromptTemplate, filePath: string, notes: string, ): Promise { const content = readFileSync(filePath, 'utf-8'); await query( `INSERT INTO prompt_versions (prompt_id, version, task_type, template_yaml, active, deployed_by, notes) VALUES ($1, $2, $3, $4, true, 'few-shot-curator', $5) ON CONFLICT (prompt_id, version) DO NOTHING`, [template.id, template.version, template.id, content, notes], ); } // ─── Types ────────────────────────────────────────────────────────────────── interface HighConfOutput { id: string; task_type: string; input_text: string; output_text: string; confidence: number; } interface RejectedOutput { id: string; task_type: string; input_text: string; output_text: string; reviewer_notes: string | null; } // ─── Main job ─────────────────────────────────────────────────────────────── export async function runFewShotCurator(): Promise { const startedAt = Date.now(); logger.info('Few-shot curator job started'); // 1. Pull high-confidence approved outputs not yet processed const highConfResult = await query( `SELECT lc.id, lc.task_type, rq.input_text, lc.output_text, lc.confidence::float as confidence FROM llm_calls lc JOIN review_queue rq ON rq.call_id = lc.id WHERE lc.confidence >= $1 AND lc.status = 'approved' AND lc.output_text IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM few_shot_candidates fsc WHERE fsc.llm_call_id = lc.id ) ORDER BY lc.confidence DESC LIMIT 200`, [MIN_CONFIDENCE], ); // Also try without review_queue join (direct calls that bypassed review) const directHighConfResult = await query( `SELECT lc.id, lc.task_type, '' as input_text, lc.output_text, lc.confidence::float as confidence FROM llm_calls lc WHERE lc.confidence >= $1 AND lc.status = 'approved' AND lc.output_text IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM few_shot_candidates fsc WHERE fsc.llm_call_id = lc.id ) AND NOT EXISTS ( SELECT 1 FROM review_queue rq WHERE rq.call_id = lc.id ) ORDER BY lc.confidence DESC LIMIT 100`, [MIN_CONFIDENCE], ); const allHighConf = [...highConfResult.rows, ...directHighConfResult.rows]; logger.info({ count: allHighConf.length }, 'Pulled high-confidence outputs'); // 2. Pull rejected outputs for negative examples const rejectedResult = await query( `SELECT rq.id, rq.task_type, rq.input_text, rq.output_text, rq.reviewer_notes FROM review_queue rq WHERE rq.decision = 'rejected' AND rq.reviewed_at > now() - interval '7 days' AND NOT EXISTS ( SELECT 1 FROM few_shot_candidates fsc WHERE fsc.llm_call_id = rq.call_id AND fsc.is_negative = true )`, ); logger.info({ count: rejectedResult.rows.length }, 'Pulled rejected outputs for negative examples'); // 3. Group by task_type and check diversity const byTaskType = new Map(); for (const output of allHighConf) { const list = byTaskType.get(output.task_type) ?? []; list.push(output); byTaskType.set(output.task_type, list); } let totalPromoted = 0; let totalNegative = 0; // 4. Process each task_type for (const [taskType, outputs] of byTaskType.entries()) { const loaded = loadTemplate(taskType); if (!loaded) { // No template file for this task_type — store as candidates anyway for (const output of outputs) { await storeFewShotCandidate(output, null); } continue; } const { template, filePath } = loaded; const existingExamples = (template.few_shot_examples ?? []).map((e) => e.assistant); const goodCandidates: Array<{ output: HighConfOutput; similarity: number }> = []; for (const output of outputs) { // Skip too-long outputs if (output.output_text.length > MAX_FEW_SHOT_LENGTH) continue; const similarity = maxSimilarityToSet(output.output_text, existingExamples); await storeFewShotCandidate(output, similarity); if (similarity < SIMILARITY_THRESHOLD) { goodCandidates.push({ output, similarity }); } } // 5. Promote if enough diverse candidates if (goodCandidates.length >= CANDIDATES_REQUIRED) { // Pick the best (highest confidence, most diverse) goodCandidates.sort((a, b) => { // Score = confidence + (1 - similarity) → favor high confidence + low similarity const scoreA = a.output.confidence + (1 - a.similarity); const scoreB = b.output.confidence + (1 - b.similarity); return scoreB - scoreA; }); const best = goodCandidates[0]; if (!best) continue; const newExample: FewShotExample = { user: best.output.input_text || `[auto-curated from task: ${taskType}]`, assistant: best.output.output_text, }; const updatedTemplate: PromptTemplate = { ...template, version: bumpPatchVersion(template.version), few_shot_examples: [...(template.few_shot_examples ?? []), newExample], }; writeTemplate(filePath, updatedTemplate); await recordPromptVersion( updatedTemplate, filePath, `Added few-shot example (confidence: ${best.output.confidence.toFixed(1)}, similarity: ${best.similarity.toFixed(3)})`, ); // Mark as promoted in DB await query( `UPDATE few_shot_candidates SET promoted = true, promoted_at = now(), template_version = $1 WHERE llm_call_id = $2`, [updatedTemplate.version, best.output.id], ); totalPromoted++; logger.info( { taskType, version: updatedTemplate.version, confidence: best.output.confidence, similarity: best.similarity, }, 'Added few-shot example to template', ); } } // 6. Handle negative examples from rejections for (const rejected of rejectedResult.rows) { const loaded = loadTemplate(rejected.task_type); if (!loaded) continue; const { template, filePath } = loaded; const negExample: NegativeExample = { input: rejected.input_text, bad_output: rejected.output_text, why_bad: rejected.reviewer_notes ?? 'Rejected by human reviewer', }; const updatedTemplate: PromptTemplate = { ...template, version: bumpPatchVersion(template.version), negative_examples: [...(template.negative_examples ?? []), negExample], }; writeTemplate(filePath, updatedTemplate); await recordPromptVersion( updatedTemplate, filePath, `Added negative example from review_queue rejection`, ); // Store in few_shot_candidates as negative await query( `INSERT INTO few_shot_candidates (task_type, input_text, output_text, confidence, is_negative, negative_reason, promoted, promoted_at, template_version) VALUES ($1, $2, $3, 0, true, $4, true, now(), $5) ON CONFLICT DO NOTHING`, [ rejected.task_type, rejected.input_text, rejected.output_text, rejected.reviewer_notes ?? 'rejected', updatedTemplate.version, ], ); totalNegative++; logger.info({ taskType: rejected.task_type, version: updatedTemplate.version }, 'Added negative example to template'); } const durationMs = Date.now() - startedAt; logger.info({ totalPromoted, totalNegative, durationMs }, 'Few-shot curator job completed'); } async function storeFewShotCandidate( output: HighConfOutput, similarity: number | null, ): Promise { try { await query( `INSERT INTO few_shot_candidates (task_type, llm_call_id, input_text, output_text, confidence, similarity_to_existing) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, [ output.task_type, output.id, output.input_text, output.output_text, output.confidence, similarity, ], ); } catch (err) { logger.error({ err, outputId: output.id }, 'Failed to store few-shot candidate'); } } export { bumpMinorVersion };