Skip to content

Media Signal Refinery

The Media Signal Refinery is silicon-zack's personal content intelligence system. It pulls from a firehose of sources (Hacker News, RSS, X, podcasts, and ad-hoc URL submissions), normalizes and deduplicates everything into append-only daily logs, then runs a tiered cascade of LLM passes β€” cheap triage scoring, summarization, deep analysis, and a daily cross-item synthesis β€” escalating spend only on items worth it. Feedback (upvote / save / dismiss) trains an interest profile that feeds back into scoring, and the highest-signal items are pushed to Telegram as alerts.

Framing: silicon-zack is supposed to know what bio-zack cares about and surface it without being asked. The refinery is the engine for "continuous lightweight curation across consumption domains" β€” background ingestion of everything, retrievable on demand, ranked by a profile that sharpens with every vote.

Pipeline at a Glance

FETCH (source adapters, per-source scheduler loops)
  Hacker News (Firebase) | RSS (feedparser) | X (browser scrape) | Podcasts (OPML)  | User-submitted URLs
        β”‚                        β”‚                    β”‚                  β”‚                    β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                              β–Ό
                          NORMALIZE + DEDUP   (normalize.py)
                          - URL canonicalization, fingerprint, cross-source dedup
                          - append β†’ data/media/items/YYYY-MM-DD.jsonl
                                              β–Ό
                          PASS 1 Β· TRIAGE     (triage.py)   batch of 10 β†’ cheap LLM
                          - score 0-100 against interest profile
                          - append β†’ data/media/scores/YYYY-MM-DD.jsonl
                                              β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ score β‰₯ summary_score (70) ────────────────┐
                  β–Ό                                                             β”‚
   PASS 2 Β· SUMMARIZE (summarize.py)   one item / call                         β”‚
   - Trafilatura content extraction β†’ bullets + why_it_matters + tags          β”‚
   - append β†’ data/media/summaries/YYYY-MM-DD.jsonl                            β”‚
                  β–Ό                                                             β”‚
   PASS 3 Β· DEEP ANALYSIS (deep_analysis.py)  score β‰₯ 85, 30-min loop          β”‚
   - extended summary, key quotes, action items, goal relevance, connections   β”‚
   - append β†’ data/media/deep/YYYY-MM-DD.jsonl                                 β”‚
                  β–Ό                                                             β”‚
   PASS 4 Β· SYNTHESIS (synthesis.py)   5am PT daily                            β”‚
   - themes, narrative briefing, consolidated action items, goal connections   β”‚
   - write β†’ data/media/synthesis/YYYY-MM-DD.json                             β”‚
                  β–Ό                                                             β–Ό
   ALERTS (alerts.py)  score β‰₯ alert_score (85)              REVIEW QUEUE  (GET /media/queue)
   β†’ Telegram via proactive outreach                         + feedback (POST /media/feedback)
                                                                            β–Ό
                                              INTEREST PROFILE  (interest.py, time-decayed)
                                              └────────► feeds back into PASS 1 triage

Why This Shape

Decision Rationale
LLM triage over keyword rules Rules can't read context. An LLM holding the interest profile understands that "AI in a medical trial" and "AI startup funding" are different signals to different people. The classifier improves as the profile grows.
Tiered escalation by score Cheap model triages everything; a mid-tier model summarizes only items above the summary threshold; a deeper pass runs on the top sliver. Spend tracks value instead of volume.
Append-only JSONL per day Each stage writes one file per UTC day. Trivial to inspect, back up, diff, and replay. Days are natural partitions for time-windowed queries. No central DB to migrate (the only SQLite is the user-submission queue, which needs ACID).
Feedback β†’ profile β†’ triage Every vote/save adjusts topic and source weights; the profile is the only "configuration" the scorer needs, and it's learned, not hand-written.
Hard budget caps LLM cost can run away. Daily/monthly caps and per-task budgets pause work silently rather than billing surprises.

How the Pipeline Actually Runs

