Merge feat/mechanics-deterministic-pass: deterministic mechanics suggestion family

Reuse the companion prose.ts rules as the single deterministic detector;
applyable rules feed 'mechanics' suggestion cards via a persist-only
endpoint, awareness rules stay companion bubbles. Mechanics wins span
collisions with LLM cards. Migration 0008 adds the type.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 20:44:00 -07:00
12 changed files with 767 additions and 181 deletions

View File

@@ -336,6 +336,35 @@ CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
name: "0007_vocab_definition", name: "0007_vocab_definition",
stmt: ` stmt: `
ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT ''; 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);
`, `,
}, },
} }

View File

@@ -101,6 +101,7 @@ const (
SuggestionTypeClarity = "clarity" SuggestionTypeClarity = "clarity"
SuggestionTypeVoice = "voice" SuggestionTypeVoice = "voice"
SuggestionTypeCollocation = "collocation" SuggestionTypeCollocation = "collocation"
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
SuggestionStatusPending = "pending" SuggestionStatusPending = "pending"
SuggestionStatusAccepted = "accepted" SuggestionStatusAccepted = "accepted"

View File

@@ -8,7 +8,9 @@ package suggestions
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"strings" "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. // the existing /api/docs router so they share its base path.
func (h *Handler) RegisterDocRoutes(r chi.Router) { func (h *Handler) RegisterDocRoutes(r chi.Router) {
r.Post("/{id}/check", h.check) r.Post("/{id}/check", h.check)
r.Post("/{id}/mechanics", h.mechanics)
r.Post("/{id}/voice", h.voice) r.Post("/{id}/voice", h.voice)
r.Post("/{id}/collocation", h.collocation) r.Post("/{id}/collocation", h.collocation)
r.Post("/{id}/rewrite", h.rewrite) 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. // 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) { func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope) 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, // voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
// explicit-action pass; replaces only the pending voice flags. // explicit-action pass; replaces only the pending voice flags.
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) { func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
@@ -163,9 +274,11 @@ type pendingScope struct {
var ( var (
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but // grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
// the explicit-action families (voice, collocation), which run on their own // the other self-owned families (voice, collocation, mechanics), which run on
// cadence and must survive a grammar checkpoint. // their own cadence/pass and must survive a grammar checkpoint. Notably the
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation')", forceType: ""} // 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 owns the voice flags only.
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice} voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
// collocationScope owns the collocation flags only. // collocationScope owns the collocation flags only.
@@ -291,7 +404,51 @@ func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
} }
out = append(out, s) 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). // accept marks a suggestion accepted (the client applies the replacement text).

View File

@@ -316,3 +316,116 @@ func TestCheckpointRateLimit(t *testing.T) {
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls) 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)
}
}

View File

@@ -274,7 +274,7 @@ export default function App() {
if (currentDoc) { if (currentDoc) {
patchSummary(currentDoc.id, { word_count: change.word_count }) patchSummary(currentDoc.id, { word_count: change.word_count })
schedule(change) schedule(change)
scheduleCheckpoint() scheduleCheckpoint(change.content_text)
} }
}, },
[currentDoc, patchSummary, schedule, scheduleCheckpoint], [currentDoc, patchSummary, schedule, scheduleCheckpoint],

View File

