plan_generator.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  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. Output is converted to unified plan schema v2.0 compatible with
  5. src/plan_schema.py (fixed_params + variables with value lists).
  6. """
  7. import json
  8. import math
  9. from pathlib import Path
  10. from typing import Dict, List, Optional, Any, Tuple
  11. from ..config import PROMPTS_DIR, KIMI_MAX_TOKENS
  12. from ..services.ai_client import get_kimi_client
  13. from ..services.l0_prescreening import L0PreScreeningEngine
  14. from ..services.rule_engine import SCAN_PARAMETERS, get_parameter, recommend_range, BoundaryConditions
  15. from ..services.fixed_params_template import build_default_fixed_params, get_fixed_param_template, CATEGORY_CN
  16. # Single-source strategy registry (mirrors src/plan_schema.py validation) so the
  17. # AI-generated method string is normalized against the platform registry instead
  18. # of being trusted verbatim. Avoids a second definition of the strategy whitelist.
  19. from src.afmcore.strategies import (
  20. is_registered as _strategy_registered,
  21. normalize_method as _strategy_normalize,
  22. )
  23. # Mapping from common AI-output variable names to exact Motor-CAD variable names
  24. _VARIABLE_NAME_MAP = {
  25. "airgap": "Airgap",
  26. "airgap_mm": "Airgap",
  27. "air_gap": "Airgap",
  28. "air_gap_mm": "Airgap",
  29. "airgap_length": "Airgap",
  30. "airgap_length_mm": "Airgap",
  31. "air_gap_length": "Airgap",
  32. "air_gap_length_mm": "Airgap",
  33. "gap_length": "Airgap",
  34. "gap_length_mm": "Airgap",
  35. "magnet_length": "Magnet_Length",
  36. "magnet_length_mm": "Magnet_Length",
  37. "magnet_axial_thickness": "Magnet_Length",
  38. "magnet_axial_thickness_mm": "Magnet_Length",
  39. "magnet_axial_length": "Magnet_Length",
  40. "magnet_axial_length_mm": "Magnet_Length",
  41. "magnet_thickness": "Magnet_Thickness",
  42. "magnet_thickness_mm": "Magnet_Thickness",
  43. "magnet_radial_depth": "Magnet_Thickness",
  44. "magnet_radial_depth_mm": "Magnet_Thickness",
  45. "magnet_radial_thickness": "Magnet_Thickness",
  46. "magnet_radial_thickness_mm": "Magnet_Thickness",
  47. "magnet_arc": "Magnet_Arc_[ED]",
  48. "magnet_arc_deg": "Magnet_Arc_[ED]",
  49. "magnet_arc_[ed]": "Magnet_Arc_[ED]",
  50. "magnet_pole_arc": "Magnet_Arc_[ED]",
  51. "magnet_pole_arc_ratio": "Magnet_Arc_[ED]",
  52. "pole_arc": "Magnet_Arc_[ED]",
  53. "pole_arc_deg": "Magnet_Arc_[ED]",
  54. "pole_arc_ratio": "Magnet_Arc_[ED]",
  55. "pole_arc_coefficient": "Magnet_Arc_[ED]",
  56. "rms_current": "RMSCurrent",
  57. "rms_current_a": "RMSCurrent",
  58. "current_a": "RMSCurrent",
  59. "current": "RMSCurrent",
  60. "phase_current": "RMSCurrent",
  61. "phase_current_a": "RMSCurrent",
  62. "rated_current": "RMSCurrent",
  63. "rated_current_a": "RMSCurrent",
  64. "shaft_speed": "Shaft_Speed",
  65. "shaft_speed_rpm": "Shaft_Speed",
  66. "speed_rpm": "Shaft_Speed",
  67. "speed": "Shaft_Speed",
  68. "rotational_speed": "Shaft_Speed",
  69. "rotational_speed_rpm": "Shaft_Speed",
  70. "rated_speed": "Shaft_Speed",
  71. "rated_speed_rpm": "Shaft_Speed",
  72. "magnet_temperature": "Magnet_Temperature",
  73. "magnet_temperature_c": "Magnet_Temperature",
  74. "magnet_temp_c": "Magnet_Temperature",
  75. "magnet_temp": "Magnet_Temperature",
  76. "torque_points_per_cycle": "TorquePointsPerCycle",
  77. "torque_points": "TorquePointsPerCycle",
  78. "sampling_points": "TorquePointsPerCycle",
  79. "torque_sampling_points": "TorquePointsPerCycle",
  80. # Geometry / winding variants the AI commonly emits (normalized to the
  81. # canonical names registered in rule_engine.SCAN_PARAMETERS).
  82. "stator_outer_diameter": "Stator_Outer_Diameter",
  83. "stator_outer_diameter_mm": "Stator_Outer_Diameter",
  84. "stator_outer_dia": "Stator_Outer_Diameter",
  85. "stator_od": "Stator_Outer_Diameter",
  86. "stator_lam_outer_dia": "Stator_Outer_Diameter",
  87. "stator_lam_dia_outer": "Stator_Outer_Diameter",
  88. "stator_lam_outside_dia": "Stator_Outer_Diameter",
  89. "stator_lamination_outer_dia": "Stator_Outer_Diameter",
  90. "stator_inner_diameter": "Stator_Inner_Diameter",
  91. "stator_inner_dia": "Stator_Inner_Diameter",
  92. "stator_lam_inner_dia": "Stator_Inner_Diameter",
  93. "stator_id": "Stator_Inner_Diameter",
  94. "slot_depth": "Slot_Depth",
  95. "slot_depth_mm": "Slot_Depth",
  96. "stator_slot_depth": "Slot_Depth",
  97. "turns_per_coil": "Turns_per_Coil",
  98. "coil_turns": "Turns_per_Coil",
  99. "turnspercoil": "Turns_per_Coil",
  100. "coil_turns_per_phase": "Turns_per_Coil",
  101. "turns_per_coil_per_phase": "Turns_per_Coil",
  102. "number_of_turns": "Turns_per_Coil",
  103. "wire_diameter": "Wire_Diameter",
  104. "wire_diameter_mm": "Wire_Diameter",
  105. "wire_dia": "Wire_Diameter",
  106. "magnet_wire_diameter": "Wire_Diameter",
  107. "coil_wire_diameter": "Wire_Diameter",
  108. }
  109. def _normalize_variable_name(name: str) -> str:
  110. """Map AI-output variable name to exact Motor-CAD variable name."""
  111. if not name:
  112. return name
  113. # Direct match in registry
  114. if name in SCAN_PARAMETERS:
  115. return name
  116. # Case-insensitive lookup in registry
  117. lower = name.lower()
  118. for reg_name in SCAN_PARAMETERS:
  119. if reg_name.lower() == lower:
  120. return reg_name
  121. # Mapping table
  122. mapped = _VARIABLE_NAME_MAP.get(lower)
  123. if mapped:
  124. return mapped
  125. return name
  126. def _extract_range(sv: Dict[str, Any]) -> Tuple[Optional[float], Optional[float], Optional[float]]:
  127. """Extract min/max/step from a scan variable dict with flexible field names."""
  128. min_v = None
  129. max_v = None
  130. step = None
  131. # Try various field names for min
  132. for key in ["min_value", "min", "minVal", "lower", "start", "from", "minimum"]:
  133. if key in sv and sv[key] is not None:
  134. try:
  135. min_v = float(sv[key])
  136. break
  137. except (ValueError, TypeError):
  138. pass
  139. # Try various field names for max
  140. for key in ["max_value", "max", "maxVal", "upper", "stop", "end", "to", "maximum"]:
  141. if key in sv and sv[key] is not None:
  142. try:
  143. max_v = float(sv[key])
  144. break
  145. except (ValueError, TypeError):
  146. pass
  147. # Try step
  148. for key in ["step", "step_size", "increment", "delta", "resolution"]:
  149. if key in sv and sv[key] is not None:
  150. try:
  151. step = float(sv[key])
  152. break
  153. except (ValueError, TypeError):
  154. pass
  155. # If range provided as [min, max]
  156. if (min_v is None or max_v is None) and "range" in sv:
  157. rng = sv["range"]
  158. if isinstance(rng, (list, tuple)) and len(rng) >= 2:
  159. try:
  160. min_v = float(rng[0]) if min_v is None else min_v
  161. max_v = float(rng[1]) if max_v is None else max_v
  162. except (ValueError, TypeError):
  163. pass
  164. return min_v, max_v, step
  165. def _generate_values(start: float, stop: float, step: float) -> List[float]:
  166. """Generate evenly spaced values from start to stop inclusive."""
  167. if step <= 0 or start is None or stop is None:
  168. return []
  169. count = int(math.floor((stop - start) / step + 1e-9)) + 1
  170. if count <= 0 or count > 500:
  171. return []
  172. vals = [round(start + i * step, 6) for i in range(count)]
  173. if vals and abs(vals[-1] - stop) > 1e-9:
  174. vals.append(round(stop, 6))
  175. return vals
  176. def convert_ai_plan_to_unified(ai_plan: Dict[str, Any], boundary_conditions: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
  177. """Convert AI-generated plan format to unified plan schema v2.0.
  178. ...
  179. """
  180. bc = BoundaryConditions.from_dict(boundary_conditions or {})
  181. # Collect scan variables from either scan_variables or variables field
  182. raw_vars = ai_plan.get("scan_variables", []) or ai_plan.get("variables", [])
  183. variables = []
  184. for sv in raw_vars:
  185. if not isinstance(sv, dict):
  186. continue
  187. raw_name = sv.get("name", "")
  188. if not raw_name:
  189. continue
  190. # Normalize variable name to Motor-CAD exact name
  191. name = _normalize_variable_name(raw_name)
  192. # If AI already provided values array, use it directly
  193. values = sv.get("values", [])
  194. if not isinstance(values, list):
  195. values = []
  196. min_v, max_v, step = _extract_range(sv)
  197. # If no values but have range, generate
  198. if not values and min_v is not None and max_v is not None and step:
  199. values = _generate_values(min_v, max_v, step)
  200. # If still no values, fall back to rule engine defaults
  201. if not values:
  202. param = get_parameter(name)
  203. if param:
  204. rng = recommend_range(name, bc)
  205. min_v = rng["start"]
  206. max_v = rng["stop"]
  207. step = rng["step"]
  208. values = _generate_values(min_v, max_v, step)
  209. # Get display name and unit from registry if available
  210. param = get_parameter(name)
  211. tmpl = get_fixed_param_template(name)
  212. display_name = sv.get("display_name") or (param.display_name if param else tmpl.get("display_name", raw_name))
  213. name_cn = sv.get("name_cn") or tmpl.get("name_cn") or (param.display_name if param else raw_name)
  214. unit = sv.get("unit") or (param.unit if param else tmpl.get("unit", ""))
  215. category = sv.get("category") or (param.category if param else tmpl.get("category", ""))
  216. category_cn = sv.get("category_cn") or CATEGORY_CN.get(category, category)
  217. description = sv.get("description") or tmpl.get("description", "")
  218. description_cn = sv.get("description_cn") or tmpl.get("description_cn", "")
  219. variables.append({
  220. "name": name,
  221. "display_name": display_name,
  222. "name_cn": name_cn,
  223. "unit": unit,
  224. "start": min_v,
  225. "stop": max_v,
  226. "step": step,
  227. "values": values,
  228. "category": category,
  229. "category_cn": category_cn,
  230. "description": description,
  231. "description_cn": description_cn,
  232. })
  233. # Deduplicate variables by name (keep first)
  234. seen = set()
  235. unique_vars = []
  236. for v in variables:
  237. if v["name"] not in seen:
  238. seen.add(v["name"])
  239. unique_vars.append(v)
  240. variables = unique_vars
  241. # Separate standard registry variables from custom variables
  242. standard_vars = []
  243. custom_vars = []
  244. warnings = []
  245. for v in variables:
  246. if v["name"] in SCAN_PARAMETERS:
  247. standard_vars.append(v)
  248. else:
  249. custom_vars.append(v)
  250. warnings.append(f"Variable '{v['name']}' is not a standard Motor-CAD variable, may need manual adjustment")
  251. # If too many standard variables, keep only top 4 (to avoid combinatorial explosion)
  252. MAX_VARIABLES = 4
  253. if len(standard_vars) > MAX_VARIABLES:
  254. warnings.append(f"Too many scan variables ({len(standard_vars)}), keeping top {MAX_VARIABLES} to avoid combinatorial explosion")
  255. standard_vars = standard_vars[:MAX_VARIABLES]
  256. # Use standard variables primarily, append custom ones that have explicit values
  257. variables = standard_vars
  258. for cv in custom_vars:
  259. if cv.get("values") and len(cv["values"]) > 0:
  260. variables.append(cv)
  261. else:
  262. warnings.append(f"Skipping custom variable '{cv['name']}' - no valid values")
  263. # Hard limit: total variables <= 4
  264. if len(variables) > MAX_VARIABLES:
  265. warnings.append(f"Total variables ({len(variables)}) exceeds limit {MAX_VARIABLES}, keeping first {MAX_VARIABLES}")
  266. variables = variables[:MAX_VARIABLES]
  267. # Recalculate total points
  268. total_points = 1
  269. for v in variables:
  270. total_points *= len(v.get("values", [])) if v.get("values") else 1
  271. # If still too many points, trim variables one by one until under threshold
  272. MAX_POINTS = 200
  273. while total_points > MAX_POINTS and len(variables) > 1:
  274. removed = variables.pop()
  275. warnings.append(f"Removed variable '{removed['name']}' ({len(removed.get('values', []))} values) to reduce total points below {MAX_POINTS}")
  276. total_points = 1
  277. for v in variables:
  278. total_points *= len(v.get("values", [])) if v.get("values") else 1
  279. if total_points > MAX_POINTS:
  280. warnings.append(f"Total scan points ({total_points}) exceeds {MAX_POINTS}, consider narrowing ranges")
  281. # Fixed params: template-driven (ALL parameters from FIXED_PARAM_TEMPLATES,
  282. # excluding scan variables). Values inferred from boundary conditions where possible.
  283. # AI does not need to output fixed_params - the template is the single source.
  284. scan_names = {v["name"].lower() for v in variables}
  285. fixed_params = build_default_fixed_params(
  286. scan_variable_names=list(scan_names),
  287. boundary_conditions=boundary_conditions,
  288. )
  289. # Append any custom fixed params the AI explicitly provided that are not
  290. # already in the template (keeps AI flexibility for special cases).
  291. existing_names = {fp["name"].lower() for fp in fixed_params}
  292. for fp in ai_plan.get("fixed_params", []):
  293. if not isinstance(fp, dict) or not fp.get("name"):
  294. continue
  295. fname = fp["name"].lower()
  296. if fname in existing_names or fname in scan_names:
  297. continue
  298. tmpl = get_fixed_param_template(fp["name"])
  299. val = fp.get("value", 0)
  300. try:
  301. val = float(val)
  302. except (TypeError, ValueError):
  303. pass
  304. fixed_params.append({
  305. "name": fp["name"],
  306. "display_name": fp.get("display_name") or tmpl.get("display_name", fp["name"]),
  307. "name_cn": fp.get("name_cn") or tmpl.get("name_cn", fp["name"]),
  308. "unit": fp.get("unit") or tmpl.get("unit", ""),
  309. "value": val,
  310. "category": fp.get("category") or tmpl.get("category", "General"),
  311. "category_cn": fp.get("category_cn") or CATEGORY_CN.get(tmpl.get("category", "General"), tmpl.get("category", "General")),
  312. "description": fp.get("description") or tmpl.get("description", ""),
  313. "description_cn": fp.get("description_cn") or tmpl.get("description_cn", ""),
  314. })
  315. existing_names.add(fname)
  316. # Normalize acceptance_criteria to standard format
  317. ac = ai_plan.get("acceptance_criteria", {})
  318. if isinstance(ac, dict):
  319. hc = ac.get("hard_constraints")
  320. if isinstance(hc, dict):
  321. # Nested dict form: {"outer_diameter_mm": "<=110", ...} -> list of
  322. # "metric op value" strings so the UI can render them directly.
  323. ac = dict(ac)
  324. ac["hard_constraints"] = [f"{k} {v}" for k, v in hc.items()]
  325. if ac and not isinstance(ac.get("hard_constraints"), list):
  326. # AI may return custom format like {rated_torque_Nm_min: 25, ...}
  327. hard_constraints = []
  328. for key, val in ac.items():
  329. if isinstance(val, (int, float)):
  330. if key.endswith("_min"):
  331. metric = key.replace("_min", "").replace("_Nm", "").replace("_percent", "").replace("_N", "").replace("_T", "")
  332. hard_constraints.append(f"{metric} >= {val}")
  333. elif key.endswith("_max"):
  334. metric = key.replace("_max", "").replace("_Nm", "").replace("_percent", "").replace("_N", "").replace("_T", "")
  335. hard_constraints.append(f"{metric} <= {val}")
  336. if hard_constraints:
  337. ac = {
  338. "hard_constraints": hard_constraints,
  339. "objective_metric": ac.get("objective_metric", "efficiency_pct"),
  340. "objective_direction": ac.get("objective_direction", "maximize"),
  341. "soft_targets": {},
  342. }
  343. # Normalize search_strategy
  344. ss = ai_plan.get("search_strategy", {})
  345. if not isinstance(ss, dict):
  346. ss = {}
  347. # Normalize the method against the platform strategy registry. The model may
  348. # emit a non-registered method (e.g. "full_factorial_grid"); fall back to the
  349. # deterministic full_factorial default and record a warning instead of letting
  350. # the invalid value propagate to downstream validation.
  351. raw_method = ss.get("method", "")
  352. method = _strategy_normalize(raw_method)
  353. if method and not _strategy_registered(method):
  354. warnings.append(
  355. f"Unknown search strategy method '{raw_method}' - defaulting to full_factorial"
  356. )
  357. method = "full_factorial"
  358. if not method:
  359. method = "full_factorial"
  360. if ss and "method" not in ss:
  361. ss = {
  362. "method": method,
  363. "initial_samples": 16,
  364. "batch_size": 4,
  365. "max_solver_calls": 80,
  366. "local_trust_region": False,
  367. "objective_metric": "efficiency_pct",
  368. "objective_direction": "maximize",
  369. }
  370. else:
  371. ss = dict(ss)
  372. ss["method"] = method
  373. # Estimate total points
  374. total_points = 1
  375. for v in variables:
  376. total_points *= len(v.get("values", [])) if v.get("values") else 1
  377. return {
  378. "name": ai_plan.get("plan_name", "AI Generated Plan"),
  379. "topology": ai_plan.get("topology", "SSSR"),
  380. "model_path": ai_plan.get("model_path", ""),
  381. "fixed_params": fixed_params,
  382. "variables": variables,
  383. "cases": [{"id": "default", "name": "Default operating point", "params": {}}],
  384. "output_metrics": [
  385. "tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w",
  386. "copper_loss_w", "iron_loss_w", "magnet_loss_w", "back_emf_v",
  387. "input_power_w", "output_power_w", "shaft_speed_rpm",
  388. ],
  389. "search_strategy": ss,
  390. "acceptance_criteria": ac if ac else None,
  391. "ai_reasoning": ai_plan.get("reasoning", ""),
  392. "estimated_points": total_points,
  393. "estimated_time_min": total_points * 3,
  394. "warnings": warnings,
  395. }
  396. class AIPlanGenerator:
  397. """Generate simulation plans from natural language using AI."""
  398. def __init__(self, l0_engine: Optional[L0PreScreeningEngine] = None):
  399. self.ai_client = get_kimi_client()
  400. self.l0_engine = l0_engine or L0PreScreeningEngine()
  401. self._prompt_template = None
  402. def _load_prompt(self) -> str:
  403. """Load plan generation prompt template."""
  404. if self._prompt_template is None:
  405. prompt_path = PROMPTS_DIR / "plan_generation" / "generate.txt"
  406. if prompt_path.exists():
  407. with open(prompt_path, "r", encoding="utf-8") as f:
  408. self._prompt_template = f.read()
  409. else:
  410. self._prompt_template = self._default_prompt()
  411. return self._prompt_template
  412. def _default_prompt(self) -> str:
  413. """Fallback default prompt."""
  414. return """\u4f60\u662f\u8f74\u5411\u78c1\u901a\u7535\u673a\u4eff\u771f\u65b9\u6848\u4e13\u5bb6\u3002\u6839\u636e\u7528\u6237\u9700\u6c42\u548c\u8fb9\u754c\u6761\u4ef6\u751f\u6210JSON\u683c\u5f0f\u7684\u4eff\u771f\u65b9\u6848\u3002\n\u56fa\u5b9a\u53c2\u6570\u5df2\u7531\u7cfb\u7edf\u7edf\u4e00\u6a21\u677f\u751f\u6210\uff0c\u4f60\u65e0\u9700\u8f93\u51fafixed_params\uff1b\u4f60\u53ea\u9700\u8f93\u51fa\u4ee5\u4e0b\u5b57\u6bb5\uff1a\n- plan_name: \u65b9\u6848\u540d\u79f0\uff08\u7b80\u77ed\u63cf\u8ff0\uff09\n- topology: \u62d3\u6251\u7ed3\u6784\uff08SSSR/DRSS/SDSR\uff09\n- scan_variables: \u626b\u63cf\u53d8\u91cf\u6570\u7ec4\uff0c\u6bcf\u9879\u542bname\uff08\u7528Motor-CAD\u6807\u51c6\u53d8\u91cf\u540d\uff09\u3001start\u3001stop\u3001step\uff0c\u6700\u591a4\u4e2a\n- search_strategy: \u641c\u7d22\u7b56\u7565\uff08method, objective_metric, objective_direction\u7b49\uff09\n- acceptance_criteria: \u9a8c\u6536\u6807\u51c6\uff08\u786c\u7ea6\u675f\u3001\u6027\u80fd\u76ee\u6807\uff09\n- reasoning: \u4e2d\u6587\u8bbe\u8ba1\u601d\u8def\uff0c\u7ea6200\u5b57\uff0c\u53ea\u8bb2\u5173\u952e\u8bbe\u8ba1\u51b3\u7b56\u548c\u6838\u5fc3\u53c2\u6570\u9009\u62e9\u7406\u7531\uff0c\u7b80\u6d01\u76f4\u63a5\n\u8f93\u51fa\u7eafJSON\uff0c\u4e0d\u8981\u591a\u4f59\u6587\u5b57\u3002"""
  415. def generate(
  416. self,
  417. user_requirement: str,
  418. project_context: Optional[Dict[str, Any]] = None,
  419. existing_experience: Optional[List[Dict[str, Any]]] = None,
  420. ) -> Dict[str, Any]:
  421. """Generate a simulation plan from natural language.
  422. Args:
  423. user_requirement: Natural language description of the simulation need.
  424. project_context: Optional project context (topology, existing boundary conditions).
  425. existing_experience: Optional similar experience cases for reference.
  426. Returns:
  427. Dict with generated plan, validation results, and AI reasoning.
  428. """
  429. if not self.ai_client.is_configured:
  430. raise RuntimeError("KIMI_API_KEY is not configured")
  431. # Build context message
  432. context_parts = []
  433. if project_context:
  434. context_parts.append(f"\u9879\u76ee\u4e0a\u4e0b\u6587\uff1a{json.dumps(project_context, ensure_ascii=False)}")
  435. if existing_experience:
  436. 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)}")
  437. user_message = user_requirement
  438. if context_parts:
  439. user_message += "\n\n" + "\n".join(context_parts)
  440. # Call AI
  441. system_prompt = self._load_prompt()
  442. result = self.ai_client.chat_json(
  443. messages=[{"role": "user", "content": user_message}],
  444. system_prompt=system_prompt,
  445. max_tokens=KIMI_MAX_TOKENS,
  446. )
  447. # Parse generated plan
  448. plan = result.get("parsed_json", {})
  449. if not plan:
  450. # Try to extract and repair JSON from raw content
  451. raw = result.get("raw_content", result.get("content", ""))
  452. plan = self._try_repair_json(raw)
  453. if not plan:
  454. plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
  455. # Convert to unified format
  456. bc = (project_context or {}).get("boundary_conditions") if project_context else None
  457. unified_plan = convert_ai_plan_to_unified(plan, boundary_conditions=bc)
  458. # Validate with L0 pre-screening
  459. validation = self._validate_plan(plan)
  460. return {
  461. "plan": unified_plan,
  462. "raw_ai_plan": plan,
  463. "validation": validation,
  464. "ai_reasoning": plan.get("reasoning", ""),
  465. "usage": result.get("usage", {}),
  466. "raw_content": result.get("content", ""),
  467. }
  468. def _try_repair_json(self, raw: str) -> Dict[str, Any]:
  469. """Try to extract and repair JSON from potentially truncated AI output.
  470. Handles:
  471. - Markdown code fences
  472. - Truncated JSON (missing closing braces)
  473. - Extra text before/after JSON
  474. """
  475. if not raw:
  476. return {}
  477. text = raw.strip()
  478. # Remove markdown code fences
  479. if text.startswith("```"):
  480. lines = text.split("\n")
  481. if lines[0].startswith("```"):
  482. lines = lines[1:]
  483. if lines and lines[-1].strip() == "```":
  484. lines = lines[:-1]
  485. text = "\n".join(lines).strip()
  486. # Find first { and last }
  487. first_brace = text.find("{")
  488. last_brace = text.rfind("}")
  489. if first_brace == -1:
  490. return {}
  491. if last_brace == -1 or last_brace < first_brace:
  492. # JSON is truncated - try to close it
  493. json_str = text[first_brace:]
  494. # Count open braces and close them
  495. open_braces = json_str.count("{") - json_str.count("}")
  496. open_brackets = json_str.count("[") - json_str.count("]")
  497. # Remove trailing incomplete content
  498. last_comma = max(json_str.rfind(","), json_str.rfind(":"))
  499. if last_comma > len(json_str) * 0.8:
  500. json_str = json_str[:last_comma]
  501. # Close brackets and braces
  502. json_str += "]" * max(0, open_brackets)
  503. json_str += "}" * max(0, open_braces)
  504. else:
  505. json_str = text[first_brace:last_brace + 1]
  506. try:
  507. return json.loads(json_str)
  508. except json.JSONDecodeError:
  509. # Try one more repair: remove trailing comma before closing
  510. try:
  511. repaired = json_str.rstrip()
  512. while repaired and repaired[-1] in ", \n\t":
  513. repaired = repaired[:-1]
  514. # Re-count and close
  515. open_braces = repaired.count("{") - repaired.count("}")
  516. open_brackets = repaired.count("[") - repaired.count("]")
  517. repaired += "]" * max(0, open_brackets)
  518. repaired += "}" * max(0, open_braces)
  519. return json.loads(repaired)
  520. except (json.JSONDecodeError, Exception):
  521. return {}
  522. def _validate_plan(self, plan: Dict[str, Any]) -> Dict[str, Any]:
  523. """Validate generated plan with L0 pre-screening.
  524. Checks:
  525. 1. Boundary conditions feasibility
  526. 2. Scan variable ranges within engineering limits
  527. 3. Sample point count estimation
  528. 4. Required fields presence
  529. """
  530. issues = []
  531. warnings = []
  532. checks = []
  533. # Check required fields
  534. required_fields = ["plan_name", "topology", "scan_variables"]
  535. for field in required_fields:
  536. if field not in plan:
  537. issues.append(f"Missing required field: {field}")
  538. # Validate topology
  539. topology = plan.get("topology", "")
  540. if topology and topology not in ("SSSR", "DRSS", "SDSR"):
  541. warnings.append(f"Unusual topology: {topology}")
  542. # Validate boundary conditions with L0
  543. bc = plan.get("boundary_conditions", {})
  544. if bc:
  545. l0_report = self.l0_engine.evaluate(bc)
  546. checks.append({
  547. "name": "boundary_conditions_l0",
  548. "feasible": l0_report.feasible,
  549. "passed": l0_report.passed_checks,
  550. "total": l0_report.total_checks,
  551. "failed_items": [r.name for r in l0_report.results if not r.passed],
  552. })
  553. if not l0_report.feasible:
  554. issues.append(f"Boundary conditions infeasible: {l0_report.failed_checks} checks failed")
  555. # Validate scan variables
  556. variables = plan.get("scan_variables", [])
  557. if not isinstance(variables, list):
  558. variables = []
  559. total_points = 1
  560. normalized_vars = []
  561. for var in variables:
  562. if isinstance(var, str):
  563. var = {"name": var, "min_value": None, "max_value": None}
  564. if not isinstance(var, dict):
  565. continue
  566. normalized_vars.append(var)
  567. name = var.get("name", "unknown")
  568. min_v = var.get("min_value")
  569. max_v = var.get("max_value")
  570. step = var.get("step")
  571. if min_v is None or max_v is None:
  572. issues.append(f"Variable {name}: missing min/max values")
  573. continue
  574. if min_v >= max_v:
  575. issues.append(f"Variable {name}: min ({min_v}) >= max ({max_v})")
  576. if step and step > 0:
  577. n_points = int((max_v - min_v) / step) + 1
  578. total_points *= n_points
  579. if n_points > 50:
  580. warnings.append(f"Variable {name}: {n_points} levels may be too many")
  581. plan["scan_variables"] = normalized_vars
  582. checks.append({
  583. "name": "scan_variables",
  584. "count": len(variables),
  585. "estimated_full_factorial_points": total_points,
  586. "adaptive_search_recommended": total_points > 100,
  587. })
  588. if total_points > 500:
  589. warnings.append(f"Full factorial would require {total_points} points - strongly recommend adaptive search")
  590. # Validate search strategy
  591. search = plan.get("search_strategy", {})
  592. if search:
  593. max_calls = search.get("max_solver_calls", 80)
  594. if max_calls < total_points and not search.get("method", "").startswith(("constrained", "active")):
  595. warnings.append(f"Budget ({max_calls}) < full factorial ({total_points}) but method is not adaptive")
  596. overall_valid = len(issues) == 0
  597. return {
  598. "valid": overall_valid,
  599. "issues": issues,
  600. "warnings": warnings,
  601. "checks": checks,
  602. }
  603. def refine_plan(
  604. self,
  605. original_plan: Dict[str, Any],
  606. user_feedback: str,
  607. ) -> Dict[str, Any]:
  608. """Refine an existing plan based on user feedback.
  609. Args:
  610. original_plan: The previously generated plan.
  611. user_feedback: Natural language feedback for refinement.
  612. Returns:
  613. Refined plan with validation.
  614. """
  615. if not self.ai_client.is_configured:
  616. raise RuntimeError("KIMI_API_KEY is not configured")
  617. 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"
  618. 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"
  619. result = self.ai_client.chat_json(
  620. messages=[{"role": "user", "content": user_message}],
  621. system_prompt=system_prompt,
  622. max_tokens=KIMI_MAX_TOKENS,
  623. )
  624. refined_plan = result.get("parsed_json", {})
  625. if not refined_plan:
  626. raw = result.get("raw_content", result.get("content", ""))
  627. refined_plan = self._try_repair_json(raw)
  628. if not refined_plan:
  629. refined_plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
  630. bc = original_plan.get("boundary_conditions") if isinstance(original_plan, dict) else None
  631. unified_plan = convert_ai_plan_to_unified(refined_plan, boundary_conditions=bc)
  632. validation = self._validate_plan(refined_plan)
  633. return {
  634. "plan": unified_plan,
  635. "raw_ai_plan": refined_plan,
  636. "validation": validation,
  637. "ai_reasoning": refined_plan.get("reasoning", ""),
  638. "usage": result.get("usage", {}),
  639. }
  640. # Global singleton
  641. _generator: Optional[AIPlanGenerator] = None
  642. def get_plan_generator() -> AIPlanGenerator:
  643. """Get or create global AIPlanGenerator singleton."""
  644. global _generator
  645. if _generator is None:
  646. _generator = AIPlanGenerator()
  647. return _generator