"""老师傅经验反馈 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], )