From eeeac08db7834caaa74912ce122dfa9a9bcf83e2 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:27:53 -0700 Subject: [PATCH] adventure: publish gogobee's LLM prose, guarded, template as the net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/storage/roster.go | 29 ++++++ internal/web/adventure.go | 122 ++++++++++++++++++++++ internal/web/adventure_prose_test.go | 146 +++++++++++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 internal/web/adventure_prose_test.go diff --git a/internal/storage/roster.go b/internal/storage/roster.go index f407500..b6bbdf2 100644 --- a/internal/storage/roster.go +++ b/internal/storage/roster.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "log/slog" + "strings" ) // RosterEntry is one adventurer's currently-true state, as of the last snapshot @@ -140,3 +141,31 @@ func RosterSnapshotAt() int64 { } return at.Int64 } + +// KnownCharacterNames returns the set of character names on the current board, +// lowercased, for the prose-guard. It is the answer to "is this a name of a real +// adventurer other than the one the fact is about" — a name Pete knows but that +// the fact did not authorize walking onto a public page. +// +// Best-effort: an empty set (query error, or gogobee has never pushed a board) +// disables only the name half of the guard, never the length caps. The board is +// a snapshot, so a character who has dropped off it is not covered — the guard's +// job is protecting people who are currently in the realm, not auditing history. +func KnownCharacterNames() map[string]bool { + rows, err := Get().Query(`SELECT name FROM adventure_roster WHERE name <> ''`) + if err != nil { + slog.Error("KnownCharacterNames query failed", "err", err) + return nil + } + defer rows.Close() + + names := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + continue + } + names[strings.ToLower(name)] = true + } + return names +} diff --git a/internal/web/adventure.go b/internal/web/adventure.go index f68d587..258bf6d 100644 --- a/internal/web/adventure.go +++ b/internal/web/adventure.go @@ -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. diff --git a/internal/web/adventure_prose_test.go b/internal/web/adventure_prose_test.go new file mode 100644 index 0000000..eb645d6 --- /dev/null +++ b/internal/web/adventure_prose_test.go @@ -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) + } + } +}