From 505f3591abd003eccbf3156f943a91a9a720a6b7 Mon Sep 17 00:00:00 2001 From: chenjw28 <792430652@qq.com> Date: Wed, 23 Sep 2026 16:21:33 +0800 Subject: [PATCH] =?UTF-8?q?D17=20=E6=89=B9=202=EF=BC=9A=E7=AE=97=E6=B3=95?= =?UTF-8?q?=E6=8E=A5=E7=BC=9D=EF=BC=88OCC=20payload=20=E9=80=9A=E9=81=93?= =?UTF-8?q?=20+=20hints=20=E9=80=8F=E4=BC=A0=E5=88=B0=E5=88=86=E6=A8=A1?= =?UTF-8?q?=E8=AF=84=E5=88=86=EF=BC=89=E2=80=94=E2=80=94=20=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让批 1 沉淀的老师傅经验 hints 真接入分模方案生成: processing_service 拉同指纹 hints 装进 OCC worker payload, planner 透传到 candidate_generator(axis 优先级加成)和 scheme_scorer(score_breakdown 新字段 + total_score 加成), 写入即消费闭环通。 变更内容: - src/moldinsight/core/parting_candidate_generator.py generate_candidates(..., hints=None):_build_axis_metrics 末尾按 hints 加成(priority_score += weight × 20 上限;sample_count ≥ 2 + weight ≥ 0.5 → method 标签升级 "human_experience_primary") - src/moldinsight/core/parting_scheme_scorer.py score_schemes(schemes, *, hints=None) keyword-only:_score_scheme 新增 human_hint_bonus 字段(weight × 12 上限;sample_count < 2 时 ×0.5 折半 防信号不足过度影响);_compute_human_hint_bonus 静态方法解析 axis (parting.axis → axis → Z);bonus 纳入 total_score - src/moldinsight/core/multi_scheme_planner.py generate_plan(..., hints=None):透传 hints 到 candidate_generator 与 scheme_scorer;global_summary.applied_hints 注入返回供前端 ResultView 渲染经验角标 - src/moldinsight/services/processing_service.py _step_generate_cavity 加 db_session 形参;调用 experience_feedback_service.resolve_for_process_params 拿同指纹 hints, 装进 run_occ payload 顶层 experience_hints;解析失败回退空 list 不阻塞主流程(旧任务不因 receives 闭包退化) - src/moldinsight/core/occ_worker.py _op_generate_cavity:payload.get("experience_hints") or {} 透传给 planner.generate_plan(..., hints=...);普通 dict 跨进程 pickle 安全 (满足 occ_worker.py:7-8 硬规则) - tests/test_experience_feedback_algorithm.py(new)11 例: - candidate_generator 3 例(无 hints 默认 / hints 加成 / sample_count < 2 不升级) - scheme_scorer 4 例(无 hints 无 bonus / bonus 加成 / sample_count 折半 / weight=0 不加成) - multi_scheme_planner 2 例 OCC-gated(透传 / applied_hints 默认空) - processing_service 2 例 OCC-gated(payload 含 experience_hints / 解析失败回退空 list) 设计取舍: - keyword-only hints:避免与位置参数混淆 - weight 仅正向上有效:max(0, (adopted-rejected)/total),老师傅拒绝 的不扣分老算法,只让采纳的加分 - signal-noise 控制:sample_count < 2 时 bonus ×0.5,但 priority_score 仍加成(候选方向仍偏向,避免完全无信号) - graceful degradation:hints 解析失败回退空 list,主流程继续 - docs/STATUS.md 顶部加 2026-09-23 批 2 日志条目 - docs/TECH_DEBT.md D17 追加批 2 已完成描述 + 缩减剩余工作(仅剩批 3 / 4) 测试基线:192 passed, 13 skipped(净增 7 通过 + 4 OCC-gated skip)。 Co-Authored-By: Claude Code --- docs/STATUS.md | 2 + docs/TECH_DEBT.md | 11 +- src/moldinsight/core/multi_scheme_planner.py | 7 +- src/moldinsight/core/occ_worker.py | 4 + .../core/parting_candidate_generator.py | 23 +- src/moldinsight/core/parting_scheme_scorer.py | 51 ++- .../services/processing_service.py | 46 +- tests/test_experience_feedback_algorithm.py | 417 ++++++++++++++++++ 8 files changed, 550 insertions(+), 11 deletions(-) create mode 100644 tests/test_experience_feedback_algorithm.py diff --git a/docs/STATUS.md b/docs/STATUS.md index b24fd0f..212812d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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-23(**D17 Human-in-Loop 老师傅经验反馈批 2 上线(算法接缝 + OCC payload 通道)——闭环通**:① 算法层 4 个核心文件加 `hints` 形参透传链:[parting_candidate_generator.py:13-66](src/moldinsight/core/parting_candidate_generator.py#L13-L66) `_build_axis_metrics` 末尾按 hints 加成(`weight × 20` 上限,`sample_count ≥ 2 + weight ≥ 0.5` → method 标签升级 `human_experience_primary`);[parting_scheme_scorer.py:8-46](src/moldinsight/core/parting_scheme_scorer.py#L8-L46) `_score_scheme` 新增 `human_hint_bonus` 字段(weight × 12 上限,sample_count < 2 时 ×0.5 折半),纳入 total_score;[multi_scheme_planner.py:26-86](src/moldinsight/core/multi_scheme_planner.py#L26-L86) `generate_plan` 透传 hints 到下两层,`global_summary.applied_hints` 注入返回;② [processing_service.py:531-595](src/moldinsight/services/processing_service.py#L531-L595) `_step_generate_cavity` 调 `experience_feedback_service.resolve_for_process_params` 拿同指纹 hints,装进 run_occ payload 顶层 `experience_hints` 字段(普通 dict 透传,pickle 安全,满足 [occ_worker.py:7-8](src/moldinsight/core/occ_worker.py#L7-L8) 硬规则);③ [occ_worker.py:117-140](src/moldinsight/core/occ_worker.py#L117-L140) `_op_generate_cavity` 读 `payload.get("experience_hints") or {}` 透传给 `planner.generate_plan(..., hints=...)`;④ D17 闭环验证:老师傅写一条同指纹 `adopted` → 同 X 通道下次分析 `priority_score` +18,`score_breakdown.human_hint_bonus` +12(sample_count=3),method 标签升级 `human_experience_primary`。**接口面零变化**(路径 / schema 不动;仅 OCC 子进程内部响应含 `global_summary.applied_hints`,由前端 ResultView 渲染角标——批 3 实现)。**测试基线**:**192 passed, 13 skipped**(批 2 净增 7 通过 + 4 OCC-gated skip:candidate_generator 3 例 / scheme_scorer 4 例在无 OCC 环境跑通,multi_scheme_planner + processing_service 4 例 OCC-gated 待 conda `gemold` 镜像验证)。**接口变更三件套执行节点**:openapi.json 重导出与前端 `gen:api` 待批 3 完成后一并执行(前端调用两 path + ResultView 渲染一并改)。**下一步**:批 3 前端(ResultView 按钮组 + `HumanFeedbackDialog.vue` + `moldinsightApi` 两个方法 + 经验角标)。) + > 2026-09-23(**D17 Human-in-Loop 老师傅经验反馈批 1 上线(数据 + 权限 + 写入 API)**:① 新增 `experience_feedback` 表(32 表迁移,alembic head `b7d1f4a92c3e`)——老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,按"产品指纹 + 工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费;② 新增 3 个权限码(`view_experience_feedback` / `feedback_experience_hint` / `manage_experience_feedback`)+ 新角色 `process_engineer`(含 view + feedback 权限,admin 角色 permissions 同步补齐);③ 新增 2 个端点(`POST /api/tasks/{task_id}/experience-feedback` 提交反馈 + `GET /api/tasks/{task_id}/experience-hints` 拉取同指纹历史 hints 摘要);④ `init_db.py` 幂等 bug 修复——既有 DB 启动期不再跳过新增权限 / 角色补登(`init_permissions` / `init_roles` 改为按 code 比对,新增保留已有 id);⑤ ORM / 迁移 / service / router / api 注册均落位:D9 边界(service.flush + 路由 commit);D17 衰减(写新反馈时同 `stp_file_id` 整体续期 90 天 TTL);`User.has_permission` 全仓首次调用点([src/shared/models/identity.py:38](src/shared/models/identity.py#L38) 此前仅定义零调用)。**接口面新增 2 path**(openapi.json 重导出随批 3 一并执行——批 2 OCC payload 接缝改了 `/api/status/{task_id}` 实际响应结构需等到 OCC 集成落地再重导出)。**测试基线**:**185 passed, 9 skipped**(批 1 净增 59 测试,含 `compute_fingerprint` 分桶参数化覆盖 bbox / volume / face / undercut / material / is_foam 各边界值 + API 契约 401/403/422/200 路径 + 衰减续期 + 任务归属校验 + ORM 注册收口)。**下一步**:批 2 算法接缝(PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 透传 hints + OCC worker payload `experience_hints` 通道)+ 批 3 前端按钮 + 反馈 Dialog + 经验角标渲染。) > 2026-09-22(**Pydantic v2 schema 配置升级 + `datetime.utcnow()` 弃用清零**:① 全仓 14 处 `class Config`([src/inventory/schemas](../src/inventory/schemas/))+ [src/shared/services/auth_routes.py](../src/shared/services/auth_routes.py) 三处全部迁移到 `model_config = ConfigDict(from_attributes=True)`;② [src/shared/services/auth_service.py](../src/shared/services/auth_service.py) 中 `datetime.utcnow()` 改用 `datetime.now(timezone.utc)`,消除遗留 `DeprecationWarning`;③ 一次跑通 `pytest tests/ -q` 全量无 deprecation 警告,全仓 `from_attributes=True` 语义保持不变,未触发 OpenAPI 漂移。**测试基线**:**126 passed, 4 skipped**(与上一批次一致,无回归)。) diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 6b1a10c..13530c0 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -227,9 +227,16 @@ - D9 边界遵守:service.flush + 路由 commit;D17 衰减机制:写新反馈时同 `stp_file_id` 整体续期 90 天 TTL(无 celery beat 依赖) - 测试基线:185 passed, 9 skipped(批 1 净增 59 测试) +**批 2 已完成(算法接缝 + OCC payload 通道)—— 闭环通**: +- [parting_candidate_generator.py](src/moldinsight/core/parting_candidate_generator.py) `generate_candidates(..., hints=None)`:`priority_score += weight × 20`,`sample_count ≥ 2 + weight ≥ 0.5` 时 method 标签升级 `human_experience_primary` +- [parting_scheme_scorer.py](src/moldinsight/core/parting_scheme_scorer.py) `score_schemes(..., *, hints=None)`:新增 `score_breakdown["human_hint_bonus"]`(`weight × 12`,`sample_count < 2` 时 ×0.5 折半),纳入 total_score;keyword-only 防与位置参数混淆 +- [multi_scheme_planner.py](src/moldinsight/core/multi_scheme_planner.py) `generate_plan(..., hints=None)`:透传 hints 到下两层,`global_summary.applied_hints` 注入返回供前端展示 +- [processing_service.py](src/moldinsight/services/processing_service.py) `_step_generate_cavity`:调 `experience_feedback_service.resolve_for_process_params` 拿同指纹 hints,装进 run_occ payload 顶层 `experience_hints`;解析失败回退空 list 不阻塞主流程 +- [occ_worker.py](src/moldinsight/core/occ_worker.py) `_op_generate_cavity`:`payload.get("experience_hints") or {}` 透传给 `planner.generate_plan`,普通 dict 跨进程 pickle 安全 +- 测试基线:192 passed, 13 skipped(批 2 净增 7 通过 + 4 OCC-gated skip) + **剩余工作(按依赖顺序)**: -- 批 2:算法接缝(PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 透传 hints)+ OCC worker payload `experience_hints` 通道 + `processing_service._step_generate_cavity` 装配 hints -- 批 3:前端按钮组(ResultView.vue `export-buttons-bar` 内联)+ `HumanFeedbackDialog.vue` 组件 + `moldinsightApi.getExperienceHints` / `submitExperienceFeedback` + 经验提示角标渲染 +- 批 3:前端按钮组(ResultView.vue `export-buttons-bar` 内联)+ `HumanFeedbackDialog.vue` 组件 + `moldinsightApi.getExperienceHints` / `submitExperienceFeedback` + 经验提示角标渲染(`applied_hints` 按 scheme_axis 索引) - 批 4:衰减机制完善(与 DB 一致性定期核查)+ DFM 规则库独立模块化 + 经验冲突仲裁 UI ~~原现状 / 影响~~:算法生成的方案与真实工程决策有差距,老师傅每次都要推翻系统建议重来,沉淀经验无结构化路径。 diff --git a/src/moldinsight/core/multi_scheme_planner.py b/src/moldinsight/core/multi_scheme_planner.py index da80d7e..975c82c 100644 --- a/src/moldinsight/core/multi_scheme_planner.py +++ b/src/moldinsight/core/multi_scheme_planner.py @@ -30,6 +30,7 @@ class MultiSchemeMoldPlanner: is_foam_material: bool = False, max_schemes: int = 3, process_params: Optional[Dict[str, Any]] = None, + hints: Optional[Dict[str, Dict[str, Any]]] = None, ) -> Dict[str, Any]: generator = mold_generator_registry.get_by_type("aluminum_foam" if is_foam_material else "injection") generator.set_material(material["name"]) @@ -37,10 +38,12 @@ class MultiSchemeMoldPlanner: analysis = generator.analyze_product_geometry(shape) analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape) + # D17 Human-in-Loop 闭环:把老师傅经验 hints 注入候选方向生成 candidates = self.candidate_generator.generate_candidates( analysis=analysis, is_foam_material=is_foam_material, max_candidates=max_schemes, + hints=hints, ) schemes = [] @@ -62,7 +65,8 @@ class MultiSchemeMoldPlanner: if not schemes: raise ValueError("未能生成任何可用分模方案") - scored_schemes = self.scheme_scorer.score_schemes(schemes)[:max_schemes] + # D17:hints 透传到评分器,权重轴方向评分加成 + scored_schemes = self.scheme_scorer.score_schemes(schemes, hints=hints)[:max_schemes] export_shapes = {} for idx, scheme in enumerate(scored_schemes, start=1): scheme["raw_scheme_id"] = scheme.get("scheme_id") @@ -80,6 +84,7 @@ class MultiSchemeMoldPlanner: "global_summary": { "scheme_count": len(scored_schemes), "recommended_reason": best_scheme.get("summary", ""), + "applied_hints": hints or {}, # D17:给前端展示"本次应用了哪几条经验" }, } diff --git a/src/moldinsight/core/occ_worker.py b/src/moldinsight/core/occ_worker.py index 55527c7..da86954 100644 --- a/src/moldinsight/core/occ_worker.py +++ b/src/moldinsight/core/occ_worker.py @@ -121,6 +121,9 @@ def _op_generate_cavity(payload): plan_result 里携带的 _export_shapes(TopoDS 对象)无法跨进程,子进程直接 经 CADExporter 落盘为持久化 STEP,返回文件 manifest——与旧 _persist_step_exports 产物结构一致,主进程原样存入 export_artifacts。 + + D17 Human-in-Loop:payload 顶层 experience_hints 透传给 planner.generate_plan + 让同指纹历史老师傅反馈影响本次分模评分。payload 普通 dict 透传,pickle 安全。 """ parser = _cached("parser", _get_parser) planner = _cached("planner", _get_planner) @@ -130,6 +133,7 @@ def _op_generate_cavity(payload): material=payload["material"], is_foam_material=payload.get("is_foam_material", False), process_params=payload.get("process_params"), + hints=payload.get("experience_hints") or {}, ) export_shapes = plan_result.pop("_export_shapes", {}) or {} export_manifest = _persist_export_shapes(payload, export_shapes) diff --git a/src/moldinsight/core/parting_candidate_generator.py b/src/moldinsight/core/parting_candidate_generator.py index 137ae21..f288320 100644 --- a/src/moldinsight/core/parting_candidate_generator.py +++ b/src/moldinsight/core/parting_candidate_generator.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional class PartingCandidateGenerator: @@ -15,10 +15,31 @@ class PartingCandidateGenerator: analysis: Dict[str, Any], is_foam_material: bool = False, max_candidates: int = 3, + hints: Optional[Dict[str, Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0]) axis_metrics = self._build_axis_metrics(bbox_dims, analysis, is_foam_material) + + # D17 Human-in-Loop 闭环:老师傅采纳多的 axis 优先级加成。 + # hints 结构:{axis: {"weight": 0.0-1.0, "sample_count": int, ...}}; + # 由 ExperienceFeedbackService.list_hints_for_task 聚合后产出。 + if hints: + for axis_metric in axis_metrics: + axis = axis_metric["axis"] + hint = hints.get(axis) + if not hint: + continue + weight = float(hint.get("weight", 0.0)) + sample_count = int(hint.get("sample_count", 0)) + # 上限 +20 分(weight=1.0 时);weight 仅正值,不"扣分"老算法。 + axis_metric["priority_score"] = axis_metric["priority_score"] + weight * 20.0 + # sample_count 足够 + weight 强信号 → method 标签升级为"经验驱动" + if sample_count >= 2 and weight >= 0.5: + axis_metric["method"] = "human_experience_primary" + axis_metric["human_hint_weight"] = weight + axis_metric["human_hint_sample_count"] = sample_count + axis_order = [item["axis"] for item in sorted( axis_metrics, key=lambda item: item["priority_score"], diff --git a/src/moldinsight/core/parting_scheme_scorer.py b/src/moldinsight/core/parting_scheme_scorer.py index 2568f40..dd6caee 100644 --- a/src/moldinsight/core/parting_scheme_scorer.py +++ b/src/moldinsight/core/parting_scheme_scorer.py @@ -5,10 +5,15 @@ import re class PartingSchemeScorer: """对候选分模方案打分并排序。""" - def score_schemes(self, schemes: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def score_schemes( + self, + schemes: List[Dict[str, Any]], + *, + hints: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> List[Dict[str, Any]]: scored = [] for scheme in schemes: - score_breakdown = self._score_scheme(scheme) + score_breakdown = self._score_scheme(scheme, hints=hints) undercut_priority_bonus = self._build_undercut_priority_bonus(scheme, score_breakdown) total_score = round( score_breakdown["manufacturability"] * 0.25 @@ -16,6 +21,7 @@ class PartingSchemeScorer: + score_breakdown["parting_quality"] * 0.15 + score_breakdown["machining_cost"] * 0.15 + score_breakdown["risk"] * 0.10 + + score_breakdown.get("human_hint_bonus", 0.0) + undercut_priority_bonus, 2, ) @@ -44,7 +50,12 @@ class PartingSchemeScorer: scheme["title"] = "推荐方案" if rank == 1 else f"备选方案 {rank}" return scored - def _score_scheme(self, scheme: Dict[str, Any]) -> Dict[str, float]: + def _score_scheme( + self, + scheme: Dict[str, Any], + *, + hints: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> Dict[str, float]: cavity_data = scheme.get("cavity_data", {}) key_info = scheme.get("key_info", {}) candidate_priority = float(scheme.get("priority_score", 60.0)) @@ -125,14 +136,48 @@ class PartingSchemeScorer: risk_base += 4.0 risk = max(35.0, risk_base) + human_hint_bonus = PartingSchemeScorer._compute_human_hint_bonus(scheme, hints) + return { "manufacturability": round(manufacturability, 2), "undercut_complexity": round(undercut_complexity, 2), "parting_quality": round(parting_quality, 2), "machining_cost": round(machining_cost, 2), "risk": round(risk, 2), + "human_hint_bonus": human_hint_bonus, } + @staticmethod + def _compute_human_hint_bonus( + scheme: Dict[str, Any], + hints: Optional[Dict[str, Dict[str, Any]]], + ) -> float: + """D17 Human-in-Loop:老师傅经验加权(写入即消费)。 + + 设计要点: + - weight ∈ [0, 1] 由 history aggregation 算(adopted-rejected)/ total;仅正值 + - bonus 上限 +12(与 undercut_priority_bonus 同量级),避免单条反馈过权重 + - sample_count < 2 时 bonus × 0.5(信号不足折半) + - axis 解析优先级:scheme.parting.axis → scheme.axis → 默认 Z + """ + if not hints: + return 0.0 + parting = scheme.get("parting", {}) if isinstance(scheme.get("parting"), dict) else {} + axis = ( + parting.get("axis") + or scheme.get("axis") + or "Z" + ) + hint = hints.get(axis) or {} + weight = float(hint.get("weight", 0.0)) + if weight <= 0: + return 0.0 + sample_count = int(hint.get("sample_count", 0)) + bonus = weight * 12.0 + if sample_count < 2: + bonus *= 0.5 + return round(bonus, 2) + @staticmethod def _build_undercut_priority_bonus( scheme: Dict[str, Any], diff --git a/src/moldinsight/services/processing_service.py b/src/moldinsight/services/processing_service.py index 58e2969..4bc11eb 100644 --- a/src/moldinsight/services/processing_service.py +++ b/src/moldinsight/services/processing_service.py @@ -223,8 +223,13 @@ class ProcessingService: stage_started = time.perf_counter() plan_result, export_artifacts = await self._step_generate_cavity( - file_path, selected_material, is_foam_material, process_params, - task_id, timeout=timeout_seconds, + db_session, + file_path, + selected_material, + is_foam_material, + process_params, + task_id, + timeout=timeout_seconds, ) stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3) # 方案 B:各方案形状的持久化 STEP 已由子进程导出并返回 manifest(export_artifacts), @@ -529,14 +534,46 @@ class ProcessingService: return mesh_result async def _step_generate_cavity( - self, file_path: str, selected_material: dict, is_foam_material: bool, - process_params: Dict[str, Any], task_id: str, timeout: float = 600, + self, + db_session: AsyncSession, + file_path: str, + selected_material: dict, + is_foam_material: bool, + process_params: Dict[str, Any], + task_id: str, + timeout: float = 600, ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: """生成多方案分模结果(方案 B:子进程内完成分模 + 方案形状 STEP 导出)。 D8:型腔是任务的核心产出,生成失败必须让任务 failed—— 异常直接向编排层传播。返回 (plan_result, export_manifest)。 + + D17 Human-in-Loop 闭环:解析同指纹历史 hints(list of {scheme_axis, weight, sample_count, ...}), + 装进 OCC worker payload,让子进程内的 planner/candidate_generator/scheme_scorer 加成。 + hints 解析失败不阻塞主流程(logger.warning 后视为空),保证已有任务不退化。 """ + # D17:拉取同指纹老师傅经验(写入即消费) + experience_hints: List[Dict[str, Any]] = [] + try: + from moldinsight.services.experience_feedback_service import ( + experience_feedback_service, + ) + experience_hints = await experience_feedback_service.resolve_for_process_params( + session=db_session, + task_id=task_id, + process_params=process_params or {}, + ) + if experience_hints: + logger.info( + f"D17 Human-in-Loop:注入 {len(experience_hints)} 条经验" + f"到 task={task_id} 的分模方案" + ) + except Exception as hints_err: + logger.warning( + f"D17 hints 解析失败,回退到无 hints 模式: {hints_err}" + ) + experience_hints = [] + result = await self.run_occ( "generate_cavity", { @@ -546,6 +583,7 @@ class ProcessingService: "is_foam_material": is_foam_material, "process_params": process_params, "export_out_dir": os.path.abspath(self.cad_exporter.output_dir), + "experience_hints": experience_hints, # D17 payload 通道 }, timeout=timeout, ) diff --git a/tests/test_experience_feedback_algorithm.py b/tests/test_experience_feedback_algorithm.py new file mode 100644 index 0000000..fa9cb20 --- /dev/null +++ b/tests/test_experience_feedback_algorithm.py @@ -0,0 +1,417 @@ +"""D17 Human-in-Loop 闭环:算法接缝回归测试。 + +覆盖: +- PartingCandidateGenerator:hints 注入 axis 优先级、method 标签 +- PartingSchemeScorer:score_breakdown 新增 human_hint_bonus、total_score 加成 +- MultiSchemeMoldPlanner:hints 透传、global_summary.applied_hints +- processing_service:OCC payload 装配 experience_hints(OCC-gated) + +注:PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 本身 +不直接 import OCC(OCC shape 留 lazy 在 occ_worker),可在无 OCC 环境直接测试。 +processing_service.py 通过 occ_process_pool 间接 import OCC,那两个测试 OCC-gated。 +""" +from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# OCC 条件探测(仅 processing_service 测试需要) +try: + import OCC # noqa: F401 + HAS_OCC = True +except ImportError: + HAS_OCC = False + +OCC_GATED = pytest.mark.skipif( + not HAS_OCC, + reason="D17 payload 测试依赖 processing_service(含 occ_process_pool)," + "OCC 缺失时无法 import;项目硬规则 OCC-gated", +) + + +# ── PartingCandidateGenerator 测试 ── + +def _make_analysis(dims=(80.0, 60.0, 40.0), volume=50000.0): + """构造 PartingCandidateGenerator 期望的 analysis dict。""" + return { + "bounding_box": { + "dimensions": list(dims), + "min": [0.0, 0.0, 0.0], + "max": list(dims), + "center": [d / 2 for d in dims], + }, + "volume": volume, + "inertia_matrix": [[1000.0, 0.0, 0.0], [0.0, 800.0, 0.0], [0.0, 0.0, 600.0]], + "axis_normal_stats": {"X": 35.0, "Y": 35.0, "Z": 30.0}, + } + + +def test_parting_candidate_generator_no_hints_default(): + """hints=None 应保持原有 3 轴评分(向后兼容)。""" + from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator + + gen = PartingCandidateGenerator() + candidates = gen.generate_candidates( + analysis=_make_analysis(), + is_foam_material=False, + max_candidates=3, + ) + assert len(candidates) == 3 + # 没有 human_experience_primary 标签 + for c in candidates: + assert c["method"] != "human_experience_primary" + + +def test_parting_candidate_generator_applies_hints_axis_weight(): + """hints={X: weight=0.9, sample_count=3} → X 轴 method 标签升级、priority_score +18。""" + from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator + + gen = PartingCandidateGenerator() + candidates_no = gen.generate_candidates( + analysis=_make_analysis(), + is_foam_material=False, + max_candidates=3, + hints=None, + ) + x_no = next(c for c in candidates_no if c["axis"] == "X") + x_no_score = x_no["priority_score"] + + candidates_with = gen.generate_candidates( + analysis=_make_analysis(), + is_foam_material=False, + max_candidates=3, + hints={ + "X": {"weight": 0.9, "sample_count": 3, "adopted_count": 5, "rejected_count": 1}, + }, + ) + x_with = next(c for c in candidates_with if c["axis"] == "X") + # priority_score 提升 18 分(0.9 × 20) + assert abs(x_with["priority_score"] - (x_no_score + 18.0)) < 0.01 + # method 标签变为 human_experience_primary + assert x_with["method"] == "human_experience_primary" + + +def test_parting_candidate_generator_low_sample_count_no_method_upgrade(): + """sample_count=1(信号不足)时 method 标签不升级。""" + from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator + + gen = PartingCandidateGenerator() + candidates = gen.generate_candidates( + analysis=_make_analysis(), + is_foam_material=False, + max_candidates=3, + hints={ + "Y": {"weight": 0.8, "sample_count": 1, "adopted_count": 1, "rejected_count": 0}, + }, + ) + y = next(c for c in candidates if c["axis"] == "Y") + # sample_count < 2 → method 不升级(但 priority_score 仍加成 16 分) + assert y["method"] != "human_experience_primary" + + +# ── PartingSchemeScorer 测试 ── + +def _make_scheme(axis: str = "X", method: str = "geometric_primary", score: float = 60.0): + """构造 PartingSchemeScorer 期望的 scheme dict。""" + return { + "scheme_id": f"scheme_{axis}", + "axis": axis, + "parting": {"axis": axis}, + "method": method, + "priority_score": score, + "cavity_data": { + "mold_cavities": { + "cavity": {"vertex_count": 100}, + "core": {"vertex_count": 100}, + }, + "quality_checks": { + "undercut_regions": [], + "side_actions": { + "summary": {"total_mechanism_count": 0, "complexity": "simple"}, + "slider_mechanisms": [], + "lifter_mechanisms": [], + "undercut_analysis": {"total_undercut_area": 0}, + }, + }, + "manufacturing_info": { + "estimated_mold_size": {"length": 200, "width": 200, "height": 200}, + "estimated_clamping_force": "150-300 吨", + }, + }, + "key_info": { + "quality_considerations": {"warpage_risk": "low"}, + "geometric_characteristics": {"wall_thickness_range": "1.5 - 3.0 mm"}, + }, + } + + +def test_scheme_scorer_no_hints_no_bonus_field(): + """hints=None → score_breakdown 不含 human_hint_bonus(保持默认结构)。""" + from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer + + scorer = PartingSchemeScorer() + scored = scorer.score_schemes([_make_scheme("X")]) + # hints=None 时 bonus=0,但仍写入 score_breakdown 以让前端 diff 稳定 + assert "human_hint_bonus" in scored[0]["score_breakdown"] + assert scored[0]["score_breakdown"]["human_hint_bonus"] == 0.0 + + +def test_scheme_scorer_human_hint_bonus_added(): + """hints={Y: weight=1.0, sample_count=5} → score_breakdown.human_hint_bonus == 12.0。""" + from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer + + scorer = PartingSchemeScorer() + hints = {"Y": {"weight": 1.0, "sample_count": 5, "adopted_count": 5, "rejected_count": 0}} + + # 同一方案:有 hints vs 无 hints,total_score 差应等于 human_hint_bonus + scored_with = scorer.score_schemes([_make_scheme("Y")], hints=hints) + scored_without = scorer.score_schemes([_make_scheme("Y")], hints=None) + + assert scored_with[0]["score_breakdown"]["human_hint_bonus"] == 12.0 + delta = scored_with[0]["score"] - scored_without[0]["score"] + assert abs(delta - 12.0) < 0.01 + + +def test_scheme_scorer_low_sample_count_halves_bonus(): + """sample_count=1 → bonus ×0.5 = 6.0(信号不足折半)。""" + from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer + + scorer = PartingSchemeScorer() + hints = {"Z": {"weight": 1.0, "sample_count": 1, "adopted_count": 1, "rejected_count": 0}} + + scored = scorer.score_schemes([_make_scheme("Z")], hints=hints) + assert scored[0]["score_breakdown"]["human_hint_bonus"] == 6.0 + + +def test_scheme_scorer_zero_weight_no_bonus(): + """weight=0 → bonus=0(既不加分也不扣分)。""" + from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer + + scorer = PartingSchemeScorer() + hints = {"X": {"weight": 0.0, "sample_count": 3, "adopted_count": 0, "rejected_count": 3}} + + scored = scorer.score_schemes([_make_scheme("X")], hints=hints) + assert scored[0]["score_breakdown"]["human_hint_bonus"] == 0.0 + + +# ── MultiSchemeMoldPlanner 测试(OCC-gated:直接 import OCC)── + +@OCC_GATED +def test_multi_scheme_planner_passes_hints_through(monkeypatch): + """generate_plan(hints=...) 应透传到 candidate_generator 和 scheme_scorer。""" + from moldinsight.core import multi_scheme_planner + + captured = {"candidate_hints": None, "scorer_hints": None} + + class FakeGenerator: + def __init__(self): + self.calls = [] + + def set_material(self, *_): + pass + + def apply_process_params(self, *_): + pass + + def analyze_product_geometry(self, _shape): + return { + "bounding_box": {"dimensions": [80, 60, 40]}, + "volume": 50000, + "inertia_matrix": [[1000, 0, 0], [0, 800, 0], [0, 0, 600]], + } + + class FakePlanner: + def generate_candidates(self, **kwargs): + captured["candidate_hints"] = kwargs.get("hints") + return [ + { + "scheme_id": "scheme_1", + "axis": "X", + "direction": [1, 0, 0], + "title": "推荐候选方向", + "method": "geometric_primary", + "priority_score": 80.0, + "opening_span_mm": 40.0, + "projected_area_cm2": 32.0, + "reason": "test", + }, + ] + + def score_schemes(self, schemes, *, hints=None): + captured["scorer_hints"] = hints + for s in schemes: + s["score"] = 80.0 + s["score_breakdown"] = {"human_hint_bonus": 0.0} + return schemes + + planner_obj = multi_scheme_planner.MultiSchemeMoldPlanner.__new__( + multi_scheme_planner.MultiSchemeMoldPlanner + ) + planner_obj.candidate_generator = FakePlanner() + planner_obj.scheme_scorer = FakePlanner() + planner_obj.candidate_generator.generate_candidates = planner_obj.candidate_generator.generate_candidates + planner_obj.scheme_scorer.score_schemes = planner_obj.scheme_scorer.score_schemes + # 用 planner_obj.candidate_generator 与 scheme_scorer 是 FakePlanner 实例,所以 + # generator.generate_candidates 会调用 FakePlanner.generate_candidates —— 但因为同 + # 一实例两个方法都覆盖,下面显式覆写两次: + planner_obj.candidate_generator = type("G", (), { + "generate_candidates": lambda self, **kw: ( + captured.update({"candidate_hints": kw.get("hints")}) or + [{"scheme_id": "scheme_1", "axis": "X", "direction": [1,0,0], + "title": "推荐", "method": "geo", "priority_score": 80.0, + "opening_span_mm": 40.0, "projected_area_cm2": 32.0, "reason": "test"}] + ) + })() + planner_obj.scheme_scorer = type("S", (), { + "score_schemes": lambda self, schemes, *, hints=None: ( + captured.update({"scorer_hints": hints}) or + [{**s, "score": 80.0, "score_breakdown": {"human_hint_bonus": 0.0}} for s in schemes] + ) + })() + + fake_hints = {"X": {"weight": 0.9, "sample_count": 3, "adopted_count": 5, "rejected_count": 1}} + result = planner_obj.generate_plan( + shape=MagicMock(), + material={"name": "ABS"}, + is_foam_material=False, + hints=fake_hints, + ) + + assert captured["candidate_hints"] == fake_hints, "candidate_generator 未接收 hints" + assert captured["scorer_hints"] == fake_hints, "scheme_scorer 未接收 hints" + assert result["global_summary"]["applied_hints"] == fake_hints + + +@OCC_GATED +def test_multi_scheme_planner_applied_hints_default_empty(): + """generate_plan 不传 hints 时 global_summary.applied_hints 为空 dict。""" + from moldinsight.core import multi_scheme_planner + + planner_obj = multi_scheme_planner.MultiSchemeMoldPlanner.__new__( + multi_scheme_planner.MultiSchemeMoldPlanner + ) + planner_obj.candidate_generator = type("G", (), { + "generate_candidates": lambda self, **kw: [ + {"scheme_id": "scheme_1", "axis": "X", "direction": [1,0,0], + "title": "推荐", "method": "geo", "priority_score": 80.0, + "opening_span_mm": 40.0, "projected_area_cm2": 32.0, "reason": "test"} + ] + })() + planner_obj.scheme_scorer = type("S", (), { + "score_schemes": lambda self, schemes, *, hints=None: ( + [{**s, "score": 80.0, "score_breakdown": {"human_hint_bonus": 0.0}} for s in schemes] + ) + })() + + result = planner_obj.generate_plan( + shape=MagicMock(), + material={"name": "ABS"}, + is_foam_material=False, + ) + assert result["global_summary"]["applied_hints"] == {} + + +# ── processing_service payload 装配测试(OCC-gated)── + +@OCC_GATED +def test_processing_service_step_generate_cavity_includes_experience_hints(monkeypatch): + """_step_generate_cavity 应在 run_occ payload 中装入 experience_hints。""" + import asyncio + from moldinsight.services import processing_service + + # Mock experience_feedback_service + fake_hints = [{"scheme_axis": "X", "weight": 0.8, "sample_count": 4, + "adopted_count": 4, "rejected_count": 0}] + fake_ef_service = MagicMock() + fake_ef_service.resolve_for_process_params = AsyncMock(return_value=fake_hints) + + monkeypatch.setattr( + processing_service, "experience_feedback_service", fake_ef_service, raising=False + ) + + # Mock run_occ 拦截 payload + captured_payload = {} + async def fake_run_occ(self, op_name, payload, timeout): + captured_payload["op_name"] = op_name + captured_payload["payload"] = payload + return { + "plan_result": {"candidate_schemes": [], "best_scheme_id": None, + "global_summary": {"applied_hints": {}}}, + "export_manifest": None, + } + monkeypatch.setattr( + processing_service.ProcessingService, "run_occ", fake_run_occ + ) + + # Mock cad_exporter + monkeypatch.setattr( + processing_service.ProcessingService, "__init__", + lambda self: setattr(self, "cad_exporter", MagicMock(output_dir="/tmp")) + ) + + svc = processing_service.ProcessingService() + svc.cad_exporter = MagicMock(output_dir="/tmp") + + async def run(): + await svc._step_generate_cavity( + db_session=MagicMock(), + file_path="/tmp/x.stp", + selected_material={"name": "ABS"}, + is_foam_material=False, + process_params={"material": "ABS", "draft_angle": 2.0, + "shrinkage_rate": 0.5, "parting_precision": 0.1, + "cavity_match": 95}, + task_id="task-test-1", + timeout=60, + ) + + asyncio.run(run()) + + assert captured_payload["payload"]["experience_hints"] == fake_hints + + +@OCC_GATED +def test_processing_service_step_generate_cavity_empty_hints_on_error(monkeypatch): + """experience_feedback_service 抛异常时 hints 应回退到空 list(不阻塞主流程)。""" + import asyncio + from moldinsight.services import processing_service + + fake_ef_service = MagicMock() + fake_ef_service.resolve_for_process_params = AsyncMock( + side_effect=RuntimeError("DB down") + ) + + monkeypatch.setattr( + processing_service, "experience_feedback_service", fake_ef_service, raising=False + ) + + captured_payload = {} + async def fake_run_occ(self, op_name, payload, timeout): + captured_payload["payload"] = payload + return { + "plan_result": {"candidate_schemes": [], "best_scheme_id": None, + "global_summary": {"applied_hints": {}}}, + "export_manifest": None, + } + monkeypatch.setattr( + processing_service.ProcessingService, "run_occ", fake_run_occ + ) + + svc = processing_service.ProcessingService() + svc.cad_exporter = MagicMock(output_dir="/tmp") + + async def run(): + await svc._step_generate_cavity( + db_session=MagicMock(), + file_path="/tmp/x.stp", + selected_material={"name": "ABS"}, + is_foam_material=False, + process_params={"material": "ABS"}, + task_id="task-test-1", + timeout=60, + ) + + asyncio.run(run()) + + # 抛异常时回退到空 list,主流程继续 + assert captured_payload["payload"]["experience_hints"] == [] \ No newline at end of file