File size: 9,655 Bytes
9c3ba60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
"""Phase 6E — Live-Eval Harness CLI with opt-in /api/generate adapter.

Without ``NEXUS_RUN_LIVE_EVAL=true`` in the environment this script
**refuses** to make any real generation calls and exits non-zero. This is
the guard that keeps live evaluation out of the normal backend test gate.

Live evaluation is a thin orchestration layer:

1. POST to ``/api/generate`` with the prompt as ``topic``.
2. Poll ``/api/slides/{task_id}`` until 200 (done), failure, or timeout.
3. Shape the response into the deck dict expected by
   :func:`services.eval_service.evaluate_deck`.
4. Write one JSON record per prompt under
   ``backend/storage/evals/`` (or ``$NEXUS_EVAL_OUTPUT_DIR``).

Network calls happen only via the injectable ``HttpClient`` protocol so
tests can exercise the orchestration without real HTTP.

Usage::

    # Refuse — no env flag:
    python -m scripts.run_live_eval --prompt-id biz-001
    # Real run — explicit opt-in:
    NEXUS_RUN_LIVE_EVAL=true python -m scripts.run_live_eval \\
        --prompt-id biz-001 --base-url http://localhost:8000
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Protocol

from services.eval_service import evaluate_deck, get_prompt_spec, load_prompts


DEFAULT_TIMEOUT_SECONDS = 600
DEFAULT_POLL_INTERVAL_SECONDS = 3.0


class HttpResponse(Protocol):
    status_code: int

    def json(self) -> Any: ...


class HttpClient(Protocol):
    def post(self, url: str, *, json: dict[str, Any], timeout: float) -> HttpResponse: ...

    def get(self, url: str, *, timeout: float) -> HttpResponse: ...

    def close(self) -> None: ...


def _output_dir() -> Path:
    base = Path(os.environ.get("NEXUS_EVAL_OUTPUT_DIR") or "/app/storage/evals")
    base.mkdir(parents=True, exist_ok=True)
    return base


def _refuse_if_not_opted_in() -> None:
    flag = (os.environ.get("NEXUS_RUN_LIVE_EVAL") or "").strip().lower()
    if flag != "true":
        sys.stderr.write(
            "[run_live_eval] refusing to run: NEXUS_RUN_LIVE_EVAL is not set to 'true'.\n"
            "                  Set NEXUS_RUN_LIVE_EVAL=true to opt in to live calls.\n"
        )
        raise SystemExit(2)


def _build_default_client() -> HttpClient:  # pragma: no cover - real network
    import httpx

    return httpx.Client()  # type: ignore[return-value]


class LiveGenerationError(RuntimeError):
    """Raised for non-recoverable generation failures (HTTP error, task failed)."""


class LiveGenerationTimeout(RuntimeError):
    """Raised when polling exceeds the configured timeout."""


def run_live_generation(
    prompt_text: str,
    *,
    base_url: str,
    theme: str,
    search_web: bool,
    slide_count: int,
    timeout_seconds: float,
    poll_interval_seconds: float,
    client: HttpClient,
    sleep: Callable[[float], None] = time.sleep,
    now: Callable[[], float] = time.monotonic,
    min_sources: int = 0,
) -> dict[str, Any]:
    """Drive a single end-to-end generate→poll→fetch round-trip.

    Returns a deck dict shaped like ``{"slides": [...], "sources": [...]}``
    suitable for :func:`services.eval_service.evaluate_deck`.

    Pure-orchestration: all HTTP goes through ``client`` so tests can
    inject a fake. The default client is created by ``_build_default_client``.
    """

    base = base_url.rstrip("/")
    payload = {
        "topic": prompt_text,
        "slide_count": int(slide_count),
        "theme": theme,
        "search_web": bool(search_web),
    }
    # Phase 6U: forward expected min_sources so the agent loop's harvest
    # step has an explicit target. Default 0 preserves the prior behaviour.
    if int(min_sources or 0) > 0:
        payload["min_sources"] = int(min_sources)
    create_resp = client.post(f"{base}/api/generate", json=payload, timeout=30.0)
    if create_resp.status_code >= 400:
        raise LiveGenerationError(
            f"POST /api/generate failed: HTTP {create_resp.status_code}"
        )
    body = create_resp.json()
    task_id = body.get("task_id")
    if not task_id:
        raise LiveGenerationError(f"POST /api/generate missing task_id: {body!r}")

    deadline = now() + float(timeout_seconds)
    while True:
        if now() >= deadline:
            raise LiveGenerationTimeout(
                f"task {task_id} did not complete within {timeout_seconds}s"
            )
        resp = client.get(f"{base}/api/slides/{task_id}", timeout=30.0)
        if resp.status_code == 200:
            data = resp.json()
            slides = data.get("slides") or []
            sources = data.get("sources") or []
            return {"slides": slides, "sources": sources, "_task_id": task_id}
        if resp.status_code == 409:
            # Task still pending; /api/slides 409s until status==done. If
            # the task transitions to "failed", /api/slides also 409s with
            # a body.detail mentioning failed — surface that explicitly.
            try:
                detail = resp.json().get("detail", "")
            except Exception:
                detail = ""
            if isinstance(detail, str) and "failed" in detail.lower():
                raise LiveGenerationError(f"task {task_id} failed: {detail}")
            sleep(float(poll_interval_seconds))
            continue
        if resp.status_code == 404:
            raise LiveGenerationError(f"task {task_id} not found")
        raise LiveGenerationError(
            f"GET /api/slides/{task_id} failed: HTTP {resp.status_code}"
        )


def _write_record(record: dict[str, Any]) -> Path:
    out_dir = _output_dir()
    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    path = out_dir / f"{record['prompt_id']}-{ts}.json"
    path.write_text(json.dumps(record, indent=2, sort_keys=True), encoding="utf-8")
    return path


def main(
    argv: list[str] | None = None,
    *,
    client_factory: Callable[[], HttpClient] | None = None,
    write_record: Callable[[dict[str, Any]], Path] = _write_record,
) -> int:
    parser = argparse.ArgumentParser(description="NEXUS live-eval harness (opt-in).")
    parser.add_argument(
        "--prompt-id",
        required=False,
        help="Run a single prompt id from benchmarks/prompts.json. Omit to run all.",
    )
    parser.add_argument(
        "--base-url",
        default="http://localhost:8000",
        help="Backend base URL for /api/generate.",
    )
    parser.add_argument(
        "--timeout-seconds",
        type=float,
        default=DEFAULT_TIMEOUT_SECONDS,
        help="Max wait per prompt before giving up.",
    )
    parser.add_argument(
        "--poll-interval-seconds",
        type=float,
        default=DEFAULT_POLL_INTERVAL_SECONDS,
        help="Seconds between /api/slides polls.",
    )
    parser.add_argument(
        "--theme",
        default="Editorial",
        help="Theme to pass to /api/generate.",
    )
    search_group = parser.add_mutually_exclusive_group()
    search_group.add_argument(
        "--search-web",
        dest="search_web",
        action="store_true",
        help="Pass search_web=true to /api/generate (default).",
    )
    search_group.add_argument(
        "--no-search-web",
        dest="search_web",
        action="store_false",
        help="Pass search_web=false to /api/generate.",
    )
    parser.set_defaults(search_web=True)
    parser.add_argument(
        "--slide-count",
        type=int,
        default=8,
        help="slide_count to pass to /api/generate.",
    )
    args = parser.parse_args(argv)

    _refuse_if_not_opted_in()

    prompts = load_prompts()
    if args.prompt_id:
        prompts = [get_prompt_spec(args.prompt_id, prompts)]

    client = (client_factory or _build_default_client)()
    written: list[Path] = []
    failures: list[tuple[str, str]] = []
    try:
        for spec in prompts:
            # Phase 6U: prefer the per-prompt evidence target so each
            # prompt's required source count drives the harvest depth.
            evidence = spec.get("expected_evidence") or {}
            spec_min_sources = 0
            try:
                spec_min_sources = int(evidence.get("min_sources", 0) or 0)
            except (TypeError, ValueError):
                spec_min_sources = 0
            try:
                deck = run_live_generation(
                    spec["prompt"],
                    base_url=args.base_url,
                    theme=args.theme,
                    search_web=args.search_web,
                    slide_count=args.slide_count,
                    timeout_seconds=args.timeout_seconds,
                    poll_interval_seconds=args.poll_interval_seconds,
                    client=client,
                    min_sources=spec_min_sources,
                )
            except (LiveGenerationError, LiveGenerationTimeout) as exc:
                failures.append((spec["id"], str(exc)))
                sys.stderr.write(f"[run_live_eval] {spec['id']} FAILED: {exc}\n")
                continue
            record = evaluate_deck(deck, spec, ran_live=True)
            written.append(write_record(record))
    finally:
        try:
            client.close()
        except Exception:
            pass

    for path in written:
        sys.stdout.write(f"wrote {path}\n")
    if failures:
        sys.stderr.write(
            f"[run_live_eval] {len(failures)} prompt(s) failed: "
            + ", ".join(f"{pid}={err}" for pid, err in failures)
            + "\n"
        )
        return 1
    return 0


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())