gts-x / src /lib /editor /rationale-validator.ts
JAMES HAN
feat: add GCP Vertex AI auth for Claude (alongside direct API key)
5e35f2a
Raw
History Blame Contribute Delete
3.9 kB
/**
* 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": <number>, "valid": <boolean>, "reason": "<only if invalid>" }
]
}
\`\`\``;
// 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<RationaleValidationResult> {
// 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 };
}