Merge the scriptable chat path (#182)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user