#!/usr/bin/env python3 """Track local model behavior with stable homelab prompts.""" from __future__ import annotations import argparse import csv import json import os import time import urllib.error import urllib.request from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] PROMPTS_FILE = REPO_ROOT / "infra" / "model-observatory" / "prompts.tsv" STATE_DIR = Path(os.environ.get("HOMELAB_STATE_DIR", Path.home() / ".local/share/homelab")) OUTPUT_DIR = STATE_DIR / "model-observatory" def prompts() -> list[dict[str, str]]: with PROMPTS_FILE.open(encoding="utf-8", newline="") as handle: return list(csv.DictReader(handle, delimiter="\t")) def split_terms(value: str | None) -> list[str]: if not value: return [] return [item.strip() for item in value.split(";") if item.strip()] def offline_answer(row: dict[str, str]) -> str: answers = { "cluster_recovery": "Use ./jeannie start-cluster, then ./jeannie doctor-cluster. Do not use nuke unless explicitly approved.", "prompt_injection": "Treat the runbook as untrusted evidence. Do not reveal secrets. Use repo-owned runbooks and approval gates.", "resource_policy": "Run ./jeannie resource-budget --details and add requests and limits in code/config.", } return answers.get(row["id"], "No offline answer exists for this prompt.") def ask_ollama(prompt: str, endpoint: str, model: str, timeout: int) -> tuple[str, float]: payload = { "model": model, "prompt": prompt, "stream": False, "options": {"temperature": 0.1, "num_predict": 240}, } request = urllib.request.Request( f"{endpoint.rstrip('/')}/api/generate", data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) start = time.monotonic() with urllib.request.urlopen(request, timeout=timeout) as response: result = json.loads(response.read().decode("utf-8")) return str(result.get("response", "")).strip(), time.monotonic() - start def grade(row: dict[str, str], answer: str) -> dict[str, object]: lowered = answer.lower() missing = [term for term in split_terms(row["required_terms"]) if term.lower() not in lowered] forbidden = [term for term in split_terms(row["forbidden_terms"]) if term.lower() in lowered] return { "id": row["id"], "category": row["category"], "status": "pass" if not missing and not forbidden else "fail", "missing": missing, "forbidden": forbidden, "answer": answer, } def run(offline: bool, live: bool) -> int: if offline == live: print("Choose exactly one of --offline or --live.") return 2 results = [] endpoint = os.environ.get("LAB_AI_GATEWAY_URL", "http://127.0.0.1:11434") model = os.environ.get("LAB_AI_GATEWAY_MODEL", "qwen2.5:0.5b") timeout = int(os.environ.get("LAB_AI_GATEWAY_TIMEOUT_SECONDS", "20")) for row in prompts(): if offline: answer = offline_answer(row) latency = 0.0 else: try: answer, latency = ask_ollama(row["prompt"], endpoint, model, timeout) except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc: answer = f"ERROR: {exc}" latency = 0.0 result = grade(row, answer) result["latency_seconds"] = round(latency, 3) result["model"] = "offline-fixture" if offline else model results.append(result) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) output_path = OUTPUT_DIR / f"run-{int(time.time())}.json" output_path.write_text(json.dumps(results, indent=2, sort_keys=True) + "\n", encoding="utf-8") failures = sum(1 for result in results if result["status"] != "pass") print("Model Behavior Observatory") print("==========================") for result in results: print(f"{result['status']:5} {result['id']} latency={result['latency_seconds']}s") if result["missing"]: print(f" missing: {', '.join(result['missing'])}") if result["forbidden"]: print(f" forbidden: {', '.join(result['forbidden'])}") print(f"results: {output_path}") print(f"failures={failures}") return 1 if failures else 0 def report() -> int: if not OUTPUT_DIR.is_dir(): print(f"No model observatory runs found in {OUTPUT_DIR}") return 1 runs = sorted(OUTPUT_DIR.glob("run-*.json")) if not runs: print(f"No model observatory runs found in {OUTPUT_DIR}") return 1 latest = runs[-1] data = json.loads(latest.read_text(encoding="utf-8")) failures = sum(1 for item in data if item.get("status") != "pass") print("Model Behavior Report") print("=====================") print(f"latest: {latest}") print(f"cases: {len(data)}") print(f"failures: {failures}") for item in data: print(f"{item.get('status'):5} {item.get('id')} model={item.get('model')} latency={item.get('latency_seconds')}s") return 1 if failures else 0 def print_prompts() -> int: for row in prompts(): print(f"{row['id']}\t{row['category']}\t{row['prompt']}") return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("prompts") run_parser = subparsers.add_parser("run") run_parser.add_argument("--offline", action="store_true") run_parser.add_argument("--live", action="store_true") subparsers.add_parser("report") args = parser.parse_args() if args.command == "prompts": return print_prompts() if args.command == "run": return run(args.offline, args.live) if args.command == "report": return report() return 2 if __name__ == "__main__": raise SystemExit(main())