plan_generator.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. """AI Plan Generator (P3-M3).
  2. Converts natural language requirements into structured simulation plans
  3. using Kimi k3 model, with L0 pre-screening validation.
  4. """
  5. import json
  6. from pathlib import Path
  7. from typing import Dict, List, Optional, Any, Tuple
  8. from ..config import PROMPTS_DIR
  9. from ..services.ai_client import get_kimi_client
  10. from ..services.l0_prescreening import L0PreScreeningEngine
  11. class AIPlanGenerator:
  12. """Generate simulation plans from natural language using AI."""
  13. def __init__(self, l0_engine: Optional[L0PreScreeningEngine] = None):
  14. self.ai_client = get_kimi_client()
  15. self.l0_engine = l0_engine or L0PreScreeningEngine()
  16. self._prompt_template = None
  17. def _load_prompt(self) -> str:
  18. """Load plan generation prompt template."""
  19. if self._prompt_template is None:
  20. prompt_path = PROMPTS_DIR / "plan_generation" / "generate.txt"
  21. if prompt_path.exists():
  22. with open(prompt_path, "r", encoding="utf-8") as f:
  23. self._prompt_template = f.read()
  24. else:
  25. self._prompt_template = self._default_prompt()
  26. return self._prompt_template
  27. def _default_prompt(self) -> str:
  28. """Fallback default prompt."""
  29. 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"""
  30. def generate(
  31. self,
  32. user_requirement: str,
  33. project_context: Optional[Dict[str, Any]] = None,
  34. existing_experience: Optional[List[Dict[str, Any]]] = None,
  35. ) -> Dict[str, Any]:
  36. """Generate a simulation plan from natural language.
  37. Args:
  38. user_requirement: Natural language description of the simulation need.
  39. project_context: Optional project context (topology, existing boundary conditions).
  40. existing_experience: Optional similar experience cases for reference.
  41. Returns:
  42. Dict with generated plan, validation results, and AI reasoning.
  43. """
  44. if not self.ai_client.is_configured:
  45. raise RuntimeError("KIMI_API_KEY is not configured")
  46. # Build context message
  47. context_parts = []
  48. if project_context:
  49. context_parts.append(f"\u9879\u76ee\u4e0a\u4e0b\u6587\uff1a{json.dumps(project_context, ensure_ascii=False)}")
  50. if existing_experience:
  51. 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)}")
  52. user_message = user_requirement
  53. if context_parts:
  54. user_message += "\n\n" + "\n".join(context_parts)
  55. # Call AI
  56. system_prompt = self._load_prompt()
  57. result = self.ai_client.chat_json(
  58. messages=[{"role": "user", "content": user_message}],
  59. system_prompt=system_prompt,
  60. max_tokens=2000,
  61. )
  62. # Parse generated plan
  63. plan = result.get("parsed_json", {})
  64. if not plan:
  65. # Try to extract and repair JSON from raw content
  66. raw = result.get("raw_content", result.get("content", ""))
  67. plan = self._try_repair_json(raw)
  68. if not plan:
  69. plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
  70. # Validate with L0 pre-screening
  71. validation = self._validate_plan(plan)
  72. return {
  73. "plan": plan,
  74. "validation": validation,
  75. "ai_reasoning": plan.get("reasoning", ""),
  76. "usage": result.get("usage", {}),
  77. "raw_content": result.get("content", ""),
  78. }
  79. def _try_repair_json(self, raw: str) -> Dict[str, Any]:
  80. """Try to extract and repair JSON from potentially truncated AI output.
  81. Handles:
  82. - Markdown code fences
  83. - Truncated JSON (missing closing braces)
  84. - Extra text before/after JSON
  85. """
  86. if not raw:
  87. return {}
  88. text = raw.strip()
  89. # Remove markdown code fences
  90. if text.startswith("```"):
  91. lines = text.split("\n")
  92. if lines[0].startswith("```"):
  93. lines = lines[1:]
  94. if lines and lines[-1].strip() == "```":
  95. lines = lines[:-1]
  96. text = "\n".join(lines).strip()
  97. # Find first { and last }
  98. first_brace = text.find("{")
  99. last_brace = text.rfind("}")
  100. if first_brace == -1:
  101. return {}
  102. if last_brace == -1 or last_brace < first_brace:
  103. # JSON is truncated - try to close it
  104. json_str = text[first_brace:]
  105. # Count open braces and close them
  106. open_braces = json_str.count("{") - json_str.count("}")
  107. open_brackets = json_str.count("[") - json_str.count("]")
  108. # Remove trailing incomplete content
  109. last_comma = max(json_str.rfind(","), json_str.rfind(":"))
  110. if last_comma > len(json_str) * 0.8:
  111. json_str = json_str[:last_comma]
  112. # Close brackets and braces
  113. json_str += "]" * max(0, open_brackets)
  114. json_str += "}" * max(0, open_braces)
  115. else:
  116. json_str = text[first_brace:last_brace + 1]
  117. try:
  118. return json.loads(json_str)
  119. except json.JSONDecodeError:
  120. # Try one more repair: remove trailing comma before closing
  121. try:
  122. repaired = json_str.rstrip()
  123. while repaired and repaired[-1] in ", \n\t":
  124. repaired = repaired[:-1]
  125. # Re-count and close
  126. open_braces = repaired.count("{") - repaired.count("}")
  127. open_brackets = repaired.count("[") - repaired.count("]")
  128. repaired += "]" * max(0, open_brackets)
  129. repaired += "}" * max(0, open_braces)
  130. return json.loads(repaired)
  131. except (json.JSONDecodeError, Exception):
  132. return {}
  133. def _validate_plan(self, plan: Dict[str, Any]) -> Dict[str, Any]:
  134. """Validate generated plan with L0 pre-screening.
  135. Checks:
  136. 1. Boundary conditions feasibility
  137. 2. Scan variable ranges within engineering limits
  138. 3. Sample point count estimation
  139. 4. Required fields presence
  140. """
  141. issues = []
  142. warnings = []
  143. checks = []
  144. # Check required fields
  145. required_fields = ["plan_name", "topology", "scan_variables"]
  146. for field in required_fields:
  147. if field not in plan:
  148. issues.append(f"Missing required field: {field}")
  149. # Validate topology
  150. topology = plan.get("topology", "")
  151. if topology and topology not in ("SSSR", "DRSS", "SDSR"):
  152. warnings.append(f"Unusual topology: {topology}")
  153. # Validate boundary conditions with L0
  154. bc = plan.get("boundary_conditions", {})
  155. if bc:
  156. l0_report = self.l0_engine.evaluate(bc)
  157. checks.append({
  158. "name": "boundary_conditions_l0",
  159. "feasible": l0_report.feasible,
  160. "passed": l0_report.passed_checks,
  161. "total": l0_report.total_checks,
  162. "failed_items": [r.name for r in l0_report.results if not r.passed],
  163. })
  164. if not l0_report.feasible:
  165. issues.append(f"Boundary conditions infeasible: {l0_report.failed_checks} checks failed")
  166. # Validate scan variables
  167. variables = plan.get("scan_variables", [])
  168. if not isinstance(variables, list):
  169. variables = []
  170. total_points = 1
  171. normalized_vars = []
  172. for var in variables:
  173. if isinstance(var, str):
  174. var = {"name": var, "min_value": None, "max_value": None}
  175. if not isinstance(var, dict):
  176. continue
  177. normalized_vars.append(var)
  178. name = var.get("name", "unknown")
  179. min_v = var.get("min_value")
  180. max_v = var.get("max_value")
  181. step = var.get("step")
  182. if min_v is None or max_v is None:
  183. issues.append(f"Variable {name}: missing min/max values")
  184. continue
  185. if min_v >= max_v:
  186. issues.append(f"Variable {name}: min ({min_v}) >= max ({max_v})")
  187. if step and step > 0:
  188. n_points = int((max_v - min_v) / step) + 1
  189. total_points *= n_points
  190. if n_points > 50:
  191. warnings.append(f"Variable {name}: {n_points} levels may be too many")
  192. plan["scan_variables"] = normalized_vars
  193. checks.append({
  194. "name": "scan_variables",
  195. "count": len(variables),
  196. "estimated_full_factorial_points": total_points,
  197. "adaptive_search_recommended": total_points > 100,
  198. })
  199. if total_points > 500:
  200. warnings.append(f"Full factorial would require {total_points} points - strongly recommend adaptive search")
  201. # Validate search strategy
  202. search = plan.get("search_strategy", {})
  203. if search:
  204. max_calls = search.get("max_solver_calls", 80)
  205. if max_calls < total_points and not search.get("method", "").startswith(("constrained", "active")):
  206. warnings.append(f"Budget ({max_calls}) < full factorial ({total_points}) but method is not adaptive")
  207. overall_valid = len(issues) == 0
  208. return {
  209. "valid": overall_valid,
  210. "issues": issues,
  211. "warnings": warnings,
  212. "checks": checks,
  213. }
  214. def refine_plan(
  215. self,
  216. original_plan: Dict[str, Any],
  217. user_feedback: str,
  218. ) -> Dict[str, Any]:
  219. """Refine an existing plan based on user feedback.
  220. Args:
  221. original_plan: The previously generated plan.
  222. user_feedback: Natural language feedback for refinement.
  223. Returns:
  224. Refined plan with validation.
  225. """
  226. if not self.ai_client.is_configured:
  227. raise RuntimeError("KIMI_API_KEY is not configured")
  228. 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"
  229. 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"
  230. result = self.ai_client.chat_json(
  231. messages=[{"role": "user", "content": user_message}],
  232. system_prompt=system_prompt,
  233. max_tokens=2000,
  234. )
  235. refined_plan = result.get("parsed_json", {})
  236. if not refined_plan:
  237. raw = result.get("raw_content", result.get("content", ""))
  238. refined_plan = self._try_repair_json(raw)
  239. if not refined_plan:
  240. refined_plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
  241. validation = self._validate_plan(refined_plan)
  242. return {
  243. "plan": refined_plan,
  244. "validation": validation,
  245. "ai_reasoning": refined_plan.get("reasoning", ""),
  246. "usage": result.get("usage", {}),
  247. }
  248. # Global singleton
  249. _generator: Optional[AIPlanGenerator] = None
  250. def get_plan_generator() -> AIPlanGenerator:
  251. """Get or create global AIPlanGenerator singleton."""
  252. global _generator
  253. if _generator is None:
  254. _generator = AIPlanGenerator()
  255. return _generator