Files
Pete/internal/explainer/explainer.go
prosolis 3537e073e9 !explain via reaction: thread-reply with a 3-bullet TL;DR
When a user reacts  on one of Pete's posts, fetch the article body,
ask Ollama for a 3-bullet summary, and post it as a threaded reply
rooted at the original story event. Per-process cooldown of 5min per
story keeps repeated reactions from re-summarizing.

- ingestion.FetchArticleBody: visible <p> text capped at 8000 chars
- classifier.OllamaClient.GenerateText: non-JSON variant
- storage.GetStoryByGUID: full row lookup
- matrix.PostThreadedReply: m.thread + m.in_reply_to fallback
- poster.SetReactionCallback: optional hook fired after recording
- New package: internal/explainer
2026-05-22 18:27:11 -07:00

170 lines
5.0 KiB
Go

// 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 (
"fmt"
"html"
"log/slog"
"strings"
"sync"
"time"
"pete/internal/classifier"
"pete/internal/ingestion"
"pete/internal/matrix"
"pete/internal/storage"
"maunium.net/go/mautrix/id"
)
// QuestionEmoji is the reaction key that triggers a summary. Matrix delivers
// the reaction key verbatim — Element sends "❓" (U+2753).
const QuestionEmoji = "❓"
// 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 emoji != QuestionEmoji {
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) {
prompt := fmt.Sprintf("Headline: %s\n\nArticle body:\n%s", headline, body)
out, err := e.ollama.GenerateText(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
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Normalize common bullet markers
for _, prefix := range []string{"- ", "* ", "• ", "·"} {
if strings.HasPrefix(line, prefix) {
line = strings.TrimSpace(line[len(prefix):])
break
}
}
// Skip header-ish lines the model sometimes adds
low := strings.ToLower(line)
if strings.HasPrefix(low, "summary:") || strings.HasPrefix(low, "tl;dr") {
continue
}
if line == "" {
continue
}
bullets = append(bullets, line)
}
if 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()
}