One-gesture correction on /chat (V-628) #184

Merged
claude merged 7 commits from task/630-one-gesture-correction-on-chat-v-628 into master 2026-08-06 17:51:05 +02:00
3 changed files with 250 additions and 0 deletions
Showing only changes of commit e5ec4abe04 - Show all commits
+21
View File
@@ -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
+111
View File
@@ -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()
}
+118
View File
@@ -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))
}
}