import { detectPassiveVoice } from './passiveVoice' const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|just|basically|actually|literally|clearly|obviously|simply|absolutely|totally|completely|certainly|probably|mostly|nearly|almost|somewhat|arguably|undeniably|remarkably|incredibly|extremely|highly)\b/gi export interface PolishDimension { label: string score: number // 0–100 issueCount: number } export interface PolishScore { overall: number dimensions: { passiveVoice: PolishDimension weaselWords: PolishDimension sentenceRhythm: PolishDimension wordVariety: PolishDimension dialogueTags: PolishDimension } } // Score a rate (issues per 1000 words): 0 issues = 100, penalises linearly up to `cap` function rateScore(issueCount: number, wordCount: number, cap: number): number { if (wordCount === 0) return 100 const rate = (issueCount / wordCount) * 1000 return Math.max(0, Math.round(100 - (rate / cap) * 100)) } function stripMarkdown(text: string): string { return text .replace(/^#{1,6}\s+/gm, '') // headings .replace(/\*\*?|__?/g, '') // bold/italic .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links .replace(/`[^`]+`/g, '') // inline code .replace(/^>\s+/gm, '') // blockquotes } function countWordsIn(text: string): number { const t = text.trim() return t === '' ? 0 : t.split(/\s+/).length } function splitSentences(text: string): string[] { return text .split(/(?<=[.!?])\s+/) .map(s => s.trim()) .filter(s => s.length > 0) } // Sentence rhythm: coefficient of variation of sentence lengths (higher variance = better rhythm) function sentenceRhythmScore(text: string): { score: number; issueCount: number } { const sentences = splitSentences(text) if (sentences.length < 3) return { score: 100, issueCount: 0 } const lengths = sentences.map(s => s.split(/\s+/).length) const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length const variance = lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length const stdDev = Math.sqrt(variance) const cv = mean > 0 ? stdDev / mean : 0 // Count "monotonous runs": 3+ consecutive sentences within 2 words of each other in length let runsCount = 0 let runLen = 1 for (let i = 1; i < lengths.length; i++) { if (Math.abs(lengths[i] - lengths[i - 1]) <= 2) { runLen++ if (runLen === 3) runsCount++ } else { runLen = 1 } } // cv >= 0.6 = great rhythm; 0.3 = mediocre; < 0.3 = monotonous const score = Math.min(100, Math.round((cv / 0.6) * 100)) return { score, issueCount: runsCount } } // Word variety: type-token ratio in sliding windows of 100 words function wordVarietyScore(text: string): { score: number; issueCount: number } { const words = text.toLowerCase().match(/\b[a-z']+\b/g) ?? [] if (words.length < 20) return { score: 100, issueCount: 0 } const WINDOW = 100 const SKIP_WORDS = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','is','was','are','were','it','he','she','they','i','you','we','be','been','being','that','this','those','these','have','has','had','do','did','does','not','so','as','if','by','from','into','than','then','when','where','who','which','what','his','her','their','its','my','your','our','up','out','about','after','before','all','some','one','no','more','also']) const contentWords = words.filter(w => !SKIP_WORDS.has(w)) if (contentWords.length < 20) return { score: 100, issueCount: 0 } const ttrs: number[] = [] for (let i = 0; i + WINDOW <= contentWords.length; i += Math.floor(WINDOW / 2)) { const window = contentWords.slice(i, i + WINDOW) const unique = new Set(window).size ttrs.push(unique / window.length) } const avgTTR = ttrs.reduce((a, b) => a + b, 0) / ttrs.length // TTR >= 0.7 = rich; 0.4 = acceptable; < 0.4 = repetitive const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100))) // Count repeated content words within 50-word windows let repeatedCount = 0 for (let i = 0; i + 50 <= contentWords.length; i += 25) { const window = contentWords.slice(i, i + 50) const counts = new Map() for (const w of window) counts.set(w, (counts.get(w) ?? 0) + 1) for (const [, count] of counts) if (count >= 3) repeatedCount++ } return { score, issueCount: repeatedCount } } // Dialogue tag quality: penalise said-bookisms and adverbs on tags const SAID_BOOKISMS = /\b(hissed|snapped|exclaimed|barked|spat|snarled|growled|shrieked|whined|croaked|breathed|murmured)\b/gi const ADV_TAG = /\b(said|asked|replied|whispered)\s+\w+ly\b/gi function dialogueTagScore(text: string, wordCount: number): { score: number; issueCount: number } { const bookisms = (text.match(SAID_BOOKISMS) ?? []).length const advTags = (text.match(ADV_TAG) ?? []).length const total = bookisms + advTags return { score: rateScore(total, wordCount, 8), issueCount: total } } export function computePolishScore(rawText: string): PolishScore { const text = stripMarkdown(rawText) const wordCount = countWordsIn(text) const passiveIssues = wordCount > 10 ? detectPassiveVoice(text).length : 0 const weaselIssues = wordCount > 10 ? (text.match(WEASEL_PATTERN) ?? []).length : 0 const passive: PolishDimension = { label: 'Passive voice', score: rateScore(passiveIssues, wordCount, 12), issueCount: passiveIssues } const weasel: PolishDimension = { label: 'Weasel words', score: rateScore(weaselIssues, wordCount, 10), issueCount: weaselIssues } const rhythmResult = sentenceRhythmScore(text) const rhythm: PolishDimension = { label: 'Sentence rhythm', score: rhythmResult.score, issueCount: rhythmResult.issueCount } const varietyResult = wordVarietyScore(text) const variety: PolishDimension = { label: 'Word variety', score: varietyResult.score, issueCount: varietyResult.issueCount } const dialogueResult = dialogueTagScore(text, wordCount) const dialogue: PolishDimension = { label: 'Dialogue tags', score: dialogueResult.score, issueCount: dialogueResult.issueCount } const weights = { passive: 0.2, weasel: 0.2, rhythm: 0.25, variety: 0.2, dialogue: 0.15 } const overall = Math.round( passive.score * weights.passive + weasel.score * weights.weasel + rhythm.score * weights.rhythm + variety.score * weights.variety + dialogue.score * weights.dialogue ) return { overall, dimensions: { passiveVoice: passive, weaselWords: weasel, sentenceRhythm: rhythm, wordVariety: variety, dialogueTags: dialogue } } }