src / detector.ts
export interface DetectionSignal {
name: string;
score: number; // 0-100, higher = more AI-like
weight: number;
detail: string;
}
export interface DetectionResult {
score: number; // 0-100 weighted average
verdict: string;
signals: DetectionSignal[];
flaggedPhrasesFound: string[];
}
const FLAGGED_PHRASES: string[] = [
"certainly", "delve", "it's worth noting", "as an ai", "i cannot",
"moreover", "furthermore", "in conclusion", "it is important to note",
"in summary", "to summarize", "in the realm of", "nuanced", "comprehensive",
"leverage", "utilize", "paradigm", "robust", "seamlessly", "cutting-edge",
"game-changer", "at the end of the day", "when it comes to", "in today's world",
"it goes without saying", "absolutely", "of course", "it's crucial to",
"it's essential to", "please note that", "rest assured", "keep in mind",
"as previously mentioned", "as mentioned above", "to be fair",
"in other words", "all in all", "last but not least", "make no mistake",
"navigate", "it is worth noting",
];
const TRANSITION_PHRASES: string[] = [
"furthermore", "moreover", "additionally", "in conclusion", "therefore",
"however", "nevertheless", "consequently", "subsequently", "nonetheless",
"in contrast", "on the contrary", "to summarize", "in summary", "to conclude",
"as a result", "in addition", "on the other hand", "for instance", "for example",
];
function splitSentences(text: string): string[] {
return text
.split(/(?<=[.!?])\s+/)
.map(s => s.trim())
.filter(s => s.length > 0);
}
function splitParagraphs(text: string): string[] {
return text
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(p => p.length > 0);
}
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(w => w.length > 0).length;
}
function stdDev(values: number[]): number {
if (values.length < 2) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
return Math.sqrt(variance);
}
function flaggedPhraseDensityScore(text: string): { score: number; detail: string; found: string[] } {
const lower = text.toLowerCase();
const words = countWords(text);
if (words === 0) return { score: 0, detail: "empty text", found: [] };
let totalCount = 0;
const found: string[] = [];
for (const phrase of FLAGGED_PHRASES) {
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matches = lower.match(new RegExp(`\\b${escaped}\\b`, "g"));
if (matches) {
totalCount += matches.length;
found.push(phrase);
}
}
// phrases per 100 words × 40 → score capped at 100
const density = (totalCount / Math.max(words / 100, 1));
const score = Math.min(100, Math.round(density * 40));
return {
score,
detail: found.length > 0 ? `Found: ${found.slice(0, 5).join(", ")}` : "No flagged phrases",
found,
};
}
function sentenceBurstinessScore(text: string): { score: number; detail: string } {
const sentences = splitSentences(text);
if (sentences.length < 2) return { score: 50, detail: "insufficient sentences for analysis" };
const lengths = sentences.map(s => countWords(s));
const sd = stdDev(lengths);
// stdDev 0 → score 100 (fully uniform = AI); stdDev 15+ → score 0 (varied = human)
const score = Math.max(0, Math.round(100 - sd * 6.5));
return {
score,
detail: `Sentence std dev: ${sd.toFixed(1)} words (lower = more uniform = more AI-like)`,
};
}
function transitionWordDensityScore(text: string): { score: number; detail: string } {
const lower = text.toLowerCase();
const sentences = splitSentences(text);
if (sentences.length === 0) return { score: 0, detail: "no sentences" };
let count = 0;
const found: string[] = [];
for (const phrase of TRANSITION_PHRASES) {
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matches = lower.match(new RegExp(`\\b${escaped}\\b`, "g"));
if (matches) {
count += matches.length;
found.push(phrase);
}
}
const perSentence = count / sentences.length;
const score = Math.min(100, Math.round(perSentence * 150));
return {
score,
detail: found.length > 0
? `${count} transition(s) across ${sentences.length} sentences`
: "no transition words",
};
}
function paragraphSymmetryScore(text: string): { score: number; detail: string } {
const paragraphs = splitParagraphs(text);
if (paragraphs.length < 2) return { score: 50, detail: "single paragraph — insufficient data" };
const lengths = paragraphs.map(p => countWords(p));
const sd = stdDev(lengths);
// stdDev 0 → score 100 (uniform = AI); stdDev 40+ → score 0 (varied = human)
const score = Math.max(0, Math.round(100 - sd * 2.5));
return {
score,
detail: `Paragraph std dev: ${sd.toFixed(1)} words (lower = more uniform = more AI-like)`,
};
}
function avgSentenceLengthScore(text: string): { score: number; detail: string } {
const sentences = splitSentences(text);
if (sentences.length === 0) return { score: 0, detail: "no sentences" };
const lengths = sentences.map(s => countWords(s));
const avg = lengths.reduce((a, b) => a + b, 0) / lengths.length;
// Peak AI range: 18–25 words (center: 21.5). Falls off on both sides.
const distFromPeak = Math.abs(avg - 21.5);
const score = Math.max(0, Math.round(100 - distFromPeak * 8));
return {
score,
detail: `Avg sentence length: ${avg.toFixed(1)} words (18–25 is typical AI range)`,
};
}
function getVerdict(score: number): string {
if (score <= 30) return "likely human";
if (score <= 60) return "mixed / uncertain";
if (score <= 80) return "likely AI-written";
return "almost certainly AI-written";
}
export function detectAiText(text: string): DetectionResult {
const phrase = flaggedPhraseDensityScore(text);
const burstiness = sentenceBurstinessScore(text);
const transition = transitionWordDensityScore(text);
const symmetry = paragraphSymmetryScore(text);
const avgLen = avgSentenceLengthScore(text);
const signals: DetectionSignal[] = [
{ name: "flagged_phrase_density", score: phrase.score, weight: 0.30, detail: phrase.detail },
{ name: "sentence_burstiness", score: burstiness.score, weight: 0.25, detail: burstiness.detail },
{ name: "transition_word_density",score: transition.score, weight: 0.20, detail: transition.detail },
{ name: "paragraph_symmetry", score: symmetry.score, weight: 0.15, detail: symmetry.detail },
{ name: "avg_sentence_length", score: avgLen.score, weight: 0.10, detail: avgLen.detail },
];
const score = Math.round(
signals.reduce((sum, s) => sum + s.score * s.weight, 0)
);
return {
score,
verdict: getVerdict(score),
signals,
flaggedPhrasesFound: phrase.found,
};
}