Phase 28's step (b): both remaining items are about direction, and both had
a wrong answer that looked right.
isTranslation could not simply be read backwards. readsAsEnglish is a
deliberately low bar — Latin letters, not swamped by another script — which
every Portuguese sentence clears as easily as English does, so swapping its
two halves would have called every genuine Portuguese correction inside a
Portuguese document a translation. The flipped direction uses sentenceLang
from doclang.go instead, where English has its own curated marker list and
has to out-evidence the pair language to win. The English-document path is
untouched; reconcilePending carries the verdict to ask the question the
right way round.
The tap-through's whole observable change is a model call that stops
happening. /suggestions/{id}/translate now recovers the explanation's
language by re-running targetFor rather than assuming the pair, which gives
today's answer everywhere except the case that was broken: the Portuguese
writer whose explanation already arrived in Portuguese, previously
round-tripped through the model into Portuguese again. It answers "" there,
and the client's existing `res.translation.trim() || explanation` fallback
seeds the bubble with the explanation itself — no frontend change at all.
It deliberately does not render that explanation into English on the
grounds that English is technically the other half: an unasked-for
rendering into the language she is practising is noise, not a seed.
Tests pin both directions of the detector, with Portuguese-in-Portuguese as
the case the file exists for, plus four handler tests through the real
/check and /translate paths — including the skipped seed asserting the
model was never called, and the learning_pair zh learner whose English
explanation still renders into Chinese.
Left of the phase: (c) the garden's language tagging and read-aloud.
Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
306 lines
9.8 KiB
Go
306 lines
9.8 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 and `docLang` this document's language
|
|
// verdict; between them they type a finding that turns out to be one language
|
|
// rendered into the other, in whichever direction this document makes useful
|
|
// (see language.go).
|
|
func (h *Handler) reconcilePending(
|
|
docID, contentText, pairLang, docLang 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, docLang) {
|
|
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()
|
|
}
|