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