the simulator can script a chat reply (V-542)

Item 4. actionChat calls h.phraser.PhraseChat, and LLMPhraser posts raw
HTTP to /v1/chat/completions rather than going through the llm client
scriptedLLM stands in for. The simulator wired phraser.NewStub() anyway,
so no scenario could assert what she says on a chat turn: every reply came
back as a pick from fallbacks_ru_v1.json, four variants deep, and the same
scenario returned "тут я пас." one run and "не знаю, честно." the next.

scriptedPhraser embeds the Stub and overrides PhraseChat only, reading the
same script entries the router reads. A reply is accepted in either shape
the phrasing contract allows, the {"response","mood"} object or plain text,
so a scenario writes one thing for both paths.

An unscripted chat turn returns an error rather than a fallback, matching
scriptedLLM: actionChat logs it and uses ChatFallback(), so scenarios that
never meant to assert a chat reply behave as before.

conversation_anaphora turn 4 now pins its text — the reply that asks which
device he means, which is the recorded defect in the box's own words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:13:39 +04:00
parent ee4c26f13c
commit 8d2c1b6f99
2 changed files with 55 additions and 4 deletions
+50 -1
View File
@@ -347,6 +347,55 @@ func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
}
// scriptedPhraser answers the chat path from the same script the router reads.
//
// It exists because actionChat calls h.phraser.PhraseChat, and the production
// implementation posts raw HTTP to /v1/chat/completions rather than going
// through the llm client scriptedLLM stands in for. So until this, no scenario
// could script what she SAYS on a chat turn: the simulator wired phraser.NewStub()
// and every chat reply came back as a pick from fallbacks_ru_v1.json, four
// variants deep, which varied between two runs of one scenario (V-542 item 4).
//
// Everything except PhraseChat is the Stub's, by embedding. A nudge and a
// reminder are phrased by the tick loop, which has its own phraser and its own
// assertions; this seam is only about the conversation.
type scriptedPhraser struct {
*phraser.Stub
entries []scriptEntry
}
// PhraseChat returns the scripted reply for the utterance, or an error when the
// scenario scripted none. The error rather than a fallback is deliberate and
// matches scriptedLLM: actionChat logs it and falls back to ChatFallback(), so a
// scenario that never meant to assert on a chat reply behaves exactly as it did
// before, and one that DID means to is told its script has a hole.
func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, _ []dialogue.Turn) (string, error) {
for _, e := range p.entries {
if e.Reply == "" {
continue
}
if e.Match != "" && !strings.Contains(strings.ToLower(utterance), strings.ToLower(e.Match)) {
continue
}
return chatReplyText(e.Reply), nil
}
return "", fmt.Errorf("simulator: no scripted chat reply for %q", truncateRunes(utterance, 60))
}
// chatReplyText reads a scripted reply in either shape the phrasing contract
// allows: the {"response","mood"} object the model emits, or plain text.
// LLMPhraser does this parse itself, so a scenario writes one thing and both
// paths understand it.
func chatReplyText(reply string) string {
var out struct {
Response string `json:"response"`
}
if err := json.Unmarshal([]byte(reply), &out); err == nil && out.Response != "" {
return out.Response
}
return reply
}
// ---------------------------------------------------------------------------
// Building the world
// ---------------------------------------------------------------------------
@@ -440,7 +489,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
api: api,
matcher: matcher,
tools: tool.NewExecutor(api, 5*time.Second),
phraser: phraser.NewStub(),
phraser: &scriptedPhraser{Stub: phraser.NewStub(), entries: sc.Script},
replier: newLLMReplier(scripted, nil),
now: clock.Now,
dataStore: st,