Feat narrative analysis indicators command#70
Conversation
Virality calc corrections:
- Cap each change_* component at 5.0 (ACCELERATION_CHANGE_CAP) so a single
video with a tiny prev-day baseline can't drive acceleration_rate into
the thousands and drown the 0.40/0.35/0.25 weights.
- Drop the "1.0 when prev data missing" fallback (was conflating "freshly
observed narrative" with "100% growth in every dimension").
- Add a WATCH branch for narratives with composite > 0.85 but acceleration
below 1.0 — closes the gap where plateaued-but-popular narratives were
silently classified as NONE.
Indicators surface:
- Surface alert_level on NarrativeSummary so the list response carries it.
- Add alert_level filter to GET /api/narratives (repeatable query param).
- Add GET /api/narratives/{id}/indicators/history?days=N — single round
trip in place of N per-day calls.
Evolution chart:
- Rewrite get_narrative_stats to derive the time series from video_stats
snapshots (latest per video per day, summed across the narrative) instead
of grouping by videos.uploaded_at. Same response shape, but the chart now
reflects how engagement *grew* over time rather than when content was
first published — viral narratives show their characteristic end ramp.
e6d2fbf removed the '1.0 when prev data missing' fallback in calculate_acceleration_rate_for_date but left this test asserting the old behaviour, so it failed. A missing baseline now yields 0.0.
Add an optional sort='composite' query param to GET /api/narratives that ranks results by each narrative's latest composite_virality indicator (highest first) via a LATERAL join, threaded through the controller, service and repo. Default ordering (newest first) is unchanged.
Generalise the list-sort to rank by any latest virality indicator; acceleration_rate is used by the overview to rank early-surge narratives (where acceleration, not composite, is the defining signal).
The /indicators/history endpoint and its service method only fed the analysis sparklines, which were removed from the frontend. The dashboard and detail panel now read the single /indicators endpoint, so the history machinery has no consumer. Drops the endpoint, the service method and the now-unused timedelta import.
…rs' into feat-narrative-analysis-indicators-command # Conflicts: # core/narratives/service.py # tests/narratives/test_virality_service.py
…is-indicators-command # Conflicts: # core/narratives/controller.py # core/narratives/repo.py # core/narratives/service.py
…ass calc_date in the virality calc
…ing whitespace The migration was named *video-virality-scores* but actually creates the narrative_virality_scores / narrative_analysis_indicators tables, the narrative_alert_level enum and the alert_level column. Rename it to add-narrative-virality-tables. Also strip PR-introduced trailing whitespace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| metadata = metadata || %(metadata)s | ||
| metadata = metadata || %(metadata)s, | ||
| updated_at = NOW() |
JamesMcMinn
left a comment
There was a problem hiding this comment.
I think this mostly looks good, but some of the more complicated queries make the assumption that video_stats data gets updated for every video at least once per day, which isn't the case.
Once these are fixed I think it's good to go.
| -- Latest snapshot per (video, day). DISTINCT ON gives one row | ||
| -- per video per calendar day, taking the most recent recorded_at. |
There was a problem hiding this comment.
There's no guarantee that every video in a narrative gets scraped every day, which I think breaks this query and a few others added in this PR.
We scrape the last 200 videos per channel / keyword approximately every hour, but once they drop out of the 200 most recent videos, we move to a dynamic approach where we scrape each video based on the expected new views. This means that an older, low view count video could only be scraped once every few days (or even weeks) since we're limited by how many videos we can re-scrape data for.
Additionally, if the data hasn't changed since the last scrape, we don't update it, so a video that's not getting any new views wouldn't be included in the count for a day.
I think this query, and any other query that uses dates as a filter or join on the video_stats table needs an update to use the last value for a video, rather than assuming there will be an entry for every date.
Claude is suggesting the following which I think would correct for this:
WITH narrative_videos AS (
SELECT DISTINCT v.id
FROM videos v
JOIN video_claims vc ON v.id = vc.video_id
JOIN claim_narratives cn ON vc.id = cn.claim_id
WHERE cn.narrative_id = %(narrative_id)s
),
daily_latest AS (
-- Latest snapshot per (video, day) — unchanged.
SELECT DISTINCT ON (vs.video_id, vs.recorded_at::date)
vs.video_id,
vs.recorded_at::date AS day,
vs.views, vs.likes, vs.comments
FROM video_stats vs
JOIN narrative_videos nv ON vs.video_id = nv.id
ORDER BY vs.video_id, vs.recorded_at::date, vs.recorded_at DESC
),
change_days AS (
-- The days on which the narrative's cumulative state actually changed.
SELECT DISTINCT day FROM daily_latest
),
carried AS (
-- For each change-day, carry forward each video's latest snapshot
-- recorded on or before that day. This is the real end-of-day state:
-- a video keeps contributing its last-known counts on days when it
-- wasn't re-snapshotted.
SELECT cd.day, latest.video_id, latest.views, latest.likes, latest.comments
FROM change_days cd
JOIN LATERAL (
SELECT DISTINCT ON (dl.video_id)
dl.video_id, dl.views, dl.likes, dl.comments
FROM daily_latest dl
WHERE dl.day <= cd.day
ORDER BY dl.video_id, dl.day DESC
) latest ON TRUE
),
per_day AS (
SELECT
day,
COUNT(DISTINCT video_id) AS videos_with_stats,
COALESCE(SUM(views), 0) AS cum_views,
COALESCE(SUM(likes), 0) AS cum_likes,
COALESCE(SUM(comments), 0) AS cum_comments
FROM carried
GROUP BY day
)
SELECT
day AS date,
videos_with_stats AS video_count,
GREATEST(cum_views - LAG(cum_views, 1, 0::bigint) OVER (ORDER BY day), 0) AS views,
GREATEST(cum_likes - LAG(cum_likes, 1, 0::bigint) OVER (ORDER BY day), 0) AS likes,
GREATEST(cum_comments - LAG(cum_comments, 1, 0::bigint) OVER (ORDER BY day), 0) AS comments,
videos_with_stats AS cumulative_video_count,
cum_views AS cumulative_views,
cum_likes AS cumulative_likes,
cum_comments AS cumulative_comments
FROM per_day
ORDER BY daybasically, new change_days and carried CTEs to correct for "missing" days. Otherwise I think the query is fine.
| ], | ||
| ) | ||
|
|
||
| async def get_bulk_narrative_stats_comparison(self, calc_date: date) -> list[dict]: |
There was a problem hiding this comment.
This needs a fix similar to get_narrative_stats.
The narrative stats queries assumed video_stats has a row for every video on every calendar day. It doesn't: we scrape the most recent ~200 videos per channel/keyword roughly hourly, then re-scrape older videos based on expected new views, so a low-view video may only be recorded every few days or weeks. Unchanged data is not re-recorded either. Any query that filters or joins video_stats by date therefore treated a missing day as zero engagement, making narratives look like they collapsed and then recovered. Fix the three affected queries to use each video's latest snapshot recorded on or before the reference point, rather than a snapshot from that exact day: - get_narrative_stats: add change_days + carried CTEs that carry each video's last-known snapshot forward to every day the narrative changed, so the cumulative series stays monotonic. - get_bulk_narrative_stats_comparison: compare the latest snapshot on or before calc_date / prev_date (<= instead of =), so videos not scraped on a given day no longer drop out and distort acceleration. - get_narrative_stats_delta_for_period: baseline on the last snapshot before the window rather than the first one inside it, so growth for videos scraped only once in the window is no longer reported as zero. Add DB-backed tests covering sparse-snapshot scenarios for all three.
There was a problem hiding this comment.
@JamesMcMinn @Naroh091 That's right, I've already made the changes to the three queries to account for the fact that we won't always have daily video statistics. I've also included a test that simulates this situation.
I've made the changes to the following functions:
- get_narrative_stats
- get_bulk_narrative_stats_comparison
- get_narrative_stats_delta_for_period
Overview
This Pull Request adds a new process to identify the virality status from a narrative based on the increasing statistics related to their videos. The goal is to make this an automated process by executing it with a cronjob every day.
1.
run_narrative_analysis_indicators_pipelinecommandTo achieve this, the command line
run_narrative_analysis_indicators_pipelinewas created to run a calculation process divided on three phases:This iterates all over the active prevalent narratives, which are the ones that their videos were updated on the last 24 hours, and persists each calculation on the database to store the results for future analysis.
The command allows the following parameters:
2.
GET /api/narratives/{narrative_id:uuid}/indicatorsendpointThe calculated results and the alert level classification will be available on the narrative's detail view on the frontend side by consuming a new endpoint
GET /api/narratives/{narrative_id:uuid}/indicators.This endpoint accepts a date parameter to obtain the results from a specific day. By default, it returns the last analysis indicators stored on the database; this means that if a narrative has not received an update on the last 24 hours, the dashboard will render the last stored result.