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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 项锁文件契约)。)
|
||||
|
||||
@@ -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')
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user