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:
@@ -0,0 +1,199 @@
|
||||
"""老师傅经验反馈 API:D17 Human-in-Loop 闭环。
|
||||
|
||||
端点:
|
||||
- POST /api/tasks/{task_id}/experience-feedback 提交方案级反馈
|
||||
- GET /api/tasks/{task_id}/experience-hints 拉取同指纹历史 hints 摘要
|
||||
|
||||
权限:
|
||||
- 写入:Depends(get_current_active_user) + ensure_task_access + 行内 has_permission
|
||||
- 读取:Depends(get_current_active_user) + ensure_task_access(所有登录用户可看)
|
||||
|
||||
Pydantic 模型写在路由文件内(项目硬规则,shared/models/schemas.py 不扩张)。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
from moldinsight.services.experience_feedback_service import (
|
||||
ExperienceFeedbackService,
|
||||
compute_fingerprint,
|
||||
)
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.models import ExperienceFeedback, ProcessingTask, GeometryData
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic 请求 / 响应模型(写在路由文件内,硬规则)──
|
||||
|
||||
class ExperienceFeedbackCreate(BaseModel):
|
||||
"""老师傅方案级反馈请求体。"""
|
||||
|
||||
scheme_id: str = Field(..., min_length=1, max_length=64)
|
||||
feedback_status: Literal["adopted", "adjust", "rejected"]
|
||||
feedback_reason: Optional[str] = Field(None, max_length=2000)
|
||||
adjust_suggestion: Optional[str] = Field(None, max_length=2000)
|
||||
confidence_at_submit: Optional[float] = Field(None, ge=0.0, le=1.0)
|
||||
score_at_submit: Optional[float] = Field(None, ge=0.0, le=100.0)
|
||||
|
||||
|
||||
class ExperienceFeedbackResponse(BaseModel):
|
||||
"""反馈写入响应。"""
|
||||
|
||||
id: int
|
||||
scheme_id: str
|
||||
scheme_axis: str
|
||||
feedback_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ExperienceHintItem(BaseModel):
|
||||
"""同指纹历史 hints 摘要(按 scheme_axis 聚合)。"""
|
||||
|
||||
scheme_axis: str
|
||||
adopted_count: int
|
||||
rejected_count: int
|
||||
adjust_count: int
|
||||
confidence: float
|
||||
weight: float
|
||||
sample_count: int
|
||||
|
||||
|
||||
class ExperienceHintsResponse(BaseModel):
|
||||
"""GET /experience-hints 响应。"""
|
||||
|
||||
task_id: str
|
||||
stp_file_id: int
|
||||
material_name: str
|
||||
is_foam: bool
|
||||
fingerprint: Dict[str, str]
|
||||
hints: List[ExperienceHintItem]
|
||||
|
||||
|
||||
# ── 端点 ──
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/experience-feedback",
|
||||
response_model=ExperienceFeedbackResponse,
|
||||
)
|
||||
async def submit_feedback(
|
||||
task_id: str,
|
||||
body: ExperienceFeedbackCreate,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""提交方案级反馈。
|
||||
|
||||
权限:登录用户 + 任务归属 + feedback_experience_hint。
|
||||
写入后由路由 commit(D9 边界)+ invalidate_task_view(task_view 60s TTL 失效)。
|
||||
"""
|
||||
# 1. 任务归属校验(与 task_router / design_router 同一约定)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
# 2. 权限校验:行内 has_permission(identity.py:38 全仓首次调用)
|
||||
if not current_user.has_permission("feedback_experience_hint"):
|
||||
raise HTTPException(403, "需要工艺工程师或管理员权限")
|
||||
|
||||
# 3. 写反馈(仅 flush,D9 边界由本路由 commit)
|
||||
feedback = await ExperienceFeedbackService().record_feedback(
|
||||
session=db_session,
|
||||
task_id=task_id,
|
||||
scheme_id=body.scheme_id,
|
||||
feedback_status=body.feedback_status,
|
||||
feedback_reason=body.feedback_reason,
|
||||
adjust_suggestion=body.adjust_suggestion,
|
||||
user=current_user,
|
||||
confidence_at_submit=body.confidence_at_submit,
|
||||
score_at_submit=body.score_at_submit,
|
||||
process_params_snapshot=None, # 路由不接管 process_params,由算法层填
|
||||
)
|
||||
|
||||
try:
|
||||
await db_session.commit()
|
||||
except Exception as exc:
|
||||
await db_session.rollback()
|
||||
logger.error(f"反馈提交失败: {exc}")
|
||||
raise HTTPException(500, "反馈提交失败")
|
||||
|
||||
# 4. 失效任务视图缓存(写反馈后 next view 立即反映 hints)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return ExperienceFeedbackResponse(
|
||||
id=feedback.id,
|
||||
scheme_id=feedback.scheme_id,
|
||||
scheme_axis=feedback.scheme_axis,
|
||||
feedback_status=feedback.feedback_status,
|
||||
created_at=feedback.created_at or datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}/experience-hints",
|
||||
response_model=ExperienceHintsResponse,
|
||||
)
|
||||
async def get_experience_hints(
|
||||
task_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""拉取该任务的同指纹历史 hints 摘要。
|
||||
|
||||
权限:登录用户 + 任务归属。组织知识对所有人可见(不要求工艺工程师权限)。
|
||||
"""
|
||||
# 1. 任务归属校验
|
||||
row = await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
_, stp_file = row
|
||||
|
||||
# 2. 取 material / is_foam
|
||||
pt_row = await db_session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
processing_task = pt_row.scalar_one_or_none()
|
||||
params = (processing_task.parameters if processing_task else None) or {}
|
||||
material_name = str(params.get("material") or "ABS")
|
||||
is_foam = bool(params.get("is_foam_material", False))
|
||||
|
||||
# 3. 计算 fingerprint(用于回显 + 与 record_feedback 用同一函数)
|
||||
geo_row = await db_session.execute(
|
||||
select(GeometryData).where(GeometryData.stp_file_id == stp_file.id)
|
||||
)
|
||||
geo = geo_row.scalar_one_or_none()
|
||||
geometry_summary: Dict[str, Any] = {}
|
||||
if geo is not None:
|
||||
geometry_summary = {
|
||||
"volume": geo.volume,
|
||||
"bounding_box": {
|
||||
"min": geo.bounding_box_min,
|
||||
"max": geo.bounding_box_max,
|
||||
},
|
||||
"topology_faces": geo.topology_faces,
|
||||
}
|
||||
fingerprint = compute_fingerprint(geometry_summary, material_name, is_foam)
|
||||
|
||||
# 4. 拉 hints 聚合
|
||||
hints = await ExperienceFeedbackService().list_hints_for_task(
|
||||
session=db_session,
|
||||
stp_file_id=stp_file.id,
|
||||
material_name=material_name,
|
||||
is_foam=is_foam,
|
||||
)
|
||||
|
||||
return ExperienceHintsResponse(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file.id,
|
||||
material_name=material_name,
|
||||
is_foam=is_foam,
|
||||
fingerprint=fingerprint,
|
||||
hints=[ExperienceHintItem(**h) for h in hints],
|
||||
)
|
||||
Reference in New Issue
Block a user