Files
gogobee/internal/plugin/pete_dispatch_voice_test.go
prosolis 22b7949791 adventure: author Pete's dispatches in his voice with the local LLM
emitFact now runs the final fact through authorDispatch, which asks the local
Ollama model for a warm-reporter headline and lede and ships them on the Fact.
Pete guards and publishes them, falling back to its own templates on anything
it rejects — so authoring is best-effort by design: LLM off, a timeout, a
malformed generation, or an over-length pair all return an empty prose pair and
Pete templates the fact. The names allowed in the prose are the fact's Actors,
built from the post-opt-out subject/opponent, so what the model may say and what
Pete's guard permits are the same list. Synchronous like the holdem tip rewrite,
but on a tight 15s budget: news facts are infrequent and a template now beats a
voiced dispatch late. With no route for Pete to call back into this box, the
voice lives in the prompt here rather than in a Pete-owned inference endpoint.
2026-07-17 09:28:13 -07:00

82 lines
2.6 KiB
Go

package plugin
import (
"strings"
"testing"
"gogobee/internal/peteclient"
)
func TestParseDispatch(t *testing.T) {
cases := []struct {
name string
raw string
wantOK bool
wantHeadPre string
}{
{
name: "clean json",
raw: `{"headline": "Josie cleared the Ossuary.", "lede": "Alone, no less."}`,
wantOK: true,
wantHeadPre: "Josie cleared",
},
{
name: "wrapped in prose and fences",
raw: "Sure! Here you go:\n```json\n{\"headline\":\"A win.\",\"lede\":\"Nice one.\"}\n```",
wantOK: true,
wantHeadPre: "A win.",
},
{
name: "think block stripped",
raw: "<think>let me consider the tone</think>\n{\"headline\":\"Held the line.\",\"lede\":\"Proud of you all.\"}",
wantOK: true,
wantHeadPre: "Held the line.",
},
{name: "no json", raw: "I could not write that.", wantOK: false},
{name: "empty headline", raw: `{"headline":"","lede":"body"}`, wantOK: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h, l, ok := parseDispatch(c.raw)
if ok != c.wantOK {
t.Fatalf("ok = %v, want %v (h=%q l=%q)", ok, c.wantOK, h, l)
}
if ok && !strings.HasPrefix(h, c.wantHeadPre) {
t.Errorf("headline = %q, want prefix %q", h, c.wantHeadPre)
}
})
}
}
// TestBuildDispatchPrompt pins the two properties the prose-guard depends on:
// the allowed names are stated verbatim, and only the set facts appear (no empty
// labels for the model to fill in with invention).
func TestBuildDispatchPrompt(t *testing.T) {
f := peteclient.Fact{
EventType: "boss_kill", Subject: "Josie", Boss: "the Bone Warden",
Zone: "the Ossuary", Level: 14, Actors: []string{"Josie"},
}
p := buildDispatchPrompt(f)
if !strings.Contains(p, "ONLY these adventurer names, exactly as written: Josie") {
t.Errorf("prompt does not constrain names to Actors:\n%s", p)
}
if !strings.Contains(p, "the Bone Warden") || !strings.Contains(p, "the Ossuary") {
t.Error("prompt dropped a supplied fact")
}
// Unset fields must not appear as empty labels.
if strings.Contains(p, "region:") || strings.Contains(p, "milestone:") {
t.Errorf("prompt lists an unset fact:\n%s", p)
}
}
// TestBuildDispatchPromptRealmEvent: a realm-level fact with no named adventurer
// still produces a usable prompt that tells the model there is no name to use.
func TestBuildDispatchPromptRealmEvent(t *testing.T) {
f := peteclient.Fact{EventType: "siege_start", Boss: "the Horde", Stakes: "the whole town"}
p := buildDispatchPrompt(f)
if !strings.Contains(p, "no named adventurer") {
t.Errorf("realm event prompt missing the no-name note:\n%s", p)
}
}