Finish Phase 22: the half of Petal that works with the tunnel down

Grammar lite, the false-friend list, the daily invitation and the offline
miscollocations — the four remaining §5–§6 items, all client-side and all
alive on a box that cannot reach the model.

The offline collocations forced a schema change. `type` had been doubling
as the answer to "which engine found this" — `mechanics` meant offline —
and that stops being true the moment an offline rule proposes a
collocation. Migration 0013 adds `source` (llm | local) and every pass now
scopes its DELETE by engine; without it the coach silently wiped every
offline chunk on the page. Existing rows backfill by type, so a pre-0013
collocation row is claimed as the coach's, which it was: the offline list
did not exist yet.

The rule pack is hand-curated rather than mined, and the entries left out
are the point — `married with` is wrong until "married with children",
`arrive to` wants at or in depending on the noun. A pack running on every
keystroke must not correct correct writing.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 15:05:55 -07:00
parent e9b8595456
commit 1bbc8fc8d3
20 changed files with 1678 additions and 50 deletions
+59 -28
View File
@@ -90,6 +90,22 @@ type mechanicsFinding struct {
Original string `json:"original"`
Replacement string `json:"replacement"`
Explanation string `json:"explanation"`
// Which family this offline finding belongs to. Empty (the historical shape)
// means mechanics; the miscollocation rules send 'collocation' so a chunk the
// rule pack caught is indistinguishable from one the coach caught — same
// family, same rail, and the same planting into the garden on accept.
Type string `json:"type"`
}
// localType maps a client-supplied family onto the two an offline rule may claim.
// Anything else — including the empty string older clients send — is mechanics,
// so a stray label can never smuggle a row into an LLM family and survive that
// pass's DELETE.
func localType(t string) string {
if strings.ToLower(strings.TrimSpace(t)) == db.SuggestionTypeCollocation {
return db.SuggestionTypeCollocation
}
return db.SuggestionTypeMechanics
}
// maxMechanicsFindings caps a single submission so a runaway client can't flood
@@ -144,10 +160,16 @@ func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
httputil.WriteJSON(w, http.StatusOK, out)
}
// replaceMechanics swaps the document's pending mechanics rows for the supplied
// replaceMechanics swaps the document's pending offline 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.
//
// The DELETE is scoped by *source*, not by type: the rule pack owns both the
// mechanics family and its share of the collocation family, and every run is a
// full recompute of the document, so everything it wrote last time goes. Scoping
// by type instead would strand offline collocations the current text no longer
// warrants — the one row nobody would ever replace.
func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error {
tx, err := h.DB.Begin()
if err != nil {
@@ -156,8 +178,8 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND type = ?`,
docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics,
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND source = ?`,
docID, db.SuggestionStatusPending, db.SuggestionSourceLocal,
); err != nil {
return err
}
@@ -175,9 +197,10 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er
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,
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
docID, f.From, f.To, f.Original, f.Replacement, f.Explanation,
localType(f.Type), db.SuggestionSourceLocal,
); err != nil {
return err
}
@@ -289,17 +312,22 @@ type pendingScope struct {
forceType string // if set, every inserted row gets this type; else normalizeType
}
// Every scope below is confined to source='llm'. The offline rule pack replaces
// its own rows wholesale on each edit (see replaceMechanics) and its findings
// must survive all three model passes — including the collocation coach, which
// now shares the collocation family with it.
var (
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
// 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.
collocationScope = pendingScope{deleteWhere: "type = 'collocation'", forceType: db.SuggestionTypeCollocation}
// the other self-owned families (voice, collocation), which run on their own
// cadence/pass and must survive a grammar checkpoint. Notably the offline pass
// writes its rows in the same /check request just before this DELETE runs, so
// the source clause is also what keeps them alive.
grammarScope = pendingScope{deleteWhere: "source = 'llm' AND type NOT IN ('voice','collocation')", forceType: ""}
// voiceScope owns the model's voice flags only.
voiceScope = pendingScope{deleteWhere: "source = 'llm' AND type = 'voice'", forceType: db.SuggestionTypeVoice}
// collocationScope owns the model's collocation flags only — the rule pack's
// share of the same family is left standing.
collocationScope = pendingScope{deleteWhere: "source = 'llm' AND type = 'collocation'", forceType: db.SuggestionTypeCollocation}
)
// replacePending swaps a document's pending suggestions within one family for a
@@ -341,9 +369,9 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
}
from, to := locate(contentText, s.Original)
if _, err := tx.Exec(
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
docID, from, to, s.Original, s.Replacement, s.Explanation, typ, db.SuggestionSourceLLM,
); err != nil {
return err
}
@@ -497,7 +525,7 @@ func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) {
rows, err := h.DB.Query(
`SELECT s.id, s.doc_id, s.from_pos, s.to_pos, s.original, s.replacement,
s.explanation, s.type, s.status, s.created_at
s.explanation, s.type, s.status, s.source, s.created_at
FROM suggestions s
JOIN documents d ON d.id = s.doc_id
WHERE s.doc_id = ? AND d.user_id = ? AND s.status = ?
@@ -514,7 +542,7 @@ func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) {
var s db.Suggestion
if err := rows.Scan(
&s.ID, &s.DocID, &s.FromPos, &s.ToPos, &s.Original, &s.Replacement,
&s.Explanation, &s.Type, &s.Status, &s.CreatedAt,
&s.Explanation, &s.Type, &s.Status, &s.Source, &s.CreatedAt,
); err != nil {
return nil, err
}
@@ -526,11 +554,14 @@ func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) {
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.
// dedupeSpans resolves collisions between the offline rule pack and the model:
// when a local finding and an LLM suggestion fight over the same characters, the
// local one 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 is why the split is by
// source rather than by type — an offline miscollocation is as exact as an
// offline comma, and the coach's fuzzy version of the same chunk shouldn't
// double up next to it.
//
// This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only,
// no replacement) and collocation legitimately co-occupy the same span, and that
@@ -540,7 +571,7 @@ 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 {
if s.Source == db.SuggestionSourceLocal && s.FromPos >= 0 {
claimed = append(claimed, span{s.FromPos, s.ToPos})
}
}
@@ -550,7 +581,7 @@ func dedupeSpans(in []db.Suggestion) []db.Suggestion {
out := make([]db.Suggestion, 0, len(in))
for _, s := range in {
if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 {
if s.Source != db.SuggestionSourceLocal && s.FromPos >= 0 {
overlaps := false
for _, sp := range claimed {
if s.FromPos < sp.to && sp.from < s.ToPos {
@@ -559,7 +590,7 @@ func dedupeSpans(in []db.Suggestion) []db.Suggestion {
}
}
if overlaps {
continue // an exact mechanics fix owns these characters
continue // an exact offline fix owns these characters
}
}
out = append(out, s)