From bb38f5d1da706cdb54a6efd9ed795b14921bb6ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 22:57:42 +0000 Subject: [PATCH 1/2] Make inline suggestions measurements of the manuscript, not a list applied blindly Weak verbs: the detector now measures this manuscript's reliance on each generic verb against the family of specific verbs the author already uses, and stays silent when the author varies. Where it speaks it quotes the count and rate. Participles (", made quietly"), passives, aspectual uses ("started to"), clauses after "thought" and idioms ("made sense", "looked after") are never raised, and shown instances are spread across the book rather than taken from its opening. prose-norms sets the finding aside in explanation, speech and reflection, with a reason, and keeps it in action and description. Repetition: a capitalised word whose lowercase form never occurs in the text is a name, and names are not repetitions. The editor now honours what the engine decided: findings set aside for their passage render muted with the reason in the tooltip and no fix button; per-type counts and category lists are of the findings that actually scored. A synonym from a fixed list is never auto-applied for the author (the button becomes Edit Here), and the fixer no longer picks a replacement at random. Also fixes the double-escaped "Replace & Fix" label. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- analyzer.js | 152 ++++++++++++++++++++++++++++++--------- app.html | 10 +-- app.js | 49 ++++++++----- copy-detectors.test.js | 75 +++++++++++++++++++ fixer.js | 4 +- prose-norms.js | 8 +++ scripts/browser-smoke.js | 26 ++++++- styles.css | 3 + 8 files changed, 269 insertions(+), 58 deletions(-) create mode 100644 copy-detectors.test.js diff --git a/analyzer.js b/analyzer.js index f45af4c..541e72d 100644 --- a/analyzer.js +++ b/analyzer.js @@ -408,46 +408,114 @@ const Analyzer = { // ======================== // WEAK VERB DETECTION // ======================== + // A generic verb is only a fault when the author leans on it. Each entry names the + // alternatives we can offer and the wider family of specific verbs an author may already + // be using. The detector measures THIS manuscript's reliance on the generic verb against + // that family and says nothing when the author already varies. Where it does speak it + // quotes the count, so the author can disagree with the reading. Whether the passage is + // one where vividness is even a virtue is decided afterwards by prose-norms.js. WEAK_VERBS: { - 'walked':'strode, ambled, trudged, sauntered', - 'looked':'glanced, peered, gazed, scrutinized','went':'hurried, wandered, dashed, strolled', - 'got':'obtained, acquired, seized, snatched','put':'placed, positioned, deposited, set', - 'made':'crafted, fashioned, constructed, forged','came':'arrived, emerged, appeared, materialized', - 'thought':'pondered, mused, considered, reflected','saw':'noticed, observed, spotted, witnessed', - 'ran':'sprinted, dashed, bolted, jogged','moved':'shifted, glided, crept, lunged', - 'turned':'pivoted, swiveled, whirled, rotated', - 'seemed':'appeared, suggested, indicated, implied','started':'began, commenced, initiated, launched', - 'stood':'towered, loomed, perched, positioned', - 'sat':'perched, settled, reclined, lounged','held':'clutched, gripped, grasped, cradled' + walked: { doing:'walking', alts:'strode, ambled, trudged, sauntered', family:['strode','ambled','trudged','sauntered','paced','marched','strolled','wandered','shuffled','stalked','crept','limped','hiked','trekked','stepped','tramped','plodded'] }, + looked: { doing:'looking', alts:'glanced, peered, gazed, scrutinized', family:['glanced','peered','gazed','scrutinized','scrutinised','stared','watched','studied','eyed','glared','squinted','surveyed','regarded','scanned','searched','inspected','examined'] }, + went: { doing:'going', alts:'hurried, wandered, dashed, strolled', family:['hurried','wandered','dashed','strolled','headed','travelled','traveled','drove','rode','flew','crossed','climbed','descended','departed','marched','trudged','made for'] }, + got: { doing:'getting', alts:'obtained, acquired, seized, snatched', family:['obtained','acquired','seized','snatched','grabbed','fetched','received','earned','bought','caught','gathered','collected','secured','won'] }, + put: { doing:'placing', alts:'placed, positioned, deposited, set', family:['placed','positioned','deposited','laid','dropped','tucked','slid','shoved','stuffed','rested','propped','hung','planted','set down','slipped'] }, + made: { doing:'making', alts:'crafted, fashioned, constructed, forged', family:['crafted','fashioned','constructed','forged','built','shaped','assembled','formed','created','produced','carved','stitched','brewed','baked','cooked','drew','moulded','molded'] }, + came: { doing:'arriving', alts:'arrived, emerged, appeared, materialized', family:['arrived','emerged','appeared','materialized','materialised','approached','entered','returned','reached','surfaced','drifted','burst','crept','slipped','stumbled in','walked in'] }, + thought: { doing:'thinking', alts:'pondered, mused, considered, reflected', family:['pondered','mused','considered','reflected','wondered','realized','realised','suspected','decided','reasoned','weighed','imagined','recalled','remembered','brooded','deliberated'] }, + saw: { doing:'seeing', alts:'noticed, observed, spotted, witnessed', family:['noticed','observed','spotted','witnessed','glimpsed','watched','recognized','recognised','spied','discerned','sighted','caught sight','made out'] }, + ran: { doing:'running', alts:'sprinted, dashed, bolted, jogged', family:['sprinted','dashed','bolted','jogged','raced','fled','hurried','rushed','charged','tore','scrambled','darted','loped','galloped','pelted'] }, + moved: { doing:'moving', alts:'shifted, glided, crept, lunged', family:['shifted','glided','crept','lunged','slid','edged','drifted','swayed','stepped','shuffled','eased','swung','leaned','rolled','inched'] }, + turned: { doing:'turning', alts:'pivoted, swiveled, whirled, rotated', family:['pivoted','swiveled','swivelled','whirled','rotated','spun','wheeled','twisted','swung','faced','veered','rounded','swerved'] }, + seemed: { doing:'seeming', alts:'appeared, suggested, indicated, implied', family:['appeared','suggested','indicated','implied','looked','sounded','struck'] }, + started: { doing:'starting', alts:'began, commenced, initiated, launched', family:['began','commenced','initiated','launched','opened','set out','set off','took up','embarked','kicked off'] }, + stood: { doing:'standing', alts:'towered, loomed, perched, positioned', family:['towered','loomed','perched','positioned','rose','waited','lingered','leaned','hovered','straightened','remained','planted','braced','froze'] }, + sat: { doing:'sitting', alts:'perched, settled, reclined, lounged', family:['perched','settled','reclined','lounged','slumped','sank','crouched','squatted','knelt','sprawled','rested','dropped','hunched'] }, + held: { doing:'holding', alts:'clutched, gripped, grasped, cradled', family:['clutched','gripped','grasped','cradled','clasped','squeezed','hugged','carried','pinned','hoisted','balanced','steadied','clung'] } + }, + // Constructions in which the generic verb is not doing the sentence's main work, or sits + // in an idiom no "vivid" verb can replace ("made sense", "looked after", "turned out"). + WEAK_VERB_PARTICLES: /^(up|out|off|over|down|into|through|away|back|around|round|along|forward|aside|after|like|as if|as though)\b/i, + WEAK_VERB_IDIOMS: { + made:['sense','sure','it','of','do','use','room','time','money','peace','clear','good','well','certain','fun','love','light','way','a point','a decision','a difference','a living','a mistake','a fool','an effort','an appearance','an exception','the most','the best','the case','the cut','matters','amends','ends meet','his way','her way','their way','my way','our way','its way','me','him','her','them','us','you','himself','herself','themselves','myself','yourself','ourselves','itself'], + got:['rid','used','married','lost','ready','tired','sick','hurt','caught','stuck','dressed','home','here','there','hold','wind','word','on with','the better','the message','the point','the idea','the hang','the feeling','the impression','the sense','in the way','under way','underway','going'], + came:['across','about','true','apart','upon','first','second','last','close','of','undone','alive','loose','clean','to terms','to light','to life','to rest','to pass','to a head','to an end','to mind','to the conclusion','to nothing','to blows','to power','to grief','to the point','with'], + went:['on','ahead','wrong','well','badly','mad','quiet','silent','still','cold','white','pale','red','dark','blank','numb','without','by','about','for','to sleep','to bed','to war','to work','to school','to church','to court','to great lengths','out of','missing','unanswered','unnoticed','unheard','unsaid','so far','too far','further','on to'], + looked:['forward','the part','the other way','it','tired','pale','older','younger','different','fine','good','bad','well','lost','alike','sad','happy','worried','confused','surprised','exhausted','beautiful','terrible','awful','ridiculous','small','tiny','huge','thin','sick','ill','dead','alive','young','old','new','strange','odd','familiar','unhappy','angry','afraid','scared','nervous','calm','relieved','pleased','embarrassed','ashamed','proud','grim','serious','stern','kind','gentle','cold','warm','as'], + put:['together','an end','a stop','it','him','her','them','me','us','you','pressure','weight','faith','trust','effort','money','words','simply','bluntly','plainly','differently','another way','to bed','to sleep','to death','to use','to work','to rest','to shame','to the test','up with','paid','right','straight','on'], + turned:['against','to','toward','towards','red','pale','white','cold','sour','bad','a corner','a profit','a blind eye','the corner','the page','the tables','the tide','the key','the lock','the handle','the wheel','the volume','heads','tail','turtle','left','right','on','in','it','him','her','them','me','us','you','sixty','seventy','forty','fifty','thirty','twenty','eighteen','twenty-one','a year','two','three','four','five','six','seven','eight','nine','ten'], + started:['again','from','with','on','in','as','at','by','the car','the engine','the fire','a fire','a fight','a family','a business','a company','a conversation','a war','something','it','trouble','work','school','college','university','life','over'], + seemed:['to','like','that','so','as'], + stood:['still','firm','fast','ground','his ground','her ground','their ground','my ground','trial','a chance','in for','on end','on ceremony','corrected','accused','a round','the test','to reason','watch','guard','by','for','alone','together','apart','tall','there','here','at','in','on','before','behind','beside','between','near','next','over','under','with','and','to','a','an','the'], + sat:['still','tight','through','by','with','at','beside','next','across','opposite','on','in','for','the exam','the test','well','badly','right','wrong','quietly','silently','alone','together','there','here','and','a','an','the','up','down','back','out'], + held:['together','still','fast','firm','tight','good','true','sway','court','office','hands','breath','his breath','her breath','their breath','my breath','our breath','the line','the door','the floor','the fort','a grudge','a meeting','a candle','an election','a position','a record','the record','the view','the belief','the opinion','that','it','him','her','them','me','us','you','forth','a moment','the key','on to','onto','back','in','up','out','off','over','down','at bay','to','with','for','sway','water'], + thought:['better','twice','aloud','out loud','hard','long','little','much','nothing','for a moment','through','ahead','otherwise','differently','police','crime','leader','experiment','process','bubble','it','he','she','they','we','you','i','the','this','there','that','of','about','so','not','to','with','and','myself','himself','herself','themselves','ourselves','yourself','maybe','perhaps','how','what','why','where','when','who','which','if','as'], + saw:['to','through','off','fit','red','the light','the point','the world','the doctor','a doctor','the dentist','the last of','the back of','eye to eye','the end of','action','service','sense','reason','the funny side','the error','the writing'], + ran:['late','low','high','dry','short','wild','deep','smooth','rough','free','riot','aground','amok','the risk','the show','the shop','the business','the company','the numbers','the country','the household','the meeting','the race','a hand','a finger','a bath','a fever','a temperature','a business','a shop','an errand','errands','a tab','a check','a test','tests','in the family','its course','his course','her course','their course','a mile','miles','the length','in circles','a red light','a story','an ad','an article','a headline','a program','a programme','a scan','a report','a piece','for','with','on','to','from','at','in','past','by','across','between','and','a','an','the','it','him','her','them','me','us','you'], + moved:['on','in','past','toward','towards','to tears','to action','to speak','to say','to write','to ask','to help','to laughter','house','home','abroad','ahead','closer','nearer','that','the motion','the goalposts','the needle','the meeting','the date','the deadline','the time','heaven and earth','mountains','the crowd','the audience','the jury','the reader','me','him','her','them','us','you','deeply','profoundly','greatly','with','by','at','and','to','a','an','the','it','his','her','their','my','our','its','from'] }, findWeakVerbs(text, genre) { const issues = []; - // Nonfiction softening. This multiplies SEVERITY WEIGHT, never confidence: confidence is - // a claim about whether the detection is real, and the validation layer discards anything - // under 0.6. Multiplying confidence by 0.3-0.5 therefore deleted every one of these - // findings for nonfiction instead of softening them. Register-appropriate instances are - // now handled contextually by prose-norms.js, with a reason the author can read. - const nfSoften = this.isNonfiction(genre); - const nfWeight = 1; - const PER_VERB_LIMIT = 5; // cap flags per verb to avoid noise in long manuscripts - for (const [verb, alternatives] of Object.entries(this.WEAK_VERBS)) { + const SHOWN_PER_VERB = 8; // highlights per verb: a display cap, stated in the finding + const MIN_USES = 3; // fewer than this is not a habit + const RELIANCE = 0.6; // share of the family the generic verb must carry to be raised + const totalWords = Math.max(1, (text.match(/\b\w+\b/g) || []).length); + const lower = text.toLowerCase(); + const countAll = word => { + const m = lower.match(new RegExp('\\b' + word.replace(/\s+/g, '\\s+') + '\\b', 'g')); + return m ? m.length : 0; + }; + + for (const [verb, entry] of Object.entries(this.WEAK_VERBS)) { const regex = new RegExp(`\\b${verb}\\b`, 'gi'); + const clean = []; let match; - let count = 0; - while ((match = regex.exec(text)) !== null && count < PER_VERB_LIMIT) { - count++; + while ((match = regex.exec(text)) !== null) { + if (this._weakVerbIsIdiomatic(text, verb, match.index)) continue; + clean.push(match); + } + const uses = clean.length; + if (uses < MIN_USES) continue; + const family = entry.family.reduce((sum, w) => sum + countAll(w), 0); + const reliance = uses / (uses + family); + if (reliance < RELIANCE) continue; // the author already varies; nothing to say + const perK = Math.round(uses / totalWords * 1000 * 10) / 10; + const message = `"${verb}" carries ${uses} of the ${uses + family} ${entry.doing} verbs in this manuscript (${perK} per 1,000 words). A more specific verb would show the action.`; + const shown = Math.min(uses, SHOWN_PER_VERB); + const suggestion = `Alternatives to weigh: ${entry.alts}.` + (uses > shown ? ` Showing ${shown} of ${uses} uses.` : ''); + // Spread the shown instances across the manuscript rather than taking the first few, + // so a habit in chapter twenty is as visible as one in chapter one. + for (let k = 0; k < shown; k++) { + const m = clean[Math.floor(k * uses / shown)]; issues.push({ - type: 'weak-verb', text: match[0], index: match.index, length: match[0].length, - severity: 'low', confidence: 0.9 * nfWeight, - message: `Weak verb "${match[0]}" — a more specific verb creates vivid imagery.`, - suggestion: `Try: ${alternatives}` + type: 'weak-verb', text: m[0], index: m.index, length: m[0].length, + severity: 'low', confidence: 0.9, message, suggestion, + detail: { verb, uses, family, reliance: Math.round(reliance * 100) / 100, perK } }); } } return issues; }, + // True where the verb is not carrying the sentence, or sits in an idiom. Rules first + // (participle, passive, aspectual "started to", a clause after "thought"), then the + // per-verb idiom list. + _weakVerbIsIdiomatic(text, verb, index) { + const before = text.slice(Math.max(0, index - 14), index); + if (/[,;:—–-]\s*$/.test(before)) return true; // ", made quietly" + if (/\b(am|is|are|was|were|be|been|being|get|gets|got|getting|having|to)\s+$/i.test(before)) return true; + if (verb === 'got' && /\b(have|has|had|'ve|'s|'d|’ve|’s|’d)\s+$/i.test(before)) return true; + const afterTrim = text.slice(index + verb.length, index + verb.length + 40).replace(/^\s+/, ''); + if (!afterTrim) return false; + if (this.WEAK_VERB_PARTICLES.test(afterTrim)) return true; + if (/^to\s+[a-z]+/i.test(afterTrim) && ['started', 'seemed', 'came', 'got', 'went'].includes(verb)) return true; + if (verb === 'started' && /^[a-z]+ing\b/i.test(afterTrim)) return true; + const lowerAfter = afterTrim.toLowerCase(); + return (this.WEAK_VERB_IDIOMS[verb] || []).some(p => + lowerAfter.startsWith(p) && !/[a-z]/.test(lowerAfter.charAt(p.length))); + }, + // ======================== // WORDY PHRASE DETECTION // ======================== @@ -581,14 +649,25 @@ const Analyzer = { 'out','about','just','very','all','also','how','what','when','where','which','who']); const synMap={said:['stated','replied','remarked','noted','added'],looked:['glanced','gazed','peered','watched','studied'],walked:['strode','moved','paced','strolled','crossed'],made:['created','crafted','formed','produced','built'],came:['arrived','appeared','emerged','approached','entered'],went:['headed','moved','traveled','crossed','departed'],turned:['pivoted','shifted','swung','rotated','spun'],stood:['rose','remained','lingered','waited','stayed'],knew:['understood','recognized','realized','sensed','grasped'],thought:['considered','wondered','reflected','believed','imagined'],felt:['sensed','experienced','noticed','detected','perceived'],took:['grabbed','seized','claimed','accepted','retrieved'],gave:['offered','handed','presented','provided','delivered'],started:['began','initiated','launched','commenced','opened'],seemed:['appeared','looked','sounded','suggested','indicated'],told:['informed','explained','revealed','instructed','described'],asked:['questioned','inquired','wondered','requested','demanded'],eyes:['gaze','stare','glance','look','vision'],face:['expression','features','countenance','visage','look'],hand:['grip','palm','fingers','fist','grasp'],head:['mind','thoughts','skull','brow','temple'],voice:['tone','words','speech','whisper','sound'],door:['entrance','doorway','threshold','entry','gate'],room:['chamber','space','quarters','hall','area'],time:['moment','occasion','instance','period','while'],back:['spine','rear','return','retreat','behind'],long:['extended','prolonged','lengthy','enduring','sustained'],dark:['dim','shadowed','unlit','gloomy','murky'],small:['little','slight','tiny','compact','modest'],found:['discovered','located','uncovered','encountered','spotted'],called:['named','summoned','addressed','hailed','dubbed'],people:['individuals','figures','crowd','group','folk'],world:['realm','domain','land','sphere','landscape'],place:['location','spot','position','site','area'],still:['motionless','calm','quiet','unmoving','yet'],words:['speech','language','phrases','remarks','terms'],thing:['object','matter','item','element','detail'],woman:['figure','lady','person','character','she'],before:['earlier','previously','prior','ahead','formerly'],every:['each','all','entire','whole','total'],never:['rarely','seldom','hardly','not once','at no point'],always:['constantly','perpetually','inevitably','forever','endlessly'],around:['surrounding','about','nearby','encircling','throughout']}; const seen = new Set(); + // Names repeat because they are names. A capitalised word whose lowercase form never + // occurs anywhere in this manuscript is treated as one and never raised; "Alice" is a + // name, "The" and "Poverty" at a sentence start are not. + const lowercaseForms = new Set(text.match(/\b[a-z]{4,}\b/g) || []); for (let i = 0; i < sentBounds.length - 1; i++) { const s1 = sentBounds[i], s2 = sentBounds[i + 1]; const words1 = s1.raw.toLowerCase().match(/\b[a-z]{4,}\b/g) || []; const words2 = s2.raw.toLowerCase().match(/\b[a-z]{4,}\b/g) || []; const set1 = new Set(words1.filter(w => !stopWords.has(w))); + const names = new Set(); + for (const raw of [s1.raw, s2.raw]) { + (raw.match(/\b[A-Z][a-z]{3,}\b/g) || []).forEach(tok => { + const w = tok.toLowerCase(); + if (!lowercaseForms.has(w)) names.add(w); + }); + } for (const word of words2) { - if (set1.has(word) && !stopWords.has(word)) { + if (set1.has(word) && !stopWords.has(word) && !names.has(word)) { // Find the word position within the second sentence's known bounds const wordIdx = lower.indexOf(word, s2.start); if (wordIdx === -1 || wordIdx >= s2.end) continue; @@ -3780,8 +3859,9 @@ const Analyzer = { subScores.languageQuality = Math.round((sh.clarityReadability||0)*0.4+(sh.voiceAuthority||0)*0.3+grammarScore*0.3); } - // Issue density normalized per 1000 words - const issuesPerK = Math.round(allIssues.length / Math.max(totalWords, 1) * 1000 * 10) / 10; + // Issue density normalized per 1000 words, over the findings that actually scored. + const issuesPerK = Math.round(scoredIssues.length / Math.max(totalWords, 1) * 1000 * 10) / 10; + const countScored = t => scoredIssues.reduce((n, i) => n + (i.type === t ? 1 : 0), 0); return { overall, genre, totalWords, manuscriptMode, issuesPerK, @@ -3811,13 +3891,15 @@ const Analyzer = { showTell: { score: showTellScore, issues: showTellIssues }, issues: allIssues, rawIssues, // unfiltered concatenated detector outputs (telemetry / debugging only) + // Counts are of findings that survived validation and were not set aside for their + // passage, so the number beside a score is the number that produced it. issueCounts: { - passive: passiveIssues.length, adverb: adverbIssues.length, - cliche: clicheIssues.length, 'weak-verb': weakVerbIssues.length, - wordy: wordyIssues.length, repetition: repetitionIssues.length, - 'sentence-length': longSentenceIssues.length, 'show-tell': showTellIssues.length, - 'confused-word': confusedWordIssues.length, - grammar: grammarIssues.length, + passive: countScored('passive'), adverb: countScored('adverb'), + cliche: countScored('cliche'), 'weak-verb': countScored('weak-verb'), + wordy: countScored('wordy'), repetition: countScored('repetition'), + 'sentence-length': countScored('sentence-length'), 'show-tell': countScored('show-tell'), + 'confused-word': countScored('confused-word'), + grammar: countScored('grammar'), pov: lineEditing.findings ? lineEditing.findings.filter(f => f.type === 'pov').length : 0, dialogue: dialogue.findings ? dialogue.findings.length : 0 } diff --git a/app.html b/app.html index ba0286d..b999bf9 100644 --- a/app.html +++ b/app.html @@ -5,7 +5,7 @@ AuthorScrolls - Author's Best Buddy - + @@ -439,8 +439,8 @@

