e1f84a3474
Two defects found reviewing the PR. The insert ran on the turn's own context, so a caller that hung up or timed out cancelled it. That is exactly the turn worth having. It now runs detached, with a one-second bound of its own, because a write must not hold the reply. Retention was enforced on write alone, so a box that goes quiet for a month kept every row until the next sixty-fourth turn. pruneTracesOnStart closes that, and RoutingTraceRetention is exported so the daemon reads the same number the store enforces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
119 lines
4.7 KiB
Go
119 lines
4.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// RoutingTraceRetention is how long a raw trace lives (owner's call,
|
|
// 06-08-2026). A trace is read within a day or two of the turn that produced it,
|
|
// or never, so two weeks is diagnosis with room for a weekend. It is deliberately
|
|
// an age and not a row count: the useful question is "what did she do this week",
|
|
// and a busy Tuesday must not push last Friday out.
|
|
//
|
|
// A correction is not covered by this bound. The moment the owner corrects a
|
|
// turn, the pair is promoted out of the trace into a seed-shaped row and kept
|
|
// indefinitely, because a label is not a transcript. Keeping the transcript that
|
|
// carried it would defeat the point of the bound.
|
|
const RoutingTraceRetention = 14 * 24 * time.Hour
|
|
|
|
// RoutingTrace is one turn's arbitration, persisted. It is internal/decision's
|
|
// Record plus the four things the ring never had to carry: which reach the
|
|
// utterance arrived on, whether stage 0 answered before the classifier was
|
|
// consulted, which encoder body was live, and what the turn actually did.
|
|
type RoutingTrace struct {
|
|
ID int64 `json:"id"`
|
|
Ts time.Time `json:"ts"`
|
|
Utterance string `json:"utterance"`
|
|
Source string `json:"source"`
|
|
Winner string `json:"winner"`
|
|
Intent string `json:"intent"`
|
|
// ClaimedBeforeHead — stage 0 or a pre-route resolver answered, so the turn
|
|
// teaches nothing about the classifier. It is a large share of real traffic,
|
|
// and counting those turns as training signal would fit the head to the
|
|
// grammars rather than to him.
|
|
ClaimedBeforeHead bool `json:"claimed_before_head"`
|
|
// EncoderID names the encoder body that was live. A fitted distance means
|
|
// nothing under another body, and V-546 trains a copy of the weights.
|
|
EncoderID string `json:"encoder_id"`
|
|
// Outcome is what happened, not what was routed: a route that reached a gap
|
|
// and a route that ran are different turns.
|
|
Outcome string `json:"outcome"`
|
|
// Correction is the owner's label, empty until he gives one (V-630).
|
|
Correction string `json:"correction"`
|
|
// Claims is internal/decision's per-claimant detail, stored as JSON because
|
|
// nothing queries inside it: it is read whole, beside the turn it explains.
|
|
Claims json.RawMessage `json:"claims"`
|
|
}
|
|
|
|
// WriteRoutingTrace appends one turn and drops the ones past retention.
|
|
func (s *Store) WriteRoutingTrace(ctx context.Context, tr RoutingTrace) (int64, error) {
|
|
claims := "[]"
|
|
if len(tr.Claims) > 0 {
|
|
claims = string(tr.Claims)
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO routing_traces
|
|
(ts, utterance, source, winner, intent, claimed_before_head, encoder_id, outcome, correction, claims)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
|
tr.Ts.UnixMilli(), tr.Utterance, tr.Source, tr.Winner, tr.Intent,
|
|
tr.ClaimedBeforeHead, tr.EncoderID, tr.Outcome, tr.Correction, claims)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("write routing trace: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("last insert id: %w", err)
|
|
}
|
|
// Prune rarely. Turns arrive at human rate, so the bound is a ceiling and
|
|
// paying for a delete on every one of them buys nothing. 64 turns is hours.
|
|
if id%64 == 0 {
|
|
if err := s.PruneRoutingTraces(ctx, tr.Ts.Add(-RoutingTraceRetention)); err != nil {
|
|
return id, err
|
|
}
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// PruneRoutingTraces deletes every trace older than before. A corrected turn is
|
|
// deleted with the rest: the label was promoted out when the owner wrote it, so
|
|
// what is left here is the transcript, and the transcript is what expires.
|
|
func (s *Store) PruneRoutingTraces(ctx context.Context, before time.Time) error {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM routing_traces WHERE ts < ?`, before.UnixMilli()); err != nil {
|
|
return fmt.Errorf("prune routing traces: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RecentRoutingTraces returns the newest n turns, newest first.
|
|
func (s *Store) RecentRoutingTraces(ctx context.Context, n int) ([]RoutingTrace, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, ts, utterance, source, winner, intent, claimed_before_head,
|
|
encoder_id, outcome, correction, claims
|
|
FROM routing_traces
|
|
ORDER BY id DESC
|
|
LIMIT ?`, n)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recent routing traces: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []RoutingTrace
|
|
for rows.Next() {
|
|
var tr RoutingTrace
|
|
var tsMilli int64
|
|
var claims string
|
|
if err := rows.Scan(&tr.ID, &tsMilli, &tr.Utterance, &tr.Source, &tr.Winner,
|
|
&tr.Intent, &tr.ClaimedBeforeHead, &tr.EncoderID, &tr.Outcome,
|
|
&tr.Correction, &claims); err != nil {
|
|
return nil, err
|
|
}
|
|
tr.Ts = time.UnixMilli(tsMilli).UTC()
|
|
tr.Claims = json.RawMessage(claims)
|
|
out = append(out, tr)
|
|
}
|
|
return out, rows.Err()
|
|
}
|