Spaces:
Sleeping
Sleeping
georgejieh
Mirror canonical stub-remediation: redact warnings, expose Generation.warning, drop warning from public digest responses, reject unknown sessions
8bc73da unverified Download app/api/trigger.py from ColdAshSage/reed: direct link, hf CLI and curl.
- Browser
- Download file 7.26 kB
-
https://huggingface.co/spaces/ColdAshSage/reed/resolve/main/app/api/trigger.py
- Command line
-
hf download hf://spaces/ColdAshSage/reed/app/api/trigger.py
-
curl -L -o trigger.py https://huggingface.co/spaces/ColdAshSage/reed/resolve/main/app/api/trigger.py
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) | |
| 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(), | |
| } | |