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,
+5 -3
View File
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"name": "conversation_anaphora",
"description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor, then asks four questions that all say \"он\" and never name it again.\n\nThis scenario exists because the shape had nowhere to fail. The routing fixture scores one utterance at a time, so a conversation that breaks on its second turn cannot lose a point there, and V-44 step 2 could only be verified by hand. That is item 3 of V-542.\n\nFour of the five replies below are WRONG, and the assertions pin them anyway. Read them as the recorded defect rather than the contract: she has the last four turns in front of her and never once names the thing he is asking about. Every wrong assertion is marked in its step note with what it must become. When V-542 lands, those flip and the ones marked correct do not move.\n\nWhat the four assert is that the reply LACKS \"монитор\", and that shape is forced by the harness rather than chosen. The simulator wires phraser.NewStub(), and LLMPhraser.PhraseChat posts raw HTTP to /v1/chat/completions rather than going through the llm client the harness scripts, so no scenario can script a reply on the chat path. Both wrong replies here therefore come from internal/phraser/fallbacks_ru_v1.json, which PICKS between four variants — \"тут я пас.\" one run and \"не знаю, честно.\" the next so asserting a fallback string would pin the picker. Absence of the referent is the defect itself and holds whichever variant she reaches for. Giving the harness an httptest phraser is the prerequisite for asserting anything about what a conversation actually says; that is item 4 of V-542.\n\nThe routes are scripted exactly as the box produced them, because the failure is not the model's. Turn 1 went to fact despite \"давай поболтаем\", every question after it went to query, and turn 4 went to chat. A scripted route is what lets this scenario pin the daemon's half without a llama-server in the loop.",
"description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor, then asks four questions that all say \"он\" and never name it again.\n\nThis scenario exists because the shape had nowhere to fail. The routing fixture scores one utterance at a time, so a conversation that breaks on its second turn cannot lose a point there, and V-44 step 2 could only be verified by hand. That is item 3 of V-542.\n\nFour of the five replies below are WRONG, and the assertions pin them anyway. Read them as the recorded defect rather than the contract: she has the last four turns in front of her and never once names the thing he is asking about. Every wrong assertion is marked in its step note with what it must become. When V-542 lands, those flip and the ones marked correct do not move.\n\nWhat the four assert is that the reply LACKS \"монитор\". Absence is the defect itself: she is answering a question about a thing she wrote down two minutes ago and cannot name it. It also survives the fallback picker, which matters on the three query turns — they refuse from internal/phraser/fallbacks_ru_v1.json, four variants deep, and the same scenario returned \"тут я пас.\" one run and \"не знаю, честно.\" the next, so a string assertion there would pin the picker rather than the daemon.\n\nTurn 4 asserts its text as well, because that turn goes through the chat path and the chat path is now scriptable. scriptedPhraser in simulator_test.go answers PhraseChat from the same script entries the router reads (V-542 item 4); before it, the simulator wired phraser.NewStub() and no scenario could say what she SAYS on a chat turn at all.\n\nThe routes are scripted exactly as the box produced them, because the failure is not the model's. Turn 1 went to fact despite \"давай поболтаем\", every question after it went to query, and turn 4 went to chat. A scripted route is what lets this scenario pin the daemon's half without a llama-server in the loop.",
"start": "2026-08-05T14:00:00+03:00",
"script": [
{
@@ -19,7 +19,8 @@
},
{
"match": "переплатил",
"route": "[{\"intent\":\"chat\",\"text\":\"мне кажется я переплатил\"}]"
"route": "[{\"intent\":\"chat\",\"text\":\"мне кажется я переплатил\"}]",
"reply": "{\"response\":\"я не знаю, о каком именно устройстве ты говоришь.\",\"mood\":\"neutral\"}"
},
{
"match": "стоит его вернуть",
@@ -55,8 +56,9 @@
},
{
"at": "14:03",
"note": "WRONG, and it is the same wall from the other side. This turn routed chat, so it HAD the history that Session.History holds, and it still cannot say what the device is — because turn 1's object went to the fact store rather than the transcript. So a source reading the conversation is not sufficient on its own; decision 1 has to say which store the referent comes from.",
"note": "WRONG, and it is the same wall from the other side. This turn routed chat, so it HAD the history that Session.History holds, and it asks which device he means anyway — because turn 1's object went to the fact store rather than the transcript. So a source reading the conversation is not sufficient on its own; decision 1 has to say which store the referent comes from. This is the one step whose text is pinned: the reply is scripted and reaches PhraseChat, so it is the box's own words rather than a fallback pick. Must become: a reply that names the monitor.",
"say": "мне кажется я переплатил",
"expect_reply_contains": ["о каком именно устройстве"],
"expect_reply_lacks": ["монитор"],
"expect_no_send": true
},