She reaches for her own language mid-sentence when English won't come, and Petal already handled it: it found the span and rendered it into English. It just filed the result as a Clarity fix, so the pair model's flagship moment read as tidying up her Chinese. The type is now derived from the span rather than asked of the model. A type is structural, and a model that re-reasons every pass would drift between labels for a sentence nobody had touched — the instability the last session spent itself removing. The label the model volunteers is still ignored. Only the grammar checkpoint can be promoted. A pass with a forced type owns its family: voice reads paragraphs for tone and its rows carry no replacement, so a "translation" there would be a card offering nothing to accept. zh is a different script and counting Han runes is close to certain. The Latin pairs share an alphabet with English and get none of that, so they fall back to function words and need two before Petal claims anything — with every word that is also English left out, even the common ones. The heuristic is justified by how cheap being wrong is: it changes a coloured pill, and nothing else. The pill is the one bilingual type name in the rail. Every other type stays English because those are the terms she is learning; this card's whole subject is her own language. And it stops truncating its two lines — elsewhere the diff is a word and the explanation is what she reads, but here the two sentences are the card. Two things only the running page could report. The inline underline was invisible: the decoration carries a per-type class and the base rule is a transparent border, so a type with no colour rule gets no mark at all. And at 1517×810 with the document list open there is no rail — the margin is 258 where railEnabled wants 348 — so what she gets is the inline hover card. Item 7 is written the other way round. Migration 0015 rebuilds the suggestions table for the CHECK, which makes it the first one here that could quietly drop her rows; there is a test that carries every column, both timestamps and both indexes across it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
304 lines
9.7 KiB
Go
304 lines
9.7 KiB
Go
package suggestions
|
|
|
|
import (
|
|
"database/sql"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
|
)
|
|
|
|
// Reconciliation replaces the old "delete the family, insert the new batch"
|
|
// shape of every pass. A suggestion the pass proposes again is the *same*
|
|
// suggestion: it keeps its row, and therefore its id, its created_at and — most
|
|
// visibly — the explanation it was first given. The model re-words its reasoning
|
|
// every time it is asked, so re-inserting meant one unchanged mistake carried
|
|
// three different explanations in a single sitting.
|
|
//
|
|
// The id is what the frontend keys its cards on, so a stable id is also what
|
|
// keeps the rail from emptying and refilling, a card from collapsing mid-read,
|
|
// and the arrival chime from re-firing for advice she has already seen.
|
|
|
|
// pendingRow is the part of an existing pending suggestion reconciliation cares
|
|
// about.
|
|
type pendingRow struct {
|
|
id string
|
|
original string
|
|
replacement string
|
|
chunkHash string
|
|
from int
|
|
}
|
|
|
|
// loadPending reads the pending rows a pass owns. `where` is the pass's own
|
|
// scoping clause (by source, and for the model passes by family) — the same
|
|
// fragment that used to scope its DELETE.
|
|
func loadPending(tx *sql.Tx, docID, where string) ([]pendingRow, error) {
|
|
rows, err := tx.Query(
|
|
`SELECT id, original, replacement, chunk_hash, from_pos FROM suggestions
|
|
WHERE doc_id = ? AND status = ? AND `+where,
|
|
docID, db.SuggestionStatusPending,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []pendingRow
|
|
for rows.Next() {
|
|
var r pendingRow
|
|
if err := rows.Scan(&r.id, &r.original, &r.replacement, &r.chunkHash, &r.from); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// editKey identifies an edit by what it proposes, not where: "this exact change
|
|
// to this exact text". Normalized like the suppression comparisons, so the
|
|
// editor's quote rewriting and a reflowed paragraph don't read as a new edit.
|
|
func editKey(original, replacement string) string {
|
|
return normalizeForDedup(original) + "\x00" + normalizeForDedup(replacement)
|
|
}
|
|
|
|
// editIndex matches freshly proposed edits against the rows already standing.
|
|
type editIndex struct {
|
|
rows []pendingRow
|
|
used []bool
|
|
byKey map[string][]int
|
|
}
|
|
|
|
func indexByEdit(rows []pendingRow) *editIndex {
|
|
idx := &editIndex{rows: rows, used: make([]bool, len(rows)), byKey: map[string][]int{}}
|
|
for i, r := range rows {
|
|
k := editKey(r.original, r.replacement)
|
|
idx.byKey[k] = append(idx.byKey[k], i)
|
|
}
|
|
return idx
|
|
}
|
|
|
|
// take claims the standing row for this edit, if there is one. When a document
|
|
// repeats the same mistake, `near` (the fresh span's start) picks the closest
|
|
// standing row, so two identical cards keep their own identities instead of
|
|
// trading them whenever the text between them grows.
|
|
func (i *editIndex) take(original, replacement string, near int) (pendingRow, bool) {
|
|
best, bestDist := -1, 0
|
|
for _, n := range i.byKey[editKey(original, replacement)] {
|
|
if i.used[n] {
|
|
continue
|
|
}
|
|
d := i.rows[n].from - near
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
if best < 0 || d < bestDist {
|
|
best, bestDist = n, d
|
|
}
|
|
}
|
|
if best < 0 {
|
|
return pendingRow{}, false
|
|
}
|
|
i.used[best] = true
|
|
return i.rows[best], true
|
|
}
|
|
|
|
// reposition updates the advisory offsets (and the sentence a row belongs to)
|
|
// without touching anything the writer can see. The frontend re-anchors by
|
|
// string at render time, so these only matter for the local-vs-model span
|
|
// arbitration in dedupeSpans.
|
|
func reposition(tx *sql.Tx, row pendingRow, from, to int, chunkHash string) error {
|
|
if row.from == from && row.chunkHash == chunkHash {
|
|
return nil
|
|
}
|
|
_, err := tx.Exec(
|
|
`UPDATE suggestions SET from_pos = ?, to_pos = ?, chunk_hash = ? WHERE id = ?`,
|
|
from, to, chunkHash, row.id,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// reconcilePending brings a model pass's family in line with what it just
|
|
// proposed, sentence by sentence:
|
|
//
|
|
// - A row on a sentence this pass didn't ask about is kept untouched — that
|
|
// is the whole point of chunking. Only its offsets are refreshed.
|
|
// - A row on a sentence that no longer exists in the document is dropped: she
|
|
// rewrote or deleted it.
|
|
// - A row on a sentence the pass *did* ask about survives only if the model
|
|
// proposed the same edit again, in which case it keeps its identity.
|
|
//
|
|
// `fresh` names the sentences the model was asked about (nil when it wasn't
|
|
// called at all). inPlayAll marks the whole-document passes — voice and the
|
|
// collocation coach — where every row is up for re-proposal because the model
|
|
// just re-read everything.
|
|
//
|
|
// `pairLang` is the writer's own language, needed only to type a finding that
|
|
// turns out to be her language rendered into English (see language.go).
|
|
func (h *Handler) reconcilePending(
|
|
docID, contentText, pairLang string,
|
|
raw []llm.RawSuggestion,
|
|
scope pendingScope,
|
|
chunks, fresh []chunk,
|
|
inPlayAll bool,
|
|
) error {
|
|
tx, err := h.DB.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
existing, err := loadPending(tx, docID, scope.deleteWhere)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
present := hashSet(chunks)
|
|
asked := hashSet(fresh)
|
|
modelRan := inPlayAll || fresh != nil
|
|
|
|
// Sentences to hand back to the model next time, because a row we were
|
|
// caching on them turned out to be unanchorable (see below).
|
|
reopen := map[string]bool{}
|
|
|
|
var inPlay []pendingRow
|
|
for _, r := range existing {
|
|
switch {
|
|
// A row whose sentence we can't name is never cached — it is re-examined
|
|
// whenever the model speaks, and left alone when it doesn't.
|
|
case inPlayAll, r.chunkHash == "" && modelRan, asked[r.chunkHash]:
|
|
inPlay = append(inPlay, r)
|
|
case r.chunkHash != "" && !present[r.chunkHash]:
|
|
if _, err := tx.Exec(`DELETE FROM suggestions WHERE id = ?`, r.id); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
// Untouched sentence: keep the card exactly as she last saw it.
|
|
from, to := locate(contentText, r.original)
|
|
if from < 0 {
|
|
// The sentence is unchanged in substance but the quoted span no
|
|
// longer matches byte for byte — a quote mark the editor rewrote
|
|
// inside it, say. The frontend anchors by that string, so this card
|
|
// can't be shown; drop it and let the sentence be read again rather
|
|
// than cache advice nobody can see.
|
|
reopen[r.chunkHash] = true
|
|
if _, err := tx.Exec(`DELETE FROM suggestions WHERE id = ?`, r.id); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := reposition(tx, r, from, to, r.chunkHash); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for h := range reopen {
|
|
delete(present, h)
|
|
}
|
|
|
|
sup, err := buildSuppressor(tx, docID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
index := indexByEdit(inPlay)
|
|
kept := make(map[string]bool, len(inPlay))
|
|
for _, s := range raw {
|
|
if sup.suppressed(s.Original, s.Replacement) {
|
|
continue
|
|
}
|
|
from, to := locate(contentText, s.Original)
|
|
// Attribute the finding to a sentence the model was actually shown before
|
|
// falling back to the whole document: a short span ("the the") can occur in
|
|
// two sentences, and crediting it to the cached one would drop it as advice
|
|
// we already have.
|
|
hash := chunkFor(s.Original, fresh)
|
|
if hash == "" {
|
|
hash = chunkFor(s.Original, chunks)
|
|
}
|
|
// A sentence we didn't ask about already has whatever advice it deserves.
|
|
// The model can't normally quote one — it was only shown the delta — but if
|
|
// it wanders there anyway, the cached card stands rather than gaining a
|
|
// twin.
|
|
if !inPlayAll && hash != "" && present[hash] && !asked[hash] {
|
|
continue
|
|
}
|
|
if row, ok := index.take(s.Original, s.Replacement, from); ok {
|
|
kept[row.id] = true
|
|
if err := reposition(tx, row, from, to, hash); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
// A pass with a forced type owns its family outright and is never asked
|
|
// about translation: voice reads whole paragraphs for tone, and the
|
|
// collocation coach is about English word pairings. Only the open-typed
|
|
// grammar checkpoint can turn out to have been handed her own language.
|
|
typ := scope.forceType
|
|
if typ == "" {
|
|
typ = normalizeType(s.Type)
|
|
if isTranslation(s.Original, s.Replacement, pairLang) {
|
|
typ = db.SuggestionTypeTranslate
|
|
}
|
|
}
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source, chunk_hash)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
docID, from, to, s.Original, s.Replacement, s.Explanation, typ, db.SuggestionSourceLLM, hash,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Asked about and not proposed again: the model has changed its mind, or she
|
|
// has fixed it.
|
|
for _, r := range inPlay {
|
|
if kept[r.id] {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM suggestions WHERE id = ?`, r.id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Record the sentences this family has now read. Every sentence still in the
|
|
// document has been read by *some* pass: the ones just asked about now, the
|
|
// rest in an earlier round.
|
|
if scope.chunked {
|
|
if _, err := tx.Exec(
|
|
`DELETE FROM checked_chunks WHERE doc_id = ? AND family = ?`, docID, scope.family,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
for h := range present {
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO checked_chunks (doc_id, family, hash) VALUES (?, ?, ?)`,
|
|
docID, scope.family, h,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// checkedChunks loads the sentences a family read on its last pass.
|
|
func (h *Handler) checkedChunks(docID, family string) (map[string]bool, error) {
|
|
rows, err := h.DB.Query(
|
|
`SELECT hash FROM checked_chunks WHERE doc_id = ? AND family = ?`, docID, family,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string]bool{}
|
|
for rows.Next() {
|
|
var hash string
|
|
if err := rows.Scan(&hash); err != nil {
|
|
return nil, err
|
|
}
|
|
out[hash] = true
|
|
}
|
|
return out, rows.Err()
|
|
}
|