!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:
prosolis
2026-05-22 18:27:11 -07:00
parent e26b69e43f
commit 3537e073e9
7 changed files with 320 additions and 10 deletions

View File

@@ -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 (<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 {
selectors := []string{
`meta[property="og:image:secure_url"]`,