There is no cron. The refinery runs as in-process asyncio loops inside the API server (./run api, port 33800). Loops are declared once in api/src/loop_engine/registry.py, grouped, and started by api/src/scheduler_task_groups.py (start_media_tasks, start_operations_tasks). Each loop method lives on VitaScheduler in api/src/scheduler.py; the interval constants are class attributes there.

Loop method Task name Group Cadence What it does
_media_hn_loop media_hn media 5 min (MEDIA_HN_INTERVAL) Fetch + score Hacker News
_media_rss_loop media_rss media 30 min (MEDIA_RSS_INTERVAL) Fetch + score RSS feeds
_media_podcast_loop media_podcast media 4 h (MEDIA_PODCAST_INTERVAL) Fetch recent episodes
_podcast_curation_loop podcast_curation media 12 h (PODCAST_CURATION_INTERVAL) Curate the Pocket Casts Up Next queue
_listened_digest_loop listened_digest media 2 h (LISTENED_DIGEST_INTERVAL) Bridge actual listening history into the transcript pipeline
_media_x_loop media_x media 6 h, 8am–10pm PT (MEDIA_X_INTERVAL) Process browser-scraped X items
_media_bookmarks_loop media_bookmarks media 12 h, 8am–10pm PT (MEDIA_BOOKMARKS_INTERVAL) Fresh Camofox bookmark scrape + analysis; alert on auth/fetch failure
_deep_analysis_loop deep_analysis operations 30 min (DEEP_ANALYSIS_INTERVAL) Pass 3 on top-scoring items
_synthesis_loop synthesis operations 5am PT daily Pass 4 daily synthesis
_user_submit_loop user_submit β€” 60 s (USER_SUBMIT_INTERVAL) Drain the URL-submission queue
_x_content_gen_loop x_content_gen operations Sunday 10am PT (related) generate X drafts

Note: HN polls every 5 minutes, not 15. Earlier docs and the YAML's poll_interval_minutes: 15 are misleading β€” the live cadence is the MEDIA_HN_INTERVAL = 5 * 60 constant in api/src/scheduler.py, confirmed by the registry label "Every 5 min". The YAML poll_interval_minutes value is not the source of truth for the HN loop period.

You can also run any stage by hand:

# Full pipeline (fetch β†’ score β†’ summarize β†’ transcribe β†’ podcast-summary)
uv run python scripts/media/run_pipeline.py

# Single stage / single source
uv run python scripts/media/run_pipeline.py --fetch --source hn
uv run python scripts/media/run_pipeline.py --score
uv run python scripts/media/run_pipeline.py --summarize

# Standalone passes
uv run python scripts/media/deep_analysis.py    # Pass 3 structure/self-test
uv run python scripts/media/synthesis.py        # Pass 4
uv run python scripts/media/alerts.py           # send pending alerts
uv run python scripts/media/bookmarks.py        # X bookmark ingestion

run_pipeline.py itself covers fetch / score / summarize / transcribe / podcast-summary; Pass 3 (deep_analysis) and Pass 4 (synthesis) are separate scheduler loops, not steps inside run_pipeline.py.

Source Adapters

Location: scripts/media/sources/

Each adapter emits a common NormalizedItem (see below). Fetch concurrency is guarded by per-source lock files (data/media/.refresh-{source}.lock) with a 10-minute staleness reset, so a manual refresh and a scheduled poll can't double-fetch.

Source Adapter Mechanism Cadence
Hacker News sources/hn.py Firebase topstories.json + per-item fetch; tracks last_seen_id in health.json 5 min
RSS sources/rss.py feedparser with conditional GET (etag / last-modified per feed) 30 min
X / Twitter feed sources/x_browser.py Passive helper β€” save_scraped_tweets() / parse_scraped_tweet() driven by /sync-x; the general feed adapter does not scrape autonomously. manual
X bookmarks scripts/browser/x.py + media/bookmarks.py Dedicated Camofox scrape, then dedup/analyze/theme/Forge pipeline. This is separate from the general X source adapter. 12 h, 8am–10pm PT
Podcasts sources/podcasts.py OPML parse + per-feed RSS episode fetch (max_age_days, max_episodes_per_feed) 4 h
User submit sources/user_submit.py Content extraction for queued URLs on demand

