polish meter

This commit is contained in:
TC
2026-06-14 19:59:51 +10:00
parent 7083c67121
commit 3f524a1e0b
8 changed files with 403 additions and 1 deletions

86
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"pdf-parse": "^2.4.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"write-good": "^1.0.8",
"zustand": "^4.5.0"
},
"devDependencies": {
@@ -35,6 +36,7 @@
"@types/pdf-parse": "^1.1.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/write-good": "^1.0.3",
"@vitejs/plugin-react": "^4.2.0",
"electron": "^33.0.0",
"electron-builder": "^24.0.0",
@@ -2342,6 +2344,13 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/write-good": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/write-good/-/write-good-1.0.3.tgz",
"integrity": "sha512-GhnPEKhJcKa9XfppRiVXxrhzKOOWuJLVlv/ZSIiEp+gCnu3dKJ4RKFtMNuzuASBkGzHoO3WlypDO6yw5jjq58A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -2402,6 +2411,16 @@
"node": ">=6.5"
}
},
"node_modules/adverb-where": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/adverb-where/-/adverb-where-0.2.6.tgz",
"integrity": "sha512-uVazUDEPYpBSVRjEDTzO6hVXh9X/eQb+gobzDpqdzMiM1MkfGxfPtgN8YerBjAeDkoABZprsOwhSZnY4X3knnw==",
"license": "MIT",
"engines": {
"node": ">=6",
"npm": ">=5"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -3646,6 +3665,12 @@
"node": ">= 0.4"
}
},
"node_modules/e-prime": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/e-prime/-/e-prime-0.10.4.tgz",
"integrity": "sha512-tzBmM2mFSnAq5BuxPSyin6qXb3yMe1wufJN7L7ZPcEWS5S+jI2dhKQEoqHVEcSMMXo/j5lcWpX5jzA6wLSmX6w==",
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -5182,6 +5207,16 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/no-cliches": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/no-cliches/-/no-cliches-0.3.6.tgz",
"integrity": "sha512-3yZ1vfGKOcv0dyyhUeqA0Qa6RsQ4SfUnL6o2IWR4sVg8kdnJo48XTWbMLdtnfiZTbCUdsMttNwyJcihEdGCZBw==",
"license": "MIT",
"engines": {
"node": ">=16",
"npm": ">=8"
}
},
"node_modules/node-addon-api": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
@@ -5301,6 +5336,12 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/passive-voice": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/passive-voice/-/passive-voice-0.1.0.tgz",
"integrity": "sha512-Pj9iwzXw4bKEtdugGYm92jT4tnsj+xrTSkHFEM4bn6fefqbFdZi49tZMmGIZ91aIQTyFtMUww7O2qYaZKAsDag==",
"license": "MIT"
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -6131,6 +6172,16 @@
"tmp": "^0.2.0"
}
},
"node_modules/too-wordy": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/too-wordy/-/too-wordy-0.3.6.tgz",
"integrity": "sha512-fK4DKkEcrpBbK6uANekH37VeNAb/88qKdkqc/nBOFJpHdvXKXdA4lZRkiM6zNlow00Zp4W4/lnWyqqCaOQlg/w==",
"license": "MIT",
"engines": {
"node": ">=6",
"npm": ">=5"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -6342,6 +6393,12 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/weasel-words": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/weasel-words/-/weasel-words-0.1.1.tgz",
"integrity": "sha512-rWkTAGqs4TN6qreS06+irmFUMrQVx5KoFjD8CxMHUsAwmxw/upDcfleaEYOLsonUbornahg+VJ9xrWxp4udyJA==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
@@ -6426,6 +6483,35 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/write-good": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/write-good/-/write-good-1.0.8.tgz",
"integrity": "sha512-P1Ct7+DNrOcr2JAxDZ3Q5i5sx2LSveu7iLaoUL0A+YiG0GKf0l5+9j3rwMeyh6JeTL1+HfQV1rnwEvzhNIvpFw==",
"license": "MIT",
"dependencies": {
"adverb-where": "^0.2.2",
"commander": "^2.19.0",
"e-prime": "^0.10.4",
"no-cliches": "^0.3.0",
"passive-voice": "^0.1.0",
"too-wordy": "^0.3.1",
"weasel-words": "^0.1.1"
},
"bin": {
"write-good": "bin/write-good.js",
"writegood": "bin/write-good.js"
},
"engines": {
"node": ">=6",
"npm": ">=5"
}
},
"node_modules/write-good/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@@ -19,6 +19,7 @@
"@types/pdf-parse": "^1.1.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/write-good": "^1.0.3",
"@vitejs/plugin-react": "^4.2.0",
"electron": "^33.0.0",
"electron-builder": "^24.0.0",
@@ -46,6 +47,7 @@
"pdf-parse": "^2.4.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"write-good": "^1.0.8",
"zustand": "^4.5.0"
},
"build": {

View File

@@ -11,6 +11,7 @@ import DOMPurify from 'dompurify'
import { useEditorStore } from '../../store/editorStore'
import type { TextAnnotation } from '../../types/editor'
import { reanchorAnnotations } from '../../utils/annotationParser'
import { computePolishScore } from '../../utils/polishMetrics'
import { ContextMenu } from '../FileTree/ContextMenu'
import type { MenuItem } from '../FileTree/ContextMenu'
import './Editor.css'
@@ -724,6 +725,16 @@ export function MarkdownEditor(): JSX.Element {
wordTimer = setTimeout(() => { wordTimer = null; setWordStats(getStats()) }, 150)
}
// Debounced polish score — runs static analysis 1.5s after the user stops typing
let polishTimer: ReturnType<typeof setTimeout> | null = null
function schedulePolishScore(text: string): void {
if (polishTimer) clearTimeout(polishTimer)
polishTimer = setTimeout(() => {
polishTimer = null
useEditorStore.getState().setPolishScore(computePolishScore(text))
}, 1500)
}
// Debounced telemetry snapshot — fires 5s after the user stops typing
let telemetryTimer: ReturnType<typeof setTimeout> | null = null
function scheduleTelemetrySnapshot(wordCount: number): void {
@@ -799,6 +810,7 @@ export function MarkdownEditor(): JSX.Element {
if (update.docChanged) {
const text = update.state.doc.toString()
scheduleSetContent(text)
schedulePolishScore(text)
const total = countWords(text)
wordTotalRef.current = total
scheduleTelemetrySnapshot(total)
@@ -920,6 +932,7 @@ export function MarkdownEditor(): JSX.Element {
if (!view) return
const current = view.state.doc.toString()
if (current !== activeFileContent) {
useEditorStore.getState().setPolishScore(computePolishScore(activeFileContent))
// Mark the file-load as non-undoable: loading a file should never
// appear in the undo stack, so Cmd+Z can't empty the document.
view.dispatch({

View File

@@ -531,3 +531,79 @@
.fb-card-header--no-jump:hover {
background: transparent;
}
/* ── Polish meter ───────────────────────────────────── */
:root {
--polish-good: #1D9E75;
--polish-mid: #EF9F27;
--polish-low: #E24B4A;
}
.pm-widget {
margin: 10px 10px 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
}
.pm-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.pm-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.pm-overall {
font-size: 18px;
font-weight: 600;
line-height: 1;
}
.pm-dims {
display: flex;
flex-direction: column;
gap: 5px;
}
.pm-dim {
display: flex;
align-items: center;
gap: 8px;
}
.pm-dim-label {
font-size: 11px;
color: var(--text-muted);
width: 112px;
flex-shrink: 0;
}
.pm-bar-track {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.pm-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease, background 0.4s ease;
}
.pm-dim-score {
font-size: 10px;
color: var(--text-muted);
width: 22px;
text-align: right;
flex-shrink: 0;
}

View File

@@ -3,6 +3,7 @@ import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useEditorStore } from '../../store/editorStore'
import type { TextAnnotation } from '../../types/editor'
import type { PolishScore } from '../../utils/polishMetrics'
import {
tooltipAnalysisCache,
cancelPendingDismiss,
@@ -266,6 +267,38 @@ function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element {
)
}
function scoreColor(score: number): string {
if (score >= 75) return 'var(--polish-good)'
if (score >= 45) return 'var(--polish-mid)'
return 'var(--polish-low)'
}
function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
const dims = Object.values(score.dimensions)
return (
<div className="pm-widget">
<div className="pm-header">
<span className="pm-title">Polish</span>
<span className="pm-overall" style={{ color: scoreColor(score.overall) }}>{score.overall}</span>
</div>
<div className="pm-dims">
{dims.map(dim => (
<div key={dim.label} className="pm-dim">
<span className="pm-dim-label">{dim.label}</span>
<div className="pm-bar-track">
<div
className="pm-bar-fill"
style={{ width: `${dim.score}%`, background: scoreColor(dim.score) }}
/>
</div>
<span className="pm-dim-score">{dim.score}</span>
</div>
))}
</div>
</div>
)
}
export function FeedbackPanel(): JSX.Element {
const {
annotations,
@@ -276,6 +309,7 @@ export function FeedbackPanel(): JSX.Element {
clearArchivedAnnotations,
removeArchivedAnnotation,
addDocumentNote,
polishScore,
} = useEditorStore()
const [analyseAll, setAnalyseAll] = useState(false)
const [addingNote, setAddingNote] = useState(false)
@@ -330,6 +364,7 @@ export function FeedbackPanel(): JSX.Element {
if (!hasActive && !hasArchive) {
return (
<div className="fb-panel">
{polishScore && <PolishMeter score={polishScore} />}
<div className="fb-empty">
<p>No feedback yet.</p>
<p>Run a critique from the toolbar to highlight issues in your text.</p>
@@ -363,6 +398,7 @@ export function FeedbackPanel(): JSX.Element {
return (
<div className="fb-panel">
{polishScore && <PolishMeter score={polishScore} />}
{hasActive && (
<>
<div className="fb-toolbar">

View File

@@ -63,6 +63,10 @@ interface EditorState {
// Clear active file (e.g. after deletion)
clearActiveFile: () => void
// Polish score
polishScore: import('../utils/polishMetrics').PolishScore | null
setPolishScore: (score: import('../utils/polishMetrics').PolishScore | null) => void
// Word counts
projectWordCount: number
setProjectWordCount: (count: number) => void
@@ -623,6 +627,9 @@ export const useEditorStore = create<EditorState>((set, get) => ({
})
},
polishScore: null,
setPolishScore: (polishScore) => set({ polishScore }),
projectWordCount: 0,
setProjectWordCount: (projectWordCount) => set({ projectWordCount }),

View File

@@ -0,0 +1,182 @@
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
}
}
}

File diff suppressed because one or more lines are too long