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,93 @@
"""add experience_feedback
Human-in-Loop 闭环(D17,2026-09):老师傅对系统推荐方案给出'采纳 / 调整 / 拒绝'
反馈,按'产品指纹 + 工艺参数'为索引跨任务匹配;下次同指纹产品分析自动消费。
- 新增 experience_feedback 表(方案级反馈)
- 跨模块裸 FK(user_id / processing_task_id / stp_file_id)
- 复合索引 (stp_file_id, scheme_axis, feedback_status) 用于按方向聚合采纳计数
- GIN 索引(PG only)用于 fingerprint JSON 字段检索
Revision ID: b7d1f4a92c3e
Revises: a3f8c2d91e47
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b7d1f4a92c3e'
down_revision: Union[str, Sequence[str], None] = 'a3f8c2d91e47'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'experience_feedback',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('processing_task_id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('scheme_id', sa.String(length=64), nullable=False),
sa.Column('scheme_axis', sa.String(length=1), nullable=False),
sa.Column('scheme_method', sa.String(length=50), nullable=True),
sa.Column('feedback_status', sa.String(length=20), nullable=False),
sa.Column('feedback_reason', sa.Text(), nullable=True),
sa.Column('adjust_suggestion', sa.Text(), nullable=True),
sa.Column('process_params_snapshot', sa.JSON(), nullable=True),
sa.Column('fingerprint', sa.JSON(), nullable=False),
sa.Column('confidence_at_submit', sa.Float(), nullable=True),
sa.Column('score_at_submit', sa.Float(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_code', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(
['processing_task_id'], ['processing_tasks.id'], ondelete='CASCADE'
),
sa.ForeignKeyConstraint(
['stp_file_id'], ['stp_files.id'], ondelete='CASCADE'
),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], ondelete='RESTRICT'
),
)
op.create_index(op.f('ix_experience_feedback_id'), 'experience_feedback', ['id'], unique=False)
op.create_index(op.f('ix_experience_feedback_processing_task_id'), 'experience_feedback', ['processing_task_id'], unique=False)
op.create_index(op.f('ix_experience_feedback_stp_file_id'), 'experience_feedback', ['stp_file_id'], unique=False)
op.create_index(op.f('ix_experience_feedback_scheme_id'), 'experience_feedback', ['scheme_id'], unique=False)
op.create_index(op.f('ix_experience_feedback_feedback_status'), 'experience_feedback', ['feedback_status'], unique=False)
op.create_index(op.f('ix_experience_feedback_user_id'), 'experience_feedback', ['user_id'], unique=False)
op.create_index(op.f('ix_experience_feedback_created_at'), 'experience_feedback', ['created_at'], unique=False)
op.create_index(op.f('ix_experience_feedback_expires_at'), 'experience_feedback', ['expires_at'], unique=False)
op.create_index(
op.f('ix_experience_feedback_stp_axis_status'),
'experience_feedback',
['stp_file_id', 'scheme_axis', 'feedback_status'],
unique=False,
)
# GIN 索引(PG only):fingerprint JSON 字段 jsonb_path_query 类查询
if op.get_bind().dialect.name == 'postgresql':
op.execute(
"CREATE INDEX ix_experience_feedback_fingerprint_gin "
"ON experience_feedback USING GIN (fingerprint)"
)
def downgrade() -> None:
if op.get_bind().dialect.name == 'postgresql':
op.execute("DROP INDEX IF EXISTS ix_experience_feedback_fingerprint_gin")
op.drop_index(op.f('ix_experience_feedback_stp_axis_status'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_expires_at'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_created_at'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_user_id'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_feedback_status'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_scheme_id'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_stp_file_id'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_processing_task_id'), table_name='experience_feedback')
op.drop_index(op.f('ix_experience_feedback_id'), table_name='experience_feedback')
op.drop_table('experience_feedback')