Spaces:
Sleeping
Sleeping
| """Implementation of `uofa setup verify` (REQ-DIST-006). | |
| Runs an end-to-end smoke test against a known fixture and reports the | |
| result. The command is the user-facing reassurance step that confirms | |
| "your install actually works" right after setup completes; it is also | |
| the diagnostic for troubleshooting a broken install. | |
| Failure modes reported with actionable next-step messages: | |
| * Daemon health check failed → "model load timed out" | |
| * Extracted JSON failed validation → "extracted JSON failed schema validation" | |
| * F1 below the 0.95 threshold → "F1 below threshold (X.XX, expected ≥ 0.95)" | |
| The verify fixture lives in src/uofa_cli/_data/fixtures/verify/ and is | |
| calibrated for qwen3.5:4b. If the model is upgraded the fixture's | |
| expected.json may need re-calibration. | |
| """ | |
| from __future__ import annotations | |
| import importlib.resources | |
| import json | |
| import subprocess | |
| import time | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Callable | |
| from uofa_cli import setup_install, setup_state | |
| from uofa_cli.eval_scoring import score_extraction | |
| from uofa_cli.llm_extractor import _json_to_result | |
| F1_THRESHOLD = 0.95 | |
| class VerifyResult: | |
| ok: bool | |
| f1: float | None | |
| diagnostic: str | |
| elapsed_seconds: float | |
| def _verify_fixture_dir() -> Path: | |
| """Locate the verify fixture inside the installed package. | |
| Uses importlib.resources so it works in editable installs and zipapp- | |
| style bundles equally. | |
| """ | |
| pkg_files = importlib.resources.files("uofa_cli") | |
| fixture = pkg_files / "_data" / "fixtures" / "verify" | |
| return Path(str(fixture)) | |
| def verify( | |
| cfg: setup_state.SetupConfig | None = None, | |
| on_status: Callable[[str], None] | None = None, | |
| ) -> VerifyResult: | |
| """Run the end-to-end verify smoke test.""" | |
| say = on_status or (lambda _: None) | |
| started = time.monotonic() | |
| if cfg is None: | |
| cfg = setup_state.load_config() | |
| if cfg is None: | |
| return VerifyResult( | |
| ok=False, | |
| f1=None, | |
| diagnostic="No config: run `uofa setup` first.", | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| fixture_dir = _verify_fixture_dir() | |
| passage_path = fixture_dir / "passage.txt" | |
| expected_path = fixture_dir / "expected.json" | |
| if not passage_path.is_file() or not expected_path.is_file(): | |
| return VerifyResult( | |
| ok=False, | |
| f1=None, | |
| diagnostic=f"Verify fixture missing under {fixture_dir}", | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| say(f"Starting Ollama daemon on port {cfg.ollama_port}.") | |
| daemon = setup_install.start_managed_daemon( | |
| cfg.ollama_binary, | |
| port=cfg.ollama_port, | |
| models_dir=cfg.ollama_models_dir, | |
| ) | |
| try: | |
| try: | |
| setup_install.wait_for_daemon(cfg.ollama_port) | |
| except TimeoutError as e: | |
| return VerifyResult( | |
| ok=False, | |
| f1=None, | |
| diagnostic=f"model load timed out: {e}", | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| say("Running extraction against fixture passage.") | |
| try: | |
| extracted_json = _run_extraction(cfg, passage_path) | |
| except RuntimeError as e: | |
| return VerifyResult( | |
| ok=False, | |
| f1=None, | |
| diagnostic=f"extraction failed: {e}", | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| try: | |
| ground_truth = json.loads(expected_path.read_text()) | |
| except json.JSONDecodeError as e: | |
| return VerifyResult( | |
| ok=False, | |
| f1=None, | |
| diagnostic=f"expected.json is malformed: {e}", | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| try: | |
| result = _json_to_result(extracted_json, pack_name=ground_truth.get("pack", "vv40")) | |
| except Exception as e: | |
| return VerifyResult( | |
| ok=False, | |
| f1=None, | |
| diagnostic=f"extracted JSON failed schema validation: {e}", | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| scores = score_extraction(result, ground_truth) | |
| f1 = scores["f1"] | |
| if f1 < F1_THRESHOLD: | |
| return VerifyResult( | |
| ok=False, | |
| f1=f1, | |
| diagnostic=( | |
| f"F1 below threshold ({f1:.2f}, expected >= {F1_THRESHOLD})" | |
| ), | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| return VerifyResult( | |
| ok=True, | |
| f1=f1, | |
| diagnostic=f"verified: F1={f1:.2f} (>= {F1_THRESHOLD})", | |
| elapsed_seconds=time.monotonic() - started, | |
| ) | |
| finally: | |
| daemon.terminate() | |
| try: | |
| daemon.wait(timeout=5) | |
| except subprocess.TimeoutExpired: | |
| daemon.kill() | |
| def _run_extraction(cfg: setup_state.SetupConfig, passage_path: Path) -> dict: | |
| """Send the passage text to the daemon and return the parsed JSON. | |
| Bypasses the heavier extract_cmd dispatch path (which insists on a | |
| pack manifest, prompt template, etc.) in favor of a direct | |
| /api/chat call with a minimal "extract credibility factors as JSON" | |
| prompt. This keeps the verify fixture tiny. | |
| """ | |
| import requests | |
| text = passage_path.read_text() | |
| prompt = ( | |
| "Extract credibility factors mentioned in the following passage " | |
| "as a single JSON object with a 'credibility_factors' array. " | |
| "Each factor must include 'factor_type' (string) and " | |
| "'achieved_level' (integer 0-4) when stated. Return ONLY the JSON.\n\n" | |
| f"PASSAGE:\n{text}" | |
| ) | |
| resp = requests.post( | |
| f"http://127.0.0.1:{cfg.ollama_port}/api/chat", | |
| json={ | |
| "model": cfg.model_tag, | |
| "messages": [{"role": "user", "content": prompt}], | |
| "stream": False, | |
| "format": "json", | |
| }, | |
| timeout=180, | |
| ) | |
| resp.raise_for_status() | |
| payload = resp.json() | |
| content = payload.get("message", {}).get("content", "") | |
| if not content: | |
| raise RuntimeError(f"Empty response from /api/chat: {payload}") | |
| try: | |
| return json.loads(content) | |
| except json.JSONDecodeError as e: | |
| raise RuntimeError(f"daemon returned non-JSON content: {content[:200]}") from e | |