Task 5: demand-aware ingestion funnel - #678
Conversation
Make the crawler's expensive LLM simplification demand-driven instead of supply-driven. Previously simplification ran until a static per-language cap (50/day/topic for da/fr/de, 20 otherwise) and then stopped — so dormant languages were simplified at the same rate as busy ones and first-in-feed-order won the quota. New zeeguu/core/content_retriever/funnel.py: - compute_demand_surface(): the distinct (language, topic) buckets active readers (last_seen <= 30d) actually want, deduped across the cohort; a reader with no topic subscription lends demand to the whole language. - FunnelBudget: per-(language, topic) quota that scales 20->50 with reader count, plus a pilot-light floor (<=5 distinct topics/day) for dormant *supported* languages so a new learner / the kiosk never hits an empty feed. - Pre-download title triage: skip a feed entirely when its language is already satisfied for the day, or keep only the best-N titles (cheap LLM ranker with a safe first-N fallback) when headroom is limited. article_downloader.py + crawl.py thread a FunnelBudget instead of the old static-cap counts dict. Legacy download_from_feed callers pass no budget, so their behaviour is unchanged. No-topic articles are now simplified only when the language has all-topic readers (old code always simplified them). Promotion-on-join (backfill.py): subscribing to a topic or switching learned language fires a background backfill that simplifies a few EXISTING recent raw articles for the newly-demanded bucket (dormant buckets already hold raw, un-simplified articles — the crawl stores them, only skips the LLM), so the reader doesn't wait for the next crawl. No-ops if the bucket already has fresh inventory. Wired into the subscribe and learned-language endpoints. 31 tests in zeeguu/core/test/test_funnel.py (in-memory SQLite). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…reshness
Three correctness bugs from code review of the demand-aware funnel:
1. Pilot-light floor re-fired on every intra-day crawl. FunnelBudget seeded
today's demand counts but not floor_topics_used, so each hourly crawl process
re-floored a fresh set of ~5 topics for a dormant language — ~5*N/day instead
of ~5. FunnelBudget now derives floor_topics_used from today's counts for
languages with no demand (correct by construction for any caller).
2. _apply_title_triage claimed "never raises" but had no try/except, so a
titleless RSS item (or any error) aborted the entire feed instead of falling
back to the full list. Wrapped the body; also use feed_item.get("title", "").
3. backfill freshness/recency keyed off published_time, which simplified
children inherit from the (backdatable) parent — so the "already stocked"
guard under-counted and the candidate query missed recently-crawled but
old-dated raw articles. Both now filter/order on crawled_at (ingestion time).
+5 regression tests (36 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Closing — superseded rather than rejected. Two things changed under it since 15 Jul. 1. The pipeline moved. #698 made simplification on-demand, so the expensive crawl-time step is no longer 2. The budget has a cliff.
So it would bite on 55% of volume (including German, the single biggest line item) and do nothing for the other 45% — and one user clearing their topic subscriptions silently flips a language back to unbounded, with no signal. What replaces it. A fill-to-target model: for each of the 72 currently demanded (language, topic) buckets, ensure ~3 fresh articles that the bucket's active readers haven't seen; assess only the deficit. That is ~216/day against ~1,324 today (~84%), it is self-limiting (a quiet day costs nothing, where a quota always spends its allowance), and "reader with no topics" becomes all 8 topics need filling rather than infinity — the cliff disappears instead of being patched. Worth lifting out of this PR when that lands:
Thanks — the demand-surface framing is what made the replacement obvious. |



Makes the crawler's expensive LLM simplification demand-driven instead of supply-driven, and gives newly-demanded buckets an immediate head start. All 6 phases of the Task 5 design.
Problem
Simplification ran until a static per-language cap (50/day/topic for da/fr/de, 20 otherwise), then stopped. So dormant languages were simplified at the same rate as busy ones, and whichever articles appeared first in feed order won the quota (first-N, not best-N). Evidence from the week-28 report: ~370 crawled per 1 opened.
What changed
New
zeeguu/core/content_retriever/funnel.pycompute_demand_surface()— the distinct(language, topic)buckets that active readers (last_seen ≤ 30d) actually want, deduped across the cohort. A reader with no topic subscription reads the whole language, so they lend demand to every topic in it.FunnelBudget— per-(language, topic)quota that scales 20→50 with reader count, plus a pilot-light floor (≤5 distinct topics/day) for dormant supported languages so a brand-new learner (or the Romanian kiosk) never opens an empty feed.article_downloader.py+crawl.pythread aFunnelBudgetinstead of the old static-cap counts dict. Legacydownload_from_feedcallers pass no budget → behaviour unchanged. No-topic articles are now simplified only when the language has all-topic readers (old code always simplified them = pure waste).Promotion-on-join —
backfill.pySubscribing to a topic or switching learned language fires a background backfill that simplifies a few existing recent raw articles for the newly-demanded bucket. Dormant buckets already hold raw, un-simplified articles (the crawl stores them, only skips the LLM), so this is fast and download-free — no mini-crawl. No-ops if the bucket already has fresh inventory. Wired into the subscribe and learned-language endpoints via
run_in_background.Phase mapping
/simplify_article= the tail)Tests
31 tests in
zeeguu/core/test/test_funnel.py(in-memory SQLite): quota scaling, demand surface, gate/floor/no-topic paths, headroom, triage selection + fallback, backfill inventory queries. Regression-checkedtest_retrieve_and_compute/test_article/test_feed/test_endpoint_names.Reviewer notes / conscious choices
last_seen ≤ 30d(chosen over open-based). Implication: German (many recently-seen users) stays "in demand" — this does not reproduce the open-based "German → 0" reduction; topic-dedup and dormant-language savings still apply. The definition is isolated in one function (_active_reader_rows) for an easy switch later.crawl_stats.pydashboard still displays the now-superseded static "cap" (legacy constants kept alive only for it) — follow-up.🤖 Generated with Claude Code