feat(ingestion,frontend): add live scholar progress counter to dashboard

Emit scholar_progress SSE events during two-pass ingestion so the
Activity Monitor shows "X / N visited · Y finished · Z new publications"
in real time instead of static post-run counts.

- scholar_processing: add on_progress callback to _run_first_pass and
  _run_depth_pass; build _emit closure in run_scholar_iteration that
  publishes visited/finished/total via run_events; batch-emit scholars
  finished in first pass before depth pass; skip emission on abort/cancel
- run_status store: add scholarProgress state, reset on stream open and
  reset(); subscribe to scholar_progress SSE events with safe parsing
- DashboardPage: show live counter during run, fall back to static text
  when run is not active or no progress received yet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Visser 2026-03-03 17:36:43 +01:00
parent f50b609a36
commit 4620978dce
3 changed files with 55 additions and 3 deletions

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import logging
import random
from collections.abc import Callable, Coroutine
from datetime import UTC, datetime
from typing import Any
@ -274,6 +275,7 @@ async def _run_first_pass(
request_delay_seconds: int,
queue_delay_seconds: int,
progress: RunProgress,
on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None,
) -> dict[int, int]:
first_pass_cstarts: dict[int, int] = {}
for index, scholar in enumerate(scholars):
@ -310,6 +312,8 @@ async def _run_first_pass(
scholars_remaining=len(scholars) - index - 1,
)
return first_pass_cstarts
if on_progress is not None:
await on_progress(1, 0)
resume_cstart = outcome.result_entry.get("continuation_cstart")
if resume_cstart is not None and int(resume_cstart) > start_cstart:
first_pass_cstarts[int(scholar.id)] = int(resume_cstart)
@ -330,6 +334,7 @@ async def _run_depth_pass(
auto_queue_continuations: bool,
queue_delay_seconds: int,
progress: RunProgress,
on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None,
) -> None:
for index, scholar in enumerate(scholars):
resume_cstart = first_pass_cstarts.get(int(scholar.id))
@ -367,6 +372,8 @@ async def _run_depth_pass(
scholars_remaining=len(scholars) - index - 1,
)
break
if on_progress is not None:
await on_progress(0, 1)
async def run_scholar_iteration(
@ -387,6 +394,8 @@ async def run_scholar_iteration(
auto_queue_continuations: bool,
queue_delay_seconds: int,
) -> RunProgress:
from app.services.runs.events import run_events
progress = RunProgress()
scholar_kwargs: dict[str, Any] = {
"request_delay_seconds": request_delay_seconds,
@ -396,6 +405,21 @@ async def run_scholar_iteration(
"rate_limit_backoff_seconds": rate_limit_backoff_seconds,
"page_size": page_size,
}
visited = 0
finished = 0
total = len(scholars)
async def _emit(v: int = 0, f: int = 0) -> None:
nonlocal visited, finished
visited += v
finished += f
await run_events.publish(
run.id,
"scholar_progress",
{"visited": visited, "finished": finished, "total": total},
)
first_pass_cstarts = await _run_first_pass(
db_session,
scholars=scholars,
@ -407,8 +431,12 @@ async def run_scholar_iteration(
request_delay_seconds=request_delay_seconds,
queue_delay_seconds=queue_delay_seconds,
progress=progress,
on_progress=_emit,
)
remaining_max = max(max_pages_per_scholar - 1, 0)
scholars_finished_in_first_pass = len(scholars) - len(first_pass_cstarts)
if scholars_finished_in_first_pass > 0:
await _emit(f=scholars_finished_in_first_pass)
if remaining_max <= 0:
return progress
await _run_depth_pass(
@ -424,5 +452,6 @@ async def run_scholar_iteration(
auto_queue_continuations=auto_queue_continuations,
queue_delay_seconds=queue_delay_seconds,
progress=progress,
on_progress=_emit,
)
return progress