diff --git a/src/renderer/utils/polishMetrics.ts b/src/renderer/utils/polishMetrics.ts index 74be560..4e8ba1c 100644 --- a/src/renderer/utils/polishMetrics.ts +++ b/src/renderer/utils/polishMetrics.ts @@ -1,7 +1,5 @@ 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 @@ -13,8 +11,15 @@ export interface PolishScore { dimensions: { passiveVoice: PolishDimension weaselWords: PolishDimension + adverbDensity: PolishDimension + filterWords: PolishDimension + showTell: PolishDimension sentenceRhythm: PolishDimension + paragraphRhythm: PolishDimension + repeatedStarters: PolishDimension wordVariety: PolishDimension + cliches: PolishDimension + overusedWords: PolishDimension dialogueTags: PolishDimension } } @@ -28,11 +33,11 @@ function rateScore(issueCount: number, wordCount: number, cap: number): number { 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 + .replace(/^#{1,6}\s+/gm, '') + .replace(/\*\*?|__?/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`[^`]+`/g, '') + .replace(/^>\s+/gm, '') } function countWordsIn(text: string): number { @@ -41,13 +46,39 @@ function countWordsIn(text: string): number { } function splitSentences(text: string): string[] { - return text - .split(/(?<=[.!?])\s+/) - .map(s => s.trim()) - .filter(s => s.length > 0) + 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) +// ── Weasel words ────────────────────────────────────────────────────────────── +const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|basically|actually|literally|clearly|obviously|simply|absolutely|totally|completely|certainly|probably|mostly|nearly|almost|somewhat|arguably|undeniably|remarkably|incredibly|extremely|highly)\b/gi + +// ── Adverb density ──────────────────────────────────────────────────────────── +const ADVERB_PATTERN = /\b\w+ly\b/gi +const ADVERB_EXCEPTIONS = new Set(['only','early','daily','likely','lonely','lovely','elderly','friendly','lively','deadly','holy','ugly','silly','hilly','belly','bully','rally','ally','jelly','fully','ully']) + +function adverbDensityScore(text: string, wordCount: number): { score: number; issueCount: number } { + const matches = (text.match(ADVERB_PATTERN) ?? []).filter(w => !ADVERB_EXCEPTIONS.has(w.toLowerCase())) + return { score: rateScore(matches.length, wordCount, 20), issueCount: matches.length } +} + +// ── Filter words ────────────────────────────────────────────────────────────── +const FILTER_PATTERN = /\b(she saw|he saw|she heard|he heard|she felt|he felt|she noticed|he noticed|she realized|he realized|she thought|he thought|she wondered|he wondered|she knew|he knew|she watched|he watched|she could see|he could see|she could hear|he could hear|she remembered|he remembered|she decided|he decided)\b/gi + +function filterWordScore(text: string, wordCount: number): { score: number; issueCount: number } { + const matches = (text.match(FILTER_PATTERN) ?? []).length + return { score: rateScore(matches, wordCount, 8), issueCount: matches } +} + +// ── Show / tell proxies ─────────────────────────────────────────────────────── +const EMOTION_ADJECTIVES = 'angry|sad|happy|afraid|scared|nervous|anxious|excited|lonely|confused|surprised|disappointed|embarrassed|ashamed|jealous|tired|bored|furious|terrified|delighted|miserable|guilty|proud|content|relieved|frustrated|hopeful|desperate|bitter|disgusted|horrified|depressed|irritated|overwhelmed|resentful|heartbroken|ecstatic|joyful|melancholy|sorrowful|gloomy|cheerful|peaceful|calm|worried|tense|uneasy|weary' +const SHOW_TELL_PATTERN = new RegExp(`\\b(was|were|is|are)\\s+(very\\s+)?(${EMOTION_ADJECTIVES})\\b`, 'gi') + +function showTellScore(text: string, wordCount: number): { score: number; issueCount: number } { + const matches = (text.match(SHOW_TELL_PATTERN) ?? []).length + return { score: rateScore(matches, wordCount, 10), issueCount: matches } +} + +// ── Sentence rhythm ─────────────────────────────────────────────────────────── function sentenceRhythmScore(text: string): { score: number; issueCount: number } { const sentences = splitSentences(text) if (sentences.length < 3) return { score: 100, issueCount: 0 } @@ -55,10 +86,8 @@ function sentenceRhythmScore(text: string): { score: number; issueCount: number 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 + const cv = mean > 0 ? Math.sqrt(variance) / 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++) { @@ -70,113 +99,176 @@ function sentenceRhythmScore(text: string): { score: number; issueCount: number } } - // 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 +// ── Paragraph rhythm ────────────────────────────────────────────────────────── +function paragraphRhythmScore(text: string): { score: number; issueCount: number } { + const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0) + if (paragraphs.length < 3) return { score: 100, issueCount: 0 } + + const lengths = paragraphs.map(p => countWordsIn(p)) + 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 cv = mean > 0 ? Math.sqrt(variance) / mean : 0 + + // Count wall-of-text paragraphs (> 150 words) and tiny runs (≤ 5 words each) + const walls = lengths.filter(l => l > 150).length + const score = Math.min(100, Math.round((cv / 0.8) * 100)) + return { score, issueCount: walls } +} + +// ── Repeated sentence starters ──────────────────────────────────────────────── +function repeatedStarterScore(text: string): { score: number; issueCount: number } { + const sentences = splitSentences(text) + if (sentences.length < 4) return { score: 100, issueCount: 0 } + + const starters = sentences.map(s => s.split(/\s+/)[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? '') + + let runs = 0 + let runLen = 1 + for (let i = 1; i < starters.length; i++) { + if (starters[i] === starters[i - 1] && starters[i] !== '') { + runLen++ + if (runLen === 3) runs++ + } else { + runLen = 1 + } + } + + // Also penalise pronoun-heavy openings overall + const PRONOUNS = new Set(['he','she','i','they','it','we','you']) + const pronounStarts = starters.filter(w => PRONOUNS.has(w)).length + const pronounRatio = pronounStarts / starters.length + + const runPenalty = Math.min(60, runs * 15) + const pronounPenalty = Math.max(0, Math.round((pronounRatio - 0.4) / 0.4 * 40)) + const score = Math.max(0, 100 - runPenalty - pronounPenalty) + return { score, issueCount: runs } +} + +// ── Word variety ────────────────────────────────────────────────────────────── +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']) + 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 WINDOW = 100 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 win = contentWords.slice(i, i + WINDOW) + ttrs.push(new Set(win).size / win.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 win = contentWords.slice(i, i + 50) const counts = new Map() - for (const w of window) counts.set(w, (counts.get(w) ?? 0) + 1) + for (const w of win) counts.set(w, (counts.get(w) ?? 0) + 1) for (const [, count] of counts) if (count >= 3) repeatedCount++ } + const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100))) return { score, issueCount: repeatedCount } } -// Dialogue tag quality: penalise said-bookisms and adverbs on tags +// ── Clichés ─────────────────────────────────────────────────────────────────── +const CLICHES = [ + 'heart pounding','blood ran cold','butterflies in','stomach churned','knees weak', + 'breath caught','time stood still','world fell away','tears streamed','voice cracked', + 'eyes widened','jaw dropped','skin crawled','hair stood on end','spine tingled', + 'heart sank','heart leapt','heart raced','mind went blank','hands trembled', + 'face drained','colour drained','blood froze','heart stopped','chest tightened', + 'lump in her throat','lump in his throat','pit of her stomach','pit of his stomach', + 'at the end of the day','all hell broke loose','couldn\'t believe her eyes', + 'couldn\'t believe his eyes','needle in a haystack','tip of the iceberg', + 'dead of night','ray of hope','last but not least','speak of the devil', + 'cold as ice','sharp as a knife','black as night','white as snow','red as blood', + 'silence was deafening','deafening silence','elephant in the room', +] +const CLICHE_PATTERN = new RegExp(CLICHES.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi') + +function clicheScore(text: string, wordCount: number): { score: number; issueCount: number } { + const matches = (text.match(CLICHE_PATTERN) ?? []).length + return { score: rateScore(matches, wordCount, 6), issueCount: matches } +} + +// ── Overused filler words ───────────────────────────────────────────────────── +const OVERUSED_PATTERN = /\b(just|suddenly|started to|began to|that|somehow|somehow|anyway|whatever|stuff|things|got|get)\b/gi + +function overusedWordScore(text: string, wordCount: number): { score: number; issueCount: number } { + const matches = (text.match(OVERUSED_PATTERN) ?? []).length + return { score: rateScore(matches, wordCount, 25), issueCount: matches } +} + +// ── Dialogue 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 + const total = (text.match(SAID_BOOKISMS) ?? []).length + (text.match(ADV_TAG) ?? []).length return { score: rateScore(total, wordCount, 8), issueCount: total } } +// ── Main export ─────────────────────────────────────────────────────────────── export function computePolishScore(rawText: string): PolishScore { const text = stripMarkdown(rawText) const wordCount = countWordsIn(text) + const skip = wordCount <= 10 - const passiveIssues = wordCount > 10 ? detectPassiveVoice(text).length : 0 - const weaselIssues = wordCount > 10 ? (text.match(WEASEL_PATTERN) ?? []).length : 0 + const dim = (label: string, score: number, issueCount: number): PolishDimension => ({ label, score, issueCount }) - const passive: PolishDimension = { - label: 'Passive voice', - score: rateScore(passiveIssues, wordCount, 12), - issueCount: passiveIssues - } + const passiveIssues = skip ? 0 : detectPassiveVoice(text).length + const weaselIssues = skip ? 0 : (text.match(WEASEL_PATTERN) ?? []).length - const weasel: PolishDimension = { - label: 'Weasel words', - score: rateScore(weaselIssues, wordCount, 10), - issueCount: weaselIssues - } + const adverb = skip ? { score: 100, issueCount: 0 } : adverbDensityScore(text, wordCount) + const filter = skip ? { score: 100, issueCount: 0 } : filterWordScore(text, wordCount) + const showTell = skip ? { score: 100, issueCount: 0 } : showTellScore(text, wordCount) + const sRhythm = skip ? { score: 100, issueCount: 0 } : sentenceRhythmScore(text) + const pRhythm = skip ? { score: 100, issueCount: 0 } : paragraphRhythmScore(text) + const starters = skip ? { score: 100, issueCount: 0 } : repeatedStarterScore(text) + const variety = skip ? { score: 100, issueCount: 0 } : wordVarietyScore(text) + const cliche = skip ? { score: 100, issueCount: 0 } : clicheScore(text, wordCount) + const overused = skip ? { score: 100, issueCount: 0 } : overusedWordScore(text, wordCount) + const dialogue = skip ? { score: 100, issueCount: 0 } : dialogueTagScore(text, wordCount) - 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 - ) + const scores = [ + rateScore(passiveIssues, wordCount, 12), + rateScore(weaselIssues, wordCount, 10), + adverb.score, + filter.score, + showTell.score, + sRhythm.score, + pRhythm.score, + starters.score, + variety.score, + cliche.score, + overused.score, + dialogue.score, + ] + const overall = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) return { overall, dimensions: { - passiveVoice: passive, - weaselWords: weasel, - sentenceRhythm: rhythm, - wordVariety: variety, - dialogueTags: dialogue + passiveVoice: dim('Passive voice', rateScore(passiveIssues, wordCount, 12), passiveIssues), + weaselWords: dim('Weasel words', rateScore(weaselIssues, wordCount, 10), weaselIssues), + adverbDensity: dim('Adverb density', adverb.score, adverb.issueCount), + filterWords: dim('Filter words', filter.score, filter.issueCount), + showTell: dim('Show / tell', showTell.score, showTell.issueCount), + sentenceRhythm: dim('Sentence rhythm', sRhythm.score, sRhythm.issueCount), + paragraphRhythm: dim('Paragraph rhythm', pRhythm.score, pRhythm.issueCount), + repeatedStarters: dim('Sentence starters', starters.score, starters.issueCount), + wordVariety: dim('Word variety', variety.score, variety.issueCount), + cliches: dim('Clichés', cliche.score, cliche.issueCount), + overusedWords: dim('Filler words', overused.score, overused.issueCount), + dialogueTags: dim('Dialogue tags', dialogue.score, dialogue.issueCount), } } }