One-gesture correction on /chat (V-628) #184
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user