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
+1
View File
@@ -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 |
+2
View File
@@ -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 项锁文件契约)。)
+21
View File
@@ -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. 当前推荐治理顺序
@@ -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')
+1
View File
@@ -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),
]
@@ -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],
)
+2
View File
@@ -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",
]
@@ -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"<ExperienceFeedback(id={self.id}, stp_file_id={self.stp_file_id}, "
f"scheme_id='{self.scheme_id}', axis='{self.scheme_axis}', "
f"status='{self.feedback_status}')>"
)
@@ -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()
+54 -23
View File
@@ -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):
@@ -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
+319
View File
@@ -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
+5 -3
View File
@@ -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