505f3591ab
让批 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 <noreply@anthropic.com>
417 lines
16 KiB
Python
417 lines
16 KiB
Python
"""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"] == [] |