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.
195 lines
5.9 KiB
Go
195 lines
5.9 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 (
|
||
"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()
|
||
}
|