// Rules-based prose checker — NO LLM. This is the companion's "smart" side: it // reads the writer's actual text and surfaces one gentle, context-aware grammar // or style note at a time, the way an attentive tutor leaning over her shoulder // would. Every rule here is intentionally conservative: a wrong nudge erodes // trust far faster than a missed one earns it, so we only speak up when a // pattern is a high-confidence, genuinely-common English mistake. // // Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting // a short slice of her own sentence so the advice clearly belongs to *this* // paragraph and not a generic tip jar. // // Two consumers share these rules (one source of truth, no duplication): // • the companion bubbles — awareness notes, shown one at a time (analyzeProse) // • the suggestion cards — applyable one-click fixes (mechanicsFindings) // A rule is "applyable" when it can name an exact span and a single replacement; // it attaches a `fix` and feeds the cards. Awareness-only rules (run-ons, comma // splices, …) carry no `fix` and stay companion bubbles. The companion hides // fix-bearing hints so the same span never appears as both a bubble and a card. import type { Line } from './tips' export interface ProseHint extends Line { // Stable, content-derived id so the same untouched sentence isn't re-flagged // every cadence; the companion remembers recently-shown ids. id: string // Rule family, used to vary advice (don't show the same kind twice in a row). rule: string // Present only on *applyable* findings: the exact span and the text to swap in. // These become suggestion cards; the companion skips them (see useCompanion). fix?: Fix } // An exact, one-click edit: replace text[from:to] with `replacement`. export interface Fix { from: number to: number replacement: string } // A deterministic suggestion-card finding, derived from an applyable hint. Mirrors // the backend's card shape (original/replacement/explanation + span) so the card // pipeline can persist it as the 'mechanics' family. `explanation` is the English // note; the card's own translate action renders Mandarin on demand. export interface MechanicsFinding { from: number to: number original: string replacement: string explanation: string } // ── small text helpers ────────────────────────────────────────────────────── const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g // Split into sentence-ish chunks (text + leading punctuation trimmed). Mirrors // the SENTENCE_RE terminators used elsewhere, including CJK fullwidth forms. function sentences(text: string): string[] { const out: string[] = [] const re = /[^.!?。!?]+[.!?。!?]*/g let m: RegExpExecArray | null while ((m = re.exec(text))) { const s = m[0].trim() if (s) out.push(s) } return out } // First few words of a sentence, as an anchor we can quote back to her. function anchor(s: string, n = 6): string { const words = s.split(/\s+/).slice(0, n).join(' ') return words.length < s.length ? `${words}…` : words } // Cheap, stable hash → short id suffix, so ids stay stable across recomputes but // change the moment she edits the offending text. function key(s: string): string { return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48) } // Capitalize `repl`'s first letter when `original` started uppercase, so fixing a // sentence-initial word doesn't quietly lowercase the line. function matchCase(original: string, repl: string): string { if (!original || !repl) return repl const c = original[0] if (c >= 'A' && c <= 'Z') return repl[0].toUpperCase() + repl.slice(1) return repl } // ── individual rules ──────────────────────────────────────────────────────── // Each rule pushes its findings; awareness rules cap themselves to a couple so a // single sentence can't flood the bubble, while applyable rules report every // occurrence (each becomes its own card). Applyable rules attach a `fix`. // Run-on / overly long sentences — the single most common readability problem // for ESL writers, who often chain clauses that a period would serve better. // Awareness-only: there is no single mechanical fix for "split this sentence". function runOns(text: string, out: ProseHint[]) { let found = 0 for (const s of sentences(text)) { if (found >= 2) break const words = s.match(ENGLISH_WORD_RE)?.length ?? 0 const commas = (s.match(/,/g) ?? []).length const longRun = words >= 32 const commaHeavy = words >= 24 && commas >= 2 if (longRun || commaHeavy) { found++ out.push({ id: `runon:${key(s)}`, rule: 'runon', zh: '这句话有点长啦,分成两三句会更清楚 🌸', en: `This one runs long — try splitting it: “${anchor(s)}”`, }) } } } // Comma splice — two independent clauses joined by only a comma. We look for a // comma immediately followed by a subject pronoun + a finite verb, which is a // strong, low-false-positive signal of a spliced second clause. // A comma, then a subject pronoun starting a fresh clause, then its verb. We no // longer whitelist verbs (that lost too many real splices) — instead we take any // word after the pronoun and reject the function words that signal it *isn't* a // new clause (a conjunction/preposition list like “she and I”, “it of course”). const SPLICE_RE = /[a-z]\w*,\s+(I|we|they|he|she|it|you)\s+([a-z]+)\b/g const SPLICE_NONVERB = new Set([ 'and', 'or', 'but', 'nor', 'yet', 'so', 'to', 'too', 'also', 'of', 'in', 'on', 'at', 'for', 'with', 'as', 'who', 'whom', 'which', 'that', 'than', 'then', 'will', // “…, I will, …” is usually an aside, not a clean splice ]) // First words that signal the lead is an *introductory* element (transition, // subordinate clause, or adverbial phrase), where a following “, subject verb” // is correct punctuation — not a splice. Guards against “However, we left.” and // “After dinner, I went home.” const INTRO_LEAD = new Set([ 'after', 'before', 'when', 'while', 'although', 'though', 'because', 'since', 'if', 'as', 'until', 'unless', 'whenever', 'wherever', 'whereas', 'however', 'therefore', 'moreover', 'furthermore', 'nevertheless', 'nonetheless', 'meanwhile', 'consequently', 'otherwise', 'besides', 'instead', 'finally', 'first', 'firstly', 'second', 'secondly', 'next', 'then', 'later', 'eventually', 'afterwards', 'anyway', 'yesterday', 'today', 'tomorrow', 'honestly', 'actually', 'suddenly', 'sometimes', 'usually', 'often', 'also', 'still', 'now', 'here', 'there', 'well', 'yes', 'no', 'fortunately', 'unfortunately', 'surprisingly', 'clearly', 'obviously', 'basically', 'generally', 'initially', 'recently', 'currently', 'sadly', 'luckily', 'hopefully', 'frankly', 'personally', 'overall', 'soon', 'once', 'during', 'in', 'on', 'at', 'for', 'with', 'without', 'by', 'from', 'despite', 'throughout', 'given', 'regarding', ]) // A genuine comma splice has an *independent clause* before the comma. We // approximate that by requiring the lead to be ≥3 words and not begin with an // introductory word — short or transition-led leads are intro phrases, not // spliced clauses. Precision over recall: a wrong nudge costs more than a miss. // Awareness-only: the fix is ambiguous (period vs. a joining word). function commaSplices(text: string, out: ProseHint[]) { let found = 0 for (const s of sentences(text)) { if (found >= 2) break SPLICE_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = SPLICE_RE.exec(s)) && found < 2) { const commaIdx = s.indexOf(',', m.index) const lead = s.slice(0, commaIdx).trim() const words = lead.split(/\s+/).filter(Boolean) const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? '' if (words.length < 3 || INTRO_LEAD.has(firstWord)) continue if (SPLICE_NONVERB.has(m[2].toLowerCase())) continue found++ out.push({ id: `splice:${key(m[0])}`, rule: 'splice', zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。', en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`, }) } } } // Unclear antecedent — a sentence that opens with This/That/These/Those riding // straight into a verb, with no noun naming what it points back to. Only flag // when there's a prior sentence (so an antecedent is actually in question). // Awareness-only: naming the referent needs the writer. const ANTECEDENT_RE = /^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/ function antecedents(text: string, out: ProseHint[]) { const list = sentences(text) let found = 0 for (let i = 1; i < list.length && found < 2; i++) { const s = list[i] const m = s.match(ANTECEDENT_RE) if (m) { found++ out.push({ id: `antecedent:${key(s)}`, rule: 'antecedent', zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`, en: `“${m[1]}” here is a little vague — name what it refers to.`, }) } } } // Oxford comma — a 3+ item list ending “… X and Y” with no comma before the // conjunction. Guarded by a clause-starter stoplist so we don't mistake a // compound clause (“…, but she and I…”) for a list. Awareness-only: inserting // the serial comma is borderline-stylistic, so we nudge rather than auto-edit. const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g const CLAUSE_STARTERS = new Set([ 'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if', 'although', 'though', 'since', 'as', 'and', 'or', 'nor', 'yet', 'then', 'however', 'whereas', // Subject pronouns signal a clause, not a list item (“…, I went home and…”). 'i', 'you', 'he', 'she', 'it', 'we', 'they', ]) function oxford(text: string, out: ProseHint[]) { let m: RegExpExecArray | null let found = 0 OXFORD_RE.lastIndex = 0 while ((m = OXFORD_RE.exec(text)) && found < 1) { const firstWord = m[2].split(/\s+/)[0]?.toLowerCase() ?? '' if (CLAUSE_STARTERS.has(firstWord)) continue found++ out.push({ id: `oxford:${key(m[0])}`, rule: 'oxford', zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。', en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`, }) } } // A transition word opening a sentence with no comma after it (“However we…”). // Limited to conjunctive adverbs that strongly want the comma — sequence words // like “Then/First/Finally” are left out (their comma is optional). Awareness- // only: it fires per-sentence (offsets are sentence-relative), so we nudge. const INTRO_RE = /^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/ function introComma(text: string, out: ProseHint[]) { for (const s of sentences(text)) { const m = s.match(INTRO_RE) if (m) { out.push({ id: `introcomma:${m[1].toLowerCase()}`, rule: 'introcomma', zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`, en: `Put a comma after the opening transition: “${m[1]}, …”.`, }) break } } } // Sentence not starting with a capital. We look after a terminator, and ignore // CJK sentences (she writes Mandarin too — those don't take Latin capitals). // Awareness-only: a bare “. x” also matches abbreviations (“U.S. then”), so we // nudge rather than auto-capitalize. const LOWER_START_RE = /[.!?]\s+([a-z])/ function sentenceCaps(text: string, out: ProseHint[]) { if (LOWER_START_RE.test(text)) { out.push({ id: 'cap-sentence', rule: 'cap-sentence', zh: '每个句子的开头,用大写字母开始吧。', en: 'Start each new sentence with a capital letter.', }) } } // ── applyable rules (also feed the suggestion cards) ───────────────────────── // Doubled word — “the the”, “is is”. Excludes the handful of doublings that can // be legitimate (“the fact that that happened”, “she had had enough”). const DOUBLE_RE = /\b([A-Za-z]+)(\s+)\1\b/gi const LEGIT_DOUBLES = new Set(['that', 'had']) function doubles(text: string, out: ProseHint[]) { let m: RegExpExecArray | null DOUBLE_RE.lastIndex = 0 while ((m = DOUBLE_RE.exec(text))) { const w = m[1].toLowerCase() if (LEGIT_DOUBLES.has(w)) continue const from = m.index const to = from + m[0].length out.push({ id: `double:${from}:${key(m[0])}`, rule: 'double', zh: `“${m[1]}” 好像写了两遍,检查一下哦。`, en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`, fix: { from, to, replacement: m[1] }, }) } } // Lowercase standalone “I”. Conservative: only when bounded by spaces / line // edge, to avoid tangling with “i.e.” and the like. Awareness-only: a single // character can't be re-anchored unambiguously by string (every word holds an // “i”), so this stays a companion nudge rather than a one-click card. const LOWER_I_RE = /(^|[^\p{L}\p{N}_])i(?=$|[^\p{L}\p{N}_])/mu function pronounI(text: string, out: ProseHint[]) { const m = LOWER_I_RE.exec(text) LOWER_I_RE.lastIndex = 0 if (!m) return const at = m.index + m[1].length if (text[at + 1] === '.') return // “i.e.” etc. — precision over recall out.push({ id: 'cap-i', rule: 'cap-i', zh: '英文里的 “I”(我)任何时候都要大写哦。', en: 'In English, “I” is always written as a capital letter.', }) } // A stray space *before* punctuation (a common habit carried from CJK spacing). const SPACE_BEFORE_PUNCT_RE = /([A-Za-z]+) +([,;:!?]|\.(?!\.))/g function spaceBeforePunct(text: string, out: ProseHint[]) { let m: RegExpExecArray | null SPACE_BEFORE_PUNCT_RE.lastIndex = 0 while ((m = SPACE_BEFORE_PUNCT_RE.exec(text))) { const from = m.index const to = from + m[0].length out.push({ id: `space-punct:${from}`, rule: 'space-punct', zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。', en: 'No space before punctuation — it tucks right against the word.', fix: { from, to, replacement: m[1] + m[2] }, }) } } // Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”). // The comma case requires letters on both sides so numbers like 1,000 are safe; // the period case requires a real word boundary so “e.g.”/“U.S.” are skipped. const NO_SPACE_COMMA_RE = /([A-Za-z]+,)([A-Za-z]+)/g const NO_SPACE_PERIOD_RE = /([a-z]{2,}\.)([A-Z][a-z]+)/g function spaceAfterPunct(text: string, out: ProseHint[]) { for (const re of [NO_SPACE_COMMA_RE, NO_SPACE_PERIOD_RE]) { let m: RegExpExecArray | null re.lastIndex = 0 while ((m = re.exec(text))) { const from = m.index const to = from + m[0].length out.push({ id: `space-after:${from}`, rule: 'space-after', zh: '逗号、句号后面要空一格,再接下一个词。', en: 'Add a space after a comma or period before the next word.', fix: { from, to, replacement: `${m[1]} ${m[2]}` }, }) } } } // a vs. an — chosen by the *sound* of the next word. We work only on lowercase // words (sidestepping proper nouns / acronyms) and keep sound-exception lists. const A_BEFORE_VOWEL_RE = /\b(a)\s+([aeiou][a-z]+)\b/g // Vowel-spelled but consonant-sounding → “a” is correct, don't flag. const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/ const AN_BEFORE_CONSONANT_RE = /\b(an)\s+([b-df-hj-np-tv-z][a-z]+)\b/g // Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag. const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/ function articles(text: string, out: ProseHint[]) { let m: RegExpExecArray | null A_BEFORE_VOWEL_RE.lastIndex = 0 while ((m = A_BEFORE_VOWEL_RE.exec(text))) { if (CONSONANT_SOUND_RE.test(m[2])) continue // Span the whole "a " so the original is a distinctive, anchorable // phrase (a bare "a" matches everywhere). Replacement swaps only the article. out.push({ id: `article-an:${m.index}`, rule: 'article', zh: `元音开头的词前用 “an”:“an ${m[2]}”。`, en: `Before a vowel sound, use “an”: “an ${m[2]}”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) }, }) } AN_BEFORE_CONSONANT_RE.lastIndex = 0 while ((m = AN_BEFORE_CONSONANT_RE.exec(text))) { if (VOWEL_SOUND_RE.test(m[2])) continue out.push({ id: `article-a:${m.index}`, rule: 'article', zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`, en: `Before a consonant sound, use “a”: “a ${m[2]}”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) }, }) } } // Mass nouns that Mandarin speakers very commonly pluralize. These words are // essentially never valid with a trailing “s”, so the list is its own guard. const UNCOUNTABLE_PLURAL_RE = /\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/gi function uncountables(text: string, out: ProseHint[]) { let m: RegExpExecArray | null UNCOUNTABLE_PLURAL_RE.lastIndex = 0 while ((m = UNCOUNTABLE_PLURAL_RE.exec(text))) { const word = m[1] const singular = word.replace(/s$/i, '') out.push({ id: `uncountable:${m.index}`, rule: 'uncountable', zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`, en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`, fix: { from: m.index, to: m.index + word.length, replacement: singular }, }) } } // Languages, nationalities, days, and months are proper nouns in English but // not in Mandarin, so they're routinely left lowercase. We list only the // unambiguous ones — “may/march/august/china/turkey/polish” are skipped because // they're also ordinary lowercase words and would misfire. const PROPER_NOUNS = 'monday|tuesday|wednesday|thursday|friday|saturday|sunday|' + 'january|february|april|june|july|september|october|november|december|' + 'english|chinese|mandarin|cantonese|japanese|korean|french|spanish|german|' + 'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' + 'australian|mexican|brazilian|european' // Case-sensitive (lowercase-only) so already-capitalized words aren't flagged. const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`, 'g') function properCaps(text: string, out: ProseHint[]) { let m: RegExpExecArray | null PROPER_NOUN_RE.lastIndex = 0 while ((m = PROPER_NOUN_RE.exec(text))) { const word = m[1] const fixed = word[0].toUpperCase() + word.slice(1) out.push({ id: `propercap:${m.index}`, rule: 'propercap', zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`, en: `Languages, days, and months are capitalized in English: “${fixed}”.`, fix: { from: m.index, to: m.index + word.length, replacement: fixed }, }) } } // Third-person singular verb form, for the agreement rule's suggestion. function third(verb: string): string { const v = verb.toLowerCase() const irregular: Record = { have: 'has', do: 'does', go: 'goes' } if (irregular[v]) return irregular[v] if (/[^aeiou]y$/.test(v)) return v.slice(0, -1) + 'ies' if (/(s|x|z|ch|sh)$/.test(v)) return v + 'es' return v + 's' } // Subject–verb agreement after he/she/it: a bare (base-form) verb where the // third-person “-s” form is required. We skip the causative/perception frames // (“let it go”, “make it work”, “help it grow”) where the bare verb is correct. const SVA_BASE = 'go|have|do|make|like|want|need|know|think|say|see|feel|get|take|come|give|' + 'run|play|work|live|look|seem|become|bring|buy|find|keep|leave|tell|try|call|' + 'move|turn|put|mean|show|use|love|hope|wish|believe|understand|remember|enjoy' // The preceding word is optional so a sentence-initial subject (“She have…”) // still matches; when present it lets us skip causative/perception frames. const SVA_RE = new RegExp(`(?:(\\w+)\\s+)?\\b(he|she|it)\\s+(${SVA_BASE})\\b`, 'gi') const CAUSATIVE = new Set([ 'let', 'lets', 'make', 'makes', 'made', 'help', 'helps', 'helped', 'watch', 'watches', 'watched', 'see', 'sees', 'saw', 'hear', 'heard', 'have', 'has', 'had', 'let’s', "let's", ]) function subjectVerbAgreement(text: string, out: ProseHint[]) { let m: RegExpExecArray | null SVA_RE.lastIndex = 0 while ((m = SVA_RE.exec(text))) { if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue const verb = m[3] const fixed = third(verb) // Span the whole match (subject + verb, plus any captured lead) so the // original anchors; the replacement corrects only the trailing verb. const head = m[0].slice(0, m[0].length - verb.length) out.push({ id: `sva:${m.index}`, rule: 'sva', zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`, en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) }, }) } } // Singular noun after a cardinal number (“three apple”). Adjectives between the // number and noun would misfire (“two small dogs”), so we only flag when the // candidate is followed by a clause boundary — a verb, preposition, article, or // punctuation — which means it's the noun itself, not a modifier. const NUMBER_NOUN_RE = /\b(two|three|four|five|six|seven|eight|nine|ten)\s+([a-z]{3,})\b(?=\s*(?:[.,!?;:]|$|\s(?:is|are|was|were|and|or|but|that|which|who|in|on|at|to|for|with|will|can|could|would|should|the|a|an)\b))/g // Irregular plurals / mass nouns that are correct without an “s”. const NON_S_PLURAL = new Set([ 'people', 'children', 'men', 'women', 'fish', 'deer', 'sheep', 'feet', 'teeth', 'mice', 'geese', 'police', 'staff', 'series', 'species', 'aircraft', 'cattle', 'hundred', 'thousand', 'million', 'billion', 'dozen', 'percent', ]) function pluralAfterNumber(text: string, out: ProseHint[]) { let m: RegExpExecArray | null NUMBER_NOUN_RE.lastIndex = 0 while ((m = NUMBER_NOUN_RE.exec(text))) { const noun = m[2].toLowerCase() if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue // Span " " so the original anchors; append “s” to the noun. out.push({ id: `plural:${m.index}`, rule: 'plural', zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`, en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` }, }) } } // Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare // possessive, so the article often gets stacked on top. The article goes. const DOUBLE_DET_RE = /\b(a|an|the)(\s+)(a|an|the|my|your|his|her|its|our|their)\b/gi function doubleDeterminer(text: string, out: ProseHint[]) { let m: RegExpExecArray | null DOUBLE_DET_RE.lastIndex = 0 while ((m = DOUBLE_DET_RE.exec(text))) { // Two *identical* words ("the the") are a doubled word, not stacked // determiners — leave that to the `doubles` rule so we don't double-flag. if (m[1].toLowerCase() === m[3].toLowerCase()) continue out.push({ id: `doubledet:${m.index}`, rule: 'doubledet', zh: `“${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`, en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`, // Drop the article (m[1]); keep the second determiner, casing preserved. fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) }, }) } } // “there is” with a plural quantifier → should be “there are”. We trigger only // on clearly-plural cues (skip “some/any”, which are fine with singular mass // nouns: “there is some water”). const THERE_IS_RE = /\bthere(\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/gi function thereIsPlural(text: string, out: ProseHint[]) { let m: RegExpExecArray | null THERE_IS_RE.lastIndex = 0 while ((m = THERE_IS_RE.exec(text))) { // Span the whole "there is " phrase so it anchors; swap the verb part // (m[1]: “ is” / “'s”) for “ are”, preserving the “there” casing and the cue. const replacement = m[0].slice(0, 5) + ' are' + m[0].slice(5 + m[1].length) out.push({ id: `thereis:${m.index}`, rule: 'thereis', zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`, en: `With a plural, use “there are”: “there are ${m[2]}…”.`, fix: { from: m.index, to: m.index + m[0].length, replacement }, }) } } // its / it's — only the two unambiguous directions: “it's own” (always its own) // and “its a/an” (the possessive can't take an article → it's a/an). const ITS_OWN_RE = /\b(it's|it’s)\s+own\b/gi const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/gi function itsConfusion(text: string, out: ProseHint[]) { let m: RegExpExecArray | null ITS_OWN_RE.lastIndex = 0 while ((m = ITS_OWN_RE.exec(text))) { // Span "it's own" so it anchors; swap the leading token to the possessive. out.push({ id: `its-own:${m.index}`, rule: 'its', zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。', en: '“it’s” means “it is” — the possessive is “its”: “its own”.', fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) }, }) } ITS_ARTICLE_RE.lastIndex = 0 while ((m = ITS_ARTICLE_RE.exec(text))) { // Span "its a/an" so it anchors; swap "its" → "it's". out.push({ id: `its-article:${m.index}`, rule: 'its', zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`, en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`, fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) }, }) } } // Comparative + “then” where “than” is meant (“better then”, “more then”). We // use an explicit comparative list rather than a generic “-er” pattern, which // would snag “after then”, “however”, “remember”, etc. const COMPARATIVES = 'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' + 'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' + 'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer' const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})(\\s+)then\\b`, 'gi') function thanThen(text: string, out: ProseHint[]) { let m: RegExpExecArray | null THAN_THEN_RE.lastIndex = 0 while ((m = THAN_THEN_RE.exec(text))) { // Span " then" so it anchors; swap the trailing “then” → “than”. const thenFrom = m.index + m[0].length - 4 out.push({ id: `than:${m.index}`, rule: 'than', zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`, en: `For comparisons use “than”, not “then”: “${m[1]} than”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') }, }) } } // ── orchestration ─────────────────────────────────────────────────────────── // Rules run in priority order — the ones the writer cares most about first, so // that when several fire at once the companion leads with the weightiest note. const RULES: Array<(text: string, out: ProseHint[]) => void> = [ runOns, commaSplices, antecedents, oxford, articles, uncountables, properCaps, subjectVerbAgreement, pluralAfterNumber, doubleDeterminer, thereIsPlural, itsConfusion, thanThen, introComma, doubles, pronounI, sentenceCaps, spaceBeforePunct, spaceAfterPunct, ] // analyzeProse returns context-aware hints, highest-priority first. It bails on // text too short to advise on (mid-thought drafts shouldn't get picked apart). // Hints that carry a `fix` are applyable (they also surface as suggestion cards); // the companion filters those out so a span isn't both a bubble and a card. export function analyzeProse(text: string): ProseHint[] { const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0 if (englishWords < 8) return [] const out: ProseHint[] = [] for (const rule of RULES) rule(text, out) return out } // mechanicsFindings returns every applyable deterministic fix in the text, as // suggestion-card findings with exact spans. No word-count floor: a doubled word // or a stray lowercase “i” is worth fixing even in a short draft, the way a // spell-checker would. The card pipeline persists these as the 'mechanics' // family; collisions with the LLM cards are resolved server-side (mechanics // wins, since its span is exact). export function mechanicsFindings(text: string): MechanicsFinding[] { const hints: ProseHint[] = [] for (const rule of RULES) rule(text, hints) const found: MechanicsFinding[] = [] for (const h of hints) { if (!h.fix) continue const { from, to, replacement } = h.fix const original = text.slice(from, to) if (!original || original === replacement) continue found.push({ from, to, original, replacement, explanation: h.en }) } // Two rules can occasionally claim overlapping spans (e.g. a doubled word that // also reads as stacked determiners). Resolve to one card per stretch of text: // earlier start wins, ties broken by the longer span. The backend resolves the // separate mechanics-vs-LLM collisions; this handles mechanics-vs-mechanics. found.sort((a, b) => (a.from !== b.from ? a.from - b.from : b.to - a.to)) const out: MechanicsFinding[] = [] let lastEnd = -1 for (const f of found) { if (f.from < lastEnd) continue out.push(f) lastEnd = f.to } return out }