adventure: author Pete's dispatches in his voice with the local LLM

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.
This commit is contained in:
prosolis
2026-07-17 09:28:13 -07:00
parent fbed45fc96
commit 22b7949791
4 changed files with 295 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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, "<think>"); i != -1 {
if j := strings.Index(s, "</think>"); j != -1 {
s = s[:i] + s[j+len("</think>"):]
}
}
// 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
}

View File

@@ -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: "<think>let me consider the tone</think>\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)
}
}