Files
Maven/internal/store/routinglabels.go
claude e5ec4abe04 a corrected turn is promoted to a label that outlives the trace (V-630)
Migration #24 adds routing_labels, and CorrectTurn writes it. Nothing calls it
yet; the wire and the surface are the next commits.

Separate table, and that is the whole retention argument. A trace is a
transcript and expires in 14 days. A correction is a label the owner wrote by
hand, and it is the only supervised signal this box will ever get, so it is
promoted out at the moment he writes it and kept.

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. UNIQUE(utterance) so a second
correction replaces the first, because his second answer is the one he meant.
The label and the trace stamp go in one transaction: a stamp with no label loses
the signal when the trace expires.

ErrNoSuchTrace is held apart from a write failure. Correcting a turn older than
the bound is the expected case, and the surface should say so rather than report
a broken database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:30:46 +04:00

112 lines
4.1 KiB
Go

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()
}