7 Commits

Author SHA1 Message Date
prosolis
49e84278d5 Merge feat/calm-suggestions: drop no-op suggestions + suppress fickle re-edits
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-27 00:18:47 -07:00
prosolis
9d2501a625 Suppress fickle re-edits of sentences the user already settled
Suppression keyed on the exact original->replacement pair, which the
model routinely sidestepped: it reverses an accepted edit (reverse
pair), re-polishes its own accepted output (new original == accepted
replacement), and the editor's smart-quote churn ("..." -> '...')
defeated even a byte-exact match. Result: a few sentences got nudged
back and forth pass after pass.

Replace the exact-pair actionedKeys with a suppressor that compares
under a normalization folding all quote variants and collapsing
whitespace, and drops a fresh suggestion when it re-touches an
already-settled span: same edit re-proposed, an original the user
already accepted/dismissed, the model re-touching its own accepted
output, or a multi-word sub-clause contained in an accepted span.

Tradeoff: once a sentence is accepted/dismissed it won't be re-flagged
until its text changes — stability over marginal improvement, the right
call for the calm ESL persona.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-27 00:18:23 -07:00
prosolis
08b801e752 Drop no-op suggestions where replacement equals original
The checkpoint model sometimes "flags" a correct sentence and echoes it
verbatim as the replacement, producing a card whose before/after are
identical and whose explanation says it's already fine — a suggestion
that suggests nothing. ParseCheckpoint already dropped empty-original
items; also drop these no-ops. Awareness-only families (voice) carry an
empty replacement, so the guard only fires on the edit families.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-27 00:18:10 -07:00
prosolis
dd1dd7879b Merge feat/spelling-popover: right-click corrections + robust word resolution 2026-06-26 22:23:42 -07:00
prosolis
cb8f132d43 Spelling popover: right-click corrections + robust word resolution
Resolve the misspelled word from the click/right-click position and the
checker rather than from the .petal-misspelling DOM span: clicking a word
moves the caret into it, which fires the decoration rebuild that
deliberately un-underlines the caret word, so the span is already gone by
the time the handler runs. Factored into openMisspellAt(pos), which only
opens when the checker actually flags the word, and is gated to clicks
inside .petal-prose so a click on a floating card doesn't resolve a word
hidden behind it.

Right-click now offers spelling corrections first on a misspelled word
(the familiar "did you mean" gesture), falling back to word lookup
otherwise.

AskPetal: focus the input with preventScroll so opening the card doesn't
jump the document to the top.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 20:54:47 -07:00
prosolis
cea25a3ebc 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
2026-06-26 20:44:00 -07:00
prosolis
96f68a91ee Add deterministic mechanics suggestion family (rule-based, no LLM)
Reuse the companion's prose.ts rules engine as the single source of
deterministic detection instead of duplicating it. Applyable rules now
also emit exact-span fixes (original -> replacement) that surface as
suggestion cards; awareness-only rules (run-ons, splices, ...) stay
companion bubbles. The companion hides fix-bearing hints so a span is
never both a bubble and a card.

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

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

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

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

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 20:43:37 -07:00
16 changed files with 1011 additions and 238 deletions

View File

