adventure: publish gogobee's LLM prose, guarded, template as the net
gogobee's LLM now authors a dispatch's headline and lede; Pete prefers them over its template render when both are present and pass a prose-level guard, and falls back to the template otherwise. factGuard only ever checked the structured Subject/Opponent fields, which was the whole safety story while a Pete template was the renderer — a template prints nothing Pete did not interpolate. LLM prose breaks that: the guard would be validating fields that are no longer what's rendered. proseGuard checks the rendered text itself, rejecting a known adventurer named outside the fact's Actors (a live way to put words in a real player's mouth) and anything past the length caps. The templates stop being the renderer and become the safety net; they are not deleted.
This commit is contained in:
146
internal/web/adventure_prose_test.go
Normal file
146
internal/web/adventure_prose_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestProseAcceptedWhenClean: gogobee's LLM prose replaces the template render
|
||||
// (on the site row AND the live Matrix post) when both fields are present and
|
||||
// name nobody the fact did not authorize.
|
||||
func TestProseAcceptedWhenClean(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
const hl = "Josie went into the Ossuary alone and came back with the crown."
|
||||
const lede = "No fanfare, no party — just Josie, a locked door, and a very bad afternoon for whatever was guarding it. She walked back out at level 14 with the thing everyone else left behind."
|
||||
f := AdvFact{
|
||||
GUID: "boss_kill:j:1", EventType: "boss_kill", Tier: "priority",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden",
|
||||
Zone: "the Ossuary", Level: 14, OccurredAt: 1,
|
||||
Headline: hl, Lede: lede,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
got, err := storage.GetStoryByGUID(f.GUID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if got.Headline != hl {
|
||||
t.Errorf("stored headline = %q, want the LLM headline", got.Headline)
|
||||
}
|
||||
if got.Lede != lede {
|
||||
t.Errorf("stored lede = %q, want the LLM lede", got.Lede)
|
||||
}
|
||||
if len(*posted) != 1 || (*posted)[0].Headline != hl {
|
||||
t.Fatalf("live post did not carry LLM headline: %+v", *posted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProseRejectedNamesBystander: prose that names a real adventurer on the
|
||||
// board who is NOT in the fact's Actors is the injection this guard exists for.
|
||||
// It must fall back to the template, not print the name.
|
||||
func TestProseRejectedNamesBystander(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
// Kif is a real, current adventurer — but this fact is about Josie.
|
||||
if err := storage.ReplaceRoster([]storage.RosterEntry{
|
||||
{Token: "tk", Name: "Kif", Level: 9, Status: "idle"},
|
||||
}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "boss_kill:j:2", EventType: "boss_kill", Tier: "priority",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden",
|
||||
Zone: "the Ossuary", Level: 14, OccurredAt: 1,
|
||||
Headline: "Josie and Kif split the Ossuary hoard.",
|
||||
Lede: "A tidy bit of teamwork down in the dark today.",
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, err := storage.GetStoryByGUID(f.GUID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if strings.Contains(got.Headline+got.Lede, "Kif") {
|
||||
t.Errorf("bystander name leaked past the guard: %q / %q", got.Headline, got.Lede)
|
||||
}
|
||||
// The template render is what should have published instead.
|
||||
tHl, _, _ := renderAdventure(f)
|
||||
if got.Headline != tHl {
|
||||
t.Errorf("did not fall back to template: headline = %q, want %q", got.Headline, tHl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProseRejectedTooLong: a runaway generation past the length caps is not a
|
||||
// dispatch. Falls back to the template.
|
||||
func TestProseRejectedTooLong(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "arrival:z:1", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter",
|
||||
OccurredAt: 1,
|
||||
Headline: "Welcome, Zapp.",
|
||||
Lede: strings.Repeat("very long ", maxDispatchLede),
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, err := storage.GetStoryByGUID(f.GUID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
tHl, _, _ := renderAdventure(f)
|
||||
if got.Headline != tHl {
|
||||
t.Errorf("over-length prose was not rejected: headline = %q", got.Headline)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProseNeedsBothFields: a headline with no lede is half a voice. The guard
|
||||
// path is only taken when both are present; otherwise the whole template renders.
|
||||
func TestProseNeedsBothFields(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "arrival:z:2", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter",
|
||||
OccurredAt: 1,
|
||||
Headline: "Welcome, Zapp — a headline with no body.",
|
||||
// Lede intentionally empty.
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, _ := storage.GetStoryByGUID(f.GUID)
|
||||
tHl, _, _ := renderAdventure(f)
|
||||
if got == nil || got.Headline != tHl {
|
||||
t.Errorf("half-authored prose was used; want template headline %q", tHl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsWholeWord(t *testing.T) {
|
||||
cases := []struct {
|
||||
hay, needle string
|
||||
want bool
|
||||
}{
|
||||
{"josie and kif split it", "kif", true},
|
||||
{"the kiffish blade", "kif", false}, // substring, not a word
|
||||
{"al arrived", "al", true}, // short name, still bounded
|
||||
{"alabama arrived", "al", false}, // bounded off
|
||||
{"ended with kif.", "kif", true}, // trailing punctuation is a boundary
|
||||
{"名月 cleared it", "名月", true}, // non-ASCII name, bounded by space
|
||||
{"the 名月光 shard", "名月", false}, // non-ASCII substring, not a word
|
||||
{"nobody here", "kif", false}, // absent
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsWholeWord(c.hay, c.needle); got != c.want {
|
||||
t.Errorf("containsWholeWord(%q,%q) = %v, want %v", c.hay, c.needle, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user