rss: stop claiming "что нового" and stop re-noting the same items

"что нового?" is a greeting, and the feed matcher claimed it: "нового" was a
feed noun and "что" an ask. With no feeds block, which is what ships, the answer
to hello was "я пока не читаю ленты — они не настроены". A newness word now
needs a named topic or a real feed noun beside it. The topic prepositions lose
"о" for the same class of reason: one rune of filler produced a category of
whatever followed it, and then "по этой теме в лентах пока ничего".

An undated feed was re-noted in full on every boot. Dated items are deduped
against the durable mark, undated ones against a map that dies with the process,
so five items became five more on the next start, stamped now, at the top of the
recent-notes window. A crash loop made that a flood. The mark is now set for an
undated feed too, and its existence marks the first poll after a restart as a
resync: those items are recorded as seen rather than written.

A burst larger than max_items lost its middle. The poll walked the feed
newest-first, stopped at the cap, and marked the newest item written, which put
everything below the cap behind the mark forever. The cap now applies to the
oldest candidates and the mark follows what was written, so max_items paces
instead of dropping.

The category tag was read out loud: "Заголовок [технологии]" went through piper
brackets and all, because the answer path took the whole first line. The tag is
parsed off for reading and is now the only thing a topic is matched against.
Matching the whole note meant "что нового про погоду" hit any tech headline
whose link contained "pogod".

Also: the charset comment on dec.Strict described something Strict does not do,
and a skipped feed is named in the log.

