| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- """API client for web backend integration (System 2 <-> System 1).
- Allows the local executor to:
- - Fetch simulation plans from the web backend
- - Upload scan results back to the web backend
- All source is ASCII.
- """
- from __future__ import annotations
- import json
- import os
- from pathlib import Path
- from typing import Any
- import urllib.request
- import urllib.error
- class WebAPIClient:
- """Client for the web backend REST API."""
- def __init__(self, base_url: str = "http://127.0.0.1:8000"):
- self.base_url = base_url.rstrip("/")
- self.timeout = 30
- def _request(self, method: str, path: str, data: dict | None = None,
- files: dict | None = None) -> Any:
- """Make an HTTP request to the API."""
- url = f"{self.base_url}{path}"
- if files:
- # Multipart form upload
- import uuid
- boundary = uuid.uuid4().hex
- body = b""
- for field_name, file_info in files.items():
- body += f"--{boundary}\r\n".encode()
- body += f'Content-Disposition: form-data; name="{field_name}"; filename="{file_info["filename"]}"\r\n'.encode()
- body += b"Content-Type: application/octet-stream\r\n\r\n"
- body += file_info["content"]
- body += b"\r\n"
- body += f"--{boundary}--\r\n".encode()
- req = urllib.request.Request(url, data=body, method=method)
- req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
- else:
- body = json.dumps(data).encode() if data else None
- req = urllib.request.Request(url, data=body, method=method)
- req.add_header("Content-Type", "application/json")
- try:
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
- return json.loads(resp.read())
- except urllib.error.HTTPError as e:
- error_body = e.read().decode()
- raise RuntimeError(f"API {method} {path} failed: {e.code} {error_body}")
- except urllib.error.URLError as e:
- raise RuntimeError(f"API connection failed: {e.reason}")
- # -- Health --
- def health_check(self) -> bool:
- """Check if the web backend is reachable."""
- try:
- result = self._request("GET", "/api/health")
- return result.get("status") == "ok"
- except Exception:
- return False
- # -- Projects --
- def list_projects(self, topology: str | None = None) -> list[dict]:
- """List all projects."""
- path = "/api/projects"
- if topology:
- path += f"?topology={topology}"
- result = self._request("GET", path)
- return result.get("items", [])
- def get_project(self, project_id: int) -> dict:
- """Get a project by ID."""
- return self._request("GET", f"/api/projects/{project_id}")
- # -- Plans --
- def list_plans(self, project_id: int | None = None) -> list[dict]:
- """List plans, optionally filtered by project."""
- path = "/api/plans"
- if project_id:
- path += f"?project_id={project_id}"
- result = self._request("GET", path)
- return result.get("items", [])
- def get_plan(self, plan_id: int) -> dict:
- """Get a plan by ID."""
- return self._request("GET", f"/api/plans/{plan_id}")
- def download_plan(self, plan_id: int) -> dict:
- """Download a plan as simulation_plan.json (compatible with local executor)."""
- return self._request("GET", f"/api/plans/{plan_id}/download")
- def download_plan_by_uuid(self, plan_uuid: str) -> dict:
- """Download a plan by its plan_id string (e.g. SP-20260827-001)."""
- return self._request("GET", f"/api/plans/by-plan-id/{plan_uuid}/download")
- def upload_results(self, plan_id: int, csv_path: str | Path) -> dict:
- """Upload scan_results.csv to the web backend."""
- csv_path = Path(csv_path)
- if not csv_path.exists():
- raise FileNotFoundError(f"CSV file not found: {csv_path}")
- with open(csv_path, "rb") as f:
- content = f.read()
- return self._request(
- "POST",
- f"/api/plans/{plan_id}/upload-results",
- files={"file": {"filename": csv_path.name, "content": content}},
- )
- def get_plan_results(self, plan_id: int) -> list[dict]:
- """Get all results for a plan."""
- result = self._request("GET", f"/api/plans/{plan_id}/results")
- return result.get("items", [])
- # -- Experience Library --
- def list_experience(self, topology: str | None = None,
- tag: str | None = None, limit: int = 50) -> list[dict]:
- """List experience cases with optional filters."""
- path = f"/api/experience?limit={limit}"
- if topology:
- path += f"&topology={topology}"
- if tag:
- path += f"&tag={tag}"
- result = self._request("GET", path)
- return result.get("items", [])
- def get_experience(self, case_id: int) -> dict:
- """Get an experience case by ID."""
- return self._request("GET", f"/api/experience/{case_id}")
- def create_experience(self, data: dict) -> dict:
- """Create an experience case.
- Args:
- data: dict with params, metrics, topology, conclusion, tags, rating, etc.
- """
- return self._request("POST", "/api/experience", data=data)
- def update_experience(self, case_id: int, data: dict) -> dict:
- """Update an experience case (conclusion, tags, rating, params, metrics)."""
- return self._request("PUT", f"/api/experience/{case_id}", data=data)
- def delete_experience(self, case_id: int) -> None:
- """Delete an experience case."""
- self._request("DELETE", f"/api/experience/{case_id}")
- def import_experience_from_plan(self, plan_id: int,
- tags: list[str] | None = None,
- rating: int = 0,
- auto_conclusion: bool = True) -> dict:
- """Import all OK results from a plan into the experience library.
- Returns dict with imported/skipped counts.
- """
- data = {
- "tags": tags or ["auto-imported"],
- "rating": rating,
- "auto_conclusion": auto_conclusion,
- }
- return self._request("POST", f"/api/experience/from-plan/{plan_id}", data=data)
- def import_experience_from_result(self, result_id: int,
- conclusion: str | None = None,
- tags: list[str] | None = None,
- rating: int = 0) -> dict:
- """Import a single simulation result into the experience library."""
- data = {
- "conclusion": conclusion or "",
- "tags": tags or ["auto-imported"],
- "rating": rating,
- }
- return self._request("POST", f"/api/experience/from-result/{result_id}", data=data)
- # -- Analytics --
- def get_metric_defs(self) -> list[dict]:
- """Get all available metric definitions."""
- result = self._request("GET", "/api/analytics/metrics")
- return result.get("metrics", [])
- def get_experience_stats(self, topology: str | None = None) -> dict:
- """Get experience library aggregate statistics."""
- path = "/api/analytics/experience/stats"
- if topology:
- path += f"?topology={topology}"
- return self._request("GET", path)
- def find_similar_experience(self, params: dict[str, float],
- topology: str | None = None,
- top_k: int = 5,
- tolerance: float = 0.3) -> list[dict]:
- """Find experience cases similar to target parameters."""
- query = f"?top_k={top_k}&tolerance={tolerance}"
- if topology:
- query += f"&topology={topology}"
- result = self._request("POST", f"/api/analytics/experience/similar{query}",
- data={"params": params})
- return result.get("items", [])
- def get_plan_trend(self, plan_id: int, param_key: str, metric_key: str) -> dict:
- """Get parameter vs metric trend data for a plan."""
- return self._request("GET",
- f"/api/analytics/plans/{plan_id}/trend?param_key={param_key}&metric_key={metric_key}")
- def get_plan_pareto(self, plan_id: int, x_metric: str = "total_losses_w",
- y_metric: str = "efficiency_pct") -> dict:
- """Get Pareto frontier data for a plan."""
- return self._request("GET",
- f"/api/analytics/plans/{plan_id}/pareto?x_metric={x_metric}&y_metric={y_metric}")
- def get_plan_sensitivity(self, plan_id: int, metric_key: str) -> dict:
- """Get parameter sensitivity ranking for a target metric."""
- return self._request("GET",
- f"/api/analytics/plans/{plan_id}/sensitivity?metric_key={metric_key}")
- def get_project_overview(self, project_id: int) -> dict:
- """Get project overview analytics (plans, results, best metrics)."""
- return self._request("GET", f"/api/analytics/projects/{project_id}/overview")
- # -- Sync: local experience DB to web --
- def sync_local_experience_to_web(self, local_db_path: str | Path,
- topology: str | None = None) -> dict:
- """Sync cases from local experience DB to the web experience library.
- Reads all runs from local SQLite DB and creates corresponding
- experience cases on the web backend. Skips duplicates by
- (plan_id + params signature).
- Returns dict with synced/skipped counts.
- """
- from .experience_db import ExperienceDB
- local_db = ExperienceDB(local_db_path)
- runs = local_db.get_all_runs(topology=topology, limit=1000)
- local_db.close()
- # Get existing web cases to avoid duplicates
- existing = self.list_experience(topology=topology, limit=500)
- existing_keys = set()
- for c in existing:
- key_parts = [c.get("source_plan_id", "")]
- for k in sorted(c.get("params", {}).keys()):
- key_parts.append(f"{k}={c['params'][k]}")
- existing_keys.add("|".join(key_parts))
- synced = 0
- skipped = 0
- for run in runs:
- key_parts = [run.get("plan_id", "")]
- for k in sorted(run.get("params", {}).keys()):
- key_parts.append(f"{k}={run['params'][k]}")
- key = "|".join(key_parts)
- if key in existing_keys:
- skipped += 1
- continue
- try:
- self.create_experience({
- "source_plan_id": run.get("plan_id", ""),
- "topology": run.get("topology", "SSSR"),
- "model_path": run.get("model_path", ""),
- "params": run.get("params", {}),
- "metrics": run.get("metrics", {}),
- "conclusion": run.get("notes", "") or "Synced from local experience DB",
- "tags": ["local-sync"],
- "rating": 0,
- })
- synced += 1
- existing_keys.add(key)
- except Exception:
- skipped += 1
- return {"synced": synced, "skipped": skipped, "total_local": len(runs)}
- # -- Convenience: full workflow --
- def fetch_and_save_plan(self, plan_id: int, output_dir: str | Path) -> Path:
- """Download a plan and save it as simulation_plan.json.
- Args:
- plan_id: Plan ID in the web backend.
- output_dir: Directory to save the plan JSON.
- Returns:
- Path to the saved simulation_plan.json.
- """
- plan_data = self.download_plan(plan_id)
- output_dir = Path(output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
- plan_uuid = plan_data.get("plan_id", "plan")
- output_path = output_dir / f"{plan_uuid}.json"
- with open(output_path, "w", encoding="utf-8") as f:
- json.dump(plan_data.get("plan_data", plan_data), f, indent=2, ensure_ascii=False)
- return output_path
- def get_api_client() -> WebAPIClient:
- """Get API client with URL from environment variable or default."""
- base_url = os.environ.get("AFM_WEB_API_URL", "http://127.0.0.1:8000")
- return WebAPIClient(base_url)
|