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