mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
Two halves of the same gap. A dispatch announcing a clear, a retreat or a death said how an expedition ended and gave a reader no way to reach what happened in it — so those three facts now carry the run they concluded, and Pete's permalink can open the log behind the headline. Finding that run is done by asking what is actually true at the moment a dispatch is filed rather than by threading a run id through five call sites that have already let go of it. Both guards on that lookup are load-bearing: a run with no beats behind it would mint a link to a 404, and without the recency window a campaign death at the Empty Throne would attach itself to whatever dungeon that player last walked. The other half is the summary. Every line of the liveblog is assembled by Pete out of a beat's own nouns, which is right for a log — it has to be exactly what happened, in order. But a report is read afterwards and the question it answers is not "what happened", it is "what was that run", and that is a judgement. So this is the one piece of prose on the channel, and it earns the model far better than a dispatch headline does. It runs on the roster ticker, not at the moment the run ends. A run ending is already a chokepoint with a dispatch being authored against it, and a second generation there would stall the command that killed the boss. One per tick, and a run that can't be summarised is closed out with an empty beat rather than retried forever — the row is what stops the sweep picking it up again, and a report with no summary is still the log and the numbers. The prompt states the one permitted name twice and forbids arithmetic: a model asked to write warmly about a party will invent a second member of it, and a total it works out itself will contradict the exact totals printed beside it. Both were seen against the real box before this was tightened. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
363 lines
13 KiB
Go
363 lines
13 KiB
Go
package plugin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"gogobee/internal/peteclient"
|
|
)
|
|
|
|
// The run summary — three sentences over forty beats.
|
|
//
|
|
// Every other line in the liveblog is assembled by Pete out of a beat's own
|
|
// nouns and numbers, and that is the right split: a log has to be exactly what
|
|
// happened, in order, and prose in the middle of it would be the more convincing
|
|
// of the two accounts and the less true. But a *report* is read afterwards, by
|
|
// somebody who wasn't watching, and the question it answers is not "what
|
|
// happened" — the log already answers that — it is "what was that run". That is
|
|
// a judgement, and no template makes judgements.
|
|
//
|
|
// So this is the one piece of prose on the channel, and it earns the model far
|
|
// better than a dispatch headline does. authorDispatch turns four fields into a
|
|
// sentence a template could nearly have written; this reads a whole expedition
|
|
// and picks out what mattered.
|
|
//
|
|
// Three rules, and the first one is why this file exists at all:
|
|
//
|
|
// - **Off the hot path.** It runs on the roster ticker, not at the moment the
|
|
// run ends. A run ending is already a player-facing beat with a dispatch
|
|
// being authored against it; adding a second bounded-but-real LLM call to
|
|
// that chokepoint would stall the command that killed the boss.
|
|
// - **One per tick.** A backlog after an outage drains over minutes rather
|
|
// than spooling a hundred generations at once.
|
|
// - **Best effort, exactly once.** A run that can't be summarised is filed
|
|
// with an empty summary beat rather than retried forever — the row is what
|
|
// stops the sweep picking it up again next tick, and a report with no
|
|
// summary is still the log and the numbers, which is most of it.
|
|
|
|
// runSummaryMaxBeats bounds what goes into the prompt. Far more than a normal
|
|
// run produces; the cap is for the multi-day expedition that beat out hundreds,
|
|
// where the last chunk is the part with the ending in it.
|
|
const runSummaryMaxBeats = 120
|
|
|
|
// maxRunSummary mirrors Pete's cap so we never ship prose Pete will reject on
|
|
// length alone. Byte count, matching Pete's len() check.
|
|
const maxRunSummary = 1200
|
|
|
|
// runSummaryTimeout is deliberately four times the dispatch budget.
|
|
//
|
|
// dispatchLLMTimeout is tight because authoring runs on a game chokepoint and a
|
|
// template dispatch now beats a voiced one late. Nothing here is waiting on this:
|
|
// it is a background ticker, the run ended minutes ago, and the page it feeds is
|
|
// already serving without it. The cost of being impatient is the opposite of
|
|
// there — a timeout files an empty summary beat, and that run never gets another
|
|
// chance at one. So this waits long enough that a timeout means the box is down
|
|
// rather than that the model was thinking.
|
|
const runSummaryTimeout = 60 * time.Second
|
|
|
|
var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout}
|
|
|
|
// sweepRunSummaries authors the summary for at most one finished run per call.
|
|
// Called from the roster ticker, after the beats themselves have been pushed —
|
|
// the summary is the last beat of a run's story and there is no rush to have it
|
|
// overtake the log it is about.
|
|
func (p *AdventurePlugin) sweepRunSummaries() {
|
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
|
return
|
|
}
|
|
if os.Getenv("OLLAMA_HOST") == "" || os.Getenv("OLLAMA_MODEL") == "" {
|
|
return // no model, no summary, no wasted queries asking which run needs one
|
|
}
|
|
runID := nextRunNeedingSummary()
|
|
if runID == "" {
|
|
return
|
|
}
|
|
// File the beat whatever happens below. An empty one carries no prose and Pete
|
|
// stores nothing from it — its entire job is to be the row that stops this run
|
|
// coming back round every two minutes for the rest of the week.
|
|
summary, name := authorRunSummary(runID)
|
|
if summary == "" {
|
|
slog.Debug("run summary: nothing authored, filing an empty beat to close it out", "run", runID)
|
|
}
|
|
recordRunBeat(runID, peteclient.RunBeat{
|
|
Kind: "summary",
|
|
Name: name,
|
|
Prose: summary,
|
|
})
|
|
}
|
|
|
|
// nextRunNeedingSummary picks the most recently finished run that has an `end`
|
|
// beat and no `summary` beat yet.
|
|
//
|
|
// Newest first, deliberately. If the sweep is behind — an outage, a busy
|
|
// evening — the run somebody is most likely to be looking at right now is the
|
|
// one that just ended, not the one from four hours ago. The old ones still get
|
|
// their turn on later ticks; they just don't get to hold up the fresh one.
|
|
func nextRunNeedingSummary() string {
|
|
var runID string
|
|
err := db.Get().QueryRow(`
|
|
SELECT e.run_id
|
|
FROM pete_run_beat e
|
|
WHERE e.kind = 'end'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM pete_run_beat s
|
|
WHERE s.run_id = e.run_id AND s.kind = 'summary')
|
|
ORDER BY e.occurred_at DESC
|
|
LIMIT 1`).Scan(&runID)
|
|
if err != nil {
|
|
return "" // ErrNoRows is the common case: nothing to summarise
|
|
}
|
|
if !runBeatAllowed(runID) {
|
|
// An opted-out player's beats never leave the box, so there is nothing for
|
|
// a summary to be attached to. File the closing beat anyway (it will be
|
|
// retired locally with the rest) so this run stops being picked.
|
|
recordRunBeat(runID, peteclient.RunBeat{Kind: "summary"})
|
|
return ""
|
|
}
|
|
return runID
|
|
}
|
|
|
|
// authorRunSummary reads a run's beats back and returns Pete's summary of it,
|
|
// plus the adventurer's name for the guard allow-list. Returns empty strings on
|
|
// any failure — the model being off, a timeout, an unparseable completion, an
|
|
// over-long generation — because every one of those is a report without a
|
|
// summary rather than a problem.
|
|
func authorRunSummary(runID string) (summary, name string) {
|
|
beats, err := loadRunBeatsForSummary(runID)
|
|
if err != nil || len(beats) == 0 {
|
|
return "", ""
|
|
}
|
|
name = runSummarySubject(beats)
|
|
if name == "" {
|
|
// No name means no allow-list on Pete's side, which means the guard rejects
|
|
// anything naming anyone. Don't spend a generation to have it thrown away.
|
|
return "", ""
|
|
}
|
|
|
|
raw, err := callOllamaDispatch(runSummaryHTTP, os.Getenv("OLLAMA_HOST"), os.Getenv("OLLAMA_MODEL"),
|
|
buildRunSummaryPrompt(name, beats))
|
|
if err != nil {
|
|
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
|
|
return "", name
|
|
}
|
|
summary = parseRunSummary(raw)
|
|
if summary == "" || len(summary) > maxRunSummary {
|
|
slog.Warn("run summary: unusable output", "run", runID, "len", len(summary))
|
|
return "", name
|
|
}
|
|
return summary, name
|
|
}
|
|
|
|
// loadRunBeatsForSummary reads a run's own beats back out of the outbound
|
|
// buffer. It reads the TAIL and re-sorts, so a run long enough to hit the cap
|
|
// contributes the part with its ending in it rather than its first morning.
|
|
func loadRunBeatsForSummary(runID string) ([]peteclient.RunBeat, error) {
|
|
rows, err := db.Get().Query(`
|
|
SELECT seq, payload FROM pete_run_beat
|
|
WHERE run_id = ? ORDER BY seq DESC LIMIT ?`, runID, runSummaryMaxBeats)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []peteclient.RunBeat
|
|
for rows.Next() {
|
|
var seq int64
|
|
var payload string
|
|
if err := rows.Scan(&seq, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
var b peteclient.RunBeat
|
|
if err := json.Unmarshal([]byte(payload), &b); err != nil {
|
|
continue // a row the pusher will retire on its own; not this sweep's problem
|
|
}
|
|
b.RunID, b.Seq = runID, seq
|
|
out = append(out, b)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
|
|
return out, nil
|
|
}
|
|
|
|
// runSummarySubject finds the one name a summary is allowed to use. Only the
|
|
// `start` beat carries identity, by design — so a run whose start beat was
|
|
// dropped has no subject here, and gets no summary rather than an anonymous one.
|
|
func runSummarySubject(beats []peteclient.RunBeat) string {
|
|
for _, b := range beats {
|
|
if b.Name != "" {
|
|
return b.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildRunSummaryPrompt renders the run as a plain numbered log and asks for
|
|
// three sentences over it.
|
|
//
|
|
// The beats go in as facts, not as Pete's rendered lines: Pete's phrasing is
|
|
// Pete's, and feeding a model its own output back would have it summarising a
|
|
// summary. The rules are the dispatch prompt's, tightened in the one place that
|
|
// matters here — a run log is full of monster names and a model asked to write
|
|
// about a party is very willing to invent a second member of it.
|
|
func buildRunSummaryPrompt(name string, beats []peteclient.RunBeat) string {
|
|
var log strings.Builder
|
|
zone, outcome := "", ""
|
|
n := 0
|
|
for _, b := range beats {
|
|
if b.Zone != "" && zone == "" {
|
|
zone = b.Zone
|
|
}
|
|
line := describeBeatForPrompt(b)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if b.Kind == "end" {
|
|
outcome = b.Outcome
|
|
}
|
|
n++
|
|
fmt.Fprintf(&log, "%d. %s\n", n, line)
|
|
}
|
|
if zone == "" {
|
|
zone = "a dungeon"
|
|
}
|
|
ending := "the run ended"
|
|
switch outcome {
|
|
case "cleared":
|
|
ending = "they cleared it"
|
|
case "died":
|
|
ending = "they died down there"
|
|
case "retreated":
|
|
ending = "they walked out alive but beaten"
|
|
}
|
|
|
|
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. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks.
|
|
|
|
Below is the log of one expedition, room by room, exactly as it was recorded. Write a SHORT summary of how the run went: what it cost them, the moment it turned, and how it ended.
|
|
|
|
STRICT RULES — do not violate these:
|
|
- The ONLY adventurer name you may use is: %s. Never invent another adventurer, companion, party member or friend. If the log does not say someone was there, they were not there.
|
|
- Monster, zone and item names in the log are game names — use them as given.
|
|
- Use ONLY what the log says. Do not invent numbers, fights, items or outcomes.
|
|
- Do NOT add numbers together and do NOT state any total. The exact totals are printed next to your summary and a total you worked out yourself will contradict them. Quote a number only if that exact number appears on one line of the log.
|
|
- Three sentences at most. No markdown, no emoji, no headline, no bullet points.
|
|
- Past tense, third person. Do not address the reader as "you".
|
|
|
|
Respond with ONLY a JSON object, no other text:
|
|
{"summary": "at most three sentences about how the run went"}
|
|
|
|
The expedition: %s went into %s, and %s.
|
|
|
|
The log:
|
|
%s`, name, name, zone, ending, log.String())
|
|
}
|
|
|
|
// describeBeatForPrompt renders one beat as a flat fact line for the prompt.
|
|
// Returns "" for a beat with nothing in it worth a sentence — a room with no
|
|
// identity, an empty haul — so the model isn't handed forty lines of "walked
|
|
// into the next room" to find three sentences in.
|
|
func describeBeatForPrompt(b peteclient.RunBeat) string {
|
|
switch b.Kind {
|
|
case "start":
|
|
if b.TotalRooms > 0 {
|
|
return fmt.Sprintf("set out into %s, %d rooms deep", orSomething(b.Zone), b.TotalRooms)
|
|
}
|
|
return "set out into " + orSomething(b.Zone)
|
|
case "combat":
|
|
what := orSomething(b.Target)
|
|
switch b.RoomKind {
|
|
case "boss":
|
|
what = "the boss, " + what
|
|
case "elite":
|
|
what = "an elite, " + what
|
|
}
|
|
switch b.Outcome {
|
|
case "won":
|
|
s := fmt.Sprintf("killed %s, taking %d damage", what, b.Amount)
|
|
if b.HPMax > 0 {
|
|
s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax)
|
|
}
|
|
if b.Crits > 0 {
|
|
s += fmt.Sprintf(", %d critical hit(s)", b.Crits)
|
|
}
|
|
return s
|
|
case "retreat":
|
|
return "could not finish " + what + " in time and withdrew"
|
|
default:
|
|
return "was beaten by " + what
|
|
}
|
|
case "trap":
|
|
if b.Amount <= 0 {
|
|
return "spotted a trap and stepped over it"
|
|
}
|
|
s := fmt.Sprintf("sprung a trap for %d damage", b.Amount)
|
|
if b.HPMax > 0 {
|
|
s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax)
|
|
}
|
|
return s
|
|
case "treasure":
|
|
return "found " + orSomething(b.Target)
|
|
case "lock":
|
|
if b.Outcome == "picked" {
|
|
return "picked a locked door"
|
|
}
|
|
return "found every way on sealed and doubled back"
|
|
case "region":
|
|
return "crossed out of " + orSomething(b.Region) + " into " + orSomething(b.Target)
|
|
case "haul":
|
|
if b.Amount <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("gathered %d supplies along the way", b.Amount)
|
|
case "end":
|
|
switch b.Outcome {
|
|
case "cleared":
|
|
return "finished the run and got out"
|
|
case "died":
|
|
return "did not come home"
|
|
case "retreated":
|
|
return "withdrew, wounded but alive"
|
|
}
|
|
return "the run ended"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func orSomething(s string) string {
|
|
if s == "" {
|
|
return "something"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// parseRunSummary pulls {"summary": ...} out of the completion, tolerating the
|
|
// same noise parseDispatch does: reasoning blocks, fences, prose around the JSON.
|
|
func parseRunSummary(raw string) string {
|
|
s := raw
|
|
if i := strings.Index(s, "<think>"); i != -1 {
|
|
if j := strings.Index(s, "</think>"); j != -1 {
|
|
s = s[:i] + s[j+len("</think>"):]
|
|
}
|
|
}
|
|
start := strings.Index(s, "{")
|
|
end := strings.LastIndex(s, "}")
|
|
if start < 0 || end <= start {
|
|
return ""
|
|
}
|
|
var out struct {
|
|
Summary string `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(out.Summary)
|
|
}
|