2c9ba9d6b3
新增 experience_feedback 表(32 表迁移,alembic head b7d1f4a92c3e),
老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,按"产品指纹 +
工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费。
变更内容:
- src/moldinsight/models/experience_feedback.py(new)ORM:Base 单点来源、
跨模块裸 FK(user_id / processing_task_id / stp_file_id)、不建 ORM
relationship;fingerprint JSON 列存跨任务匹配键
- src/moldinsight/models/__init__.py 导出 ExperienceFeedback
- migrations/versions/b7d1f4a92c3e_add_experience_feedback.py(new)32 表
迁移;fingerprint 列 PG 下加 GIN 索引(jsonb_path_query 支持)
- src/shared/database/init_db.py 加 3 个权限码(view_experience_feedback /
feedback_experience_hint / manage_experience_feedback)+ 新角色
process_engineer;admin 角色 permissions 同步补齐;init_permissions /
init_roles 改为按 code 比对(新增保留已有 id,避免 FK 引用失效)——
修复既有 DB 启动期漏掉新权限的幂等 bug
- src/moldinsight/services/experience_feedback_service.py(new)service:
compute_fingerprint 分桶(bbox_aspect / volume_bucket / face_bucket /
undercut_class / material_family / is_foam)/ record_feedback(D9 边界:
service.flush + 路由 commit;D17 衰减:同 stp_file_id 整体续期 90 天 TTL,
无 celery beat 依赖)/ list_hints_for_task / resolve_for_process_params
- src/moldinsight/api/experience_feedback_router.py(new)路由:Pydantic
模型写在路由文件内(项目硬规则);POST /api/tasks/{task_id}/experience-feedback
+ GET /api/tasks/{task_id}/experience-hints;归属 TaskQueryService.ensure_task_access
+ User.has_permission 全仓首次调用点
- src/moldinsight/api/__init__.py ROUTE_MODULES 注册新路由
- tests/test_model_ownership.py EXPECTED_TABLES 加 experience_feedback
(31→32)
- tests/test_experience_feedback_fingerprint.py(new)分桶参数化覆盖
bbox / volume / face / undercut / material / is_foam 各边界值
- tests/test_experience_feedback_router.py(new)API 契约 9 例
(401/403/422/200 路径 + 衰减续期 + 任务归属校验 + ORM 注册收口)
- docs/STATUS.md 顶部加 2026-09-23 批 1 日志条目
- docs/TECH_DEBT.md D17 加批 1 已完成描述 + 剩余工作清单
- docs/API_CONTRACT.md §3.2 加 D17 端点表格
测试基线:185 passed, 9 skipped(净增 59 测试)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""D17 Human-in-Loop 闭环:compute_fingerprint 分桶边界值测试。
|
||
|
||
compute_fingerprint 是跨任务匹配的核心函数,纯函数,单独覆盖各分桶边界。
|
||
D17 验收:fingerprint 字典结构稳定 + 分桶边界值与 plan §5.1 一致。
|
||
"""
|
||
import pytest
|
||
|
||
from moldinsight.services.experience_feedback_service import compute_fingerprint
|
||
|
||
|
||
def _geo(volume: float = 50000.0, dims=(80, 60, 40), faces: int = 250, undercuts: int = 0):
|
||
"""构造测试用 geometry_data 字典。dims 单位 mm,volume 单位 mm³。"""
|
||
return {
|
||
"volume": volume,
|
||
"bounding_box": {
|
||
"dimensions": list(dims),
|
||
},
|
||
"topology_faces": faces,
|
||
"undercut_count": undercuts,
|
||
}
|
||
|
||
|
||
# ── bbox_aspect 分桶(按 sorted_dims mid/min 比)──
|
||
|
||
@pytest.mark.parametrize("dims,expected", [
|
||
# ratio = mid/min
|
||
((100, 100, 100), "compact"), # ratio=1.0 → compact(1.0 ≤ r < 1.5)
|
||
((100, 130, 100), "compact"), # ratio=1.0(去重后)
|
||
((60, 80, 100), "compact"), # ratio=1.0 → compact
|
||
((40, 80, 100), "slab"), # ratio=80/40=2.0 → slab(1.5 ≤ r < 3.0)
|
||
((20, 80, 100), "elongated"), # ratio=80/20=4.0 → elongated(3.0 ≤ r < 6.0)
|
||
((10, 80, 100), "long_bar"), # ratio=80/10=8 → long_bar(≥ 6.0)
|
||
((0, 0, 0), "compact"), # 零值默认
|
||
])
|
||
def test_bbox_aspect_bucket(dims, expected):
|
||
fp = compute_fingerprint(_geo(dims=dims), "ABS", False)
|
||
assert fp["bbox_aspect"] == expected, f"dims={dims} → expected {expected}, got {fp['bbox_aspect']}"
|
||
|
||
|
||
# ── volume_bucket 分桶(mm³ → cm³,buckets: xs<10 / s<100 / m<500 / l<2000 / xl≥2000)──
|
||
|
||
@pytest.mark.parametrize("volume_mm3,expected", [
|
||
(0, "xs"), # 0 cm³
|
||
(5000, "xs"), # 5 cm³
|
||
(9999, "xs"), # 边界 9.99 cm³ → xs
|
||
(10000, "s"), # 10 cm³ → s(10 ≤ v < 100)
|
||
(50000, "s"), # 50 cm³
|
||
(99999, "s"), # 边界 99.99 cm³
|
||
(100000, "m"), # 100 cm³ → m(100 ≤ v < 500)
|
||
(250000, "m"), # 250 cm³
|
||
(499999, "m"), # 边界 499.99 cm³
|
||
(500000, "l"), # 500 cm³ → l(500 ≤ v < 2000)
|
||
(1000000, "l"), # 1000 cm³
|
||
(1999999, "l"), # 边界 1999.99 cm³
|
||
(2000000, "xl"), # 2000 cm³ → xl(v ≥ 2000)
|
||
(10000000, "xl"), # 10000 cm³
|
||
])
|
||
def test_volume_bucket(volume_mm3, expected):
|
||
fp = compute_fingerprint(_geo(volume=volume_mm3), "ABS", False)
|
||
assert fp["volume_bucket"] == expected, f"volume={volume_mm3}mm³ → expected {expected}, got {fp['volume_bucket']}"
|
||
|
||
|
||
# ── face_bucket 分桶 ──
|
||
|
||
@pytest.mark.parametrize("faces,expected", [
|
||
(0, "simple"), # 0 面
|
||
(99, "simple"), # 边界 99
|
||
(100, "normal"), # 边界 100
|
||
(499, "normal"), # 边界 499
|
||
(500, "complex"), # 边界 500
|
||
(1999, "complex"), # 边界 1999
|
||
(2000, "dense"), # 边界 2000
|
||
(10000, "dense"),
|
||
])
|
||
def test_face_bucket(faces, expected):
|
||
fp = compute_fingerprint(_geo(faces=faces), "ABS", False)
|
||
assert fp["face_bucket"] == expected, f"faces={faces} → expected {expected}, got {fp['face_bucket']}"
|
||
|
||
|
||
# ── undercut_class 分桶 ──
|
||
|
||
@pytest.mark.parametrize("undercuts,expected", [
|
||
(0, "none"), # 0 → none
|
||
(1, "mild"), # 1 → mild
|
||
(3, "mild"), # 边界 3
|
||
(4, "moderate"), # 边界 4
|
||
(8, "moderate"), # 边界 8
|
||
(9, "heavy"), # 边界 9
|
||
(50, "heavy"),
|
||
])
|
||
def test_undercut_class(undercuts, expected):
|
||
fp = compute_fingerprint(_geo(undercuts=undercuts), "ABS", False)
|
||
assert fp["undercut_class"] == expected, f"undercuts={undercuts} → expected {expected}, got {fp['undercut_class']}"
|
||
|
||
|
||
# ── material_family 分桶 ──
|
||
|
||
@pytest.mark.parametrize("material,expected", [
|
||
("ABS", "abs"),
|
||
("ABS+PC", "abs"), # 子串匹配
|
||
("PP", "pp"),
|
||
("PA66", "pa"),
|
||
("AlSi10Mg", "foam"), # "al" + "si"
|
||
("AlSi12", "foam"),
|
||
("Aluminium Alloy", "other"), # 只有 al 无 si
|
||
("POM", "other"),
|
||
("", "other"),
|
||
])
|
||
def test_material_family(material, expected):
|
||
fp = compute_fingerprint(_geo(), material, False)
|
||
assert fp["material_family"] == expected, f"material={material} → expected {expected}, got {fp['material_family']}"
|
||
|
||
|
||
# ── is_foam 字段 ──
|
||
|
||
@pytest.mark.parametrize("is_foam,expected", [
|
||
(True, "true"),
|
||
(False, "false"),
|
||
])
|
||
def test_is_foam(is_foam, expected):
|
||
fp = compute_fingerprint(_geo(), "ABS", is_foam)
|
||
assert fp["is_foam"] == expected
|
||
|
||
|
||
# ── 字典键完整性 ──
|
||
|
||
def test_fingerprint_has_all_keys():
|
||
fp = compute_fingerprint(_geo(), "ABS", False)
|
||
expected_keys = {
|
||
"bbox_aspect", "volume_bucket", "face_bucket",
|
||
"undercut_class", "material_family", "is_foam",
|
||
}
|
||
assert set(fp.keys()) == expected_keys
|
||
|
||
|
||
# ── 空 geometry_data 容错 ──
|
||
|
||
def test_empty_geometry_data_returns_safe_defaults():
|
||
fp = compute_fingerprint(None, "ABS", False)
|
||
# 不应抛异常,所有键存在且为合法 bucket 值
|
||
assert fp["bbox_aspect"] == "compact"
|
||
assert fp["volume_bucket"] == "xs"
|
||
assert fp["face_bucket"] == "simple"
|
||
assert fp["undercut_class"] == "none"
|
||
assert fp["material_family"] == "abs"
|
||
assert fp["is_foam"] == "false"
|
||
|
||
|
||
def test_partial_geometry_data():
|
||
"""只有 bounding_box 没有 topology_faces,应走 fallback 0。"""
|
||
fp = compute_fingerprint(
|
||
{"bounding_box": {"dimensions": [100, 100, 100]}},
|
||
"ABS",
|
||
False,
|
||
)
|
||
assert fp["face_bucket"] == "simple" # 0 面 → simple |