@@ -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);
`,
},
}

View File

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

View File

@@ -89,6 +89,14 @@ func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
if s.Original == "" {
continue
}
// Drop no-ops: the model sometimes "flags" a correct sentence and echoes
// it verbatim as the replacement, producing a card whose before/after are
// identical and whose explanation says it's already fine — a suggestion
// that suggests nothing. Awareness-only families (voice) carry an empty
// replacement, so this only ever fires on the edit families.
if s.Replacement != "" && s.Original == s.Replacement {
continue
}
out = append(out, s)
}
return out, nil

View File

@@ -37,6 +37,25 @@ func TestParseCheckpoint(t *testing.T) {
raw: "I could not find any issues!",
wantErr: true,
},
{
// A correct sentence echoed verbatim as its own replacement is a card
// that suggests nothing — dropped. The second item is a real edit.
name: "drops no-op where replacement equals original",
raw: `{"suggestions":[{"original":"It wasn't the most graceful morning.","replacement":"It wasn't the most graceful morning.","explanation":"used correctly here","type":"idiom"},{"original":"teh","replacement":"the","explanation":"typo","type":"grammar"}]}`,
want: 1,
},
{
// Whitespace-only difference is still a no-op once both are trimmed.
name: "drops no-op after whitespace trim",
raw: `{"suggestions":[{"original":"the cat sat","replacement":" the cat sat ","explanation":"already fine","type":"grammar"}]}`,
want: 0,
},
{
// Awareness-only voice flags carry an empty replacement and must survive.
name: "keeps awareness-only flag with empty replacement",
raw: `{"suggestions":[{"original":"a long run-on passage","replacement":"","explanation":"this drifts from your voice","type":"voice"}]}`,
want: 1,
},
{
name: "braces inside string values",
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,

View File

@@ -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
}
sup, err := buildSuppressor(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 sup.suppressed(f.Original, f.Replacement) {
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.
@@ -176,13 +289,12 @@ var (
// fresh batch in a single transaction. Accepted/rejected suggestions and the
// other family's pending rows are left untouched.
//
// Suggestions the user already accepted or dismissed are suppressed from the
// fresh batch: the model has no memory between passes, so without this it would
// re-propose the identical edit on the very next checkpoint — re-nagging a
// sentence the user already resolved. This matters most when an accept silently
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
// never changed): the sentence is unaltered, yet the user shouldn't see the same
// card again.
// Suggestions touching a sentence the user already settled are suppressed from
// the fresh batch (see suppressor): not just the identical edit re-proposed, but
// reversals and re-polishing of the model's own just-accepted output — the
// "fickle, keeps going back and forth on a few sentences" behavior. The model has
// no memory between passes, so without this it re-opens resolved sentences every
// checkpoint.
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
tx, err := h.DB.Begin()
if err != nil {
@@ -197,13 +309,13 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return err
}
actioned, err := actionedKeys(tx, docID)
sup, err := buildSuppressor(tx, docID)
if err != nil {
return err
}
for _, s := range raw {
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
if sup.suppressed(s.Original, s.Replacement) {
continue
}
typ := scope.forceType
@@ -223,37 +335,131 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return tx.Commit()
}
// actionedKeys returns the set of original→replacement keys the user has already
// accepted or rejected for this document, so a fresh checkpoint can skip
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
// sentence is not suppressed.
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
// dedupQuoteReplacer folds every straight/curly single- and double-quote variant
// (and backtick/acute accent) onto one canonical character. The editor and the
// model both rewrite quotes between passes — a sentence accepted with "…" comes
// back flagged with '…' — so without folding, byte-identical text reads as a
// different edit and the suppression below misses it. (This normalization is for
// dedup ONLY; the frontend still anchors on the verbatim `original`.)
var dedupQuoteReplacer = strings.NewReplacer(
"", "'", "", "'", "", "'", "", "'", // single curly
"“", "'", "”", "'", "„", "'", "″", "'", // double curly
"\"", "'", "`", "'", "´", "'", // straight double, backtick, acute
)
// normalizeForDedup canonicalizes a string for suppression comparisons: quotes
// folded (above) and runs of whitespace collapsed to single spaces (so a reflowed
// paragraph still matches). Used only to decide what to suppress, never to alter
// stored or rendered text.
func normalizeForDedup(s string) string {
return strings.Join(strings.Fields(dedupQuoteReplacer.Replace(s)), " ")
}
// suppressor decides which fresh suggestions to drop because the user has already
// settled the sentence they touch. The model has no memory between passes, so on
// every checkpoint it re-examines the current text and proposes edits — including
// ones that re-open a sentence the user already resolved. Three families of those
// are suppressed (all compared under normalizeForDedup):
//
// - pairs: the identical edit, re-proposed verbatim (the original "accept it,
// then it nags again" case; also covers a silent no-op accept that left the
// text unchanged).
// - actionedOrig: any edit whose original is a span the user already accepted or
// dismissed an edit on — "you already decided about this exact sentence."
// - acceptedRepl: any edit whose original is text the user accepted AS a
// replacement — i.e. the model re-touching its own just-accepted output, which
// is how the reversals and endless re-polishing arise (accept "due to the
// rain", next pass proposes changing "due to the rain" back). Guarded to
// multi-word spans so word-level fixes aren't swept up as collateral.
// - acceptedReplList holds the same accepted replacements for a containment
// check: the model evades the exact acceptedRepl match by re-flagging a
// *sub-clause* of an accepted sentence (flag "she was…due to the rain" instead
// of the whole sentence). When one of the new original / an accepted
// replacement contains the other and the shorter side is substantial
// (>= minContainWords words), it's the same settled span and is dropped.
type suppressor struct {
pairs map[string]struct{}
actionedOrig map[string]struct{}
acceptedRepl map[string]struct{}
acceptedReplList []string
}
// minContainWords is the floor for the containment check: the shorter of the two
// spans must be at least this many words before a substring relationship counts
// as "the same settled text." High enough that an incidental common phrase ("the
// rain") can't suppress an unrelated sentence, low enough to catch a re-flagged
// clause.
const minContainWords = 4
// suppressed reports whether a fresh suggestion should be dropped as already
// settled. An empty original is never suppressed here (it can't anchor anyway and
// is dropped upstream).
func (s suppressor) suppressed(original, replacement string) bool {
o := normalizeForDedup(original)
if o == "" {
return false
}
if _, ok := s.pairs[o+"\x00"+normalizeForDedup(replacement)]; ok {
return true
}
if _, ok := s.actionedOrig[o]; ok {
return true
}
if strings.ContainsRune(o, ' ') {
if _, ok := s.acceptedRepl[o]; ok {
return true
}
}
// Containment: the model re-flagged a sub-clause of (or a window around) an
// accepted span. Suppress when one contains the other and the shorter side is
// a substantial multi-word run.
for _, r := range s.acceptedReplList {
shorter, longer := o, r
if len(r) < len(o) {
shorter, longer = r, o
}
if len(strings.Fields(shorter)) >= minContainWords && strings.Contains(longer, shorter) {
return true
}
}
return false
}
// buildSuppressor loads the document's accepted/rejected edits and indexes them
// into the three suppression families described on suppressor.
func buildSuppressor(tx *sql.Tx, docID string) (suppressor, error) {
rows, err := tx.Query(
`SELECT original, replacement FROM suggestions
`SELECT original, replacement, status FROM suggestions
WHERE doc_id = ? AND status IN (?, ?)`,
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
)
if err != nil {
return nil, err
return suppressor{}, err
}
defer rows.Close()
keys := make(map[string]struct{})
for rows.Next() {
var original, replacement string
if err := rows.Scan(&original, &replacement); err != nil {
return nil, err
}
keys[suggestionKey(original, replacement)] = struct{}{}
s := suppressor{
pairs: make(map[string]struct{}),
actionedOrig: make(map[string]struct{}),
acceptedRepl: make(map[string]struct{}),
}
return keys, rows.Err()
}
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
// replacement text. Two suggestions with the same key are "the same edit."
func suggestionKey(original, replacement string) string {
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
for rows.Next() {
var original, replacement, status string
if err := rows.Scan(&original, &replacement, &status); err != nil {
return suppressor{}, err
}
o := normalizeForDedup(original)
r := normalizeForDedup(replacement)
s.pairs[o+"\x00"+r] = struct{}{}
if o != "" {
s.actionedOrig[o] = struct{}{}
}
if status == db.SuggestionStatusAccepted && r != "" {
s.acceptedRepl[r] = struct{}{}
s.acceptedReplList = append(s.acceptedReplList, r)
}
}
return s, rows.Err()
}
// listForDoc returns the document's current pending suggestions (used when the
@@ -291,7 +497,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).

View File

@@ -165,6 +165,48 @@ func TestResolvedSuggestionsNotReproposed(t *testing.T) {
}
}
// TestFickleEditsSuppressed proves the suppressor kills the "keeps going back and
// forth on a few sentences" behavior seen live on the Missing Key doc: a later
// pass that (a) reverses an edit the user just accepted — even with the editor's
// double→single quote churn that defeats a byte-exact match — or (b) re-polishes
// the model's own accepted output. A genuinely new edit on a fresh sentence still
// survives.
func TestFickleEditsSuppressed(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"He left \"early,\" because of the rain.","replacement":"He left \"early,\" due to the rain.","explanation":"smoother","type":"phrasing"},
{"original":"The cat always have a calm face.","replacement":"The cat always has a calm face.","explanation":"agreement","type":"grammar"}
]}`}
srv, docID, h := newTestServer(t, client)
h.Limit = llm.NewRateLimiter(0)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
var got []db.Suggestion
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 2 {
t.Fatalf("first pass: want 2, got %d", len(got))
}
for _, s := range got {
do(t, srv, http.MethodPost, "/suggestions/"+s.ID+"/accept", "")
}
// Reversal of the first accept (note the " → ' quote churn) and a re-polish of
// the second accept must both be dropped; only the unrelated edit survives.
client.response = `{"suggestions":[
{"original":"He left 'early,' due to the rain.","replacement":"He left 'early,' because of the rain.","explanation":"reverts the accepted edit","type":"phrasing"},
{"original":"The cat always has a calm face.","replacement":"The cat always seems to have a calm face.","explanation":"re-polishes accepted output","type":"phrasing"},
{"original":"due to the rain","replacement":"because of the rain","explanation":"sub-clause reversal of the accepted span","type":"phrasing"},
{"original":"She drived home.","replacement":"She drove home.","explanation":"past tense","type":"grammar"}
]}`
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
var again []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
t.Fatalf("decode: %v", err)
}
if len(again) != 1 || again[0].Original != "She drived home." {
t.Fatalf("want only the new edit to survive, got %d: %+v", len(again), again)
}
}
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
// disjoint pending families: running one never wipes the other's flags, and
// each endpoint returns the unified pending set.
@@ -316,3 +358,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)
}
}

