api_client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. """API client for web backend integration (System 2 <-> System 1).
  2. Allows the local executor to:
  3. - Fetch simulation plans from the web backend
  4. - Upload scan results back to the web backend
  5. All source is ASCII.
  6. """
  7. from __future__ import annotations
  8. import json
  9. import os
  10. from pathlib import Path
  11. from typing import Any
  12. import urllib.request
  13. import urllib.error
  14. class WebAPIClient:
  15. """Client for the web backend REST API."""
  16. def __init__(self, base_url: str = "http://127.0.0.1:8000"):
  17. self.base_url = base_url.rstrip("/")
  18. self.timeout = 30
  19. def _request(self, method: str, path: str, data: dict | None = None,
  20. files: dict | None = None) -> Any:
  21. """Make an HTTP request to the API."""
  22. url = f"{self.base_url}{path}"
  23. if files:
  24. # Multipart form upload
  25. import uuid
  26. boundary = uuid.uuid4().hex
  27. body = b""
  28. for field_name, file_info in files.items():
  29. body += f"--{boundary}\r\n".encode()
  30. body += f'Content-Disposition: form-data; name="{field_name}"; filename="{file_info["filename"]}"\r\n'.encode()
  31. body += b"Content-Type: application/octet-stream\r\n\r\n"
  32. body += file_info["content"]
  33. body += b"\r\n"
  34. body += f"--{boundary}--\r\n".encode()
  35. req = urllib.request.Request(url, data=body, method=method)
  36. req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
  37. else:
  38. body = json.dumps(data).encode() if data else None
  39. req = urllib.request.Request(url, data=body, method=method)
  40. req.add_header("Content-Type", "application/json")
  41. try:
  42. with urllib.request.urlopen(req, timeout=self.timeout) as resp:
  43. return json.loads(resp.read())
  44. except urllib.error.HTTPError as e:
  45. error_body = e.read().decode()
  46. raise RuntimeError(f"API {method} {path} failed: {e.code} {error_body}")
  47. except urllib.error.URLError as e:
  48. raise RuntimeError(f"API connection failed: {e.reason}")
  49. # -- Health --
  50. def health_check(self) -> bool:
  51. """Check if the web backend is reachable."""
  52. try:
  53. result = self._request("GET", "/api/health")
  54. return result.get("status") == "ok"
  55. except Exception:
  56. return False
  57. # -- Projects --
  58. def list_projects(self, topology: str | None = None) -> list[dict]:
  59. """List all projects."""
  60. path = "/api/projects"
  61. if topology:
  62. path += f"?topology={topology}"
  63. result = self._request("GET", path)
  64. return result.get("items", [])
  65. def get_project(self, project_id: int) -> dict:
  66. """Get a project by ID."""
  67. return self._request("GET", f"/api/projects/{project_id}")
  68. # -- Plans --
  69. def list_plans(self, project_id: int | None = None) -> list[dict]:
  70. """List plans, optionally filtered by project."""
  71. path = "/api/plans"
  72. if project_id:
  73. path += f"?project_id={project_id}"
  74. result = self._request("GET", path)
  75. return result.get("items", [])
  76. def get_plan(self, plan_id: int) -> dict:
  77. """Get a plan by ID."""
  78. return self._request("GET", f"/api/plans/{plan_id}")
  79. def download_plan(self, plan_id: int) -> dict:
  80. """Download a plan as simulation_plan.json (compatible with local executor)."""
  81. return self._request("GET", f"/api/plans/{plan_id}/download")
  82. def download_plan_by_uuid(self, plan_uuid: str) -> dict:
  83. """Download a plan by its plan_id string (e.g. SP-20260827-001)."""
  84. return self._request("GET", f"/api/plans/by-plan-id/{plan_uuid}/download")
  85. def upload_results(self, plan_id: int, csv_path: str | Path) -> dict:
  86. """Upload scan_results.csv to the web backend."""
  87. csv_path = Path(csv_path)
  88. if not csv_path.exists():
  89. raise FileNotFoundError(f"CSV file not found: {csv_path}")
  90. with open(csv_path, "rb") as f:
  91. content = f.read()
  92. return self._request(
  93. "POST",
  94. f"/api/plans/{plan_id}/upload-results",
  95. files={"file": {"filename": csv_path.name, "content": content}},
  96. )
  97. def get_plan_results(self, plan_id: int) -> list[dict]:
  98. """Get all results for a plan."""
  99. result = self._request("GET", f"/api/plans/{plan_id}/results")
  100. return result.get("items", [])
  101. # -- Experience Library --
  102. def list_experience(self, topology: str | None = None,
  103. tag: str | None = None, limit: int = 50) -> list[dict]:
  104. """List experience cases with optional filters."""
  105. path = f"/api/experience?limit={limit}"
  106. if topology:
  107. path += f"&topology={topology}"
  108. if tag:
  109. path += f"&tag={tag}"
  110. result = self._request("GET", path)
  111. return result.get("items", [])
  112. def get_experience(self, case_id: int) -> dict:
  113. """Get an experience case by ID."""
  114. return self._request("GET", f"/api/experience/{case_id}")
  115. def create_experience(self, data: dict) -> dict:
  116. """Create an experience case.
  117. Args:
  118. data: dict with params, metrics, topology, conclusion, tags, rating, etc.
  119. """
  120. return self._request("POST", "/api/experience", data=data)
  121. def update_experience(self, case_id: int, data: dict) -> dict:
  122. """Update an experience case (conclusion, tags, rating, params, metrics)."""
  123. return self._request("PUT", f"/api/experience/{case_id}", data=data)
  124. def delete_experience(self, case_id: int) -> None:
  125. """Delete an experience case."""
  126. self._request("DELETE", f"/api/experience/{case_id}")
  127. def import_experience_from_plan(self, plan_id: int,
  128. tags: list[str] | None = None,
  129. rating: int = 0,
  130. auto_conclusion: bool = True) -> dict:
  131. """Import all OK results from a plan into the experience library.
  132. Returns dict with imported/skipped counts.
  133. """
  134. data = {
  135. "tags": tags or ["auto-imported"],
  136. "rating": rating,
  137. "auto_conclusion": auto_conclusion,
  138. }
  139. return self._request("POST", f"/api/experience/from-plan/{plan_id}", data=data)
  140. def import_experience_from_result(self, result_id: int,
  141. conclusion: str | None = None,
  142. tags: list[str] | None = None,
  143. rating: int = 0) -> dict:
  144. """Import a single simulation result into the experience library."""
  145. data = {
  146. "conclusion": conclusion or "",
  147. "tags": tags or ["auto-imported"],
  148. "rating": rating,
  149. }
  150. return self._request("POST", f"/api/experience/from-result/{result_id}", data=data)
  151. # -- Analytics --
  152. def get_metric_defs(self) -> list[dict]:
  153. """Get all available metric definitions."""
  154. result = self._request("GET", "/api/analytics/metrics")
  155. return result.get("metrics", [])
  156. def get_experience_stats(self, topology: str | None = None) -> dict:
  157. """Get experience library aggregate statistics."""
  158. path = "/api/analytics/experience/stats"
  159. if topology:
  160. path += f"?topology={topology}"
  161. return self._request("GET", path)
  162. def find_similar_experience(self, params: dict[str, float],
  163. topology: str | None = None,
  164. top_k: int = 5,
  165. tolerance: float = 0.3) -> list[dict]:
  166. """Find experience cases similar to target parameters."""
  167. query = f"?top_k={top_k}&tolerance={tolerance}"
  168. if topology:
  169. query += f"&topology={topology}"
  170. result = self._request("POST", f"/api/analytics/experience/similar{query}",
  171. data={"params": params})
  172. return result.get("items", [])
  173. def get_plan_trend(self, plan_id: int, param_key: str, metric_key: str) -> dict:
  174. """Get parameter vs metric trend data for a plan."""
  175. return self._request("GET",
  176. f"/api/analytics/plans/{plan_id}/trend?param_key={param_key}&metric_key={metric_key}")
  177. def get_plan_pareto(self, plan_id: int, x_metric: str = "total_losses_w",
  178. y_metric: str = "efficiency_pct") -> dict:
  179. """Get Pareto frontier data for a plan."""
  180. return self._request("GET",
  181. f"/api/analytics/plans/{plan_id}/pareto?x_metric={x_metric}&y_metric={y_metric}")
  182. def get_plan_sensitivity(self, plan_id: int, metric_key: str) -> dict:
  183. """Get parameter sensitivity ranking for a target metric."""
  184. return self._request("GET",
  185. f"/api/analytics/plans/{plan_id}/sensitivity?metric_key={metric_key}")
  186. def get_project_overview(self, project_id: int) -> dict:
  187. """Get project overview analytics (plans, results, best metrics)."""
  188. return self._request("GET", f"/api/analytics/projects/{project_id}/overview")
  189. # -- Sync: local experience DB to web --
  190. def sync_local_experience_to_web(self, local_db_path: str | Path,
  191. topology: str | None = None) -> dict:
  192. """Sync cases from local experience DB to the web experience library.
  193. Reads all runs from local SQLite DB and creates corresponding
  194. experience cases on the web backend. Skips duplicates by
  195. (plan_id + params signature).
  196. Returns dict with synced/skipped counts.
  197. """
  198. from .experience_db import ExperienceDB
  199. local_db = ExperienceDB(local_db_path)
  200. runs = local_db.get_all_runs(topology=topology, limit=1000)
  201. local_db.close()
  202. # Get existing web cases to avoid duplicates
  203. existing = self.list_experience(topology=topology, limit=500)
  204. existing_keys = set()
  205. for c in existing:
  206. key_parts = [c.get("source_plan_id", "")]
  207. for k in sorted(c.get("params", {}).keys()):
  208. key_parts.append(f"{k}={c['params'][k]}")
  209. existing_keys.add("|".join(key_parts))
  210. synced = 0
  211. skipped = 0
  212. for run in runs:
  213. key_parts = [run.get("plan_id", "")]
  214. for k in sorted(run.get("params", {}).keys()):
  215. key_parts.append(f"{k}={run['params'][k]}")
  216. key = "|".join(key_parts)
  217. if key in existing_keys:
  218. skipped += 1
  219. continue
  220. try:
  221. self.create_experience({
  222. "source_plan_id": run.get("plan_id", ""),
  223. "topology": run.get("topology", "SSSR"),
  224. "model_path": run.get("model_path", ""),
  225. "params": run.get("params", {}),
  226. "metrics": run.get("metrics", {}),
  227. "conclusion": run.get("notes", "") or "Synced from local experience DB",
  228. "tags": ["local-sync"],
  229. "rating": 0,
  230. })
  231. synced += 1
  232. existing_keys.add(key)
  233. except Exception:
  234. skipped += 1
  235. return {"synced": synced, "skipped": skipped, "total_local": len(runs)}
  236. # -- Convenience: full workflow --
  237. def fetch_and_save_plan(self, plan_id: int, output_dir: str | Path) -> Path:
  238. """Download a plan and save it as simulation_plan.json.
  239. Args:
  240. plan_id: Plan ID in the web backend.
  241. output_dir: Directory to save the plan JSON.
  242. Returns:
  243. Path to the saved simulation_plan.json.
  244. """
  245. plan_data = self.download_plan(plan_id)
  246. output_dir = Path(output_dir)
  247. output_dir.mkdir(parents=True, exist_ok=True)
  248. plan_uuid = plan_data.get("plan_id", "plan")
  249. output_path = output_dir / f"{plan_uuid}.json"
  250. with open(output_path, "w", encoding="utf-8") as f:
  251. json.dump(plan_data.get("plan_data", plan_data), f, indent=2, ensure_ascii=False)
  252. return output_path
  253. def get_api_client() -> WebAPIClient:
  254. """Get API client with URL from environment variable or default."""
  255. base_url = os.environ.get("AFM_WEB_API_URL", "http://127.0.0.1:8000")
  256. return WebAPIClient(base_url)