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:
@@ -8,7 +8,9 @@ package suggestions
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -46,6 +48,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||
// the existing /api/docs router so they share its base path.
|
||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
r.Post("/{id}/check", h.check)
|
||||
r.Post("/{id}/mechanics", h.mechanics)
|
||||
r.Post("/{id}/voice", h.voice)
|
||||
r.Post("/{id}/collocation", h.collocation)
|
||||
r.Post("/{id}/rewrite", h.rewrite)
|
||||
@@ -64,10 +67,118 @@ func (h *Handler) Routes() chi.Router {
|
||||
}
|
||||
|
||||
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
||||
// The deterministic mechanics family is owned by a separate pass (see mechanics),
|
||||
// detected client-side; a grammar checkpoint leaves those flags untouched.
|
||||
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
||||
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
||||
}
|
||||
|
||||
// mechanicsFinding is one deterministic, rule-based fix detected client-side (see
|
||||
// web Companion/prose.ts). Detection lives in the frontend — the same rules that
|
||||
// power the companion's prose notes — so the server only persists these; it does
|
||||
// not compute them. Offsets are exact plaintext spans from the detector.
|
||||
type mechanicsFinding struct {
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
Original string `json:"original"`
|
||||
Replacement string `json:"replacement"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// maxMechanicsFindings caps a single submission so a runaway client can't flood
|
||||
// the table; far above any realistic count for one document.
|
||||
const maxMechanicsFindings = 500
|
||||
|
||||
// mechanics persists the client-detected deterministic fixes as the 'mechanics'
|
||||
// family and returns the document's unified pending set. Free and not rate-
|
||||
// limited — it runs alongside the grammar checkpoint. It mirrors a single LLM
|
||||
// family: it replaces only the pending mechanics rows, honours actioned-
|
||||
// suppression, and (unlike the LLM passes) keeps the detector's exact offsets
|
||||
// rather than re-locating by string, which matters when the same word repeats.
|
||||
func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
// Confirm the document exists (and is the local user's) for clean 404s.
|
||||
var exists bool
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Findings []mechanicsFinding `json:"findings"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||
httputil.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
if len(body.Findings) > maxMechanicsFindings {
|
||||
body.Findings = body.Findings[:maxMechanicsFindings]
|
||||
}
|
||||
|
||||
if err := h.replaceMechanics(docID, body.Findings); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.fetchPending(docID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// replaceMechanics swaps the document's pending mechanics rows for the supplied
|
||||
// findings in one transaction, leaving the LLM families and actioned rows
|
||||
// untouched. Findings the user already accepted or dismissed are suppressed (the
|
||||
// detector has no memory between runs), and malformed spans are skipped.
|
||||
func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error {
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND type = ?`,
|
||||
docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actioned, err := actionedKeys(tx, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range findings {
|
||||
if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" {
|
||||
continue // malformed span — the client re-anchors by string anyway
|
||||
}
|
||||
if _, seen := actioned[suggestionKey(f.Original, f.Replacement)]; seen {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, db.SuggestionTypeMechanics,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
||||
// explicit-action pass; replaces only the pending voice flags.
|
||||
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -163,9 +274,11 @@ type pendingScope struct {
|
||||
|
||||
var (
|
||||
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
|
||||
// the explicit-action families (voice, collocation), which run on their own
|
||||
// cadence and must survive a grammar checkpoint.
|
||||
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation')", forceType: ""}
|
||||
// the other self-owned families (voice, collocation, mechanics), which run on
|
||||
// their own cadence/pass and must survive a grammar checkpoint. Notably the
|
||||
// deterministic mechanics pass writes its rows in the same /check request just
|
||||
// before this DELETE runs, so excluding it here is what keeps them alive.
|
||||
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation','mechanics')", forceType: ""}
|
||||
// voiceScope owns the voice flags only.
|
||||
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
||||
// collocationScope owns the collocation flags only.
|
||||
@@ -291,7 +404,51 @@ func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dedupeSpans(out), nil
|
||||
}
|
||||
|
||||
// dedupeSpans resolves collisions between the deterministic mechanics family and
|
||||
// the LLM families: when a mechanics finding and an LLM suggestion fight over the
|
||||
// same characters, mechanics wins and the LLM card is dropped. Its span is exact
|
||||
// (the detector matched it), whereas the LLM positions are only advisory
|
||||
// (re-anchored by string at render), so the precise fix should own the span.
|
||||
//
|
||||
// This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only,
|
||||
// no replacement) and collocation legitimately co-occupy the same span, and that
|
||||
// is intended. Suggestions that never anchored (from_pos < 0) occupy no real span
|
||||
// and are always kept.
|
||||
func dedupeSpans(in []db.Suggestion) []db.Suggestion {
|
||||
type span struct{ from, to int }
|
||||
var claimed []span
|
||||
for _, s := range in {
|
||||
if s.Type == db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||
claimed = append(claimed, span{s.FromPos, s.ToPos})
|
||||
}
|
||||
}
|
||||
if len(claimed) == 0 {
|
||||
return in
|
||||
}
|
||||
|
||||
out := make([]db.Suggestion, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||
overlaps := false
|
||||
for _, sp := range claimed {
|
||||
if s.FromPos < sp.to && sp.from < s.ToPos {
|
||||
overlaps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if overlaps {
|
||||
continue // an exact mechanics fix owns these characters
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accept marks a suggestion accepted (the client applies the replacement text).
|
||||
|
||||
@@ -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