Important distinction: the general X feed remains browser/manual-only; config.sources.x.mode is "browser" and /sync-x populates that adapter. Curated bookmarks have their own automated 12-hour Camofox loop and do not imply that the home-feed adapter is automated.

NormalizedItem & deduplication

Location: scripts/media/normalize.py

@dataclass
class NormalizedItem:
    id: str           # source-specific id
    source: str       # "hn" | "rss" | "x" | "podcast"
    url: str          # canonical URL
    title: str
    content: str      # body text if available
    author: str
    timestamp: str    # ISO
    raw: dict         # source-specific metadata (e.g. external_url, hn score)
    fingerprint: str  # content hash for dedup

URL normalization: lowercase scheme + host, strip www., remove tracking params (utm_*, fbclid, …), sort remaining query params, drop fragments. The Deduplicator tracks seen URLs and fingerprints in health.json and dedups across sources (the same link arriving from HN and an RSS feed collapses to one item).

Pass 1 β€” Triage Scoring

Location: scripts/media/triage.py

Unscored items are pulled in batches of 10 (TriageScorer(batch_size=10)) and scored 0–100 by a cheap model. The interest profile is injected into the system prompt, so scoring is personalized. A budget check runs before each batch β€” if the daily cap is hit, triage stops and items accumulate unscored until the next UTC day.

The scorer returns a structured block per item:

ITEM_ID: <id>
SCORE: <0-100>
REASON: <one sentence>
Score band Meaning Downstream
90–100 act-on / deep-engage summarize + deep analysis + alert
70–89 worth reading summarize
50–69 skim-worthy stored only
0–49 low signal stored only

Results append to data/media/scores/YYYY-MM-DD.jsonl.

Pass 2 β€” Summarization

Location: scripts/media/summarize.py

Items at or above summary_score (default 70) are summarized. Content is extracted via Trafilatura (ContentExtractor, with an httpx fallback). Summaries are produced one item per LLM call β€” summarize_item() iterated by summarize_high_scoring(). There is no batching here (only Pass 1 batches).

{
  "bullets": ["Key point 1", "Key point 2", "Key point 3"],
  "why_it_matters": "One sentence connecting it to the reader's interests",
  "tags": ["topic-a", "topic-b", "tutorial"]
}

Stored at data/media/summaries/YYYY-MM-DD.jsonl.

Pass 3 β€” Deep Analysis

Location: scripts/media/deep_analysis.py

The deployed deep pass. Runs on a 30-minute scheduler loop over items scoring β‰₯ 85 (DeepAnalyzer(score_threshold=85)), capped at max_items per run, that haven't already been analyzed for the day. It re-extracts full article text (up to 8K chars), loads active goals from goals/active/, and gives the model the day's other high-scoring titles so it can find cross-item connections.

@dataclass
class DeepAnalysisRecord:
    item_id: str
    title: str
    extended_summary: str       # 200-400 word analysis
    key_quotes: list[str]       # 3-5 quotes
    action_items: list[str]     # what to do / watch
    goal_relevance: list[dict]  # [{goal_id, goal_name, relevance_note}]
    connections: list[str]      # links to other items / trends
    tags_enriched: list[str]    # granular tags
    model: str
    analyzed_at: str
    ...

Records append to data/media/deep/YYYY-MM-DD.jsonl.

Note β€” the deprecated deep dive. scripts/media/deep_dive.py is the never-deployed original premium-model pass (it used a Claude-tier model at much higher cost). It is superseded by deep_analysis.py and is dead code; its docstring says so. Do not treat deep_dive.py, the deep_dive_score threshold (90), or the media_deep_dive aloop mode as the live deep path β€” they describe the retired design. The live route is the deep_analysis aloop mode at threshold 85.

Pass 4 β€” Daily Synthesis

Location: scripts/media/synthesis.py

Once a day (5am PT, before the daily brief) synthesis reads the day's deep-analysis records and summaries and produces a single cross-item briefing.

