Skip to content

Feat narrative analysis indicators command#70

Merged
Naroh091 merged 15 commits into
mainfrom
feat-narrative-analysis-indicators-command
Jul 6, 2026
Merged

Feat narrative analysis indicators command#70
Naroh091 merged 15 commits into
mainfrom
feat-narrative-analysis-indicators-command

Conversation

@ftello0403

@ftello0403 ftello0403 commented May 12, 2026

Copy link
Copy Markdown
Contributor

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_pipeline command

To achieve this, the command line run_narrative_analysis_indicators_pipeline was created to run a calculation process divided on three phases:

  1. Calculate per-narrative virality scores (engagement, reach, velocity) in batches.
  2. Compute composite virality and acceleration rate indicators for the day.
  3. Classify and persist alert levels for every narrative.

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:

  • batch-size (optional, default=100): Number of narratives to process per batch.
  • hours (optional, default=24): Time window in hours for narrative analysis indicators calculation.

2. GET /api/narratives/{narrative_id:uuid}/indicators endpoint

The 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.

ftello0403 and others added 12 commits May 20, 2026 14:39
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
…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>
@Naroh091 Naroh091 marked this pull request as ready for review June 19, 2026 14:55
@Naroh091 Naroh091 requested a review from JamesMcMinn June 19, 2026 14:55
Comment thread core/videos/repo.py
Comment on lines -125 to +126
metadata = metadata || %(metadata)s
metadata = metadata || %(metadata)s,
updated_at = NOW()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix.

@JamesMcMinn JamesMcMinn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread core/narratives/repo.py
Comment on lines +1175 to +1176
-- Latest snapshot per (video, day). DISTINCT ON gives one row
-- per video per calendar day, taking the most recent recorded_at.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 day

basically, new change_days and carried CTEs to correct for "missing" days. Otherwise I think the query is fine.

Comment thread core/narratives/repo.py
],
)

async def get_bulk_narrative_stats_comparison(self, calc_date: date) -> list[dict]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MiguelAzores MiguelAzores left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

@JamesMcMinn JamesMcMinn self-requested a review June 26, 2026 14:22
@Naroh091 Naroh091 merged commit 9807c2b into main Jul 6, 2026
3 checks passed
@Naroh091 Naroh091 deleted the feat-narrative-analysis-indicators-command branch July 6, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants