This commit is contained in:
2026-05-11 11:35:15 +08:00
parent 5e1d475a22
commit a68b9ca57f
22 changed files with 432 additions and 11341 deletions
+334
View File
@@ -0,0 +1,334 @@
"""
LLM 增强分析服务
提供两个核心能力:
1. generate_design_report — 将分析 JSON 转换为结构化评审报告
2. recommend_parting_direction — 基于几何 + 制造约束推荐最优分型方向
适配层:OpenAI 兼容 API(支持 OpenAI / DeepSeek / vLLM / Ollama 等)
未配置 LLM 时静默降级,不影响主流程。
"""
import json
import re
from typing import Optional, Dict, Any, List
import httpx
from config.settings import settings
from utils.logger import get_logger
logger = get_logger(__name__)
_DESIGN_REPORT_SYSTEM = """你是一位资深注塑模具设计工程师,拥有 20 年模具 DFM 评审经验。
请根据提供的模具分析数据,生成一份专业的模具设计评审报告。
要求:
1. 使用中文
2. 按 "问题摘要 → 关键风险 → 分模方案推荐 → 制造可行性 → 修改建议" 结构组织
3. 技术术语准确(如:锁模力、投影面积、分型面、滑块、斜顶、拔模角、缩痕、熔接痕)
4. 每个建议标注优先级(高/中/低)和预计工时
5. 报告末尾给出一个总体评分(1-10分)
6. 如果数据不足以判断某项,明确标注"数据不足,需人工确认"
直接输出 Markdown 格式报告,不要输出 JSON。"""
_DESIGN_REPORT_USER = """请根据以下模具分析数据生成评审报告:
## 产品信息
- 文件:{filename}
- 材料:{material}
- 体积:{volume}
- 表面积:{surface_area}
- 边界框:{bbox}
## 检测特征
{features}
## 质量指标
{quality_metrics}
## 分模方案
{schemes}
## 制造参数
- 推荐模具材料:{mold_material}
- 推荐模具硬度:{mold_hardness}
- 预估锁模力:{clamping_force}
- 模具尺寸(长×宽×高):{mold_size}
- 预估成型周期:{cycle_time}
- 拔模角:{draft_angle}
- 收缩率:{shrinkage_rate}
## 原始设计建议
{recommendations}"""
_PARTING_SYSTEM = """你是一位注塑模具分模专家。
根据产品几何特征和多个候选分模方向的评分数据,推荐最优分模方向。
输出要求:严格输出 JSON,不要输出其他内容。
JSON 格式:
{
"recommended_axis": "Z",
"confidence": 0.85,
"reasoning": "详细的中文推理过程...",
"risk_notes": ["风险1", "风险2"],
"rankings": [{"axis":"Z","rank":1,"score":92,"note":"..."},{"axis":"X","rank":2,"score":78,"note":"..."}]
}"""
_PARTING_USER = """请评估以下候选分模方向并推荐最优方案:
产品几何:
- 边界框 (mm):{bbox}
- 面法向分布:{normal_stats}
- 惯性矩:{inertia}
约束条件:
- 材料:{material}
- 型腔数:{cavity_count}
- 最大锁模力 (吨):{max_clamping_force}
- 泡沫材料:{is_foam}
候选方案:
{schemes}
请综合评估制造可行性、成本和风险,给出推荐。"""
class LLMService:
"""LLM 增强分析服务(单例)"""
def __init__(self):
self._enabled = settings.LLM_ENABLED
self._api_url = settings.LLM_API_URL.rstrip("/")
self._api_key = settings.LLM_API_KEY
self._model = settings.LLM_MODEL
self._timeout = settings.LLM_TIMEOUT
self._max_tokens = settings.LLM_MAX_TOKENS
if self._enabled:
logger.info(
"LLM 增强分析已启用: model=%s endpoint=%s",
self._model, self._api_url,
)
else:
logger.info("LLM 增强分析未启用(设置 LLM_ENABLED=true 启用)")
async def generate_design_report(
self,
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""生成模具设计评审报告 (Markdown)"""
if not self._enabled:
return None
try:
prompt = self._build_design_report_prompt(analysis_result, detailed_cavity_json)
response = await self._chat(
system=_DESIGN_REPORT_SYSTEM,
user=prompt,
max_tokens=self._max_tokens,
)
if response:
logger.info("LLM 设计报告生成成功 (%d 字符)", len(response))
return response
except Exception as e:
logger.warning("LLM 设计报告生成失败(不影响主流程): %s", e)
return None
async def recommend_parting_direction(
self,
geometry_data: Dict[str, Any],
candidate_schemes: List[Dict[str, Any]],
material: Dict[str, Any],
cavity_count: int = 1,
) -> Optional[Dict[str, Any]]:
"""推荐最优分型方向"""
if not self._enabled:
return None
try:
prompt = self._build_parting_prompt(
geometry_data, candidate_schemes, material, cavity_count,
)
response = await self._chat(
system=_PARTING_SYSTEM,
user=prompt,
max_tokens=min(self._max_tokens, 1200),
expect_json=True,
)
if response:
result = self._parse_json_response(response)
if result:
logger.info(
"LLM 分型推荐: %s (置信度 %.2f)",
result.get("recommended_axis", "?"),
result.get("confidence", 0),
)
return result
return None
except Exception as e:
logger.warning("LLM 分型推荐失败(不影响主流程): %s", e)
return None
def _build_design_report_prompt(
self,
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]],
) -> str:
detected_features = analysis_result.get("detected_features", [])
quality_metrics = analysis_result.get("quality_metrics", {})
recommendations = analysis_result.get("design_recommendations", [])
feature_text = json.dumps(detected_features, ensure_ascii=False, indent=2)
if len(feature_text) > 4000:
feature_text = feature_text[:4000] + "\n... (已截断)"
schemes_text = ""
if detailed_cavity_json:
schemes = detailed_cavity_json.get("candidate_schemes", [])
if schemes:
schemes_text = json.dumps(
[
{
"scheme_id": s.get("scheme_id"),
"rank": s.get("rank"),
"title": s.get("title"),
"score": s.get("score"),
"confidence_score": s.get("confidence_score"),
"summary": s.get("summary"),
"parting_axis": s.get("parting", {}).get("axis"),
"mold_structure_type": s.get("mold_structure_type"),
"dfm_violations": s.get("dfm_violations", []),
}
for s in schemes
],
ensure_ascii=False,
indent=2,
)
best_scheme = (
detailed_cavity_json.get("candidate_schemes", [{}])[0]
if detailed_cavity_json
else {}
)
cavity_data = best_scheme.get("cavity_data", {}) if isinstance(best_scheme, dict) else {}
mfg_info = cavity_data.get("manufacturing_info", {})
metadata = cavity_data.get("metadata", {})
return _DESIGN_REPORT_USER.format(
filename=metadata.get("file_name", "unknown.stp"),
material=metadata.get("selected_material", "ABS"),
volume=f"{analysis_result.get('geometry_data', {}).get('volume', 0):.1f} mm³",
surface_area=f"{analysis_result.get('geometry_data', {}).get('surface_area', 0):.1f} mm²",
bbox=json.dumps(analysis_result.get("geometry_data", {}).get("bounding_box", {}), ensure_ascii=False),
features=feature_text or "无特征检测数据",
quality_metrics=json.dumps(quality_metrics, ensure_ascii=False, indent=2),
schemes=schemes_text or "无分模方案数据",
mold_material=mfg_info.get("mold_material", "自动选择"),
mold_hardness=mfg_info.get("mold_hardness", "自动选择"),
clamping_force=mfg_info.get("estimated_clamping_force", "自动计算"),
mold_size=json.dumps(mfg_info.get("estimated_mold_size", {}), ensure_ascii=False),
cycle_time=mfg_info.get("estimated_cycle_time", "自动计算"),
draft_angle=f"{metadata.get('draft_angle', 2.0)}°",
shrinkage_rate=f"{metadata.get('shrinkage_rate', 0.0) * 100:.2f}%"
if isinstance(metadata.get("shrinkage_rate"), (int, float))
else "自动计算",
recommendations=json.dumps(recommendations, ensure_ascii=False, indent=2) if recommendations else "无",
)
def _build_parting_prompt(
self,
geometry_data: Dict[str, Any],
candidate_schemes: List[Dict[str, Any]],
material: Dict[str, Any],
cavity_count: int,
) -> str:
bbox = geometry_data.get("bounding_box", {})
axis_normal_stats = geometry_data.get("axis_normal_stats", {})
inertia = geometry_data.get("inertia_matrix", [])
inertia_diag = [
inertia[i][i] if i < len(inertia) and i < len(inertia[i]) else 0.0
for i in range(3)
]
schemes_text = json.dumps(
[
{
"axis": s.get("parting", {}).get("axis") or s.get("axis"),
"score": s.get("score"),
"confidence_score": s.get("confidence_score"),
"summary": s.get("summary"),
"mold_structure_type": s.get("mold_structure_type"),
"core_required": s.get("core_required"),
"dfm_violations": s.get("dfm_violations", []),
"undercut_regions_count": len(s.get("undercut_regions", [])),
"score_breakdown": s.get("score_breakdown", {}),
}
for s in candidate_schemes
],
ensure_ascii=False,
indent=2,
)
return _PARTING_USER.format(
bbox=json.dumps(bbox, ensure_ascii=False),
normal_stats=json.dumps(axis_normal_stats, ensure_ascii=False),
inertia=json.dumps(inertia_diag, ensure_ascii=False),
material=material.get("name", "ABS"),
cavity_count=cavity_count,
max_clamping_force="3000 吨(最大)",
is_foam="是" if material.get("is_foam") else "否",
schemes=schemes_text,
)
async def _chat(
self,
system: str,
user: str,
max_tokens: int = 2000,
expect_json: bool = False,
temperature: float = 0.3,
) -> Optional[str]:
url = f"{self._api_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self._model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": temperature,
}
if expect_json:
payload["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
return content.strip() if content else None
@staticmethod
def _parse_json_response(raw: str) -> Optional[Dict[str, Any]]:
try:
return json.loads(raw)
except json.JSONDecodeError:
match = re.search(r"\{[\s\S]*\}", raw)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
logger.warning("LLM JSON 解析失败: %s...", raw[:200])
return None
llm_service = LLMService()