@dataclass
class DailySynthesis:
    date: str
    themes: list[dict]            # [{theme, items, description}]
    narrative: str                # 3-5 paragraph briefing
    top_items: list[dict]         # ordered by signal
    action_items: list[str]       # consolidated across the day
    goal_connections: list[dict]  # [{goal_id, items, insight}]
    model: str
    synthesized_at: str
    ...

Written to data/media/synthesis/YYYY-MM-DD.json. The synthesis feeds into the "Media Intel" section of the daily brief (see VITA Daily Brief).

Alerts

Location: scripts/media/alerts.py

Items at or above alert_score (default 85) that haven't already been alerted are sent to Telegram through the proactive outreach queue (enqueue_message, trigger media_signal). The manager records sent URLs per day so the same item isn't re-alerted, and exposes send_pending_alerts() / get_alert_stats(). This is the "Alert" branch of the cascade. See Proactive Outreach.

User URL Submissions

Location: scripts/media/user_submit.py (queue logic), api/src/services/scheduler_user_submit.py (scheduler integration)

Submitted URLs go through a SQLite queue (data/media/submissions.db) for ACID guarantees, drained by the _user_submit_loop every 60 seconds (small batches, limit=3).

  1. Queue β€” URL normalized (tracking params stripped, www. removed), stored pending.
  2. Dedup β€” checked against existing submissions by normalized URL.
  3. Fetch β€” content extracted.
  4. Score β€” triaged with a lower threshold (40 vs 70) since the user explicitly asked for it.
  5. Summarize β€” bullets + why-it-matters if it clears 40.
  6. Store β€” enters the normal item pipeline.

Failure handling: transient errors retry up to 3 times (retry_count < 3); jobs stuck in processing longer than 10 min (PROCESSING_TIMEOUT_MINUTES = 10) reset to pending; permanent failures are marked failed with the error message.

Podcast Subsystem

The podcast path is richer than a single adapter:

Module Role
sources/podcasts.py Fetch recent episodes from the OPML subscription list
podcast_curator.py Curate the Pocket Casts Up Next queue: clean finished episodes, gather candidates, build identity context (goals/projects/observations), rubric-score via LLM, then select with diversity + dedup constraints
listened_digest.py Bridge actual listening history (full Pocket Casts history, not just queued items) into the transcript/summary pipeline
transcribe.py Download audio for high-scoring episodes and transcribe via AssemblyAI (speaker diarization, $0.012/min)
podcast_summary.py Turn transcripts into executive summary + key topics + notable quotes + takeaways

OPML lives at data/podcasts/subscriptions.opml (not under data/media/); refresh it via /sync-podcasts (re-export from Pocket Casts when the file is stale, default > 90 days). Transcription is gated by transcribe_score and a daily transcription budget. See Podcast Pipeline.

X Bookmark Ingestion

Location: scripts/media/bookmarks.py

A separate, media-pipeline-shaped flow for X bookmarks: fresh scrape β†’ dedup β†’ analyze β†’ cluster β†’ surface. VitaScheduler runs it every 12 hours during the 8am–10pm PT window with fetch_fresh=True; manual cached analysis remains available. Fresh scraping uses scripts/browser/x.py, updates data/x/bookmarks.json, and writes analysis outputs under data/media/bookmarks/:

data/media/bookmarks/
β”œβ”€β”€ raw/          # fetched bookmark batches
β”œβ”€β”€ analyzed/     # per-item analysis (themes, vita relevance, actionable insights)
β”œβ”€β”€ seen.json     # dedup ledger
└── themes.json   # current clustered themes
uv run python scripts/media/bookmarks.py              # full pipeline
uv run python scripts/media/bookmarks.py analyze-only # skip fetch, use cache
uv run python scripts/media/bookmarks.py themes       # show current themes

Fresh-fetch failure is explicit. The pipeline returns status: fetch_failed instead of quietly serving the old cache, and the scheduler queues a Telegram alert deduplicated to once per day. Camofox checks both the current URL and page state for login redirects and can import cookies from a logged-in Chromium profile; if recovery still fails, use the alert's noVNC login path. A run analyzes at most 20 unseen bookmarks, creates Forge sparks for sufficiently relevant rising themes, and exposes high-relevance results to the briefing.

