Files
petal/web/src/components/Companion/prose.ts
prosolis 96f68a91ee Add deterministic mechanics suggestion family (rule-based, no LLM)
Reuse the companion's prose.ts rules engine as the single source of
deterministic detection instead of duplicating it. Applyable rules now
also emit exact-span fixes (original -> replacement) that surface as
suggestion cards; awareness-only rules (run-ons, splices, ...) stay
companion bubbles. The companion hides fix-bearing hints so a span is
never both a bubble and a card.

Spans are widened to a distinctive phrase ("a old" -> "an old",
"She have" -> "She has") so they re-anchor by string in the editor; a
lone lowercase "i" stays awareness-only since a single char can't anchor.

Backend: detection lives client-side, so the new persist-only
POST /docs/{id}/mechanics endpoint receives findings and stores them as
the 'mechanics' family with their exact offsets. It honours
actioned-suppression, leaves the LLM families untouched, and a checkpoint
no longer wipes it. fetchPending dedupes spans with mechanics winning any
collision against an LLM card (its span is exact). Migration 0008 adds the
'mechanics' suggestion type.

Client renders the mechanics fixes immediately (no LLM wait) and the cards
use a calm sage "Tidy-up" accent.

Verified end-to-end in a real browser on millenia: detect -> persist ->
render -> accept applies the fix.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 20:43:37 -07:00

696 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <noun>" 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<string, string> = { 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'
}
// Subjectverb 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', 'lets', "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 "<number> <noun>" 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 <plural>" 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|its)\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: '“its” = “it is”表示“它的”要用 “its”所以是 “its own”。',
en: '“its” 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: `这里应该是 “its ${m[1]}it is“its” 是“它的”。`,
en: `Here it should be “its ${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 "<comparative> 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
}