experience.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. """Experience case API router (CRUD + import from results for Phase 2)."""
  2. import json
  3. from fastapi import APIRouter, Depends, HTTPException, Query
  4. from sqlalchemy.orm import Session
  5. from ..database import get_db
  6. from ..models.experience_case import ExperienceCase
  7. from ..models.simulation_plan import SimulationPlan
  8. from ..models.simulation_result import SimulationResult
  9. router = APIRouter(prefix="/api/experience", tags=["experience"])
  10. def _case_to_dict(case: ExperienceCase) -> dict:
  11. return {
  12. "id": case.id,
  13. "source_plan_id": case.source_plan_id or "",
  14. "topology": case.topology or "SSSR",
  15. "model_path": case.model_path or "",
  16. "params": case.get_params(),
  17. "metrics": case.get_metrics(),
  18. "conclusion": case.conclusion or "",
  19. "tags": [t.strip() for t in (case.tags or "").split(",") if t.strip()],
  20. "rating": case.rating or 0,
  21. "created_at": case.created_at,
  22. }
  23. def _generate_conclusion(params: dict, metrics: dict) -> str:
  24. """Auto-generate a simple conclusion from metrics values.
  25. Uses ASCII-only text. Describes key performance indicators.
  26. """
  27. parts = []
  28. tavg = metrics.get("tavg_nm")
  29. if tavg is not None:
  30. parts.append(f"avg torque {tavg:.3f} Nm")
  31. eff = metrics.get("efficiency_pct")
  32. if eff is not None:
  33. parts.append(f"efficiency {eff:.1f}%")
  34. ripple = metrics.get("ripple_pct")
  35. if ripple is not None:
  36. parts.append(f"ripple {ripple:.2f}%")
  37. losses = metrics.get("total_losses_w")
  38. if losses is not None:
  39. parts.append(f"total losses {losses:.1f} W")
  40. if not parts:
  41. return "Imported from simulation result"
  42. # Add quality assessment
  43. if eff is not None and tavg is not None:
  44. if eff >= 85 and tavg >= 1.0:
  45. parts.append("good overall performance")
  46. elif eff < 80:
  47. parts.append("efficiency below target")
  48. return "; ".join(parts)
  49. # ---------------------------------------------------------------------------
  50. # CRUD
  51. # ---------------------------------------------------------------------------
  52. @router.get("")
  53. def list_experience(
  54. topology: str | None = None,
  55. tag: str | None = None,
  56. skip: int = Query(0, ge=0),
  57. limit: int = Query(50, ge=1, le=200),
  58. db: Session = Depends(get_db),
  59. ):
  60. """List experience cases with filters."""
  61. query = db.query(ExperienceCase)
  62. if topology:
  63. query = query.filter(ExperienceCase.topology == topology)
  64. if tag:
  65. query = query.filter(ExperienceCase.tags.contains(tag))
  66. total = query.count()
  67. cases = query.order_by(ExperienceCase.created_at.desc()).offset(skip).limit(limit).all()
  68. return {"total": total, "items": [_case_to_dict(c) for c in cases]}
  69. @router.post("", status_code=201)
  70. def create_experience(
  71. data: dict,
  72. db: Session = Depends(get_db),
  73. ):
  74. """Create an experience case from a dict. Dedup by source_plan_id + params hash."""
  75. params_json = json.dumps(data.get("params", {}), ensure_ascii=False, sort_keys=True)
  76. source_plan_id = data.get("source_plan_id", "")
  77. # Dedup check: if same plan_id and params already exist, return existing
  78. if source_plan_id:
  79. existing = db.query(ExperienceCase).filter(
  80. ExperienceCase.source_plan_id == source_plan_id,
  81. ExperienceCase.params_json == params_json,
  82. ).first()
  83. if existing:
  84. return _case_to_dict(existing)
  85. case = ExperienceCase(
  86. source_plan_id=source_plan_id,
  87. topology=data.get("topology", "SSSR"),
  88. model_path=data.get("model_path", ""),
  89. conclusion=data.get("conclusion", ""),
  90. tags=",".join(data.get("tags", [])),
  91. rating=data.get("rating", 0),
  92. )
  93. case.params_json = params_json
  94. case.metrics_json = json.dumps(data.get("metrics", {}), ensure_ascii=False)
  95. db.add(case)
  96. db.commit()
  97. db.refresh(case)
  98. return _case_to_dict(case)
  99. @router.get("/{case_id}")
  100. def get_experience(case_id: int, db: Session = Depends(get_db)):
  101. """Get an experience case by ID."""
  102. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  103. if not case:
  104. raise HTTPException(status_code=404, detail="Experience case not found")
  105. return _case_to_dict(case)
  106. @router.put("/{case_id}")
  107. def update_experience(
  108. case_id: int,
  109. data: dict,
  110. db: Session = Depends(get_db),
  111. ):
  112. """Update an experience case (conclusion, tags, rating, params, metrics)."""
  113. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  114. if not case:
  115. raise HTTPException(status_code=404, detail="Experience case not found")
  116. if "conclusion" in data:
  117. case.conclusion = data["conclusion"]
  118. if "tags" in data:
  119. case.tags = ",".join(data["tags"])
  120. if "rating" in data:
  121. case.rating = int(data["rating"])
  122. if "params" in data:
  123. case.params_json = json.dumps(data["params"], ensure_ascii=False)
  124. if "metrics" in data:
  125. case.metrics_json = json.dumps(data["metrics"], ensure_ascii=False)
  126. if "topology" in data:
  127. case.topology = data["topology"]
  128. if "model_path" in data:
  129. case.model_path = data["model_path"]
  130. db.commit()
  131. db.refresh(case)
  132. return _case_to_dict(case)
  133. @router.delete("/{case_id}", status_code=204)
  134. def delete_experience(case_id: int, db: Session = Depends(get_db)):
  135. """Delete an experience case."""
  136. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  137. if not case:
  138. raise HTTPException(status_code=404, detail="Experience case not found")
  139. db.delete(case)
  140. db.commit()
  141. return None
  142. # ---------------------------------------------------------------------------
  143. # Import from simulation results
  144. # ---------------------------------------------------------------------------
  145. @router.post("/from-plan/{plan_id}")
  146. def import_from_plan(
  147. plan_id: int,
  148. data: dict | None = None,
  149. db: Session = Depends(get_db),
  150. ):
  151. """Import all OK results from a plan into the experience library.
  152. Creates one experience case per OK simulation result.
  153. Auto-generates conclusion if not provided.
  154. Request body (optional):
  155. tags: list of tags to apply to all imported cases
  156. rating: default rating (0-5)
  157. auto_conclusion: bool (default True) - generate conclusion from metrics
  158. """
  159. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  160. if not plan:
  161. raise HTTPException(status_code=404, detail="Plan not found")
  162. data = data or {}
  163. tags = data.get("tags", ["auto-imported"])
  164. rating = int(data.get("rating", 0))
  165. auto_conclusion = data.get("auto_conclusion", True)
  166. results = db.query(SimulationResult).filter(
  167. SimulationResult.plan_id == plan_id,
  168. SimulationResult.status == "OK",
  169. ).all()
  170. if not results:
  171. return {"imported": 0, "skipped": 0, "message": "No OK results found in plan"}
  172. # Get topology and model_path from plan_json if available
  173. plan_dict = plan.get_plan_dict() if hasattr(plan, "get_plan_dict") else {}
  174. topology = plan_dict.get("topology") or getattr(plan, "topology", None) or "SSSR"
  175. model_path = plan_dict.get("model_path") or getattr(plan, "model_path", "") or ""
  176. imported = 0
  177. skipped = 0
  178. for r in results:
  179. params = r.get_params()
  180. metrics = r.get_metrics()
  181. if not params or not metrics:
  182. skipped += 1
  183. continue
  184. conclusion = ""
  185. if auto_conclusion:
  186. conclusion = _generate_conclusion(params, metrics)
  187. case = ExperienceCase(
  188. source_plan_id=plan.plan_id or str(plan.id),
  189. topology=topology,
  190. model_path=model_path,
  191. conclusion=conclusion,
  192. tags=",".join(tags),
  193. rating=rating,
  194. )
  195. case.params_json = json.dumps(params, ensure_ascii=False)
  196. case.metrics_json = json.dumps(metrics, ensure_ascii=False)
  197. db.add(case)
  198. imported += 1
  199. db.commit()
  200. return {
  201. "imported": imported,
  202. "skipped": skipped,
  203. "plan_id": plan.id,
  204. "plan_uuid": plan.plan_id,
  205. "message": f"Imported {imported} cases from plan",
  206. }
  207. @router.post("/from-result/{result_id}")
  208. def import_from_result(
  209. result_id: int,
  210. data: dict | None = None,
  211. db: Session = Depends(get_db),
  212. ):
  213. """Import a single simulation result into the experience library."""
  214. result = db.query(SimulationResult).filter(SimulationResult.id == result_id).first()
  215. if not result:
  216. raise HTTPException(status_code=404, detail="Result not found")
  217. if result.status != "OK":
  218. raise HTTPException(status_code=400, detail=f"Cannot import non-OK result (status={result.status})")
  219. data = data or {}
  220. plan = db.query(SimulationPlan).filter(SimulationPlan.id == result.plan_id).first()
  221. params = result.get_params()
  222. metrics = result.get_metrics()
  223. conclusion = data.get("conclusion") or _generate_conclusion(params, metrics)
  224. tags = data.get("tags", ["auto-imported"])
  225. rating = int(data.get("rating", 0))
  226. # Get topology and model_path from plan_json if available
  227. plan_dict = plan.get_plan_dict() if plan and hasattr(plan, "get_plan_dict") else {}
  228. topology = plan_dict.get("topology") or (getattr(plan, "topology", None) if plan else None) or "SSSR"
  229. model_path = plan_dict.get("model_path") or (getattr(plan, "model_path", "") if plan else "") or ""
  230. case = ExperienceCase(
  231. source_plan_id=plan.plan_id if plan else str(result.plan_id),
  232. topology=topology,
  233. model_path=model_path,
  234. conclusion=conclusion,
  235. tags=",".join(tags),
  236. rating=rating,
  237. )
  238. case.params_json = json.dumps(params, ensure_ascii=False)
  239. case.metrics_json = json.dumps(metrics, ensure_ascii=False)
  240. db.add(case)
  241. db.commit()
  242. db.refresh(case)
  243. return _case_to_dict(case)