✨ clickable feedback
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 // 0–100
|
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
Reference in New Issue
Block a user