| """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: |
| import httpx |
|
|
| return httpx.Client() |
|
|
|
|
| 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), |
| } |
| |
| |
| 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: |
| |
| |
| |
| 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: |
| |
| |
| 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__": |
| raise SystemExit(main()) |
|
|