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.
|
||||
|
||||
Reference in New Issue
Block a user