| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- """AI Plan Generator (P3-M3).
- Converts natural language requirements into structured simulation plans
- using Kimi k3 model, with L0 pre-screening validation.
- """
- import json
- from pathlib import Path
- from typing import Dict, List, Optional, Any, Tuple
- from ..config import PROMPTS_DIR
- from ..services.ai_client import get_kimi_client
- from ..services.l0_prescreening import L0PreScreeningEngine
- class AIPlanGenerator:
- """Generate simulation plans from natural language using AI."""
- def __init__(self, l0_engine: Optional[L0PreScreeningEngine] = None):
- self.ai_client = get_kimi_client()
- self.l0_engine = l0_engine or L0PreScreeningEngine()
- self._prompt_template = None
- def _load_prompt(self) -> str:
- """Load plan generation prompt template."""
- if self._prompt_template is None:
- prompt_path = PROMPTS_DIR / "plan_generation" / "generate.txt"
- if prompt_path.exists():
- with open(prompt_path, "r", encoding="utf-8") as f:
- self._prompt_template = f.read()
- else:
- self._prompt_template = self._default_prompt()
- return self._prompt_template
- def _default_prompt(self) -> str:
- """Fallback default prompt."""
- return """\u4f60\u662f\u8f74\u5411\u78c1\u901a\u7535\u673a\u4eff\u771f\u65b9\u6848\u4e13\u5bb6\u3002\u5c06\u7528\u6237\u9700\u6c42\u8f6c\u5316\u4e3aJSON\u683c\u5f0f\u7684\u4eff\u771f\u65b9\u6848\uff0c\u5305\u542bplan_name\u3001topology\u3001boundary_conditions\u3001scan_variables\u3001search_strategy\u3001acceptance_criteria\u3001reasoning\u3002\u8f93\u51fa\u7eafJSON\u3002"""
- def generate(
- self,
- user_requirement: str,
- project_context: Optional[Dict[str, Any]] = None,
- existing_experience: Optional[List[Dict[str, Any]]] = None,
- ) -> Dict[str, Any]:
- """Generate a simulation plan from natural language.
- Args:
- user_requirement: Natural language description of the simulation need.
- project_context: Optional project context (topology, existing boundary conditions).
- existing_experience: Optional similar experience cases for reference.
- Returns:
- Dict with generated plan, validation results, and AI reasoning.
- """
- if not self.ai_client.is_configured:
- raise RuntimeError("KIMI_API_KEY is not configured")
- # Build context message
- context_parts = []
- if project_context:
- context_parts.append(f"\u9879\u76ee\u4e0a\u4e0b\u6587\uff1a{json.dumps(project_context, ensure_ascii=False)}")
- if existing_experience:
- context_parts.append(f"\u53c2\u8003\u7ecf\u9a8c\u6848\u4f8b\uff08{len(existing_experience)}\u4e2a\uff09\uff1a{json.dumps(existing_experience[:3], ensure_ascii=False)}")
- user_message = user_requirement
- if context_parts:
- user_message += "\n\n" + "\n".join(context_parts)
- # Call AI
- system_prompt = self._load_prompt()
- result = self.ai_client.chat_json(
- messages=[{"role": "user", "content": user_message}],
- system_prompt=system_prompt,
- max_tokens=2000,
- )
- # Parse generated plan
- plan = result.get("parsed_json", {})
- if not plan:
- # Try to extract and repair JSON from raw content
- raw = result.get("raw_content", result.get("content", ""))
- plan = self._try_repair_json(raw)
- if not plan:
- plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
- # Validate with L0 pre-screening
- validation = self._validate_plan(plan)
- return {
- "plan": plan,
- "validation": validation,
- "ai_reasoning": plan.get("reasoning", ""),
- "usage": result.get("usage", {}),
- "raw_content": result.get("content", ""),
- }
- def _try_repair_json(self, raw: str) -> Dict[str, Any]:
- """Try to extract and repair JSON from potentially truncated AI output.
- Handles:
- - Markdown code fences
- - Truncated JSON (missing closing braces)
- - Extra text before/after JSON
- """
- if not raw:
- return {}
- text = raw.strip()
- # Remove markdown code fences
- if text.startswith("```"):
- lines = text.split("\n")
- if lines[0].startswith("```"):
- lines = lines[1:]
- if lines and lines[-1].strip() == "```":
- lines = lines[:-1]
- text = "\n".join(lines).strip()
- # Find first { and last }
- first_brace = text.find("{")
- last_brace = text.rfind("}")
- if first_brace == -1:
- return {}
- if last_brace == -1 or last_brace < first_brace:
- # JSON is truncated - try to close it
- json_str = text[first_brace:]
- # Count open braces and close them
- open_braces = json_str.count("{") - json_str.count("}")
- open_brackets = json_str.count("[") - json_str.count("]")
- # Remove trailing incomplete content
- last_comma = max(json_str.rfind(","), json_str.rfind(":"))
- if last_comma > len(json_str) * 0.8:
- json_str = json_str[:last_comma]
- # Close brackets and braces
- json_str += "]" * max(0, open_brackets)
- json_str += "}" * max(0, open_braces)
- else:
- json_str = text[first_brace:last_brace + 1]
- try:
- return json.loads(json_str)
- except json.JSONDecodeError:
- # Try one more repair: remove trailing comma before closing
- try:
- repaired = json_str.rstrip()
- while repaired and repaired[-1] in ", \n\t":
- repaired = repaired[:-1]
- # Re-count and close
- open_braces = repaired.count("{") - repaired.count("}")
- open_brackets = repaired.count("[") - repaired.count("]")
- repaired += "]" * max(0, open_brackets)
- repaired += "}" * max(0, open_braces)
- return json.loads(repaired)
- except (json.JSONDecodeError, Exception):
- return {}
- def _validate_plan(self, plan: Dict[str, Any]) -> Dict[str, Any]:
- """Validate generated plan with L0 pre-screening.
- Checks:
- 1. Boundary conditions feasibility
- 2. Scan variable ranges within engineering limits
- 3. Sample point count estimation
- 4. Required fields presence
- """
- issues = []
- warnings = []
- checks = []
- # Check required fields
- required_fields = ["plan_name", "topology", "scan_variables"]
- for field in required_fields:
- if field not in plan:
- issues.append(f"Missing required field: {field}")
- # Validate topology
- topology = plan.get("topology", "")
- if topology and topology not in ("SSSR", "DRSS", "SDSR"):
- warnings.append(f"Unusual topology: {topology}")
- # Validate boundary conditions with L0
- bc = plan.get("boundary_conditions", {})
- if bc:
- l0_report = self.l0_engine.evaluate(bc)
- checks.append({
- "name": "boundary_conditions_l0",
- "feasible": l0_report.feasible,
- "passed": l0_report.passed_checks,
- "total": l0_report.total_checks,
- "failed_items": [r.name for r in l0_report.results if not r.passed],
- })
- if not l0_report.feasible:
- issues.append(f"Boundary conditions infeasible: {l0_report.failed_checks} checks failed")
- # Validate scan variables
- variables = plan.get("scan_variables", [])
- if not isinstance(variables, list):
- variables = []
- total_points = 1
- normalized_vars = []
- for var in variables:
- if isinstance(var, str):
- var = {"name": var, "min_value": None, "max_value": None}
- if not isinstance(var, dict):
- continue
- normalized_vars.append(var)
- name = var.get("name", "unknown")
- min_v = var.get("min_value")
- max_v = var.get("max_value")
- step = var.get("step")
- if min_v is None or max_v is None:
- issues.append(f"Variable {name}: missing min/max values")
- continue
- if min_v >= max_v:
- issues.append(f"Variable {name}: min ({min_v}) >= max ({max_v})")
- if step and step > 0:
- n_points = int((max_v - min_v) / step) + 1
- total_points *= n_points
- if n_points > 50:
- warnings.append(f"Variable {name}: {n_points} levels may be too many")
- plan["scan_variables"] = normalized_vars
- checks.append({
- "name": "scan_variables",
- "count": len(variables),
- "estimated_full_factorial_points": total_points,
- "adaptive_search_recommended": total_points > 100,
- })
- if total_points > 500:
- warnings.append(f"Full factorial would require {total_points} points - strongly recommend adaptive search")
- # Validate search strategy
- search = plan.get("search_strategy", {})
- if search:
- max_calls = search.get("max_solver_calls", 80)
- if max_calls < total_points and not search.get("method", "").startswith(("constrained", "active")):
- warnings.append(f"Budget ({max_calls}) < full factorial ({total_points}) but method is not adaptive")
- overall_valid = len(issues) == 0
- return {
- "valid": overall_valid,
- "issues": issues,
- "warnings": warnings,
- "checks": checks,
- }
- def refine_plan(
- self,
- original_plan: Dict[str, Any],
- user_feedback: str,
- ) -> Dict[str, Any]:
- """Refine an existing plan based on user feedback.
- Args:
- original_plan: The previously generated plan.
- user_feedback: Natural language feedback for refinement.
- Returns:
- Refined plan with validation.
- """
- if not self.ai_client.is_configured:
- raise RuntimeError("KIMI_API_KEY is not configured")
- system_prompt = self._load_prompt() + "\n\n\u4f60\u6b63\u5728\u4f18\u5316\u4e00\u4e2a\u5df2\u6709\u7684\u4eff\u771f\u65b9\u6848\u3002\u6839\u636e\u7528\u6237\u53cd\u9988\u8c03\u6574\u65b9\u6848\uff0c\u4fdd\u6301\u5176\u4ed6\u90e8\u5206\u4e0d\u53d8\u3002"
- user_message = f"\u539f\u6709\u65b9\u6848\uff1a\n{json.dumps(original_plan, ensure_ascii=False, indent=2)}\n\n\u7528\u6237\u53cd\u9988\uff1a{user_feedback}\n\n\u8bf7\u8f93\u51fa\u4f18\u5316\u540e\u7684\u5b8c\u6574\u65b9\u6848JSON\u3002"
- result = self.ai_client.chat_json(
- messages=[{"role": "user", "content": user_message}],
- system_prompt=system_prompt,
- max_tokens=2000,
- )
- refined_plan = result.get("parsed_json", {})
- if not refined_plan:
- raw = result.get("raw_content", result.get("content", ""))
- refined_plan = self._try_repair_json(raw)
- if not refined_plan:
- refined_plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
- validation = self._validate_plan(refined_plan)
- return {
- "plan": refined_plan,
- "validation": validation,
- "ai_reasoning": refined_plan.get("reasoning", ""),
- "usage": result.get("usage", {}),
- }
- # Global singleton
- _generator: Optional[AIPlanGenerator] = None
- def get_plan_generator() -> AIPlanGenerator:
- """Get or create global AIPlanGenerator singleton."""
- global _generator
- if _generator is None:
- _generator = AIPlanGenerator()
- return _generator
|