View File

@@ -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],

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<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.
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
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')
})
})
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
// 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 <noun>" so the original is a distinctive, anchorable
// phrase (a bare "a" matches everywhere). Replacement swaps only the article.
out.push({
id: `article-an:${m.index}`,
rule: 'article',
zh: `元音开头的词前用 “an”“an ${m[2]}”。`,
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
})
}
AN_BEFORE_CONSONANT_RE.lastIndex = 0
while ((m = AN_BEFORE_CONSONANT_RE.exec(text))) {
if (VOWEL_SOUND_RE.test(m[2])) continue
out.push({
id: `article-a:${m.index}`,
rule: 'article',
zh: `辅音开头的词前用 “a”“a ${m[2]}”。`,
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
})
}
}
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
// essentially never valid with a trailing “s”, so the list is its own guard.
const UNCOUNTABLE_PLURAL_RE =
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/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 "<number> <noun>" 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 <plural>" phrase so it anchors; swap the verb part
// (m[1]: “ is” / “'s”) for “ are”, preserving the “there” casing and the cue.
const replacement = m[0].slice(0, 5) + ' are' + m[0].slice(5 + m[1].length)
out.push({
id: `thereis:${m[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|its)\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: '“its” = “it is”表示“它的”要用 “its”所以是 “its own”。',
en: '“its” means “it is” — the possessive is “its”: “its own”.',
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
})
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: `这里应该是 “its ${m[1]}it is“its” 是“它的”。`,
en: `Here it should be “its ${m[1]}” (it is); “its” means belonging to it.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
})
}
}
@@ -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 "<comparative> 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
}

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
// 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,
)

