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("") + 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())