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"`
|
Milestone string `json:"milestone,omitempty"`
|
||||||
OccurredAt int64 `json:"occurred_at"`
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
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
|
// 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
|
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)
|
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