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:
prosolis
2026-07-17 09:27:53 -07:00
parent 6219224ea9
commit eeeac08db7
3 changed files with 297 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"strings"
) )
// RosterEntry is one adventurer's currently-true state, as of the last snapshot // RosterEntry is one adventurer's currently-true state, as of the last snapshot
@@ -140,3 +141,31 @@ func RosterSnapshotAt() int64 {
} }
return at.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
}

View File

@@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"strings" "strings"
"time" "time"
"unicode"
"pete/internal/storage" "pete/internal/storage"
) )
@@ -37,6 +38,13 @@ type AdvFact struct {
Milestone string `json:"milestone"` Milestone string `json:"milestone"`
OccurredAt int64 `json:"occurred_at"` OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push"` 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 // 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 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) headline, lede, ok := renderAdventure(f)
if !ok { if !ok {
http.Error(w, "unknown event_type", http.StatusBadRequest) http.Error(w, "unknown event_type", http.StatusBadRequest)
return 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. // Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
if storage.IsGUIDSeen(f.GUID) { if storage.IsGUIDSeen(f.GUID) {
@@ -374,6 +397,105 @@ func factGuard(f AdvFact) bool {
return true 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 // renderAdventure returns the deterministic headline + lede for a fact. Copied
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only — // 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. // no LLM — so output is safe and reproducible. ok is false for an unknown type.

View 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)
}
}
}