File size: 7,255 Bytes
a588900
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8bc73da
 
a588900
 
8bc73da
a588900
 
 
 
 
 
8bc73da
 
 
 
8182828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a588900
 
 
 
 
70b5a31
a588900
 
 
 
 
 
70b5a31
 
8182828
 
 
 
a588900
 
e885092
 
 
 
 
 
 
 
 
a588900
 
8bc73da
 
 
 
 
cf4fbb1
 
 
 
 
8182828
cf4fbb1
e885092
 
cf4fbb1
 
e885092
 
cf4fbb1
 
e885092
 
 
 
cf4fbb1
e885092
 
 
a588900
 
 
 
 
 
e77384c
 
 
 
 
 
 
70b5a31
e77384c
 
 
 
 
 
 
 
 
1071f6c
 
 
 
e885092
 
 
e77384c
8bc73da
e77384c
 
 
e885092
 
 
 
 
e77384c
 
 
 
 
 
 
e885092
 
 
 
8bc73da
 
 
 
 
 
 
 
 
 
e77384c
a125930
e77384c
a588900
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""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(),
    }