From eff960c54620bfba47d40bc3792ba805dea3dab3 Mon Sep 17 00:00:00 2001 From: Sam Scholten Date: Sun, 30 Aug 2026 20:34:38 +1000 Subject: rss: provenance + score calibration in items Item titles gain a rune-capped source-feed suffix so list views (which only ever show the channel title) display provenance inline. Item bodies open with feed, model score, and the batch-wide score median; the median is computed over every scored entry at poll time and stored on the run row as a nil-safe pointer, so old rows render without it. /api/status exposes the median too. --- cmds/rss.go | 57 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 13 deletions(-) (limited to 'cmds/rss.go') diff --git a/cmds/rss.go b/cmds/rss.go index 3c49513..8679f6f 100644 --- a/cmds/rss.go +++ b/cmds/rss.go @@ -8,10 +8,16 @@ import ( "encoding/xml" "fmt" "time" + "unicode/utf8" "fluxrec/core" ) +// Cap on the source-feed name in item titles: feed titles can be +// arbitrarily long, and the suffix must stay compact so list views keep +// showing the article title itself. +const maxFeedTitleRunes = 40 + const rssXMLHeader = `` + "\n" type rssGUID struct { @@ -40,9 +46,11 @@ type rssDocument struct { } // RenderRSS renders runs (oldest to newest, as read from runs.jsonl) as an -// RSS 2.0 document. The source feed title prefixes each item description so -// provenance is visible in-reader; item content was already cleaned when -// the run row was written. +// RSS 2.0 document. Each item title gets the source feed as a rune-capped +// suffix (readers show the channel title as the source of every item, so +// this is the only in-list provenance), and the description body opens +// with feed + model score + the batch-wide median score. Item content was +// already cleaned when the run row was written. func RenderRSS(runs []core.RunRow) ([]byte, error) { doc := rssDocument{ Version: "2.0", @@ -54,11 +62,11 @@ func RenderRSS(runs []core.RunRow) ([]byte, error) { for i := len(runs) - 1; i >= 0; i-- { for _, k := range runs[i].Kept { doc.Channel.Items = append(doc.Channel.Items, rssItem{ - Title: k.Title, + Title: rssItemTitle(k), Link: k.URL, GUID: rssGUID{IsPermaLink: false, Value: fmt.Sprintf("mf:%d", k.EntryID)}, PubDate: k.PublishedAt.Format(time.RFC1123Z), - Description: rssDescription(k), + Description: rssDescription(k, runs[i].ScoreMedian), }) } } @@ -69,14 +77,37 @@ func RenderRSS(runs []core.RunRow) ([]byte, error) { return append([]byte(rssXMLHeader), body...), nil } -// rssDescription builds the item body: source feed name on the first line, -// then the cleaned content (if any). -func rssDescription(k core.KeptItem) string { - if k.Content == "" { - return k.FeedTitle +// rssItemTitle builds the displayed title: article title first, then the +// (rune-capped) source feed as a suffix so narrow list views truncate the +// provenance, not the title itself. +func rssItemTitle(k core.KeptItem) string { + feed := truncateRunes(k.FeedTitle, maxFeedTitleRunes) + if feed == "" { + return k.Title + } + return k.Title + " — " + feed +} + +func truncateRunes(s string, max int) string { + if utf8.RuneCountInString(s) <= max { + return s } - if k.FeedTitle == "" { - return k.Content + return string([]rune(s)[:max]) + "…" +} + +// rssDescription builds the item body: first a headline line with the +// source feed, model score, and batch-wide median (omitted for run rows +// written before the median was logged), then the cleaned content (if any). +func rssDescription(k core.KeptItem, median *float64) string { + head := fmt.Sprintf("score %.2f", k.Score) + if median != nil { + head += fmt.Sprintf(" · batch median %.2f", *median) + } + if k.FeedTitle != "" { + head = k.FeedTitle + " · " + head + } + if k.Content == "" { + return head } - return k.FeedTitle + "\n\n" + k.Content + return head + "\n\n" + k.Content } -- cgit v1.2.3