Found in review of #66.
This commit is contained in:
kami
2026-08-01 14:21:36 +04:00
parent 3ff2a9340a
commit 694d9e4e45
6 changed files with 321 additions and 49 deletions
+5 -7
View File
@@ -241,15 +241,13 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
continue
}
if !router.CategoryMatches(n.Text, q.Category) {
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
continue
}
// The note carries title, summary and link; she reads the title.
title := n.Text
if i := strings.IndexByte(title, '\n'); i > 0 {
title = title[:i]
}
picked = append(picked, strings.TrimSpace(title))
// The note carries title, summary, category tag and link; she reads the
// title alone. The tag is for the match above, and piper reads brackets
// out loud.
picked = append(picked, rss.NoteHeadline(n.Text))
if len(picked) == feedReadOut {
break
}
+47 -14
View File
@@ -16,14 +16,25 @@ type FeedQuery struct {
Category string
}
// feedNouns — the words that make a question about the feeds themselves.
// feedNouns — the words that name the feeds themselves. One of these is enough,
// with an ask, to make the turn a feed question.
var feedNouns = []string{
"лента", "ленте", "ленты", "лентах", "лентам",
"новости", "новостей", "новостях", "новостям",
"новое", "нового", "новенького",
"feed", "feeds", "news", "headlines",
}
// vagueNouns — the newness words that are NOT about the feeds by themselves.
//
// "что нового?" is the most common opener in the language and it is a greeting,
// not a request for headlines. It used to match here, so the shipping daemon —
// which has no feeds block — answered "я пока не читаю ленты, они не настроены",
// a configuration status in reply to hello. With feeds on it answered "в лентах
// пока ничего нового", which is no better. A vague noun claims the turn only
// when the utterance narrows it: a named topic ("что нового по технологиям"), or
// a feed noun somewhere in it ("что нового в лентах").
var vagueNouns = []string{"новое", "нового", "новенького", "new"}
// newnessMarkers — the "что нового" half. "нового" alone is in feedNouns
// because it carries the question on its own ("что нового?"); a bare "лента"
// needs the ask, which is what askMarkers below is for.
@@ -33,13 +44,15 @@ var askMarkers = []string{
}
// ParseFeedQuery reports whether an utterance asks what is new in the feeds, and
// which topic if it names one after "по"/"о"/"про"/"about".
// which topic if it names one after "по"/"об"/"про"/"about".
//
// Both a feed noun and an ask are required. "у меня новая лента в инстаграме" is
// a statement and must not be read as a request to recite headlines.
// A feed noun and an ask are required. "у меня новая лента в инстаграме" is a
// statement and must not be read as a request to recite headlines. A vague
// newness word counts as the noun only when a topic is named — see vagueNouns
// for why the bare "что нового?" must fall through.
func ParseFeedQuery(text string) (FeedQuery, bool) {
toks := planTokens(text)
noun, ask := false, false
noun, vague, ask := false, false, false
for _, t := range toks {
for _, n := range feedNouns {
if t == n {
@@ -47,6 +60,12 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
break
}
}
for _, n := range vagueNouns {
if t == n {
vague = true
break
}
}
for _, a := range askMarkers {
if t == a {
ask = true
@@ -54,24 +73,34 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
}
}
}
if !noun || !ask {
if !ask {
return FeedQuery{}, false
}
return FeedQuery{Category: feedCategory(toks)}, true
cat := feedCategory(toks)
if !noun && !(vague && cat != "") {
return FeedQuery{}, false
}
return FeedQuery{Category: cat}, true
}
// categoryPreps — the prepositions a topic follows. Russian marks the topic with
// a preposition ("по технологиям", "про политику"), so the word after one is the
// category; there is no stemming here, and the match against the configured
// category is a prefix comparison for exactly that reason.
var categoryPreps = map[string]bool{"по": true, "о": true, "об": true, "про": true, "about": true, "on": true}
//
// "о" is not in the list. It is one rune and it turns up as filler, a typo and
// half of "о'кей", so any utterance carrying a stray "о" produced a category of
// whatever word came next and she answered "по этой теме в лентах пока ничего"
// to a question that named no theme. "об" and "про" carry the same meaning and
// cannot be mistaken for anything else.
var categoryPreps = map[string]bool{"по": true, "об": true, "про": true, "about": true, "on": true}
func feedCategory(toks []string) string {
for i, t := range toks {
if categoryPreps[t] && i+1 < len(toks) {
next := toks[i+1]
// "по новостям" names no topic, it repeats the noun.
for _, n := range feedNouns {
for _, n := range append(append([]string{}, feedNouns...), vagueNouns...) {
if next == n {
return ""
}
@@ -82,12 +111,16 @@ func feedCategory(toks []string) string {
return ""
}
// CategoryMatches reports whether a note's text plausibly belongs to the
// category he named. Russian inflects the topic ("технологиям" vs the configured
// CategoryMatches reports whether a feed note's own category tag is the one he
// named. Russian inflects the topic ("технологиям" vs the configured
// "технологии"), and there is no stemmer in this repo, so the comparison is on a
// common prefix — long enough that "полит" and "погод" stay apart, short enough
// to survive a case ending.
func CategoryMatches(text, category string) bool {
//
// tag is the note's stored category (rss.NoteCategory), NOT the whole note. It
// used to be the whole note, which meant "что нового про погоду" matched any
// tech headline whose link happened to contain "pogod".
func CategoryMatches(tag, category string) bool {
if category == "" {
return true
}
@@ -95,7 +128,7 @@ func CategoryMatches(text, category string) bool {
if stem == "" {
return false
}
return strings.Contains(strings.ToLower(text), stem)
return strings.Contains(strings.ToLower(tag), stem)
}
// categoryStem cuts a word down to the part inflection leaves alone. 5 runes is
+13 -3
View File
@@ -9,13 +9,19 @@ func TestParseFeedQuery(t *testing.T) {
category string
}{
{"что нового в лентах?", true, ""},
{"что нового?", true, ""},
{"что нового по технологиям", true, "технологиям"},
{"какие новости?", true, ""},
{"что нового по технологиям?", true, "технологиям"},
{"расскажи новости про политику", true, "политику"},
{"что нового по новостям", true, ""},
{"what's new in the feeds?", true, ""},
{"any news about kubernetes", true, "kubernetes"},
// "что нового?" is a greeting. Claiming it made the shipping daemon
// answer hello with "я пока не читаю ленты — они не настроены".
{"что нового?", false, ""},
{"ну что нового", false, ""},
// A stray "о" is not a topic marker.
{"что нового в лентах, о боже", true, ""},
// Statements, not requests.
{"у меня новая лента в инстаграме", false, ""},
{"новости меня утомили", false, ""},
@@ -36,12 +42,16 @@ func TestParseFeedQuery(t *testing.T) {
func TestCategoryMatches(t *testing.T) {
// The inflected form he says must match the form the config spells.
if !CategoryMatches("Новый релиз [технологии]", "технологиям") {
if !CategoryMatches("технологии", "технологиям") {
t.Error("inflected category did not match")
}
if CategoryMatches("Новый релиз [технологии]", "политику") {
if CategoryMatches("технологии", "политику") {
t.Error("unrelated category matched")
}
// The tag, not the note. The link in a tech headline is not a weather report.
if CategoryMatches("технологии", "погоду") {
t.Error("a tech note matched a weather question")
}
if !CategoryMatches("anything", "") {
t.Error("an empty category must match everything")
}
+7 -3
View File
@@ -72,9 +72,13 @@ type feedDoc struct {
func Parse(r io.Reader) (Feed, error) {
var doc feedDoc
dec := xml.NewDecoder(r)
// Feeds in the wild declare windows-1251 and worse. We only ever read
// UTF-8; a charset we cannot decode is a feed we do not read, which is
// better than mojibake in his notes.
// Strict=false buys tolerance of the malformed markup feeds are full of:
// unclosed tags, stray entities. It has nothing to do with charsets.
//
// Charsets are handled by not handling them: CharsetReader stays nil, so a
// feed declaring windows-1251 fails to parse rather than being read as
// UTF-8. That is the behaviour we want — a charset we cannot decode is a
// feed we do not read, which beats mojibake in his notes.
dec.Strict = false
if err := dec.Decode(&doc); err != nil {
return Feed{}, fmt.Errorf("rss: bad xml: %w", err)
+114
View File
@@ -0,0 +1,114 @@
package rss
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
// undatedFeed — a feed whose items carry no pubDate. Plenty of real ones do not.
func undatedFeed(titles ...string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>u</title>`)
for _, t := range titles {
fmt.Fprintf(&b, `<item><title>%s</title><link>https://example.org/%s</link><guid>%s</guid></item>`, t, t, t)
}
b.WriteString(`</channel></rss>`)
return b.String()
}
// datedFeed — newest first, one hour apart, the standard shape.
func datedFeed(base time.Time, n int) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>d</title>`)
for i := 0; i < n; i++ {
ts := base.Add(-time.Duration(i) * time.Hour)
fmt.Fprintf(&b, `<item><title>item-%d</title><link>https://example.org/%d</link><guid>g%d</guid><pubDate>%s</pubDate></item>`,
i, i, i, ts.Format(time.RFC1123Z))
}
b.WriteString(`</channel></rss>`)
return b.String()
}
func TestPoll_UndatedFeedIsNotReNotedAfterARestart(t *testing.T) {
// The seen-IDs map dies with the process, so before the durable mark was
// consulted every boot re-noted the whole front page, stamped now, on top of
// the recent-notes window. A crash loop made that a flood.
feed := FeedConfig{Name: "u", URL: "https://example.org/rss"}
fetch := &fakeFetch{body: undatedFeed("a", "b", "c")}
marks := newMarks()
notes1 := &fakeNotes{}
p1 := NewPoller([]FeedConfig{feed}, fetch, notes1, marks, nil, nil, Config{})
p1.PollDue(context.Background(), now)
if len(notes1.notes) != 3 {
t.Fatalf("first boot wrote %d notes, want 3", len(notes1.notes))
}
if marks.m["u"].IsZero() {
t.Fatal("an undated feed left no mark, so the next process cannot tell it has been read")
}
// Restart. Same process-lifetime dedup map, gone.
notes2 := &fakeNotes{}
p2 := NewPoller([]FeedConfig{feed}, fetch, notes2, marks, nil, nil, Config{})
p2.PollDue(context.Background(), now.Add(time.Hour))
if len(notes2.notes) != 0 {
t.Fatalf("a restart re-noted %d undated items: %v", len(notes2.notes), notes2.notes)
}
// And the same process still notices something genuinely new.
fetch.body = undatedFeed("a", "b", "c", "d")
p2.PollDue(context.Background(), now.Add(2*time.Hour))
if len(notes2.notes) != 1 {
t.Fatalf("a new undated item after the resync wrote %d notes, want 1", len(notes2.notes))
}
}
func TestPoll_BurstLargerThanMaxItemsIsPacedNotDropped(t *testing.T) {
// max_items reads as pacing in the config doc. Marking the newest item
// written put everything below the cap behind the mark, permanently.
feed := FeedConfig{Name: "d", URL: "https://example.org/rss"}
fetch := &fakeFetch{body: datedFeed(now.Add(-time.Minute), 12)}
notes := &fakeNotes{}
marks := newMarks()
p := NewPoller([]FeedConfig{feed}, fetch, notes, marks, nil, nil, Config{MaxItems: 5, MaxAge: 48 * time.Hour})
at := now
for i := 0; i < 3; i++ {
if _, err := p.PollFeed(context.Background(), feed, at); err != nil {
t.Fatal(err)
}
at = at.Add(time.Hour)
}
seen := map[string]bool{}
for _, n := range notes.notes {
title := strings.SplitN(n.text, "\n", 2)[0]
if seen[title] {
t.Errorf("item %q was noted twice", title)
}
seen[title] = true
}
if len(seen) != 12 {
t.Errorf("after three polls of a 12-item burst she has %d of them; the rest were dropped for good", len(seen))
}
}
func TestNoteHeadlineAndCategory(t *testing.T) {
text := NoteText(FeedConfig{Category: "технологии"}, Item{
Title: "Новая уязвимость", Summary: "Патч вышел", Link: "https://example.org/a",
})
if got := NoteHeadline(text); got != "Новая уязвимость" {
t.Errorf("NoteHeadline = %q; she reads the brackets out loud", got)
}
if got := NoteCategory(text); got != "технологии" {
t.Errorf("NoteCategory = %q, want технологии", got)
}
// A feed's own leading tag stays part of the title.
if got := NoteHeadline("[перевод] Что-то"); got != "[перевод] Что-то" {
t.Errorf("NoteHeadline stripped the feed's own tag: %q", got)
}
if got := NoteCategory("Без категории"); got != "" {
t.Errorf("NoteCategory on an untagged note = %q, want empty", got)
}
}
+135 -22
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"sort"
"strings"
"time"
)
@@ -88,6 +89,7 @@ type Poller struct {
cfg Config
nextDue map[string]time.Time
seen map[string]map[string]bool // feed → item ID, for items with no date
polled map[string]bool // feed → polled at least once in THIS process
}
// NewPoller wires a poller. Returns nil when there is nothing to poll — a
@@ -96,7 +98,9 @@ func NewPoller(feeds []FeedConfig, fetch Fetcher, notes Notes, marks Marks, embe
var valid []FeedConfig
for _, f := range feeds {
if strings.TrimSpace(f.Name) == "" || strings.TrimSpace(f.URL) == "" {
log.Printf("rss: skipping a feed with no name or no url")
// Name the offender. A silent skip in a list of six feeds is a
// config typo nobody finds.
log.Printf("rss: skipping feed %q (%q): a feed needs both a name and a url", f.Name, f.URL)
continue
}
valid = append(valid, f)
@@ -118,6 +122,7 @@ func NewPoller(feeds []FeedConfig, fetch Fetcher, notes Notes, marks Marks, embe
embed: embed, ranker: ranker, cfg: cfg,
nextDue: map[string]time.Time{},
seen: map[string]map[string]bool{},
polled: map[string]bool{},
}
}
@@ -164,14 +169,31 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
return 0, err
}
mark := p.mark(ctx, f.Name, now)
newest := mark
written := 0
mark, durable := p.mark(ctx, f.Name, now)
// resync — the first poll of this feed since the process started, on a feed
// we have read before. Undated items are deduped by an in-memory ID set that
// dies with the process, so on this poll they are all "unseen" again and
// would all be re-noted. See fresh.
resync := durable && !p.polled[f.Name]
p.polled[f.Name] = true
// Gather first, cap second, and write OLDEST first.
//
// The old loop walked the feed newest-first and stopped at MaxItems, then
// marked the newest item it had written. Feeds are newest-first, so with
// twenty new items and a cap of five it wrote the five newest and moved the
// mark past all twenty: items six through twenty were older than the mark on
// the next poll and were dropped for good. max_items reads as a pacing knob
// in the config doc, and that made it a silent loss. Writing the oldest five
// and marking the newest of THOSE is pacing: the rest arrive over the polls
// that follow, in order, each one exactly once.
var cands []Item
sawUndated := false
for _, it := range feed.Items {
if written >= p.cfg.MaxItems {
break
if it.Published.IsZero() {
sawUndated = true
}
if !p.fresh(f, it, mark, now) {
if !p.fresh(f, it, mark, now, resync) {
continue
}
if !Matches(f, it) {
@@ -185,6 +207,18 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
continue
}
}
cands = append(cands, it)
}
sort.SliceStable(cands, func(i, j int) bool {
return itemTime(cands[i], now).Before(itemTime(cands[j], now))
})
if len(cands) > p.cfg.MaxItems {
cands = cands[:p.cfg.MaxItems]
}
newest := time.Time{}
written := 0
for _, it := range cands {
if err := p.write(ctx, f, it, now); err != nil {
return written, err
}
@@ -193,33 +227,71 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
newest = it.Published
}
}
if p.marks != nil && newest.After(mark) {
if err := p.marks.SetMark(ctx, f.Name, newest); err != nil {
log.Printf("rss: feed %s: save mark: %v", f.Name, err)
}
}
p.advance(ctx, f.Name, mark, newest, sawUndated, now)
return written, nil
}
// mark — how far this feed was read. A feed with no mark starts MaxAge ago, so
// a first poll takes today's headlines instead of the whole archive.
func (p *Poller) mark(ctx context.Context, feed string, now time.Time) time.Time {
// itemTime — an item's own date, or now when the feed did not give one. Undated
// items sort last, which is the only defensible guess: they were seen now.
func itemTime(it Item, now time.Time) time.Time {
if it.Published.IsZero() {
return now
}
return it.Published
}
// advance moves the durable mark to the newest item actually WRITTEN. Because
// the cap is applied to the oldest candidates (see PollFeed), that is never
// ahead of an item still waiting to be read.
//
// A feed whose items carry no dates gets the mark set to now instead. Nothing
// else would ever set it, and the mark's existence is what tells the next
// process that this feed has been read before.
func (p *Poller) advance(ctx context.Context, feed string, mark, newest time.Time, sawUndated bool, now time.Time) {
if p.marks == nil {
return
}
at := newest
if at.IsZero() && sawUndated {
at = now
}
if at.IsZero() || !at.After(mark) {
return
}
if err := p.marks.SetMark(ctx, feed, at); err != nil {
log.Printf("rss: feed %s: save mark: %v", feed, err)
}
}
// mark — how far this feed was read, and whether that came from the durable
// store. A feed with no mark starts MaxAge ago, so a first poll takes today's
// headlines instead of the whole archive; durable is false in that case, and it
// is what tells PollFeed the difference between "never read" and "read by an
// earlier process".
func (p *Poller) mark(ctx context.Context, feed string, now time.Time) (time.Time, bool) {
cold := now.Add(-p.cfg.MaxAge)
if p.marks == nil {
return cold
return cold, false
}
at, err := p.marks.LastMark(ctx, feed)
if err != nil || at.IsZero() {
return cold
return cold, false
}
return at
return at, true
}
// fresh — two dedup rules, because feeds are inconsistent about dates. A dated
// item must be newer than the mark; an undated one is kept once per process by
// ID. Both are needed: dates alone re-import undated feeds forever, IDs alone
// lose their memory on restart.
func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time) bool {
// ID.
//
// The ID set does not survive a restart, and on its own that re-notes an undated
// feed's whole front page on every boot — five notes, then five more, all stamped
// `now`, sitting at the top of the recent-notes window and crowding out the notes
// he actually made. A crash loop turns it into a flood. So on the first poll after
// a restart of a feed we have read before (resync), undated items are recorded as
// seen and NOT written. The cost is the undated items that appeared while the
// daemon was down. That is a bounded loss, and the alternative is an unbounded one.
func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time, resync bool) bool {
if !it.Published.IsZero() {
if !it.Published.After(mark) {
return false
@@ -239,7 +311,7 @@ func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time) bool {
return false
}
p.seen[f.Name][id] = true
return true
return !resync
}
// write stores one item as a note. Source "rss:<feed>" is what the answer path
@@ -291,6 +363,47 @@ func NoteText(f FeedConfig, it Item) string {
return b.String()
}
// NoteHeadline is the part of a feed note she reads out: the first line with the
// category tag taken off. The tag is bookkeeping for the answer path, and piper
// says brackets out loud — "Заголовок [технологии]" is what he heard before.
func NoteHeadline(text string) string {
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if head, _, ok := splitTag(line); ok {
return head
}
return line
}
// NoteCategory is the category tag a feed note carries, empty when it has none.
// Matching a topic against THIS rather than against the whole note is what keeps
// "что нового про погоду" from matching a tech headline whose link happens to
// contain "pogod".
func NoteCategory(text string) string {
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
_, tag, _ := splitTag(strings.TrimSpace(line))
return tag
}
// splitTag pulls a trailing "[...]" off a headline. Only a trailing one: a title
// that opens with "[перевод]" is the feed's own word, not ours.
func splitTag(line string) (head, tag string, ok bool) {
if !strings.HasSuffix(line, "]") {
return line, "", false
}
i := strings.LastIndexByte(line, '[')
if i < 0 {
return line, "", false
}
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1 : len(line)-1]), true
}
// trimRunes cuts on a rune boundary — a note is Russian as often as English and
// half a cyrillic letter is a broken note.
func trimRunes(s string, max int) string {