Choos - - + + @@ -456,12 +456,12 @@

Choos - + - + diff --git a/app.js b/app.js index d7e5005..2be8b6d 100644 --- a/app.js +++ b/app.js @@ -666,6 +666,11 @@ function _setPanelsHidden(hidden){ // REPLACE & FIX: select the highlight text and use execCommand to replace // This works with native undo (Ctrl+Z) and properly updates the DOM +// Findings the engine set aside for their passage (see prose-norms.js) stay on the record +// with their reason, but they are not what the scores were built from and not what the +// author is asked to act on. +function liveIssues(r){return ((r&&r.issues)||[]).filter(i=>!i.contextSuppressed)} + function replaceAndFix(hlElement){ const type=hlElement.dataset.t; const suggestion=hlElement.dataset.s||''; @@ -1154,10 +1159,10 @@ function showDetail(cat){ const copyTypes=new Set(['passive','adverb','cliche','wordy','confused-word','repetition']); let issueRows=''; if(cat==='sh_clarity'){ - const ci=(r.issues||[]).filter(i=>copyTypes.has(i.type)).slice(0,5); + const ci=liveIssues(r).filter(i=>copyTypes.has(i.type)).slice(0,5); if(ci.length>0)issueRows='
Top copy issues
'+ci.map(i=>'
'+esc(i.text.substring(0,50))+'
'+esc(i.suggestion)+'
').join(''); }else if(cat==='sh_voice'){ - const wi=(r.issues||[]).filter(i=>i.type==='weak-verb').slice(0,5); + const wi=liveIssues(r).filter(i=>i.type==='weak-verb').slice(0,5); if(wi.length>0)issueRows='
Weak verbs weaken authority
'+wi.map(i=>'
'+esc(i.text.substring(0,50))+'
'+esc(i.suggestion)+'
').join(''); }else if(cat==='sh_structure'){ const pi=(r.lineEditing?.findings||[]).filter(f=>f.type==='pov').slice(0,5); @@ -1183,11 +1188,11 @@ function showDetail(cat){ }else if(cat==='plot'){ issues=(r.lineEditing?.findings||[]).filter(f=>f.type==='pov').map(f=>({type:'pov',text:f.message||'',suggestion:f.message||'',severity:f.severity||'medium',index:0,length:0})); }else if(cat==='copy'){ - issues=r.issues.filter(i=>copyTypes.has(i.type)); + issues=liveIssues(r).filter(i=>copyTypes.has(i.type)); }else{ const directMap={pacing:'sentence-length',style:'weak-verb',showTell:'show-tell',grammar:'grammar'}; const t=directMap[cat]; - issues=t?r.issues.filter(i=>i.type===t):[]; + issues=t?liveIssues(r).filter(i=>i.type===t):[]; } // Sort by severity: high first, then medium, then low @@ -1198,7 +1203,9 @@ function showDetail(cat){ // Determine if suggestion has a concrete replacement function hasConcreteFix(iss){ - return !!iss.suggestion.match(/Replace with:\s*".+?"/)||!!iss.suggestion.match(/Try:\s*.+/i)||iss.type==='adverb'||iss.type==='wordy'||iss.type==='cliche'; + // Mechanical fixes and AI-grounded replacements only; a synonym from a fixed list is + // offered for the author to weigh, never applied for them. + return !!iss.suggestion.match(/Replace with:\s*".+?"/)||iss.type==='adverb'||iss.type==='wordy'||iss.type==='cliche'; } // Progress indicator @@ -2036,7 +2043,7 @@ function renderAnnotatedAsPages(text,issues){ if(inlineIssues.length>MAX_INLINE_HL){ const sevRank={high:0,medium:1,low:2}; inlineIssues=[...inlineIssues] - .sort((a,b)=>((sevRank[a.severity]??3)-(sevRank[b.severity]??3))||((b.confidence||0)-(a.confidence||0))) + .sort((a,b)=>((a.contextSuppressed?1:0)-(b.contextSuppressed?1:0))||((sevRank[a.severity]??3)-(sevRank[b.severity]??3))||((b.confidence||0)-(a.confidence||0))) .slice(0,MAX_INLINE_HL); } // Build non-overlapping issues sorted by position @@ -2120,18 +2127,27 @@ function renderAnnotatedAsPages(text,issues){ const labels={passive:'Passive voice detected',adverb:'Adverb detected',cliche:'Cliche detected','weak-verb':'Weak verb detected',wordy:'Wordy phrase','show-tell':'Show vs Tell',repetition:'Word repetition','sentence-length':'Long sentence','confused-word':'Wrong word',grammar:'Grammar issue'}; const t=hl.dataset.t; const sug=hl.dataset.s||''; - // Determine if this issue has an auto-replacement available + const msg=hl.dataset.m||''; + // A finding the engine set aside for this passage is shown quietly, with its reason, + // and never offered as something to fix. + const setAside=hl.classList.contains('hl-ctx')?(hl.dataset.r||'Set aside for this passage.'):''; + // Auto-fix only where the replacement is mechanical (remove an adverb, swap a wordy + // phrase, the mapped clich\u00E9) or came from the AI reader with the sentence in view. + // A verb or synonym from a fixed list is never applied for the author: it is shown + // for them to weigh, and the button takes them to the word instead. const hasAI=!!sug.match(/Replace with:\s*".+?"/); - const hasTry=!!sug.match(/Try:\s*.+/i); - // Types with reliable auto-fix: wordy (has "Replace with"), weak-verb/repetition (has "Try:"), adverb (remove), passive (restructure), cliche (has map) - const canAutoFix=hasAI||hasTry||t==='adverb'||t==='wordy'||t==='cliche'; - const fixLabel=canAutoFix?'Replace & Fix':'Edit Here'; - tip.innerHTML='
'+esc(labels[t]||t)+'
\u2192 Suggestion:
\u201C'+esc(sug)+'\u201D
'; + const canAutoFix=!setAside&&(hasAI||t==='adverb'||t==='wordy'||t==='cliche'); + const fixLabel=canAutoFix?'Replace & Fix':'Edit Here'; + const body=setAside + ?'
Set aside for this passage:
'+esc(setAside)+'
' + :(msg&&t==='weak-verb'?'
'+esc(msg)+'
':'')+'
\u2192 Suggestion:
\u201C'+esc(sug)+'\u201D
'; + tip.innerHTML='
'+esc(labels[t]||t)+'
'+body+'
'+(setAside?'':'')+'
'; tip.classList.add('on'); const rect=hl.getBoundingClientRect(); tip.style.top=(rect.bottom+8)+'px'; tip.style.left=Math.min(rect.left,window.innerWidth-360)+'px'; - $('tip-fix-btn').onclick=()=>{ + const fixBtn=$('tip-fix-btn'); + if(fixBtn)fixBtn.onclick=()=>{ if(canAutoFix){replaceAndFix(activeHL)} else{ // Focus the highlight text for manual editing @@ -2162,7 +2178,8 @@ function getAnnotatedSlice(fullText,start,end,issues){ const iStart=Math.max(i.index,start); const iEnd=Math.min(i.index+i.length,end); if(iStart>pos)h+=esc(fullText.substring(pos,iStart)); - h+=''+esc(fullText.substring(iStart,iEnd))+''; + const setAside=i.contextSuppressed&&i._context?' data-r="'+escA(i._context.reason||'')+'"':''; + h+=''+esc(fullText.substring(iStart,iEnd))+''; pos=iEnd; } if(pos{t.addEventListener('click',()=>{ const d=$('rp-detail');const r=analysisResult; if(mode==='suggestions'){renderRight(r);return} if(mode==='rewrite'){ - const samples=r.issues.filter(i=>_REWRITE_TYPES.has(i.type)).slice(0,8); + const samples=liveIssues(r).filter(i=>_REWRITE_TYPES.has(i.type)).slice(0,8); d.innerHTML='
Rewrite Suggestions Experimental
'+(samples.length===0?'

No rewrite targets found.

':samples.map(i=>'
'+esc(i.text.substring(0,40))+'
'+esc(i.suggestion)+'
\u2018'+esc(i.text.substring(0,60))+'\u2019
').join('')); d.querySelectorAll('.rpd-navigate').forEach(q=>{q.addEventListener('click',()=>{const card=q.closest('.rpd-issue');const issueText=card.dataset.issueText;document.querySelectorAll('.btab').forEach(b=>b.classList.remove('active'));document.querySelectorAll('.ms-page').forEach(p=>p.classList.remove('active'));const ab=document.querySelector('.btab[data-p="annotated"]');if(ab)ab.classList.add('active');$('ed-annotated')?.classList.add('active');const page=$('ed-annotated');const q2=issueText.substring(0,60).replace(/"/g,'"');const hl=page?.querySelector('.hl[data-q="'+q2+'"]');if(hl){hl.scrollIntoView({behavior:'smooth',block:'center'});hl.style.outline='3px solid var(--gold)';hl.style.outlineOffset='3px';setTimeout(()=>{hl.style.outline=''},3000)}})}); d.querySelectorAll('.rpd-rewrite-btn').forEach(btn=>{btn.addEventListener('click',()=>{doRewrite(btn.closest('.rpd-issue'))})}); @@ -2412,7 +2429,7 @@ $('export-btn')?.addEventListener('click',()=>{ const issueLabels={passive:'Passive Voice','weak-verb':'Weak Verb',adverb:'Adverb',cliche:'Cliché',wordy:'Wordy','show-tell':'Show vs Tell',repetition:'Repetition','sentence-length':'Long Sentence'}; // Build annotated HTML - const sorted=[...r.issues].sort((a,b)=>a.index-b.index); + const sorted=[...liveIssues(r)].sort((a,b)=>a.index-b.index); const noOverlap=[];let lastEnd=-1; for(const i of sorted){if(i.index>=lastEnd){noOverlap.push(i);lastEnd=i.index+i.length}} diff --git a/copy-detectors.test.js b/copy-detectors.test.js new file mode 100644 index 0000000..9e14161 --- /dev/null +++ b/copy-detectors.test.js @@ -0,0 +1,75 @@ +'use strict'; +// The inline "suggestions" (weak verb, repetition) must be measurements of THIS manuscript, +// judged for the passage they sit in, never a fixed list applied blindly. +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'); +globalThis.ProseContext=require('./prose-context');globalThis.ProseNorms=require('./prose-norms'); +eval(fs.readFileSync(require.resolve('./analyzer'),'utf8')+'; globalThis.Analyzer=Analyzer;'); + +const weak=(text)=>Analyzer.findWeakVerbs(text,'literary'); +const rep=(text)=>Analyzer.findRepetitions(text); + +test('a participle, a passive and an idiom are not the sentence\'s verb and are never raised',()=>{ + const t=[ + 'Just choices, made quietly, and alone.', // participial phrase + 'The bed was made before dawn.', // passive: another detector's job + 'None of it made sense to her.', // idiom + 'He made up his mind and made for the door.', // particle, idiom + 'She started to run. He seemed to think so. They came to realize it.', // aspectual / raising + 'I thought that he was lying. She thought about it.', // a clause pondered cannot take + ].join(' '); + assert.deepEqual(weak(t),[],JSON.stringify(weak(t).map(i=>i.text))); +}); + +test('a verb the author already varies is left alone; a verb the author leans on is raised with its count',()=>{ + const varied='Mara walked to the gate. Daniel strode after her. Ola trudged behind. Bello walked home. Zara paced the yard. Kemi ambled past. Femi walked on. Tunde sauntered by.'; + assert.deepEqual(weak(varied),[],'walked is 3 of 8 walking verbs: no habit to report'); + const leaning='Mara walked to the gate. Daniel walked toward her. Ola walked behind. Bello walked home. Zara walked the yard. Kemi walked past. Femi walked on.'; + const found=weak(leaning); + assert.ok(found.length>=3,'the habit is raised'); + assert.match(found[0].message,/"walked" carries 7 of the 7 walking verbs/); + assert.match(found[0].message,/per 1,000 words/); + assert.equal(found[0].detail.uses,7); + assert.equal(found[0].detail.family,0); + assert.doesNotMatch(found[0].suggestion,/^Try:/,'the alternatives are offered to weigh, not as a fix to apply'); +}); + +test('shown instances are spread across the manuscript, not taken from its opening',()=>{ + let t='';for(let i=0;i<40;i++)t+='She walked to the well and back. '; + const found=weak(t); + assert.equal(found.length,8,'display cap'); + assert.match(found[0].suggestion,/Showing 8 of 40 uses/); + const span=found[found.length-1].index-found[0].index; + assert.ok(span>t.length*0.7,'instances reach into the last third of the text'); +}); + +test('a plain verb is set aside in explanation and speech, kept in action, with a reason on the record',()=>{ + const exposition='Most people assume that effort is the whole story. I made a study of it. The evidence made a different case. Research generally made this clear. You will find the argument made in every chapter. This is what the data made of it.'; + const action='Bello made a grab for the rope. Zara made a dash for the ditch. Oumar made a lunge at the gate. The herd made a run at the fence. He made a leap for the wall.'; + const r=Analyzer.analyze(exposition+'\n\n'+action,'literary'); + const made=r.issues.filter(i=>i.type==='weak-verb'); + assert.ok(made.length>0,'the verb is raised somewhere'); + const inExposition=made.filter(i=>i.indexi.index>=exposition.length); + assert.ok(inExposition.length>0&&inExposition.every(i=>i.contextSuppressed),'explanatory uses are set aside'); + assert.match(inExposition[0]._context.reason,/explanatory writing/); + assert.ok(inAction.length>0&&inAction.every(i=>!i.contextSuppressed),'action uses are kept'); + // Counts beside the score are counts of what scored. + assert.equal(r.issueCounts['weak-verb'],inAction.length); +}); + +test('a name repeated across sentences is a name, not a repetition',()=>{ + const t='Alice trusted Bob. Alice returned to Bob at dusk. The gate was open. The gate was never open.'; + const found=rep(t).map(i=>i.text); + assert.ok(!found.includes('alice'),found.join(',')); + assert.ok(found.includes('gate'),'an echoed common word is still raised: '+found.join(',')); +}); + +test('repetition in explanatory prose is set aside with a reason, and the count follows the score',()=>{ + const t='Merriam-Webster defines poverty as a lack of money or comfort. But poverty is also the absence of access to healthcare, to education, to what a child needs to grow. It is the choice nobody makes for the child. The overworked parent is doing three jobs at once, and it is affecting more people than we generally admit.'; + const r=Analyzer.analyze(t,'selfHelp'); + const reps=r.issues.filter(i=>i.type==='repetition'); + assert.ok(reps.length>0,'the repetition is still on the record'); + assert.ok(reps.every(i=>i.contextSuppressed),'but set aside for explanatory prose'); + assert.match(reps[0]._context.reason,/explanatory/); + assert.equal(r.issueCounts.repetition,0,'the count beside the score is the count that scored'); +}); diff --git a/fixer.js b/fixer.js index cca6618..64aeda6 100644 --- a/fixer.js +++ b/fixer.js @@ -69,7 +69,9 @@ const Fixer = { }); } - const choice = picked[Math.floor(Math.random() * picked.length)]; + // Deterministic: the same finding always yields the same replacement. A random pick + // from a fixed list was never a judgement about the sentence. + const choice = picked[0]; if (!choice) return candidates[0]; if (original[0] === original[0].toUpperCase()) { return choice.charAt(0).toUpperCase() + choice.slice(1); diff --git a/prose-norms.js b/prose-norms.js index 0dc2b02..176f0b9 100644 --- a/prose-norms.js +++ b/prose-norms.js @@ -75,6 +75,14 @@ const ProseNorms = (() => { } } + if (type === 'weak-verb') { + // Vividness is a virtue of action and description. Elsewhere the plain verb is + // usually the right one, and "crafted" for "made" would be a worse sentence. + if (mode === 'exposition') return { action: 'suppress', reason: 'A plain verb in explanatory writing, where the precision of the point matters more than the vividness of the action.' }; + if (mode === 'dialogue') return { action: 'suppress', reason: 'A plain verb inside speech, which is how people talk.' }; + if (mode === 'reflection') return { action: 'suppress', reason: 'A plain verb in a reflective passage, where the thought is the point and the verb should not compete with it.' }; + } + if (type === 'repetition' && (mode === 'exposition' || mode === 'dialogue')) { return { action: 'suppress', reason: mode === 'dialogue' ? 'Repetition inside speech, which is how people actually talk.' diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js index 297041e..187d605 100644 --- a/scripts/browser-smoke.js +++ b/scripts/browser-smoke.js @@ -7,7 +7,11 @@ const root=path.resolve(__dirname,'..'); const text='Chapter 1: The Gate\n\n'+ 'Alice was the captain of the guard. Bob was her brother. Alice trusted Bob. They waited beside the gate, listening to the rain. '.repeat(8)+ '\n\nChapter 2: The Return\n\nThree days later, Alice returned to the village. Bob betrayed Alice. '+ - 'The old road was empty. She remembered the promise and opened the door. '.repeat(8); + 'The old road was empty. She remembered the promise and opened the door. '.repeat(8)+ + // A habit the author leans on (three "walked", no specific walking verb) in a scene… + '\n\nAlice walked to the gate. Bob walked behind her. They walked in silence and Alice pushed the door open.'+ + // …and an echo inside explanatory prose, which the engine sets aside rather than raises. + '\n\nMost readers assume that loyalty is a matter of effort. Loyalty, research generally suggests, is a matter of circumstance. You can work relentlessly and still lose loyalty.'; async function docx(){ const zip=new JSZip(); zip.file('[Content_Types].xml',''); @@ -109,6 +113,26 @@ function pdf(){ await page.keyboard.press('Control+z'); assert.equal(providerCalls,0,'No automatic paid calls on manuscript open'); await page.screenshot({path:path.join(root,'test-results/editor-desktop.png')}); + // Inline suggestions: a list synonym is shown to weigh, never applied for the author, + // and a finding set aside for its passage is drawn quietly with its reason. + const weakVerb=page.locator('.hl[data-t="weak-verb"]:not(.hl-ctx)').first(); + await weakVerb.waitFor(); + await weakVerb.click(); + const weakTip=await page.locator('#tip').innerText(); + assert.ok(!weakTip.includes('&'),'tooltip label is not double-escaped'); + assert.match(weakTip,/"walked" carries 3 of the 3 walking verbs/,'the finding quotes the manuscript\'s own count'); + assert.match(weakTip,/Edit Here/,'a verb from a fixed list is never auto-applied'); + assert.ok(!/Replace & Fix/.test(weakTip)); + await page.locator('#tip-ign-btn').click(); + const setAside=page.locator('.hl.hl-ctx[data-t="repetition"]').first(); + await setAside.waitFor(); + await setAside.click(); + const asideTip=await page.locator('#tip').innerText(); + assert.match(asideTip,/Set aside for this passage/); + assert.match(asideTip,/explanatory writing/); + assert.equal(await page.locator('#tip-fix-btn').count(),0,'nothing to fix is offered for a set-aside finding'); + await page.locator('#tip-ign-btn').click(); + console.log('PASS inline suggestions: measured weak-verb finding, no auto-apply from a list, set-aside findings muted with reason'); for(const mode of ['characters','threads','review','chapters']){ await page.locator(`[data-wsnav="${mode}"]`).click(); await page.locator(`#workspace-nav-content`).waitFor(); diff --git a/styles.css b/styles.css index 6fb3b0f..65b340d 100644 --- a/styles.css +++ b/styles.css @@ -217,6 +217,9 @@ body.lib-mode{overflow:auto} .hl[data-t="confused-word"]{background:rgba(196,92,74,.22);border-bottom:2px solid var(--red)} .hl.hl-suggest{background:none!important;border-bottom-style:dashed!important;opacity:.6} .hl.hl-suggest:hover{opacity:.85} +/* A finding the engine set aside for its passage: on the record, not on the author's plate. */ +.hl.hl-ctx{background:none!important;border-bottom:1px dotted var(--muted)!important;opacity:.55} +.hl.hl-ctx:hover{opacity:.8} .hl[data-t="bookism"]{background:rgba(212,168,85,.12);border-bottom:2px solid var(--yellow)} .hl.off{background:none!important;border-bottom:none!important} .fix-applied{transition:outline .3s} From a6fece545cea2a553d7a88db8704c21c3ceefd6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 23:09:29 +0000 Subject: [PATCH 2/2] Detailed tab: numbers read off the manuscript, and a hidden ceiling removed Characters: analyzeCharacters built a sentence-start set and never used it, so every capitalised word with three mentions was a character ("Your", "Every", "Consider"). A word is now a name only where grammar did not force the capital: it must not occur in lowercase anywhere in the text, and must be capitalised mid-sentence at least twice (or carry a speech attribution). No list of names is involved. The card is titled "Names in the text" for nonfiction. Lexical diversity: a raw unique/total ratio falls with length by arithmetic (a 70,000-word book scores ~9% however varied), so it is now a moving-average type/token ratio over 500-word windows, stable across length, and labelled as such beside the raw unique-word count. Score bands re-based for that scale. Pacing heatmap: the 200-word segments are now coloured by the five-mode passage classifier already used for context, taking the mode that covers most of each segment and calling ambiguous stretches mixed. The 27-verb keyword estimate remains only as a fallback and is labelled when used. Line editing: every sub-score was base + bonuses that topped out between 45 and 70, so a flawless manuscript could not score above ~61 on Line Editing. Sub-scores now sum to 100. Uncapped raw-count penalties (tone clash, long dialogue paragraphs, filter phrases) are now rates per 1,000 words or shares, capped. A quoted term no longer counts as a dialogue paragraph. Prose with no dialogue no longer forfeits the dialogue components. POV is N/A for nonfiction and left out of the composite rather than scored a fake 45; the nonfiction flow handling lives inside the function so sub-scores and composite always agree. Also brings analyzer-worker.js's import versions in step with app.html; it was still loading analyzer.js?v=25. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VWzYm8L13Mra3ihjhuCM2U --- analyzer-worker.js | 4 +- analyzer.js | 290 +++++++++++++++++++++++++-------------- app.html | 4 +- app.js | 13 +- detailed-metrics.test.js | 77 +++++++++++ scripts/browser-smoke.js | 8 ++ 6 files changed, 284 insertions(+), 112 deletions(-) create mode 100644 detailed-metrics.test.js diff --git a/analyzer-worker.js b/analyzer-worker.js index 755885b..26f7ade 100644 --- a/analyzer-worker.js +++ b/analyzer-worker.js @@ -4,7 +4,9 @@ // Prose context must load BEFORE analyzer.js runs an analysis: analyze() feature-detects // these globals, so without them the worker would silently produce context-free scores while // the main-thread fallback produced context-aware ones. -importScripts('prose-context.js?v=1', 'prose-norms.js?v=1', 'analyzer.js?v=25'); +// Keep these versions in step with app.html: a stale analyzer here means the worker scores +// with old detectors while the page believes it is running the new ones. +importScripts('prose-context.js?v=1', 'prose-norms.js?v=2', 'analyzer.js?v=27'); // Note: _currentVersion tracking here is structurally inert — the Worker JS runtime // is single-threaded, so _currentVersion cannot change while Analyzer.analyze() runs diff --git a/analyzer.js b/analyzer.js index 541e72d..ce721cf 100644 --- a/analyzer.js +++ b/analyzer.js @@ -1708,11 +1708,39 @@ const Analyzer = { // ======================== // STYLE ANALYSIS // ======================== + // Lexical diversity as a moving-average type/token ratio (MATTR). A raw unique/total ratio + // falls with length by arithmetic alone (a 70,000-word book scores ~9% however varied its + // vocabulary), so it cannot be compared across manuscripts or against a threshold. MATTR + // measures how many distinct words appear in each 500-word window and averages the windows, + // which is stable from a short story to a trilogy. + LEXICAL_WINDOW: 500, + movingTypeTokenRatio(tokens, window = this.LEXICAL_WINDOW) { + const n = tokens.length; + if (n === 0) return 0; + if (n <= window) return new Set(tokens).size / n; + const counts = new Map(); + let distinct = 0, sum = 0, windows = 0; + for (let i = 0; i < n; i++) { + const w = tokens[i]; + const c = counts.get(w) || 0; + if (c === 0) distinct++; + counts.set(w, c + 1); + if (i >= window) { + const out = tokens[i - window]; + const oc = counts.get(out); + if (oc === 1) { distinct--; counts.delete(out); } else counts.set(out, oc - 1); + } + if (i >= window - 1) { sum += distinct / window; windows++; } + } + return sum / windows; + }, + analyzeStyle(text) { const words = text.match(/\b[a-z']+\b/gi) || []; const totalWords = words.length; - const uniqueWords = new Set(words.map(w => w.toLowerCase())); - const lexicalDiversity = uniqueWords.size / Math.max(totalWords, 1); + const tokens = words.map(w => w.toLowerCase()); + const uniqueWords = new Set(tokens); + const lexicalDiversity = this.movingTypeTokenRatio(tokens); const avgWordLen = words.reduce((sum, w) => sum + w.length, 0) / Math.max(totalWords, 1); const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0); const paraLengths = paragraphs.map(p => p.split(/\s+/).length); @@ -1721,10 +1749,13 @@ const Analyzer = { const thirdPerson = (text.match(/\b(he|she|they)\b/gi) || []).length; const povConsistency = Math.abs(firstPerson - thirdPerson) / Math.max(firstPerson + thirdPerson, 1); let score = 10; - if (lexicalDiversity > 0.55) score += 30; - else if (lexicalDiversity > 0.45) score += 22; - else if (lexicalDiversity > 0.35) score += 14; - else if (lexicalDiversity > 0.25) score += 6; + // Tiers are for MATTR over 500-word windows, where published adult prose commonly sits + // in the low-to-mid 0.7s; they are approximate bands, and the measured figure is shown + // to the author beside the score so the band never stands in for the number. + if (lexicalDiversity >= 0.72) score += 30; + else if (lexicalDiversity >= 0.65) score += 22; + else if (lexicalDiversity >= 0.58) score += 14; + else if (lexicalDiversity >= 0.50) score += 6; if (avgWordLen > 4.5 && avgWordLen < 6) score += 15; else if (avgWordLen > 4) score += 8; else if (avgWordLen <= 3.5) score -= 5; @@ -1735,15 +1766,26 @@ const Analyzer = { if (Math.sqrt(paraLenVariance) > 20) score += 10; else if (Math.sqrt(paraLenVariance) > 10) score += 5; const pov = firstPerson > thirdPerson * 2 ? 'First Person' : thirdPerson > firstPerson * 2 ? 'Third Person' : 'Mixed'; - return { score: Math.min(100, Math.max(0, score)), totalWords, uniqueWords: uniqueWords.size, lexicalDiversity: Math.round(lexicalDiversity * 100), avgWordLength: Math.round(avgWordLen * 10) / 10, avgParagraphLength: Math.round(avgParaLen), pov, paragraphCount: paragraphs.length }; + return { score: Math.min(100, Math.max(0, score)), totalWords, uniqueWords: uniqueWords.size, lexicalDiversity: Math.round(lexicalDiversity * 100), lexicalWindow: this.LEXICAL_WINDOW, avgWordLength: Math.round(avgWordLen * 10) / 10, avgParagraphLength: Math.round(avgParaLen), pov, paragraphCount: paragraphs.length }; }, // ======================== // PACING ANALYSIS // ======================== + // The heatmap colours each 200-word stretch by the kind of prose it is. Where the + // five-mode passage classifier is available (it always is in the app and its worker) each + // segment takes the mode that covers most of its characters, and an ambiguous stretch is + // called mixed rather than forced. The keyword fallback below is only for environments + // without the classifier. analyzePacing(text) { const words = text.split(/\s+/); const segmentSize = 200; + if (typeof ProseContext !== 'undefined') { + try { + const passages = ProseContext.classify(text); + if (passages && passages.length) return this._pacingFromPassages(text, passages, segmentSize); + } catch (e) { /* fall through to the keyword estimate */ } + } const segments = []; for (let i = 0; i < words.length; i += segmentSize) { const chunk = words.slice(i, i + segmentSize).join(' '); @@ -1765,52 +1807,78 @@ const Analyzer = { else if ((chunkLower.match(/\b(thought|felt|wondered|realized|remembered|considered|reflected|pondered|mused)\b/g) || []).length > 2) type = 'reflection'; segments.push({ type, actionDensity: Math.round(actionDensity * 10) / 10, dialogueDensity: Math.round(dialogueDensity * 10) / 10, descriptionDensity: Math.round(descriptionDensity * 10) / 10, wordCount }); } - return { segments }; + return { segments, source: 'keyword-estimate' }; + }, + + _pacingFromPassages(text, passages, segmentSize) { + const tokenRe = /\S+/g; + const tokens = []; + let t; + while ((t = tokenRe.exec(text)) !== null) tokens.push({ start: t.index, end: t.index + t[0].length }); + const segments = []; + let p = 0; + for (let i = 0; i < tokens.length; i += segmentSize) { + const last = Math.min(i + segmentSize, tokens.length) - 1; + const segStart = tokens[i].start, segEnd = tokens[last].end; + const weight = {}; + while (p > 0 && passages[p].start > segStart) p--; + for (let q = p; q < passages.length && passages[q].start < segEnd; q++) { + const overlap = Math.min(segEnd, passages[q].end) - Math.max(segStart, passages[q].start); + if (overlap > 0) weight[passages[q].mode] = (weight[passages[q].mode] || 0) + overlap; + if (passages[q].end <= segStart) p = q; + } + const ranked = Object.entries(weight).sort((a, b) => b[1] - a[1]); + const total = ranked.reduce((s, [, w]) => s + w, 0) || 1; + const type = ranked.length ? ranked[0][0] : 'mixed'; + segments.push({ type, share: Math.round(ranked.length ? ranked[0][1] / total * 100 : 0), wordCount: last - i + 1 }); + } + return { segments, source: 'passage-classifier' }; }, // ======================== // CHARACTER TRACKING // ======================== + // A capitalised word is evidence of a name only where grammar did not force the capital. + // Two tests, both read off the manuscript rather than a list of names: + // 1. a word that also occurs in lowercase anywhere in the text is a common word + // ("Your", "Consider", "Research", "Poverty"), however often it opens a sentence; + // 2. a word must be capitalised mid-sentence at least twice; one that is only ever + // sentence-initial is not a name. + // Calendar words and manuscript furniture are the only fixed exclusions. analyzeCharacters(text) { const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0); - // Find proper nouns (capitalized words not at sentence start) + const paraBounds = []; + let cursor = 0; + for (const p of paragraphs) { const at = text.indexOf(p, cursor); paraBounds.push({ start: at, end: at + p.length }); cursor = at + p.length; } + const lowercaseForms = new Set(text.match(/\b[a-z]{3,}\b/g) || []); + const fixedSkips = /^(Chapter|Part|Section|Book|Prologue|Epilogue|Introduction|Preface|Foreword|Afterword|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|January|February|March|April|June|July|August|September|October|November|December)$/; const candidates = {}; - const sentenceStartWords = new Set(); - text.split(/[.!?]+/).forEach(s => { - const first = s.trim().split(/\s+/)[0]; - if (first) sentenceStartWords.add(first); - }); - // Common non-name capitalized words - const skipWords = new Set(['The','A','An','In','On','At','To','For','It','He','She','They','We','You','I','My','His','Her','Our','Their','This','That','These','Those','But','And','Or','If','So','Yet','Not','Was','Were','Is','Are','Has','Had','Have','Do','Does','Did','Will','Would','Could','Should','May','Might','Can','Just','Now','Then','Here','There','When','Where','How','What','Who','Why','Which','After','Before','During','While','Although','Because','Since','Until','Unless','Chapter','Part','Section','One','Two','Three','Four','Five']); const nameRegex = /\b([A-Z][a-z]{2,})\b/g; - let match; + let match, pi = 0; while ((match = nameRegex.exec(text)) !== null) { - const name = match[0]; - if (!skipWords.has(name)) { - if (!candidates[name]) candidates[name] = { mentions: 0, paragraphs: new Set(), dialogueCount: 0 }; - candidates[name].mentions++; - // Find which paragraph - let charCount = 0; - for (let p = 0; p < paragraphs.length; p++) { - charCount += paragraphs[p].length + 2; - if (match.index < charCount) { candidates[name].paragraphs.add(p); break; } - } - } + const name = match[1]; + if (lowercaseForms.has(name.toLowerCase()) || fixedSkips.test(name)) continue; + const before = text.slice(Math.max(0, match.index - 8), match.index); + const sentenceInitial = match.index === 0 || /(?:[.!?\u2026]["\u201D\u2019')\]]*\s+|\n\s*|["\u201C\u2018(\[]\s*|:\s+)$/.test(before); + const c = candidates[name] || (candidates[name] = { mentions: 0, mid: 0, paragraphs: new Set(), dialogueCount: 0 }); + c.mentions++; + if (!sentenceInitial) c.mid++; + while (pi < paraBounds.length - 1 && match.index >= paraBounds[pi].end) pi++; + c.paragraphs.add(pi); } - // Check dialogue attribution + // Dialogue attribution ("\u2026" Zara said) is direct evidence of a person. const dialogueAttr = text.match(/[""\u201D]\s*([A-Z][a-z]+)\s+(said|asked|whispered|replied|muttered|exclaimed|shouted|cried)/g) || []; dialogueAttr.forEach(d => { const nameMatch = d.match(/[""\u201D]\s*([A-Z][a-z]+)/); if (nameMatch && candidates[nameMatch[1]]) candidates[nameMatch[1]].dialogueCount++; }); - // Filter to recurring characters (3+ mentions) const list = Object.entries(candidates) - .filter(([_, data]) => data.mentions >= 3) - .sort((a, b) => b[1].mentions - a[1].mentions) - .map(([name, data]) => ({ - name, mentions: data.mentions, - paragraphs: Array.from(data.paragraphs).sort((a,b) => a-b), - dialogueCount: data.dialogueCount + .filter(([_, d]) => d.mentions >= 3 && (d.mid >= 2 || d.dialogueCount >= 1)) + .sort((a, b) => (b[1].mid + b[1].dialogueCount) - (a[1].mid + a[1].dialogueCount) || b[1].mentions - a[1].mentions) + .map(([name, d]) => ({ + name, mentions: d.mentions, midSentence: d.mid, + paragraphs: Array.from(d.paragraphs).sort((a, b) => a - b), + dialogueCount: d.dialogueCount })); return { list }; }, @@ -2313,15 +2381,23 @@ const Analyzer = { // === 1. TONE CONSISTENCY === // Are the word choices serving a consistent emotional register? - let toneScore = 20; + // Every sub-score below is base + components summing to 100, so a clean manuscript can + // reach 100. They used to top out between 45 and 70, which put a hidden ceiling of + // about 61 on the whole Line Editing score. + let toneScore = 40; // Detect tonal clashes: formal words near casual words const formalWords = (lower.match(/\b(nevertheless|furthermore|notwithstanding|henceforth|whereby|therein|aforementioned|commenced|endeavored|subsequently|utilized|ascertained|pursuant|heretofore)\b/g) || []).length; - const casualWords = (lower.match(/\b(gonna|wanna|gotta|kinda|sorta|stuff|things|cool|awesome|totally|basically|literally|super|pretty much|you know|like)\b/g) || []).length; - if (formalWords === 0 && casualWords === 0) toneScore += 25; - else if (formalWords === 0 || casualWords === 0) toneScore += 15; + // "like" and "things" were on the casual list; every book contains both, so every book + // with one "subsequently" read as a tonal clash. + const casualWords = (lower.match(/\b(gonna|wanna|gotta|kinda|sorta|stuff|cool|awesome|totally|basically|literally|super|pretty much|you know)\b/g) || []).length; + // A clash is two registers both present at a rate, not a stray word of each: measured + // per 1,000 words and capped, never a raw count multiplied. + const clashPerK = Math.min(formalWords, casualWords) / Math.max(totalWords, 1) * 1000; + if (formalWords === 0 && casualWords === 0) toneScore += 30; + else if (formalWords === 0 || casualWords === 0 || clashPerK < 0.2) toneScore += 20; else { - toneScore -= (formalWords + casualWords) * 4; - findings.push({ type: 'tone', severity: 'medium', message: 'Tonal clash: ' + formalWords + ' formal words mixed with ' + casualWords + ' casual words. Pick a register and stay consistent.' }); + toneScore -= Math.min(25, Math.round(clashPerK * 10)); + findings.push({ type: 'tone', severity: 'medium', message: 'Tonal clash: ' + formalWords + ' formal words mixed with ' + casualWords + ' casual words (' + clashPerK.toFixed(1) + ' per 1,000 words of the rarer register). Pick a register and stay consistent.' }); } // Detect emotional whiplash (rapid mood shifts without transition) const moodWords = { dark: /\b(dark|death|blood|fear|terror|grief|mourn|scream|agony)\b/gi, light: /\b(laugh|smile|joy|delight|warm|bright|hope|cheer|happy)\b/gi }; @@ -2336,15 +2412,15 @@ const Analyzer = { if (mood) prevMood = mood; }); const moodShiftRate = moodShifts / Math.max(sentences.length, 1); - if (moodShiftRate < 0.05) toneScore += 20; - else if (moodShiftRate < 0.1) toneScore += 10; + if (moodShiftRate < 0.05) toneScore += 30; + else if (moodShiftRate < 0.1) toneScore += 15; else if (moodShifts > sentences.length * 0.15) { toneScore -= 15; findings.push({ type: 'tone', severity: 'low', message: 'Frequent mood shifts (' + moodShifts + ') without transition. Readers may feel disoriented.' }); } // === 2. SENTENCE FLOW (do sentences connect naturally?) === - let flowScore = 15; + let flowScore = 30; // Check for abrupt topic shifts between consecutive sentences let abruptShifts = 0; for (let i = 1; i < sentences.length; i++) { @@ -2358,11 +2434,15 @@ const Analyzer = { } } const abruptRate = abruptShifts / Math.max(sentences.length - 1, 1); - if (abruptRate < 0.1) flowScore += 30; - else if (abruptRate < 0.2) flowScore += 20; - else if (abruptRate < 0.3) flowScore += 10; + // Sentence-to-sentence word overlap is the wrong test for instructional and rhetorical + // prose (short punches, deliberate topic turns, anaphora), so nonfiction is not judged + // on it and takes the middle of the range instead of a penalty. + if (isNFLine) flowScore += 25; + else if (abruptRate < 0.1) flowScore += 40; + else if (abruptRate < 0.2) flowScore += 25; + else if (abruptRate < 0.3) flowScore += 12; else { - flowScore -= Math.round(abruptRate * 40); + flowScore -= Math.min(30, Math.round(abruptRate * 40)); findings.push({ type: 'flow', severity: 'medium', message: Math.round(abruptRate * 100) + '% of sentence transitions are abrupt — no shared context or transition word. Sentences feel disconnected.' }); } // Check sentence rhythm variety (consecutive same-length sentences) @@ -2374,60 +2454,66 @@ const Analyzer = { if (diff1 < 3 && diff2 < 3) monotoneRuns++; } const monotoneRate = monotoneRuns / Math.max(sentences.length, 1); - if (monotoneRate < 0.1) flowScore += 20; - else if (monotoneRate < 0.2) flowScore += 10; + if (monotoneRate < 0.1) flowScore += 30; + else if (monotoneRate < 0.2) flowScore += 15; else if (monotoneRuns > sentences.length * 0.3) { flowScore -= 15; findings.push({ type: 'flow', severity: 'low', message: 'Sentence lengths are too uniform — creates a monotone rhythm. Vary between short punches and longer flowing sentences.' }); } // === 3. WORD PRECISION (are words earning their place?) === - let precisionScore = 15; + let precisionScore = 40; // Vague/imprecise words const vagueWords = (lower.match(/\b(thing|things|stuff|something|somehow|somewhat|somewhere|nice|good|bad|big|small|very|really|quite|rather|pretty|a lot|a bit|kind of|sort of|got|get|went|came|made|did)\b/g) || []).length; const vagueRate = vagueWords / Math.max(totalWords, 1) * 100; - if (vagueRate < 1) precisionScore += 30; - else if (vagueRate < 2) precisionScore += 20; - else if (vagueRate < 3) precisionScore += 10; + if (vagueRate < 1) precisionScore += 40; + else if (vagueRate < 2) precisionScore += 25; + else if (vagueRate < 3) precisionScore += 12; if (vagueRate > 3) { precisionScore -= Math.min(25, Math.round(vagueRate * 4)); findings.push({ type: 'precision', severity: 'medium', message: vagueWords + ' vague/imprecise words (' + vagueRate.toFixed(1) + '%). Replace "thing", "stuff", "nice", "got" with specific language.' }); } // Redundant modifiers (e.g. "completely destroyed", "very unique") const redundantMods = (lower.match(/\b(completely destroyed|totally ruined|very unique|absolutely perfect|completely finished|totally dead|very essential|extremely crucial|quite obvious|rather interesting|pretty good|really nice|very important|absolutely necessary)\b/g) || []).length; - if (redundantMods === 0) precisionScore += 15; + if (redundantMods === 0) precisionScore += 20; else { - precisionScore -= redundantMods * 5; + precisionScore -= Math.min(20, redundantMods * 5); findings.push({ type: 'precision', severity: 'low', message: redundantMods + ' redundant modifier(s) found ("very unique", "completely destroyed"). The modifier adds nothing.' }); } // === 4. PACING RHYTHM (at sentence level) === - let pacingScore = 20; - // Action scenes should have shorter sentences - // Description scenes can be longer - // But overall, variety is key + let pacingScore = 40; + // Variety of sentence length is the measure; a very wide spread is not a fault. const avgLen = lengths.reduce((a, b) => a + b, 0) / lengths.length; const stdDev = Math.sqrt(lengths.reduce((s, l) => s + Math.pow(l - avgLen, 2), 0) / lengths.length); - if (stdDev >= 5 && stdDev <= 12) pacingScore += 25; - else if (stdDev > 3 && stdDev < 5) pacingScore += 15; + if (stdDev >= 5 && stdDev <= 12) pacingScore += 35; + else if (stdDev > 3) pacingScore += 20; if (stdDev < 3) { - pacingScore -= 20; + pacingScore -= 25; findings.push({ type: 'pacing', severity: 'medium', message: 'Very low sentence length variation (stddev: ' + stdDev.toFixed(1) + '). Writing feels mechanical. Mix short and long.' }); } - // Check for dialogue pacing: dialogue paragraphs should be snappy - const dialogueParagraphs = paragraphs.filter(p => /[""\u201C]/.test(p)); + // Dialogue pacing: dialogue paragraphs should be snappy. A dialogue paragraph is one + // that carries a spoken line (a quoted span of some length), not any paragraph with a + // quoted term in it. The penalty is the SHARE of long ones, capped: a raw count times six + // took every long book with quotations to zero, and a book with no dialogue could never + // score above 45 on a dimension it had given no reason to doubt. + // A spoken line opens with a capital after the quote and closes with punctuation before + // it; matching any span between quote marks would pair the closing quote of one quoted + // term with the opening quote of the next. + const dialogueParagraphs = paragraphs.filter(p => /["\u201C][A-Z][^"\u201D\n]{18,}[.,!?\u2026][\u201D"]/.test(p)); const longDialogueParagraphs = dialogueParagraphs.filter(p => p.split(/\s+/).length > 80); - if (dialogueParagraphs.length > 0 && longDialogueParagraphs.length === 0) pacingScore += 15; - else if (longDialogueParagraphs.length > 0) { - pacingScore -= longDialogueParagraphs.length * 6; - findings.push({ type: 'pacing', severity: 'low', message: longDialogueParagraphs.length + ' dialogue paragraph(s) over 80 words. Dialogue should feel snappy — break into shorter exchanges.' }); + const longShare = dialogueParagraphs.length ? longDialogueParagraphs.length / dialogueParagraphs.length : 0; + if (dialogueParagraphs.length === 0 || longShare < 0.1) pacingScore += 25; + else { + pacingScore -= Math.min(25, Math.round(longShare * 50)); + findings.push({ type: 'pacing', severity: 'low', message: longDialogueParagraphs.length + ' of ' + dialogueParagraphs.length + ' dialogue paragraphs run over 80 words (' + Math.round(longShare * 100) + '%). Dialogue should feel snappy — break into shorter exchanges.' }); } // === 5. POV DISCIPLINE === // POV discipline is a FICTION craft concept. Memoir and self-help legitimately mix // first-person narration ("I grew up...") with third-person anecdotes ("she never // gave up") and direct address ("you can..."). Score neutral for nonfiction. - let povScore = 20; + let povScore = 50; // Strip quoted dialogue before counting narration pronouns — characters saying // "I think..." in a third-person book (or "he did it!" in a first-person book) // is speech, not narration, and was massively skewing these counts. @@ -2437,30 +2523,30 @@ const Analyzer = { const secondYou = (narrationOnly.match(/\byou\b/gi) || []).length; const dominant = firstPerson > thirdHeShe ? 'first' : thirdHeShe > firstPerson ? 'third' : 'mixed'; if (isNFLine) { - povScore = 45; // neutral — matches the max a clean fiction manuscript earns + povScore = null; // not a nonfiction dimension: reported N/A and left out of the composite } else { // Check for POV slips — ratio-based, never absolute counts. // A first-person narrator constantly refers to other people as he/she; that is NOT a // POV problem. The old check flagged any first-person book with >5 he/she pronouns // in the entire manuscript, which is every first-person book ever written. const thirdShare = thirdHeShe / Math.max(firstPerson + thirdHeShe, 1); - if (dominant === 'third' && firstPerson <= 1) povScore += 25; - else if (dominant === 'first' && thirdShare < 0.45) povScore += 25; - else if (dominant === 'mixed') povScore += 5; + if (dominant === 'third' && firstPerson <= 1) povScore += 50; + else if (dominant === 'first' && thirdShare < 0.45) povScore += 50; + else if (dominant === 'mixed') povScore += 10; if (dominant === 'third' && firstPerson > 2) { const slipRate = firstPerson / (firstPerson + thirdHeShe); // A slip is a partial leak of "I" into third-person narration. A large share // (>= 0.35) means the book is intentionally first-person-with-a-large-cast or // deliberately mixed POV — not an accident, so don't flag it. if (slipRate > 0.05 && slipRate < 0.35) { - povScore -= 15; + povScore -= 25; findings.push({ type: 'pov', severity: 'high', message: 'POV slip: ' + firstPerson + ' first-person ("I") occurrences in third-person narrative. Unless intentional, stay consistent.' }); } } // Only flag first-person narration when third-person pronouns nearly MATCH first-person // usage across the book — i.e. the narration genuinely drifts between modes. if (dominant === 'first' && thirdShare > 0.45 && thirdHeShe > 50) { - povScore -= 10; + povScore -= 20; findings.push({ type: 'pov', severity: 'medium', message: 'Narration alternates heavily between first person ("I") and third person (he/she). If sections are intentionally in different POVs, ignore this; otherwise pick one mode per scene.' }); } // Head-hopping: in third person limited, check if we see multiple characters\' thoughts @@ -2469,25 +2555,28 @@ const Analyzer = { const heThoughts = thoughtVerbs.filter(t => /^he/i.test(t)).length; const sheThoughts = thoughtVerbs.filter(t => /^she/i.test(t)).length; if (heThoughts > 0 && sheThoughts > 0) { - povScore -= 15; + povScore -= 25; findings.push({ type: 'pov', severity: 'medium', message: 'Possible head-hopping: both "he thought/felt/knew" (' + heThoughts + ') and "she thought/felt/knew" (' + sheThoughts + '). In limited third person, only one character\'s thoughts should be accessible per scene.' }); } } } // === 6. EXTRANEOUS LANGUAGE === - let extraneousScore = 20; + let extraneousScore = 30; // "began to", "started to" — just do the action const beganTo = (lower.match(/\b(began to|started to|proceeded to|continued to|attempted to|happened to|managed to)\b/g) || []).length; - if (beganTo === 0) extraneousScore += 20; + const beganPerK = beganTo / Math.max(totalWords, 1) * 1000; + // Rate per 1,000 words, capped: five "began to" in a novel is not the same habit as + // five in a short story, and a raw count times four floored every long book. + if (beganPerK < 0.5) extraneousScore += 25; else { - extraneousScore -= beganTo * 4; - findings.push({ type: 'extraneous', severity: 'low', message: beganTo + ' filter phrase(s): "began to", "started to", etc. Cut the filter — just do the action. "She began to run" → "She ran."' }); + extraneousScore -= Math.min(25, Math.round(beganPerK * 6)); + findings.push({ type: 'extraneous', severity: 'low', message: beganTo + ' filter phrases (' + beganPerK.toFixed(1) + ' per 1,000 words): "began to", "started to", etc. Cut the filter — just do the action. "She began to run" → "She ran."' }); } // "that" overuse const thatCount = (lower.match(/\bthat\b/g) || []).length; const thatRate = thatCount / Math.max(totalWords, 1) * 100; - if (thatRate < 1.5) extraneousScore += 15; + if (thatRate < 1.5) extraneousScore += 20; else if (thatRate > 2.5) { extraneousScore -= 12; findings.push({ type: 'extraneous', severity: 'low', message: '"That" appears ' + thatCount + ' times (' + thatRate.toFixed(1) + '%). Many can be removed: "She knew that he was" → "She knew he was."' }); @@ -2496,9 +2585,10 @@ const Analyzer = { // Dialogue attribution overload const attributions = (text.match(/[""\u201D]\s*(he|she|they|I)\s+(said|asked|replied|answered|whispered|shouted|muttered|exclaimed|declared|responded|cried|yelled|stated|remarked|noted)\b/gi) || []).length; const dialogueLines = (text.match(/[""\u201C][^""\u201D]*[""\u201D]/g) || []).length; - if (dialogueLines > 0 && attributions / dialogueLines < 0.5) extraneousScore += 15; - else if (dialogueLines > 0 && attributions / dialogueLines > 0.8) { - extraneousScore -= 10; + // No dialogue is no fault: the component is neutral rather than forfeited. + if (dialogueLines === 0 || attributions / dialogueLines < 0.5) extraneousScore += 25; + else if (attributions / dialogueLines > 0.8) { + extraneousScore -= 15; findings.push({ type: 'extraneous', severity: 'low', message: 'Dialogue is over-attributed (' + attributions + '/' + dialogueLines + ' lines tagged). In two-person dialogue, you can drop most tags after establishing who\'s speaking.' }); } @@ -2507,10 +2597,15 @@ const Analyzer = { flowScore = Math.min(100, Math.max(0, flowScore)); precisionScore = Math.min(100, Math.max(0, precisionScore)); pacingScore = Math.min(100, Math.max(0, pacingScore)); - povScore = Math.min(100, Math.max(0, povScore)); + if (povScore !== null) povScore = Math.min(100, Math.max(0, povScore)); extraneousScore = Math.min(100, Math.max(0, extraneousScore)); - const score = Math.round(toneScore * 0.15 + flowScore * 0.2 + precisionScore * 0.2 + pacingScore * 0.15 + povScore * 0.15 + extraneousScore * 0.15); + // Weighted over the dimensions that apply; a non-applicable one (POV in nonfiction) is + // left out and the remaining weights renormalised, never replaced by a neutral number. + const parts = [[toneScore, 0.15], [flowScore, 0.2], [precisionScore, 0.2], [pacingScore, 0.15], [povScore, 0.15], [extraneousScore, 0.15]] + .filter(([v]) => v !== null); + const weightSum = parts.reduce((s, [, w]) => s + w, 0); + const score = Math.round(parts.reduce((s, [v, w]) => s + v * w, 0) / weightSum); return { score, @@ -2518,8 +2613,8 @@ const Analyzer = { flow: { score: flowScore, abruptShifts, abruptRate: Math.round(abruptRate * 100), monotoneRuns }, precision: { score: precisionScore, vagueWords, vagueRate: Math.round(vagueRate * 10) / 10, redundantMods }, pacing: { score: pacingScore, avgSentenceLength: Math.round(avgLen), stdDev: Math.round(stdDev * 10) / 10, longDialogueParagraphs: longDialogueParagraphs.length }, - pov: { score: povScore, dominant, firstPerson, thirdPerson: thirdHeShe, slips: findings.filter(f => f.type === 'pov').length }, - extraneous: { score: extraneousScore, beganTo, thatCount, thatRate: Math.round(thatRate * 10) / 10, overAttributed: dialogueLines > 0 && attributions / dialogueLines > 0.8 }, + pov: { score: povScore, applicable: povScore !== null, dominant, firstPerson, thirdPerson: thirdHeShe, slips: findings.filter(f => f.type === 'pov').length }, + extraneous: { score: extraneousScore, beganTo, beganPerK: Math.round(beganPerK * 10) / 10, thatCount, thatRate: Math.round(thatRate * 10) / 10, overAttributed: dialogueLines > 0 && attributions / dialogueLines > 0.8 }, findings }; }, @@ -3799,20 +3894,9 @@ const Analyzer = { const totalWords = (text.match(/\b\w+\b/g) || []).length; const copyScore = this.scoreCopyEditing(narrativeIssues, narrativeWords); + // Nonfiction handling (flow not judged on word overlap, POV not applicable) lives inside + // analyzeLineEditing so the sub-scores and the composite always agree. const lineEditing = this.analyzeLineEditing(aText, genre); - // Nonfiction flow softening: sentence-to-sentence word-overlap is the wrong metric for - // instructional/rhetorical prose (short punchy sentences, deliberate topic shifts, anaphora). - // Clamp flowScore to minimum 45 and recompute composite score so it can't torpedo the line score. - if (isNF && lineEditing.flow && lineEditing.flow.score < 45) { - lineEditing.flow.score = 45; - lineEditing.findings = lineEditing.findings.filter(f => f.type !== 'flow'); - // Recompute composite using the same weights as analyzeLineEditing - lineEditing.score = Math.round( - (lineEditing.tone?.score||0)*0.15 + 45*0.2 + - (lineEditing.precision?.score||0)*0.2 + (lineEditing.pacing?.score||0)*0.15 + - (lineEditing.pov?.score||0)*0.15 + (lineEditing.extraneous?.score||0)*0.15 - ); - } const lineScore = lineEditing.score; // Show/Tell: normalize per 1000 NARRATIVE words so long manuscripts aren't unfairly floored to 0. const showTellPerK = (showTellIssues.length / Math.max(narrativeWords, 1)) * 1000; diff --git a/app.html b/app.html index b999bf9..b6c1944 100644 --- a/app.html +++ b/app.html @@ -440,7 +440,7 @@

