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 {
|
export interface QualityGateResult {
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
bannedWordsFound: string[];
|
bannedWordsFound: string[];
|
||||||
|
softWordsFound: string[];
|
||||||
aiDetectorScore: number;
|
aiDetectorScore: number;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
}
|
}
|
||||||
@@ -30,24 +31,40 @@ export function findBannedAiTells(text: string): string[] {
|
|||||||
return Array.from(new Set(hits));
|
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 {
|
try {
|
||||||
const sb = createAdminClient();
|
const sb = createAdminClient();
|
||||||
const { data } = await sb.from("banned_terms").select("term, ban_level, is_active").eq("is_active", true);
|
const { data } = await sb
|
||||||
const hits: string[] = [];
|
.from("banned_terms")
|
||||||
const lower = " " + text.toLowerCase() + " ";
|
.select("term, ban_level")
|
||||||
for (const row of data || []) {
|
.eq("is_active", true);
|
||||||
const term = String((row as any).term || "").toLowerCase();
|
return ((data || []) as any[]).map((r) => ({
|
||||||
if (!term) continue;
|
term: String(r.term || ""),
|
||||||
const pattern = new RegExp(`\\b${escapeRegex(term)}\\b`);
|
ban_level: r.ban_level === "soft" ? "soft" : "hard",
|
||||||
if (pattern.test(lower)) hits.push((row as any).term);
|
}));
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
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 {
|
export function aiDetectorHeuristic(text: string): number {
|
||||||
const stripped = stripHtml(text);
|
const stripped = stripHtml(text);
|
||||||
const words = stripped.split(/\s+/).filter(Boolean);
|
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 all = `${title}\n\n${excerpt}\n\n${body}`;
|
||||||
|
|
||||||
const aiTells = findBannedAiTells(all);
|
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[] = [];
|
const reasons: string[] = [];
|
||||||
if (bannedWords.length > 0) {
|
if (bannedWords.length > 0) {
|
||||||
reasons.push(`banned phrases: ${bannedWords.slice(0, 5).join(", ")}${bannedWords.length > 5 ? " …" : ""}`);
|
reasons.push(`banned phrases: ${bannedWords.slice(0, 5).join(", ")}${bannedWords.length > 5 ? " …" : ""}`);
|
||||||
}
|
}
|
||||||
if (detectorScore > detectorThreshold) {
|
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 {
|
return {
|
||||||
passed: reasons.length === 0,
|
passed: reasons.length === 0,
|
||||||
bannedWordsFound: bannedWords,
|
bannedWordsFound: bannedWords,
|
||||||
|
softWordsFound: softWords,
|
||||||
aiDetectorScore: detectorScore,
|
aiDetectorScore: detectorScore,
|
||||||
reasons,
|
reasons,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user