From 22b7949791a0469704617713cb42665869f62aa0 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Fri, 17 Jul 2026 09:28:13 -0700
Subject: [PATCH] adventure: author Pete's dispatches in his voice with the
local LLM
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
emitFact now runs the final fact through authorDispatch, which asks the local
Ollama model for a warm-reporter headline and lede and ships them on the Fact.
Pete guards and publishes them, falling back to its own templates on anything
it rejects — so authoring is best-effort by design: LLM off, a timeout, a
malformed generation, or an over-length pair all return an empty prose pair and
Pete templates the fact. The names allowed in the prose are the fact's Actors,
built from the post-opt-out subject/opponent, so what the model may say and what
Pete's guard permits are the same list. Synchronous like the holdem tip rewrite,
but on a tight 15s budget: news facts are infrequent and a template now beats a
voiced dispatch late. With no route for Pete to call back into this box, the
voice lives in the prompt here rather than in a Pete-owned inference endpoint.
---
internal/peteclient/client.go | 7 +
internal/plugin/pete.go | 6 +
internal/plugin/pete_dispatch_voice.go | 201 ++++++++++++++++++++
internal/plugin/pete_dispatch_voice_test.go | 81 ++++++++
4 files changed, 295 insertions(+)
create mode 100644 internal/plugin/pete_dispatch_voice.go
create mode 100644 internal/plugin/pete_dispatch_voice_test.go
diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go
index 606e8d0..b231951 100644
--- a/internal/peteclient/client.go
+++ b/internal/peteclient/client.go
@@ -48,6 +48,13 @@ type Fact struct {
Milestone string `json:"milestone,omitempty"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
+ // Headline/Lede are LLM-authored prose for this fact, both optional. Pete
+ // prefers them over its own template when present and past its prose-guard,
+ // and falls back to the template otherwise — so an empty pair (LLM off, or
+ // authoring failed) is the normal, safe case. Populated by emitFact; see
+ // authorDispatch. Names in the prose must come only from Actors.
+ Headline string `json:"headline,omitempty"`
+ Lede string `json:"lede,omitempty"`
}
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go
index f391410..e8c123c 100644
--- a/internal/plugin/pete.go
+++ b/internal/plugin/pete.go
@@ -183,6 +183,12 @@ func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
}
}
f.Actors = actors
+ // Author the prose in Pete's voice from the FINAL fact, so the names in the
+ // dispatch match the Actors allow-list Pete guards against. Best-effort: an
+ // empty pair (LLM off or authoring failed) just means Pete templates the
+ // fact. Synchronous, like the holdem tip rewrite — news facts are infrequent
+ // and the call is tightly bounded (dispatchLLMTimeout).
+ f.Headline, f.Lede = authorDispatch(f)
peteclient.Emit(f)
}
diff --git a/internal/plugin/pete_dispatch_voice.go b/internal/plugin/pete_dispatch_voice.go
new file mode 100644
index 0000000..6f45f49
--- /dev/null
+++ b/internal/plugin/pete_dispatch_voice.go
@@ -0,0 +1,201 @@
+package plugin
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "gogobee/internal/peteclient"
+)
+
+// LLM-authored adventure dispatches. gogobee owns the raw model compute; this is
+// where a structured fact becomes warm-reporter prose for Pete to publish. Pete
+// is still the editor and the safety boundary: it runs its own prose-guard over
+// whatever we send and falls back to its templates on anything it does not like,
+// so authoring here is best-effort by design — every failure path returns an
+// empty pair and Pete templates the fact.
+//
+// The voice must live somewhere, and with no route for Pete to call back into
+// this box (see roster.go in the Pete repo) it lives in the prompt below. Keep
+// it faithful to pete_adventure_news_voice.md; Pete's persona, not gogobee's.
+
+// dispatchLLMTimeout bounds the authoring call. emitFact runs on game-event
+// chokepoints (a party wipe fires one per member), so this is deliberately far
+// tighter than the interactive 120s tip budget: if the model cannot turn a
+// handful of facts into two sentences this fast, it is effectively down, and a
+// template dispatch now beats a voiced one late.
+const dispatchLLMTimeout = 15 * time.Second
+
+// Length ceilings, mirrored from Pete's proseGuard so we never ship prose Pete
+// will reject for length alone. Byte counts, matching Pete's len() check.
+const (
+ maxDispatchHeadline = 200
+ maxDispatchLede = 800
+)
+
+var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
+
+// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
+// two empty strings if the model is unconfigured, errors, times out, or produces
+// anything malformed. The fact must already have its FINAL Actors set (post
+// opt-out anonymisation) — that list is the only set of names the prose may use,
+// and it is what Pete's guard checks the output against.
+func authorDispatch(f peteclient.Fact) (headline, lede string) {
+ host := os.Getenv("OLLAMA_HOST")
+ model := os.Getenv("OLLAMA_MODEL")
+ if host == "" || model == "" {
+ return "", ""
+ }
+
+ prompt := buildDispatchPrompt(f)
+ raw, err := callOllamaDispatch(host, model, prompt)
+ if err != nil {
+ slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
+ return "", ""
+ }
+
+ h, l, ok := parseDispatch(raw)
+ if !ok {
+ slog.Warn("pete dispatch: unparseable LLM output, Pete will template", "guid", f.GUID)
+ return "", ""
+ }
+ // Ship only a complete, in-bounds pair. A half-authored dispatch or an
+ // over-long one is exactly what Pete would reject anyway; catch it here so a
+ // bad generation costs nothing on the wire.
+ if h == "" || l == "" || len(h) > maxDispatchHeadline || len(l) > maxDispatchLede {
+ slog.Warn("pete dispatch: LLM output empty or over length, Pete will template",
+ "guid", f.GUID, "headline_len", len(h), "lede_len", len(l))
+ return "", ""
+ }
+ return h, l
+}
+
+// buildDispatchPrompt renders the persona, the strict rules, and this fact's
+// structured facts into a single prompt. The facts block lists only the fields
+// that are set, each labelled, so the model has the who/what/where and no room
+// to invent the rest.
+func buildDispatchPrompt(f peteclient.Fact) string {
+ var facts strings.Builder
+ add := func(label, val string) {
+ if val != "" {
+ fmt.Fprintf(&facts, "- %s: %s\n", label, val)
+ }
+ }
+ addN := func(label string, n int) {
+ if n != 0 {
+ fmt.Fprintf(&facts, "- %s: %d\n", label, n)
+ }
+ }
+ add("event", f.EventType)
+ add("who this is about (the subject)", f.Subject)
+ add("the other person named", f.Opponent)
+ add("monster or boss", f.Boss)
+ add("dungeon or zone", f.Zone)
+ add("region", f.Region)
+ addN("character level", f.Level)
+ addN("count", f.Count)
+ add("outcome", f.Outcome)
+ add("stakes or item", f.Stakes)
+ add("class and race", f.ClassRace)
+ add("milestone", f.Milestone)
+
+ names := "(none — this is a realm-level event with no named adventurer)"
+ if len(f.Actors) > 0 {
+ names = strings.Join(f.Actors, ", ")
+ }
+
+ return fmt.Sprintf(`You are Pete, a warm, friendly local news reporter for a fantasy adventuring town. Think a beloved local newscaster who genuinely knows everyone and is glad to see them. You have journalistic bones — a clear headline and a who/what/where lede that gets the facts right — delivered with personable, first-person warmth. You root for the community, celebrate wins, mourn losses gently, welcome newcomers. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks.
+
+Write a short news dispatch about the event below.
+
+STRICT RULES — do not violate these:
+- Use ONLY these adventurer names, exactly as written: %s. Never invent a name, never use any other person's name, never use an @-handle.
+- Use ONLY the facts listed. Do not invent numbers, outcomes, items, or events that are not below.
+- The monster/boss, zone, region and item names are game names — you may use them as given.
+- Do not address the reader as "you" unless the event is Pete's own duel.
+- No markdown, no emoji, no quotation marks around the whole thing.
+
+Respond with ONLY a JSON object, no other text:
+{"headline": "one short sentence, a real headline", "lede": "one to three warm sentences with the who/what/where"}
+
+The event:
+%s`, names, facts.String())
+}
+
+// callOllamaDispatch posts a single non-streaming generation and returns the raw
+// completion (think-tags stripped). Its own bounded client, separate from the
+// interactive callOllama, because the game loop cannot wait 120s on the news.
+func callOllamaDispatch(host, model, prompt string) (string, error) {
+ payload := map[string]interface{}{
+ "model": model,
+ "prompt": prompt,
+ "stream": false,
+ "think": false,
+ "options": map[string]interface{}{
+ "num_ctx": 4096,
+ },
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("marshal payload: %w", err)
+ }
+ apiURL := strings.TrimRight(host, "/") + "/api/generate"
+ resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data))
+ if err != nil {
+ return "", fmt.Errorf("ollama request: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("read response: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ var result struct {
+ Response string `json:"response"`
+ }
+ if err := json.Unmarshal(body, &result); err != nil {
+ return "", fmt.Errorf("parse response: %w", err)
+ }
+ return result.Response, nil
+}
+
+// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
+// the usual noise (think blocks, markdown fences, prose around the JSON). ok is
+// false when no JSON object with a headline can be recovered.
+func parseDispatch(raw string) (headline, lede string, ok bool) {
+ s := raw
+ // Drop a Qwen-style reasoning block if present.
+ if i := strings.Index(s, ""); i != -1 {
+ if j := strings.Index(s, ""); j != -1 {
+ s = s[:i] + s[j+len(""):]
+ }
+ }
+ // Isolate the first {...} object so surrounding prose or fences don't break
+ // the decode.
+ start := strings.Index(s, "{")
+ end := strings.LastIndex(s, "}")
+ if start < 0 || end <= start {
+ return "", "", false
+ }
+ var out struct {
+ Headline string `json:"headline"`
+ Lede string `json:"lede"`
+ }
+ if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil {
+ return "", "", false
+ }
+ headline = strings.TrimSpace(out.Headline)
+ lede = strings.TrimSpace(out.Lede)
+ if headline == "" {
+ return "", "", false
+ }
+ return headline, lede, true
+}
diff --git a/internal/plugin/pete_dispatch_voice_test.go b/internal/plugin/pete_dispatch_voice_test.go
new file mode 100644
index 0000000..0f3e683
--- /dev/null
+++ b/internal/plugin/pete_dispatch_voice_test.go
@@ -0,0 +1,81 @@
+package plugin
+
+import (
+ "strings"
+ "testing"
+
+ "gogobee/internal/peteclient"
+)
+
+func TestParseDispatch(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ wantOK bool
+ wantHeadPre string
+ }{
+ {
+ name: "clean json",
+ raw: `{"headline": "Josie cleared the Ossuary.", "lede": "Alone, no less."}`,
+ wantOK: true,
+ wantHeadPre: "Josie cleared",
+ },
+ {
+ name: "wrapped in prose and fences",
+ raw: "Sure! Here you go:\n```json\n{\"headline\":\"A win.\",\"lede\":\"Nice one.\"}\n```",
+ wantOK: true,
+ wantHeadPre: "A win.",
+ },
+ {
+ name: "think block stripped",
+ raw: "let me consider the tone\n{\"headline\":\"Held the line.\",\"lede\":\"Proud of you all.\"}",
+ wantOK: true,
+ wantHeadPre: "Held the line.",
+ },
+ {name: "no json", raw: "I could not write that.", wantOK: false},
+ {name: "empty headline", raw: `{"headline":"","lede":"body"}`, wantOK: false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ h, l, ok := parseDispatch(c.raw)
+ if ok != c.wantOK {
+ t.Fatalf("ok = %v, want %v (h=%q l=%q)", ok, c.wantOK, h, l)
+ }
+ if ok && !strings.HasPrefix(h, c.wantHeadPre) {
+ t.Errorf("headline = %q, want prefix %q", h, c.wantHeadPre)
+ }
+ })
+ }
+}
+
+// TestBuildDispatchPrompt pins the two properties the prose-guard depends on:
+// the allowed names are stated verbatim, and only the set facts appear (no empty
+// labels for the model to fill in with invention).
+func TestBuildDispatchPrompt(t *testing.T) {
+ f := peteclient.Fact{
+ EventType: "boss_kill", Subject: "Josie", Boss: "the Bone Warden",
+ Zone: "the Ossuary", Level: 14, Actors: []string{"Josie"},
+ }
+ p := buildDispatchPrompt(f)
+
+ if !strings.Contains(p, "ONLY these adventurer names, exactly as written: Josie") {
+ t.Errorf("prompt does not constrain names to Actors:\n%s", p)
+ }
+ if !strings.Contains(p, "the Bone Warden") || !strings.Contains(p, "the Ossuary") {
+ t.Error("prompt dropped a supplied fact")
+ }
+ // Unset fields must not appear as empty labels.
+ if strings.Contains(p, "region:") || strings.Contains(p, "milestone:") {
+ t.Errorf("prompt lists an unset fact:\n%s", p)
+ }
+}
+
+// TestBuildDispatchPromptRealmEvent: a realm-level fact with no named adventurer
+// still produces a usable prompt that tells the model there is no name to use.
+func TestBuildDispatchPromptRealmEvent(t *testing.T) {
+ f := peteclient.Fact{EventType: "siege_start", Boss: "the Horde", Stakes: "the whole town"}
+ p := buildDispatchPrompt(f)
+ if !strings.Contains(p, "no named adventurer") {
+ t.Errorf("realm event prompt missing the no-name note:\n%s", p)
+ }
+}