adventure: stop dropping dispatches Pete has no words for
An event_type with no template was a 400 at ingest. That reads like caution and behaves like deletion: gogobee retries a 400 to its cap and then parks the row forever, so rejecting a type Pete hadn't learned to phrase didn't defer the event, it destroyed it. companion_hire went that way. It has been emitted from `!expedition hire` since the combat-engine work landed and has never once reached the site — the game logged a successful emit every time, and the queue row simply never sent. The mitigation on the books was "always deploy Pete first", which is a thing a person has to remember rather than a property of the system. So invert it. An unknown type now warns, gets counted, and publishes on a neutral fallback. 400 is kept for facts that are actually invalid: no guid, or a name that failed the fact-guard. gogobee can ship a new event type any day of the week now; the worst case is a thin card until Pete learns the words. It is thinner than it sounds in practice. gogobee authors dispatch prose from the fact's fields with no per-type switch, so an unrecognised type still arrives with a real headline and lede and is allowed to use them. The fallback only shows through when the model is off or the prose-guard refused the output. Untemplated types never post live to Matrix, whatever tier they claim. A thin card among cards is cheap and reversible; pinging everyone in the room with a dispatch Pete couldn't phrase is neither. The daily digest still carries it, one line among many, which is the right volume for something we don't understand yet. And give companion_hire its template. Pete is the one being hired, so it is first-person like his duels — third-person Pete filling in as a cleric reads as somebody else reporting on him. The admin status page grows a "dispatches with no template" panel, so the next one of these is a to-do list Pete can see rather than an archaeology dig. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
+120
-7
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
@@ -101,12 +102,27 @@ 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
|
||||
// Render the template first. It is the fallback for every fact whose LLM
|
||||
// prose is absent or fails the guard.
|
||||
//
|
||||
// An event type Pete has no template for is NOT an error. It used to be a 400,
|
||||
// and that was the wrong call: gogobee retries a 400 to its cap and then parks
|
||||
// the dispatch forever, so the only thing the rejection accomplished was
|
||||
// deleting a real game event that Pete simply hadn't learned to phrase yet.
|
||||
// `companion_hire` was dropped that way from the day it shipped, and the
|
||||
// mitigation on the books ("always deploy Pete first") is a rule a human has
|
||||
// to remember rather than a property of the system.
|
||||
//
|
||||
// So: an unknown type publishes on the neutral fallback and is counted for the
|
||||
// operator. gogobee can ship a new event type any day; the worst case is a
|
||||
// thin card until Pete learns the words. 400 stays for facts that are actually
|
||||
// invalid — no guid, or a failed name guard.
|
||||
headline, lede, known := renderAdventure(f)
|
||||
if !known {
|
||||
advNoteUnknownType(f.EventType)
|
||||
slog.Warn("adventure ingest: no template for event_type, publishing on fallback",
|
||||
"guid", f.GUID, "event_type", f.EventType)
|
||||
headline, lede = advFallbackRender(f)
|
||||
}
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
||||
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
||||
@@ -198,7 +214,13 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||
// digest. Website section always gets the row above regardless of tier.
|
||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||
//
|
||||
// An untemplated type never interrupts the room, whatever tier it claims.
|
||||
// Publishing one to the site is cheap and reversible — a thin card among
|
||||
// cards. Pinging everyone in Matrix with a dispatch Pete couldn't phrase is
|
||||
// neither. It still reaches Matrix through the daily digest, one line among
|
||||
// many, which is the right volume for something we don't understand yet.
|
||||
if f.Tier == "priority" && known && s.advPost != nil && s.adv.Channel != "" {
|
||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||
// media), and the link's og:image carries the preview instead.
|
||||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||
@@ -254,10 +276,83 @@ func advEventMeta(eventType string) (label, emoji string) {
|
||||
return "The contract landed", "💀"
|
||||
case "mischief_fizzled":
|
||||
return "Nobody home", "🚪"
|
||||
case "companion_hire":
|
||||
return "Pete tags along", "🎒"
|
||||
}
|
||||
return "Dispatch", "📣"
|
||||
}
|
||||
|
||||
// advUnknownTypes counts event types that arrived without a template, so an
|
||||
// operator can see what Pete needs to learn to write. Before the unknown-type
|
||||
// inversion these were 400s: gogobee retried to its cap and then parked the
|
||||
// dispatch forever, which is how `companion_hire` was silently dropped for
|
||||
// months. Now they publish on the neutral fallback and land here instead, where
|
||||
// the admin status page can say "companion_hire ×12, still no template".
|
||||
var advUnknownTypes = struct {
|
||||
sync.Mutex
|
||||
counts map[string]int
|
||||
}{counts: map[string]int{}}
|
||||
|
||||
func advNoteUnknownType(eventType string) {
|
||||
advUnknownTypes.Lock()
|
||||
defer advUnknownTypes.Unlock()
|
||||
advUnknownTypes.counts[eventType]++
|
||||
}
|
||||
|
||||
// AdvUnknownTypeCounts returns a copy of the untemplated-type tally.
|
||||
func AdvUnknownTypeCounts() map[string]int {
|
||||
advUnknownTypes.Lock()
|
||||
defer advUnknownTypes.Unlock()
|
||||
out := make(map[string]int, len(advUnknownTypes.counts))
|
||||
for k, v := range advUnknownTypes.counts {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withArticle prefixes a noun with "a"/"an". Class names are a small closed set
|
||||
// from the game ("cleric", "artificer"), so first-letter vowel is enough — this
|
||||
// is not trying to be a general English article engine.
|
||||
func withArticle(noun string) string {
|
||||
if noun == "" {
|
||||
return ""
|
||||
}
|
||||
switch noun[0] {
|
||||
case 'a', 'e', 'i', 'o', 'u':
|
||||
return "an " + noun
|
||||
}
|
||||
return "a " + noun
|
||||
}
|
||||
|
||||
// advFallbackRender is the dispatch for an event type Pete has no template for.
|
||||
//
|
||||
// It is deliberately thin and deliberately honest: it says something happened
|
||||
// and admits the details aren't in yet, rather than guessing at semantics Pete
|
||||
// doesn't have. Only guarded or game-authored fields reach it — Subject has
|
||||
// already passed factGuard, and Zone is game-authored — so it carries no more
|
||||
// exposure than any templated branch.
|
||||
//
|
||||
// In practice it is rarely what publishes. gogobee authors LLM prose for every
|
||||
// fact from the fact's fields with no per-type switch (authorDispatch in
|
||||
// pete_dispatch_voice.go), so an unknown type still arrives with a real headline
|
||||
// and lede, and this only shows through when the model is off or the prose-guard
|
||||
// rejected it.
|
||||
func advFallbackRender(f AdvFact) (headline, lede string) {
|
||||
const stillGetting = " I'm still getting the details on this one — I'll fill it in properly when I have them."
|
||||
switch {
|
||||
case f.Subject != "" && f.Zone != "":
|
||||
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||
fmt.Sprintf("Something happened out in %s involving %s.%s", f.Zone, f.Subject, stillGetting)
|
||||
case f.Subject != "":
|
||||
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||
fmt.Sprintf("%s has been up to something.%s", f.Subject, stillGetting)
|
||||
case f.Zone != "":
|
||||
return fmt.Sprintf("Something's happened in %s.", f.Zone),
|
||||
"Word just came in from the field." + stillGetting
|
||||
}
|
||||
return "Word in from the realm.", "Something happened out there." + stillGetting
|
||||
}
|
||||
|
||||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
||||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
||||
// the external-image thumbnailer.
|
||||
@@ -631,6 +726,24 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
case "milestone":
|
||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||
case "companion_hire":
|
||||
// Pete himself has been hired onto somebody's expedition. He is the one
|
||||
// being reported on here, so this is the one family besides his duels that
|
||||
// is properly first-person. Subject is the LEADER who hired him (and whose
|
||||
// opt-out therefore applies), class_race is the seat he's filling.
|
||||
seat, seatArticled := strings.ToLower(f.ClassRace), ""
|
||||
if seat == "" {
|
||||
seat, seatArticled = "an extra pair of hands", "an extra pair of hands"
|
||||
} else {
|
||||
seatArticled = withArticle(seat)
|
||||
}
|
||||
intoZone := ""
|
||||
if f.Zone != "" {
|
||||
intoZone = " into " + f.Zone
|
||||
}
|
||||
return fmt.Sprintf("Filling in as %s for %s.", seat, f.Subject),
|
||||
fmt.Sprintf("%s needed %s and I wasn't doing much, so I'm tagging along%s%s. I'll pull my weight — the reporting can wait till we're home.",
|
||||
f.Subject, seatArticled, intoZone, atLevel), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user