Phase B+: severity-aware quality gates (hard banned terms vs soft)
src/lib/ai/quality-gates.ts:
- Read bb.banned_terms with the ban_level column so admins can mark
a term as hard (instant fail) or soft (score nudge).
- Hard hits join the hard-coded AI_TELLS list to instant-fail the
rewrite (status=failed_quality, regenerate).
- Soft hits each contribute +0.05 to the heuristic AI-detector score,
capped at +0.30, so a borderline-slop article can be tipped over
the 0.65 threshold without single-handedly being rejected.
- Multi-word phrases are matched as substrings; single words use a
boundary regex that handles hyphenated terms cleanly.
- QualityGateResult now exposes softWordsFound alongside the existing
bannedWordsFound; failure_reason text includes the soft list when
the score trip is what failed.
44 terms seeded in bb.banned_terms (23 hard hype words like
"revolutionary", "industry-leading", "world-class", plus 7 PR-speak
openers like "pleased to announce"; 21 soft like "innovative",
"leverages", "robust", "transform").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string[]> {
|
||||
interface BannedTermRow {
|
||||
term: string;
|
||||
ban_level: "hard" | "soft";
|
||||
}
|
||||
|
||||
async function fetchCustomBannedTerms(): Promise<BannedTermRow[]> {
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user