reed / app /api /trigger.py
georgejieh
Mirror canonical stub-remediation: redact warnings, expose Generation.warning, drop warning from public digest responses, reject unknown sessions
8bc73da unverified
Raw History Blame Contribute Delete
7.26 kB
"""Token-protected manual trigger endpoint.
POST /api/trigger/{session} runs generate_digest synchronously and
returns the new digest id. Used by Hugging Face Spaces cron and any
operator who wants to run a session out of schedule.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException
from app.api.deps import get_config, get_store
from app.config import AppConfig
from app.digests.generator import generate_digest
from app.digests.models import Digest, Generation, MarketSnapshotMeta
from app.digests.redaction import redact_warning
from app.digests.store import DigestStore
from app.providers.factory import get_provider
from app.sessions.registry import all_sessions
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/trigger", tags=["trigger"])
# Set of valid session names, shared by scheduler and trigger.
SESSION_NAMES: set[str] = {s.name for s in all_sessions()}
def _parse_as_of(as_of: str) -> datetime:
"""Parse an ISO-8601 `as_of` query parameter into an aware UTC datetime.
Naive timestamps are rejected. Values must include either a 'Z'
suffix or an explicit UTC offset (e.g. +00:00, -04:00).
"""
try:
parsed = datetime.fromisoformat(as_of.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f"invalid as_of: {as_of!r}; expected ISO-8601 with timezone (Z or explicit offset)",
) from exc
if parsed.tzinfo is None or parsed.tzinfo.utcoffset(parsed) is None:
raise HTTPException(
status_code=400,
detail=f"naive as_of: {as_of!r}; include 'Z' or an explicit timezone offset",
)
return parsed.astimezone(timezone.utc)
@router.post("/{session}", response_model=dict)
def trigger_session(
session: str,
x_reed_token: str | None = Header(default=None),
config: AppConfig = Depends(get_config),
as_of: str | None = None,
store: DigestStore = Depends(get_store),
) -> dict:
"""Run generate_digest for `session` and return the new digest id.
Auth: when `REED_TRIGGER_TOKEN` is set in the environment, the
`X-REED-Token` header must match. When unset, the endpoint is
open only when REED_ENV=dev and we are not on HF (operator override).
Optional `as_of` query param (ISO-8601 with timezone) anchors the
time-window RSS filter and the digest's own as_of field. Used for
backfilling briefs for past dates when RSS feeds still have the
headlines in their payload. Format: ?as_of=2026-07-23T08:00:00Z
"""
expected = os.environ.get("REED_TRIGGER_TOKEN")
# Fail closed when token is unset in prod; allow dev only with REED_ENV=dev.
if not expected:
env = os.environ.get("REED_ENV", "prod")
on_hf = bool(os.environ.get("SPACE_ID"))
if env != "dev" or on_hf:
raise HTTPException(status_code=503, detail="trigger token not configured")
else:
import hmac
if not x_reed_token or not hmac.compare_digest(x_reed_token, expected):
raise HTTPException(status_code=401, detail="missing or invalid token")
# Reject unknown sessions before any persistence or provider work.
if session not in SESSION_NAMES:
logger.warning("trigger rejected unknown session %r", session)
raise HTTPException(status_code=400, detail=f"unknown session {session!r}")
# Parse as_of query param if provided (backfill mode). Do this BEFORE
# the holiday check so the holiday gate can target the backfill date,
# not today.
parsed_as_of = None
if as_of:
parsed_as_of = _parse_as_of(as_of)
# Holiday skip: GHA cron path goes through this trigger, so the gate must
# live here (not just in scheduler.py) for the HF Space deployment.
# For backfill (as_of provided), check the backfill date for holidays,
# not today.
from datetime import datetime as _dt, timezone as _tz
from app.market_calendar import is_us_market_holiday
holiday_anchor = parsed_as_of if parsed_as_of else _dt.now(_tz.utc)
if config.scheduler.skip_holidays and is_us_market_holiday(holiday_anchor):
return {
"id": None,
"headline": "[STUB] skipped (US market holiday)",
"session": session,
"as_of": holiday_anchor.isoformat(),
"skipped": True,
}
try:
provider = get_provider(config)
except Exception as exc:
logger.warning("provider init failed in trigger: %s", exc)
raise HTTPException(status_code=503, detail=f"provider init failed: {exc}")
try:
digest = generate_digest(
session=session,
config=config,
provider=provider,
store=store,
market_snapshot_meta=None,
as_of=parsed_as_of,
)
except Exception as exc:
# The session failed end-to-end (LLM provider error, JSON parse,
# Pydantic validation, mirror push, etc.). Save a stub digest
# so the trigger does not 500 and the dataset repo still gets
# a record. The stub carries fallback_used=True and the
# original exception in the headline so the operator can see
# what failed.
logger.exception("generate_digest failed in trigger; saving stub")
# Surface the exception class and message to stderr for live debugging.
import traceback
print(f"TRIGGER_FAIL: {type(exc).__name__}: {exc}", flush=True)
print(f"TRIGGER_FAIL_TRACEBACK:\n{traceback.format_exc()}", flush=True)
# Build a clean stub: clear stories/sources/themes so the public dataset
# never contains fabricated content. Exception text lives only in the
# operator-only warning field and the trigger response log.
now = datetime.now(timezone.utc)
warning = redact_warning(f"{type(exc).__name__}: {exc}")
digest = Digest(
session=session, # type: ignore[arg-type]
as_of=now,
headline=f"[STUB] {session} brief unavailable",
executive_summary=(
"REED could not generate a structured brief for this session. "
"The next scheduled trigger will retry."
),
market_snapshot={},
market_snapshot_meta=MarketSnapshotMeta(
source="stub",
fetched_at=now.isoformat(timespec="seconds"),
values_raw={},
delayed=True,
),
stories=[],
themes=[],
watch_next_session=[],
sources=[],
generation=Generation(
provider="stub",
model="stub",
agent_turns=0,
tool_calls=0,
scraped_urls=0,
fallback_used=True,
duration_ms=0,
warning=warning,
),
)
store.write(digest)
return {
"id": digest.id,
"headline": digest.headline,
"session": digest.session,
"as_of": (digest.as_of or datetime.now(timezone.utc)).isoformat(),
}