ai_plan.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. """AI Plan Generation router (P3-M3).
  2. Endpoints for generating simulation plans from natural language
  3. using Kimi k3 model, with L0 pre-screening validation.
  4. Includes project-bound endpoint that saves AI-generated plan directly
  5. as a SimulationPlan under the project.
  6. """
  7. import json
  8. import uuid
  9. from datetime import datetime
  10. from typing import Dict, Any, Optional, List
  11. from fastapi import APIRouter, HTTPException, Depends
  12. from pydantic import BaseModel, Field
  13. from sqlalchemy.orm import Session
  14. from ..database import get_db
  15. from ..models.project import Project
  16. from ..models.simulation_plan import SimulationPlan
  17. from ..models.experience_case import ExperienceCase
  18. from ..services.plan_generator import get_plan_generator
  19. router = APIRouter(prefix="/api/ai-plan", tags=["AI Plan Generation"])
  20. def _load_experience_cases(db: Session, topology: str, limit: int = 5) -> List[Dict[str, Any]]:
  21. """Load recent experience cases for the topology so the AI can reuse prior
  22. design knowledge. Without this the library accumulated by the adaptive
  23. loop never flows back into plan generation (value-chain break)."""
  24. cases = (
  25. db.query(ExperienceCase)
  26. .filter(ExperienceCase.topology == topology)
  27. .order_by(ExperienceCase.created_at.desc())
  28. .limit(limit)
  29. .all()
  30. )
  31. out = []
  32. for c in cases:
  33. out.append({
  34. "topology": c.topology,
  35. "params": c.get_params(),
  36. "metrics": c.get_metrics(),
  37. "conclusion": c.conclusion,
  38. "tags": c.tags,
  39. })
  40. return out
  41. class GeneratePlanRequest(BaseModel):
  42. """Request to generate a plan from natural language."""
  43. requirement: str = Field(..., description="Natural language simulation requirement")
  44. project_context: Optional[Dict[str, Any]] = Field(default=None, description="Optional project context")
  45. existing_experience: Optional[List[Dict[str, Any]]] = Field(default=None, description="Optional similar experience cases")
  46. class GenerateAndSaveRequest(BaseModel):
  47. """Request to generate a plan and save it under a project."""
  48. requirement: str = Field(default="", description="Natural language requirement (optional, uses project BC as base)")
  49. model_path: Optional[str] = Field(default=None, description="Override model path")
  50. plan_name: Optional[str] = Field(default=None, description="Override plan name")
  51. class RefinePlanRequest(BaseModel):
  52. """Request to refine an existing plan."""
  53. original_plan: Dict[str, Any] = Field(..., description="The previously generated plan")
  54. feedback: str = Field(..., description="Natural language feedback for refinement")
  55. @router.post("/generate")
  56. def generate_plan(request: GeneratePlanRequest, db: Session = Depends(get_db)):
  57. """Generate a simulation plan from natural language.
  58. Uses Kimi k3 model to convert natural language requirements into
  59. a structured simulation plan, with L0 pre-screening validation.
  60. Returns unified plan format (fixed_params + variables with values).
  61. """
  62. try:
  63. generator = get_plan_generator()
  64. # Auto-load library experience when the caller didn't pass any.
  65. experience = request.existing_experience
  66. if experience is None:
  67. topo = (request.project_context or {}).get("topology", "SSSR")
  68. experience = _load_experience_cases(db, topo)
  69. result = generator.generate(
  70. user_requirement=request.requirement,
  71. project_context=request.project_context,
  72. existing_experience=experience,
  73. )
  74. return result
  75. except RuntimeError as e:
  76. raise HTTPException(status_code=503, detail=str(e))
  77. except Exception as e:
  78. raise HTTPException(status_code=500, detail=f"Plan generation failed: {str(e)}")
  79. @router.post("/projects/{project_id}/generate-and-save")
  80. def generate_and_save_plan(
  81. project_id: int,
  82. request: GenerateAndSaveRequest,
  83. db: Session = Depends(get_db),
  84. ):
  85. """Generate a plan using AI and save it directly under a project.
  86. Uses the project's boundary conditions as context. The generated plan
  87. is in unified format (fixed_params + variables with value lists) and
  88. is saved as a SimulationPlan with status 'draft'.
  89. Args:
  90. project_id: Target project ID.
  91. request: Optional natural language requirement and overrides.
  92. Returns:
  93. Created SimulationPlan response dict.
  94. """
  95. project = db.query(Project).filter(Project.id == project_id).first()
  96. if not project:
  97. raise HTTPException(status_code=404, detail="Project not found")
  98. # Build context from project boundary conditions. Expand against the full
  99. # BC catalog so unset fields appear explicitly as null - the AI can only
  100. # propose bc_suggestions for fields it can SEE are unset (previously unset
  101. # keys were simply absent, so the model had no way to know what to fill).
  102. from ..services.bc_fields import get_bc_field_catalog
  103. bc = project.get_boundary_conditions()
  104. full_bc = {f["key"]: bc.get(f["key"]) for f in get_bc_field_catalog()}
  105. context = {
  106. "project_id": project.id,
  107. "project_name": project.name,
  108. "topology": project.topology,
  109. "model_path": project.model_path or "",
  110. "boundary_conditions": full_bc,
  111. }
  112. # Build requirement: use explicit requirement or construct from BC
  113. if request.requirement.strip():
  114. requirement = request.requirement
  115. else:
  116. # Construct a requirement from boundary conditions
  117. parts = [f"Topology: {project.topology}"]
  118. if bc.get("outer_diameter_mm"):
  119. parts.append(f"Outer diameter <= {bc['outer_diameter_mm']}mm")
  120. if bc.get("speed_rpm"):
  121. parts.append(f"Rated speed {bc['speed_rpm']}rpm")
  122. if bc.get("current_a"):
  123. parts.append(f"RMS current {bc['current_a']}A")
  124. if bc.get("target_efficiency_pct"):
  125. parts.append(f"Target efficiency >= {bc['target_efficiency_pct']}%")
  126. if bc.get("target_torque_nm"):
  127. parts.append(f"Target torque >= {bc['target_torque_nm']}Nm")
  128. if bc.get("max_losses_w"):
  129. parts.append(f"Max losses <= {bc['max_losses_w']}W")
  130. if bc.get("power_w"):
  131. parts.append(f"Power {bc['power_w']}W")
  132. requirement = "Generate a simulation plan for: " + "; ".join(parts) + ". Recommend scan variables and fixed parameters."
  133. try:
  134. generator = get_plan_generator()
  135. result = generator.generate(
  136. user_requirement=requirement,
  137. project_context=context,
  138. existing_experience=_load_experience_cases(db, project.topology or "SSSR"),
  139. )
  140. except RuntimeError as e:
  141. raise HTTPException(status_code=503, detail=str(e))
  142. except Exception as e:
  143. raise HTTPException(status_code=500, detail=f"AI generation failed: {str(e)}")
  144. unified_plan = result.get("plan", {})
  145. if not unified_plan or "variables" not in unified_plan:
  146. raise HTTPException(status_code=502, detail="AI returned invalid plan format")
  147. # Override model_path and name
  148. model_path = request.model_path or project.model_path or unified_plan.get("model_path", "")
  149. plan_name = request.plan_name or unified_plan.get("name", f"AI Plan {datetime.now().strftime('%m%d %H%M')}")
  150. # Normalize topology against the single-source registry. Legacy projects may
  151. # carry a non-registered topology (e.g. "AFIR"); fall back to the only active
  152. # topology (SSSR) and surface a warning instead of failing with 422.
  153. from src.afmcore.topology import is_supported as _topology_supported
  154. topology = project.topology or "SSSR"
  155. topology_warnings: List[str] = []
  156. if not _topology_supported(topology):
  157. topology_warnings.append(
  158. f"Unknown topology '{topology}' - defaulted to SSSR"
  159. )
  160. topology = "SSSR"
  161. # Auto-fill the base model from the topology registry when nothing
  162. # provided one (project/model-less plans otherwise fail preflight with an
  163. # empty model_path). DRSS/SDSR have no base model yet -> stays empty and
  164. # preflight will report it explicitly.
  165. if not model_path:
  166. from src.afmcore.topology import default_model_for
  167. model_path = default_model_for(topology)
  168. if model_path:
  169. topology_warnings.append(
  170. f"model_path auto-filled from topology default model: {model_path}"
  171. )
  172. # Build plan_data
  173. plan_id = f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
  174. # Persist the project's boundary conditions (normalized to canonical BC
  175. # keys) onto the plan so the plan-detail boundary display has data and the
  176. # BC the plan was generated against is traceable (P1-1).
  177. # Source tracking (bc_meta): user-specified project BC keys are tagged
  178. # "user"; AI bc_suggestions only fill keys the user left unset and are
  179. # tagged "ai" (requirement: distinguish user-specified vs AI-supplemented).
  180. from ..services.bc_fields import normalize_bc
  181. user_bc = normalize_bc(bc)
  182. raw_ai_plan = result.get("raw_ai_plan", {})
  183. ai_suggestions = raw_ai_plan.get("bc_suggestions") if isinstance(raw_ai_plan, dict) else None
  184. bc_meta = {k: {"source": "user"} for k in user_bc}
  185. merged_bc = dict(user_bc)
  186. if isinstance(ai_suggestions, dict):
  187. for k, v in ai_suggestions.items():
  188. if v is None or v == "":
  189. continue
  190. norm = normalize_bc({k: v})
  191. for nk, nv in norm.items():
  192. if nk not in merged_bc: # never override a user-specified value
  193. merged_bc[nk] = nv
  194. bc_meta[nk] = {"source": "ai"}
  195. plan_data = {
  196. "plan_id": plan_id,
  197. "plan_version": "2.0",
  198. "created_at": datetime.now().isoformat(timespec="seconds"),
  199. "topology": topology,
  200. "model_path": model_path,
  201. "boundary_conditions": merged_bc,
  202. "bc_meta": bc_meta,
  203. "fixed_params": unified_plan.get("fixed_params", []),
  204. "variables": unified_plan.get("variables", []),
  205. "cases": unified_plan.get("cases", [{"id": "default", "name": "Default", "params": {}}]),
  206. "output_metrics": unified_plan.get("output_metrics", []),
  207. "search_strategy": unified_plan.get("search_strategy"),
  208. "acceptance_criteria": unified_plan.get("acceptance_criteria"),
  209. "ai_reasoning": unified_plan.get("ai_reasoning", ""),
  210. "iteration": 1,
  211. "parent_plan_id": "",
  212. }
  213. # Validate against the single-source plan schema (structural checks
  214. # before persisting an AI-generated plan).
  215. from src.plan_schema import validate_plan_dict
  216. _ok, _errs = validate_plan_dict(plan_data, require_model_path=False)
  217. if not _ok:
  218. raise HTTPException(
  219. status_code=422,
  220. detail="Invalid generated plan: " + "; ".join(_errs),
  221. )
  222. # Compute estimated points
  223. estimated_points = 1
  224. for v in plan_data["variables"]:
  225. estimated_points *= len(v.get("values", [])) if v.get("values") else 1
  226. # Variables summary for display
  227. variables_summary = {}
  228. for v in plan_data["variables"]:
  229. variables_summary[v["name"]] = {
  230. "unit": v.get("unit", ""),
  231. "values": v.get("values", []),
  232. "count": len(v.get("values", [])),
  233. }
  234. # Save to database
  235. db_plan = SimulationPlan(
  236. project_id=project_id,
  237. name=plan_name,
  238. plan_id=plan_id,
  239. status="draft",
  240. estimated_points=estimated_points,
  241. estimated_time_min=estimated_points * 3,
  242. notes=f"AI-generated. Reasoning: {unified_plan.get('ai_reasoning', '')[:500]}",
  243. )
  244. db_plan.set_plan_dict(plan_data)
  245. db_plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
  246. db.add(db_plan)
  247. db.commit()
  248. db.refresh(db_plan)
  249. return {
  250. "id": db_plan.id,
  251. "plan_id": db_plan.plan_id,
  252. "name": db_plan.name,
  253. "status": db_plan.status,
  254. "estimated_points": db_plan.estimated_points,
  255. "estimated_time_min": db_plan.estimated_time_min,
  256. "plan_data": plan_data,
  257. "ai_reasoning": unified_plan.get("ai_reasoning", ""),
  258. "validation": result.get("validation", {}),
  259. "warnings": topology_warnings + unified_plan.get("warnings", []),
  260. }
  261. @router.post("/refine")
  262. def refine_plan(request: RefinePlanRequest):
  263. """Refine an existing plan based on user feedback."""
  264. try:
  265. generator = get_plan_generator()
  266. result = generator.refine_plan(
  267. original_plan=request.original_plan,
  268. user_feedback=request.feedback,
  269. )
  270. return result
  271. except RuntimeError as e:
  272. raise HTTPException(status_code=503, detail=str(e))
  273. except Exception as e:
  274. raise HTTPException(status_code=500, detail=f"Plan refinement failed: {str(e)}")
  275. @router.get("/templates")
  276. def list_templates():
  277. """List available plan generation prompt templates."""
  278. return {
  279. "templates": [
  280. {
  281. "name": "generate",
  282. "path": "prompts/plan_generation/generate.txt",
  283. "description": "Natural language to structured simulation plan (unified v2.0)",
  284. }
  285. ],
  286. "model": "kimi-k3",
  287. "endpoint": "https://api.kimi.com/coding/v1",
  288. }