@@ -76,7 +76,7 @@ export interface Gloss {
gloss: string 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, // 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 // the sentence it was met in, and its spaced-repetition state. `reps` drives how
@@ -135,6 +135,17 @@ export interface Suggestion {
created_at: string 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<T>(path: string, init?: RequestInit): Promise<T> { async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, { const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -168,6 +179,14 @@ export const api = {
// word pairings ("do a decision" → "make a decision"). Returns the unified // word pairings ("do a decision" → "make a decision"). Returns the unified
// pending set too. Rate-limited per document server-side. // pending set too. Rate-limited per document server-side.
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }), collocationDoc: (id: string) => req<Suggestion[]>(`/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<Suggestion[]>(`/docs/${id}/mechanics`, {
method: 'POST',
body: JSON.stringify({ findings }),
}),
// Pending suggestions for a doc, loaded when the editor opens it. // Pending suggestions for a doc, loaded when the editor opens it.
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`), listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
acceptSuggestion: (id: string) => acceptSuggestion: (id: string) =>

View File

@@ -248,3 +248,119 @@ describe('capitalization basics', () => {
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence') 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<typeof mechanicsFindings>[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 <noun>" 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([])
})
})

View File

@@ -8,6 +8,14 @@
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting // 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* // a short slice of her own sentence so the advice clearly belongs to *this*
// paragraph and not a generic tip jar. // 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' import type { Line } from './tips'
@@ -17,6 +25,28 @@ export interface ProseHint extends Line {
id: string id: string
// Rule family, used to vary advice (don't show the same kind twice in a row). // Rule family, used to vary advice (don't show the same kind twice in a row).
rule: string 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 ────────────────────────────────────────────────────── // ── small text helpers ──────────────────────────────────────────────────────
@@ -48,11 +78,23 @@ function key(s: string): string {
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48) 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 ──────────────────────────────────────────────────────── // ── 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 // Run-on / overly long sentences — the single most common readability problem
// for ESL writers, who often chain clauses that a period would serve better. // 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[]) { function runOns(text: string, out: ProseHint[]) {
let found = 0 let found = 0
for (const s of sentences(text)) { 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 // 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 // introductory word — short or transition-led leads are intro phrases, not
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss. // 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[]) { function commaSplices(text: string, out: ProseHint[]) {
let found = 0 let found = 0
for (const s of sentences(text)) { 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 // 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 // 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). // when there's a prior sentence (so an antecedent is actually in question).
// Awareness-only: naming the referent needs the writer.
const ANTECEDENT_RE = 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/ /^(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 // 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 // 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 OXFORD_RE = /(\w+),\s+([\w'-]+(?:\s+[\w'-]+){0,2})\s+(and|or)\s+[\w'-]+/g
const CLAUSE_STARTERS = new Set([ const CLAUSE_STARTERS = new Set([
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if', '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 // A transition word opening a sentence with no comma after it (“However we…”).
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists. // Limited to conjunctive adverbs that strongly want the comma — sequence words
const A_BEFORE_VOWEL_RE = /\ba\s+([aeiou][a-z]+)\b/g // like “Then/First/Finally” are left out (their comma is optional). Awareness-
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag. // only: it fires per-sentence (offsets are sentence-relative), so we nudge.
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/ const INTRO_RE =
const AN_BEFORE_CONSONANT_RE = /\ban\s+([b-df-hj-np-tv-z][a-z]+)\b/g /^(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]/
// 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[]) { function introComma(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null for (const s of sentences(text)) {
let found = 0 const m = s.match(INTRO_RE)
A_BEFORE_VOWEL_RE.lastIndex = 0 if (m) {
while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) { out.push({
if (CONSONANT_SOUND_RE.test(m[1])) continue id: `introcomma:${m[1].toLowerCase()}`,
found++ rule: 'introcomma',
out.push({ zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
id: `article-an:${key(m[1])}`, en: `Put a comma after the opening transition: “${m[1]}, …”.`,
rule: 'article', })
zh: `元音开头的词前用 “an”“an ${m[1]}”。`, break
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.',
})
} }
} }
// Sentence not starting with a capital. We look after a terminator, and ignore // 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). // 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])/ const LOWER_START_RE = /[.!?]\s+([a-z])/
function sentenceCaps(text: string, out: ProseHint[]) { 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). // ── applyable rules (also feed the suggestion cards) ─────────────────────────
const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/
function spaceBeforePunct(text: string, out: ProseHint[]) { // Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
if (SPACE_BEFORE_PUNCT_RE.test(text)) { // 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({ out.push({
id: 'space-punct', id: `double:${from}:${key(m[0])}`,
rule: 'space-punct', rule: 'double',
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。', zh: `${m[1]}” 好像写了两遍,检查一下哦。`,
en: 'No space before punctuation — it tucks right against the word.', en: `${m[1]} ${m[1]}” — looks like a word got doubled.`,
fix: { from, to, replacement: m[1] },
}) })
} }
} }
// ── Chinese-L1 interference rules (Tier 1) ────────────────────────────────── // Lowercase standalone “I”. Conservative: only when bounded by spaces / line
// Mandarin lacks plural inflection, articles, and verb agreement, so these // edge, to avoid tangling with “i.e.” and the like. Awareness-only: a single
// patterns are the predictable places that grammar "leaks" into her English. // character can't be re-anchored unambiguously by string (every word holds an
// All are closed-list or strong-signal to keep false positives near zero. // “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 // 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. // essentially never valid with a trailing “s”, so the list is its own guard.
const UNCOUNTABLE_PLURAL_RE = 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[]) { function uncountables(text: string, out: ProseHint[]) {
const m = UNCOUNTABLE_PLURAL_RE.exec(text) let m: RegExpExecArray | null
UNCOUNTABLE_PLURAL_RE.lastIndex = 0 UNCOUNTABLE_PLURAL_RE.lastIndex = 0
if (m) { while ((m = UNCOUNTABLE_PLURAL_RE.exec(text))) {
const singular = m[1].replace(/s$/i, '') const word = m[1]
const singular = word.replace(/s$/i, '')
out.push({ out.push({
id: `uncountable:${m[1].toLowerCase()}`, id: `uncountable:${m.index}`,
rule: 'uncountable', rule: 'uncountable',
zh: `${m[1]}” 是不可数名词,不用加 s写 “${singular}” 就好。`, zh: `${word}” 是不可数名词,不用加 s写 “${singular}” 就好。`,
en: `${m[1]}” is uncountable — drop the “s”: just “${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|' + 'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
'australian|mexican|brazilian|european' 'australian|mexican|brazilian|european'
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged. // 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[]) { function properCaps(text: string, out: ProseHint[]) {
const m = PROPER_NOUN_RE.exec(text) let m: RegExpExecArray | null
if (m) { PROPER_NOUN_RE.lastIndex = 0
const fixed = m[1][0].toUpperCase() + m[1].slice(1) while ((m = PROPER_NOUN_RE.exec(text))) {
const word = m[1]
const fixed = word[0].toUpperCase() + word.slice(1)
out.push({ out.push({
id: `propercap:${m[1]}`, id: `propercap:${m.index}`,
rule: 'propercap', rule: 'propercap',
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`, zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
en: `Languages, days, and months are capitalized in English: “${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[]) { function subjectVerbAgreement(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null let m: RegExpExecArray | null
let found = 0
SVA_RE.lastIndex = 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 if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
found++ const verb = m[3]
const fixed = third(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({ out.push({
id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`, id: `sva:${m.index}`,
rule: 'sva', rule: 'sva',
zh: `主语是 he/she/it 时,动词要加 -s${m[2]} ${fixed}”。`, zh: `主语是 he/she/it 时,动词要加 -s${m[2]} ${fixed}”。`,
en: `After he/she/it the verb takes “-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[]) { function pluralAfterNumber(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null let m: RegExpExecArray | null
let found = 0
NUMBER_NOUN_RE.lastIndex = 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() const noun = m[2].toLowerCase()
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
found++ // Span "<number> <noun>" so the original anchors; append “s” to the noun.
out.push({ out.push({
id: `plural:${key(m[0])}`, id: `plural:${m.index}`,
rule: 'plural', rule: 'plural',
zh: `${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`, zh: `${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
en: `After “${m[1]}”, the noun is plural: “${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 // 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 = 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[]) { function doubleDeterminer(text: string, out: ProseHint[]) {
const m = DOUBLE_DET_RE.exec(text) let m: RegExpExecArray | null
DOUBLE_DET_RE.lastIndex = 0 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({ out.push({
id: `doubledet:${key(m[0])}`, id: `doubledet:${m.index}`,
rule: 'doubledet', rule: 'doubledet',
zh: `${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`, zh: `${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
en: `${m[1]} ${m[2]}” stacks two determiners — keep just one.`, 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 // on clearly-plural cues (skip “some/any”, which are fine with singular mass
// nouns: “there is some water”). // nouns: “there is some water”).
const THERE_IS_RE = 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[]) { function thereIsPlural(text: string, out: ProseHint[]) {
const m = THERE_IS_RE.exec(text) let m: RegExpExecArray | null
THERE_IS_RE.lastIndex = 0 THERE_IS_RE.lastIndex = 0
if (m) { 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({ out.push({
id: `thereis:${m[1].toLowerCase()}`, id: `thereis:${m.index}`,
rule: 'thereis', rule: 'thereis',
zh: `后面是复数时用 “there are”“there are ${m[1]}…”。`, zh: `后面是复数时用 “there are”“there are ${m[2]}…”。`,
en: `With a plural, use “there are”: “there are ${m[1]}…”.`, 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) // 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). // 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_OWN_RE = /\b(it's|its)\s+own\b/gi
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/gi
function itsConfusion(text: string, out: ProseHint[]) { 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({ out.push({
id: 'its-own', id: `its-own:${m.index}`,
rule: 'its', rule: 'its',
zh: '“its” = “it is”表示“它的”要用 “its”所以是 “its own”。', zh: '“its” = “it is”表示“它的”要用 “its”所以是 “its own”。',
en: '“its” means “it is” — the possessive 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) },
}) })
return
} }
const m = ITS_ARTICLE_RE.exec(text)
ITS_ARTICLE_RE.lastIndex = 0 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({ out.push({
id: 'its-article', id: `its-article:${m.index}`,
rule: 'its', rule: 'its',
zh: `这里应该是 “its ${m[1]}it is“its” 是“它的”。`, zh: `这里应该是 “its ${m[1]}it is“its” 是“它的”。`,
en: `Here it should be “its ${m[1]}” (it is); “its” means belonging to it.`, 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) },
}) })
} }
} }
@@ -485,57 +606,20 @@ const COMPARATIVES =
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' + 'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' + 'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer' '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[]) { function thanThen(text: string, out: ProseHint[]) {
const m = THAN_THEN_RE.exec(text) let m: RegExpExecArray | null
if (m) { 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({ out.push({
id: `than:${m[1].toLowerCase()}`, id: `than:${m.index}`,
rule: 'than', rule: 'than',
zh: `比较的时候用 “than”不是 “then”${m[1]} than”。`, zh: `比较的时候用 “than”不是 “then”${m[1]} than”。`,
en: `For comparisons use “than”, not “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') },
}
}
// 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.',
}) })
} }
} }
@@ -568,6 +652,8 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [
// analyzeProse returns context-aware hints, highest-priority first. It bails on // 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). // 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[] { export function analyzeProse(text: string): ProseHint[] {
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0 const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
if (englishWords < 8) return [] if (englishWords < 8) return []
@@ -575,3 +661,35 @@ export function analyzeProse(text: string): ProseHint[] {
for (const rule of RULES) rule(text, out) for (const rule of RULES) rule(text, out)
return 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
}

View File

@@ -127,7 +127,10 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
// every finding was shown recently. Avoids repeating a finding or the same // 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. // rule family back-to-back so the companion never feels like a broken record.
const nextTip = useCallback((): Bubble => { 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( const fresh = hints.find(
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current, (h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
) )

View File

@@ -10,4 +10,5 @@ export const TYPE_META: Record<SuggestionType, { color: string; label: string }>
clarity: { color: 'var(--color-sky)', label: 'Clarity' }, clarity: { color: 'var(--color-sky)', label: 'Clarity' },
voice: { color: 'var(--color-honey)', label: 'Voice' }, voice: { color: 'var(--color-honey)', label: 'Voice' },
collocation: { color: 'var(--color-blossom)', label: 'Word pairing' }, collocation: { color: 'var(--color-blossom)', label: 'Word pairing' },
mechanics: { color: 'var(--color-sage)', label: 'Tidy-up' },
} }

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type Suggestion } from '../api/client' import { api, type Suggestion } from '../api/client'
import { mechanicsFindings } from '../components/Companion/prose'
const DEBOUNCE_MS = 4000 const DEBOUNCE_MS = 4000
@@ -27,6 +28,13 @@ export function useCheckpoint(docId: string | null) {
const docIdRef = useRef(docId) const docIdRef = useRef(docId)
docIdRef.current = 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<string | null>(null)
// Token to discard responses from a doc we've since navigated away from. // Token to discard responses from a doc we've since navigated away from.
const runRef = useRef(0) const runRef = useRef(0)
@@ -46,6 +54,21 @@ export function useCheckpoint(docId: string | null) {
setChecking(true) setChecking(true)
let retrying = false let retrying = false
try { 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) const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) { if (run === runRef.current && id === docIdRef.current) {
setSuggestions(fresh) setSuggestions(fresh)
@@ -116,8 +139,11 @@ export function useCheckpoint(docId: string | null) {
[runExplicitPass], [runExplicitPass],
) )
// Call on every edit; schedules a check 4s after typing settles. // Call on every edit; schedules a check 4s after typing settles. Pass the
const schedule = useCallback(() => { // 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(debounceRef.current)
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS) debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
@@ -129,6 +155,7 @@ export function useCheckpoint(docId: string | null) {
clearTimeout(debounceRef.current) clearTimeout(debounceRef.current)
clearTimeout(retryRef.current) clearTimeout(retryRef.current)
runRef.current++ runRef.current++
latestTextRef.current = null // unknown until the new doc's first edit
setSuggestions([]) setSuggestions([])
setChecking(false) setChecking(false)
setVoicing(false) setVoicing(false)

View File

@@ -21,6 +21,7 @@
--color-sky: #A8CCE8; /* clarity */ --color-sky: #A8CCE8; /* clarity */
--color-honey: #CE9B4F; /* voice */ --color-honey: #CE9B4F; /* voice */
--color-blossom: #E59ABF; /* collocation — warm blossom pink */ --color-blossom: #E59ABF; /* collocation — warm blossom pink */
--color-sage: #B7C7B9; /* mechanics — calm sage, a gentle tidy-up nudge */
--color-success: #8FCFA8; /* saved / accepted */ --color-success: #8FCFA8; /* saved / accepted */
/* Typography */ /* Typography */
@@ -234,6 +235,7 @@ button, a, input {
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); } .petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
.petal-suggestion-voice { border-bottom-color: var(--color-honey); } .petal-suggestion-voice { border-bottom-color: var(--color-honey); }
.petal-suggestion-collocation { border-bottom-color: var(--color-blossom); } .petal-suggestion-collocation { border-bottom-color: var(--color-blossom); }
.petal-suggestion-mechanics { border-bottom-color: var(--color-sage); }
@keyframes petal-suggestion-in { @keyframes petal-suggestion-in {
from { opacity: 0; transform: translateY(4px); } from { opacity: 0; transform: translateY(4px); }