clickable feedback

This commit is contained in:
TC
2026-06-14 20:22:34 +10:00
parent 5bb1212efd
commit 497c29c792
5 changed files with 264 additions and 145 deletions

View File

@@ -537,6 +537,7 @@
--polish-good: #1D9E75; --polish-good: #1D9E75;
--polish-mid: #EF9F27; --polish-mid: #EF9F27;
--polish-low: #E24B4A; --polish-low: #E24B4A;
--polish-active: #378ADD;
} }
.pm-widget { .pm-widget {
@@ -577,6 +578,25 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
border-radius: 3px;
padding: 1px 3px;
margin: 0 -3px;
}
.pm-dim--clickable {
cursor: pointer;
}
.pm-dim--clickable:hover {
background: rgba(255, 255, 255, 0.05);
}
.pm-dim--active {
background: rgba(55, 138, 221, 0.08);
}
.pm-dim--active .pm-dim-label {
color: var(--polish-active);
} }
.pm-dim-label { .pm-dim-label {
@@ -584,6 +604,7 @@
color: var(--text-muted); color: var(--text-muted);
width: 112px; width: 112px;
flex-shrink: 0; flex-shrink: 0;
transition: color 0.2s;
} }
.pm-bar-track { .pm-bar-track {

View File

@@ -3,7 +3,7 @@ import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { useEditorStore } from '../../store/editorStore' import { useEditorStore } from '../../store/editorStore'
import type { TextAnnotation } from '../../types/editor' import type { TextAnnotation } from '../../types/editor'
import type { PolishScore } from '../../utils/polishMetrics' import type { PolishScore, PolishDimension } from '../../utils/polishMetrics'
import { import {
tooltipAnalysisCache, tooltipAnalysisCache,
cancelPendingDismiss, cancelPendingDismiss,
@@ -28,6 +28,7 @@ function badgeColor(type: TextAnnotation['type']): string {
case 'custom': return 'rgba(30, 200, 150, 0.8)' case 'custom': return 'rgba(30, 200, 150, 0.8)'
case 'user_comment': return 'rgba(240, 100, 180, 0.85)' case 'user_comment': return 'rgba(240, 100, 180, 0.85)'
case 'document_note': return 'rgba(80, 180, 240, 0.85)' case 'document_note': return 'rgba(80, 180, 240, 0.85)'
case 'polish': return 'rgba(55, 138, 221, 0.75)'
} }
} }
@@ -274,7 +275,39 @@ function scoreColor(score: number): string {
} }
function PolishMeter({ score }: { score: PolishScore }): JSX.Element { function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
const dims = Object.values(score.dimensions) const [activeKey, setActiveKey] = useState<string | null>(null)
const { setAnnotations, clearAnnotations } = useEditorStore()
const dims = Object.entries(score.dimensions) as [string, PolishDimension][]
function handleDimClick(key: string, dim: PolishDimension): void {
if (activeKey === key) {
setActiveKey(null)
clearAnnotations()
return
}
if (dim.matches.length === 0) return
setActiveKey(key)
const annotations: TextAnnotation[] = dim.matches.map((m, i) => ({
id: `polish-${key}-${i}`,
type: 'polish' as const,
from: m.from,
to: m.to,
matchedText: m.text,
message: `${dim.label}: "${m.text.slice(0, 60)}${m.text.length > 60 ? '…' : ''}"`,
}))
setAnnotations(annotations)
}
// Clear active state when score changes (new file or reanalysis)
const prevOverall = useRef(score.overall)
useEffect(() => {
if (prevOverall.current !== score.overall) {
prevOverall.current = score.overall
setActiveKey(null)
}
}, [score.overall])
return ( return (
<div className="pm-widget"> <div className="pm-widget">
<div className="pm-header"> <div className="pm-header">
@@ -282,18 +315,27 @@ function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
<span className="pm-overall" style={{ color: scoreColor(score.overall) }}>{score.overall}</span> <span className="pm-overall" style={{ color: scoreColor(score.overall) }}>{score.overall}</span>
</div> </div>
<div className="pm-dims"> <div className="pm-dims">
{dims.map(dim => ( {dims.map(([key, dim]) => {
<div key={dim.label} className="pm-dim"> const isActive = activeKey === key
const clickable = dim.matches.length > 0
return (
<div
key={key}
className={`pm-dim${clickable ? ' pm-dim--clickable' : ''}${isActive ? ' pm-dim--active' : ''}`}
onClick={() => clickable && handleDimClick(key, dim)}
title={clickable ? `Click to highlight ${dim.issueCount} instance${dim.issueCount !== 1 ? 's' : ''}` : undefined}
>
<span className="pm-dim-label">{dim.label}</span> <span className="pm-dim-label">{dim.label}</span>
<div className="pm-bar-track"> <div className="pm-bar-track">
<div <div
className="pm-bar-fill" className="pm-bar-fill"
style={{ width: `${dim.score}%`, background: scoreColor(dim.score) }} style={{ width: `${dim.score}%`, background: isActive ? 'var(--polish-active)' : scoreColor(dim.score) }}
/> />
</div> </div>
<span className="pm-dim-score">{dim.score}</span> <span className="pm-dim-score" style={{ color: isActive ? 'var(--polish-active)' : undefined }}>{dim.score}</span>
</div> </div>
))} )
})}
</div> </div>
</div> </div>
) )

View File

@@ -41,7 +41,7 @@ export interface ChatSession {
messages: ChatMessage[] messages: ChatMessage[]
} }
export type AnnotationType = 'passive_voice' | 'past_progressive' | 'weak_verbs' | 'cliches' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment' | 'document_note' export type AnnotationType = 'passive_voice' | 'past_progressive' | 'weak_verbs' | 'cliches' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment' | 'document_note' | 'polish'
export interface TextAnnotation { export interface TextAnnotation {
id: string id: string

View File

@@ -1,9 +1,16 @@
import { detectPassiveVoice } from './passiveVoice' import { detectPassiveVoice } from './passiveVoice'
export interface PolishMatch {
from: number
to: number
text: string
}
export interface PolishDimension { export interface PolishDimension {
label: string label: string
score: number // 0100 score: number
issueCount: number issueCount: number
matches: PolishMatch[]
} }
export interface PolishScore { export interface PolishScore {
@@ -24,29 +31,62 @@ export interface PolishScore {
} }
} }
// Score a rate (issues per 1000 words): 0 issues = 100, penalises linearly up to `cap`
function rateScore(issueCount: number, wordCount: number, cap: number): number { function rateScore(issueCount: number, wordCount: number, cap: number): number {
if (wordCount === 0) return 100 if (wordCount === 0) return 100
const rate = (issueCount / wordCount) * 1000 const rate = (issueCount / wordCount) * 1000
return Math.max(0, Math.round(100 - (rate / cap) * 100)) return Math.max(0, Math.round(100 - (rate / cap) * 100))
} }
function stripMarkdown(text: string): string {
return text
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*?|__?/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/^>\s+/gm, '')
}
function countWordsIn(text: string): number { function countWordsIn(text: string): number {
const t = text.trim() const t = text.trim()
return t === '' ? 0 : t.split(/\s+/).length return t === '' ? 0 : t.split(/\s+/).length
} }
function splitSentences(text: string): string[] { // Returns {text, from, to} for each sentence, positions in the raw text
return text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(s => s.length > 0) function splitSentencesWithPos(text: string): Array<{ text: string; from: number; to: number }> {
const result: Array<{ text: string; from: number; to: number }> = []
const re = /[.!?]+\s+/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const to = m.index + m[0].length
const s = text.slice(last, to).trim()
if (s.length > 0) result.push({ text: s, from: last, to })
last = to
}
if (last < text.length) {
const s = text.slice(last).trim()
if (s.length > 0) result.push({ text: s, from: last, to: text.length })
}
return result
}
function splitParasWithPos(text: string): Array<{ text: string; from: number; to: number }> {
const result: Array<{ text: string; from: number; to: number }> = []
const re = /\n{2,}/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const s = text.slice(last, m.index).trim()
if (s.length > 0) result.push({ text: s, from: last, to: m.index })
last = m.index + m[0].length
}
if (last < text.length) {
const s = text.slice(last).trim()
if (s.length > 0) result.push({ text: s, from: last, to: text.length })
}
return result
}
function regexMatches(text: string, pattern: RegExp): PolishMatch[] {
const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g')
re.lastIndex = 0
const result: PolishMatch[] = []
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
result.push({ from: m.index, to: m.index + m[0].length, text: m[0] })
}
return result
} }
// ── Weasel words ────────────────────────────────────────────────────────────── // ── Weasel words ──────────────────────────────────────────────────────────────
@@ -54,128 +94,153 @@ const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|basically|actually|li
// ── Adverb density ──────────────────────────────────────────────────────────── // ── Adverb density ────────────────────────────────────────────────────────────
const ADVERB_PATTERN = /\b\w+ly\b/gi 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']) 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'])
function adverbDensityScore(text: string, wordCount: number): { score: number; issueCount: number } { function adverbMatches(text: string): PolishMatch[] {
const matches = (text.match(ADVERB_PATTERN) ?? []).filter(w => !ADVERB_EXCEPTIONS.has(w.toLowerCase())) return regexMatches(text, ADVERB_PATTERN).filter(m => !ADVERB_EXCEPTIONS.has(m.text.toLowerCase()))
return { score: rateScore(matches.length, wordCount, 20), issueCount: matches.length }
} }
// ── Filter words ────────────────────────────────────────────────────────────── // ── 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 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 ─────────────────────────────────────────────────────── // ── 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 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') 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 ─────────────────────────────────────────────────────────── // ── Sentence rhythm ───────────────────────────────────────────────────────────
function sentenceRhythmScore(text: string): { score: number; issueCount: number } { function sentenceRhythmAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const sentences = splitSentences(text) const sentences = splitSentencesWithPos(text)
if (sentences.length < 3) return { score: 100, issueCount: 0 } if (sentences.length < 3) return { score: 100, matches: [] }
const lengths = sentences.map(s => s.split(/\s+/).length) const lengths = sentences.map(s => s.text.split(/\s+/).length)
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.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 variance = lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length
const cv = mean > 0 ? Math.sqrt(variance) / mean : 0 const cv = mean > 0 ? Math.sqrt(variance) / mean : 0
let runsCount = 0 const matches: PolishMatch[] = []
let runStart = 0
let runLen = 1 let runLen = 1
for (let i = 1; i < lengths.length; i++) { for (let i = 1; i < lengths.length; i++) {
if (Math.abs(lengths[i] - lengths[i - 1]) <= 2) { if (Math.abs(lengths[i] - lengths[i - 1]) <= 2) {
runLen++ runLen++
if (runLen === 3) runsCount++ if (runLen === 3) runStart = i - 2
if (runLen >= 3) {
const s = sentences[runStart]
const e = sentences[i]
const existing = matches.find(m => m.from === s.from)
if (existing) {
existing.to = e.to
existing.text = text.slice(s.from, e.to)
} else {
matches.push({ from: s.from, to: e.to, text: text.slice(s.from, e.to) })
}
}
} else { } else {
runLen = 1 runLen = 1
} }
} }
const score = Math.min(100, Math.round((cv / 0.6) * 100)) const score = Math.min(100, Math.round((cv / 0.6) * 100))
return { score, issueCount: runsCount } return { score, matches }
} }
// ── Paragraph rhythm ────────────────────────────────────────────────────────── // ── Paragraph rhythm ──────────────────────────────────────────────────────────
function paragraphRhythmScore(text: string): { score: number; issueCount: number } { function paragraphRhythmAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0) const paras = splitParasWithPos(text)
if (paragraphs.length < 3) return { score: 100, issueCount: 0 } if (paras.length < 3) return { score: 100, matches: [] }
const lengths = paragraphs.map(p => countWordsIn(p)) const lengths = paras.map(p => countWordsIn(p.text))
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.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 variance = lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length
const cv = mean > 0 ? Math.sqrt(variance) / mean : 0 const cv = mean > 0 ? Math.sqrt(variance) / mean : 0
// Count wall-of-text paragraphs (> 150 words) and tiny runs (≤ 5 words each) const matches: PolishMatch[] = paras
const walls = lengths.filter(l => l > 150).length .filter((_, i) => lengths[i] > 150)
.map(p => ({ from: p.from, to: p.to, text: p.text }))
const score = Math.min(100, Math.round((cv / 0.8) * 100)) const score = Math.min(100, Math.round((cv / 0.8) * 100))
return { score, issueCount: walls } return { score, matches }
} }
// ── Repeated sentence starters ──────────────────────────────────────────────── // ── Repeated sentence starters ────────────────────────────────────────────────
function repeatedStarterScore(text: string): { score: number; issueCount: number } { const PRONOUNS = new Set(['he','she','i','they','it','we','you'])
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, '') ?? '') function repeatedStarterAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const sentences = splitSentencesWithPos(text)
if (sentences.length < 4) return { score: 100, matches: [] }
let runs = 0 const starters = sentences.map(s => s.text.split(/\s+/)[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? '')
const matches: PolishMatch[] = []
let runLen = 1 let runLen = 1
let runStart = 0
for (let i = 1; i < starters.length; i++) { for (let i = 1; i < starters.length; i++) {
if (starters[i] === starters[i - 1] && starters[i] !== '') { if (starters[i] === starters[i - 1] && starters[i] !== '') {
runLen++ runLen++
if (runLen === 3) runs++ if (runLen === 3) runStart = i - 2
if (runLen >= 3) {
const s = sentences[runStart]
const e = sentences[i]
const existing = matches.find(m => m.from === s.from)
if (existing) { existing.to = e.to; existing.text = text.slice(s.from, e.to) }
else matches.push({ from: s.from, to: e.to, text: text.slice(s.from, e.to) })
}
} else { } else {
runLen = 1 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 pronounStarts = starters.filter(w => PRONOUNS.has(w)).length
const pronounRatio = pronounStarts / starters.length const pronounRatio = pronounStarts / starters.length
const runPenalty = Math.min(60, matches.length * 15)
const runPenalty = Math.min(60, runs * 15)
const pronounPenalty = Math.max(0, Math.round((pronounRatio - 0.4) / 0.4 * 40)) const pronounPenalty = Math.max(0, Math.round((pronounRatio - 0.4) / 0.4 * 40))
const score = Math.max(0, 100 - runPenalty - pronounPenalty) const score = Math.max(0, 100 - runPenalty - pronounPenalty)
return { score, issueCount: runs } return { score, matches }
} }
// ── Word variety ────────────────────────────────────────────────────────────── // ── 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']) 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 } { function wordVarietyAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const words = text.toLowerCase().match(/\b[a-z']+\b/g) ?? [] const allWordMatches = [...text.matchAll(/\b[a-z']+\b/gi)]
if (words.length < 20) return { score: 100, issueCount: 0 } if (allWordMatches.length < 20) return { score: 100, matches: [] }
const contentWords = words.filter(w => !SKIP_WORDS.has(w)) const contentWordMatches = allWordMatches.filter(m => !SKIP_WORDS.has(m[0].toLowerCase()))
if (contentWords.length < 20) return { score: 100, issueCount: 0 } if (contentWordMatches.length < 20) return { score: 100, matches: [] }
const WINDOW = 100 const WINDOW = 100
const words = contentWordMatches.map(m => m[0].toLowerCase())
const ttrs: number[] = [] const ttrs: number[] = []
for (let i = 0; i + WINDOW <= contentWords.length; i += Math.floor(WINDOW / 2)) { for (let i = 0; i + WINDOW <= words.length; i += Math.floor(WINDOW / 2)) {
const win = contentWords.slice(i, i + WINDOW) const win = words.slice(i, i + WINDOW)
ttrs.push(new Set(win).size / win.length) ttrs.push(new Set(win).size / win.length)
} }
const avgTTR = ttrs.reduce((a, b) => a + b, 0) / ttrs.length const avgTTR = ttrs.length > 0 ? ttrs.reduce((a, b) => a + b, 0) / ttrs.length : 1
const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100)))
let repeatedCount = 0 // Find words that appear 3+ times within any 50-word window; highlight duplicates
for (let i = 0; i + 50 <= contentWords.length; i += 25) { const flagged = new Set<number>() // indices into contentWordMatches
const win = contentWords.slice(i, i + 50) for (let i = 0; i + 50 <= words.length; i += 25) {
const counts = new Map<string, number>() const win = words.slice(i, i + 50)
for (const w of win) counts.set(w, (counts.get(w) ?? 0) + 1) const counts = new Map<string, number[]>()
for (const [, count] of counts) if (count >= 3) repeatedCount++ for (let j = 0; j < win.length; j++) {
const w = win[j]
if (!counts.has(w)) counts.set(w, [])
counts.get(w)!.push(i + j)
}
for (const [, indices] of counts) {
if (indices.length >= 3) indices.forEach(idx => flagged.add(idx))
}
} }
const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100))) const matches: PolishMatch[] = [...flagged]
return { score, issueCount: repeatedCount } .sort((a, b) => a - b)
.map(idx => {
const m = contentWordMatches[idx]
return { from: m.index!, to: m.index! + m[0].length, text: m[0] }
})
return { score, matches }
} }
// ── Clichés ─────────────────────────────────────────────────────────────────── // ── Clichés ───────────────────────────────────────────────────────────────────
@@ -187,88 +252,79 @@ const CLICHES = [
'face drained','colour drained','blood froze','heart stopped','chest tightened', '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', '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', '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', 'couldn\'t believe his eyes','dead of night','ray of hope','silence was deafening',
'dead of night','ray of hope','last but not least','speak of the devil', 'deafening silence','elephant in the room','cold as ice','sharp as a knife',
'cold as ice','sharp as a knife','black as night','white as snow','red as blood', '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') 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 ───────────────────────────────────────────────────── // ── Overused filler words ─────────────────────────────────────────────────────
const OVERUSED_PATTERN = /\b(just|suddenly|started to|began to|that|somehow|somehow|anyway|whatever|stuff|things|got|get)\b/gi const OVERUSED_PATTERN = /\b(just|suddenly|started to|began to|somehow|anyway|stuff|things)\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 ───────────────────────────────────────────────────────────── // ── Dialogue tags ─────────────────────────────────────────────────────────────
const SAID_BOOKISMS = /\b(hissed|snapped|exclaimed|barked|spat|snarled|growled|shrieked|whined|croaked|breathed|murmured)\b/gi 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 const ADV_TAG = /\b(said|asked|replied|whispered)\s+\w+ly\b/gi
function dialogueTagScore(text: string, wordCount: number): { score: number; issueCount: number } {
const total = (text.match(SAID_BOOKISMS) ?? []).length + (text.match(ADV_TAG) ?? []).length
return { score: rateScore(total, wordCount, 8), issueCount: total }
}
// ── Main export ─────────────────────────────────────────────────────────────── // ── Main export ───────────────────────────────────────────────────────────────
export function computePolishScore(rawText: string): PolishScore { export function computePolishScore(rawText: string): PolishScore {
const text = stripMarkdown(rawText) const wordCount = countWordsIn(rawText)
const wordCount = countWordsIn(text)
const skip = wordCount <= 10 const skip = wordCount <= 10
const dim = (label: string, score: number, issueCount: number): PolishDimension => ({ label, score, issueCount }) function dim(label: string, score: number, matches: PolishMatch[]): PolishDimension {
return { label, score, issueCount: matches.length, matches }
}
const passiveIssues = skip ? 0 : detectPassiveVoice(text).length const passiveAnnotations = skip ? [] : detectPassiveVoice(rawText)
const weaselIssues = skip ? 0 : (text.match(WEASEL_PATTERN) ?? []).length const passiveMatches: PolishMatch[] = passiveAnnotations
.filter(a => a.from !== undefined && a.to !== undefined)
.map(a => ({ from: a.from!, to: a.to!, text: a.matchedText ?? '' }))
const adverb = skip ? { score: 100, issueCount: 0 } : adverbDensityScore(text, wordCount) const weaselMatches = skip ? [] : regexMatches(rawText, WEASEL_PATTERN)
const filter = skip ? { score: 100, issueCount: 0 } : filterWordScore(text, wordCount) const adverbMatches_ = skip ? [] : adverbMatches(rawText)
const showTell = skip ? { score: 100, issueCount: 0 } : showTellScore(text, wordCount) const filterMatches = skip ? [] : regexMatches(rawText, FILTER_PATTERN)
const sRhythm = skip ? { score: 100, issueCount: 0 } : sentenceRhythmScore(text) const showTellMatches= skip ? [] : regexMatches(rawText, SHOW_TELL_PATTERN)
const pRhythm = skip ? { score: 100, issueCount: 0 } : paragraphRhythmScore(text) const sRhythm = skip ? { score: 100, matches: [] } : sentenceRhythmAnalysis(rawText)
const starters = skip ? { score: 100, issueCount: 0 } : repeatedStarterScore(text) const pRhythm = skip ? { score: 100, matches: [] } : paragraphRhythmAnalysis(rawText)
const variety = skip ? { score: 100, issueCount: 0 } : wordVarietyScore(text) const starters = skip ? { score: 100, matches: [] } : repeatedStarterAnalysis(rawText)
const cliche = skip ? { score: 100, issueCount: 0 } : clicheScore(text, wordCount) const variety = skip ? { score: 100, matches: [] } : wordVarietyAnalysis(rawText)
const overused = skip ? { score: 100, issueCount: 0 } : overusedWordScore(text, wordCount) const clicheMatches = skip ? [] : regexMatches(rawText, CLICHE_PATTERN)
const dialogue = skip ? { score: 100, issueCount: 0 } : dialogueTagScore(text, wordCount) const overusedMatches= skip ? [] : regexMatches(rawText, OVERUSED_PATTERN)
const dialogueMatches= skip ? [] : [
...regexMatches(rawText, SAID_BOOKISMS),
...regexMatches(rawText, ADV_TAG),
]
const passiveScore = rateScore(passiveMatches.length, wordCount, 12)
const weaselScore = rateScore(weaselMatches.length, wordCount, 10)
const adverbScore = rateScore(adverbMatches_.length, wordCount, 20)
const filterScore = rateScore(filterMatches.length, wordCount, 8)
const showTellScore = rateScore(showTellMatches.length, wordCount, 10)
const clicheScore = rateScore(clicheMatches.length, wordCount, 6)
const overusedScore = rateScore(overusedMatches.length, wordCount, 25)
const dialogueScore = rateScore(dialogueMatches.length, wordCount, 8)
const scores = [ const scores = [
rateScore(passiveIssues, wordCount, 12), passiveScore, weaselScore, adverbScore, filterScore, showTellScore,
rateScore(weaselIssues, wordCount, 10), sRhythm.score, pRhythm.score, starters.score, variety.score,
adverb.score, clicheScore, overusedScore, dialogueScore,
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) const overall = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
return { return {
overall, overall,
dimensions: { dimensions: {
passiveVoice: dim('Passive voice', rateScore(passiveIssues, wordCount, 12), passiveIssues), passiveVoice: dim('Passive voice', passiveScore, passiveMatches),
weaselWords: dim('Weasel words', rateScore(weaselIssues, wordCount, 10), weaselIssues), weaselWords: dim('Weasel words', weaselScore, weaselMatches),
adverbDensity: dim('Adverb density', adverb.score, adverb.issueCount), adverbDensity: dim('Adverb density', adverbScore, adverbMatches_),
filterWords: dim('Filter words', filter.score, filter.issueCount), filterWords: dim('Filter words', filterScore, filterMatches),
showTell: dim('Show / tell', showTell.score, showTell.issueCount), showTell: dim('Show / tell', showTellScore, showTellMatches),
sentenceRhythm: dim('Sentence rhythm', sRhythm.score, sRhythm.issueCount), sentenceRhythm: dim('Sentence rhythm', sRhythm.score, sRhythm.matches),
paragraphRhythm: dim('Paragraph rhythm', pRhythm.score, pRhythm.issueCount), paragraphRhythm: dim('Paragraph rhythm', pRhythm.score, pRhythm.matches),
repeatedStarters: dim('Sentence starters', starters.score, starters.issueCount), repeatedStarters: dim('Sentence starters',starters.score, starters.matches),
wordVariety: dim('Word variety', variety.score, variety.issueCount), wordVariety: dim('Word variety', variety.score, variety.matches),
cliches: dim('Clichés', cliche.score, cliche.issueCount), cliches: dim('Clichés', clicheScore, clicheMatches),
overusedWords: dim('Filler words', overused.score, overused.issueCount), overusedWords: dim('Filler words', overusedScore, overusedMatches),
dialogueTags: dim('Dialogue tags', dialogue.score, dialogue.issueCount), dialogueTags: dim('Dialogue tags', dialogueScore, dialogueMatches),
} }
} }
} }

File diff suppressed because one or more lines are too long