fix(moldinsight): 方案预览契约断裂——工艺参数面板全 N/A + 备选方案预览无法切换
多方案重构后 cavity_data 与可视化端字段契约断裂,两处收口: - 契约桥接:CalculationService.attach_scheme_info_contract 把方案级 key_info 内嵌回 cavity_data(mold_cavities.cavity_key_info / cavity_count / manufacturing_info 展示字段),multi_scheme_planner._build_scheme 生成后 立即接线——3D 预览"关键工艺参数"面板此前 10 项 8 项恒 N/A, 前端型腔数恒回退 1 腔 - 方案级预览恢复:_attach_scheme_previews 改为每方案生成完整预览 (HTML + 摘要/数据 JSON 直传 RustFS)并写 scheme["html_file"], 前端切方案即切 iframe(此前仅最优方案一份,备选永远显示推荐预览); 删除零消费的 summary_file 死产物;任务级 HTML 复用推荐方案不再重复生成 - 前端 3D 预览标题栏加"当前:方案名"标识 已知取舍:报告键存储随方案数线性增长(记 TECH_DEBT D11 追记); 历史任务不回填,需重新分析。接口面零变化,openapi 不重导出。 测试:193 passed, 13 skipped(净增契约桥接回归测试 1 项,OCC-free); vue-tsc 通过;本地无 pythonocc,planner 运行时路径待 conda 环境补跑。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。
|
||||
> 早期条目(2026-09-17 之前)已精简为锚点,完整流水见 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md) 与 [archive/2026-09_status_history.md](archive/2026-09_status_history.md)。
|
||||
|
||||
> 2026-09-27(**分模方案预览契约修复:关键工艺参数面板整片 N/A + 备选方案预览无法切换**——多方案重构后 `cavity_data` 与可视化端的字段契约断裂,两处收口。① **legacy 契约桥接**:新增 `CalculationService.attach_scheme_info_contract`([calculation_service.py](../src/moldinsight/services/calculation_service.py)),多方案规划器([multi_scheme_planner.py](../src/moldinsight/core/multi_scheme_planner.py) `_build_scheme`)生成 `cavity_data` + `key_info` 后立即把方案级 `key_info` 内嵌回 `cavity_data`(`mold_cavities.cavity_key_info` + `cavity_count` + `manufacturing_info.mold_material / mold_hardness / surface_finish / estimated_cycle_time / parting_line_length`)——3D 预览"关键工艺参数"面板([html_generator.py](../src/shared/utils/html_generator.py) `updateInfoPanel` / `updateSummaryPanels`)只拿得到 `cavity_data`,多方案重构后这些字段仅存在于 `scheme.key_info`,面板 10 项 8 项恒 N/A、前端结果页"型腔数"恒回退 1 腔(根因佐证:`_strip_heavy_geometry` 专门保留 `mold_cavities.cavity_key_info`,说明预览契约仍是 legacy 结构,多方案生成器未遵守);② **方案级预览恢复**:[processing_service.py](../src/moldinsight/services/processing_service.py) `_attach_scheme_previews` 由"仅最优方案生成完整 HTML + 其余方案只生成 summary JSON(前端零消费死产物 `summary_file`,已删除)"改为**每方案生成完整预览**(HTML + `_summary.json` + `_data.json` 三件直传 RustFS 报告键)并写 `scheme["html_file"] = /html/{name}`——前端 ResultView 预览取 `selectedScheme?.html_file`,此前后端从不写方案级 `html_file`,切方案 iframe `:key` 不变,备选方案永远显示推荐方案预览;任务级 HTML 复用推荐方案预览不再重复生成(`save_html_file` 要求本地文件存在,`_attach_scheme_previews` 返回推荐方案预览本地路径,无任何方案 `cavity_data` 时回退任务级单份生成);③ 前端 [ResultView.vue](../frontend/src/modules/moldinsight/ResultView.vue) 3D 预览标题栏加"当前:方案名" t-tag(多方案时渲染),切换有明确反馈。**已知取舍**:每方案预览复制一份网格数据 JSON,RustFS 报告键存储随方案数线性增长,换备选方案真实独立分模预览(存储吃紧时演进共享产品网格 + 方案差分,见 [TECH_DEBT.md](TECH_DEBT.md) D11 追记)。**历史任务不回填**:已入库任务缺字段与每方案预览文件,需重新分析才能看到完整参数与切换预览。**接口面零变化**(无路由/Pydantic schema 变更,`scheme.html_file` 为 `/api/status/{task_id}` 自由 dict 内新增字段,openapi.json 不触发重导出)。**测试基线**:**193 passed, 13 skipped**(净增 1:`test_calculation_service_bridges_scheme_info_contract`,OCC-free 直测契约桥接);前端 `vue-tsc -b` 通过。**说明**:本地 pip 环境无 pythonocc,`multi_scheme_planner` 运行时路径未实跑(py_compile 通过;core→services import 模式与 `aluminum_foam_mold` 引 `MaterialService` 同款,无循环依赖),OCC 全量验证待 conda 环境补跑。)
|
||||
>
|
||||
> 2026-09-26(**端口默认值统一 10003 / 10004**:约定"容器内部端口无所谓,重要的是映射到宿主机的端口;前端页面 = 10003"。① [.env.example](../.env.example) 端口段重写:移除冗余的 `HOST` / `PORT`(uvicorn 命令硬编码,未读取)+ 移除误导性的"应用内部监听端口"注释;新增端口段约定(`FRONTEND_PORT=10003` 浏览器入口、`BACKEND_PORT=10003` 同端口供调试直连、`MOLDINSIGHT_PORT=10003` / `INVENTORY_PORT=10004` 独立模式);② [docker-compose.yml](../docker-compose.yml) frontend 默认端口回退 `80→10003`、backend 默认 `8000→10003`、删除 backend service 内冗余的 `HOST/PORT` env(uvicorn `--host/--port` 已是单一事实源,env 无代码读);③ [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) / [docker-compose.inventory.yml](../docker-compose.inventory.yml) `MOLDINSIGHT_PORT/INVENTORY_PORT` 默认 `8000/8001→10003/10004`;④ [docs/deployment/DEPLOY_PORT.md](../docs/deployment/DEPLOY_PORT.md) §3 / [docs/deployment/PORT_CONFIG.md](../docs/deployment/PORT_CONFIG.md) §1 §2 端口映射示例同步。**验证**:yaml 渲染后端口映射 `[unified] frontend 10003→80 / backend 10003→8000`、`[moldinsight] 10003→8000`、`[inventory] 10004→8001`,与约定一致。**遗留**:服务器 `.env` 与新版 `.env.example` 对齐(已有字段名一致,仅注释差异,不需要重设值)。
|
||||
>
|
||||
> 2026-09-26(**Compose 拆分部署机端到端复验:3 个收尾修复 + 1 处文档澄清**——① `--workdir` 误用修复:[docker-compose.yml](../docker-compose.yml) / [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) 中 `moldinsight-celery` 的 `command:` 原照搬旧 Dockerfile.celery 的 `celery worker --workdir=/app/src ...`,celery 5.x 已移除 `--workdir` 选项(部署机实测报 `No such option '--workdir'`),改为 `cd /app/src && exec celery -A celery_app worker ...`——celery_app.py 内 `include=["celery_tasks"]` 为裸模块名,必须在 `src/` 下启动 worker,与是否支持 `--workdir` 解耦,跨 celery 版本稳定;② **裸 `up` 不重建已有镜像**澄清:部署机复用旧 gemold-backend 镜像起容器(旧 miniconda base + 旧代码),`docker compose up -d` 仅在本地无同名镜像时构建,DEPLOYMENT §1.2 / README / OPERATIONS §4 / LINUX_SETUP §11 同步补一句"更新代码后须 `up -d --build` 或先 `docker compose build`";③ build.sh 步骤由"四步(base→backend→celery→frontend)"修正为"三步(base→backend→frontend,celery 复用 backend 镜像)"——`Dockerfile.celery` 早已删除但 build.sh 与 README 的描述未跟改,三处文档统一收口。**遗留**:服务器 `docker compose up -d --build` 重建验证新 base + 新 celery 启动命令端到端可用。)
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
- **读侧**:`/html` StaticFiles 挂载删除,新增代理路由 [html_report_router.py](../src/moldinsight/api/html_report_router.py)(挂根路径保持 URL 形状——持久化 cavity JSON 与前端 iframe 均引用 `/html/{filename}`):RustFS 报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404,防路径穿越(单段文件名校验)
|
||||
- **部署**:celery 服务摘除 `html_data` 卷(不再写本地);Dockerfile.moldinsight 删除 `COPY html_output/`(构建机陈旧报告不再烤进镜像)
|
||||
- **已知约束**(沿用 StaticFiles 时代既定姿态,非新引入):报告路由不做认证——iframe 无法携带 Authorization 头
|
||||
- **2026-09-27 追记**:可视化产物从"每任务一套"改为**每分模方案一套**——`_attach_scheme_previews` 为每个 `candidate_schemes` 生成独立 HTML / `_summary.json` / `_data.json`([processing_service.py](../src/moldinsight/services/processing_service.py)),`scheme["html_file"]` 供前端切方案即切预览;任务级 HTML 复用推荐方案那份(`HTMLFile` 记录仍仅推荐方案一条)。**已知取舍**:网格数据 JSON 每方案复制一份,报告键存储随方案数线性增长(当前 ≤3 方案,接受);存储吃紧时再演进共享产品网格 + 仅差分型腔/模芯,暂不排期
|
||||
- 回归测试:[tests/test_html_report_router.py](../tests/test_html_report_router.py)(8 例:四链路命中、新旧格式记录区分、媒体类型、404、穿越拒绝)
|
||||
|
||||
~~原现状 / 影响~~:可视化 HTML/摘要同时写本地与 RustFS,多副本下 /html 命中结果取决于负载均衡,跨副本文件不共享。
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
<div class="summary-header">
|
||||
<h3>3D 预览</h3>
|
||||
<t-tag theme="primary">重点区域</t-tag>
|
||||
<t-tag v-if="candidateSchemes.length > 1" theme="warning" variant="light">
|
||||
当前:{{ selectedScheme?.title || selectedScheme?.scheme_id }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<t-alert v-if="state.previewStatus === 'error'" theme="warning" title="预览加载失败" :message="'HTML 已生成但加载异常,请检查该链接是否可访问:' + selectedHtmlFile" style="margin-bottom: var(--space-3);" />
|
||||
<iframe
|
||||
|
||||
@@ -11,6 +11,7 @@ from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, topods
|
||||
from moldinsight.core.mold_generator_registry import mold_generator_registry
|
||||
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
|
||||
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
|
||||
from moldinsight.services.calculation_service import CalculationService
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -149,6 +150,9 @@ class MultiSchemeMoldPlanner:
|
||||
|
||||
cavity_data = generator.generate_detailed_cavity_json(cavity_result)
|
||||
key_info = generator.generate_cavity_key_info(cavity_result)
|
||||
# 桥接 legacy 契约:3D 预览面板/前端只读 cavity_data 内嵌字段,
|
||||
# key_info 不内嵌回去则"关键工艺参数"整片 N/A(见 attach_scheme_info_contract)
|
||||
CalculationService.attach_scheme_info_contract(cavity_data, key_info)
|
||||
cavity_data.setdefault("metadata", {})
|
||||
cavity_data["metadata"]["scheme_id"] = candidate["scheme_id"]
|
||||
cavity_data["metadata"]["scheme_method"] = candidate["method"]
|
||||
|
||||
@@ -364,6 +364,33 @@ class CalculationService:
|
||||
cls.attach_injection_system_summaries(result, material["name"])
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def attach_scheme_info_contract(cavity_data: Dict[str, Any], key_info: Dict[str, Any]) -> None:
|
||||
"""把方案级 key_info 内嵌回 cavity_data,补齐下游依赖的 legacy 字段契约(原地修改)。
|
||||
|
||||
3D 预览 HTML(shared/utils/html_generator 的"关键工艺参数"面板)只拿得到 cavity_data:
|
||||
- 面板读 mold_cavities.cavity_key_info.geometric_characteristics 与
|
||||
manufacturing_info.mold_material / mold_hardness / surface_finish /
|
||||
estimated_cycle_time / parting_line_length;
|
||||
- 前端结果页"型腔数"读 mold_cavities.cavity_count。
|
||||
多方案重构后这些字段只存在于 scheme.key_info,缺失会导致面板整片 N/A——在此统一桥接。
|
||||
"""
|
||||
mold_cavities = cavity_data.setdefault("mold_cavities", {})
|
||||
mold_cavities.setdefault("cavity_count", 1)
|
||||
mold_cavities["cavity_key_info"] = key_info
|
||||
|
||||
requirements = key_info.get("manufacturing_requirements", {})
|
||||
mold_parameters = key_info.get("mold_parameters", {})
|
||||
manufacturing = cavity_data.setdefault("manufacturing_info", {})
|
||||
manufacturing.setdefault(
|
||||
"mold_material",
|
||||
requirements.get("cavity_material") or manufacturing.get("recommended_material", ""),
|
||||
)
|
||||
manufacturing.setdefault("mold_hardness", requirements.get("hardness", ""))
|
||||
manufacturing.setdefault("surface_finish", requirements.get("surface_finish", ""))
|
||||
manufacturing.setdefault("estimated_cycle_time", requirements.get("estimated_cycle_time", ""))
|
||||
manufacturing.setdefault("parting_line_length", mold_parameters.get("parting_line_length", ""))
|
||||
|
||||
@classmethod
|
||||
def attach_injection_system_summaries(
|
||||
cls,
|
||||
|
||||
@@ -316,7 +316,7 @@ class ProcessingService:
|
||||
try:
|
||||
html_generator = HTMLGenerator(output_dir=str(html_out_dir))
|
||||
|
||||
detailed_cavity_json = await self._attach_scheme_previews(
|
||||
detailed_cavity_json, best_html_local = await self._attach_scheme_previews(
|
||||
detailed_cavity_json=detailed_cavity_json,
|
||||
geometry_data=geometry_data,
|
||||
stp_filename=Path(file_path).name,
|
||||
@@ -329,11 +329,22 @@ class ProcessingService:
|
||||
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else best_cavity_data
|
||||
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
|
||||
|
||||
# 8. 保存模具型腔数据(包含方案级预览链接)
|
||||
# 8. 保存模具型腔数据(包含方案级 html_file 预览链接)
|
||||
await self.analysis_storage.save_mold_cavity_data(
|
||||
db_session, stp_file_id, detailed_cavity_json
|
||||
)
|
||||
|
||||
# 任务级 HTML 复用推荐方案预览(各方案已生成并上传,不再重复生成)
|
||||
if best_html_local:
|
||||
await self.analysis_storage.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(best_html_local).name,
|
||||
best_html_local,
|
||||
)
|
||||
else:
|
||||
# 兜底:候选方案均无 cavity_data 时退回任务级单份生成
|
||||
logger.warning("未生成任何方案级预览,回退任务级单份可视化")
|
||||
html_file_path = html_generator.generate_and_save_visualization(
|
||||
geometry_data,
|
||||
Path(file_path).name,
|
||||
@@ -443,7 +454,7 @@ class ProcessingService:
|
||||
"material": requested_material,
|
||||
"parameters": process_params,
|
||||
"stage_timings": stage_timings,
|
||||
"html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}",
|
||||
"html_file": (best_scheme or {}).get("html_file") or "",
|
||||
"verification": verification_result,
|
||||
"llm_report": llm_report,
|
||||
"export_artifacts": export_artifacts,
|
||||
@@ -723,40 +734,49 @@ class ProcessingService:
|
||||
pointcloud_data: Optional[Dict[str, Any]] = None,
|
||||
lod_data: Optional[Dict[str, Any]] = None,
|
||||
html_generator: HTMLGenerator = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
|
||||
) -> Tuple[Dict[str, Any], Optional[str]]:
|
||||
"""为每个候选分模方案生成独立 3D 预览(HTML + 摘要/数据 JSON 直传 RustFS)。
|
||||
|
||||
前端结果页切方案即切预览,依赖方案级 scheme["html_file"]=/html/{name};
|
||||
只给最优方案生成单份 HTML(旧逻辑)会让备选方案永远显示推荐方案预览。
|
||||
|
||||
返回 (detailed_cavity_json, 推荐方案预览的本地路径):
|
||||
本地路径供 save_html_file 落 HTMLFile 记录(读侧要求本地文件存在),
|
||||
任务级 html_file 直接复用推荐方案预览,不再重复生成。
|
||||
"""
|
||||
if html_generator is None:
|
||||
raise ValueError(
|
||||
"html_generator 不能为空(D11:摘要产物统一经任务临时目录上传 RustFS)"
|
||||
"html_generator 不能为空(D11:可视化产物统一经任务临时目录上传 RustFS)"
|
||||
)
|
||||
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
|
||||
if not candidate_schemes:
|
||||
return detailed_cavity_json
|
||||
return detailed_cavity_json, None
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
best_html_local: Optional[str] = None
|
||||
|
||||
for scheme in candidate_schemes:
|
||||
cavity_data = scheme.get("cavity_data")
|
||||
if not cavity_data:
|
||||
continue
|
||||
suffix = scheme.get("scheme_id")
|
||||
base_stem = Path(stp_filename).stem.replace(" ", "_")
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
summary_name = f"mold_{base_stem}_{suffix}_{ts}_summary.json"
|
||||
summary_content = html_generator.generate_3d_viewer_summary(
|
||||
geometry_data, cavity_data
|
||||
suffix = str(scheme.get("scheme_id") or Path(stp_filename).stem)
|
||||
html_file_path = html_generator.generate_and_save_visualization(
|
||||
geometry_data,
|
||||
Path(stp_filename).name,
|
||||
cavity_data=cavity_data,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
suffix=suffix,
|
||||
)
|
||||
# D11:摘要 JSON 写任务临时目录后直传 RustFS 报告键,不落本地 html_output
|
||||
summary_path = html_generator.save_data_file(summary_content, summary_name)
|
||||
await rustfs_manager.upload_report_artifact(
|
||||
summary_name, Path(summary_path).read_bytes(),
|
||||
content_type="application/json",
|
||||
)
|
||||
scheme["summary_file"] = f"/html/{summary_name}"
|
||||
await self._upload_report_artifacts(Path(html_file_path))
|
||||
scheme["html_file"] = f"/html/{Path(html_file_path).name}"
|
||||
if best_scheme and scheme.get("scheme_id") == best_scheme.get("scheme_id"):
|
||||
best_html_local = html_file_path
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
if best_scheme:
|
||||
detailed_cavity_json["html_file"] = best_scheme.get("html_file")
|
||||
|
||||
return detailed_cavity_json
|
||||
return detailed_cavity_json, best_html_local
|
||||
|
||||
async def _upload_report_artifacts(self, html_file_path: Path):
|
||||
"""D11:任务临时目录中的可视化产物(.html / _summary.json / _data.json)
|
||||
|
||||
@@ -78,6 +78,52 @@ def test_calculation_service_attaches_injection_system_summary():
|
||||
assert "injection_system" in result
|
||||
|
||||
|
||||
def test_calculation_service_bridges_scheme_info_contract():
|
||||
"""多方案 cavity_data 必须自包含可视化面板依赖的 legacy 字段。
|
||||
|
||||
3D 预览"关键工艺参数"面板只拿得到 cavity_data;key_info 不内嵌回去则
|
||||
面板整片 N/A、前端型腔数恒为 1(2026-09 事故根因)。
|
||||
"""
|
||||
key_info = {
|
||||
"mold_parameters": {
|
||||
"shrinkage_rate": "0.50%",
|
||||
"draft_angle": "2.0°",
|
||||
"parting_line_length": 620.0,
|
||||
},
|
||||
"geometric_characteristics": {
|
||||
"product_volume": "12.00 cm³",
|
||||
"product_weight": "12.60 g",
|
||||
"wall_thickness_range": "1.40 - 2.60 mm",
|
||||
},
|
||||
"manufacturing_requirements": {
|
||||
"cavity_material": "P20钢材",
|
||||
"hardness": "HRC 28-32",
|
||||
"surface_finish": "SPI A2",
|
||||
"estimated_cycle_time": "25 秒",
|
||||
},
|
||||
}
|
||||
cavity_data = {
|
||||
"metadata": {"shrinkage_rate": 0.005, "draft_angle": 2.0},
|
||||
"manufacturing_info": {"recommended_material": "P20钢材"},
|
||||
"mold_cavities": {"cavity": {"vertex_count": 1}, "core": {"vertex_count": 1}},
|
||||
}
|
||||
|
||||
CalculationService.attach_scheme_info_contract(cavity_data, key_info)
|
||||
|
||||
mold_cavities = cavity_data["mold_cavities"]
|
||||
assert mold_cavities["cavity_count"] == 1
|
||||
assert (
|
||||
mold_cavities["cavity_key_info"]["geometric_characteristics"]["product_volume"]
|
||||
== "12.00 cm³"
|
||||
)
|
||||
manufacturing = cavity_data["manufacturing_info"]
|
||||
assert manufacturing["mold_material"] == "P20钢材"
|
||||
assert manufacturing["mold_hardness"] == "HRC 28-32"
|
||||
assert manufacturing["surface_finish"] == "SPI A2"
|
||||
assert manufacturing["estimated_cycle_time"] == "25 秒"
|
||||
assert manufacturing["parting_line_length"] == 620.0
|
||||
|
||||
|
||||
def test_material_service_falls_back_to_abs_for_unknown_material():
|
||||
material = MaterialService.get_material("UNKNOWN")
|
||||
assert material["name"] == "ABS"
|
||||
|
||||
Reference in New Issue
Block a user