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
This commit is contained in:
@@ -316,3 +316,116 @@ func TestCheckpointRateLimit(t *testing.T) {
|
||||
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// postMechanics submits a deterministic-findings batch (as the client's prose
|
||||
// detector would) and returns the unified pending set.
|
||||
func postMechanics(t *testing.T, srv http.Handler, docID, findingsJSON string) []db.Suggestion {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", `{"findings":`+findingsJSON+`}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("mechanics: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var got []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// TestMechanicsPersistsFindings proves the endpoint persists client-detected
|
||||
// findings as a 'mechanics' family with the exact offsets the client supplied,
|
||||
// and that a later grammar checkpoint leaves them untouched (own family).
|
||||
func TestMechanicsPersistsFindings(t *testing.T) {
|
||||
client := &stubClient{response: `{"suggestions":[]}`}
|
||||
srv, docID, _ := newTestServer(t, client)
|
||||
|
||||
got := postMechanics(t, srv, docID, `[
|
||||
{"from":0,"to":1,"original":"i","replacement":"I","explanation":"capitalize I"},
|
||||
{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}
|
||||
]`)
|
||||
var mech []db.Suggestion
|
||||
for _, s := range got {
|
||||
if s.Type == db.SuggestionTypeMechanics {
|
||||
mech = append(mech, s)
|
||||
}
|
||||
}
|
||||
if len(mech) != 2 {
|
||||
t.Fatalf("want 2 mechanics findings, got %d: %+v", len(mech), got)
|
||||
}
|
||||
// The client's exact offsets are preserved verbatim.
|
||||
for _, s := range mech {
|
||||
if s.Original == "the the" && (s.FromPos != 6 || s.ToPos != 13) {
|
||||
t.Errorf("offsets not preserved: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A grammar checkpoint must not wipe the mechanics family.
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var after []db.Suggestion
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &after)
|
||||
count := 0
|
||||
for _, s := range after {
|
||||
if s.Type == db.SuggestionTypeMechanics {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("grammar checkpoint disturbed the mechanics family: got %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMechanicsWinsSpanCollision proves that when a mechanics finding and an LLM
|
||||
// grammar suggestion claim overlapping characters, the LLM card is dropped from
|
||||
// the response and the exact mechanics fix owns the span.
|
||||
func TestMechanicsWinsSpanCollision(t *testing.T) {
|
||||
// LLM grammar suggestion covers "the the cat", overlapping the mechanics
|
||||
// "the the" finding at [0,7].
|
||||
client := &stubClient{response: `{"suggestions":[
|
||||
{"original":"the the cat","replacement":"the cat","explanation":"wordy","type":"grammar"}
|
||||
]}`}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, "the the cat sat", docID); err != nil {
|
||||
t.Fatalf("set content: %v", err)
|
||||
}
|
||||
|
||||
// Persist the mechanics finding, then run the grammar pass.
|
||||
postMechanics(t, srv, docID, `[{"from":0,"to":7,"original":"the the","replacement":"the","explanation":"doubled word"}]`)
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var got []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.Type == db.SuggestionTypeGrammar {
|
||||
t.Fatalf("overlapping grammar card should have been dropped in favor of mechanics, got %+v", got)
|
||||
}
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != db.SuggestionTypeMechanics {
|
||||
t.Fatalf("want sole mechanics finding, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMechanicsActionedSuppression proves a dismissed mechanics fix is not
|
||||
// re-proposed on the next submission of the same finding.
|
||||
func TestMechanicsActionedSuppression(t *testing.T) {
|
||||
client := &stubClient{response: `{"suggestions":[]}`}
|
||||
srv, docID, _ := newTestServer(t, client)
|
||||
|
||||
findings := `[{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}]`
|
||||
got := postMechanics(t, srv, docID, findings)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want 1 finding, got %+v", got)
|
||||
}
|
||||
// Dismiss it, then resubmit the same finding — it must stay suppressed.
|
||||
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/dismiss", "")
|
||||
again := postMechanics(t, srv, docID, findings)
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("dismissed mechanics fix should not reappear, got %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user