35018226ef
Nine reply cases and a fourth column in the talk report. The reply path is a separate object from the phraser in the daemon, so Pair joins a Talker and a Confirmer for a run that covers everything Maven says. Cases carry intent/key/value because the replier is phrased from the decision the router resolved, not from the raw utterance. Three of them are baits the other paths cannot produce: a masculine verb about himself that she must not copy onto herself, a polite plural input that must still come back на ты, and an unresolved note that invites a question a confirmation is not allowed to ask. Not scored against a model here — this box has no llama-server, and the baseline test is opt-in on MAVEN_LLM_URL.
309 lines
9.8 KiB
Go
309 lines
9.8 KiB
Go
package eval
|
|
|
|
// This file scores the CONVERSATIONAL paths, the ones the nudge fixture never
|
|
// touches: chat, query-with-notes, and general knowledge. All three now carry
|
|
// the shared persona block (internal/persona), and all three produce long
|
|
// free-form Russian — which is exactly where a persona break (formality, third
|
|
// person, masculine self-reference) is most likely and where, until this file,
|
|
// nothing could see one.
|
|
//
|
|
// Why a second fixture instead of more nudge cases: the checks differ. A nudge
|
|
// must be one short sentence with no question in it; a chat reply is allowed
|
|
// 1-3 sentences and a follow-up question is a FEATURE there. Mixing them would
|
|
// need per-case check masks, and the nudge scorer stays untouched this way.
|
|
//
|
|
// Why per-path reporting: a chat regression and a knowledge regression have
|
|
// different causes (chat prompt vs router.KnowledgePrompt), and one blended
|
|
// percentage cannot tell them apart.
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/dialogue"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
//go:embed talk_v1.json
|
|
var talkFixtureJSON []byte
|
|
|
|
// The phrasing paths under test. Values match the fixture's "path" field.
|
|
const (
|
|
PathChat = "chat" // PhraseChat
|
|
PathQuery = "query" // PhraseQuery with notes
|
|
PathKnowledge = "knowledge" // PhraseQuery with no notes
|
|
PathReply = "reply" // PhraseReply, the reactive confirmation
|
|
)
|
|
|
|
// TalkPaths — report order.
|
|
var TalkPaths = []string{PathChat, PathQuery, PathKnowledge, PathReply}
|
|
|
|
// TalkCheckNames — the checks that apply to a free-form reply, in report order.
|
|
// Deliberately a subset of CheckNames: length, mood and "no questions" are nudge
|
|
// properties and would fail a correct chat reply. These paths return no mood at
|
|
// all, so there is nothing to check there.
|
|
var TalkCheckNames = []string{
|
|
CheckNonEmpty, CheckEllipsis, CheckLang, CheckFeminine, CheckAddress, CheckOnTopic,
|
|
}
|
|
|
|
// TalkCase — one turn as the daemon would present it.
|
|
//
|
|
// History is flat text because that is all PhraseChat uses (it concatenates
|
|
// turn texts into one user message); intents and slots would be dead fields.
|
|
// Notes are what the store would have matched for a query.
|
|
//
|
|
// WantAny is the on-topic contract: at least one lowercased fragment must appear
|
|
// in the reply. Fragments are stems ("пароль" → "парол") so declension does not
|
|
// defeat them.
|
|
//
|
|
// Intent, Key and Value carry the reply path's decision: that path is phrased
|
|
// from what the router already resolved, not from the raw utterance. Utterance
|
|
// stays filled anyway, because it is what a human reads in the report.
|
|
type TalkCase struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
Utterance string `json:"utterance"`
|
|
History []string `json:"history,omitempty"`
|
|
Notes []string `json:"notes,omitempty"`
|
|
Intent string `json:"intent,omitempty"`
|
|
Key string `json:"key,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
WantAny []string `json:"want_any"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// TalkFixture — the versioned envelope, same gating as Fixture.
|
|
type TalkFixture struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Name string `json:"name"`
|
|
Notes []string `json:"notes"`
|
|
Cases []TalkCase `json:"cases"`
|
|
}
|
|
|
|
// LoadTalk returns the embedded conversational fixture.
|
|
func LoadTalk() (TalkFixture, error) {
|
|
var f TalkFixture
|
|
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
|
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
|
}
|
|
if f.SchemaVersion != SchemaVersion {
|
|
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
|
}
|
|
if len(f.Cases) == 0 {
|
|
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// Talker — the methods a conversational path must have to be scorable.
|
|
// *phraser.LLMPhraser satisfies the first two; *phraser.Replier satisfies the
|
|
// third, so a run that scores all four paths passes a Pair.
|
|
type Talker interface {
|
|
PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
|
|
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
|
|
}
|
|
|
|
// Confirmer — the reply path. *phraser.Replier satisfies it.
|
|
type Confirmer interface {
|
|
PhraseReply(ctx context.Context, d router.Decision) (string, error)
|
|
}
|
|
|
|
// Pair joins the two objects the daemon wires separately — the phraser and the
|
|
// replier — so one ScoreTalk call covers every path Maven speaks through. A bare
|
|
// Talker still works; its reply cases score as errors, which is honest.
|
|
type Pair struct {
|
|
Talker
|
|
Confirmer
|
|
}
|
|
|
|
// TalkOutcome — one scored case.
|
|
type TalkOutcome struct {
|
|
Case TalkCase
|
|
Reply string
|
|
Err error
|
|
Latency time.Duration
|
|
Pass bool
|
|
Failed []string
|
|
Reasons []string
|
|
}
|
|
|
|
// TalkReport — the aggregate. ByPath is the point of this scorer.
|
|
type TalkReport struct {
|
|
Name string
|
|
Total int
|
|
Passed int
|
|
Errors int
|
|
ByCheck map[string]int
|
|
ByPath map[string]TagStat
|
|
Outcomes []TalkOutcome
|
|
P50 time.Duration
|
|
P95 time.Duration
|
|
Max time.Duration
|
|
}
|
|
|
|
// Accuracy — fraction of cases that passed every check.
|
|
func (r TalkReport) Accuracy() float64 {
|
|
if r.Total == 0 {
|
|
return 0
|
|
}
|
|
return float64(r.Passed) / float64(r.Total)
|
|
}
|
|
|
|
// ScoreTalk runs every case through t and aggregates. A phrasing error scores as
|
|
// a miss and is counted separately: "the model was down" and "the model wrote
|
|
// something bad" must not be the same number.
|
|
func ScoreTalk(ctx context.Context, name string, t Talker, f TalkFixture) (TalkReport, error) {
|
|
rep := TalkReport{
|
|
Name: name,
|
|
Total: len(f.Cases),
|
|
ByCheck: map[string]int{},
|
|
ByPath: map[string]TagStat{},
|
|
}
|
|
for _, n := range TalkCheckNames {
|
|
rep.ByCheck[n] = 0
|
|
}
|
|
lat := make([]time.Duration, 0, len(f.Cases))
|
|
|
|
for _, c := range f.Cases {
|
|
start := time.Now()
|
|
reply, err := c.run(ctx, t)
|
|
o := TalkOutcome{Case: c, Reply: reply, Err: err, Latency: time.Since(start)}
|
|
lat = append(lat, o.Latency)
|
|
|
|
if err != nil {
|
|
rep.Errors++
|
|
o.Failed = append(o.Failed, "call")
|
|
o.Reasons = append(o.Reasons, fmt.Sprintf("phrase error: %v", err))
|
|
} else {
|
|
for _, res := range RunTalkChecks(c, reply) {
|
|
if res.Pass {
|
|
rep.ByCheck[res.Name]++
|
|
continue
|
|
}
|
|
o.Failed = append(o.Failed, res.Name)
|
|
o.Reasons = append(o.Reasons, res.Name+": "+res.Detail)
|
|
}
|
|
}
|
|
|
|
o.Pass = len(o.Failed) == 0
|
|
if o.Pass {
|
|
rep.Passed++
|
|
}
|
|
bump(rep.ByPath, c.Path, o.Pass)
|
|
rep.Outcomes = append(rep.Outcomes, o)
|
|
}
|
|
|
|
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
|
|
rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95)
|
|
if len(lat) > 0 {
|
|
rep.Max = lat[len(lat)-1]
|
|
}
|
|
return rep, nil
|
|
}
|
|
|
|
// run dispatches the case to its path. knowledge and query are the same method;
|
|
// the empty notes slice is what selects the no-notes branch inside PhraseQuery.
|
|
func (c TalkCase) run(ctx context.Context, t Talker) (string, error) {
|
|
switch c.Path {
|
|
case PathChat:
|
|
return t.PhraseChat(ctx, c.Utterance, c.turns())
|
|
case PathQuery:
|
|
return t.PhraseQuery(ctx, c.Utterance, c.Notes)
|
|
case PathKnowledge:
|
|
return t.PhraseQuery(ctx, c.Utterance, nil)
|
|
case PathReply:
|
|
conf, ok := t.(Confirmer)
|
|
if !ok {
|
|
return "", fmt.Errorf("target cannot phrase replies — pass a Pair")
|
|
}
|
|
return conf.PhraseReply(ctx, c.decision())
|
|
}
|
|
return "", fmt.Errorf("unknown path %q", c.Path)
|
|
}
|
|
|
|
// decision rebuilds what the router would have handed the replier. Text is the
|
|
// utterance for a note or a reminder, which is what the router puts there.
|
|
func (c TalkCase) decision() router.Decision {
|
|
return router.Decision{
|
|
Intent: router.Intent(c.Intent),
|
|
Slots: router.Slots{
|
|
Key: c.Key,
|
|
Value: c.Value,
|
|
Text: c.Utterance,
|
|
HasKey: c.Key != "",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c TalkCase) turns() []dialogue.Turn {
|
|
turns := make([]dialogue.Turn, 0, len(c.History))
|
|
for _, h := range c.History {
|
|
turns = append(turns, dialogue.Turn{Text: h})
|
|
}
|
|
return turns
|
|
}
|
|
|
|
// RunTalkChecks scores one reply. Order matches TalkCheckNames.
|
|
func RunTalkChecks(c TalkCase, reply string) []Result {
|
|
return []Result{
|
|
checkNonEmpty(reply),
|
|
checkEllipsis(reply),
|
|
checkLang(reply),
|
|
checkFeminine(reply),
|
|
checkAddress(reply),
|
|
checkOnTopicAny(c.WantAny, reply),
|
|
}
|
|
}
|
|
|
|
// String renders the comparison table — composite, then per-check so a
|
|
// regression names the property, then per-path so it names the prompt.
|
|
func (r TalkReport) String() string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s: %d/%d cases pass every check (%.1f%%), %d errors\n",
|
|
r.Name, r.Passed, r.Total, 100*r.Accuracy(), r.Errors)
|
|
for _, name := range TalkCheckNames {
|
|
fmt.Fprintf(&b, " %-10s %d/%d\n", name, r.ByCheck[name], r.Total)
|
|
}
|
|
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
|
|
fmt.Fprintf(&b, " by path: %s\n", renderStats(r.ByPath))
|
|
return b.String()
|
|
}
|
|
|
|
// Failures — per-case detail, sorted by ID so two runs diff cleanly.
|
|
func (r TalkReport) Failures() string {
|
|
var b strings.Builder
|
|
for _, o := range r.sorted() {
|
|
if o.Pass {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " %s %q\n %s\n", o.Case.ID, o.Reply, strings.Join(o.Reasons, "; "))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Replies — every generated reply verbatim. This is what a human reads to judge
|
|
// tone; the score only says which checks fired.
|
|
func (r TalkReport) Replies() string {
|
|
var b strings.Builder
|
|
for _, o := range r.sorted() {
|
|
mark := "ok "
|
|
if !o.Pass {
|
|
mark = "FAIL"
|
|
}
|
|
fmt.Fprintf(&b, " %s %-9s %-22s %q\n", mark, o.Case.Path, o.Case.ID, o.Reply)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (r TalkReport) sorted() []TalkOutcome {
|
|
out := append([]TalkOutcome(nil), r.Outcomes...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
|
|
return out
|
|
}
|