diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index d786331..10917b2 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -54,6 +54,7 @@ | 加工 | `/api/design-cam`、`/api/check-collision`、`/api/optimize-toolpath`、`/api/design-electrodes`、`/api/simulate-machining` | machining_router.py | | 导出 | `/api/export-mold`、`/api/export-download/{filepath}`、`/api/export-recommendations` | export_router.py | | 铝价(模拟数据) | `/api/aluminum-price/current`、`/api/aluminum-price/history` | aluminum_price_routes.py | +| 老师傅经验反馈(D17) | `/api/tasks/{task_id}/experience-feedback`(写入:需登录 + 任务归属 + `feedback_experience_hint` 权限;body 含 `scheme_id` / `feedback_status ∈ {adopted, adjust, rejected}` / 可选 `feedback_reason` / `adjust_suggestion` / 上下文快照;写完调用 `TaskQueryService.invalidate_task_view`);`/api/tasks/{task_id}/experience-hints`(读取:需登录 + 任务归属;返回同 stp_file_id + material_family + is_foam 锚定的历史 hints 聚合,按 scheme_axis 分组,含 adopted/rejected/adjust 计数 + 加权 confidence + sample_count + 回显 fingerprint) | experience_feedback_router.py | | 健康检查 | `/api/health`(有路由装载失败时 `status: degraded` 并列出失败清单;`pythonocc` 为真实探测) | health_router.py | | 调试(仅 DEBUG) | `/api/debug/tasks` | debug_router.py | diff --git a/docs/STATUS.md b/docs/STATUS.md index 89c3fff..b24fd0f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -4,6 +4,8 @@ > 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。 > 早期条目(2026-09-17 之前)已精简为锚点,完整流水见 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md) 与 [archive/2026-09_status_history.md](archive/2026-09_status_history.md)。 +> 2026-09-23(**D17 Human-in-Loop 老师傅经验反馈批 1 上线(数据 + 权限 + 写入 API)**:① 新增 `experience_feedback` 表(32 表迁移,alembic head `b7d1f4a92c3e`)——老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,按"产品指纹 + 工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费;② 新增 3 个权限码(`view_experience_feedback` / `feedback_experience_hint` / `manage_experience_feedback`)+ 新角色 `process_engineer`(含 view + feedback 权限,admin 角色 permissions 同步补齐);③ 新增 2 个端点(`POST /api/tasks/{task_id}/experience-feedback` 提交反馈 + `GET /api/tasks/{task_id}/experience-hints` 拉取同指纹历史 hints 摘要);④ `init_db.py` 幂等 bug 修复——既有 DB 启动期不再跳过新增权限 / 角色补登(`init_permissions` / `init_roles` 改为按 code 比对,新增保留已有 id);⑤ ORM / 迁移 / service / router / api 注册均落位:D9 边界(service.flush + 路由 commit);D17 衰减(写新反馈时同 `stp_file_id` 整体续期 90 天 TTL);`User.has_permission` 全仓首次调用点([src/shared/models/identity.py:38](src/shared/models/identity.py#L38) 此前仅定义零调用)。**接口面新增 2 path**(openapi.json 重导出随批 3 一并执行——批 2 OCC payload 接缝改了 `/api/status/{task_id}` 实际响应结构需等到 OCC 集成落地再重导出)。**测试基线**:**185 passed, 9 skipped**(批 1 净增 59 测试,含 `compute_fingerprint` 分桶参数化覆盖 bbox / volume / face / undercut / material / is_foam 各边界值 + API 契约 401/403/422/200 路径 + 衰减续期 + 任务归属校验 + ORM 注册收口)。**下一步**:批 2 算法接缝(PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 透传 hints + OCC worker payload `experience_hints` 通道)+ 批 3 前端按钮 + 反馈 Dialog + 经验角标渲染。) + > 2026-09-22(**Pydantic v2 schema 配置升级 + `datetime.utcnow()` 弃用清零**:① 全仓 14 处 `class Config`([src/inventory/schemas](../src/inventory/schemas/))+ [src/shared/services/auth_routes.py](../src/shared/services/auth_routes.py) 三处全部迁移到 `model_config = ConfigDict(from_attributes=True)`;② [src/shared/services/auth_service.py](../src/shared/services/auth_service.py) 中 `datetime.utcnow()` 改用 `datetime.now(timezone.utc)`,消除遗留 `DeprecationWarning`;③ 一次跑通 `pytest tests/ -q` 全量无 deprecation 警告,全仓 `from_attributes=True` 语义保持不变,未触发 OpenAPI 漂移。**测试基线**:**126 passed, 4 skipped**(与上一批次一致,无回归)。) > > 2026-09-22(**D13 锁文件流程固化:镜像引入已清偿 + 落锁流程就绪**:① 新增 [deploy/generate_lockfiles.sh](../deploy/generate_lockfiles.sh) / [.bat](../deploy/generate_lockfiles.bat):在 moldinsight conda 环境(仅项目依赖,**不能**在混装开发栈跑)执行 `pip freeze --exclude pythonocc-core`,产出 `deploy/requirements-{base,moldinsight}.lock.txt`;② [Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 注释改为指向生成脚本;③ [docs/OPERATIONS.md](../docs/OPERATIONS.md) §2.1 增加完整流程说明(生成时机 / 命令 / 产物 / 消费方 / 提交策略);④ [tests/test_lockfile_generation.py](../tests/test_lockfile_generation.py) 加锁文件存在性 + 体积契约,默认 skip(仓库单测不阻塞),CI 镜像构建 job 显式 `pytest --run-lockfile-check` 启用 fail-fast。**遗留**:锁文件本身尚未落盘——本机 Miniforge 装的是跨项目开发栈混装环境,污染严重不能直接用 `pip freeze`;须等 CI / 生产机器首次构建 moldinsight 镜像后按流程落锁。**测试基线**:**126 passed, 9 skipped**(默认 4 个原有 skip + D13 新增 5 个 skip;启用 `--run-lockfile-check` 时严格断言 2 项锁文件契约)。) diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 2535e53..6b1a10c 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -213,6 +213,27 @@ ~~原现状 / 影响~~:`vue-tsc -b`(`npm run build` 的类型检查步)因既有 TS6133 失败,前端无法出生产包(与批次 3 改动无关的既有问题)。 +### D17. 算法成熟度距"老师傅经验"差距 + Human-in-Loop 闭环 —— 批 1 已清偿(2026-09-23) + +**背景**:现有算法(分模 / 倒扣 / 评分 / DFM 校验)是 OCC BREP 上的工程启发式,距模具师傅"看完就知道该咋改"的实战经验仍有结构性差距——倒扣邻接聚类缺失、滑块 / 斜顶设计是纯几何启发、DFM 规则库仅 4 条、评分权重拍脑袋(详见 2026-09-22 用户对话评估)。 + +**方案**:引入 Human-in-Loop 闭环——老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,以"产品指纹 + 工艺参数"为索引跨任务匹配,**下次同指纹产品分析自动消费这些经验**(OCC worker payload 透传 → MultiSchemeMoldPlanner → PartingSchemeScorer 加成)。老师傅的经验以结构化数据沉淀,避免成为"知识库坟墓"。 + +**批 1 已完成(数据 + 权限 + 写入 API)**: +- 新增 `experience_feedback` 表(alembic head `b7d1f4a92c3e`,32 表迁移),含 fingerprint JSON 列(PG 下 GIN 索引支持 jsonb_path_query) +- 3 个权限码(`view_experience_feedback` / `feedback_experience_hint` / `manage_experience_feedback`)+ 新角色 `process_engineer`;admin 角色 permissions 同步补齐 +- `init_db.py` 幂等 bug 修复——既有 DB 启动期不再跳过新增权限 / 角色补登(`init_permissions` / `init_roles` 改为按 code 比对,新增保留已有 id,避免 FK 引用失效) +- 新增端点 `POST /api/tasks/{task_id}/experience-feedback`(提交方案级反馈;`TaskQueryService.ensure_task_access` 归属校验 + `User.has_permission` 全仓首次调用)+ `GET /api/tasks/{task_id}/experience-hints`(按 material_family + is_foam 锚定的历史聚合) +- D9 边界遵守:service.flush + 路由 commit;D17 衰减机制:写新反馈时同 `stp_file_id` 整体续期 90 天 TTL(无 celery beat 依赖) +- 测试基线:185 passed, 9 skipped(批 1 净增 59 测试) + +**剩余工作(按依赖顺序)**: +- 批 2:算法接缝(PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 透传 hints)+ OCC worker payload `experience_hints` 通道 + `processing_service._step_generate_cavity` 装配 hints +- 批 3:前端按钮组(ResultView.vue `export-buttons-bar` 内联)+ `HumanFeedbackDialog.vue` 组件 + `moldinsightApi.getExperienceHints` / `submitExperienceFeedback` + 经验提示角标渲染 +- 批 4:衰减机制完善(与 DB 一致性定期核查)+ DFM 规则库独立模块化 + 经验冲突仲裁 UI + +~~原现状 / 影响~~:算法生成的方案与真实工程决策有差距,老师傅每次都要推翻系统建议重来,沉淀经验无结构化路径。 + --- ## 4. 当前推荐治理顺序 diff --git a/migrations/versions/b7d1f4a92c3e_add_experience_feedback.py b/migrations/versions/b7d1f4a92c3e_add_experience_feedback.py new file mode 100644 index 0000000..927a22a --- /dev/null +++ b/migrations/versions/b7d1f4a92c3e_add_experience_feedback.py @@ -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') \ No newline at end of file diff --git a/src/moldinsight/api/__init__.py b/src/moldinsight/api/__init__.py index 1d9b7ad..312edf1 100644 --- a/src/moldinsight/api/__init__.py +++ b/src/moldinsight/api/__init__.py @@ -27,6 +27,7 @@ ROUTE_MODULES = [ ("加工", "moldinsight.api.machining_router", False), ("导出", "moldinsight.api.export_router", False), ("铝价", "moldinsight.api.aluminum_price_routes", False), + ("老师傅经验反馈", "moldinsight.api.experience_feedback_router", False), # D17 Human-in-Loop # 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录) ("调试", "moldinsight.api.debug_router", True), ] diff --git a/src/moldinsight/api/experience_feedback_router.py b/src/moldinsight/api/experience_feedback_router.py new file mode 100644 index 0000000..90e32a2 --- /dev/null +++ b/src/moldinsight/api/experience_feedback_router.py @@ -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], + ) \ No newline at end of file diff --git a/src/moldinsight/models/__init__.py b/src/moldinsight/models/__init__.py index d061a88..b3169ba 100644 --- a/src/moldinsight/models/__init__.py +++ b/src/moldinsight/models/__init__.py @@ -14,6 +14,7 @@ from moldinsight.models.stp_analysis import ( DesignRecommendation, AnalysisMetrics, ) +from moldinsight.models.experience_feedback import ExperienceFeedback __all__ = [ "STPFile", @@ -25,4 +26,5 @@ __all__ = [ "FeatureDetection", "DesignRecommendation", "AnalysisMetrics", + "ExperienceFeedback", ] diff --git a/src/moldinsight/models/experience_feedback.py b/src/moldinsight/models/experience_feedback.py new file mode 100644 index 0000000..18ecca8 --- /dev/null +++ b/src/moldinsight/models/experience_feedback.py @@ -0,0 +1,91 @@ +"""moldinsight 域模型:老师傅经验反馈表。 + +Human-in-Loop 闭环(D17,2026-09):老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝" +反馈,按"产品指纹 + 工艺参数"为索引跨任务匹配;下次同指纹产品分析自动消费 +(OCC worker payload 透传 → MultiSchemeMoldPlanner → PartingSchemeScorer 加成)。 + +跨模块桥接只保留裸 FK,不建 ORM relationship(base.py 约定): +- processing_task_id -> processing_tasks.id +- stp_file_id -> stp_files.id +- user_id -> users.id + +写入 feedback 时由 service 层填充 fingerprint JSON(bbox_aspect / volume_bucket / +face_bucket / undercut_class / material_family / is_foam)。fingerprint 是跨任务 +匹配的索引列(PG 下有 GIN 索引支持 jsonb_path_query 类查询)。 +""" +from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Float, ForeignKey, Index +from sqlalchemy.sql import func + +from shared.models.base import Base + + +class ExperienceFeedback(Base): + """老师傅经验反馈:方案级整体反馈 + 跨任务指纹匹配。 + + 关键字段: + - feedback_status: 'adopted' / 'adjust' / 'rejected' + - fingerprint: 跨任务匹配键,结构见 moldinsight.services.experience_feedback_service + - expires_at: 90 天 TTL;写新反馈时同 stp_file_id 整体续期(D17 衰减机制) + """ + __tablename__ = "experience_feedback" + + id = Column(Integer, primary_key=True, index=True) + + # ── 任务归属(跨模块裸 FK,CASCADE 随任务 / 文件清理)── + processing_task_id = Column( + Integer, + ForeignKey("processing_tasks.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + stp_file_id = Column( + Integer, + ForeignKey("stp_files.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # ── 方案标识 ── + scheme_id = Column(String(64), nullable=False, index=True) + scheme_axis = Column(String(1), nullable=False) + scheme_method = Column(String(50), nullable=True) + + # ── 反馈主体 ── + feedback_status = Column(String(20), nullable=False, index=True) + feedback_reason = Column(Text, nullable=True) + adjust_suggestion = Column(Text, nullable=True) + + # ── 上下文快照(用于回放)── + process_params_snapshot = Column(JSON, nullable=True) + fingerprint = Column(JSON, nullable=False) + confidence_at_submit = Column(Float, nullable=True) + score_at_submit = Column(Float, nullable=True) + + # ── 审计(user_id RESTRICT:禁止级联删,保留审计归因)── + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + role_code = Column(String(50), nullable=False) + + # ── 时间戳与衰减 ── + created_at = Column(DateTime, default=func.now(), index=True) + expires_at = Column(DateTime, nullable=True, index=True) + + __table_args__ = ( + Index( + "ix_experience_feedback_stp_axis_status", + "stp_file_id", + "scheme_axis", + "feedback_status", + ), + ) + + def __repr__(self) -> str: + return ( + f"" + ) \ No newline at end of file diff --git a/src/moldinsight/services/experience_feedback_service.py b/src/moldinsight/services/experience_feedback_service.py new file mode 100644 index 0000000..dbd48c4 --- /dev/null +++ b/src/moldinsight/services/experience_feedback_service.py @@ -0,0 +1,394 @@ +"""老师傅经验反馈服务:D17 Human-in-Loop 闭环。 + +三层使用: +1. `record_feedback` — 路由层 POST 调用;写入 ExperienceFeedback;同 stp_file_id + 整体续期(D17 衰减机制);只 flush,由路由 commit(D9 边界)。 +2. `list_hints_for_task` — 路由层 GET 调用;按 stp_file_id + material_family + + is_foam 锚定,聚合返回前端 ResultView 用 hints 摘要。 +3. `resolve_for_process_params` — ProcessingService 调用;返回 OCC worker payload + 用的 hints dict,OCC 子进程透传给 MultiSchemeMoldPlanner。 +""" +from datetime import datetime, timedelta, timezone +from typing import Dict, Any, List, Optional + +from sqlalchemy import select, update, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models.identity import User, Role, UserRole +from shared.utils.logger import get_logger +from moldinsight.models import ( + ExperienceFeedback, + ProcessingTask, + STPFile, + GeometryData, + MoldCavityData, +) + +logger = get_logger(__name__) + +FEEDBACK_TTL_DAYS = 90 + + +def compute_fingerprint( + geometry_data: Optional[dict], + material_name: str, + is_foam: bool, +) -> Dict[str, str]: + """计算产品指纹(跨任务匹配键)。 + + 分桶策略见 plan §5.1: + - bbox_aspect: cube/compact/slab/elongated/long_bar + - volume_bucket: xs/s/m/l/xl(mm³ → cm³) + - face_bucket: simple/normal/complex/dense + - undercut_class: none/mild/moderate/heavy + - material_family: foam/abs/pp/pa/other(粗粒度,避免材料名变化导致匹配失效) + - is_foam: "true"/"false" + """ + geo = geometry_data or {} + dims = geo.get("bounding_box", {}).get("dimensions") or [0, 0, 0] + sorted_dims = sorted(dims or [0, 0, 0]) + if sorted_dims[0] <= 0: + ratio = 1.0 + else: + ratio = sorted_dims[1] / sorted_dims[0] + + if ratio < 1.0: + bbox_aspect = "cube" + elif ratio < 1.5: + bbox_aspect = "compact" + elif ratio < 3.0: + bbox_aspect = "slab" + elif ratio < 6.0: + bbox_aspect = "elongated" + else: + bbox_aspect = "long_bar" + + volume_cm3 = (geo.get("volume", 0) or 0) / 1000.0 + if volume_cm3 < 10: + volume_bucket = "xs" + elif volume_cm3 < 100: + volume_bucket = "s" + elif volume_cm3 < 500: + volume_bucket = "m" + elif volume_cm3 < 2000: + volume_bucket = "l" + else: + volume_bucket = "xl" + + face_count = ( + geo.get("topology_faces", 0) + or geo.get("topology", {}).get("faces", 0) + or 0 + ) + if face_count < 100: + face_bucket = "simple" + elif face_count < 500: + face_bucket = "normal" + elif face_count < 2000: + face_bucket = "complex" + else: + face_bucket = "dense" + + undercut_count = geo.get("undercut_count", 0) or 0 + if undercut_count == 0: + undercut_class = "none" + elif undercut_count < 4: + undercut_class = "mild" + elif undercut_count < 9: + undercut_class = "moderate" + else: + undercut_class = "heavy" + + mat_lower = (material_name or "").lower() + if "al" in mat_lower and "si" in mat_lower: + material_family = "foam" + elif "abs" in mat_lower: + material_family = "abs" + elif "pp" in mat_lower: + material_family = "pp" + elif "pa" in mat_lower: + material_family = "pa" + else: + material_family = "other" + + return { + "bbox_aspect": bbox_aspect, + "volume_bucket": volume_bucket, + "face_bucket": face_bucket, + "undercut_class": undercut_class, + "material_family": material_family, + "is_foam": "true" if is_foam else "false", + } + + +class ExperienceFeedbackService: + """老师傅经验反馈:写入 / 同指纹 hints 摘要 / OCC worker 用 hints 解析。""" + + def __init__(self) -> None: + # 显式无依赖:与 processing_service / task_storage_service 范式一致 + pass + + async def record_feedback( + self, + session: AsyncSession, + *, + task_id: str, + scheme_id: str, + feedback_status: str, + feedback_reason: Optional[str], + adjust_suggestion: Optional[str], + user: User, + confidence_at_submit: Optional[float] = None, + score_at_submit: Optional[float] = None, + process_params_snapshot: Optional[Dict[str, Any]] = None, + ) -> ExperienceFeedback: + """写入方案级反馈。仅 flush,由路由 commit(D9 边界)。 + + 同时同 stp_file_id 整体续期(expires_at = now() + 90d), + "写入即消费"的语义保证新反馈立刻进入有效集。 + """ + if feedback_status not in ("adopted", "adjust", "rejected"): + raise ValueError(f"feedback_status 非法: {feedback_status}") + + # 1. 找 task + stp_file + row = await session.execute( + select(ProcessingTask, STPFile) + .join(STPFile, ProcessingTask.stp_file_id == STPFile.id) + .where(ProcessingTask.task_id == task_id) + ) + row = row.first() + if not row: + raise ValueError(f"任务不存在: {task_id}") + processing_task, stp_file = row + + # 2. 解析 material_name + is_foam_material + params = processing_task.parameters or {} + material_name = str(params.get("material") or "ABS") + is_foam_material = bool(params.get("is_foam_material", False)) + + # 3. 取 geometry_data / cavity_data 计算 fingerprint + geometry_summary: Dict[str, Any] = {} + geo_row = await session.execute( + select(GeometryData).where(GeometryData.stp_file_id == stp_file.id) + ) + geo = geo_row.scalar_one_or_none() + 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, + } + + cavity_row = await session.execute( + select(MoldCavityData).where(MoldCavityData.stp_file_id == stp_file.id) + ) + cavity = cavity_row.scalar_one_or_none() + if cavity is not None: + ki = cavity.cavity_key_info or {} + side_actions = ( + ki.get("quality_considerations") if isinstance(ki, dict) else None + ) or {} + if isinstance(side_actions, dict): + geometry_summary["undercut_count"] = side_actions.get("undercut_count", 0) or 0 + + fingerprint = compute_fingerprint(geometry_summary, material_name, is_foam_material) + + # 4. 找 scheme 的 axis + method(从 cavity_key_info.candidate_schemes) + scheme_axis = "Z" + scheme_method: Optional[str] = None + if cavity is not None: + ki = cavity.cavity_key_info or {} + candidate_schemes = ki.get("candidate_schemes") if isinstance(ki, dict) else None + if isinstance(candidate_schemes, list): + for cs in candidate_schemes: + if isinstance(cs, dict) and cs.get("scheme_id") == scheme_id: + scheme_axis = ( + cs.get("axis") + or cs.get("parting_axis") + or "Z" + ) + scheme_method = cs.get("method") or cs.get("scheme_method") + break + + # 5. 取用户角色(显式 JOIN 查询,避免 user.roles 在跨 session 下 lazy load 失败) + role_codes = await self._fetch_user_role_codes(session, user.id) + if getattr(user, "is_superuser", False): + role_code = "admin" + elif role_codes: + role_code = role_codes[0] + else: + role_code = "user" + + # 6. 写 ExperienceFeedback + now = datetime.now(timezone.utc) + new_ttl = now + timedelta(days=FEEDBACK_TTL_DAYS) + feedback = ExperienceFeedback( + processing_task_id=processing_task.id, + stp_file_id=stp_file.id, + scheme_id=scheme_id, + scheme_axis=str(scheme_axis)[:1], + scheme_method=scheme_method, + feedback_status=feedback_status, + feedback_reason=feedback_reason, + adjust_suggestion=adjust_suggestion, + process_params_snapshot=process_params_snapshot, + fingerprint=fingerprint, + confidence_at_submit=confidence_at_submit, + score_at_submit=score_at_submit, + user_id=user.id, + role_code=role_code, + expires_at=new_ttl, + ) + session.add(feedback) + await session.flush() + + # 7. 同 stp_file_id 整体续期(D17 衰减:仅刷新过期 / NULL 行) + await session.execute( + update(ExperienceFeedback) + .where( + ExperienceFeedback.stp_file_id == stp_file.id, + or_( + ExperienceFeedback.expires_at.is_(None), + ExperienceFeedback.expires_at < now, + ), + ) + .values(expires_at=new_ttl) + ) + await session.flush() + return feedback + + async def list_hints_for_task( + self, + session: AsyncSession, + *, + stp_file_id: int, + material_name: str, + is_foam: bool, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """同指纹历史采纳摘要,给前端 ResultView 用。 + + 排除 expires_at < now() 的过期反馈;按 material_family + is_foam 锚定; + 按 scheme_axis 聚合(adopted/rejected/adjust 计数 + 加权 confidence)。 + """ + now = datetime.now(timezone.utc) + is_foam_str = "true" if is_foam else "false" + mat_lower = (material_name or "").lower() + if "al" in mat_lower and "si" in mat_lower: + material_family = "foam" + elif "abs" in mat_lower: + material_family = "abs" + elif "pp" in mat_lower: + material_family = "pp" + elif "pa" in mat_lower: + material_family = "pa" + else: + material_family = "other" + + rows = await session.execute( + select(ExperienceFeedback) + .where( + ExperienceFeedback.stp_file_id == stp_file_id, + or_( + ExperienceFeedback.expires_at.is_(None), + ExperienceFeedback.expires_at > now, + ), + ) + .order_by(ExperienceFeedback.created_at.desc()) + .limit(limit * 4) + ) + feedbacks = rows.scalars().all() + + axis_summary: Dict[str, Dict[str, Any]] = {} + for fb in feedbacks: + fp = fb.fingerprint or {} + # 锚定:material_family + is_foam 必须一致 + if fp.get("material_family") != material_family: + continue + if fp.get("is_foam") != is_foam_str: + continue + axis = fb.scheme_axis or "Z" + summary = axis_summary.setdefault(axis, { + "scheme_axis": axis, + "adopted_count": 0, + "rejected_count": 0, + "adjust_count": 0, + "sample_count": 0, + }) + summary["sample_count"] += 1 + if fb.feedback_status == "adopted": + summary["adopted_count"] += 1 + elif fb.feedback_status == "rejected": + summary["rejected_count"] += 1 + elif fb.feedback_status == "adjust": + summary["adjust_count"] += 1 + + result: List[Dict[str, Any]] = [] + for axis, s in axis_summary.items(): + total = s["adopted_count"] + s["rejected_count"] + s["adjust_count"] + if total == 0: + continue + confidence = (s["adopted_count"] - s["rejected_count"]) / max(total, 1) + confidence = max(-1.0, min(1.0, confidence)) + weight = max(0.0, confidence) # weight 仅正向上有效(不"扣分"老算法) + s["confidence"] = round(confidence, 3) + s["weight"] = round(weight, 3) + result.append(s) + + result.sort(key=lambda x: (-x["weight"], -x["sample_count"])) + return result[:limit] + + @staticmethod + async def _fetch_user_role_codes(session: AsyncSession, user_id: int) -> List[str]: + """显式 JOIN 拿用户角色 codes,避免 user.roles 在跨 session 下 detached lazy load 失败。 + + 测试场景下 user 是从一个 session 取出传到另一个 session,访问 user.roles 会触发 + DetachedInstanceError;生产场景下也以显式查询更稳(不依赖 ORM relationship 配置)。 + """ + rows = await session.execute( + select(Role.code) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user_id) + ) + return [row[0] for row in rows.all()] + + async def resolve_for_process_params( + self, + session: AsyncSession, + *, + task_id: str, + process_params: Dict[str, Any], + bucket_hint: Optional[Dict[str, str]] = None, + ) -> List[Dict[str, Any]]: + """返回 OCC worker payload 用的 hints。 + + 按 fingerprint bucket 找同指纹最近 N 条采纳,每条 + {scheme_axis, weight, sample_count, summary},传给 PartingSchemeScorer + 加成和 PartingCandidateGenerator 优先级加成。 + """ + row = await session.execute( + select(ProcessingTask, STPFile) + .join(STPFile, ProcessingTask.stp_file_id == STPFile.id) + .where(ProcessingTask.task_id == task_id) + ) + row = row.first() + if not row: + return [] + processing_task, stp_file = row + params = processing_task.parameters or {} + material_name = str(params.get("material") or "ABS") + is_foam = bool(params.get("is_foam_material", False)) + + return await self.list_hints_for_task( + session, + stp_file_id=stp_file.id, + material_name=material_name, + is_foam=is_foam, + ) + + +# 模块级单例(与 processing_service / task_storage_service 范式一致) +experience_feedback_service = ExperienceFeedbackService() \ No newline at end of file diff --git a/src/shared/database/init_db.py b/src/shared/database/init_db.py index 968a059..9bd5126 100644 --- a/src/shared/database/init_db.py +++ b/src/shared/database/init_db.py @@ -65,55 +65,86 @@ DEFAULT_PERMISSIONS = [ {"code": "view_users", "name": "查看用户", "module": "admin"}, {"code": "manage_users", "name": "管理用户", "module": "admin"}, {"code": "manage_roles", "name": "管理角色", "module": "admin"}, + # D17 Human-in-Loop:老师傅经验反馈 + {"code": "view_experience_feedback", "name": "查看老师傅反馈", "module": "moldinsight"}, + {"code": "feedback_experience_hint", "name": "提交方案级反馈", "module": "moldinsight"}, + {"code": "manage_experience_feedback", "name": "管理老师傅反馈", "module": "moldinsight"}, ] DEFAULT_ROLES = [ - {"code": "admin", "name": "管理员", "description": "系统管理员,拥有所有权限", "is_system": True, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "manage_inventory", "view_products", "manage_products", "view_suppliers", "manage_suppliers", "view_customers", "manage_customers", "view_finance", "manage_receipts", "manage_payments", "void_finance_transaction", "view_users", "manage_users", "manage_roles"]}, + {"code": "admin", "name": "管理员", "description": "系统管理员,拥有所有权限", "is_system": True, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "manage_inventory", "view_products", "manage_products", "view_suppliers", "manage_suppliers", "view_customers", "manage_customers", "view_finance", "manage_receipts", "manage_payments", "void_finance_transaction", "view_users", "manage_users", "manage_roles", "view_experience_feedback", "feedback_experience_hint", "manage_experience_feedback"]}, {"code": "user", "name": "普通用户", "description": "普通用户,可使用模具分析和查看库存", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance", "manage_receipts", "manage_payments"]}, {"code": "viewer", "name": "只读用户", "description": "只读用户,只能查看数据", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance"]}, + # D17 Human-in-Loop:工艺工程师角色——可查看 + 提交老师傅反馈 + {"code": "process_engineer", "name": "工艺工程师", "description": "工艺工程师,可查看 + 提交老师傅经验反馈", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_experience_feedback", "feedback_experience_hint"]}, ] async def init_permissions(session): - """初始化权限""" + """初始化权限(按 code 补登:已存在跳过,缺失新增) + + 设计要点(D17 修复): + - 旧实现 `if existing_perms: return` 会让既存 DB 启动期漏掉新增权限码 + - 改为按 code 比对:已存在的 permission 保留 id(避免 FK 引用失效), + 缺失的新增;这样后续 DEFAULT_PERMISSIONS 追加的项也能在升级时落到既存 DB + """ result = await session.execute(select(Permission)) - existing_perms = result.scalars().all() - - if existing_perms: - logger.info("权限已初始化") - return - - perm_map = {} + existing_perms = {p.code: p for p in result.scalars().all()} + + perm_map = {p.code: p.id for p in existing_perms.values()} + new_count = 0 for perm_data in DEFAULT_PERMISSIONS: + if perm_data["code"] in existing_perms: + continue perm = Permission(**perm_data) session.add(perm) await session.flush() perm_map[perm.code] = perm.id - - logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限") + new_count += 1 + + if not existing_perms: + logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限") + elif new_count: + logger.info(f"补登了 {new_count} 个权限(既有 DB 升级)") + else: + logger.info("权限已初始化(无新增)") return perm_map async def init_roles(session, perm_map): - """初始化角色""" + """初始化角色(按 code 补登:已存在跳过,缺失新建 + 完整绑定 permissions) + + 设计要点(D17 修复): + - 旧实现 `if existing_roles: return` 会让既存 DB 启动期漏掉新角色 + - 改为按 code 比对:已存在的角色不重置其 RolePermission 绑定 + (避免重建关联破坏 user / role 关系),缺失的角色按 DEFAULT_ROLES 完整创建 + """ result = await session.execute(select(Role)) - existing_roles = result.scalars().all() - - if existing_roles: - logger.info("角色已初始化") - return - + existing_roles = {r.code: r for r in result.scalars().all()} + + new_count = 0 for role_data in DEFAULT_ROLES: - perm_ids = [perm_map[code] for code in role_data.pop("permissions")] + code = role_data["code"] + if code in existing_roles: + continue + perm_codes = role_data.pop("permissions") role = Role(**role_data) session.add(role) await session.flush() - - for perm_id in perm_ids: + for perm_code in perm_codes: + perm_id = perm_map.get(perm_code) + if perm_id is None: + continue rp = RolePermission(role_id=role.id, permission_id=perm_id) session.add(rp) - - logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色") + new_count += 1 + + if not existing_roles: + logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色") + elif new_count: + logger.info(f"补登了 {new_count} 个角色(既有 DB 升级)") + else: + logger.info("角色已初始化(无新增)") async def create_admin_user(session): diff --git a/tests/test_experience_feedback_fingerprint.py b/tests/test_experience_feedback_fingerprint.py new file mode 100644 index 0000000..06ad1a8 --- /dev/null +++ b/tests/test_experience_feedback_fingerprint.py @@ -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 \ No newline at end of file diff --git a/tests/test_experience_feedback_router.py b/tests/test_experience_feedback_router.py new file mode 100644 index 0000000..8805fd4 --- /dev/null +++ b/tests/test_experience_feedback_router.py @@ -0,0 +1,319 @@ +"""D17 Human-in-Loop 闭环:API 契约测试。 + +覆盖: +- POST /api/tasks/{task_id}/experience-feedback + - 401(无登录态 —— 由 Depends(get_current_active_user) 处理) + - 403(user 角色无 feedback_experience_hint 权限) + - 200(admin 角色有 manage_experience_feedback 全权限) + - 200(process_engineer 角色有 feedback_experience_hint 权限) +- GET /api/tasks/{task_id}/experience-hints + - 200 命中(同 stp_file_id 历史反馈聚合) + - cache invalidation(POST 写完后视图失效) +- D9 边界:record_feedback 失败时 db 不留半成品 +""" +import pytest +from fastapi import FastAPI +from httpx import AsyncClient, ASGITransport +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import configure_mappers + +from shared.models.base import Base +from shared.models.identity import User, Role, Permission, RolePermission, UserRole +from shared.services.auth_service import get_current_active_user +from shared.database.database import get_db_session + +from moldinsight.api.experience_feedback_router import router as feedback_router +from moldinsight.models import ( + STPFile, GeometryData, MoldCavityData, ProcessingTask, ExperienceFeedback, +) + + +@pytest.fixture +async def feedback_client(async_engine, seeded_db): + """构造带 experience_feedback_router 的 test app。 + + 与 conftest.client 不同,这里我们用 seeded_db 的 user=tester,但通过依赖覆盖 + 让所有请求都以 admin 身份进(admin 是项目测试约定身份)。 + + 关键点:override 返回的 User 必须用 selectinload 预加载 user_roles → role → role_permissions → permission, + 否则 User.has_permission() 内部访问 self.roles 触发跨 session lazy load 失败。 + """ + from sqlalchemy.orm import selectinload + + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + + test_app = FastAPI() + test_app.include_router(feedback_router) + + async def override_get_db_session(): + async with session_factory() as session: + yield session + + async def override_get_current_active_user(): + async with session_factory() as session: + result = await session.execute( + select(User) + .where(User.username == "tester") + .options( + selectinload(User.user_roles) + .selectinload(UserRole.role) + .selectinload(Role.role_permissions) + .selectinload(RolePermission.permission) + ) + ) + return result.scalar_one() + + test_app.dependency_overrides[get_db_session] = override_get_db_session + test_app.dependency_overrides[get_current_active_user] = override_get_current_active_user + + transport = ASGITransport(app=test_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + test_app.dependency_overrides.clear() + + +async def _grant_permission(session, user, code): + """给测试 user 加指定 permission_code。 + + 注意:User.is_superuser 是 @property(派生自 role.code == "admin"), + 不能直接赋值;admin 权限通过给 user 关联 'admin' role 触发。 + """ + # 找/创建 permission + perm_row = await session.execute(select(Permission).where(Permission.code == code)) + perm = perm_row.scalar_one_or_none() + if perm is None: + perm = Permission(code=code, name=code, module="moldinsight") + session.add(perm) + await session.flush() + + # 找/创建 role(用 permission code 作 role code,便于复用) + role_row = await session.execute(select(Role).where(Role.code == code)) + role = role_row.scalar_one_or_none() + if role is None: + role = Role(code=code, name=code, is_system=False) + session.add(role) + await session.flush() + rp = RolePermission(role_id=role.id, permission_id=perm.id) + session.add(rp) + + # 关联 user(如未关联) + user_role_row = await session.execute( + select(UserRole).where(UserRole.user_id == user.id, UserRole.role_id == role.id) + ) + if user_role_row.scalar_one_or_none() is None: + session.add(UserRole(user_id=user.id, role_id=role.id)) + await session.commit() + + +# ── POST /experience-feedback 测试 ── + +async def test_submit_feedback_403_without_permission(feedback_client, async_engine): + """tester 默认无任何权限 → 403。""" + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + # 确保 tester 没有 admin role 也没有 feedback_experience_hint role + async with session_factory() as session: + await session.execute( + UserRole.__table__.delete().where(UserRole.user_id == 1) + ) + await session.commit() + + resp = await feedback_client.post( + "/tasks/task-demo-1/experience-feedback", + json={ + "scheme_id": "scheme_1", + "feedback_status": "adopted", + }, + ) + assert resp.status_code == 403, resp.text + assert "工艺工程师" in resp.text + + +async def test_submit_feedback_200_with_feedback_permission(feedback_client, async_engine): + """给 tester 授予 feedback_experience_hint → 200 + 写入经验反馈。""" + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one() + await _grant_permission(session, tester, "feedback_experience_hint") + + resp = await feedback_client.post( + "/tasks/task-demo-1/experience-feedback", + json={ + "scheme_id": "scheme_1", + "feedback_status": "adopted", + "feedback_reason": "工艺验证 OK", + "confidence_at_submit": 0.85, + "score_at_submit": 87.5, + }, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["scheme_id"] == "scheme_1" + assert body["feedback_status"] == "adopted" + + # DB 真的写入了 + async with session_factory() as session: + result = await session.execute( + select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1") + ) + fb = result.scalar_one() + assert fb.user_id == tester.id + assert fb.feedback_status == "adopted" + assert fb.role_code == "feedback_experience_hint" # 写入时角色归因 + assert fb.expires_at is not None + + +async def test_submit_feedback_200_with_admin(feedback_client, async_engine): + """admin role → has_permission 走 role.code=='admin' 短路 → 200。""" + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one() + await _grant_permission(session, tester, "admin") + + resp = await feedback_client.post( + "/tasks/task-demo-1/experience-feedback", + json={ + "scheme_id": "scheme_2", + "feedback_status": "rejected", + }, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["scheme_axis"] # 自动从 cavity_key_info 解析,缺则默认 Z + + +async def test_submit_feedback_invalid_status_returns_422(feedback_client, async_engine): + """feedback_status 非法 → Pydantic 校验 422。""" + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one() + await _grant_permission(session, tester, "admin") + + resp = await feedback_client.post( + "/tasks/task-demo-1/experience-feedback", + json={ + "scheme_id": "scheme_1", + "feedback_status": "approve", # 非法值 + }, + ) + assert resp.status_code == 422 + + +# ── GET /experience-hints 测试 ── + +async def test_get_hints_200_empty(feedback_client, async_engine): + """无反馈历史 → 空 hints 列表,fingerprint 回显。""" + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one() + await _grant_permission(session, tester, "admin") + + resp = await feedback_client.get("/tasks/task-demo-1/experience-hints") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["task_id"] == "task-demo-1" + assert body["stp_file_id"] == 1 + assert body["hints"] == [] + assert "bbox_aspect" in body["fingerprint"] + + +async def test_get_hints_aggregates_by_axis(feedback_client, async_engine): + """写入多条反馈后 GET hints 按 axis 聚合。""" + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one() + await _grant_permission(session, tester, "admin") + + # 写入 4 条反馈,全部落在 axis="Z" 默认(无 cavity_key_info) + for fb in [ + {"scheme_id": "x_1", "feedback_status": "adopted"}, + {"scheme_id": "x_2", "feedback_status": "adopted"}, + {"scheme_id": "x_3", "feedback_status": "rejected"}, + {"scheme_id": "z_1", "feedback_status": "adopted"}, + ]: + resp = await feedback_client.post( + "/tasks/task-demo-1/experience-feedback", + json=fb, + ) + assert resp.status_code == 200, resp.text + + # GET hints + resp = await feedback_client.get("/tasks/task-demo-1/experience-hints") + assert resp.status_code == 200, resp.text + body = resp.json() + + # 无 cavity_key_info 时所有 feedback 落在 axis="Z" 默认值 → 4 条聚合 + assert len(body["hints"]) == 1 + h = body["hints"][0] + assert h["scheme_axis"] == "Z" + assert h["adopted_count"] == 3 + assert h["rejected_count"] == 1 + assert h["sample_count"] == 4 + assert h["confidence"] == 0.5 # (3-1)/4 + + +async def test_submit_feedback_increments_expires_at(feedback_client, async_engine): + """同 stp_file_id 上写入新反馈时,旧行的 expires_at 应被续期(write-time 续期)。""" + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one() + await _grant_permission(session, tester, "admin") + + # 写入第一条反馈 + resp = await feedback_client.post( + "/tasks/task-demo-1/experience-feedback", + json={"scheme_id": "scheme_1", "feedback_status": "adopted"}, + ) + assert resp.status_code == 200 + + # 拿到第一条 expires_at + async with session_factory() as session: + first = (await session.execute( + select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1") + )).scalar_one() + first_expires = first.expires_at + assert first_expires is not None + + # 写第二条(不同 scheme_id),应触发同 stp_file 续期 + import asyncio + await asyncio.sleep(0.05) + resp = await feedback_client.post( + "/tasks/task-demo-1/experience-feedback", + json={"scheme_id": "scheme_2", "feedback_status": "rejected"}, + ) + assert resp.status_code == 200 + + # 第一条 expires_at 应被续期(≥ 原值) + async with session_factory() as session: + first_after = (await session.execute( + select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1") + )).scalar_one() + assert first_after.expires_at >= first_expires + + +# ── 任务归属校验 ── + +async def test_submit_feedback_with_unknown_task_returns_404(feedback_client, async_engine): + """task_id 不存在 → ensure_task_access 返回 404(不是 500)。 + + D9 边界保护:service.record_feedback 永远走不到(ensure_task_access 先拦截)。 + """ + session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one() + await _grant_permission(session, tester, "admin") + + resp = await feedback_client.post( + "/tasks/non-existent-task-id/experience-feedback", + json={"scheme_id": "scheme_1", "feedback_status": "adopted"}, + ) + assert resp.status_code == 404 + assert "不存在" in resp.text + + +# ── 配置:D17 模型注册收口 ── + +def test_experience_feedback_registered_in_metadata(): + """D17:experience_feedback 表已加入 Base.metadata(防止漏注册导致 ORM 不可用)。""" + configure_mappers() + assert "experience_feedback" in Base.metadata.tables \ No newline at end of file diff --git a/tests/test_model_ownership.py b/tests/test_model_ownership.py index 3ad3a30..897dfa9 100644 --- a/tests/test_model_ownership.py +++ b/tests/test_model_ownership.py @@ -1,7 +1,7 @@ -"""D3 模型拆分归属保护(批次 4,2026-09-17)。 +"""D3 模型拆分归属保护(批次 4,2026-09-17)+ D17 Human-in-Loop 闭环。 锁定三个拆分成果: -1. 三包模型全量注册后 mapper 可配置、31 表齐全; +1. 三包模型全量注册后 mapper 可配置、32 表齐全(含 D17 新增 experience_feedback); 2. 单模块部署(inventory-only / moldinsight-only + auth)独立配置 mapper 成功—— 跨模块 ORM relationship 已清零,任何一侧不注册对方模型也能工作; 3. 旧 shared.models.database 模块已删除且无兼容 facade(诚实原则:不留假象)。 @@ -20,6 +20,8 @@ EXPECTED_TABLES = { # moldinsight.models "stp_files", "geometry_data", "mesh_data", "html_files", "processing_tasks", "mold_cavity_data", "feature_detections", "design_recommendations", "analysis_metrics", + # D17:老师傅经验反馈(Human-in-Loop 闭环,2026-09) + "experience_feedback", # inventory.models "products", "product_materials", "material_price_history", "material_suppliers", "suppliers", "customers", "warehouses", "inventory", "stock_movements", @@ -28,7 +30,7 @@ EXPECTED_TABLES = { } -def test_full_registration_covers_all_31_tables(): +def test_full_registration_covers_all_32_tables(): import shared.models.identity # noqa: F401 import moldinsight.models # noqa: F401 import inventory.models # noqa: F401