From 3537e073e9e9b7452872185d4936b6e6dbd791e7 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Fri, 22 May 2026 18:27:11 -0700
Subject: [PATCH] =?UTF-8?q?!explain=20via=20=E2=9D=93=20reaction:=20thread?=
=?UTF-8?q?-reply=20with=20a=203-bullet=20TL;DR?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
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
---
internal/classifier/ollama.go | 26 +++--
internal/explainer/explainer.go | 169 ++++++++++++++++++++++++++++++++
internal/ingestion/article.go | 63 ++++++++++++
internal/matrix/client.go | 29 ++++++
internal/poster/tracker.go | 26 +++++
internal/storage/queries.go | 12 +++
main.go | 5 +-
7 files changed, 320 insertions(+), 10 deletions(-)
create mode 100644 internal/explainer/explainer.go
diff --git a/internal/classifier/ollama.go b/internal/classifier/ollama.go
index 3aa481e..5816f9a 100644
--- a/internal/classifier/ollama.go
+++ b/internal/classifier/ollama.go
@@ -51,20 +51,28 @@ func NewOllamaClient(cfg config.OllamaConfig) *OllamaClient {
// Generate sends a prompt to Ollama via the chat API and returns the raw response text.
func (o *OllamaClient) Generate(prompt string) (string, error) {
+ return o.call(
+ "You are a JSON-only news classification API. Respond with valid JSON only. No preamble, no explanation, no markdown.",
+ prompt,
+ "json",
+ )
+}
+
+// GenerateText sends a system + user prompt to Ollama and returns the raw
+// text response. Unlike Generate, no JSON mode is forced.
+func (o *OllamaClient) GenerateText(systemPrompt, userPrompt string) (string, error) {
+ return o.call(systemPrompt, userPrompt, "")
+}
+
+func (o *OllamaClient) call(systemPrompt, userPrompt, format string) (string, error) {
reqBody := chatRequest{
Model: o.model,
Messages: []chatMessage{
- {
- Role: "system",
- Content: "You are a JSON-only news classification API. Respond with valid JSON only. No preamble, no explanation, no markdown.",
- },
- {
- Role: "user",
- Content: prompt,
- },
+ {Role: "system", Content: systemPrompt},
+ {Role: "user", Content: userPrompt},
},
Stream: false,
- Format: "json",
+ Format: format,
}
body, err := json.Marshal(reqBody)
diff --git a/internal/explainer/explainer.go b/internal/explainer/explainer.go
new file mode 100644
index 0000000..3a1ecb7
--- /dev/null
+++ b/internal/explainer/explainer.go
@@ -0,0 +1,169 @@
+// 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.",
+ "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) {
+ 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, "
" + html.EscapeString(raw) + "
"
+ }
+
+ var pb, hb strings.Builder
+ hb.WriteString("")
+ for i, b := range bullets {
+ if i > 0 {
+ pb.WriteByte('\n')
+ }
+ pb.WriteString("• ")
+ pb.WriteString(b)
+ hb.WriteString("- ")
+ hb.WriteString(html.EscapeString(b))
+ hb.WriteString("
")
+ }
+ hb.WriteString("
")
+ return pb.String(), hb.String()
+}
diff --git a/internal/ingestion/article.go b/internal/ingestion/article.go
index 29836a8..07b2501 100644
--- a/internal/ingestion/article.go
+++ b/internal/ingestion/article.go
@@ -101,6 +101,69 @@ func FetchOGImage(articleURL string) string {
return FetchArticleMeta(articleURL).ImageURL
}
+// MaxBodyChars is the cap on body text returned by FetchArticleBody. Keeps
+// LLM prompts bounded; most news articles fit well under this.
+const MaxBodyChars = 8000
+
+// FetchArticleBody fetches the article and returns the concatenated visible
+// body text (/ tags, falling back to all
), trimmed
+// and capped at MaxBodyChars. Returns "" on any fetch failure.
+func FetchArticleBody(articleURL string) string {
+ if articleURL == "" {
+ return ""
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", articleURL, nil)
+ if err != nil {
+ return ""
+ }
+ req.Header.Set("User-Agent", userAgent)
+ req.Header.Set("Accept", "text/html,application/xhtml+xml")
+
+ resp, err := articleClient.Do(req)
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return ""
+ }
+
+ doc, err := goquery.NewDocumentFromReader(resp.Body)
+ if err != nil {
+ return ""
+ }
+ return extractBodyText(doc)
+}
+
+func extractBodyText(doc *goquery.Document) string {
+ sel := doc.Find("article p, main p")
+ if sel.Length() == 0 {
+ sel = doc.Find("p")
+ }
+ var b strings.Builder
+ sel.Each(func(_ int, s *goquery.Selection) {
+ t := strings.TrimSpace(s.Text())
+ if t == "" {
+ return
+ }
+ if b.Len() > 0 {
+ b.WriteString("\n\n")
+ }
+ b.WriteString(t)
+ if b.Len() >= MaxBodyChars {
+ return
+ }
+ })
+ out := strings.TrimSpace(b.String())
+ if len(out) > MaxBodyChars {
+ out = out[:MaxBodyChars]
+ }
+ return out
+}
+
func extractOGImage(doc *goquery.Document, base string) string {
selectors := []string{
`meta[property="og:image:secure_url"]`,
diff --git a/internal/matrix/client.go b/internal/matrix/client.go
index e36dc64..cbf0943 100644
--- a/internal/matrix/client.go
+++ b/internal/matrix/client.go
@@ -294,6 +294,35 @@ func (c *Client) Stop() {
}
}
+// PostThreadedReply sends a plain/HTML message into the thread rooted at
+// rootEventID. Resolves channel name → room ID like PostStory. The is_falling_back
+// flag plus m.in_reply_to gives non-thread-aware clients a sensible quoted reply.
+func (c *Client) PostThreadedReply(channel string, rootEventID id.EventID, plain, htmlBody string) error {
+ roomID, ok := c.channels[channel]
+ if !ok {
+ return fmt.Errorf("unknown channel: %s", channel)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ content := &event.MessageEventContent{
+ MsgType: event.MsgText,
+ Body: plain,
+ Format: event.FormatHTML,
+ FormattedBody: htmlBody,
+ RelatesTo: &event.RelatesTo{
+ Type: event.RelThread,
+ EventID: rootEventID,
+ InReplyTo: &event.InReplyTo{
+ EventID: rootEventID,
+ },
+ IsFallingBack: true,
+ },
+ }
+ _, err := c.mx.SendMessageEvent(ctx, roomID, event.EventMessage, content)
+ return err
+}
+
// PostStory sends a story to a channel. Returns the text event ID for reaction tracking.
// If the story has a validated image, it's uploaded and sent as a separate m.image event first.
func (c *Client) PostStory(channel string, story *PostableStory) (id.EventID, error) {
diff --git a/internal/poster/tracker.go b/internal/poster/tracker.go
index 4a2685c..80e8efb 100644
--- a/internal/poster/tracker.go
+++ b/internal/poster/tracker.go
@@ -2,6 +2,7 @@ package poster
import (
"log/slog"
+ "sync"
"time"
"pete/internal/storage"
@@ -9,6 +10,24 @@ import (
"maunium.net/go/mautrix/id"
)
+// ReactionCallback is an optional hook fired after a reaction is recorded.
+// Used to wire reaction-driven features (e.g. ❓ → summary) without coupling
+// poster to those packages.
+type ReactionCallback func(roomID id.RoomID, targetEventID id.EventID, guid, channel, emoji string)
+
+var (
+ reactionCallbackMu sync.RWMutex
+ reactionCallback ReactionCallback
+)
+
+// SetReactionCallback installs a hook called after every recorded reaction.
+// Pass nil to clear. Safe to call before or after the matrix client starts.
+func SetReactionCallback(fn ReactionCallback) {
+ reactionCallbackMu.Lock()
+ reactionCallback = fn
+ reactionCallbackMu.Unlock()
+}
+
// HandleReaction processes a reaction event by mapping it back to the story GUID.
func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID) {
guid, channel, found := storage.LookupPostGUID(string(targetEventID))
@@ -24,4 +43,11 @@ func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.Event
"emoji", emoji,
"user", userID,
)
+
+ reactionCallbackMu.RLock()
+ cb := reactionCallback
+ reactionCallbackMu.RUnlock()
+ if cb != nil {
+ go cb(roomID, targetEventID, guid, channel, emoji)
+ }
}
diff --git a/internal/storage/queries.go b/internal/storage/queries.go
index 6feeec0..9716804 100644
--- a/internal/storage/queries.go
+++ b/internal/storage/queries.go
@@ -118,6 +118,18 @@ func GetUnclassifiedStories(source string) ([]Story, error) {
return stories, rows.Err()
}
+// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
+func GetStoryByGUID(guid string) (*Story, error) {
+ row := Get().QueryRow(
+ `SELECT guid, headline, lede, image_url, article_url, source, feed_hint, platforms, channel, seen_at
+ FROM stories WHERE guid = ?`, guid)
+ var s Story
+ if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.FeedHint, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
+ return nil, err
+ }
+ return &s, nil
+}
+
// InsertRecentHeadline adds a headline to the dedup context window.
func InsertRecentHeadline(guid, headline, source string) {
exec("insert recent_headline",
diff --git a/main.go b/main.go
index b57d93b..1a5a12c 100644
--- a/main.go
+++ b/main.go
@@ -11,6 +11,7 @@ import (
"pete/internal/classifier"
"pete/internal/config"
+ "pete/internal/explainer"
"pete/internal/ingestion"
"pete/internal/matrix"
"pete/internal/poster"
@@ -73,8 +74,10 @@ func main() {
// Create post queue
queue := poster.NewQueue(cfg.Posting, mx)
- // Wire reaction handler
+ // Wire reaction handler + ❓ → summary hook
mx.SetReactionHandler(poster.HandleReaction)
+ exp := explainer.New(mx, ollamaClient)
+ poster.SetReactionCallback(exp.Handle)
// Set up graceful shutdown
ctx, cancel := context.WithCancel(context.Background())