Architecture
Services
Service |
Role |
|---|---|
|
Primary data store (PostgreSQL 17 + pgvector) |
|
Celery broker and result backend |
|
FastAPI REST API |
|
|
|
|
|
|
|
Cron scheduler; schedule persisted to a named volume so it survives restarts |
|
React app served by Nginx, proxies |
An optional docker-compose.ollama.yml runs Ollama itself in Docker, on a
shared external network.
Data flow
Fetch (
fetch_all_sources, every 5 minutes): sources with identical(source_type, config)— e.g. the same public RSS feed added by two different users — are fetched once and fanned out to every subscriber, so one HTTP request can serve N users. New items are deduplicated by canonical-URL hash and content hash, then run through a fast title-similarity clustering pass. Clustered items dispatchprocess_cluster; standalone items dispatchprocess_news_item.Process (LLM analysis, dispatched per-item/cluster above, plus a
batch_process_unprocessedsweep every 15 minutes as a safety net): writesabstract, categories, extracted keywords, an embedding vector, and relevance/impact scores back to the item or cluster. See LLM Providers for the two-stage/batch/newsletter logic, and Clustering for the second clustering pass that runs after processing.Batch polling (every 2 minutes): checks in on any in-flight Anthropic Batch API jobs.
Weight decay (daily): learned category/keyword weights decay multiplicatively over time — see Learning & Scoring.
Cleanup (daily): deletes non-relevant, non-read-later items older than 30 days, then removes any clusters left with zero items.
Podcast dispatch (every 15 minutes): a per-user, timezone-aware scheduler checks every active podcast show and dispatches generation for any whose local
schedule_timehas just passed and that hasn’t already produced an episode today. Generation selects the show’s top stories (reusing the same ranking as the Relevant feed tab, filtered by the show’s time window), asks the LLM for a multi-host conversational script, synthesizes each host’s lines with a pluggable TTS provider (Piper by default — self-hosted, no cloud service), and assembles the result into one MP3 via ffmpeg. A separate daily job deletes episodes (and their audio files) older than 30 days.
Backend layout (backend/app/)
main.py— FastAPI app; mounts routers under/apiconfig.py— all settings, loaded from.env(see Configuration)models/— SQLAlchemy modelsschemas/— Pydantic request/response DTOsapi/— route handlers (news,sources,categories,settings,auth,clusters,stats,tabs,push,learning,tokens,podcasts)services/llm/— pluggable provider factory + fallback wrapper, plus Anthropic Batch API handling and podcast script generationfetchers/— one module per source type, registered via a@register_fetcher("type")decorator (see Sources)clustering.py,embedding.py— see Clusteringscoring.py,keyword_clustering.py— see Learning & Scoringfeed_ranking.py— the Relevant/Impact/Newest ranking used by both the feed API and podcast episode selectiondeduplication.py— canonical URL hashing and content hashingpush_service.py— Web Push notificationstts/— pluggable text-to-speech provider factory (piper_provider.pyis the self-hosted default) plus ffmpeg-based audio assemblypodcast_script.py— episode item selection and LLM script generationpodcast_scheduling.py— the per-user timezone due-window logic behind podcast dispatchrange_streaming.py— HTTP Range (206 Partial Content) support for episode audio playback/seeking
tasks/—celery_app.py(app + full beat schedule),fetch_tasks.py,process_tasks.py,podcast_tasks.py
Frontend layout (frontend/src/)
App.tsx— routes:/login,/(feed),/podcasts,/settingsapi/— Axios client + typed endpoint wrappersstores/— Zustand (filter state, theme/locale preferences)hooks/— TanStack Query hooks for server statecomponents/—layout/,feed/,settings/i18n/— 20+ languages viareact-i18next
Key design decisions
Source dedup + fan-out. Identical sources across users are fetched once per cycle. When a fanned-out item already has LLM results from another user’s copy of the same URL, those results are reused instead of reprocessing — same abstract, keywords, category mapping.
Two-stage LLM processing. A cheap classify-only pass gates an expensive abstract-generating pass, so tokens aren’t spent summarizing articles that don’t match any of a user’s categories. See LLM Providers.
Non-root containers. Backend/Celery run as UID 1000. If a named volume (e.g.
celerybeat-data) already exists owned by root from an older setup, remove it:docker volume rm shoebill_feed_celerybeat-data.Podcast character vs. voice. A host’s free-text character prompt only shapes how the LLM writes that host’s lines — Piper has no emotion/delivery control, so voice timbre is just a mechanically assigned speaker. Two hosts sharing a language’s only available voice will sound identical despite very different character prompts.
Per-user timezone scheduling. Podcast dispatch is the first feature needing per-user, timezone-aware timing — every other Beat schedule is a fixed global UTC crontab. Beat entries are static, so the per-user variability lives in
due_shows()(a frequent fixed-UTC tick that asks “is this show due right now, in its own timezone”), not in Beat config.