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:
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
@@ -37,6 +38,13 @@ type AdvFact struct {
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
|
||||
// and past the prose-guard they replace the template render; otherwise Pete
|
||||
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
|
||||
// the templates are no longer the renderer, they are the safety net. See
|
||||
// proseGuard and adventure_expansion_spec.md §2.
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Lede string `json:"lede,omitempty"`
|
||||
}
|
||||
|
||||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||||
@@ -93,11 +101,26 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Render the template first. It validates the event type, and it is the
|
||||
// fallback for every fact whose LLM prose is absent or fails the guard.
|
||||
headline, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Prefer gogobee's LLM prose when it is present and safe. Both fields must be
|
||||
// supplied — a half-authored dispatch is not a voice, and mixing an LLM
|
||||
// headline with a template lede reads as two writers. The guard is what makes
|
||||
// the untrusted prose safe to print; a rejection is worth seeing loudly, since
|
||||
// it is either a hallucinated name or someone who found an injection path.
|
||||
if f.Headline != "" && f.Lede != "" {
|
||||
if proseGuard(f.Headline, f.Lede, f.Actors) {
|
||||
headline, lede = f.Headline, f.Lede
|
||||
} else {
|
||||
slog.Warn("adventure ingest: prose-guard rejected LLM dispatch, using template",
|
||||
"guid", f.GUID, "event_type", f.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
||||
if storage.IsGUIDSeen(f.GUID) {
|
||||
@@ -374,6 +397,105 @@ func factGuard(f AdvFact) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Length caps for LLM-authored prose, enforced before render. The 64 KiB body
|
||||
// cap on the ingest request is a transport limit, not a prose limit — a
|
||||
// dispatch is a headline and a short paragraph, and anything past these is
|
||||
// malformed, not a valid long story. Over the cap falls back to the template.
|
||||
const (
|
||||
maxDispatchHeadline = 200
|
||||
maxDispatchLede = 800
|
||||
)
|
||||
|
||||
// proseGuard decides whether gogobee's LLM-authored headline+lede is safe to
|
||||
// print. factGuard checks the STRUCTURED Subject/Opponent fields; that was the
|
||||
// whole safety story while Pete's own templates were the renderer, because a
|
||||
// template can print nothing Pete did not interpolate. LLM prose breaks that
|
||||
// assumption — the guard would be validating fields that are no longer the thing
|
||||
// being rendered — so this checks the RENDERED TEXT itself.
|
||||
//
|
||||
// Two rejections, both falling back to the template:
|
||||
// - Over the length caps: a runaway or padded generation, not a dispatch.
|
||||
// - Naming a known adventurer the fact did not authorize: any character name
|
||||
// Pete holds on the current board that is absent from the fact's Actors
|
||||
// allow-list. Character names are player-chosen, so a hallucinated or
|
||||
// injected name is a live way to put words in a real person's mouth on a
|
||||
// public page. Boss/zone/region are game-authored, never on the board, so
|
||||
// they never trip this.
|
||||
//
|
||||
// The name half is best-effort: an empty board (KnownCharacterNames nil) leaves
|
||||
// only the length caps, which is the correct degraded behaviour — with no known
|
||||
// names there is nothing to impersonate that Pete could recognise anyway.
|
||||
func proseGuard(headline, lede string, actors []string) bool {
|
||||
if len(headline) > maxDispatchHeadline || len(lede) > maxDispatchLede {
|
||||
return false
|
||||
}
|
||||
allow := make(map[string]bool, len(actors))
|
||||
for _, a := range actors {
|
||||
if a != "" {
|
||||
allow[strings.ToLower(a)] = true
|
||||
}
|
||||
}
|
||||
text := strings.ToLower(headline + "\n" + lede)
|
||||
for name := range storage.KnownCharacterNames() {
|
||||
if allow[name] {
|
||||
continue
|
||||
}
|
||||
if containsWholeWord(text, name) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// containsWholeWord reports whether needle appears in haystack bounded by
|
||||
// non-letter/digit runes (or the string edges). Both are already lowercased.
|
||||
// Bounding avoids a short character name ("Al") matching inside an unrelated
|
||||
// word ("Alabama") while still catching it as a standalone name; it is
|
||||
// deliberately rune-aware so a non-ASCII player name still bounds correctly,
|
||||
// where a stdlib \b would not.
|
||||
func containsWholeWord(haystack, needle string) bool {
|
||||
if needle == "" {
|
||||
return false
|
||||
}
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(haystack[from:], needle)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
start := from + i
|
||||
end := start + len(needle)
|
||||
beforeOK := start == 0 || !isWordRune(lastRune(haystack[:start]))
|
||||
afterOK := end == len(haystack) || !isWordRune(firstRune(haystack[end:]))
|
||||
if beforeOK && afterOK {
|
||||
return true
|
||||
}
|
||||
from = start + 1
|
||||
if from >= len(haystack) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
func firstRune(s string) rune {
|
||||
for _, r := range s {
|
||||
return r
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lastRune(s string) rune {
|
||||
var last rune
|
||||
for _, r := range s {
|
||||
last = r
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||||
|
||||
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