| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325 |
- """AI Plan Generation router (P3-M3).
- Endpoints for generating simulation plans from natural language
- using Kimi k3 model, with L0 pre-screening validation.
- Includes project-bound endpoint that saves AI-generated plan directly
- as a SimulationPlan under the project.
- """
- import json
- import uuid
- from datetime import datetime
- from typing import Dict, Any, Optional, List
- from fastapi import APIRouter, HTTPException, Depends
- from pydantic import BaseModel, Field
- from sqlalchemy.orm import Session
- from ..database import get_db
- from ..models.project import Project
- from ..models.simulation_plan import SimulationPlan
- from ..models.experience_case import ExperienceCase
- from ..services.plan_generator import get_plan_generator
- router = APIRouter(prefix="/api/ai-plan", tags=["AI Plan Generation"])
- def _load_experience_cases(db: Session, topology: str, limit: int = 5) -> List[Dict[str, Any]]:
- """Load recent experience cases for the topology so the AI can reuse prior
- design knowledge. Without this the library accumulated by the adaptive
- loop never flows back into plan generation (value-chain break)."""
- cases = (
- db.query(ExperienceCase)
- .filter(ExperienceCase.topology == topology)
- .order_by(ExperienceCase.created_at.desc())
- .limit(limit)
- .all()
- )
- out = []
- for c in cases:
- out.append({
- "topology": c.topology,
- "params": c.get_params(),
- "metrics": c.get_metrics(),
- "conclusion": c.conclusion,
- "tags": c.tags,
- })
- return out
- class GeneratePlanRequest(BaseModel):
- """Request to generate a plan from natural language."""
- requirement: str = Field(..., description="Natural language simulation requirement")
- project_context: Optional[Dict[str, Any]] = Field(default=None, description="Optional project context")
- existing_experience: Optional[List[Dict[str, Any]]] = Field(default=None, description="Optional similar experience cases")
- class GenerateAndSaveRequest(BaseModel):
- """Request to generate a plan and save it under a project."""
- requirement: str = Field(default="", description="Natural language requirement (optional, uses project BC as base)")
- model_path: Optional[str] = Field(default=None, description="Override model path")
- plan_name: Optional[str] = Field(default=None, description="Override plan name")
- class RefinePlanRequest(BaseModel):
- """Request to refine an existing plan."""
- original_plan: Dict[str, Any] = Field(..., description="The previously generated plan")
- feedback: str = Field(..., description="Natural language feedback for refinement")
- @router.post("/generate")
- def generate_plan(request: GeneratePlanRequest, db: Session = Depends(get_db)):
- """Generate a simulation plan from natural language.
- Uses Kimi k3 model to convert natural language requirements into
- a structured simulation plan, with L0 pre-screening validation.
- Returns unified plan format (fixed_params + variables with values).
- """
- try:
- generator = get_plan_generator()
- # Auto-load library experience when the caller didn't pass any.
- experience = request.existing_experience
- if experience is None:
- topo = (request.project_context or {}).get("topology", "SSSR")
- experience = _load_experience_cases(db, topo)
- result = generator.generate(
- user_requirement=request.requirement,
- project_context=request.project_context,
- existing_experience=experience,
- )
- return result
- except RuntimeError as e:
- raise HTTPException(status_code=503, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Plan generation failed: {str(e)}")
- @router.post("/projects/{project_id}/generate-and-save")
- def generate_and_save_plan(
- project_id: int,
- request: GenerateAndSaveRequest,
- db: Session = Depends(get_db),
- ):
- """Generate a plan using AI and save it directly under a project.
- Uses the project's boundary conditions as context. The generated plan
- is in unified format (fixed_params + variables with value lists) and
- is saved as a SimulationPlan with status 'draft'.
- Args:
- project_id: Target project ID.
- request: Optional natural language requirement and overrides.
- Returns:
- Created SimulationPlan response dict.
- """
- project = db.query(Project).filter(Project.id == project_id).first()
- if not project:
- raise HTTPException(status_code=404, detail="Project not found")
- # Build context from project boundary conditions. Expand against the full
- # BC catalog so unset fields appear explicitly as null - the AI can only
- # propose bc_suggestions for fields it can SEE are unset (previously unset
- # keys were simply absent, so the model had no way to know what to fill).
- from ..services.bc_fields import get_bc_field_catalog
- bc = project.get_boundary_conditions()
- full_bc = {f["key"]: bc.get(f["key"]) for f in get_bc_field_catalog()}
- context = {
- "project_id": project.id,
- "project_name": project.name,
- "topology": project.topology,
- "model_path": project.model_path or "",
- "boundary_conditions": full_bc,
- }
- # Build requirement: use explicit requirement or construct from BC
- if request.requirement.strip():
- requirement = request.requirement
- else:
- # Construct a requirement from boundary conditions
- parts = [f"Topology: {project.topology}"]
- if bc.get("outer_diameter_mm"):
- parts.append(f"Outer diameter <= {bc['outer_diameter_mm']}mm")
- if bc.get("speed_rpm"):
- parts.append(f"Rated speed {bc['speed_rpm']}rpm")
- if bc.get("current_a"):
- parts.append(f"RMS current {bc['current_a']}A")
- if bc.get("target_efficiency_pct"):
- parts.append(f"Target efficiency >= {bc['target_efficiency_pct']}%")
- if bc.get("target_torque_nm"):
- parts.append(f"Target torque >= {bc['target_torque_nm']}Nm")
- if bc.get("max_losses_w"):
- parts.append(f"Max losses <= {bc['max_losses_w']}W")
- if bc.get("power_w"):
- parts.append(f"Power {bc['power_w']}W")
- requirement = "Generate a simulation plan for: " + "; ".join(parts) + ". Recommend scan variables and fixed parameters."
- try:
- generator = get_plan_generator()
- result = generator.generate(
- user_requirement=requirement,
- project_context=context,
- existing_experience=_load_experience_cases(db, project.topology or "SSSR"),
- )
- except RuntimeError as e:
- raise HTTPException(status_code=503, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"AI generation failed: {str(e)}")
- unified_plan = result.get("plan", {})
- if not unified_plan or "variables" not in unified_plan:
- raise HTTPException(status_code=502, detail="AI returned invalid plan format")
- # Override model_path and name
- model_path = request.model_path or project.model_path or unified_plan.get("model_path", "")
- plan_name = request.plan_name or unified_plan.get("name", f"AI Plan {datetime.now().strftime('%m%d %H%M')}")
- # Normalize topology against the single-source registry. Legacy projects may
- # carry a non-registered topology (e.g. "AFIR"); fall back to the only active
- # topology (SSSR) and surface a warning instead of failing with 422.
- from src.afmcore.topology import is_supported as _topology_supported
- topology = project.topology or "SSSR"
- topology_warnings: List[str] = []
- if not _topology_supported(topology):
- topology_warnings.append(
- f"Unknown topology '{topology}' - defaulted to SSSR"
- )
- topology = "SSSR"
- # Auto-fill the base model from the topology registry when nothing
- # provided one (project/model-less plans otherwise fail preflight with an
- # empty model_path). DRSS/SDSR have no base model yet -> stays empty and
- # preflight will report it explicitly.
- if not model_path:
- from src.afmcore.topology import default_model_for
- model_path = default_model_for(topology)
- if model_path:
- topology_warnings.append(
- f"model_path auto-filled from topology default model: {model_path}"
- )
- # Build plan_data
- plan_id = f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
- # Persist the project's boundary conditions (normalized to canonical BC
- # keys) onto the plan so the plan-detail boundary display has data and the
- # BC the plan was generated against is traceable (P1-1).
- # Source tracking (bc_meta): user-specified project BC keys are tagged
- # "user"; AI bc_suggestions only fill keys the user left unset and are
- # tagged "ai" (requirement: distinguish user-specified vs AI-supplemented).
- from ..services.bc_fields import normalize_bc
- user_bc = normalize_bc(bc)
- raw_ai_plan = result.get("raw_ai_plan", {})
- ai_suggestions = raw_ai_plan.get("bc_suggestions") if isinstance(raw_ai_plan, dict) else None
- bc_meta = {k: {"source": "user"} for k in user_bc}
- merged_bc = dict(user_bc)
- if isinstance(ai_suggestions, dict):
- for k, v in ai_suggestions.items():
- if v is None or v == "":
- continue
- norm = normalize_bc({k: v})
- for nk, nv in norm.items():
- if nk not in merged_bc: # never override a user-specified value
- merged_bc[nk] = nv
- bc_meta[nk] = {"source": "ai"}
- plan_data = {
- "plan_id": plan_id,
- "plan_version": "2.0",
- "created_at": datetime.now().isoformat(timespec="seconds"),
- "topology": topology,
- "model_path": model_path,
- "boundary_conditions": merged_bc,
- "bc_meta": bc_meta,
- "fixed_params": unified_plan.get("fixed_params", []),
- "variables": unified_plan.get("variables", []),
- "cases": unified_plan.get("cases", [{"id": "default", "name": "Default", "params": {}}]),
- "output_metrics": unified_plan.get("output_metrics", []),
- "search_strategy": unified_plan.get("search_strategy"),
- "acceptance_criteria": unified_plan.get("acceptance_criteria"),
- "ai_reasoning": unified_plan.get("ai_reasoning", ""),
- "iteration": 1,
- "parent_plan_id": "",
- }
- # Validate against the single-source plan schema (structural checks
- # before persisting an AI-generated plan).
- from src.plan_schema import validate_plan_dict
- _ok, _errs = validate_plan_dict(plan_data, require_model_path=False)
- if not _ok:
- raise HTTPException(
- status_code=422,
- detail="Invalid generated plan: " + "; ".join(_errs),
- )
- # Compute estimated points
- estimated_points = 1
- for v in plan_data["variables"]:
- estimated_points *= len(v.get("values", [])) if v.get("values") else 1
- # Variables summary for display
- variables_summary = {}
- for v in plan_data["variables"]:
- variables_summary[v["name"]] = {
- "unit": v.get("unit", ""),
- "values": v.get("values", []),
- "count": len(v.get("values", [])),
- }
- # Save to database
- db_plan = SimulationPlan(
- project_id=project_id,
- name=plan_name,
- plan_id=plan_id,
- status="draft",
- estimated_points=estimated_points,
- estimated_time_min=estimated_points * 3,
- notes=f"AI-generated. Reasoning: {unified_plan.get('ai_reasoning', '')[:500]}",
- )
- db_plan.set_plan_dict(plan_data)
- db_plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
- db.add(db_plan)
- db.commit()
- db.refresh(db_plan)
- return {
- "id": db_plan.id,
- "plan_id": db_plan.plan_id,
- "name": db_plan.name,
- "status": db_plan.status,
- "estimated_points": db_plan.estimated_points,
- "estimated_time_min": db_plan.estimated_time_min,
- "plan_data": plan_data,
- "ai_reasoning": unified_plan.get("ai_reasoning", ""),
- "validation": result.get("validation", {}),
- "warnings": topology_warnings + unified_plan.get("warnings", []),
- }
- @router.post("/refine")
- def refine_plan(request: RefinePlanRequest):
- """Refine an existing plan based on user feedback."""
- try:
- generator = get_plan_generator()
- result = generator.refine_plan(
- original_plan=request.original_plan,
- user_feedback=request.feedback,
- )
- return result
- except RuntimeError as e:
- raise HTTPException(status_code=503, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Plan refinement failed: {str(e)}")
- @router.get("/templates")
- def list_templates():
- """List available plan generation prompt templates."""
- return {
- "templates": [
- {
- "name": "generate",
- "path": "prompts/plan_generation/generate.txt",
- "description": "Natural language to structured simulation plan (unified v2.0)",
- }
- ],
- "model": "kimi-k3",
- "endpoint": "https://api.kimi.com/coding/v1",
- }
|