Post-type filters, in-article voting, and visible model training - #133
Merged
Conversation
Four changes to the feed UI and what sits behind it. Post type filter. The "Post type" dropdown (all / text only / with media) becomes a set of checkboxes: image, video, link, text. The types overlap on purpose — an illustrated article answers to both "image" and "text" — so ticking boxes widens the view rather than slicing it up, and every item matches at least one type. `/feed/items` takes `post_types` in place of `text_only`, and clearing every box now shows a "nothing matches these filters" empty state with a way out, instead of the "add a source" prompt meant for a genuinely empty feed. Video playback. Two real bugs in the extraction sidecar's rendition picker: it dropped every format whose codecs the extractor didn't state (many sites state none, so nothing was playable at all), and it rejected HLS outright, which is all an increasing number of sites publish. It now keeps formats with unstated codecs while preferring ones that state both tracks, falls back to the HLS playlist when there is no progressive file, and says which it handed back. The player loads HLS through a vendored hls.js (light build, fetched only when a stream actually needs it) and natively on Safari. Alongside that, the media frame now steps down instead of breaking: a dead thumbnail falls back to the item's own picture, then to a one-line play strip, and a stream that cannot be resolved or played leaves just the title and a link to the page — never a dead play button on an empty grey box. Broken images and videos elsewhere in a card take their frame with them. Voting from the open article. The three vote buttons now sit at the foot of the reader, so an article can be voted on without closing it first. The card and the reader share one row and are repainted together, and voting from the reader collapses the card behind it as voting from the feed does. Model training. Retraining moves to a background thread: `/feed/rerank` starts a run and returns, and a new `/feed/training_status` reports where it has got to. The panel (renamed "Model Training") shows when the models were last built, which model they picked, how many votes they learned from, and how many votes have been cast since; a run in flight gets a real progress bar — one step per model, then the fit and the scoring pass — with the model currently being trained. Leaving the panel does not stop anything: a dot on the toolbar button stays lit until the run ends, and scheduled retrains show up the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5iKkj3ZLtHGTc8bz6R4yx
mullinmax
marked this pull request as ready for review
September 5, 2026 17:17
Follow-up on two things reported against the branch. Training was reading the whole feed before it did anything. Every article carries a full embedding vector, so a feed of a few thousand spent seconds (on a remote database, much longer) pulling rows that cross-validation never looks at: it learns from the labeled ones, which are a few dozen. It now loads only the articles with a vote or a list membership, and reads the rest only once a winner exists to score them with — a feed with too few votes to rank never pays that cost at all. Saving the winner's scores was one UPDATE per article, so a round trip per article. It is now batched through execute_values, which is a handful of statements instead of thousands. Random forests fit their trees across every core (n_jobs=-1). This is joblib parallelism over independent trees, not BLAS threading, so it does not fight the thread pinning the container sets, and the predictions are identical: ~13s to ~4s per cross-validation pass on four cores. On a feed of 8,000 articles with 150 votes, a full run goes from ~30s to ~19s, and the part before any progress could be reported goes from ~7s to almost nothing. The panel now narrates the run rather than showing a step number that sat still for half a minute. Progress carries a phase (reading your votes, testing models, training the winner, scoring the feed), the model in hand, and a note for what is happening inside the step — "fold 3 of 5", "scoring 8000 articles" — and the step is fractional, so the bar advances fold by fold through the slow models instead of freezing between them. It also shows how long the run has been going. Resolving a video for playback no longer shares the ingest budget. A listing walk can afford 180s and several retries; a viewer waiting on a spinner cannot, and a request that outlives the reverse proxy comes back as an opaque gateway error that says nothing to anyone. The resolve gets 40s at the API, one retry and a 10s socket timeout in the extractor, and its failures are now distinguished: 504 when the site is too slow, 502 when the service cannot be reached, 422 when the item simply has nothing playable, 501 when no extractor is configured. Each is logged with the item and how long it took, and the card shows the reason instead of a blanket "can't play here" — except for a gateway error, which carries no message of its own and keeps the generic wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5iKkj3ZLtHGTc8bz6R4yx
Ingest talks to the open web every fifteen seconds, so most of what goes wrong is ordinary: a site is down, a bridge is misconfigured, a host is rate limiting, a feed answers with nothing. Every one of those printed a full traceback, which buries the traces that do mean a bug — and identified the source by its hash, which names nothing to anyone. Backends and scrapers now raise IngestError for a failure they can explain. The job logs those as one warning naming the source and its URL, and keeps the traceback for anything else, because anything else got there without being explained and is a bug. The reason is still stored on the source and shown beside it in the UI, as before. The per-item scrapers got the same treatment. A page that refuses us, times out or has gone away is the ordinary case for open graph and the content extractor, and it happens once per article — a stack trace each time meant one dead site could bury a whole ingest cycle. An empty feed from reddit now says so: reddit answers a throttled request with a valid but empty feed rather than an error, and "Feed returned no entries" on a subreddit that plainly has posts sends you looking in the wrong place. APScheduler's own commentary is quieted to warnings. It narrates every job it starts and finishes, and with the ingest queue on a fifteen-second interval that is four lines a minute saying only that the scheduler is running, while the jobs already log what they actually did. Its warnings — an overrun, a missed run — still come through. A failed video resolve is logged at warning rather than info: someone pressed play and got nothing, which is what you go to the log to find. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5iKkj3ZLtHGTc8bz6R4yx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four changes to the feed UI and what sits behind it, plus a follow-up commit answering what testing the branch turned up.
Post type filter
The "Post type" dropdown (all / text only / with media) becomes a set of checkboxes: Image, Video, Link, Text.
The types overlap on purpose - an illustrated article answers to both "image" and "text" - so ticking boxes widens the view rather than slicing it up, and every item matches at least one type (an item with no picture, no video and no body of its own is a "link").
/feed/itemstakes apost_typesquery parameter in place oftext_only; unknown types are rejected with a 422.Clearing every box now shows a "no articles match these filters" empty state with a Clear filters button, instead of the "add a source" prompt meant for a genuinely empty feed.
Video playback
Two real bugs in the extraction sidecar's rendition picker:
"none"for a track a format genuinely lacks and leaves the field unset when the extractor simply did not say - reading "unset" as "missing" threw away every format on the many sites that report no codec information at all, so nothing was playable there.The picker now keeps formats with unstated codecs while preferring ones that state both tracks (a stated rendition beats a taller one that might be silent), falls back to the HLS playlist when there is no progressive file, and reports
is_hlsso the client knows what it was handed. The player loads HLS through a vendoredhls.js(light build, ~376 KB, fetched only when a stream actually needs it) and natively on Safari/iOS.Alongside that, the media frame steps down instead of breaking:
playable_urlmoves intosrc/ytdlp/formats.py, a plain function of the info dict with no yt-dlp or FastAPI imports, and gets a unit test file that a new CI job runs.Follow-up: resolving a video for playback no longer shares the ingest budget. A listing walk can afford 180s and several retries; a viewer waiting on a spinner cannot, and a request that outlives the reverse proxy comes back as an opaque gateway error that tells nobody anything. The resolve now gets 40s at the API, one retry and a 10s socket timeout in the extractor, and its failures are distinguished — 504 the site was too slow, 502 the service could not be reached, 422 nothing playable, 501 no extractor configured. Each is logged with the item and how long it took, and the card shows the reason rather than a blanket message (a gateway error carries no message of its own, so that one keeps the generic wording).
Voting from the open article
The three vote buttons now sit at the foot of the reader, so an article can be voted on without closing it first. The card and the reader share one vote row and are repainted together, and voting from the reader collapses the card behind it exactly as voting from the feed does. The reader stays open so you can finish reading.
Model training
Retraining moves to a background thread:
POST /feed/rerankstarts a run and returns the state it started from, and a newGET /feed/training_statusreports where the run has got to. Progress lives in an in-memory registry (ranking/progress.py) keyed by user and feed, so the scheduled ranking job reports through the same path as a requested retrain.The panel (renamed Model Training) shows when the models were last built (exact timestamp on hover), which model was chosen and how many votes it learned from, how many votes have been cast since — and, for a run in flight, a progress bar with the model currently being trained. Leaving the panel does not stop anything: a dot on the toolbar button stays lit until the run ends, the feed reloads itself when the new ranking lands, and a scheduled retrain lights the same indicator when a feed is opened.
Follow-up — training was doing far more work than it needed to. Every article carries a full embedding vector, and a run read the whole feed before doing anything, to learn from the few dozen labeled rows. That read is also why the progress bar appeared stuck on its first step: nothing could be reported until it finished.
UPDATEper article — a round trip each. It is now batched throughexecute_values.n_jobs=-1). This is joblib parallelism over independent trees, not BLAS threading, so it does not fight the thread pinning the container sets; predictions are identical.Measured on a feed of 8,000 articles with 150 votes: a full run goes from ~30s to ~19s, and the stretch before any progress could be reported goes from ~6.7s to ~0.05s.
Progress reporting got the detail to match: a phase (reading your votes / testing models / training the winner / scoring the feed), the model in hand, a note for what is happening inside the step ("fold 3 of 5", "scoring 8000 articles"), elapsed seconds, and a fractional step so the bar advances fold by fold through the slow models instead of freezing between them. Longest observed gap between updates is now ~2.4s.
Testing
pytest testsinsrc/api: 576 passed. The 4 failures that remain locally (source_templatex3,admin::test_get_version) fail identically onmainin that environment — they need a live rss-bridge andBUILD_VERSION, and the CItestjob is green.🤖 Generated with Claude Code
https://claude.ai/code/session_01E5iKkj3ZLtHGTc8bz6R4yx