401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
/**
|
|
* 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<string, number> {
|
|
const freq = new Map<string, number>();
|
|
for (const t of tokens) {
|
|
freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
}
|
|
// TF = count / total
|
|
const total = tokens.length;
|
|
const tf = new Map<string, number>();
|
|
for (const [term, count] of freq.entries()) {
|
|
tf.set(term, count / total);
|
|
}
|
|
return tf;
|
|
}
|
|
|
|
function cosineSimilarity(a: Map<string, number>, b: Map<string, number>): 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<void> {
|
|
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<void> {
|
|
const startedAt = Date.now();
|
|
logger.info('Few-shot curator job started');
|
|
|
|
// 1. Pull high-confidence approved outputs not yet processed
|
|
const highConfResult = await query<HighConfOutput>(
|
|
`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<HighConfOutput>(
|
|
`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<RejectedOutput>(
|
|
`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<string, HighConfOutput[]>();
|
|
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<void> {
|
|
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 };
|