Add hourly RecentlyOnScope batch endpoint

This commit is contained in:
2026-05-22 16:14:08 +02:00
parent c95d29023d
commit cea59d7f13
5 changed files with 297 additions and 0 deletions

View File

@@ -22,8 +22,11 @@ from api.models.task import Task, TaskStatus
from api.models.topic import Topic
from api.models.workstream import Workstream
from api.schemas.recently_on_scope import (
RecentlyOnScopeFailedDomain,
RecentlyOnScopeHourlyRun,
RecentlyOnScopeReportMetadata,
RecentlyOnScopeSourceCounts,
RecentlyOnScopeSkippedDomain,
)
from api.services.markitect_templates import inspect_markdown_template, render_markdown_template
@@ -157,6 +160,68 @@ async def generate_report(
window: DigestWindow,
) -> tuple[RecentlyOnScopeReportMetadata, str]:
data = await collect_domain_activity(session, domain_slug, window)
return _render_report_from_data(data, window)
async def generate_hourly_reports(
session: AsyncSession,
window: DigestWindow,
*,
active_only: bool = True,
include_attention: bool = False,
) -> RecentlyOnScopeHourlyRun:
generated: list[RecentlyOnScopeReportMetadata] = []
skipped: list[RecentlyOnScopeSkippedDomain] = []
failed: list[RecentlyOnScopeFailedDomain] = []
domains = await _list_domains(session, active_only=active_only)
for domain in domains:
try:
data = await collect_domain_activity(session, domain.slug, window)
counts = RecentlyOnScopeSourceCounts(**data["source_counts"])
if not _has_qualifying_activity(counts, include_attention=include_attention):
skipped.append(
RecentlyOnScopeSkippedDomain(
domain_slug=domain.slug,
reason="no qualifying activity in window",
source_counts=counts,
)
)
continue
metadata, _markdown = _render_report_from_data(data, window)
generated.append(metadata)
except Exception as exc: # pragma: no cover - exercised via router tests
failed.append(RecentlyOnScopeFailedDomain(domain_slug=domain.slug, error=str(exc)))
generated_at = datetime.now(tz=UTC)
progress_event_id = await _log_hourly_progress(
session,
window,
generated_at=generated_at,
active_only=active_only,
include_attention=include_attention,
generated=generated,
skipped=skipped,
failed=failed,
)
return RecentlyOnScopeHourlyRun(
range=window.range,
since=window.since,
until=window.until,
generated_at=generated_at,
active_only=active_only,
include_attention=include_attention,
generated=generated,
skipped=skipped,
failed=failed,
progress_event_id=progress_event_id,
)
def _render_report_from_data(
data: dict[str, Any],
window: DigestWindow,
) -> tuple[RecentlyOnScopeReportMetadata, str]:
tmpl = template_path()
inspect_markdown_template(tmpl)
markdown = render_markdown_template(tmpl, data)
@@ -207,6 +272,65 @@ def metadata_from_report(path: Path) -> RecentlyOnScopeReportMetadata | None:
return None
async def _list_domains(session: AsyncSession, *, active_only: bool) -> list[Domain]:
stmt = select(Domain).order_by(Domain.slug)
if active_only:
stmt = stmt.where(Domain.status == "active")
result = await session.execute(stmt)
return list(result.scalars().all())
def _has_qualifying_activity(
counts: RecentlyOnScopeSourceCounts,
*,
include_attention: bool,
) -> bool:
if (
counts.progress_events
or counts.decisions
or counts.workstreams
or counts.tasks
):
return True
return include_attention and counts.attention_items > 0
async def _log_hourly_progress(
session: AsyncSession,
window: DigestWindow,
*,
generated_at: datetime,
active_only: bool,
include_attention: bool,
generated: list[RecentlyOnScopeReportMetadata],
skipped: list[RecentlyOnScopeSkippedDomain],
failed: list[RecentlyOnScopeFailedDomain],
) -> uuid.UUID:
event = ProgressEvent(
event_type="recently_on_scope_hourly",
summary=(
"RecentlyOnScope hourly batch completed: "
f"{len(generated)} generated, {len(skipped)} skipped, {len(failed)} failed"
),
detail={
"range": window.range,
"since": _iso(window.since),
"until": _iso(window.until),
"generated_at": _iso(generated_at),
"active_only": active_only,
"include_attention": include_attention,
"generated": [item.model_dump(mode="json") for item in generated],
"skipped": [item.model_dump(mode="json") for item in skipped],
"failed": [item.model_dump(mode="json") for item in failed],
},
author="state-hub",
)
session.add(event)
await session.commit()
await session.refresh(event)
return event.id
async def _get_domain(domain_slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == domain_slug))
domain = result.scalar_one_or_none()