Choos - + @@ -461,7 +461,7 @@

Choos - + diff --git a/app.js b/app.js index 2be8b6d..c8a2a77 100644 --- a/app.js +++ b/app.js @@ -1321,7 +1321,7 @@ function renderDetailed(r){ lineRows.push(sr('Sentence Flow',(le.flow?.score??0)+'/100')); lineRows.push(sr('Word Precision',(le.precision?.score??0)+'/100')); lineRows.push(sr('Pacing Rhythm',(le.pacing?.score??0)+'/100')); - lineRows.push(sr('POV Discipline',(le.pov?.score??0)+'/100')); + lineRows.push(sr('POV Discipline',le.pov?.score==null?'N/A — not a nonfiction dimension':le.pov.score+'/100')); lineRows.push(sr('Extraneous Language',(le.extraneous?.score??0)+'/100')); // Show findings if(le.findings&&le.findings.length>0){ @@ -1335,7 +1335,7 @@ function renderDetailed(r){ lineRows.push(sr('Readability Grade',r.readability?.grade||'N/A')); lineRows.push(sr('Flesch Ease',(r.readability?.ease||0)+'/100')); h+=secWithTip('Line Editing',r.scores?.line||0,lineRows,'line'); - h+=sec('Style & Voice',r.scores?.style||0,[sr('POV',r.style?.pov||'N/A'),sr('Lexical Diversity',(r.style?.lexicalDiversity||0)+'/100'),sr('Unique Words',(r.style?.uniqueWords||0).toLocaleString())]); + h+=sec('Style & Voice',r.scores?.style||0,[sr('POV',r.style?.pov||'N/A'),sr('Lexical Diversity',(r.style?.lexicalDiversity||0)+'% distinct words per '+(r.style?.lexicalWindow||500)+'-word window'),sr('Unique Words',(r.style?.uniqueWords||0).toLocaleString())]); // Dialogue (deep analysis) const dl=r.dialogue||{count:0,ratio:0}; const dlRows=[dl.count===0?'No dialogue detected.':'']; @@ -1364,10 +1364,11 @@ function renderDetailed(r){ } if(r.scores?.dialogue!=null)h+=sec('Dialogue',r.scores.dialogue,dlRows); // Pacing heatmap - if(r.pacing){const cols={action:'#c0392b',dialogue:'#2980b9',description:'#27ae60',exposition:'#f39c12',reflection:'#8e44ad'}; - h+='

Pacing Heatmap

'+r.pacing.segments.map((s,i)=>'
').join('')+'
ActionDialogueDescriptionExpositionReflection
'} - // Characters - if(r.characters?.list?.length>0){const mx=Math.max(...r.characters.list.map(c=>c.mentions));h+='

Characters

'+r.characters.list.map(c=>'
'+esc(c.name)+'
'+c.mentions+' mentions
').join('')+'
'} + if(r.pacing){const cols={action:'#c0392b',dialogue:'#2980b9',description:'#27ae60',exposition:'#f39c12',reflection:'#8e44ad',mixed:'#7f8c8d'}; + const hmNote=r.pacing.source==='passage-classifier'?'Each block is 200 words, coloured by the kind of prose that covers most of it.':'Keyword estimate.'; + h+='

Pacing Heatmap

'+r.pacing.segments.map((s,i)=>'
').join('')+'
ActionDialogueDescriptionExpositionReflectionMixed

'+hmNote+'

'} + // Names: only words capitalised where grammar did not force it (see Analyzer.analyzeCharacters) + if(r.characters?.list?.length>0){const mx=Math.max(...r.characters.list.map(c=>c.mentions));const chTitle=Analyzer.isNonfiction(r.genre)?'Names in the text':'Characters';h+='

'+chTitle+'

'+r.characters.list.map(c=>'
'+esc(c.name)+'
'+c.mentions+' mentions
').join('')+'
'} // Genre-Specific Elements Scanner if(r.genreElements&&r.genreElements.applicable){ const ge=r.genreElements; diff --git a/detailed-metrics.test.js b/detailed-metrics.test.js new file mode 100644 index 0000000..6d44c20 --- /dev/null +++ b/detailed-metrics.test.js @@ -0,0 +1,77 @@ +'use strict'; +// The Detailed tab must show numbers read off the manuscript. These pin the four that were +// not: names counted from sentence-initial capitals, a lexical ratio that fell with length, +// a pacing sub-score floored by an uncapped count, and a heatmap that saw only exposition. +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'); +globalThis.ProseContext=require('./prose-context');globalThis.ProseNorms=require('./prose-norms'); +eval(fs.readFileSync(require.resolve('./analyzer'),'utf8')+'; globalThis.Analyzer=Analyzer;'); + +test('names are words capitalised where grammar did not force it; sentence openers are not names',()=>{ + const t=['Your first job is to notice. Most people never do. Every morning you wake and let the story run. Consider what it costs.', + 'I grew up in Lagos with a mother who worked three jobs; Zara, my sister, worked two. Zara taught me patience. "Start now," Zara said.', + 'Take a piece of paper. Write down one goal. Whether you succeed matters less than whether you begin. Patience is not waiting.', + 'Research suggests that context shapes behaviour. Research is not a verdict. Research, at best, is a lamp.'].join('\n\n'); + const names=Analyzer.analyzeCharacters(t).list.map(c=>c.name); + assert.ok(names.includes('Zara'),names.join(',')); + for(const w of ['Your','Most','Every','Consider','Take','Write','Whether','Patience','Research']) + assert.ok(!names.includes(w),w+' is a sentence opener, not a name: '+names.join(',')); +}); + +test('lexical diversity is stable across length instead of collapsing with it',()=>{ + const base='The harbour lay quiet under a thin morning haze, and Ola counted the boats twice before she trusted the number. Nothing moved but a heron, patient as a debt. She wrote the figure in the ledger, closed it, and walked the length of the pier to where the old man mended his nets with fingers that no longer felt the cold. '; + const short=Analyzer.analyzeStyle(base.repeat(8)); + const long=Analyzer.analyzeStyle(base.repeat(200)); + assert.ok(Math.abs(short.lexicalDiversity-long.lexicalDiversity)<=3,'short '+short.lexicalDiversity+' vs long '+long.lexicalDiversity); + assert.equal(long.lexicalWindow,500); + assert.ok(long.uniqueWords<=short.uniqueWords+1,'unique-word count is the raw figure and is reported separately'); +}); + +test('pacing rhythm is not floored by counting quoted paragraphs, and no-dialogue prose is not capped at 45',()=>{ + const said='"'+'We should have left before the rain came, and now the road is gone and the bridge with it, so tell me what you would have me do,'.repeat(2)+'" she said, and then she said a great deal more about the bridge and the rain and the road, most of it repeated, until the paragraph ran well past eighty words and nobody in the room could remember how it had begun.'; + const plain='The road was gone. The bridge had gone with it. She counted what remained: a lamp, two ropes, and the long afternoon.'; + const heavy=Analyzer.analyzeLineEditing(Array(30).fill(said).join('\n\n')+'\n\n'+plain,'literary'); + assert.ok(heavy.pacing.score>0,'thirty long dialogue paragraphs cost a capped penalty, not the whole score: '+heavy.pacing.score); + const none=Analyzer.analyzeLineEditing(Array(30).fill(plain).join('\n\n'),'selfHelp'); + assert.ok(none.pacing.score>45,'prose without dialogue can score above the old ceiling: '+none.pacing.score); + const quotedTerm='He called it "grit". The word did the work of a paragraph. '.repeat(20); + const terms=Analyzer.analyzeLineEditing(quotedTerm,'selfHelp'); + assert.equal(terms.pacing.longDialogueParagraphs,0,'a quoted term is not a dialogue paragraph'); +}); + +test('a clean manuscript is not held under a hidden ceiling, and POV is N/A for nonfiction',()=>{ + const plain='The road was gone. The bridge had gone with it. She counted what remained: a lamp, two ropes, and the long afternoon nobody had asked for.'; + const nf=Analyzer.analyzeLineEditing(Array(30).fill(plain).join('\n\n'),'selfHelp'); + for(const dim of ['tone','flow','precision','pacing','extraneous']) + assert.ok(nf[dim].score>=80,dim+' can score high when nothing is wrong: '+nf[dim].score); + assert.equal(nf.pov.score,null); + assert.equal(nf.pov.applicable,false); + assert.ok(nf.score>=85,'the composite is over the applicable dimensions only: '+nf.score); + const fic=Analyzer.analyzeLineEditing(Array(30).fill(plain).join('\n\n'),'literary'); + assert.ok(fic.pov.score>=90,'consistent third person: '+fic.pov.score); +}); + +test('a stray "like" and one formal word are not a tonal clash',()=>{ + const t=('She looked like her mother and, subsequently, like nobody at all. The things she kept were small. ').repeat(40); + const le=Analyzer.analyzeLineEditing(t,'literary'); + assert.ok(!le.findings.some(f=>f.type==='tone'),JSON.stringify(le.findings)); +}); + +test('filter phrases are rated per thousand words, not counted raw',()=>{ + const body='The lamp burned low. She read the page again and did not believe it. '.repeat(400); // ~5,600 words + const withFive=body+'She began to read. He started to speak. They continued to walk. She managed to smile. He attempted to explain.'; + const le=Analyzer.analyzeLineEditing(withFive,'literary'); + assert.ok(le.extraneous.score>=40,'five filter phrases in 5,600 words do not zero the score: '+le.extraneous.score); + assert.equal(le.extraneous.beganTo,5); +}); + +test('the heatmap follows the passage classifier and can see more than exposition',()=>{ + const action='He ran for the gate. Bello grabbed his arm and shoved him back. The rope snapped. Zara leapt the ditch, stumbled, caught herself on the post. Behind them the herd burst through the fence. '; + const dialogue='"You cannot go back," Oumar said. "Not tonight." Zara turned on him. "Watch me." "They will be waiting at the crossing," he said. "I know," she said. "That is the point." '; + const exposition='Most people assume that poverty is a failure of effort. Research suggests otherwise: circumstance, opportunity and timing generally account for more variation than motivation does. You can work relentlessly and still lose ground. '; + const t=action.repeat(8)+'\n\n'+dialogue.repeat(8)+'\n\n'+exposition.repeat(8); + const pacing=Analyzer.analyzePacing(t); + assert.equal(pacing.source,'passage-classifier'); + const types=new Set(pacing.segments.map(s=>s.type)); + assert.ok(types.has('action')&&types.has('dialogue')&&types.has('exposition'),[...types].join(',')); + assert.equal(pacing.segments.reduce((n,s)=>n+s.wordCount,0),t.trim().split(/\s+/).length); +}); diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js index 187d605..925994a 100644 --- a/scripts/browser-smoke.js +++ b/scripts/browser-smoke.js @@ -133,6 +133,14 @@ function pdf(){ assert.equal(await page.locator('#tip-fix-btn').count(),0,'nothing to fix is offered for a set-aside finding'); await page.locator('#tip-ign-btn').click(); console.log('PASS inline suggestions: measured weak-verb finding, no auto-apply from a list, set-aside findings muted with reason'); + // Detailed tab: names are read from where the capital was not forced by grammar. + await page.locator('.btab[data-p="detailed"]').click(); + const detailedNames=await page.locator('#ed-detailed .ch-name').allInnerTexts(); + assert.ok(detailedNames.includes('Alice')&&detailedNames.includes('Bob'),detailedNames.join(',')); + for(const w of ['The','They','She','Three','Most','Loyalty']) assert.ok(!detailedNames.includes(w),w+' is not a character: '+detailedNames.join(',')); + assert.match(await page.locator('#ed-detailed').innerText(),/distinct words per 500-word window/); + await page.locator('.btab[data-p="annotated"]').click(); + console.log('PASS detailed tab: characters are names, lexical diversity is window-based'); for(const mode of ['characters','threads','review','chapters']){ await page.locator(`[data-wsnav="${mode}"]`).click(); await page.locator(`#workspace-nav-content`).waitFor();