/** * Rationale Purity Validator — lightweight Haiku check for garbage rationale input. * Catches: empty strings, random characters, "none", "n/a", "asdf", non-English, lazy input. */ import { getClaudeClient } from "../ai/claude-client"; export interface RationaleEntry { index: number; rationale: string; } export interface RationaleValidationResult { valid: boolean; rejected: Array<{ index: number; reason: string }>; } const SYSTEM_PROMPT = `You validate whether subtitle editing rationales are meaningful. Each rationale should briefly explain WHY a change was made to a subtitle. REJECT if the rationale is: - Empty, whitespace-only, or very short (1-2 characters) - Random characters or keyboard smashing (e.g., "asdf", "jkl;", "xxx", "aaa") - Placeholder text (e.g., "none", "n/a", "test", "todo", ".", "-", "...") - Not in English - Nonsensical or clearly not related to subtitle editing ACCEPT if the rationale: - Briefly explains a real editing decision (even short ones like "typo fix", "timing adjusted", "split for readability") - References a specific change type (e.g., "fixed CPL", "corrected speaker", "adjusted overlap") Respond with JSON: \`\`\`json { "results": [ { "index": , "valid": , "reason": "" } ] } \`\`\``; // Quick local pre-filter for obviously bad rationales (no API call needed) const GARBAGE_PATTERNS = [ /^[\s\W]*$/, // Only whitespace/punctuation /^(.)\1{2,}$/, // Repeated single char: "aaa", "xxx" /^[a-z]{1,4}$/i, // 1-4 random letters /^(none|n\/a|na|test|todo|tbd|idk|ok|yes|no|null|undefined)$/i, /^\.{1,3}$/, // Just dots /^-{1,3}$/, // Just dashes ]; export function quickRejectRationale(rationale: string): string | null { const trimmed = rationale.trim(); if (trimmed.length === 0) return "Empty rationale"; if (trimmed.length < 3) return "Too short — must explain the change"; for (const pattern of GARBAGE_PATTERNS) { if (pattern.test(trimmed)) return "Invalid rationale — must explain why the change was made"; } return null; // Passes quick check } export async function validateRationales( entries: RationaleEntry[], ): Promise { // Quick local rejection first const rejected: Array<{ index: number; reason: string }> = []; const needsAiCheck: RationaleEntry[] = []; for (const entry of entries) { const quickReason = quickRejectRationale(entry.rationale); if (quickReason) { rejected.push({ index: entry.index, reason: quickReason }); } else { needsAiCheck.push(entry); } } // If all rejected locally, skip AI if (needsAiCheck.length === 0) { return { valid: rejected.length === 0, rejected }; } // AI check with Haiku for the rest try { const { client } = getClaudeClient(); const prompt = needsAiCheck .map((e) => `Index ${e.index}: "${e.rationale}"`) .join("\n"); const response = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 1024, system: SYSTEM_PROMPT, messages: [{ role: "user", content: `Validate these ${needsAiCheck.length} rationales:\n\n${prompt}` }], }); const text = response.content .filter((b: any) => b.type === "text") .map((b: any) => b.text) .join(""); const jsonMatch = text.match(/\{[\s\S]*\}/); if (jsonMatch) { const parsed = JSON.parse(jsonMatch[0]); if (parsed.results) { for (const r of parsed.results) { if (!r.valid) { rejected.push({ index: r.index, reason: r.reason || "Rejected by AI review" }); } } } } } catch (err) { console.error("[RationaleValidator] AI check failed, accepting all:", err); // Non-fatal — if Haiku is unavailable, accept (only local checks apply) } return { valid: rejected.length === 0, rejected }; }