✨ clickable feedback
This commit is contained in:
@@ -534,9 +534,10 @@
|
||||
|
||||
/* ── Polish meter ───────────────────────────────────── */
|
||||
:root {
|
||||
--polish-good: #1D9E75;
|
||||
--polish-mid: #EF9F27;
|
||||
--polish-low: #E24B4A;
|
||||
--polish-good: #1D9E75;
|
||||
--polish-mid: #EF9F27;
|
||||
--polish-low: #E24B4A;
|
||||
--polish-active: #378ADD;
|
||||
}
|
||||
|
||||
.pm-widget {
|
||||
@@ -577,6 +578,25 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -584,6 +604,7 @@
|
||||
color: var(--text-muted);
|
||||
width: 112px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.pm-bar-track {
|
||||
|
||||
@@ -3,7 +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 type { PolishScore, PolishDimension } from '../../utils/polishMetrics'
|
||||
import {
|
||||
tooltipAnalysisCache,
|
||||
cancelPendingDismiss,
|
||||
@@ -28,6 +28,7 @@ function badgeColor(type: TextAnnotation['type']): string {
|
||||
case 'custom': return 'rgba(30, 200, 150, 0.8)'
|
||||
case 'user_comment': return 'rgba(240, 100, 180, 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 {
|
||||
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 (
|
||||
<div className="pm-widget">
|
||||
<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>
|
||||
</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) }}
|
||||
/>
|
||||
{dims.map(([key, 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>
|
||||
<div className="pm-bar-track">
|
||||
<div
|
||||
className="pm-bar-fill"
|
||||
style={{ width: `${dim.score}%`, background: isActive ? 'var(--polish-active)' : scoreColor(dim.score) }}
|
||||
/>
|
||||
</div>
|
||||
<span className="pm-dim-score" style={{ color: isActive ? 'var(--polish-active)' : undefined }}>{dim.score}</span>
|
||||
</div>
|
||||
<span className="pm-dim-score">{dim.score}</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ChatSession {
|
||||
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 {
|
||||
id: string
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { detectPassiveVoice } from './passiveVoice'
|
||||
|
||||
export interface PolishMatch {
|
||||
from: number
|
||||
to: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface PolishDimension {
|
||||
label: string
|
||||
score: number // 0–100
|
||||
score: number
|
||||
issueCount: number
|
||||
matches: PolishMatch[]
|
||||
}
|
||||
|
||||
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 {
|
||||
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, '')
|
||||
.replace(/\*\*?|__?/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/`[^`]+`/g, '')
|
||||
.replace(/^>\s+/gm, '')
|
||||
}
|
||||
|
||||
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)
|
||||
// Returns {text, from, to} for each sentence, positions in the raw text
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
@@ -54,128 +94,153 @@ const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|basically|actually|li
|
||||
|
||||
// ── 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'])
|
||||
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 } {
|
||||
const matches = (text.match(ADVERB_PATTERN) ?? []).filter(w => !ADVERB_EXCEPTIONS.has(w.toLowerCase()))
|
||||
return { score: rateScore(matches.length, wordCount, 20), issueCount: matches.length }
|
||||
function adverbMatches(text: string): PolishMatch[] {
|
||||
return regexMatches(text, ADVERB_PATTERN).filter(m => !ADVERB_EXCEPTIONS.has(m.text.toLowerCase()))
|
||||
}
|
||||
|
||||
// ── 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 }
|
||||
function sentenceRhythmAnalysis(text: string): { score: number; matches: PolishMatch[] } {
|
||||
const sentences = splitSentencesWithPos(text)
|
||||
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 variance = lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length
|
||||
const cv = mean > 0 ? Math.sqrt(variance) / mean : 0
|
||||
|
||||
let runsCount = 0
|
||||
const matches: PolishMatch[] = []
|
||||
let runStart = 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++
|
||||
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 {
|
||||
runLen = 1
|
||||
}
|
||||
}
|
||||
|
||||
const score = Math.min(100, Math.round((cv / 0.6) * 100))
|
||||
return { score, issueCount: runsCount }
|
||||
return { score, matches }
|
||||
}
|
||||
|
||||
// ── 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 }
|
||||
function paragraphRhythmAnalysis(text: string): { score: number; matches: PolishMatch[] } {
|
||||
const paras = splitParasWithPos(text)
|
||||
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 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 matches: PolishMatch[] = paras
|
||||
.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))
|
||||
return { score, issueCount: walls }
|
||||
return { score, matches }
|
||||
}
|
||||
|
||||
// ── 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 PRONOUNS = new Set(['he','she','i','they','it','we','you'])
|
||||
|
||||
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 runStart = 0
|
||||
for (let i = 1; i < starters.length; i++) {
|
||||
if (starters[i] === starters[i - 1] && starters[i] !== '') {
|
||||
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 {
|
||||
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 runPenalty = Math.min(60, matches.length * 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 }
|
||||
return { score, matches }
|
||||
}
|
||||
|
||||
// ── 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 }
|
||||
function wordVarietyAnalysis(text: string): { score: number; matches: PolishMatch[] } {
|
||||
const allWordMatches = [...text.matchAll(/\b[a-z']+\b/gi)]
|
||||
if (allWordMatches.length < 20) return { score: 100, matches: [] }
|
||||
|
||||
const contentWords = words.filter(w => !SKIP_WORDS.has(w))
|
||||
if (contentWords.length < 20) return { score: 100, issueCount: 0 }
|
||||
const contentWordMatches = allWordMatches.filter(m => !SKIP_WORDS.has(m[0].toLowerCase()))
|
||||
if (contentWordMatches.length < 20) return { score: 100, matches: [] }
|
||||
|
||||
const WINDOW = 100
|
||||
const words = contentWordMatches.map(m => m[0].toLowerCase())
|
||||
const ttrs: number[] = []
|
||||
for (let i = 0; i + WINDOW <= contentWords.length; i += Math.floor(WINDOW / 2)) {
|
||||
const win = contentWords.slice(i, i + WINDOW)
|
||||
for (let i = 0; i + WINDOW <= words.length; i += Math.floor(WINDOW / 2)) {
|
||||
const win = words.slice(i, i + WINDOW)
|
||||
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
|
||||
for (let i = 0; i + 50 <= contentWords.length; i += 25) {
|
||||
const win = contentWords.slice(i, i + 50)
|
||||
const counts = new Map<string, number>()
|
||||
for (const w of win) counts.set(w, (counts.get(w) ?? 0) + 1)
|
||||
for (const [, count] of counts) if (count >= 3) repeatedCount++
|
||||
// Find words that appear 3+ times within any 50-word window; highlight duplicates
|
||||
const flagged = new Set<number>() // indices into contentWordMatches
|
||||
for (let i = 0; i + 50 <= words.length; i += 25) {
|
||||
const win = words.slice(i, i + 50)
|
||||
const counts = new Map<string, number[]>()
|
||||
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)))
|
||||
return { score, issueCount: repeatedCount }
|
||||
const matches: PolishMatch[] = [...flagged]
|
||||
.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 ───────────────────────────────────────────────────────────────────
|
||||
@@ -187,88 +252,79 @@ const CLICHES = [
|
||||
'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',
|
||||
'couldn\'t believe his eyes','dead of night','ray of hope','silence was deafening',
|
||||
'deafening silence','elephant in the room','cold as ice','sharp as a knife',
|
||||
'black as night','white as snow','red as blood',
|
||||
]
|
||||
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 }
|
||||
}
|
||||
const OVERUSED_PATTERN = /\b(just|suddenly|started to|began to|somehow|anyway|stuff|things)\b/gi
|
||||
|
||||
// ── 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 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 wordCount = countWordsIn(rawText)
|
||||
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 weaselIssues = skip ? 0 : (text.match(WEASEL_PATTERN) ?? []).length
|
||||
const passiveAnnotations = skip ? [] : detectPassiveVoice(rawText)
|
||||
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 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 weaselMatches = skip ? [] : regexMatches(rawText, WEASEL_PATTERN)
|
||||
const adverbMatches_ = skip ? [] : adverbMatches(rawText)
|
||||
const filterMatches = skip ? [] : regexMatches(rawText, FILTER_PATTERN)
|
||||
const showTellMatches= skip ? [] : regexMatches(rawText, SHOW_TELL_PATTERN)
|
||||
const sRhythm = skip ? { score: 100, matches: [] } : sentenceRhythmAnalysis(rawText)
|
||||
const pRhythm = skip ? { score: 100, matches: [] } : paragraphRhythmAnalysis(rawText)
|
||||
const starters = skip ? { score: 100, matches: [] } : repeatedStarterAnalysis(rawText)
|
||||
const variety = skip ? { score: 100, matches: [] } : wordVarietyAnalysis(rawText)
|
||||
const clicheMatches = skip ? [] : regexMatches(rawText, CLICHE_PATTERN)
|
||||
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 = [
|
||||
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,
|
||||
passiveScore, weaselScore, adverbScore, filterScore, showTellScore,
|
||||
sRhythm.score, pRhythm.score, starters.score, variety.score,
|
||||
clicheScore, overusedScore, dialogueScore,
|
||||
]
|
||||
const overall = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
|
||||
|
||||
return {
|
||||
overall,
|
||||
dimensions: {
|
||||
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),
|
||||
passiveVoice: dim('Passive voice', passiveScore, passiveMatches),
|
||||
weaselWords: dim('Weasel words', weaselScore, weaselMatches),
|
||||
adverbDensity: dim('Adverb density', adverbScore, adverbMatches_),
|
||||
filterWords: dim('Filter words', filterScore, filterMatches),
|
||||
showTell: dim('Show / tell', showTellScore, showTellMatches),
|
||||
sentenceRhythm: dim('Sentence rhythm', sRhythm.score, sRhythm.matches),
|
||||
paragraphRhythm: dim('Paragraph rhythm', pRhythm.score, pRhythm.matches),
|
||||
repeatedStarters: dim('Sentence starters',starters.score, starters.matches),
|
||||
wordVariety: dim('Word variety', variety.score, variety.matches),
|
||||
cliches: dim('Clichés', clicheScore, clicheMatches),
|
||||
overusedWords: dim('Filler words', overusedScore, overusedMatches),
|
||||
dialogueTags: dim('Dialogue tags', dialogueScore, dialogueMatches),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user