experience.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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 = 0,
  57. limit: int = 50,
  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."""
  75. case = ExperienceCase(
  76. source_plan_id=data.get("source_plan_id", ""),
  77. topology=data.get("topology", "SSSR"),
  78. model_path=data.get("model_path", ""),
  79. conclusion=data.get("conclusion", ""),
  80. tags=",".join(data.get("tags", [])),
  81. rating=data.get("rating", 0),
  82. )
  83. case.params_json = json.dumps(data.get("params", {}), ensure_ascii=False)
  84. case.metrics_json = json.dumps(data.get("metrics", {}), ensure_ascii=False)
  85. db.add(case)
  86. db.commit()
  87. db.refresh(case)
  88. return _case_to_dict(case)
  89. @router.get("/{case_id}")
  90. def get_experience(case_id: int, db: Session = Depends(get_db)):
  91. """Get an experience case by ID."""
  92. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  93. if not case:
  94. raise HTTPException(status_code=404, detail="Experience case not found")
  95. return _case_to_dict(case)
  96. @router.put("/{case_id}")
  97. def update_experience(
  98. case_id: int,
  99. data: dict,
  100. db: Session = Depends(get_db),
  101. ):
  102. """Update an experience case (conclusion, tags, rating, params, metrics)."""
  103. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  104. if not case:
  105. raise HTTPException(status_code=404, detail="Experience case not found")
  106. if "conclusion" in data:
  107. case.conclusion = data["conclusion"]
  108. if "tags" in data:
  109. case.tags = ",".join(data["tags"])
  110. if "rating" in data:
  111. case.rating = int(data["rating"])
  112. if "params" in data:
  113. case.params_json = json.dumps(data["params"], ensure_ascii=False)
  114. if "metrics" in data:
  115. case.metrics_json = json.dumps(data["metrics"], ensure_ascii=False)
  116. if "topology" in data:
  117. case.topology = data["topology"]
  118. if "model_path" in data:
  119. case.model_path = data["model_path"]
  120. db.commit()
  121. db.refresh(case)
  122. return _case_to_dict(case)
  123. @router.delete("/{case_id}", status_code=204)
  124. def delete_experience(case_id: int, db: Session = Depends(get_db)):
  125. """Delete an experience case."""
  126. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  127. if not case:
  128. raise HTTPException(status_code=404, detail="Experience case not found")
  129. db.delete(case)
  130. db.commit()
  131. return None
  132. # ---------------------------------------------------------------------------
  133. # Import from simulation results
  134. # ---------------------------------------------------------------------------
  135. @router.post("/from-plan/{plan_id}")
  136. def import_from_plan(
  137. plan_id: int,
  138. data: dict | None = None,
  139. db: Session = Depends(get_db),
  140. ):
  141. """Import all OK results from a plan into the experience library.
  142. Creates one experience case per OK simulation result.
  143. Auto-generates conclusion if not provided.
  144. Request body (optional):
  145. tags: list of tags to apply to all imported cases
  146. rating: default rating (0-5)
  147. auto_conclusion: bool (default True) - generate conclusion from metrics
  148. """
  149. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  150. if not plan:
  151. raise HTTPException(status_code=404, detail="Plan not found")
  152. data = data or {}
  153. tags = data.get("tags", ["auto-imported"])
  154. rating = int(data.get("rating", 0))
  155. auto_conclusion = data.get("auto_conclusion", True)
  156. results = db.query(SimulationResult).filter(
  157. SimulationResult.plan_id == plan_id,
  158. SimulationResult.status == "OK",
  159. ).all()
  160. if not results:
  161. return {"imported": 0, "skipped": 0, "message": "No OK results found in plan"}
  162. # Get topology and model_path from plan_json if available
  163. plan_dict = plan.get_plan_dict() if hasattr(plan, "get_plan_dict") else {}
  164. topology = plan_dict.get("topology") or getattr(plan, "topology", None) or "SSSR"
  165. model_path = plan_dict.get("model_path") or getattr(plan, "model_path", "") or ""
  166. imported = 0
  167. skipped = 0
  168. for r in results:
  169. params = r.get_params()
  170. metrics = r.get_metrics()
  171. if not params or not metrics:
  172. skipped += 1
  173. continue
  174. conclusion = ""
  175. if auto_conclusion:
  176. conclusion = _generate_conclusion(params, metrics)
  177. case = ExperienceCase(
  178. source_plan_id=plan.plan_id or str(plan.id),
  179. topology=topology,
  180. model_path=model_path,
  181. conclusion=conclusion,
  182. tags=",".join(tags),
  183. rating=rating,
  184. )
  185. case.params_json = json.dumps(params, ensure_ascii=False)
  186. case.metrics_json = json.dumps(metrics, ensure_ascii=False)
  187. db.add(case)
  188. imported += 1
  189. db.commit()
  190. return {
  191. "imported": imported,
  192. "skipped": skipped,
  193. "plan_id": plan.id,
  194. "plan_uuid": plan.plan_id,
  195. "message": f"Imported {imported} cases from plan",
  196. }
  197. @router.post("/from-result/{result_id}")
  198. def import_from_result(
  199. result_id: int,
  200. data: dict | None = None,
  201. db: Session = Depends(get_db),
  202. ):
  203. """Import a single simulation result into the experience library."""
  204. result = db.query(SimulationResult).filter(SimulationResult.id == result_id).first()
  205. if not result:
  206. raise HTTPException(status_code=404, detail="Result not found")
  207. if result.status != "OK":
  208. raise HTTPException(status_code=400, detail=f"Cannot import non-OK result (status={result.status})")
  209. data = data or {}
  210. plan = db.query(SimulationPlan).filter(SimulationPlan.id == result.plan_id).first()
  211. params = result.get_params()
  212. metrics = result.get_metrics()
  213. conclusion = data.get("conclusion") or _generate_conclusion(params, metrics)
  214. tags = data.get("tags", ["auto-imported"])
  215. rating = int(data.get("rating", 0))
  216. # Get topology and model_path from plan_json if available
  217. plan_dict = plan.get_plan_dict() if plan and hasattr(plan, "get_plan_dict") else {}
  218. topology = plan_dict.get("topology") or (getattr(plan, "topology", None) if plan else None) or "SSSR"
  219. model_path = plan_dict.get("model_path") or (getattr(plan, "model_path", "") if plan else "") or ""
  220. case = ExperienceCase(
  221. source_plan_id=plan.plan_id if plan else str(result.plan_id),
  222. topology=topology,
  223. model_path=model_path,
  224. conclusion=conclusion,
  225. tags=",".join(tags),
  226. rating=rating,
  227. )
  228. case.params_json = json.dumps(params, ensure_ascii=False)
  229. case.metrics_json = json.dumps(metrics, ensure_ascii=False)
  230. db.add(case)
  231. db.commit()
  232. db.refresh(case)
  233. return _case_to_dict(case)