diff --git a/src/lib/ai/quality-gates.ts b/src/lib/ai/quality-gates.ts index fc40354..61bceb9 100644 --- a/src/lib/ai/quality-gates.ts +++ b/src/lib/ai/quality-gates.ts @@ -4,6 +4,7 @@ import { BANNED_AI_TELLS } from "./persona-prompts"; export interface QualityGateResult { passed: boolean; bannedWordsFound: string[]; + softWordsFound: string[]; aiDetectorScore: number; reasons: string[]; } @@ -30,24 +31,40 @@ export function findBannedAiTells(text: string): string[] { return Array.from(new Set(hits)); } -async function findCustomBannedTerms(text: string): Promise { +interface BannedTermRow { + term: string; + ban_level: "hard" | "soft"; +} + +async function fetchCustomBannedTerms(): Promise { try { const sb = createAdminClient(); - const { data } = await sb.from("banned_terms").select("term, ban_level, is_active").eq("is_active", true); - const hits: string[] = []; - const lower = " " + text.toLowerCase() + " "; - for (const row of data || []) { - const term = String((row as any).term || "").toLowerCase(); - if (!term) continue; - const pattern = new RegExp(`\\b${escapeRegex(term)}\\b`); - if (pattern.test(lower)) hits.push((row as any).term); - } - return hits; + const { data } = await sb + .from("banned_terms") + .select("term, ban_level") + .eq("is_active", true); + return ((data || []) as any[]).map((r) => ({ + term: String(r.term || ""), + ban_level: r.ban_level === "soft" ? "soft" : "hard", + })); } catch { return []; } } +function matchTermInText(term: string, text: string): boolean { + const t = term.toLowerCase(); + if (!t) return false; + const haystack = " " + text.toLowerCase() + " "; + // word boundary for single-word terms; substring for multi-word phrases or + // hyphenated terms where \b doesn't bind cleanly across the hyphen + const isPhrase = t.includes(" "); + const pattern = isPhrase + ? new RegExp(escapeRegex(t)) + : new RegExp(`(^|[^a-z0-9])${escapeRegex(t)}([^a-z0-9]|$)`); + return pattern.test(haystack); +} + export function aiDetectorHeuristic(text: string): number { const stripped = stripHtml(text); const words = stripped.split(/\s+/).filter(Boolean); @@ -101,22 +118,41 @@ export async function runQualityGates( const all = `${title}\n\n${excerpt}\n\n${body}`; const aiTells = findBannedAiTells(all); - const custom = await findCustomBannedTerms(all); - const bannedWords = Array.from(new Set([...aiTells, ...custom])); - const detectorScore = aiDetectorHeuristic(body); + const customTerms = await fetchCustomBannedTerms(); + const hardHits: string[] = []; + const softHits: string[] = []; + for (const row of customTerms) { + if (!matchTermInText(row.term, all)) continue; + if (row.ban_level === "hard") hardHits.push(row.term); + else softHits.push(row.term); + } + + const bannedWords = Array.from(new Set([...aiTells, ...hardHits])); + const softWords = Array.from(new Set(softHits)); + + // Base heuristic + soft-term contribution: each soft hit adds 0.05, + // capped at +0.30 so it can shove a borderline output over the + // threshold without single-handedly failing it. + const baseScore = aiDetectorHeuristic(body); + const softContribution = Math.min(0.3, softWords.length * 0.05); + const detectorScore = Math.min(1, baseScore + softContribution); const reasons: string[] = []; if (bannedWords.length > 0) { reasons.push(`banned phrases: ${bannedWords.slice(0, 5).join(", ")}${bannedWords.length > 5 ? " …" : ""}`); } if (detectorScore > detectorThreshold) { - reasons.push(`AI-detector score ${detectorScore.toFixed(2)} > threshold ${detectorThreshold.toFixed(2)}`); + reasons.push( + `AI-detector score ${detectorScore.toFixed(2)} > threshold ${detectorThreshold.toFixed(2)}` + + (softWords.length ? ` (soft hits: ${softWords.slice(0, 5).join(", ")}${softWords.length > 5 ? " …" : ""})` : "") + ); } return { passed: reasons.length === 0, bannedWordsFound: bannedWords, + softWordsFound: softWords, aiDetectorScore: detectorScore, reasons, };