Files
Pete/internal/explainer/explainer.go
prosolis 69967b25c6 Plumb ctx through classifier and Ollama HTTP path
OllamaClient.Generate/GenerateText/call now take ctx and build the HTTP
request via NewRequestWithContext, so an in-flight LLM call is aborted
when the parent context is cancelled (Ctrl-C). Classifier.Classify and
its tier helpers take ctx too. ProcessFunc gets a ctx parameter so the
poller can forward its cancellable context down to classification.

Explainer.summarize manages its own 60s context since reaction-driven
flow has no parent ctx to inherit.
2026-05-22 18:55:43 -07:00

195 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package explainer summarizes a posted story on demand: when a user reacts ❓
// on one of Pete's posts, Pete fetches the article body, asks Ollama for a
// 3-bullet TL;DR, and replies in a thread rooted at the original post.
package explainer
import (
"context"
"fmt"
"html"
"log/slog"
"strings"
"sync"
"time"
"pete/internal/classifier"
"pete/internal/ingestion"
"pete/internal/matrix"
"pete/internal/storage"
"maunium.net/go/mautrix/id"
)
// questionReactions is the set of reaction keys that trigger a summary.
// Matrix delivers the key verbatim; we accept several question-y glyphs plus
// plain "?" so users don't have to hunt for the exact red question mark.
var questionReactions = map[string]bool{
"❓": true, // U+2753 red question mark
"❔": true, // U+2754 white question mark
"⁉": true, // U+2049 exclamation question mark
"⁉️": true, // U+2049 + VS16 (emoji presentation)
"🤔": true, // U+1F914 thinking face
"?": true, // plain ascii
"": true, // U+FF1F fullwidth question mark
}
// IsQuestionReaction reports whether a reaction key should trigger a summary.
func IsQuestionReaction(key string) bool {
return questionReactions[key]
}
// cooldown for repeat-explain on the same story (per process).
const explainCooldown = 5 * time.Minute
// Explainer holds the dependencies needed to produce a story summary.
type Explainer struct {
mx *matrix.Client
ollama *classifier.OllamaClient
mu sync.Mutex
lastSeen map[string]time.Time // guid -> last explanation time
}
// New builds an Explainer wired to the Matrix client and Ollama backend.
func New(mx *matrix.Client, ollama *classifier.OllamaClient) *Explainer {
return &Explainer{
mx: mx,
ollama: ollama,
lastSeen: make(map[string]time.Time),
}
}
// Handle is the entry point used as a callback from poster.HandleReaction.
// It runs synchronously — callers should invoke it in a goroutine.
func (e *Explainer) Handle(roomID id.RoomID, rootEventID id.EventID, guid, channel, emoji string) {
if !IsQuestionReaction(emoji) {
return
}
if !e.acquire(guid) {
slog.Debug("explain: skipping recent duplicate", "guid", guid)
return
}
story, err := storage.GetStoryByGUID(guid)
if err != nil || story == nil {
slog.Warn("explain: story lookup failed", "guid", guid, "err", err)
return
}
body := ingestion.FetchArticleBody(story.ArticleURL)
if body == "" {
// Couldn't get the body — fall back to lede so the user at least gets something.
body = story.Lede
}
if body == "" {
slog.Warn("explain: no body or lede available", "guid", guid, "url", story.ArticleURL)
_ = e.mx.PostThreadedReply(channel, rootEventID,
"Couldn't fetch the article body to summarize.",
"<em>Couldn't fetch the article body to summarize.</em>")
return
}
summary, err := e.summarize(story.Headline, body)
if err != nil {
slog.Error("explain: ollama failed", "guid", guid, "err", err)
_ = e.mx.PostThreadedReply(channel, rootEventID,
"Sorry, I couldn't generate a summary right now.",
"<em>Sorry, I couldn't generate a summary right now.</em>")
return
}
plain, htmlBody := formatSummary(summary)
if err := e.mx.PostThreadedReply(channel, rootEventID, plain, htmlBody); err != nil {
slog.Error("explain: send reply failed", "guid", guid, "err", err)
return
}
slog.Info("explain: summary posted", "guid", guid, "channel", channel)
}
// acquire returns true if this guid hasn't been explained within the cooldown.
// Updates the timestamp on success.
func (e *Explainer) acquire(guid string) bool {
e.mu.Lock()
defer e.mu.Unlock()
if t, ok := e.lastSeen[guid]; ok && time.Since(t) < explainCooldown {
return false
}
e.lastSeen[guid] = time.Now()
return true
}
const summarySystem = `You summarize news articles in exactly 3 short bullet points for a chat reader.
Rules:
- Output ONLY the 3 bullets, each on its own line, prefixed with "- ".
- No preamble, no headings, no closing remarks.
- Each bullet is one concise sentence (under 25 words).
- Stick strictly to facts in the article. Do not speculate.`
func (e *Explainer) summarize(headline, body string) (string, error) {
// Reaction-driven flow has no parent ctx; bound the LLM call ourselves.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
prompt := fmt.Sprintf("Headline: %s\n\nArticle body:\n%s", headline, body)
out, err := e.ollama.GenerateText(ctx, summarySystem, prompt)
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
// formatSummary converts the LLM's "- bullet\n- bullet" output into a Matrix
// plain + HTML pair. Any stray prefixes ("Summary:", "•", "*") are normalized
// to "-" bullets.
func formatSummary(raw string) (plain, htmlBody string) {
lines := strings.Split(raw, "\n")
var bullets []string
sawMarker := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip header-ish lines the model sometimes adds
low := strings.ToLower(line)
if strings.HasPrefix(low, "summary:") || strings.HasPrefix(low, "tl;dr") {
continue
}
// Normalize common bullet markers; only count this line if it had one.
hadMarker := false
for _, prefix := range []string{"- ", "* ", "• ", "·"} {
if strings.HasPrefix(line, prefix) {
line = strings.TrimSpace(line[len(prefix):])
hadMarker = true
break
}
}
if !hadMarker {
continue
}
sawMarker = true
if line == "" {
continue
}
bullets = append(bullets, line)
}
if !sawMarker || len(bullets) == 0 {
// Model produced something but we couldn't parse bullets — pass through.
return raw, "<pre>" + html.EscapeString(raw) + "</pre>"
}
var pb, hb strings.Builder
hb.WriteString("<ul>")
for i, b := range bullets {
if i > 0 {
pb.WriteByte('\n')
}
pb.WriteString("• ")
pb.WriteString(b)
hb.WriteString("<li>")
hb.WriteString(html.EscapeString(b))
hb.WriteString("</li>")
}
hb.WriteString("</ul>")
return pb.String(), hb.String()
}