diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 720b822..3381064 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -330,6 +330,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 claims TEXT NOT NULL DEFAULT '[]' ); CREATE INDEX IF NOT EXISTS idx_routing_traces_ts ON routing_traces (ts DESC);`, + // #24 — the corrected pairs (V-630). Separate from routing_traces on + // purpose, and this is the whole retention argument: a trace is a transcript + // and expires in 14 days, while a correction is a label the owner wrote by + // hand and is the only supervised signal the box will ever get. Promoting it + // out at the moment he writes it means the label survives the transcript + // that carried it. + // + // should_be may be empty. "That was wrong" with no target is a usable + // negative and must not cost more to give than the full answer would. + // + // UNIQUE(utterance) so correcting the same sentence twice replaces the + // label rather than stacking two. His second answer is the one he meant. + `CREATE TABLE IF NOT EXISTS routing_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + utterance TEXT NOT NULL UNIQUE, + was TEXT NOT NULL DEFAULT '', + should_be TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + encoder_id TEXT NOT NULL DEFAULT '' + );`, } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/routinglabels.go b/internal/store/routinglabels.go new file mode 100644 index 0000000..b21c584 --- /dev/null +++ b/internal/store/routinglabels.go @@ -0,0 +1,111 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// ErrNoSuchTrace — the trace the correction names is gone or never existed. +// Held apart from a write failure because it is the expected outcome of +// correcting a turn older than the 14-day bound, and the surface should say that +// rather than report a broken database. +var ErrNoSuchTrace = errors.New("no such routing trace") + +// RoutingLabel is one correction: what he said, what she made of it, and what it +// should have been. It is the only supervised signal in the box, so it outlives +// the trace it came from (V-630, docs/plans/22-correcting-a-turn.md). +type RoutingLabel struct { + ID int64 `json:"id"` + Ts time.Time `json:"ts"` + Utterance string `json:"utterance"` + // Was is the intent the cascade chose. Kept beside the target because the + // pair is what names the confusion, and a label with no "was" cannot say + // which boundary moved. + Was string `json:"was"` + // ShouldBe is the owner's target, and may be empty. "That was wrong, I am + // not going to tell you what it was" is a usable negative, and requiring the + // target would cost the cheap half of the gesture. + ShouldBe string `json:"should_be"` + Source string `json:"source"` + EncoderID string `json:"encoder_id"` +} + +// CorrectTurn records the owner's correction of one persisted turn. It promotes +// the pair into routing_labels and stamps the trace, both in one transaction: +// a stamped trace with no label would lose the signal when the trace expires, +// and a label with no stamp would let the same turn be corrected twice. +// +// shouldBe empty is allowed and means "wrong, target unstated". +func (s *Store) CorrectTurn(ctx context.Context, traceID int64, shouldBe string, now time.Time) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("correct turn: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var utterance, was, source, encoderID string + err = tx.QueryRowContext(ctx, ` + SELECT utterance, intent, source, encoder_id FROM routing_traces WHERE id = ?`, + traceID).Scan(&utterance, &was, &source, &encoderID) + if errors.Is(err, sql.ErrNoRows) { + return ErrNoSuchTrace + } + if err != nil { + return fmt.Errorf("correct turn: read trace: %w", err) + } + + shouldBe = strings.TrimSpace(shouldBe) + if _, err := tx.ExecContext(ctx, ` + INSERT INTO routing_labels (ts, utterance, was, should_be, source, encoder_id) + VALUES (?,?,?,?,?,?) + ON CONFLICT(utterance) DO UPDATE SET + ts = excluded.ts, was = excluded.was, should_be = excluded.should_be, + source = excluded.source, encoder_id = excluded.encoder_id`, + now.UnixMilli(), utterance, was, shouldBe, source, encoderID); err != nil { + return fmt.Errorf("correct turn: write label: %w", err) + } + // The stamp is what the trace itself carries: "corrected", or the target he + // gave. It expires with the trace, and that is fine — the label above is the + // durable half. + stamp := shouldBe + if stamp == "" { + stamp = "wrong" + } + if _, err := tx.ExecContext(ctx, + `UPDATE routing_traces SET correction = ? WHERE id = ?`, stamp, traceID); err != nil { + return fmt.Errorf("correct turn: stamp trace: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("correct turn: commit: %w", err) + } + return nil +} + +// RoutingLabels returns the newest n corrections, newest first. Nothing prunes +// them: 31 modes and 9 of them with no example at all is the problem this table +// exists to solve, and a label is a few dozen bytes. +func (s *Store) RoutingLabels(ctx context.Context, n int) ([]RoutingLabel, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, ts, utterance, was, should_be, source, encoder_id + FROM routing_labels ORDER BY id DESC LIMIT ?`, n) + if err != nil { + return nil, fmt.Errorf("routing labels: %w", err) + } + defer rows.Close() + var out []RoutingLabel + for rows.Next() { + var l RoutingLabel + var tsMilli int64 + if err := rows.Scan(&l.ID, &tsMilli, &l.Utterance, &l.Was, &l.ShouldBe, + &l.Source, &l.EncoderID); err != nil { + return nil, err + } + l.Ts = time.UnixMilli(tsMilli).UTC() + out = append(out, l) + } + return out, rows.Err() +} diff --git a/internal/store/routinglabels_test.go b/internal/store/routinglabels_test.go new file mode 100644 index 0000000..c858569 --- /dev/null +++ b/internal/store/routinglabels_test.go @@ -0,0 +1,118 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +func seedTrace(t *testing.T, s *Store, utterance, intent string, now time.Time) int64 { + t.Helper() + id, err := s.WriteRoutingTrace(context.Background(), RoutingTrace{ + Ts: now, Utterance: utterance, Intent: intent, Source: "tap:text", EncoderID: "e5-small", + }) + if err != nil { + t.Fatal(err) + } + return id +} + +// The label carries the pair, and it is what survives the transcript. +func TestCorrectTurnPromotesTheLabel(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + id := seedTrace(t, s, "поужинал", "query", now) + + if err := s.CorrectTurn(ctx, id, "fact", now); err != nil { + t.Fatal(err) + } + labels, err := s.RoutingLabels(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(labels) != 1 { + t.Fatalf("got %d labels, want 1", len(labels)) + } + l := labels[0] + if l.Utterance != "поужинал" || l.Was != "query" || l.ShouldBe != "fact" { + t.Errorf("label %+v: the pair is what names the confusion", l) + } + if l.EncoderID != "e5-small" { + t.Errorf("encoder_id %q: a fitted distance means nothing without the body", l.EncoderID) + } + // The trace is stamped too, so the same turn cannot be corrected twice into + // two labels without the surface knowing. + traces, err := s.RecentRoutingTraces(ctx, 10) + if err != nil { + t.Fatal(err) + } + if traces[0].Correction != "fact" { + t.Errorf("trace correction %q, want fact", traces[0].Correction) + } +} + +// "Wrong, and I am not telling you what it was" is the cheap half of the +// gesture, and it must not cost more than the full answer. +func TestCorrectTurnWithNoTarget(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + id := seedTrace(t, s, "закрывай", "act", now) + + if err := s.CorrectTurn(ctx, id, " ", now); err != nil { + t.Fatal(err) + } + labels, err := s.RoutingLabels(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(labels) != 1 || labels[0].ShouldBe != "" { + t.Fatalf("labels %+v: an untargeted negative is still a label", labels) + } + traces, _ := s.RecentRoutingTraces(ctx, 10) + if traces[0].Correction != "wrong" { + t.Errorf("trace correction %q, want wrong", traces[0].Correction) + } +} + +// His second answer is the one he meant, so a re-correction replaces. +func TestCorrectTurnTwiceReplaces(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + first := seedTrace(t, s, "поужинал", "query", now) + second := seedTrace(t, s, "поужинал", "chat", now.Add(time.Minute)) + + if err := s.CorrectTurn(ctx, first, "note", now); err != nil { + t.Fatal(err) + } + if err := s.CorrectTurn(ctx, second, "fact", now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + labels, err := s.RoutingLabels(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(labels) != 1 { + t.Fatalf("got %d labels for one sentence, want 1", len(labels)) + } + if labels[0].ShouldBe != "fact" || labels[0].Was != "chat" { + t.Errorf("label %+v, want the second correction", labels[0]) + } +} + +// A turn past the 14-day bound cannot be corrected, and the surface has to be +// able to say that rather than report a broken database. +func TestCorrectTurnUnknownTrace(t *testing.T) { + s := newTestStore(t) + err := s.CorrectTurn(context.Background(), 999, "fact", time.Now()) + if !errors.Is(err, ErrNoSuchTrace) { + t.Fatalf("err %v, want ErrNoSuchTrace", err) + } + labels, _ := s.RoutingLabels(context.Background(), 10) + if len(labels) != 0 { + t.Errorf("wrote %d labels for a trace that does not exist", len(labels)) + } +}