mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
201
internal/plugin/pete_dispatch_voice.go
Normal file
201
internal/plugin/pete_dispatch_voice.go
Normal 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
|
||||
}
|
||||
81
internal/plugin/pete_dispatch_voice_test.go
Normal file
81
internal/plugin/pete_dispatch_voice_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user