Files
Pete/internal/explainer/explainer.go
prosolis dd324c0317 Accept more question-mark reactions as explain triggers
Now triggers on  ⁉️ 🤔 ? ? — covers the obvious red/white/thinking
variants, the exclamation-question combo (with and without VS16), and
plain ascii / fullwidth question marks for keyboard users.
2026-05-22 18:29:32 -07:00

184 lines
5.6 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 (
"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) {
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()
}