View File

@@ -34,9 +34,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
if (el) el.scrollTop = el.scrollHeight
}, [messages])
// Focus the input when the panel opens.
// Focus the input when the panel opens. preventScroll: the card is already on
// screen as an absolutely-positioned overlay, and a default focus() would make
// the browser scroll its ancestor to "reveal" the input — jumping the document
// to the top.
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.focus({ preventScroll: true })
}, [])
// Fetch the Chinese translation of the explanation to seed the first bubble.
@@ -95,7 +98,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
console.error('Ask Petal chat failed:', err)
} finally {
setStreaming(false)
inputRef.current?.focus()
inputRef.current?.focus({ preventScroll: true })
}
}

View File

@@ -580,9 +580,37 @@ export function EditorCore({
[onDismiss, closeCard],
)
// Click a red-underlined word to open its spelling popover, anchored under the
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
// misspelling appearing elsewhere); nspell supplies the corrections.
// openMisspellAt resolves the word at a document position and, if the checker
// flags it as misspelled, opens the spelling popover anchored under the word.
// Returns true if it opened a card. We resolve the word from coordinates and
// the checker rather than from the `.petal-misspelling` DOM span: clicking a
// word moves the caret into it, which fires the decoration rebuild that
// deliberately un-underlines the caret word (see SpellCheck's caret exemption),
// so by the time a click/contextmenu handler runs the span is already gone.
const openMisspellAt = useCallback(
(pos: number): boolean => {
if (!editor || !spellChecker) return false
const range = wordAt(editor.state.doc, pos)
if (!range || spellChecker.correct(range.word)) return false
const wrapper = wrapperRef.current
if (!wrapper) return false
const start = editor.view.coordsAtPos(range.from)
const end = editor.view.coordsAtPos(range.to)
const wrapRect = wrapper.getBoundingClientRect()
const cardWidth = 240
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
const top = end.bottom - wrapRect.top + 6
// Opening a spelling popover supersedes any AI-suggestion or lookup card.
closeCard()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
return true
},
[editor, spellChecker, closeCard],
)
// Click a misspelled word to open its spelling popover, anchored under the
// word; nspell supplies the corrections.
//
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
// no hover, so the tap is the only way in (mouse users still get hover).
@@ -603,26 +631,16 @@ export function EditorCore({
}
return
}
if (!editor || !spellChecker) return
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
if (!target) return
const wrapper = wrapperRef.current
if (!wrapper) return
if (!editor) return
// Only act on clicks in the editor text itself — the floating cards/popovers
// are children of this same wrapper, and a click on one shouldn't resolve a
// (hidden) word behind it.
if (!(e.target as HTMLElement).closest('.petal-prose')) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return
const range = wordAt(editor.state.doc, coords.pos)
if (!range) return
const elRect = target.getBoundingClientRect()
const wrapRect = wrapper.getBoundingClientRect()
const cardWidth = 240
const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
const top = elRect.bottom - wrapRect.top + 6
// Opening a spelling popover supersedes any AI-suggestion hover card.
closeCard()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
openMisspellAt(coords.pos)
},
[editor, spellChecker, closeCard, openCardFor, railEnabled],
[editor, openMisspellAt, openCardFor, railEnabled],
)
const replaceMisspelling = useCallback(
@@ -757,8 +775,10 @@ export function EditorCore({
.catch((err) => console.error('vocab save failed', err))
}, [wordInfo, exampleAt, docId])
// Right-click a word to look it up. Right-clicking off any word falls through
// to the native menu (so copy/paste-by-menu still works — see the Selection fix).
// Right-click a misspelled word for spelling corrections (the familiar "did you
// mean" gesture); right-click any other word to look it up. Right-clicking off
// any word falls through to the native menu (so copy/paste-by-menu still works
// — see the Selection fix).
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!editor) return
@@ -766,9 +786,11 @@ export function EditorCore({
if (!coords) return
if (!wordAt(editor.state.doc, coords.pos)) return
e.preventDefault()
// A misspelled word offers corrections first; otherwise look it up.
if (openMisspellAt(coords.pos)) return
openWordLookup(coords.pos)
},
[editor, openWordLookup],
[editor, openMisspellAt, openWordLookup],
)
// Touch has no hover or right-click, so a long-press (~500ms without moving)

View File

@@ -10,4 +10,5 @@ export const TYPE_META: Record<SuggestionType, { color: string; label: string }>
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' },
}

View File

@@ -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<string | null>(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)

View File

@@ -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); }