diff --git a/internal/db/db.go b/internal/db/db.go index 0efee02..d52dbee 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -336,6 +336,35 @@ CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at); name: "0007_vocab_definition", stmt: ` ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT ''; +`, + }, + { + // Deterministic mechanics pass. Adds a 'mechanics' suggestion family for + // rule-based fixes (doubled words, spacing/punctuation, lowercase "i", + // curated confusables) detected in pure Go — no LLM. As with 0005, the + // `type` CHECK can't be ALTERed in place, so rebuild the table with the + // extended constraint, copy every row across, and recreate the index. + name: "0008_mechanics_suggestion_type", + stmt: ` +CREATE TABLE suggestions_new ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + from_pos INTEGER NOT NULL, + to_pos INTEGER NOT NULL, + original TEXT NOT NULL, + replacement TEXT NOT NULL, + explanation TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation','mechanics')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at) +SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions; + +DROP TABLE suggestions; +ALTER TABLE suggestions_new RENAME TO suggestions; +CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id); `, }, } diff --git a/internal/db/models.go b/internal/db/models.go index 9777ffa..635d7cb 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -101,6 +101,7 @@ const ( SuggestionTypeClarity = "clarity" SuggestionTypeVoice = "voice" SuggestionTypeCollocation = "collocation" + SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM) SuggestionStatusPending = "pending" SuggestionStatusAccepted = "accepted" diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index 5ad8c6e..3521b0c 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -8,7 +8,9 @@ package suggestions import ( "context" "database/sql" + "encoding/json" "errors" + "io" "net/http" "strings" @@ -46,6 +48,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler { // the existing /api/docs router so they share its base path. func (h *Handler) RegisterDocRoutes(r chi.Router) { r.Post("/{id}/check", h.check) + r.Post("/{id}/mechanics", h.mechanics) r.Post("/{id}/voice", h.voice) r.Post("/{id}/collocation", h.collocation) r.Post("/{id}/rewrite", h.rewrite) @@ -64,10 +67,118 @@ func (h *Handler) Routes() chi.Router { } // check runs a grammar checkpoint over the document. Fast, typing-cadence pass. +// The deterministic mechanics family is owned by a separate pass (see mechanics), +// detected client-side; a grammar checkpoint leaves those flags untouched. func (h *Handler) check(w http.ResponseWriter, r *http.Request) { h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope) } +// mechanicsFinding is one deterministic, rule-based fix detected client-side (see +// web Companion/prose.ts). Detection lives in the frontend — the same rules that +// power the companion's prose notes — so the server only persists these; it does +// not compute them. Offsets are exact plaintext spans from the detector. +type mechanicsFinding struct { + From int `json:"from"` + To int `json:"to"` + Original string `json:"original"` + Replacement string `json:"replacement"` + Explanation string `json:"explanation"` +} + +// maxMechanicsFindings caps a single submission so a runaway client can't flood +// the table; far above any realistic count for one document. +const maxMechanicsFindings = 500 + +// mechanics persists the client-detected deterministic fixes as the 'mechanics' +// family and returns the document's unified pending set. Free and not rate- +// limited — it runs alongside the grammar checkpoint. It mirrors a single LLM +// family: it replaces only the pending mechanics rows, honours actioned- +// suppression, and (unlike the LLM passes) keeps the detector's exact offsets +// rather than re-locating by string, which matters when the same word repeats. +func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) { + docID := chi.URLParam(r, "id") + + // Confirm the document exists (and is the local user's) for clean 404s. + var exists bool + err := h.DB.QueryRow( + `SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`, + docID, db.LocalUserID, + ).Scan(&exists) + if err != nil { + httputil.ServerError(w, err) + return + } + if !exists { + httputil.ErrorJSON(w, http.StatusNotFound, "document not found") + return + } + + var body struct { + Findings []mechanicsFinding `json:"findings"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { + httputil.BadRequest(w, "invalid request body") + return + } + if len(body.Findings) > maxMechanicsFindings { + body.Findings = body.Findings[:maxMechanicsFindings] + } + + if err := h.replaceMechanics(docID, body.Findings); err != nil { + httputil.ServerError(w, err) + return + } + + out, err := h.fetchPending(docID) + if err != nil { + httputil.ServerError(w, err) + return + } + httputil.WriteJSON(w, http.StatusOK, out) +} + +// replaceMechanics swaps the document's pending mechanics rows for the supplied +// findings in one transaction, leaving the LLM families and actioned rows +// untouched. Findings the user already accepted or dismissed are suppressed (the +// detector has no memory between runs), and malformed spans are skipped. +func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error { + tx, err := h.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec( + `DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND type = ?`, + docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics, + ); err != nil { + return err + } + + actioned, err := actionedKeys(tx, docID) + if err != nil { + return err + } + + for _, f := range findings { + if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" { + continue // malformed span — the client re-anchors by string anyway + } + if _, seen := actioned[suggestionKey(f.Original, f.Replacement)]; seen { + continue + } + if _, err := tx.Exec( + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, db.SuggestionTypeMechanics, + ); err != nil { + return err + } + } + + return tx.Commit() +} + // voice runs a Tier-1 voice-consistency pass over the whole document. Slow, // explicit-action pass; replaces only the pending voice flags. func (h *Handler) voice(w http.ResponseWriter, r *http.Request) { @@ -163,9 +274,11 @@ type pendingScope struct { var ( // grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but - // the explicit-action families (voice, collocation), which run on their own - // cadence and must survive a grammar checkpoint. - grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation')", forceType: ""} + // the other self-owned families (voice, collocation, mechanics), which run on + // their own cadence/pass and must survive a grammar checkpoint. Notably the + // deterministic mechanics pass writes its rows in the same /check request just + // before this DELETE runs, so excluding it here is what keeps them alive. + grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation','mechanics')", forceType: ""} // voiceScope owns the voice flags only. voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice} // collocationScope owns the collocation flags only. @@ -291,7 +404,51 @@ func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) { } out = append(out, s) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return dedupeSpans(out), nil +} + +// dedupeSpans resolves collisions between the deterministic mechanics family and +// the LLM families: when a mechanics finding and an LLM suggestion fight over the +// same characters, mechanics wins and the LLM card is dropped. Its span is exact +// (the detector matched it), whereas the LLM positions are only advisory +// (re-anchored by string at render), so the precise fix should own the span. +// +// This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only, +// no replacement) and collocation legitimately co-occupy the same span, and that +// is intended. Suggestions that never anchored (from_pos < 0) occupy no real span +// and are always kept. +func dedupeSpans(in []db.Suggestion) []db.Suggestion { + type span struct{ from, to int } + var claimed []span + for _, s := range in { + if s.Type == db.SuggestionTypeMechanics && s.FromPos >= 0 { + claimed = append(claimed, span{s.FromPos, s.ToPos}) + } + } + if len(claimed) == 0 { + return in + } + + out := make([]db.Suggestion, 0, len(in)) + for _, s := range in { + if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 { + overlaps := false + for _, sp := range claimed { + if s.FromPos < sp.to && sp.from < s.ToPos { + overlaps = true + break + } + } + if overlaps { + continue // an exact mechanics fix owns these characters + } + } + out = append(out, s) + } + return out } // accept marks a suggestion accepted (the client applies the replacement text). diff --git a/internal/suggestions/handlers_test.go b/internal/suggestions/handlers_test.go index d5f5e70..84773a4 100644 --- a/internal/suggestions/handlers_test.go +++ b/internal/suggestions/handlers_test.go @@ -316,3 +316,116 @@ func TestCheckpointRateLimit(t *testing.T) { t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls) } } + +// postMechanics submits a deterministic-findings batch (as the client's prose +// detector would) and returns the unified pending set. +func postMechanics(t *testing.T, srv http.Handler, docID, findingsJSON string) []db.Suggestion { + t.Helper() + rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", `{"findings":`+findingsJSON+`}`) + if rec.Code != http.StatusOK { + t.Fatalf("mechanics: code=%d body=%s", rec.Code, rec.Body) + } + var got []db.Suggestion + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + return got +} + +// TestMechanicsPersistsFindings proves the endpoint persists client-detected +// findings as a 'mechanics' family with the exact offsets the client supplied, +// and that a later grammar checkpoint leaves them untouched (own family). +func TestMechanicsPersistsFindings(t *testing.T) { + client := &stubClient{response: `{"suggestions":[]}`} + srv, docID, _ := newTestServer(t, client) + + got := postMechanics(t, srv, docID, `[ + {"from":0,"to":1,"original":"i","replacement":"I","explanation":"capitalize I"}, + {"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"} + ]`) + var mech []db.Suggestion + for _, s := range got { + if s.Type == db.SuggestionTypeMechanics { + mech = append(mech, s) + } + } + if len(mech) != 2 { + t.Fatalf("want 2 mechanics findings, got %d: %+v", len(mech), got) + } + // The client's exact offsets are preserved verbatim. + for _, s := range mech { + if s.Original == "the the" && (s.FromPos != 6 || s.ToPos != 13) { + t.Errorf("offsets not preserved: %+v", s) + } + } + + // A grammar checkpoint must not wipe the mechanics family. + rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") + if rec.Code != http.StatusOK { + t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body) + } + var after []db.Suggestion + _ = json.Unmarshal(rec.Body.Bytes(), &after) + count := 0 + for _, s := range after { + if s.Type == db.SuggestionTypeMechanics { + count++ + } + } + if count != 2 { + t.Fatalf("grammar checkpoint disturbed the mechanics family: got %d, want 2", count) + } +} + +// TestMechanicsWinsSpanCollision proves that when a mechanics finding and an LLM +// grammar suggestion claim overlapping characters, the LLM card is dropped from +// the response and the exact mechanics fix owns the span. +func TestMechanicsWinsSpanCollision(t *testing.T) { + // LLM grammar suggestion covers "the the cat", overlapping the mechanics + // "the the" finding at [0,7]. + client := &stubClient{response: `{"suggestions":[ + {"original":"the the cat","replacement":"the cat","explanation":"wordy","type":"grammar"} + ]}`} + srv, docID, h := newTestServer(t, client) + if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, "the the cat sat", docID); err != nil { + t.Fatalf("set content: %v", err) + } + + // Persist the mechanics finding, then run the grammar pass. + postMechanics(t, srv, docID, `[{"from":0,"to":7,"original":"the the","replacement":"the","explanation":"doubled word"}]`) + rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") + if rec.Code != http.StatusOK { + t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body) + } + var got []db.Suggestion + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + for _, s := range got { + if s.Type == db.SuggestionTypeGrammar { + t.Fatalf("overlapping grammar card should have been dropped in favor of mechanics, got %+v", got) + } + } + if len(got) != 1 || got[0].Type != db.SuggestionTypeMechanics { + t.Fatalf("want sole mechanics finding, got %+v", got) + } +} + +// TestMechanicsActionedSuppression proves a dismissed mechanics fix is not +// re-proposed on the next submission of the same finding. +func TestMechanicsActionedSuppression(t *testing.T) { + client := &stubClient{response: `{"suggestions":[]}`} + srv, docID, _ := newTestServer(t, client) + + findings := `[{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}]` + got := postMechanics(t, srv, docID, findings) + if len(got) != 1 { + t.Fatalf("want 1 finding, got %+v", got) + } + // Dismiss it, then resubmit the same finding — it must stay suppressed. + do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/dismiss", "") + again := postMechanics(t, srv, docID, findings) + if len(again) != 0 { + t.Fatalf("dismissed mechanics fix should not reappear, got %+v", again) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index d66b588..579d4cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -274,7 +274,7 @@ export default function App() { if (currentDoc) { patchSummary(currentDoc.id, { word_count: change.word_count }) schedule(change) - scheduleCheckpoint() + scheduleCheckpoint(change.content_text) } }, [currentDoc, patchSummary, schedule, scheduleCheckpoint], diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1edc086..6ec8249 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -76,7 +76,7 @@ export interface Gloss { gloss: string } -export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' +export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics' // One word in the vocabulary garden: a looked-up word with its gloss/phonetic, // the sentence it was met in, and its spaced-repetition state. `reps` drives how @@ -135,6 +135,17 @@ export interface Suggestion { created_at: string } +// A deterministic, rule-based fix detected client-side (see Companion/prose.ts). +// The frontend owns mechanics detection; the backend only persists these as the +// 'mechanics' suggestion family. Spans are exact plaintext offsets. +export interface MechanicsFinding { + from: number + to: number + original: string + replacement: string + explanation: string +} + async function req(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { headers: { 'Content-Type': 'application/json' }, @@ -168,6 +179,14 @@ export const api = { // word pairings ("do a decision" → "make a decision"). Returns the unified // pending set too. Rate-limited per document server-side. collocationDoc: (id: string) => req(`/docs/${id}/collocation`, { method: 'POST' }), + // Mechanics pass: persist the client-detected deterministic fixes as the + // 'mechanics' family and return the unified pending set. Not rate-limited (it's + // free, local detection); runs alongside the grammar checkpoint. + submitMechanics: (id: string, findings: MechanicsFinding[]) => + req(`/docs/${id}/mechanics`, { + method: 'POST', + body: JSON.stringify({ findings }), + }), // Pending suggestions for a doc, loaded when the editor opens it. listSuggestions: (id: string) => req(`/docs/${id}/suggestions`), acceptSuggestion: (id: string) => diff --git a/web/src/components/Companion/prose.test.ts b/web/src/components/Companion/prose.test.ts index 1dd2df5..6b29518 100644 --- a/web/src/components/Companion/prose.test.ts +++ b/web/src/components/Companion/prose.test.ts @@ -248,3 +248,119 @@ describe('capitalization basics', () => { expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence') }) }) + +import { mechanicsFindings } from './prose' + +// mechanicsFindings is the applyable subset feeding the suggestion cards. Each +// finding must carry an EXACT span (text.slice(from,to) === original) and a +// replacement that actually changes the text — these become one-click edits. +describe('mechanicsFindings — applyable fixes', () => { + // Every finding's span must be exact, regardless of which rule produced it. + function expectExactSpans(text: string) { + const found = mechanicsFindings(text) + for (const f of found) { + expect(text.slice(f.from, f.to), `span for ${JSON.stringify(f)}`).toBe(f.original) + expect(f.replacement).not.toBe(f.original) + } + return found + } + + const one = (text: string, rulePred: (f: ReturnType[number]) => boolean) => + expectExactSpans(text).find(rulePred) + + it('doubled word → single word, exact span', () => { + const f = one('I saw the the cat in the garden today.', (f) => f.original === 'the the') + expect(f?.replacement).toBe('the') + }) + + it('finds BOTH doublings with distinct spans', () => { + const found = mechanicsFindings('the the dog and the the cat ran around the yard.') + .filter((f) => f.original === 'the the') + expect(found).toHaveLength(2) + expect(found[0].from).not.toBe(found[1].from) + }) + + // Spans are widened to a distinctive phrase so they re-anchor by string in the + // editor (a bare "a"/"i"/" is" would match the wrong spot). The replacement + // carries the whole corrected phrase. + + it('lowercase standalone i is awareness-only (no card)', () => { + // It still flags as a companion bubble (see analyzeProse), but a single + // character can't be anchored, so it must NOT become a card. + expect(mechanicsFindings('Yesterday i walked to the store and came home.').length).toBe(0) + expect(rulesFor('Yesterday i walked to the store and came home.')).toContain('cap-i') + }) + + it('a → an: spans the whole "a " phrase', () => { + const f = one('She found a umbrella under the old wooden table.', (f) => f.original === 'a umbrella') + expect(f?.replacement).toBe('an umbrella') + }) + + it('uncountable plural → singular', () => { + const f = one('She gave me many informations about the new job.', (f) => f.original === 'informations') + expect(f?.replacement).toBe('information') + }) + + it('lowercase proper noun → capitalized', () => { + const f = one('I am learning english during my free time this year.', (f) => f.original === 'english') + expect(f?.replacement).toBe('English') + }) + + it('subject-verb agreement → corrects the verb, spans subject+verb', () => { + const f = one('She have three cats and a dog at her house.', (f) => f.original === 'She have') + expect(f?.replacement).toBe('She has') + }) + + it('singular noun after a number → plural, spans number+noun', () => { + const f = one('I bought five apple at the market this morning.', (f) => f.original === 'five apple') + expect(f?.replacement).toBe('five apples') + }) + + it('comparative + then → than, spans the phrase', () => { + const f = one('This book is better then the one I read last week.', (f) => f.original === 'better then') + expect(f?.replacement).toBe('better than') + }) + + it('space before punctuation is removed (spans the word)', () => { + const f = one('I love it , it makes me very happy indeed.', (f) => f.original === 'it ,') + expect(f?.replacement).toBe('it,') + }) + + it('missing space after a comma is added (spans both words)', () => { + const f = one('I bought apples,bananas and pears at the store.', (f) => f.original === 'apples,bananas') + expect(f?.replacement).toBe('apples, bananas') + }) + + it('stacked determiner drops the article', () => { + const f = one('She left the my book on the kitchen table again.', (f) => f.original === 'the my') + expect(f?.replacement).toBe('my') + }) + + it('there is + plural → there are, spans the phrase', () => { + const f = one('There is many people waiting outside in the cold.', (f) => f.original === 'There is many') + expect(f?.replacement).toBe('There are many') + }) + + it("it's own → its own, spans the phrase", () => { + const f = one("The cat licked it's own paw very slowly today.", (f) => f.original === "it's own") + expect(f?.replacement).toBe('its own') + }) + + it("its a → it's a, spans the phrase", () => { + const f = one('I think its a wonderful day to go outside now.', (f) => f.original === 'its a') + expect(f?.replacement).toBe("it's a") + }) + + // Awareness-only rules never produce cards — they stay companion bubbles. + it('run-ons and splices produce NO card findings', () => { + const runOn = 'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.' + expect(mechanicsFindings(runOn).some((f) => f.original.length > 30)).toBe(false) + const splice = 'I finished the report, I sent it to my boss right away today.' + // no card spans the comma-join; only (if any) small word-level fixes + expect(mechanicsFindings(splice).every((f) => f.to - f.from < 12)).toBe(true) + }) + + it('clean prose yields no findings', () => { + expect(mechanicsFindings('The garden was quiet and the rain fell softly on the leaves.')).toEqual([]) + }) +}) diff --git a/web/src/components/Companion/prose.ts b/web/src/components/Companion/prose.ts index a2769d6..c85a6e1 100644 --- a/web/src/components/Companion/prose.ts +++ b/web/src/components/Companion/prose.ts @@ -8,6 +8,14 @@ // 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' @@ -17,6 +25,28 @@ export interface ProseHint extends Line { 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 ────────────────────────────────────────────────────── @@ -48,11 +78,23 @@ 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 at most a couple of findings; the caller shows one at a time. +// 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)) { @@ -110,6 +152,7 @@ const INTRO_LEAD = new Set([ // 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)) { @@ -137,6 +180,7 @@ function commaSplices(text: string, out: ProseHint[]) { // 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/ @@ -160,7 +204,8 @@ function antecedents(text: string, out: ProseHint[]) { // 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. +// 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', @@ -187,81 +232,32 @@ function oxford(text: string, out: ProseHint[]) { } } -// 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 = /\ba\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 = /\ban\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)/ +// 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 articles(text: string, out: ProseHint[]) { - let m: RegExpExecArray | null - let found = 0 - A_BEFORE_VOWEL_RE.lastIndex = 0 - while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) { - if (CONSONANT_SOUND_RE.test(m[1])) continue - found++ - out.push({ - id: `article-an:${key(m[1])}`, - rule: 'article', - zh: `元音开头的词前用 “an”:“an ${m[1]}”。`, - en: `Before a vowel sound, use “an”: “an ${m[1]}”.`, - }) - } - AN_BEFORE_CONSONANT_RE.lastIndex = 0 - while ((m = AN_BEFORE_CONSONANT_RE.exec(text)) && found < 2) { - if (VOWEL_SOUND_RE.test(m[1])) continue - found++ - out.push({ - id: `article-a:${key(m[1])}`, - rule: 'article', - zh: `辅音开头的词前用 “a”:“a ${m[1]}”。`, - en: `Before a consonant sound, use “a”: “a ${m[1]}”.`, - }) - } -} - -// 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 - let found = 0 - DOUBLE_RE.lastIndex = 0 - while ((m = DOUBLE_RE.exec(text)) && found < 1) { - const w = m[1].toLowerCase() - if (LEGIT_DOUBLES.has(w)) continue - found++ - out.push({ - id: `double:${key(m[0])}`, - rule: 'double', - zh: `“${m[1]}” 好像写了两遍,检查一下哦。`, - en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`, - }) - } -} - -// Lowercase standalone “I”. Conservative: only when bounded by spaces / line -// edge, to avoid tangling with “i.e.” and the like. -const LOWER_I_RE = /(?:^|\s)i(?=\s|$)/m - -function pronounI(text: string, out: ProseHint[]) { - if (LOWER_I_RE.test(text)) { - out.push({ - id: 'cap-i', - rule: 'cap-i', - zh: '英文里的 “I”(我)任何时候都要大写哦。', - en: 'In English, “I” is always written as a capital letter.', - }) +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[]) { @@ -275,40 +271,148 @@ function sentenceCaps(text: string, out: ProseHint[]) { } } -// A stray space *before* punctuation (a common habit carried from CJK spacing). -const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/ +// ── applyable rules (also feed the suggestion cards) ───────────────────────── -function spaceBeforePunct(text: string, out: ProseHint[]) { - if (SPACE_BEFORE_PUNCT_RE.test(text)) { +// 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: 'space-punct', - rule: 'space-punct', - zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。', - en: 'No space before punctuation — it tucks right against the word.', + 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] }, }) } } -// ── Chinese-L1 interference rules (Tier 1) ────────────────────────────────── -// Mandarin lacks plural inflection, articles, and verb agreement, so these -// patterns are the predictable places that grammar "leaks" into her English. -// All are closed-list or strong-signal to keep false positives near zero. +// 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/i + /\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/gi function uncountables(text: string, out: ProseHint[]) { - const m = UNCOUNTABLE_PLURAL_RE.exec(text) + let m: RegExpExecArray | null UNCOUNTABLE_PLURAL_RE.lastIndex = 0 - if (m) { - const singular = m[1].replace(/s$/i, '') + while ((m = UNCOUNTABLE_PLURAL_RE.exec(text))) { + const word = m[1] + const singular = word.replace(/s$/i, '') out.push({ - id: `uncountable:${m[1].toLowerCase()}`, + id: `uncountable:${m.index}`, rule: 'uncountable', - zh: `“${m[1]}” 是不可数名词,不用加 s,写 “${singular}” 就好。`, - en: `“${m[1]}” is uncountable — drop the “s”: just “${singular}”.`, + zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`, + en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`, + fix: { from: m.index, to: m.index + word.length, replacement: singular }, }) } } @@ -324,17 +428,20 @@ const PROPER_NOUNS = '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`) +const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`, 'g') function properCaps(text: string, out: ProseHint[]) { - const m = PROPER_NOUN_RE.exec(text) - if (m) { - const fixed = m[1][0].toUpperCase() + m[1].slice(1) + 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[1]}`, + 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 }, }) } } @@ -367,17 +474,20 @@ const CAUSATIVE = new Set([ function subjectVerbAgreement(text: string, out: ProseHint[]) { let m: RegExpExecArray | null - let found = 0 SVA_RE.lastIndex = 0 - while ((m = SVA_RE.exec(text)) && found < 2) { + while ((m = SVA_RE.exec(text))) { if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue - found++ - const fixed = third(m[3]) + 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[2].toLowerCase()}:${m[3].toLowerCase()}`, + 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) }, }) } } @@ -397,35 +507,40 @@ const NON_S_PLURAL = new Set([ function pluralAfterNumber(text: string, out: ProseHint[]) { let m: RegExpExecArray | null - let found = 0 NUMBER_NOUN_RE.lastIndex = 0 - while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) { + while ((m = NUMBER_NOUN_RE.exec(text))) { const noun = m[2].toLowerCase() if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue - found++ + // Span " " so the original anchors; append “s” to the noun. out.push({ - id: `plural:${key(m[0])}`, + 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. One of the two must go. +// 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 + /\b(a|an|the)(\s+)(a|an|the|my|your|his|her|its|our|their)\b/gi function doubleDeterminer(text: string, out: ProseHint[]) { - const m = DOUBLE_DET_RE.exec(text) + let m: RegExpExecArray | null DOUBLE_DET_RE.lastIndex = 0 - if (m) { + 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:${key(m[0])}`, + id: `doubledet:${m.index}`, rule: 'doubledet', - zh: `“${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`, - en: `“${m[1]} ${m[2]}” stacks two determiners — keep just one.`, + 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]) }, }) } } @@ -434,46 +549,52 @@ function doubleDeterminer(text: string, out: ProseHint[]) { // 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/i + /\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[]) { - const m = THERE_IS_RE.exec(text) + let m: RegExpExecArray | null THERE_IS_RE.lastIndex = 0 - if (m) { + 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[1].toLowerCase()}`, + id: `thereis:${m.index}`, rule: 'thereis', - zh: `后面是复数时用 “there are”:“there are ${m[1]}…”。`, - en: `With a plural, use “there are”: “there are ${m[1]}…”.`, + 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 }, }) } } -// ── confusables (Tier 2) ───────────────────────────────────────────────────── - // 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 = /\bit's\s+own\b/i -const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i +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[]) { - if (ITS_OWN_RE.test(text)) { + 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', + 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) }, }) - return } - const m = ITS_ARTICLE_RE.exec(text) ITS_ARTICLE_RE.lastIndex = 0 - if (m) { + while ((m = ITS_ARTICLE_RE.exec(text))) { + // Span "its a/an" so it anchors; swap "its" → "it's". out.push({ - id: 'its-article', + 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) }, }) } } @@ -485,57 +606,20 @@ 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`, 'i') +const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})(\\s+)then\\b`, 'gi') function thanThen(text: string, out: ProseHint[]) { - const m = THAN_THEN_RE.exec(text) - if (m) { + 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[1].toLowerCase()}`, + id: `than:${m.index}`, rule: 'than', zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`, en: `For comparisons use “than”, not “then”: “${m[1]} than”.`, - }) - } -} - -// 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). -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[]) { - let found = false - 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]}, …”.`, - }) - found = true - break - } - } - return found -} - -// 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]/ -const NO_SPACE_PERIOD_RE = /[a-z]{2,}\.[A-Z][a-z]/ - -function spaceAfterPunct(text: string, out: ProseHint[]) { - if (NO_SPACE_COMMA_RE.test(text) || NO_SPACE_PERIOD_RE.test(text)) { - out.push({ - id: 'space-after', - rule: 'space-after', - zh: '逗号、句号后面要空一格,再接下一个词。', - en: 'Add a space after a comma or period before the next word.', + fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') }, }) } } @@ -568,6 +652,8 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [ // 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 [] @@ -575,3 +661,35 @@ export function analyzeProse(text: string): 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 +} diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts index ed44d13..1f93f36 100644 --- a/web/src/components/Companion/useCompanion.ts +++ b/web/src/components/Companion/useCompanion.ts @@ -127,7 +127,10 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT // every finding was shown recently. Avoids repeating a finding or the same // rule family back-to-back so the companion never feels like a broken record. const nextTip = useCallback((): Bubble => { - const hints = analyzeProse(textRef.current) + // Applyable hints (those with a `fix`) surface as one-click suggestion cards, + // so the companion skips them — a span shouldn't be both a bubble and a card. + // What's left is the awareness notes (run-ons, splices, …) the kitten alone gives. + const hints = analyzeProse(textRef.current).filter((h) => !h.fix) const fresh = hints.find( (h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current, ) diff --git a/web/src/components/Editor/suggestionMeta.ts b/web/src/components/Editor/suggestionMeta.ts index f494a39..5a01d01 100644 --- a/web/src/components/Editor/suggestionMeta.ts +++ b/web/src/components/Editor/suggestionMeta.ts @@ -10,4 +10,5 @@ export const TYPE_META: Record clarity: { color: 'var(--color-sky)', label: 'Clarity' }, voice: { color: 'var(--color-honey)', label: 'Voice' }, collocation: { color: 'var(--color-blossom)', label: 'Word pairing' }, + mechanics: { color: 'var(--color-sage)', label: 'Tidy-up' }, } diff --git a/web/src/hooks/useCheckpoint.ts b/web/src/hooks/useCheckpoint.ts index c2e97a9..a0a8f1f 100644 --- a/web/src/hooks/useCheckpoint.ts +++ b/web/src/hooks/useCheckpoint.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api, type Suggestion } from '../api/client' +import { mechanicsFindings } from '../components/Companion/prose' const DEBOUNCE_MS = 4000 @@ -27,6 +28,13 @@ export function useCheckpoint(docId: string | null) { const docIdRef = useRef(docId) docIdRef.current = docId + // Latest plaintext, kept fresh by schedule(), so the deterministic mechanics + // pass (detected client-side, see prose.ts) reads the current document when the + // debounce fires — no need to re-thread text through every call. null until the + // first edit of the current doc, so a tone-only check before any edit doesn't + // submit an empty batch and wipe the doc's persisted mechanics rows. + const latestTextRef = useRef(null) + // Token to discard responses from a doc we've since navigated away from. const runRef = useRef(0) @@ -46,6 +54,21 @@ export function useCheckpoint(docId: string | null) { setChecking(true) let retrying = false try { + // Deterministic mechanics first: detect client-side and persist as the + // 'mechanics' family before the grammar pass, so the checkpoint's unified + // response already carries them. Best-effort and only on the initial try — + // a grammar retry shouldn't re-submit unchanged findings. A mechanics + // failure must not block the grammar pass. + if (attempt === 0 && latestTextRef.current !== null) { + try { + // Render the mechanics fixes immediately — they're instant (no LLM), so + // the unified set they return shouldn't wait on the slow grammar pass. + const withMech = await api.submitMechanics(id, mechanicsFindings(latestTextRef.current)) + if (run === runRef.current && id === docIdRef.current) setSuggestions(withMech) + } catch (err) { + console.error('mechanics submit failed', err) + } + } const fresh = await api.checkDoc(id) if (run === runRef.current && id === docIdRef.current) { setSuggestions(fresh) @@ -116,8 +139,11 @@ export function useCheckpoint(docId: string | null) { [runExplicitPass], ) - // Call on every edit; schedules a check 4s after typing settles. - const schedule = useCallback(() => { + // Call on every edit; schedules a check 4s after typing settles. Pass the + // current plaintext so the mechanics pass sees the latest document; omit it + // (e.g. a tone-only change) to reuse the last text. + const schedule = useCallback((text?: string) => { + if (text !== undefined) latestTextRef.current = text clearTimeout(debounceRef.current) clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS) @@ -129,6 +155,7 @@ export function useCheckpoint(docId: string | null) { clearTimeout(debounceRef.current) clearTimeout(retryRef.current) runRef.current++ + latestTextRef.current = null // unknown until the new doc's first edit setSuggestions([]) setChecking(false) setVoicing(false) diff --git a/web/src/index.css b/web/src/index.css index b9dd43d..de3f1fd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -21,6 +21,7 @@ --color-sky: #A8CCE8; /* clarity */ --color-honey: #CE9B4F; /* voice */ --color-blossom: #E59ABF; /* collocation — warm blossom pink */ + --color-sage: #B7C7B9; /* mechanics — calm sage, a gentle tidy-up nudge */ --color-success: #8FCFA8; /* saved / accepted */ /* Typography */ @@ -234,6 +235,7 @@ button, a, input { .petal-suggestion-clarity { border-bottom-color: var(--color-sky); } .petal-suggestion-voice { border-bottom-color: var(--color-honey); } .petal-suggestion-collocation { border-bottom-color: var(--color-blossom); } +.petal-suggestion-mechanics { border-bottom-color: var(--color-sage); } @keyframes petal-suggestion-in { from { opacity: 0; transform: translateY(4px); }