D17 批 1:Human-in-Loop 老师傅经验反馈(数据 + 权限 + 写入 API)

新增 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>
This commit is contained in:
2026-09-23 16:11:17 +08:00
parent 79441a8a87
commit 2c9ba9d6b3
13 changed files with 1338 additions and 26 deletions
@@ -0,0 +1,156 @@
"""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