Files
hohoff/src/renderer/utils/polishMetrics.ts
2026-06-14 19:59:51 +10:00

183 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 // 0100
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<string, number>()
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
}
}
}