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

@@ -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.