// 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+2753 + VS16 (emoji presentation) "❔": true, // U+2754 white question mark "❔️": true, // U+2754 + VS16 "⁉": 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.", "Couldn't fetch the article body to summarize.") 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.", "Sorry, I couldn't generate a summary right now.") 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, "
" + html.EscapeString(raw) + "" } var pb, hb strings.Builder hb.WriteString("