!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
This commit is contained in:
@@ -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.
|
// Generate sends a prompt to Ollama via the chat API and returns the raw response text.
|
||||||
func (o *OllamaClient) Generate(prompt string) (string, error) {
|
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{
|
reqBody := chatRequest{
|
||||||
Model: o.model,
|
Model: o.model,
|
||||||
Messages: []chatMessage{
|
Messages: []chatMessage{
|
||||||
{
|
{Role: "system", Content: systemPrompt},
|
||||||
Role: "system",
|
{Role: "user", Content: userPrompt},
|
||||||
Content: "You are a JSON-only news classification API. Respond with valid JSON only. No preamble, no explanation, no markdown.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "user",
|
|
||||||
Content: prompt,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Stream: false,
|
Stream: false,
|
||||||
Format: "json",
|
Format: format,
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
body, err := json.Marshal(reqBody)
|
||||||
|
|||||||
169
internal/explainer/explainer.go
Normal file
169
internal/explainer/explainer.go
Normal file
@@ -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.",
|
||||||
|
"<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()
|
||||||
|
}
|
||||||
@@ -101,6 +101,69 @@ func FetchOGImage(articleURL string) string {
|
|||||||
return FetchArticleMeta(articleURL).ImageURL
|
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 (<article>/<main> <p> tags, falling back to all <p>), 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 {
|
func extractOGImage(doc *goquery.Document, base string) string {
|
||||||
selectors := []string{
|
selectors := []string{
|
||||||
`meta[property="og:image:secure_url"]`,
|
`meta[property="og:image:secure_url"]`,
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
// 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) {
|
func (c *Client) PostStory(channel string, story *PostableStory) (id.EventID, error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package poster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
@@ -9,6 +10,24 @@ import (
|
|||||||
"maunium.net/go/mautrix/id"
|
"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.
|
// 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) {
|
func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID) {
|
||||||
guid, channel, found := storage.LookupPostGUID(string(targetEventID))
|
guid, channel, found := storage.LookupPostGUID(string(targetEventID))
|
||||||
@@ -24,4 +43,11 @@ func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.Event
|
|||||||
"emoji", emoji,
|
"emoji", emoji,
|
||||||
"user", userID,
|
"user", userID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reactionCallbackMu.RLock()
|
||||||
|
cb := reactionCallback
|
||||||
|
reactionCallbackMu.RUnlock()
|
||||||
|
if cb != nil {
|
||||||
|
go cb(roomID, targetEventID, guid, channel, emoji)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,18 @@ func GetUnclassifiedStories(source string) ([]Story, error) {
|
|||||||
return stories, rows.Err()
|
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.
|
// InsertRecentHeadline adds a headline to the dedup context window.
|
||||||
func InsertRecentHeadline(guid, headline, source string) {
|
func InsertRecentHeadline(guid, headline, source string) {
|
||||||
exec("insert recent_headline",
|
exec("insert recent_headline",
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"pete/internal/classifier"
|
"pete/internal/classifier"
|
||||||
"pete/internal/config"
|
"pete/internal/config"
|
||||||
|
"pete/internal/explainer"
|
||||||
"pete/internal/ingestion"
|
"pete/internal/ingestion"
|
||||||
"pete/internal/matrix"
|
"pete/internal/matrix"
|
||||||
"pete/internal/poster"
|
"pete/internal/poster"
|
||||||
@@ -73,8 +74,10 @@ func main() {
|
|||||||
// Create post queue
|
// Create post queue
|
||||||
queue := poster.NewQueue(cfg.Posting, mx)
|
queue := poster.NewQueue(cfg.Posting, mx)
|
||||||
|
|
||||||
// Wire reaction handler
|
// Wire reaction handler + ❓ → summary hook
|
||||||
mx.SetReactionHandler(poster.HandleReaction)
|
mx.SetReactionHandler(poster.HandleReaction)
|
||||||
|
exp := explainer.New(mx, ollamaClient)
|
||||||
|
poster.SetReactionCallback(exp.Handle)
|
||||||
|
|
||||||
// Set up graceful shutdown
|
// Set up graceful shutdown
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|||||||
Reference in New Issue
Block a user