Self-Tuning

Location: scripts/media/tuning.py

Thresholds aren't permanently fixed. tuning.py tracks missed signals (data/media/profile/missed_signals.jsonl) β€” items the user engaged with that the system under-scored β€” and adjusts thresholds to improve precision/recall, logging every change as a TuningRecord. Effective thresholds are persisted to data/media/profile/thresholds.json (overriding the config defaults); load_thresholds() returns the live values.

Feedback & Interest Profile

Feedback

Location: scripts/media/feedback.py

Feedback is state-based with an append-only audit log, all guarded by a 10-second FileLock.

Action Effect Finality
upvote positive signal, trains profile final (once a vote is set it can't flip)
downvote negative signal, trains profile final
save applies upvote + sets bookmark reversible bookmark
unsave removes bookmark (vote unchanged) β€”
dismiss hide from queue reversible
undismiss restore to queue β€”
class ItemFeedbackState(BaseModel):
    vote: Literal["up", "down"] | None = None  # final once set
    saved: bool = False
    dismissed: bool = False

Storage:

data/media/feedback/
β”œβ”€β”€ state.json          # {item_id: ItemFeedbackState}
β”œβ”€β”€ saved_index.json    # fast saved-items lookup
β”œβ”€β”€ history/
β”‚   └── YYYY-MM-DD.jsonl  # audit log of every action
└── .state.lock         # FileLock (timeout 10s)

Vote finality returns HTTP 400 on an attempted flip; a lock-acquisition timeout surfaces as HTTP 503 ("Service busy, please try again").

Interest profile

Location: scripts/media/interest.py

Votes and saves update topic and source weights, stored at data/media/profile/interest.json with its own .interest.lock. Weights are time-decayed (configurable half-life in days), so recent feedback outweighs old. The profile is what Pass 1 injects to personalize scoring.

{
  "topics":  [{"topic": "machine-learning", "weight": 0.85, "sources": ["..."]}],
  "sources": [{"source": "hn", "weight": 1.15}],
  "feedback_count": 247,
  "updated_at": "2026-01-04T14:30:00Z"
}

Numbers above are illustrative placeholders.

API Endpoints

File: api/src/routers/media.py (base http://localhost:33800/media)

Method Path Purpose
GET /media/queue Paginated review queue with filters
GET /media/stats Pipeline statistics for a window
GET /media/sources Per-source health (returns an array)
POST /media/sources/{source}/refresh Trigger a manual fetch
POST /media/feedback Submit feedback on an item

GET /media/queue

Param Type Default Notes
days int 1–30 7 look-back window
min_score int 0–100 0 minimum score
sources list all filter by source
search string (≀100) – title search
saved_only bool false only saved
include_dismissed bool false include dismissed
sort string score score | recent | oldest
cursor string – base64 cursor (score + timestamp + id)
limit int 1–200 50 page size

Response is MediaQueueResponse (items, total, has_more, source_counts, next_cursor). Each MediaReviewItem carries score, summary (if present), feedback_state, and source-specific fields confirmed in api/src/schemas/media.py: external_url / hn_score / hn_comments (HN), feed_name (RSS), podcast_duration (podcasts), content_preview (X β€” first 280 chars, grapheme-safe).

GET /media/stats

{
  "window_days": 7,
  "total_items": 0, "scored_items": 0, "summarized_items": 0,
  "saved_items": 0, "dismissed_items": 0,
  "scores": { "high": 0, "medium": 0, "low": 0 }
}

Score distribution buckets are high β‰₯ 80, 50 ≀ medium < 80, low < 50.

GET /media/sources

Returns list[SourceStatus] β€” a JSON array, one object per source (hn, rss, x, podcast):

[
  {
    "source": "hn",
    "display_name": "Hacker News",
    "enabled": true,
    "last_fetch_started": null,
    "last_fetch_completed": null,
    "last_success": "2026-01-04T07:05:12Z",
    "items_today": 0,
    "error_count": 0,
    "is_refreshing": false,
    "refresh_cooldown_until": null,
    "feeds": null
  }
]

(feeds is populated for RSS.) is_refreshing is derived from the lock file; a lock older than 10 minutes is treated as stale.

POST /media/sources/{source}/refresh

Returns RefreshResponse with status ∈ started | already_running | cooldown | failed (plus optional job_id, cooldown_remaining_seconds, error).

POST /media/feedback

{ "item_id": "EXAMPLE_ID", "action": "upvote" }

Errors: 400 on a vote-change attempt or unknown action; 503 on feedback-lock timeout.

Configuration

File: config/media-intel.yaml

LLM provider/model/task routing moved to .aloop/config.json modes (2026-04-15). The YAML now carries only sources, budgets, thresholds, and AssemblyAI settings:

assemblyai:
  api_key: ${ASSEMBLYAI_API_KEY}
  price_per_minute: 0.012

sources:
  hn:    { enabled: true, poll_interval_minutes: 15 }   # NOTE: live HN cadence is 5 min (scheduler constant)
  rss:   { enabled: true, poll_interval_minutes: 30, feeds: ["..."] }
  x:     { mode: browser }                               # manual only
  podcasts:
    enabled: true
    poll_interval_hours: 4
    max_episodes_per_feed: 3

budgets:
  daily_usd: 5.00
  monthly_usd: 50.00
  deep_dive_daily_usd: 2.00
  transcribe_daily_usd: 3.00     # YAML override; code default is 1.50

thresholds:
  alert_score: 85
  summary_score: 70
  deep_dive_score: 90            # describes the RETIRED deep_dive path
  transcribe_score: 25           # YAML override; code default is 75

Note: the loader (scripts/media/config.py) supplies defaults when a key is absent β€” transcribe_daily_usd defaults to 1.50 and transcribe_score to 75 in code. The committed YAML overrides those (3.00 and 25). When a key is set in the YAML, the YAML wins; when it's missing, the code default applies. The live deep-analysis threshold is 85 (in deep_analysis.py), not the YAML's deep_dive_score: 90 β€” that 90 belongs to the dead path.

Model routing (.aloop/config.json)

Models are selected by task_type through the inference factory (scripts.inference.factory.run_inference / complete), not hard-wired per module. The aloop mode names below were verified in .aloop/config.json, but model assignments drift β€” defer to Model Guidance for current models.

Pass / task aloop mode (task_type) Status
Triage media_score live
Summarize media_summarize live
Deep analysis (Pass 3) deep_analysis live
Synthesis (Pass 4) synthesis live
Deep dive (legacy) media_deep_dive orphaned β€” only consumer is the dead deep_dive.py

Triage and summarize currently route to a cheap Gemini-tier flash model; deep analysis and synthesis to a DeepSeek flash model; the orphaned media_deep_dive mode still points at a Claude-tier model but is never invoked. Treat specific model strings as volatile β€” read .aloop/config.json or model-guidance.md at the moment you need them.

Storage Layout

data/media/
β”œβ”€β”€ items/        YYYY-MM-DD.jsonl   # normalized items (Pass 0)
β”œβ”€β”€ scores/       YYYY-MM-DD.jsonl   # triage scores (Pass 1)
β”œβ”€β”€ summaries/    YYYY-MM-DD.jsonl   # summaries (Pass 2)
β”œβ”€β”€ deep/         YYYY-MM-DD.jsonl   # deep analysis (Pass 3, created on first run)
β”œβ”€β”€ synthesis/    YYYY-MM-DD.json    # daily synthesis (Pass 4, created on first run)
β”œβ”€β”€ feedback/     state.json, saved_index.json, history/YYYY-MM-DD.jsonl, .state.lock
β”œβ”€β”€ profile/      interest.json, missed_signals.jsonl, thresholds.json, .interest.lock
β”œβ”€β”€ costs/        YYYY-MM.jsonl      # monthly cost ledger
β”œβ”€β”€ transcripts/  podcast transcripts (AssemblyAI)
β”œβ”€β”€ podcast_summaries/  podcast summary records
β”œβ”€β”€ bookmarks/    raw/, analyzed/, seen.json, themes.json   # X bookmark pipeline
β”œβ”€β”€ health.json   # per-source last-seen / errors / dedup state
β”œβ”€β”€ submissions.db        # SQLite URL-submission queue
└── .refresh-{source}.lock  # per-source fetch locks

Example records

items/YYYY-MM-DD.jsonl:

{"id":"EXAMPLE_ID","source":"hn","url":"https://example.com/article","title":"Example Title","content":"","author":"someuser","timestamp":"2026-01-04T05:02:16+00:00","raw":{"by":"someuser","score":90},"fingerprint":"abc123"}

scores/YYYY-MM-DD.jsonl:

{"item_id":"EXAMPLE_ID","score":92,"reason":"strong topic match from a trusted author","model":"<routed-by-aloop>","scored_at":"2026-01-04T05:15:00Z","tokens_in":45,"tokens_out":12,"cost_usd":0.000005}

summaries/YYYY-MM-DD.jsonl:

{"item_id":"EXAMPLE_ID","bullets":["Point one","Point two","Point three"],"why_it_matters":"Connects to an active interest","tags":["topic-a","tutorial"],"model":"<routed-by-aloop>","summarized_at":"2026-01-04T05:20:00Z"}

Cost & Budget Enforcement

Location: scripts/media/costs.py

Every LLM/API cost is appended to data/media/costs/YYYY-MM.jsonl. check_budget(task) is called before each batch; when a cap is hit the relevant stage stops silently and resumes at the next UTC day. Per the committed YAML:

Budget Value Scope
daily_usd $5.00 all tasks combined
monthly_usd $50.00 all tasks combined
deep_dive_daily_usd $2.00 deep analysis
transcribe_daily_usd $3.00 (code default 1.50) podcast transcription

Health Monitoring

Location: scripts/media/health.py, data/media/health.json

Tracks per-source last_success, error_count, dedup state (seen URLs + fingerprints), and cooldown. RSS feeds with 3+ consecutive errors trigger a deduped Telegram alert (rss_feed_down) from run_pipeline.py. The same health data backs GET /media/sources.

Frontend

Location: web/src/routes/media/, web/src/components/media/ (served on 33801 in dev)

The /media route is an infinite-scroll review queue with URL-persisted filters, a collapsible source-status panel, and optimistic feedback. Source-typed cards render the source-specific fields (HN points/comments, RSS feed badge, podcast duration, X content preview). React Query hooks live in web/src/hooks/useMedia.ts.

File Locations

File Purpose
api/src/routers/media.py API endpoints
api/src/schemas/media.py Pydantic models (MediaReviewItem, SourceStatus, …)
api/src/scheduler.py scheduler loop methods + interval constants
api/src/loop_engine/registry.py loop registration catalog (cadences, labels)
api/src/services/scheduler_user_submit.py user-submission scheduler integration
scripts/media/run_pipeline.py fetch/score/summarize/transcribe orchestrator
scripts/media/normalize.py URL normalization + dedup
scripts/media/triage.py Pass 1 scoring
scripts/media/summarize.py Pass 2 summaries + content extraction
scripts/media/deep_analysis.py Pass 3 deep analysis (live)
scripts/media/synthesis.py Pass 4 daily synthesis
scripts/media/deep_dive.py retired / never deployed
scripts/media/alerts.py high-signal Telegram alerts
scripts/media/tuning.py self-tuning thresholds
scripts/media/feedback.py feedback state + audit log
scripts/media/interest.py interest profile (time-decayed)
scripts/media/costs.py cost ledger + budget checks
scripts/media/health.py per-source health
scripts/media/bookmarks.py X bookmark ingestion
scripts/media/podcast_curator.py Pocket Casts Up Next curation
scripts/media/listened_digest.py listening-history bridge
scripts/media/transcribe.py AssemblyAI transcription
scripts/media/podcast_summary.py transcript summarization
scripts/media/vdb_section.py "Media Intel" section for the daily brief
config/media-intel.yaml sources / budgets / thresholds / AssemblyAI
.aloop/config.json model routing by task_type

Where to Go Next