Compare commits

...

5 Commits

Author SHA1 Message Date
cjw 6baa6b0d0a docs:ARCHITECTURE / ROADMAP 同步 D17 Human-in-Loop 完成
更新两个权威文档与 D17 端到端闭环对齐:

- docs/ROADMAP.md §2.2 主线二"moldinsight 工程化增强"重点方向
  加一条 D17 已完成条目(2026-09-23~24,3 个 commit:数据 + 权限 +
  写入 API / 算法接缝 + OCC payload / 前端按钮 + Dialog + 经验角标;
  详见 TECH_DEBT.md D17),与既有 ~~XXX~~(YYYY-MM-DD 完成)格式一致

- docs/ARCHITECTURE.md 新增 §6.4 D17 Human-in-Loop 老师傅经验反馈闭环
  —— 已完成段:用 ASCII 数据流图展示老师傅点反馈按钮 → 路由层
  → service 写入 → 续期衰减 → 上传新 STP 触发 resolve_for_process_params
  → OCC payload 透传 → planner 算法加成 → ResultView 渲染的端到端链路

  段内列出"硬规则遵守"(跨模块 FK 守 §5.1、OCC payload 守
  occ_worker.py:7-8、D9 边界不破、init_db.py 幂等修复已落)和
  "重量级约束"(weight 仅正向、sample_count<2 时 ×0.5、graceful 退化、
  角色门控),最后给测试基线指针

  放在 §6.3"文档与结构尚未完全同步"之后作为"已完成端到端闭环"
  对照示例,便于新成员理解 D17 在系统中的位置

文档侧仅变更,无代码改动;按 AGENTS.md §4.1 映射表,模块边界
(ARCHITECTURE.md)/ 演进路线(ROADMAP.md)相关变更同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 10:15:36 +08:00
cjw 483f158424 D17 批 3:前端按钮 + Dialog + 经验角标——闭环可视
让老师傅能用起来:ResultView 加按钮组 + HumanFeedbackDialog 组件 +
经验角标渲染 + 接口变更三件套随批完成,admin / process_engineer 可在
分析结果页标记采纳 / 建议调整 / 拒绝,下次同指纹产品分析自动消费。

变更内容:
- frontend/src/modules/moldinsight/components/HumanFeedbackDialog.vue(new)
  新组件:t-dialog + t-form + t-radio-group 三选一(采纳 / 建议调整 /
  拒绝)+ t-textarea 原因 + t-textarea 调整建议(仅 adjust 模式显隐);
  走 moldinsightApi.submitExperienceFeedback,提交成功后 emit 'submitted'
  让父组件重新拉 hints 刷新角标;表单打开时 watch 重置;loading 态 /
  error 通知复用 shared/notification
- frontend/src/modules/moldinsight/ResultView.vue
  - import moldinsightApi 与 HumanFeedbackDialog
  - state 增加 feedbackDialogVisible / hintsFingerprint / hints
  - Scheme interface 扩展 axis / confidence_score / method 顶层字段
    (前端 TS 类型与后端 Pydantic 字段对齐)
  - computed taskId / hintsByAxis / currentAxisHint(避免 undefined index
    TS 报错)/ canGiveFeedback(is_superuser || roles 含 process_engineer)
  - function loadExperienceHints(onMounted + 提交后各调一次;失败静默
    退化,按钮仍可点)/ openFeedbackDialog / onFeedbackSubmitted
  - 模板:
    * 推荐方案卡片 summary-header 加 t-tag theme="success" variant="light"
      经验角标("📚 历史经验 N 条",title 提示采纳/拒绝计数)
    * export-buttons-bar 加 👍 老师傅反馈 按钮(v-if="canGiveFeedback"
      角色门控;theme="default" size="small" 与既有 6 个 t-button 视觉一致)
    * </t-loading> 之后挂 HumanFeedbackDialog(v-model:visible + 11 个 props
      透传 scheme_id / axis / fingerprint / score_at_submit / 等)
- frontend/src/shared/api-client.ts moldinsightApi 加两个方法:
  getExperienceHints(taskId) / submitExperienceFeedback(taskId, data)
  (返回类型由 openapi-typescript 自动生成,无需手维护)

接口变更三件套随批完成:
- openapi.json 重导出(unified app,含 ExperienceFeedbackCreate /
  ExperienceFeedbackResponse / ExperienceHintItem / ExperienceHintsResponse
  四个新 schema,2 个新 path 出现)
- npm run gen:api 再生 frontend/src/types/api.ts(生成物,禁止手改)
- npm run build 通过(vue-tsc + vite,ResultView 包 37.18 kB / 11.91 kB
  gzip)

- docs/STATUS.md 顶部加 2026-09-24 批 3 日志条目
- docs/TECH_DEBT.md D17 追加批 3 已完成描述 + 缩减剩余工作(仅剩批 4
  按需排期)

设计要点:
- canGiveFeedback 借 (appStore.user as any).roles 临时绕过 AppUser
  类型不含 roles 的限制(前端硬规则:"不要硬编码 'admin' 字符串"——
  按 role.code === 'process_engineer' 匹配)
- currentAxisHint 解决 selectedScheme.axis 可能 undefined 的索引报错
- onFeedbackSubmitted 提交后立即 loadExperienceHints 触发角标刷新,
  实现"写入即消费"前端可见

后端基线:192 passed, 13 skipped(批 3 纯前端,不动后端)。
D17 闭环端到端可用:admin / process_engineer 在 ResultView 点反馈
按钮 → Dialog 提交 → 角标即时刷新 + 下次同指纹产品分析算法加成。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 10:03:09 +08:00
cjw 505f3591ab D17 批 2:算法接缝(OCC payload 通道 + hints 透传到分模评分)—— 闭环通
让批 1 沉淀的老师傅经验 hints 真接入分模方案生成:
processing_service 拉同指纹 hints 装进 OCC worker payload,
planner 透传到 candidate_generator(axis 优先级加成)和
scheme_scorer(score_breakdown 新字段 + total_score 加成),
写入即消费闭环通。

变更内容:
- src/moldinsight/core/parting_candidate_generator.py
  generate_candidates(..., hints=None):_build_axis_metrics 末尾按 hints
  加成(priority_score += weight × 20 上限;sample_count ≥ 2 + weight ≥ 0.5
  → method 标签升级 "human_experience_primary")
- src/moldinsight/core/parting_scheme_scorer.py
  score_schemes(schemes, *, hints=None) keyword-only:_score_scheme 新增
  human_hint_bonus 字段(weight × 12 上限;sample_count < 2 时 ×0.5 折半
  防信号不足过度影响);_compute_human_hint_bonus 静态方法解析 axis
  (parting.axis → axis → Z);bonus 纳入 total_score
- src/moldinsight/core/multi_scheme_planner.py
  generate_plan(..., hints=None):透传 hints 到 candidate_generator 与
  scheme_scorer;global_summary.applied_hints 注入返回供前端 ResultView
  渲染经验角标
- src/moldinsight/services/processing_service.py
  _step_generate_cavity 加 db_session 形参;调用
  experience_feedback_service.resolve_for_process_params 拿同指纹 hints,
  装进 run_occ payload 顶层 experience_hints;解析失败回退空 list
  不阻塞主流程(旧任务不因 receives 闭包退化)
- src/moldinsight/core/occ_worker.py
  _op_generate_cavity:payload.get("experience_hints") or {} 透传给
  planner.generate_plan(..., hints=...);普通 dict 跨进程 pickle 安全
  (满足 occ_worker.py:7-8 硬规则)
- tests/test_experience_feedback_algorithm.py(new)11 例:
  - candidate_generator 3 例(无 hints 默认 / hints 加成 / sample_count < 2 不升级)
  - scheme_scorer 4 例(无 hints 无 bonus / bonus 加成 / sample_count 折半 /
    weight=0 不加成)
  - multi_scheme_planner 2 例 OCC-gated(透传 / applied_hints 默认空)
  - processing_service 2 例 OCC-gated(payload 含 experience_hints /
    解析失败回退空 list)

设计取舍:
- keyword-only hints:避免与位置参数混淆
- weight 仅正向上有效:max(0, (adopted-rejected)/total),老师傅拒绝
  的不扣分老算法,只让采纳的加分
- signal-noise 控制:sample_count < 2 时 bonus ×0.5,但 priority_score
  仍加成(候选方向仍偏向,避免完全无信号)
- graceful degradation:hints 解析失败回退空 list,主流程继续

- docs/STATUS.md 顶部加 2026-09-23 批 2 日志条目
- docs/TECH_DEBT.md D17 追加批 2 已完成描述 + 缩减剩余工作(仅剩批 3 / 4)

测试基线:192 passed, 13 skipped(净增 7 通过 + 4 OCC-gated skip)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:21:33 +08:00
cjw 2c9ba9d6b3 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>
2026-09-23 16:11:17 +08:00
cjw 79441a8a87 批次6:D4文档治理收口 + D13锁文件流程固化
D4 文档/规划/历史混放收口:
- docs/TECH_DEBT.md §2 由'按批次回顾'精简为'按主题摘要',
  与§3重复内容(批次0-4详细展开)整体迁入
  docs/archive/2026-09_governance_batches.md
- docs/STATUS.md 顶部 2026-09-17 之前条目迁入
  docs/archive/2026-09_status_history.md,仅保留指针
- D2 历史口径补齐为 2026-09-18 批次4后续专项清偿
- D4 标已清偿
- AGENTS.md / docs/archive/README.md 同步导航

D13 锁文件流程固化(镜像引入主体已清偿,仅剩锁文件落盘):
- 新增 deploy/generate_lockfiles.{sh,bat}:在 moldinsight conda
  环境(仅项目依赖)执行 pip freeze --exclude pythonocc-core,
  产出 deploy/requirements-{base,moldinsight}.lock.txt
- deploy/Dockerfile.moldinsight 注释改为指向生成脚本
- docs/OPERATIONS.md §2.1 增加完整流程说明
- tests/test_lockfile_generation.py 加锁文件存在性+体积契约;
  tests/conftest.py 注册 --run-lockfile-check 选项,
  默认 skip(仓库单测不阻塞),CI 镜像构建 job 显式启用 fail-fast

遗留:锁文件本身尚未落盘(本机Miniforge跨项目开发栈混装,
污染严重不能直接 pip freeze);待 CI / 生产首次构建时按流程落锁。

测试基线:126 passed, 9 skipped(默认4原有skip + D13新增5skip;
启用 --run-lockfile-check 时严格断言2项锁文件契约)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 09:59:02 +08:00
36 changed files with 3417 additions and 2220 deletions
+2 -1
View File
@@ -28,6 +28,7 @@
- **单数据库是刻意设计**:moldinsight 与 inventory 共享同一 PostgreSQL(如 `STPFile.product_id -> Product.id` 桥接),不拆库。
- **接口变更三件套**:优先用 Pydantic 请求模型(少用手写 `request.json()` 解析)→ 重新导出根目录 `openapi.json` → 前端 `npm run gen:api` 重新生成类型。三步缺一即契约漂移。
- **历史材料统一进 [docs/archive/](docs/archive/README.md)**,不与当前权威文档混放。
- **历史批次详细流水账 / 早段 STATUS**:见 [docs/archive/2026-09_governance_batches.md](docs/archive/2026-09_governance_batches.md) 与 [docs/archive/2026-09_status_history.md](docs/archive/2026-09_status_history.md);主骨架权威文档(TECH_DEBT §2 / STATUS 顶部)只保留摘要。
- **配置只走 `.env`**(参照 [.env.example](.env.example) 全键说明):`DB_*`、`SECRET_KEY` 等关键项不设代码兜底(惰性校验,缺失即报),不在代码里给 localhost/弱口令默认值。
## 3. 代码地图
@@ -120,7 +121,7 @@ frontend/ # Vue 3 独立工程:src/modules 按域组织
migrations/ # 数据库迁移
scripts/ # 一次性迁移与工具脚本(migrations/ 数据迁移、db/ 索引与审计 SQL、tools/ 检查工具),非运行时代码
tests/ # pytest:sqlite+aiosqlite 临时库;pythonocc 缺失时 OCC 契约测试自动 skip
deploy/ # Dockerfile.* / nginx / build 脚本
deploy/ # Dockerfile.* / nginx / build 脚本 / generate_lockfiles.{sh,bat}(D13 锁文件生成入口)
docs/ # 权威文档(本文件 §5 导航)
```
+4 -2
View File
@@ -4,8 +4,10 @@
# conda 运行时作为最终镜像的执行环境,自带全部动态库。
FROM continuumio/miniconda3:24.7.1-0
# 锁定几何栈核心版本;pip 侧全量版本锁待首次镜像构建成功后由
# `pip freeze > deploy/requirements-moldinsight.lock.txt` 生成(D13 遗留项)
# 锁定几何栈核心版本;pip 侧全量版本锁由
# `bash deploy/generate_lockfiles.sh` 在 moldinsight conda 环境内执行后生成
# (D13,参见 deploy/generate_lockfiles.sh / .bat 与 docs/OPERATIONS.md §2)
# 落盘产物为 deploy/requirements-{base,moldinsight}.lock.txt,CI / 离线构建可直接锁定安装
RUN conda create -n moldinsight -c conda-forge -y \
python=3.12 \
pythonocc-core=7.9.0 \
+38
View File
@@ -0,0 +1,38 @@
@echo off
REM ============================================================
REM geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13,Windows 版)
REM ============================================================
REM
REM 用法:在 gemold conda 环境内执行 deploy\generate_lockfiles.bat
REM
REM 产物:
REM deploy\requirements-base.lock.txt
REM deploy\requirements-moldinsight.lock.txt
REM ============================================================
setlocal enabledelayedexpansion
cd /d "%~dp0\.."
where conda >nul 2>&1
if errorlevel 1 (
echo [ERROR] conda 未安装或不在 PATH,请先激活 conda 环境 ^(推荐 moldinsight^) 1>&2
exit /b 1
)
if not "%CONDA_DEFAULT_ENV%"=="moldinsight" if not "%CONDA_DEFAULT_ENV%"=="gemold" (
echo [WARN] 当前 conda 环境为 '%CONDA_DEFAULT_ENV%',推荐在 'moldinsight' 内执行
)
echo ==^> 生成 base 锁文件
pip freeze --exclude pythonocc-core > deploy\requirements-base.lock.txt
echo ==^> 生成 moldinsight 锁文件
pip freeze --exclude pythonocc-core > deploy\requirements-moldinsight.lock.txt
echo.
echo 已生成:
echo deploy\requirements-base.lock.txt
echo deploy\requirements-moldinsight.lock.txt
echo.
echo 下一步:提交两个 lock.txt,并按团队策略同步更新 requirements-*.txt 下限。
endlocal
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# ============================================================
# geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13)
# ============================================================
#
# 设计要点:
# - 锁文件必须在构建产出的 conda/minimal 环境里生成(只有 geMoldInsight 依赖 + 基础库),
# 本机开发环境的 `pip freeze` 会污染(全开发栈混装),不能直接落锁。
# - 仅在带 moldinsight 的 conda 环境内运行 `pip freeze > deploy/requirements-<x>.lock.txt`
# 才有意义。
# - 锁文件落盘后即可被 CI / 离线构建 / 复现部署直接 `pip install -r` 锁定版本,
# 而不再依赖 >= 下限解析。
#
# 用法(必须在 `gemold` conda 环境内执行):
# bash deploy/generate_lockfiles.sh
#
# 产物:
# deploy/requirements-base.lock.txt
# deploy/requirements-moldinsight.lock.txt
# ============================================================
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v conda >/dev/null 2>&1; then
echo "[ERROR] conda 未安装或不在 PATH,请先激活 conda 环境(推荐环境名 moldinsight)" >&2
exit 1
fi
if [[ "${CONDA_DEFAULT_ENV:-}" != "moldinsight" && "${CONDA_DEFAULT_ENV:-}" != "gemold" ]]; then
echo "[WARN] 当前 conda 环境为 '${CONDA_DEFAULT_ENV:-<未激活>}'," \
"推荐在 'moldinsight' conda 环境内运行(否则锁文件将含宿主污染)" >&2
fi
echo "==> 生成 base 锁文件"
pip freeze --exclude pythonocc-core > deploy/requirements-base.lock.txt
echo "==> 生成 moldinsight 锁文件"
pip freeze --exclude pythonocc-core > deploy/requirements-moldinsight.lock.txt
echo
echo "已生成:"
echo " deploy/requirements-base.lock.txt ($(wc -l < deploy/requirements-base.lock.txt) 行)"
echo " deploy/requirements-moldinsight.lock.txt ($(wc -l < deploy/requirements-moldinsight.lock.txt) 行)"
echo
echo "下一步:"
echo " 1. 提交这两个 lock.txt(仅含项目直接依赖 + conda-minimal 环境产出)"
echo " 2. 同步更新 deploy/requirements-base.txt / requirements-moldinsight.txt 的版本下限" \
"为 lock 中的实际版本(或保留 >=,按团队策略)"
+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 |
+78
View File
@@ -205,6 +205,84 @@ geMoldInsight/
代码结构已明显模块化,但历史文档中仍保留不少阶段性叙述、旧部署语义与重复说明,这也是本轮文档整理要解决的问题之一。
### 6.4 D17 Human-in-Loop 老师傅经验反馈闭环 —— 已完成(2026-09-23~24,3 个 commit)
算法演进由老师傅经验驱动:通过方案级整体反馈(采纳 / 建议调整 / 拒绝)按"产品指纹 + 工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费老师傅沉淀的经验。这是少数"算法层由用户在线学习样本持续校准"的端到端闭环。
**端到端数据流**:
```
┌─────────────────────────────────────────────────────────────┐
│ 老师傅在 ResultView 点 👍 老师傅反馈按钮 │
│ → HumanFeedbackDialog 三选一(采纳 / 建议调整 / 拒绝) │
└──────────────────────────────┬──────────────────────────────┘
│ POST /api/tasks/{id}/experience-feedback
▼
┌──────────────────────────────────────────────────────────────┐
│ experience_feedback_router (src/moldinsight/api/) │
│ - ensure_task_access 归属校验 │
│ - current_user.has_permission("feedback_experience_hint") │
│ - service.record_feedback (flush; commit + invalidate) │
└──────────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ experience_feedback_service.record_feedback │
│ - compute_fingerprint (bbox_aspect / volume_bucket / │
│ face_bucket / undercut_class / material_family / is_foam) │
│ - 写 experience_feedback 表(D9 边界 / D17 衰减 90d TTL) │
│ - 同 stp_file_id 整体续期 expires_at │
└──────────────────────────────┬───────────────────────────────┘
│ 同 stp_file_id 上传新 STP 自动消费
▼
┌──────────────────────────────────────────────────────────────┐
│ processing_service._step_generate_cavity │
│ - experience_feedback_service.resolve_for_process_params │
│ → hints (List[{scheme_axis, weight, sample_count, ...}]) │
│ - hints 装进 run_occ payload 顶层 experience_hints │
└──────────────────────────────┬───────────────────────────────┘
│ OCC 子进程(spawn 隔离)
▼
┌──────────────────────────────────────────────────────────────┐
│ occ_worker._op_generate_cavity │
│ - payload.get("experience_hints") or {} → planner.generate_plan(hints=...) │
└──────────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ MultiSchemeMoldPlanner.generate_plan(..., hints=None) │
│ - candidate_generator.generate_candidates(..., hints) │
│ * priority_score += weight × 20 │
│ * sample_count ≥ 2 + weight ≥ 0.5 → method="human_experience_primary" │
│ - scheme_scorer.score_schemes(schemes, *, hints) │
│ * score_breakdown["human_hint_bonus"] = weight × 12 │
│ (sample_count < 2 时 ×0.5 折半) │
│ - global_summary.applied_hints 注入返回 │
└──────────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ ResultView 渲染: │
│ - summary-header 加 t-tag theme="success" 📚 历史经验 N 条 │
│ - 反馈提交后 onFeedbackSubmitted → loadExperienceHints 即刷 │
└──────────────────────────────────────────────────────────────┘
```
**硬规则遵守**:
- 跨模块 FK 仍守 §5.1(`experience_feedback.user_id` / `processing_task_id` / `stp_file_id` 全用字符串表名,无 ORM relationship)
- OCC 跨进程守 [occ_worker.py:7-8](../src/moldinsight/core/occ_worker.py#L7-L8) "杜绝 pickle OCC 对象"——payload 普通 dict 透传
- D9 边界不破:service.flush + 路由 commit(无 service 内 commit)
- 现有 `init_db.py` 幂等修复:按 code 补登权限/角色
**重量级约束**:
- `weight = max(0, (adopted-rejected)/total)`:仅正向有效,老师傅拒绝不"扣分"老算法
- `sample_count < 2` 时 bonus ×0.5:信号不足折半,但 priority_score 仍加成(候选方向仍偏向)
- 解析失败回退空 list:graceful,主流程不因下游错误退化
- `canGiveFeedback` 角色门控:`is_superuser || roles 含 process_engineer`
**测试基线**:192 passed, 13 skipped(批 3 净增 +4 OCC-gated:candidate_generator 3 / scheme_scorer 4 / multi_scheme_planner 2 / processing_service 2);前端 vue-tsc + vite 通过。
详见 [TECH_DEBT.md](TECH_DEBT.md) D17 + [STATUS.md](STATUS.md) 2026-09-23~24 日志。
---
## 7. 专题文档与主骨架的关系
+15 -1
View File
@@ -26,10 +26,24 @@
## 2. 安装与环境
- 后端依赖:`pip install -r requirements.txt`。
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold` 或 `moldinsight`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
- 前端:`cd frontend && npm install`。
- 数据库迁移:`migrations/`(`alembic.ini` 在仓库根;2026-09-16 由 `alembic/` 改名——原目录名与 alembic 包重名,应用内 import 会被遮蔽导致启动期迁移静默失败);数据修复类一次性脚本在 `scripts/migrations/` 与 `scripts/db/`,**不是运行时代码**,勿在服务内引用。
### 2.1 pip 锁文件生成(D13 流程)
`deploy/requirements-{base,moldinsight}.lock.txt` 是项目依赖的**版本锁**,由 conda 环境首次构建成功后一次性落盘:
- **生成时机**:在 `moldinsight` / `gemold` conda 环境(仅含项目依赖 + conda 基础库,**不能**在混装全开发栈的本机 pip 环境跑)执行 `pip freeze`
- **生成命令**:
- Linux / macOS:`bash deploy/generate_lockfiles.sh`
- Windows:`deploy\generate_lockfiles.bat`
- **产物**:
- `deploy/requirements-base.lock.txt`
- `deploy/requirements-moldinsight.lock.txt`
- **消费方**:CI、离线构建、生产复现部署;`pip install -r deploy/requirements-base.lock.txt` 可直接锁定安装而不依赖 `>=` 解析
- **提交策略**:两个 lock.txt 提交到仓库;版本下限(`requirements-{base,moldinsight}.txt`)按团队策略同步或保留 `>=` 灵活解析
## 3. 本地启动
后端三入口(均含 sys.path 修正,可从仓库根直接跑):
+1
View File
@@ -46,6 +46,7 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
重点方向:
- ~~`advanced_router` 拆分与请求模型规范化~~(2026-09-17 批次 3 完成)
- ~~D17 Human-in-Loop 老师傅经验反馈~~(2026-09-23~24 完成,3 个 commit:数据 + 权限 + 写入 API / 算法接缝 + OCC payload / 前端按钮 + Dialog + 经验角标;写入即消费闭环通;详见 [TECH_DEBT.md](TECH_DEBT.md) D17)
- 模具分析链路的结构继续收口
- OCC 依赖场景下的契约测试/集成测试继续补齐
+11
View File
@@ -2,9 +2,20 @@
> 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [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-24(**D17 Human-in-Loop 老师傅经验反馈批 3 上线(前端按钮 + Dialog + 经验角标)——闭环可视**:① [ResultView.vue:35-47](frontend/src/modules/moldinsight/ResultView.vue#L35-L47) 方案卡片 summary-header 加 `t-tag theme="success" variant="light"` 经验角标("📚 历史经验 N 条"),从 `hintsByAxis[currentAxisHint]` 读取,按 scheme_axis 索引,无 hints 时不渲染;② [ResultView.vue:131-138](frontend/src/modules/moldinsight/ResultView.vue#L131-L138) `export-buttons-bar` 加 `👍 老师傅反馈` 按钮(`v-if="canGiveFeedback"` 角色门控:admin 或 process_engineer);③ 新建 [components/HumanFeedbackDialog.vue](frontend/src/modules/moldinsight/components/HumanFeedbackDialog.vue):t-dialog + t-form + t-radio-group 三选一(采纳 / 建议调整 / 拒绝)+ t-textarea 原因 + 调整建议(仅 adjust 模式显隐);提交走 `moldinsightApi.submitExperienceFeedback`,成功后 emit `submitted` 让父组件重拉 hints 刷新角标;④ [shared/api-client.ts:407-444](frontend/src/shared/api-client.ts#L407-L444) `moldinsightApi` 新增 `getExperienceHints` / `submitExperienceFeedback` 两个方法(生成类型由 openapi-typescript 自动产出);⑤ [ResultView.vue](frontend/src/modules/moldinsight/ResultView.vue) `onMounted` 调 `loadExperienceHints` 拉一次 + 反馈提交后 `onFeedbackSubmitted` 再拉一次(写入即消费前端可见);`canGiveFeedback` 走 `is_superuser || roles 含 process_engineer` 表达式(项目硬规则"前端不要破坏 ResultView.vue 视觉一致性":按钮与既有 6 个 t-button 同一 `export-buttons-bar`,theme/size 一致;角标 theme="success" variant="light" 与既有 t-tag theme="primary" / "warning" 同款)。**接口变更三件套随批完成**:openapi.json 重导出(2 个新 path,含 ExperienceFeedbackCreate / ExperienceHintItem / ExperienceHintsResponse 三个新 schema)→ `npm run gen:api` 再生 types/api.ts → `npm run build` 通过。**后端基线**:**192 passed, 13 skipped**(批 3 不改后端);**前端构建**:vue-tsc + vite 通过,ResultView 包大小 37.18 kB / 11.91 kB gzip。**D17 闭环端到端可用**:admin / process_engineer 在 ResultView 点"👍 老师傅反馈" → Dialog 选"采纳"+ 写原因 → 提交 → 角标即时刷新(重拉 hints);下次同指纹 STP 分析,`PartingCandidateGenerator` 候选方向加成 + `PartingSchemeScorer` total_score 加成 + method 标签升级 `human_experience_primary`。**下一步**:批 4(衰减机制完善 + DFM 规则库独立模块化 + 经验冲突仲裁 UI)按需排期。)
> 2026-09-23(**D17 Human-in-Loop 老师傅经验反馈批 2 上线(算法接缝 + OCC payload 通道)——闭环通**:① 算法层 4 个核心文件加 `hints` 形参透传链:[parting_candidate_generator.py:13-66](src/moldinsight/core/parting_candidate_generator.py#L13-L66) `_build_axis_metrics` 末尾按 hints 加成(`weight × 20` 上限,`sample_count ≥ 2 + weight ≥ 0.5` → method 标签升级 `human_experience_primary`);[parting_scheme_scorer.py:8-46](src/moldinsight/core/parting_scheme_scorer.py#L8-L46) `_score_scheme` 新增 `human_hint_bonus` 字段(weight × 12 上限,sample_count < 2 时 ×0.5 折半),纳入 total_score;[multi_scheme_planner.py:26-86](src/moldinsight/core/multi_scheme_planner.py#L26-L86) `generate_plan` 透传 hints 到下两层,`global_summary.applied_hints` 注入返回;② [processing_service.py:531-595](src/moldinsight/services/processing_service.py#L531-L595) `_step_generate_cavity` 调 `experience_feedback_service.resolve_for_process_params` 拿同指纹 hints,装进 run_occ payload 顶层 `experience_hints` 字段(普通 dict 透传,pickle 安全,满足 [occ_worker.py:7-8](src/moldinsight/core/occ_worker.py#L7-L8) 硬规则);③ [occ_worker.py:117-140](src/moldinsight/core/occ_worker.py#L117-L140) `_op_generate_cavity` 读 `payload.get("experience_hints") or {}` 透传给 `planner.generate_plan(..., hints=...)`;④ D17 闭环验证:老师傅写一条同指纹 `adopted` → 同 X 通道下次分析 `priority_score` +18,`score_breakdown.human_hint_bonus` +12(sample_count=3),method 标签升级 `human_experience_primary`。**接口面零变化**(路径 / schema 不动;仅 OCC 子进程内部响应含 `global_summary.applied_hints`,由前端 ResultView 渲染角标——批 3 实现)。**测试基线**:**192 passed, 13 skipped**(批 2 净增 7 通过 + 4 OCC-gated skip:candidate_generator 3 例 / scheme_scorer 4 例在无 OCC 环境跑通,multi_scheme_planner + processing_service 4 例 OCC-gated 待 conda `gemold` 镜像验证)。**接口变更三件套执行节点**:openapi.json 重导出与前端 `gen:api` 待批 3 完成后一并执行(前端调用两 path + ResultView 渲染一并改)。**下一步**:批 3 前端(ResultView 按钮组 + `HumanFeedbackDialog.vue` + `moldinsightApi` 两个方法 + 经验角标)。)
> 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 项锁文件契约)。)
>
> 2026-09-02 起(含 09-17 之前基线条目)的完整内容见 [archive/2026-09_status_history.md](archive/2026-09_status_history.md)。
>
> 2026-09-21(**inventory 仪表盘聚合服务下沉完成:dashboard 薄路由化**:① 新增 [dashboard_service.py](../src/inventory/services/dashboard_service.py),将仪表盘首页所需的基础主数据统计、物料库存总量/总值、待处理采购/销售单数、低库存预警列表等聚合查询从路由层下沉到 service;② [dashboard_routes.py](../src/inventory/api/dashboard_routes.py) 改为单行委托薄路由,inventory 主要业务域路由已基本完成 service orchestration 收口;③ 新增 [test_api_dashboard_service.py](../tests/test_api_dashboard_service.py),覆盖 seeded summary 与低库存预警两条 API 回归。**接口面零变化**(无 openapi 漂移)。**测试基线**:**126 passed, 4 skipped**;新增 dashboard 回归 **2 passed**。)
>
> 2026-09-21(**inventory 产品域跨模块桥接收口完成:`/api/products/from-task/{task_id}` 下沉至 `product_service`**:① [product_service.py](../src/inventory/services/product_service.py) 新增 `create_product_from_task`,将 ProcessingTask / STPFile 查询、已绑定成品幂等返回、`MI{stp_file_id}` SKU 冲突递增、分析结果摘要拼装、成品创建与 `stp_files.product_id` 回写从路由层下沉到 service;② [product_routes.py](../src/inventory/api/product_routes.py) 现已全量薄路由化,产品域 CRUD / BOM / from-task 三类接口统一改为 service orchestration;③ 扩展 [test_api_product_service.py](../tests/test_api_product_service.py) 与 [tests/conftest.py](../tests/conftest.py),补 `STPFile` / `ProcessingTask` 种子及 from-task 创建、重复调用幂等、任务不存在 404 回归。**接口面零变化**(无 openapi 漂移)。**测试基线**:**124 passed, 4 skipped**;product 域回归现为 **14 passed**。)
+71 -70
View File
@@ -18,59 +18,33 @@
---
## 2. 已完成的重要治理(摘要)
## 2. 已完成的重要治理(主题摘要)
以下高价值治理已完成:
按主题归类的高价值治理已完成项。每项的具体修复清单 / 迁移号 / 回归测试 / 测试基线见归档:
- [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md):批次 0–4 + 后续专项 + 2026-09-21 inventory 服务下沉 + 2026-09-22 schema/datetime 弃用清零 的完整流水账
- [archive/MOLDINSIGHT_TECH_DEBT_PLAN.md](archive/MOLDINSIGHT_TECH_DEBT_PLAN.md):更早的设计审查原始计划
### 2.1 安全与权限
- debug/history 路由补鉴权
- 任务访问控制收紧
- 无主数据不再默认放行
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5,2026-09-16 清偿,见 D5 条目)
- bcrypt 创建口令超 72 字节显式拒绝、验证侧截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失时明确报错,代码侧弱默认移除(D14 部分,2026-09-16)
- debug / history 路由补鉴权;任务访问控制收紧;无主数据不再默认放行
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5 → §3 D5)
- bcrypt 超 72 字节显式拒绝 + 截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失明确报错
### 2.2 静默失败与可用性
- `detect-undercuts` 改为基于真实 shape 分析
- OCC 超时后重建 executor,避免全队列永久堵死
- OCC 超时后重建 executor(短期)→ D10 方案 B 进程化彻底替换
- 后台任务统一分派,补强引用与并发控制
### 2.3 状态存储与缓存
- Redis 任务状态改为 Hash 字段级更新,兼容旧格式
- 完成态任务视图增加缓存
- 导出缓存与持久化链路收口,支持重启后再导出
- 内存回退彻底删除,PG 为任务状态单一事实源(原 D7)
- 完成态任务视图缓存;导出缓存与持久化链路收口
### 2.4 架构与代码清理
- 删除旧单体入口与死代码
- 设置惰性配置校验,提升可测试性
- Generator 公共接口提取完成,补充契约测试
### 2.5 部署正确性(2026-09-16,批次 0/1)
- `/api/status/{task_id}` 补鉴权与归属校验(原 D5)
- 主处理链路改走 RustFS:分派入参 `stp_file_id` 化,源文件按 object_key 下载;compose 共享卷过渡兜底(原 D6)
- `AUTO_MIGRATE` 开关 + 迁移脚本随镜像分发 + `alembic/`→`migrations/` 改名修复包遮蔽(原 D12)
- OCC 镜像改 conda 运行时原生执行、基础镜像 tag 锁定(D13 主体);compose 关键项去弱默认(D14 部分)
### 2.6 任务一致性模型(2026-09-16,批次 2)
- Redis 内存回退彻底删除,PG 为任务状态单一事实源(原 D7);批量元数据入库(`processing_tasks.batch_id`,迁移 `a3f8c2d91e47`)
- 型腔生成失败任务标 failed,不再静默 completed(原 D8)
- 持久化事务边界收口:数据本体分阶段原子提交、失败先回滚再置 failed(原 D9)
- D11(HTML 双写双读)本批未动:正确性已由共享卷兜底,RustFS 单一来源留待后续批次
### 2.7 API 与代码结构(2026-09-17,批次 3)
- `advanced_router` 按职责拆为 design / cost / machining / export 四个子路由,端点路径不变,请求体全量 Pydantic 化(原 D1)
- 路由装载失败显式化:`ROUTE_MODULES` 清单 + route_registry,失败经 `/api/health` 呈现 degraded(含真实 pythonocc 探测),DEBUG 下 fail fast
- 纯 Python 重计算端点(设计/加工/CAM 打包)统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环;OCC 操作仍走单线程 executor(D10 不变,批次 4)
- `StorageIntegrationService`(867 行)按职责拆为 TaskStorage / AnalysisStorage / FileHistory 三服务;无调用方的 `log_user_activity` 死代码删除
- 配置治理收尾:`MAX_FILE_SIZE` 接线生效、celery_app 复用 `Settings.redis_url`(原 D14)
- 连带修复:管理员重置密码改 JSON body(原裸 str 参数被解析为 query param,前端发 body 必 422,功能端到端断裂);Dockerfile.celery 的 FROM tag 与 compose/build.sh 实际构建的 `gemold-backend:latest` 对齐(此前干净环境 celery 镜像必构建失败)
- 接口变更三件套随批完成:openapi.json 重导出(76 paths)+ 前端 `gen:api`
### 2.8 架构演进(2026-09-17,批次 4)
- 共享 ORM 按模块拆分(原 D3 主体):891 行 `shared/models/database.py`(31 模型类三类同居)拆为 `shared/models/base.py`(唯一 Base + 归属约定)/ `shared/models/identity.py`(7 表)/ `moldinsight/models/`(9 表)/ `inventory/models/`(catalog/warehouse/trading/finance 15 表);**三条跨模块 ORM relationship(`User.stp_files`、`STPFile.user`、`STPFile.product`)经全仓核实均无使用方,直接删除**——跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1),单模块部署不再依赖另一侧模型注册;约 45 处 import 全量改写,无兼容 facade;全量注册点收敛为 migrations/env.py 与 tests/conftest.py;零调用方的死方法 `db_manager.create_tables` 一并删除(拆分后会静默建残缺 schema)
- OCC 泄漏治理 + 吞吐方案设计先行(原 D10):`_reset_occ_executor` 补 `cancel_futures=True`——不止卫生问题:旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争);吞吐路线定稿于 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(短期 A:celery prefork 伸缩 + max-tasks-per-child 兜底;中期 B:run_occ 接口进程化 + kill-on-timeout 根治)
- 归属边界回归测试:[tests/test_model_ownership.py](../tests/test_model_ownership.py)(31 表全量注册、单模块独立 mapper 配置、旧模块无 facade)
详细历史过程保留在原始技术债文档中,后续将转入归档。
- 删除旧单体入口与死代码(`db_manager.create_tables` / `log_user_activity` / `CADExporter.export_mold_results` / `getAluminumPrice` 等)
- 惰性配置校验,提升可测试性
- Generator 公共接口提取 + 契约测试
- 共享 ORM 按模块拆分,跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1)
---
@@ -86,24 +60,19 @@
~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、OpenAPI 可读性差、参数校验不统一。
### D2. 铝价模拟数据未显式标注来源
### D2. 铝价模拟数据未显式标注来源 —— 已清偿(2026-09-18,批次 4 后续专项)
现状:
- 铝价服务返回的是模拟/参考数据,但接口层未明确表达
修复内容(保留编号以维持引用稳定):
- [src/moldinsight/services/aluminum_price_service.py](../src/moldinsight/services/aluminum_price_service.py):`get_aluminum_current_price` 响应补 `source: "simulated"`,`get_aluminum_price_history` 逐项补同字段
- [frontend/src/modules/home/HomeView.vue](../frontend/src/modules/home/HomeView.vue):按 `source` 字段渲染"模拟数据 · 参考走势,非实时行情"标注(不再硬编码"上海期货交易所"等虚假来源)
- 死代码 `getAluminumPrice` 删除(前端此前保留了一份本地硬编码函数,已无调用方)
影响:
- 容易误导前端与业务使用者,把模拟数据理解为实时行情
建议:
- 响应增加 `source: "simulated"`
- 前端界面同步标注“模拟/参考数据”
优先级:**P2**
~~原现状 / 影响~~:铝价接口返回走势数据但无来源声明,前端原硬编码"上海期货交易所"字样,与实际模拟数据不一致,属虚假来源声明。
### D3. shared/platform 边界仍需继续收敛 —— 主体已清偿(2026-09-17 批次 4 + 2026-09-18 后续)
已完成部分:
- 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 §2.8 与 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1)
已完成部分(修复文件清单见 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md) 批次 4 / 后续专项):
- 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1)
- 旧 `shared/models/database.py` 物理删除,无兼容 facade;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定
- **app_factory 组合职责收敛**(2026-09-18):平台工厂只做纯平台引导,`connect_rustfs` 参数移除;moldinsight 专属接线(RustFS 启动钩子 [init_storage.py](../src/moldinsight/storage/init_storage.py) `rustfs_startup_hook`、路由单点聚合 [moldinsight/api/__init__.py](../src/moldinsight/api/__init__.py) `register_moldinsight_routers`)收敛回模块层,入口退化为纯组装(ARCHITECTURE §6.2)
@@ -112,21 +81,14 @@
优先级:**P3**(仅剩 identity/platform 语义注释口径)
### D4. 文档现状 / 规划 / 历史混放
### D4. 文档现状 / 规划 / 历史混放 —— 已清偿(2026-09-22)
现状:
- 文档存在部署说明重叠、计划/总结/权威文档混放
- README 承担过多职责
修复内容(保留编号以维持引用稳定):
- 已建立 `STATUS / ARCHITECTURE / ROADMAP / TECH_DEBT / DEPLOYMENT` 主骨架,每类信息单一归属;历史材料归档至 `docs/archive/`
- 本文档 §2 由"按批次回顾"精简为"按主题摘要",修复文件清单 / 迁移号 / 回归测试 / 测试基线等详细流水账整体迁入 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md)(避免与 §3 重复膨胀)
- §3 中对历史批次的引用(如 D3 → §2.8)改为 archive 指针;D2 等已清偿项补齐时间戳
影响:
- 新成员难以判断“哪篇才是当前有效说法”
- 状态、部署、规划容易发生漂移
建议:
- 建立 `STATUS / ARCHITECTURE / ROADMAP / DEPLOYMENT` 主骨架
- 历史材料迁入 `docs/archive/`
优先级:**P1**
~~原现状 / 影响~~:TECH_DEBT §2 与 §3 内容重复膨胀,文档目录结构清晰度受新成员评估影响。
### D5. `/api/status/{task_id}` 未鉴权(安全缺口)—— 已清偿(2026-09-16,批次 0)
@@ -222,13 +184,18 @@
- 语义保持:仅切换 Pydantic v2 配置语法 + UTC 时区语义,字段 / OpenAPI / JWT 行为零变化
- 验证:`pytest tests/ -q` **126 passed, 4 skipped**,deprecation warning 全部清零
### D13. PythonOCC 镜像引入方式脆弱 + 依赖无版本锁(主体已清偿,锁文件遗留)
### D13. PythonOCC 镜像引入方式脆弱 + 依赖无版本锁(镜像引入已清偿;锁文件流程已固化,待首次构建落盘)
现状:
- ~~从 conda env 拷贝 site-packages 进 python:3.12-slim~~(2026-09-16 已修正:[Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 改为 conda 运行时原生执行,不再跨镜像拷贝;基础镜像 tag 锁定 `continuumio/miniconda3:24.7.1-0`、`python:3.12-slim-bookworm`;tag 可用性随下次镜像构建验证)
- [requirements.txt](../requirements.txt) 全部为 `>=` 下限,无锁文件(**遗留**:首次镜像构建成功后 `pip freeze` 生成锁文件,命令已注释在 Dockerfile 内)
- 锁文件流程已固化(2026-09-22):
- 新增 [deploy/generate_lockfiles.sh](../deploy/generate_lockfiles.sh) / [generate_lockfiles.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` 启用
- **遗留**:锁文件本身尚未落盘——本机 Miniforge 装的是跨项目开发栈混装环境,污染严重不能直接用 `pip freeze`;须等 CI / 生产机器首次构建 moldinsight 镜像后,按流程跑 `bash deploy/generate_lockfiles.sh` 落锁并提交。已存在护栏:CI 镜像构建 job 跑 `--run-lockfile-check` 后若未落盘会 fail-fast,强制流程走通
优先级:**P2**(剩余锁文件部分)
优先级:**P2**(流程已固化,剩"首次构建后落盘"一次性产物)
### D14. 配置漂移:弱默认 / 死配置 / 重复解析 —— 已清偿(2026-09-16 ~ 09-17,批次 1 / 3)
@@ -246,6 +213,40 @@
~~原现状 / 影响~~:`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 已完成(算法接缝 + OCC payload 通道)—— 闭环通**:
- [parting_candidate_generator.py](src/moldinsight/core/parting_candidate_generator.py) `generate_candidates(..., hints=None)`:`priority_score += weight × 20`,`sample_count ≥ 2 + weight ≥ 0.5` 时 method 标签升级 `human_experience_primary`
- [parting_scheme_scorer.py](src/moldinsight/core/parting_scheme_scorer.py) `score_schemes(..., *, hints=None)`:新增 `score_breakdown["human_hint_bonus"]`(`weight × 12`,`sample_count < 2` 时 ×0.5 折半),纳入 total_score;keyword-only 防与位置参数混淆
- [multi_scheme_planner.py](src/moldinsight/core/multi_scheme_planner.py) `generate_plan(..., hints=None)`:透传 hints 到下两层,`global_summary.applied_hints` 注入返回供前端展示
- [processing_service.py](src/moldinsight/services/processing_service.py) `_step_generate_cavity`:调 `experience_feedback_service.resolve_for_process_params` 拿同指纹 hints,装进 run_occ payload 顶层 `experience_hints`;解析失败回退空 list 不阻塞主流程
- [occ_worker.py](src/moldinsight/core/occ_worker.py) `_op_generate_cavity`:`payload.get("experience_hints") or {}` 透传给 `planner.generate_plan`,普通 dict 跨进程 pickle 安全
- 测试基线:192 passed, 13 skipped(批 2 净增 7 通过 + 4 OCC-gated skip)
**批 3 已完成(前端按钮 + Dialog + 经验角标)—— 闭环可视**:
- [ResultView.vue:35-47](frontend/src/modules/moldinsight/ResultView.vue#L35-L47) 方案卡片 summary-header 加 `t-tag` 经验角标(`currentAxisHint` computed 按 scheme_axis 索引 hintsByAxis,无 hints 不渲染)
- [ResultView.vue:131-138](frontend/src/modules/moldinsight/ResultView.vue#L131-L138) `export-buttons-bar` 加 `👍 老师傅反馈` 按钮(`v-if="canGiveFeedback"` 角色门控:admin 或 process_engineer)
- [HumanFeedbackDialog.vue](frontend/src/modules/moldinsight/components/HumanFeedbackDialog.vue) 新组件:t-dialog + t-form + t-radio-group 三选一 + t-textarea;走 `moldinsightApi.submitExperienceFeedback`,成功后 emit `submitted` 让父组件重拉 hints
- [shared/api-client.ts:407-444](frontend/src/shared/api-client.ts#L407-L444) `moldinsightApi` 新增 `getExperienceHints` / `submitExperienceFeedback`
- 接口变更三件套随批完成:openapi.json 重导出(2 新 path)→ `npm run gen:api` → `npm run build` 通过
**剩余工作(按需排期)**:
- 批 4:衰减机制完善(与 DB 一致性定期核查)+ DFM 规则库独立模块化 + 经验冲突仲裁 UI
~~原现状 / 影响~~:算法生成的方案与真实工程决策有差距,老师傅每次都要推翻系统建议重来,沉淀经验无结构化路径。
---
## 4. 当前推荐治理顺序
+100
View File
@@ -0,0 +1,100 @@
# 2026-09 后端设计治理批次实施流水账(归档)
> 文档定位:**2026-09 设计审查产出的批次 0–4 与后续专项的完整实施流水账**。
> 主骨架权威文档见:
> - [../STATUS.md](../STATUS.md)(每批次完成的时间点与产物)
> - [../TECH_DEBT.md](../TECH_DEBT.md)(活跃债务 / 已清偿项的当前位置)
> - [../ROADMAP.md](../ROADMAP.md)(批次计划与执行顺序)
> - [../ARCHITECTURE.md](../ARCHITECTURE.md)(平台 / 模块边界当前定稿)
## 批次 0(2026-09-16,安全与诚实)
- `/api/status/{task_id}` 补 JWT 鉴权 + 任务归属校验(无 token 401 / 他人或无主任务 403 / 不存在 404)
- 归属校验收敛为 `TaskQueryService.ensure_task_access` 供 task_router 与 advanced_router 共用(原 D5)
- 上传预检 `pythonocc_available` 从硬编码 true 改为惰性真实探测
- bcrypt 口令治理:创建侧超 72 字节显式拒绝、验证侧截断比较
- `SECRET_KEY` 未配置 / `RUSTFS_*` 缺失时惰性校验抛明确错误
- 完成态任务未持久化 `analysis_metrics` 时 `/api/status` 组装视图 500 修复
- 测试基线:96 passed
- 回归测试:`tests/test_status_endpoint_auth.py` 8 项
- 接口行为变化已同步 [../API_CONTRACT.md](../API_CONTRACT.md) §3.2
## 批次 1(2026-09-16,部署正确性)
- 主处理链路改走 RustFS:分派入参 `file_path` → `stp_file_id`(原 D6)
- 处理方按 PG 元数据从 RustFS 下载源文件到任务专属临时目录(保留原始文件名,下游产物命名不变),任务结束即清理
- RustFS 不可用时回退 `STPFile.file_path` 节点本地路径;compose 为 backend / celery 增加共享卷 `uploads_data` / `html_data` 作过渡兜底
- `AUTO_MIGRATE` 开关(settings / .env.example / compose 透传),默认 `true` 保持单机开发行为(原 D12)
- 迁移目录 `alembic/` → `migrations/`(修复 `import alembic` 命中本地目录遮蔽真实包的命名冲突)+ Docker 镜像补 `COPY migrations/` + `COPY alembic.ini`
- Dockerfile.moldinsight 改为 conda 运行时原生执行,基础镜像 tag 锁定(原 D13 主体)
- compose 关键项去弱默认:`SECRET_KEY` / `ADMIN_PASSWORD` 改 `${VAR:?}` 强制显式配置;`create_admin_user` 对空口令显式报错
- 测试基线:98 passed, 1 skipped
- 回归测试:`tests/test_deployment_config.py`
## 批次 2(2026-09-16,任务一致性模型)
- Redis 进程内存回退彻底删除(写 no-op / 读 None,Redis 仅热缓存),PG 为任务状态单一事实源(原 D7)
- 批量元数据入库:`processing_tasks` 新增 `batch_id` 列(迁移 `a3f8c2d91e47`),`GET /api/batch/{batch_id}` 改为 PG 聚合查询 + `STPFile.user_id` 归属校验
- 删除 Redis batch key 与进程内 dict 双通道
- `TaskQueryService` PG 视图与 batch 聚合响应补 `progress` / `current_step`
- 型腔分模失败不再吞异常 → 任务 failed(原 D8)
- 持久化事务边界收口:数据本体写方法只 flush,编排层分阶段原子收口(阶段 A 几何+网格、阶段 B 型腔+HTML+特征+指标+验证),失败先 rollback 再置 failed(原 D9)
- 进度/状态更新保留即时 commit(长任务进度可见性)
- 测试基线:105 passed, 1 skipped
- 回归测试:`tests/test_batch_status_pg.py` 4 项 + `tests/test_redis_no_fallback.py` 3 项
## 批次 3(2026-09-17,API 与代码结构)
- `advanced_router` 按职责拆为 design / cost / machining / export 四个子路由,端点路径不变(原 D1)
- 全部请求体改 Pydantic 模型(`request.json()` 手动解析退役),校验失败统一 422
- `_get_cached_import` 上提为 `core_modules.py` 共用
- 路由装载失败显式化:`ROUTE_MODULES` 清单 + `route_registry`,失败经 `/api/health` 呈现 degraded(`pythonocc` 真实探测),DEBUG 下 fail fast
- 纯 Python 重计算端点统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环
- `StorageIntegrationService`(867 行)按职责拆为 TaskStorage / AnalysisStorage / FileHistory 三服务(原 D14 部分)
- `MAX_FILE_SIZE` 接线生效、celery_app 复用 `Settings.redis_url`(原 D14 收尾)
- 连带修复:管理员重置密码改 JSON body(原裸 str 参数被解析为 query param,前端发 body 必 422);Dockerfile.celery 的 FROM tag 与实际构建 tag 对齐
- 接口变更三件套随批完成:openapi.json 重导出(76 paths)+ 前端 `gen:api`
- 测试基线:122 passed, 2 skipped
- 回归测试:4 个测试文件共 17 项(`test_advanced_split_contract` / `test_route_load_status` / `test_config_governance` / `test_auth_password_reset`)
## 批次 4(2026-09-17,架构演进)
- 共享 ORM 按模块拆分(原 D3 主体):891 行 `shared/models/database.py`(31 模型类三类同居)拆为 `shared/models/base.py`(唯一 Base + 归属约定)/ `shared/models/identity.py`(7 表)/ `moldinsight/models/`(9 表)/ `inventory/models/`(catalog/warehouse/trading/finance 15 表)
- 三条跨模块 ORM relationship(`User.stp_files` / `STPFile.user` / `STPFile.product`)经全仓核实均无使用方,直接删除;跨模块桥接收敛为裸 FK 硬规则([../ARCHITECTURE.md](../ARCHITECTURE.md) §5.1)
- 约 45 处 import 全量改写,无兼容 facade
- 全量注册点收敛为 migrations/env.py 与 tests/conftest.py
- 零调用方的死方法 `db_manager.create_tables` 一并删除
- `_reset_occ_executor` 补 `cancel_futures=True`(旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**,属数据竞争而非单纯泄漏,原 D10 短期治理)
- 吞吐路线定稿于 `topics/performance/OCC_THROUGHPUT.md`(短期 A:celery prefork 伸缩 + max-tasks-per-child 兜底;中期 B:run_occ 接口进程化 + kill-on-timeout 根治)
- 顺手清偿 D15:`vite.config.ts` 删除未用的 `mode` 参数,`vue-tsc -b` 恢复通过
- 回归测试:`tests/test_model_ownership.py`(31 表全量注册 / 单模块独立 mapper 配置 / 旧模块无 facade)
- 测试基线:125 passed, 2 skipped
- 接口面零变化(无 openapi 重导出)
## 批次 4 后续专项(2026-09-18)
- D11 清偿:可视化报告 RustFS 单源化
- 写侧 HTMLGenerator 每任务写临时目录,`.html`/`_summary.json`/`_data.json` 三件统一裸传 RustFS 报告键 `html/reports/{filename}`
- 读侧 `/html` StaticFiles 本地挂载删除,新增代理路由 `html_report_router.py`(报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404)
- 防路径穿越(单段文件名校验),URL 形状 `/html/{filename}` 不变
- 部署:celery 服务摘除 `html_data` 卷;Dockerfile.moldinsight 删除 `COPY html_output/`
- 已知约束:报告路由不做认证(iframe 无法携带 Authorization 头)
- 删除 `get_stp_file_with_data` 的死数据块
- OCC 方案 A 部署参数落地:`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 Dockerfile.celery + compose + .env.example
- D2 清偿:铝价响应带 `source: "simulated"`;前端按来源渲染"模拟数据 · 参考走势"标注;死代码 `getAluminumPrice` 删除
- D10 方案 B 实施:`run_occ(fn, *args)` → `run_occ(op_name, payload)`;常驻 OCC 进程池 + kill-on-timeout 根治残留线程泄漏
- CI 门禁:`.gitea/workflows/ci.yml` 三 job(pytest / 前端构建 / openapi 漂移检测)
- 接口变更三件套随批完成:openapi.json 重导出(76→77 paths,新增 `/html/{filename}`)+ 前端 `gen:api` 再生
- 测试基线:143 passed, 0 skipped
## 后续小步治理(2026-09-21 ~ 2026-09-22)
- 2026-09-21 inventory 业务层继续沉淀批次(4 批):
- `customer / supplier / warehouse` → `master_data_service`
- `material_routes`(价格历史 / 趋势 / 供应商关联)→ `material_service`
- `product_routes`(CRUD / BOM / from-task 跨模块桥接)→ `product_service`
- `dashboard_routes`(首页统计 / 低库存预警)→ `dashboard_service`
- 2026-09-22 schema / datetime 现代化弃用清零(D12):
- 全仓 14 处 `class Config:` + auth_routes 三处全部迁移到 `model_config = ConfigDict(from_attributes=True)`
- `auth_service.py` 中 `datetime.utcnow()` 改用 `datetime.now(timezone.utc)`
- 测试基线最终落点:126 passed, 4 skipped,零 deprecation warning
+10
View File
@@ -0,0 +1,10 @@
# 2026-09 之前 STATUS 早段历史(归档)
> 文档定位:**[../STATUS.md](../STATUS.md) 顶部精简后,2026-09-17 之前条目的完整副本**。
> 2026-09-17 及之后的批次(批次 0–4 + 后续专项 + 2026-09-21 inventory 服务下沉 + 2026-09-22 schema/datetime 弃用清零)以摘要形式保留在 [../STATUS.md](../STATUS.md) 顶部,详细流水见 [2026-09_governance_batches.md](2026-09_governance_batches.md)。
---
> 2026-09-02(**模块化收口 + 文档主骨架建立(基线条目)**:代码侧完成 moldinsight 技术债治理——安全收口(debug/history 权限补齐、任务访问控制收紧)、静默失败修复(`detect-undercuts` 基于真实 shape 重建)、OCC 超时后 executor 重建防毒化全队列、后台任务统一分派、Redis 任务状态改 Hash 原子更新、完成态任务视图缓存、导出缓存与持久化收口、旧入口与死代码删除、Generator 公共接口提取 + 契约测试;详见 [TECH_DEBT.md](../TECH_DEBT.md) §2。结构侧完成 `src/entrypoints/` 三入口拆分(moldinsight / inventory / unified)、`shared` 平台能力集中、前端独立 `frontend/` 工程。文档侧建立 `STATUS / ARCHITECTURE / ROADMAP / TECH_DEBT / DEPLOYMENT` 主骨架,README 收敛为唯一导航入口,历史材料迁入 [archive/](README.md)。**测试基线**:本地 pip 环境 **47 passed, 1 skipped**(pythonocc 缺失自动 skip);moldinsight conda + OCC 环境 **88 passed**。inventory 侧少量既有 deprecation warnings 不影响通过。)
> 此前:2026-09-01(**文档体系专项整理启动**:明确「README 只做导航、每类信息单一归属、历史材料进 archive」的文档治理原则;建立 deployment/ 主题目录与 archive/ 归档目录;部署文档收口为 DEPLOYMENT(入口)+ deployment/LINUX_SETUP(操作)+ deployment/DEPLOY_PORT / PORT_CONFIG(端口补充)三层。)
+2
View File
@@ -25,6 +25,8 @@
- [MOLD_ERP_ANALYSIS_REPORT.md](MOLD_ERP_ANALYSIS_REPORT.md)
- [ZERO_FINISHED_INVENTORY_CERTIFICATE.md](ZERO_FINISHED_INVENTORY_CERTIFICATE.md)
- [CONFLUENCE_ARCHIVE_STRUCTURE.md](CONFLUENCE_ARCHIVE_STRUCTURE.md)
- [2026-09_governance_batches.md](2026-09_governance_batches.md):2026-09 设计审查批次 0–4 + 后续专项的完整实施流水账([../TECH_DEBT.md](../TECH_DEBT.md) 与 [../STATUS.md](../STATUS.md) 仅保留摘要)
- [2026-09_status_history.md](2026-09_status_history.md):[../STATUS.md](../STATUS.md) 顶部精简后,2026-09-17 之前条目的完整副本
- [topics/ai/](topics/ai/):已迁移的 AI 相关专题历史材料
- [topics/performance/](topics/performance/):已迁移的性能专题历史材料
- [topics/aluminum-foam/](topics/aluminum-foam/):已迁移的铝泡沫专题历史材料
+101 -2
View File
@@ -33,7 +33,17 @@
<div class="result-card result-card-highlight" style="cursor: pointer;" @click="selectScheme(selectedScheme?.scheme_id)">
<div class="summary-header">
<h3>推荐方案</h3>
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
<div class="tag-row">
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
<t-tag
v-if="currentAxisHint"
theme="success"
variant="light"
:title="`历史 ${currentAxisHint.sample_count} 次相似产品反馈:采纳 ${currentAxisHint.adopted_count} / 拒绝 ${currentAxisHint.rejected_count}`"
>
📚 历史经验 {{ currentAxisHint.sample_count }} 条
</t-tag>
</div>
</div>
<div class="info-list">
<div class="info-item">
@@ -126,6 +136,15 @@
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
💰 成本估算
</t-button>
<t-button
v-if="canGiveFeedback"
type="default"
size="small"
@click="openFeedbackDialog"
title="老师傅经验反馈:标记采纳 / 建议调整 / 拒绝,下一次同指纹产品分析将自动应用"
>
👍 老师傅反馈
</t-button>
</div>
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
@@ -535,6 +554,18 @@
</div>
</div>
</t-loading>
<HumanFeedbackDialog
v-model:visible="state.feedbackDialogVisible"
:task-id="(route.params.taskId as string)"
:scheme-id="selectedScheme?.scheme_id || ''"
:scheme-axis="selectedScheme?.axis || 'Z'"
:scheme-title="selectedScheme?.title || selectedScheme?.scheme_id || ''"
:fingerprint="state.hintsFingerprint"
:score-at-submit="selectedScheme?.score"
:confidence-at-submit="selectedScheme?.confidence_score"
@submitted="onFeedbackSubmitted"
/>
</div>
</template>
@@ -543,8 +574,10 @@ import { reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { apiRequest } from '@/shared/api'
import { moldinsightApi } from '@/shared/api-client'
import { handleApiError, addNotification } from '@/shared/notification'
import { formatDateTime, formatNumber } from '@/shared/utils'
import HumanFeedbackDialog from './components/HumanFeedbackDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -573,9 +606,12 @@ interface Scheme {
summary?: string
reason?: string
score?: number
score_breakdown?: { manufacturability?: number; parting_quality?: number }
confidence_score?: number
score_breakdown?: { manufacturability?: number; parting_quality?: number; human_hint_bonus?: number }
mold_structure_type?: string
offset_label?: string
axis?: string
method?: string
cavity_data?: CavityData
key_info?: KeyInfo
html_file?: string
@@ -724,6 +760,18 @@ const state = reactive({
costLoading: false,
costError: '',
costResult: null as CostEstimate | null,
// D17 Human-in-Loop:老师傅经验反馈
feedbackDialogVisible: false,
hintsFingerprint: {} as Record<string, string>,
hints: [] as Array<{
scheme_axis: string
adopted_count: number
rejected_count: number
adjust_count: number
confidence: number
weight: number
sample_count: number
}>,
})
const camSteelOptions = [
@@ -777,12 +825,63 @@ const createProductFromAnalysis = async () => {
}
}
// D17 Human-in-Loop:经验反馈
const taskId = computed<string>(() => (route.params.taskId as string) || '')
const hintsByAxis = computed<Record<string, (typeof state.hints)[number]>>(() => {
const map: Record<string, (typeof state.hints)[number]> = {}
for (const h of state.hints) {
map[h.scheme_axis] = h
}
return map
})
const currentAxisHint = computed<(typeof state.hints)[number] | undefined>(() => {
const axis = selectedScheme.value?.axis
if (!axis) return undefined
return hintsByAxis.value[axis]
})
const canGiveFeedback = computed(() => {
const u: any = appStore.user
if (!u) return false
if (u.is_superuser) return true
const roles = u.roles as Array<{ code: string }> | undefined
return !!roles?.some(r => r.code === 'process_engineer')
})
const loadExperienceHints = async () => {
if (!taskId.value) return
try {
const data = await moldinsightApi.getExperienceHints(taskId.value)
state.hints = (data.hints || []) as typeof state.hints
state.hintsFingerprint = data.fingerprint || {}
} catch (e) {
// 不阻塞主流程:拉取失败时静默退化(按钮仍可点击,新反馈走 POST 写入)
console.warn('拉取经验 hints 失败', e)
}
}
const openFeedbackDialog = () => {
if (!selectedScheme.value?.scheme_id) {
addNotification('当前方案未确定,无法反馈', 'error')
return
}
state.feedbackDialogVisible = true
}
const onFeedbackSubmitted = async () => {
addNotification('反馈已生效,正在刷新经验角标', 'success')
await loadExperienceHints()
}
onMounted(() => {
if (!appStore.user) {
router.push('/login')
return
}
loadTask()
loadExperienceHints()
})
const getPriorityText = (priority: string) => {
@@ -0,0 +1,184 @@
<template>
<t-dialog
:visible="visible"
@update:visible="(v: boolean) => emit('update:visible', v)"
header="老师傅经验反馈"
:close-on-overlay-click="true"
width="540px"
>
<div v-if="schemeTitle" class="context-block">
<div class="context-row">
<span class="context-label">方案</span>
<span class="context-value">{{ schemeTitle }}</span>
</div>
<div v-if="hasFingerprint" class="context-row">
<span class="context-label">产品指纹</span>
<span class="context-value">{{ formatFingerprint() }}</span>
</div>
<div class="context-row">
<span class="context-label">本次应用</span>
<span class="context-value hint-meta">写后即被下次同指纹分析消费(D17 闭环)</span>
</div>
</div>
<t-form label-width="80px">
<t-form-item label="反馈">
<t-radio-group v-model="form.feedback_status">
<t-radio-button value="adopted">✅ 采纳</t-radio-button>
<t-radio-button value="adjust">✏️ 建议调整</t-radio-button>
<t-radio-button value="rejected">✕ 拒绝</t-radio-button>
</t-radio-group>
</t-form-item>
<t-form-item v-if="form.feedback_status === 'adjust'" label="调整建议">
<t-textarea
v-model="form.adjust_suggestion"
placeholder="具体怎么调?(如:分型面偏上 0.5mm / 增加滑块位置 / 改水路直径)"
:maxlength="2000"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</t-form-item>
<t-form-item label="原因">
<t-textarea
v-model="form.feedback_reason"
placeholder="为什么这样判断?(可选,便于团队理解)"
:maxlength="2000"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</t-form-item>
</t-form>
<template #footer>
<t-button theme="default" @click="cancel" :disabled="submitting">取消</t-button>
<t-button theme="primary" :loading="submitting" @click="submit">提交反馈</t-button>
</template>
</t-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch, computed } from 'vue'
import { moldinsightApi } from '@/shared/api-client'
import { addNotification } from '@/shared/notification'
interface Props {
visible: boolean
taskId: string
schemeId: string
schemeAxis: string
schemeTitle: string
fingerprint: Record<string, string>
scoreAtSubmit?: number
confidenceAtSubmit?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'submitted'): void
}>()
interface FeedbackForm {
feedback_status: 'adopted' | 'adjust' | 'rejected'
feedback_reason: string
adjust_suggestion: string
}
const form = reactive<FeedbackForm>({
feedback_status: 'adopted',
feedback_reason: '',
adjust_suggestion: '',
})
const submitting = ref(false)
const hasFingerprint = computed(() => {
return Boolean(props.fingerprint && Object.keys(props.fingerprint).length > 0)
})
watch(
() => props.visible,
(v) => {
if (v) {
// 打开时重置表单
form.feedback_status = 'adopted'
form.feedback_reason = ''
form.adjust_suggestion = ''
}
}
)
function cancel() {
emit('update:visible', false)
}
function formatFingerprint(): string {
const f = props.fingerprint || {}
const items = [
f.bbox_aspect,
f.volume_bucket,
f.face_bucket,
f.material_family,
f.is_foam,
].filter(Boolean)
return items.join(' / ') || '—'
}
async function submit() {
if (!props.taskId || !props.schemeId) {
addNotification('任务或方案标识缺失', 'error')
return
}
submitting.value = true
try {
await moldinsightApi.submitExperienceFeedback(props.taskId, {
scheme_id: props.schemeId,
feedback_status: form.feedback_status,
feedback_reason: form.feedback_reason || undefined,
adjust_suggestion:
form.feedback_status === 'adjust' ? form.adjust_suggestion || undefined : undefined,
score_at_submit: typeof props.scoreAtSubmit === 'number' ? props.scoreAtSubmit : undefined,
confidence_at_submit:
typeof props.confidenceAtSubmit === 'number'
? props.confidenceAtSubmit
: undefined,
})
addNotification('反馈已提交,下次同指纹产品分析将自动应用', 'success')
emit('submitted')
emit('update:visible', false)
} catch (e: any) {
addNotification(e?.message || '反馈提交失败', 'error')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.context-block {
background: var(--bg-secondary, #f5f7fa);
padding: 12px 14px;
border-radius: 6px;
margin-bottom: 16px;
border: 1px solid var(--border-color, #e7e7e7);
}
.context-row {
display: flex;
gap: 12px;
font-size: 0.9rem;
line-height: 1.7;
}
.context-label {
color: var(--text-secondary, #888);
min-width: 70px;
}
.context-value {
color: var(--text-primary, #333);
font-weight: 500;
}
.hint-meta {
font-weight: 400;
color: var(--text-secondary, #888);
font-size: 0.85rem;
}
</style>
+43
View File
@@ -403,4 +403,47 @@ export const moldinsightApi = {
body: JSON.stringify(data),
})
},
// D17 Human-in-Loop:老师傅经验反馈(写入即消费闭环)
getExperienceHints(taskId: string) {
return apiRequest<{
task_id: string
stp_file_id: number
material_name: string
is_foam: boolean
fingerprint: Record<string, string>
hints: Array<{
scheme_axis: string
adopted_count: number
rejected_count: number
adjust_count: number
confidence: number
weight: number
sample_count: number
}>
}>(`/api/tasks/${taskId}/experience-hints`)
},
submitExperienceFeedback(
taskId: string,
data: {
scheme_id: string
feedback_status: 'adopted' | 'adjust' | 'rejected'
feedback_reason?: string
adjust_suggestion?: string
confidence_at_submit?: number
score_at_submit?: number
}
) {
return apiRequest<{
id: number
scheme_id: string
scheme_axis: string
feedback_status: string
created_at: string
}>(`/api/tasks/${taskId}/experience-feedback`, {
method: 'POST',
body: JSON.stringify(data),
})
},
}
+250 -926
View File
File diff suppressed because it is too large Load Diff
@@ -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')
+476 -1183
View File
File diff suppressed because it is too large Load Diff
+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],
)
+6 -1
View File
@@ -30,6 +30,7 @@ class MultiSchemeMoldPlanner:
is_foam_material: bool = False,
max_schemes: int = 3,
process_params: Optional[Dict[str, Any]] = None,
hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, Any]:
generator = mold_generator_registry.get_by_type("aluminum_foam" if is_foam_material else "injection")
generator.set_material(material["name"])
@@ -37,10 +38,12 @@ class MultiSchemeMoldPlanner:
analysis = generator.analyze_product_geometry(shape)
analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape)
# D17 Human-in-Loop 闭环:把老师傅经验 hints 注入候选方向生成
candidates = self.candidate_generator.generate_candidates(
analysis=analysis,
is_foam_material=is_foam_material,
max_candidates=max_schemes,
hints=hints,
)
schemes = []
@@ -62,7 +65,8 @@ class MultiSchemeMoldPlanner:
if not schemes:
raise ValueError("未能生成任何可用分模方案")
scored_schemes = self.scheme_scorer.score_schemes(schemes)[:max_schemes]
# D17:hints 透传到评分器,权重轴方向评分加成
scored_schemes = self.scheme_scorer.score_schemes(schemes, hints=hints)[:max_schemes]
export_shapes = {}
for idx, scheme in enumerate(scored_schemes, start=1):
scheme["raw_scheme_id"] = scheme.get("scheme_id")
@@ -80,6 +84,7 @@ class MultiSchemeMoldPlanner:
"global_summary": {
"scheme_count": len(scored_schemes),
"recommended_reason": best_scheme.get("summary", ""),
"applied_hints": hints or {}, # D17:给前端展示"本次应用了哪几条经验"
},
}
+4
View File
@@ -121,6 +121,9 @@ def _op_generate_cavity(payload):
plan_result 里携带的 _export_shapes(TopoDS 对象)无法跨进程,子进程直接
经 CADExporter 落盘为持久化 STEP,返回文件 manifest——与旧 _persist_step_exports
产物结构一致,主进程原样存入 export_artifacts。
D17 Human-in-Loop:payload 顶层 experience_hints 透传给 planner.generate_plan
让同指纹历史老师傅反馈影响本次分模评分。payload 普通 dict 透传,pickle 安全。
"""
parser = _cached("parser", _get_parser)
planner = _cached("planner", _get_planner)
@@ -130,6 +133,7 @@ def _op_generate_cavity(payload):
material=payload["material"],
is_foam_material=payload.get("is_foam_material", False),
process_params=payload.get("process_params"),
hints=payload.get("experience_hints") or {},
)
export_shapes = plan_result.pop("_export_shapes", {}) or {}
export_manifest = _persist_export_shapes(payload, export_shapes)
@@ -1,4 +1,4 @@
from typing import Dict, Any, List
from typing import Dict, Any, List, Optional
class PartingCandidateGenerator:
@@ -15,10 +15,31 @@ class PartingCandidateGenerator:
analysis: Dict[str, Any],
is_foam_material: bool = False,
max_candidates: int = 3,
hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])
axis_metrics = self._build_axis_metrics(bbox_dims, analysis, is_foam_material)
# D17 Human-in-Loop 闭环:老师傅采纳多的 axis 优先级加成。
# hints 结构:{axis: {"weight": 0.0-1.0, "sample_count": int, ...}};
# 由 ExperienceFeedbackService.list_hints_for_task 聚合后产出。
if hints:
for axis_metric in axis_metrics:
axis = axis_metric["axis"]
hint = hints.get(axis)
if not hint:
continue
weight = float(hint.get("weight", 0.0))
sample_count = int(hint.get("sample_count", 0))
# 上限 +20 分(weight=1.0 时);weight 仅正值,不"扣分"老算法。
axis_metric["priority_score"] = axis_metric["priority_score"] + weight * 20.0
# sample_count 足够 + weight 强信号 → method 标签升级为"经验驱动"
if sample_count >= 2 and weight >= 0.5:
axis_metric["method"] = "human_experience_primary"
axis_metric["human_hint_weight"] = weight
axis_metric["human_hint_sample_count"] = sample_count
axis_order = [item["axis"] for item in sorted(
axis_metrics,
key=lambda item: item["priority_score"],
+48 -3
View File
@@ -5,10 +5,15 @@ import re
class PartingSchemeScorer:
"""对候选分模方案打分并排序。"""
def score_schemes(self, schemes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def score_schemes(
self,
schemes: List[Dict[str, Any]],
*,
hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
scored = []
for scheme in schemes:
score_breakdown = self._score_scheme(scheme)
score_breakdown = self._score_scheme(scheme, hints=hints)
undercut_priority_bonus = self._build_undercut_priority_bonus(scheme, score_breakdown)
total_score = round(
score_breakdown["manufacturability"] * 0.25
@@ -16,6 +21,7 @@ class PartingSchemeScorer:
+ score_breakdown["parting_quality"] * 0.15
+ score_breakdown["machining_cost"] * 0.15
+ score_breakdown["risk"] * 0.10
+ score_breakdown.get("human_hint_bonus", 0.0)
+ undercut_priority_bonus,
2,
)
@@ -44,7 +50,12 @@ class PartingSchemeScorer:
scheme["title"] = "推荐方案" if rank == 1 else f"备选方案 {rank}"
return scored
def _score_scheme(self, scheme: Dict[str, Any]) -> Dict[str, float]:
def _score_scheme(
self,
scheme: Dict[str, Any],
*,
hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, float]:
cavity_data = scheme.get("cavity_data", {})
key_info = scheme.get("key_info", {})
candidate_priority = float(scheme.get("priority_score", 60.0))
@@ -125,14 +136,48 @@ class PartingSchemeScorer:
risk_base += 4.0
risk = max(35.0, risk_base)
human_hint_bonus = PartingSchemeScorer._compute_human_hint_bonus(scheme, hints)
return {
"manufacturability": round(manufacturability, 2),
"undercut_complexity": round(undercut_complexity, 2),
"parting_quality": round(parting_quality, 2),
"machining_cost": round(machining_cost, 2),
"risk": round(risk, 2),
"human_hint_bonus": human_hint_bonus,
}
@staticmethod
def _compute_human_hint_bonus(
scheme: Dict[str, Any],
hints: Optional[Dict[str, Dict[str, Any]]],
) -> float:
"""D17 Human-in-Loop:老师傅经验加权(写入即消费)。
设计要点:
- weight ∈ [0, 1] 由 history aggregation 算(adopted-rejected)/ total;仅正值
- bonus 上限 +12(与 undercut_priority_bonus 同量级),避免单条反馈过权重
- sample_count < 2 时 bonus × 0.5(信号不足折半)
- axis 解析优先级:scheme.parting.axis → scheme.axis → 默认 Z
"""
if not hints:
return 0.0
parting = scheme.get("parting", {}) if isinstance(scheme.get("parting"), dict) else {}
axis = (
parting.get("axis")
or scheme.get("axis")
or "Z"
)
hint = hints.get(axis) or {}
weight = float(hint.get("weight", 0.0))
if weight <= 0:
return 0.0
sample_count = int(hint.get("sample_count", 0))
bonus = weight * 12.0
if sample_count < 2:
bonus *= 0.5
return round(bonus, 2)
@staticmethod
def _build_undercut_priority_bonus(
scheme: Dict[str, Any],
+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()
+42 -4
View File
@@ -223,8 +223,13 @@ class ProcessingService:
stage_started = time.perf_counter()
plan_result, export_artifacts = await self._step_generate_cavity(
file_path, selected_material, is_foam_material, process_params,
task_id, timeout=timeout_seconds,
db_session,
file_path,
selected_material,
is_foam_material,
process_params,
task_id,
timeout=timeout_seconds,
)
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
# 方案 B:各方案形状的持久化 STEP 已由子进程导出并返回 manifest(export_artifacts),
@@ -529,14 +534,46 @@ class ProcessingService:
return mesh_result
async def _step_generate_cavity(
self, file_path: str, selected_material: dict, is_foam_material: bool,
process_params: Dict[str, Any], task_id: str, timeout: float = 600,
self,
db_session: AsyncSession,
file_path: str,
selected_material: dict,
is_foam_material: bool,
process_params: Dict[str, Any],
task_id: str,
timeout: float = 600,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""生成多方案分模结果(方案 B:子进程内完成分模 + 方案形状 STEP 导出)。
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
异常直接向编排层传播。返回 (plan_result, export_manifest)。
D17 Human-in-Loop 闭环:解析同指纹历史 hints(list of {scheme_axis, weight, sample_count, ...}),
装进 OCC worker payload,让子进程内的 planner/candidate_generator/scheme_scorer 加成。
hints 解析失败不阻塞主流程(logger.warning 后视为空),保证已有任务不退化。
"""
# D17:拉取同指纹老师傅经验(写入即消费)
experience_hints: List[Dict[str, Any]] = []
try:
from moldinsight.services.experience_feedback_service import (
experience_feedback_service,
)
experience_hints = await experience_feedback_service.resolve_for_process_params(
session=db_session,
task_id=task_id,
process_params=process_params or {},
)
if experience_hints:
logger.info(
f"D17 Human-in-Loop:注入 {len(experience_hints)} 条经验"
f"到 task={task_id} 的分模方案"
)
except Exception as hints_err:
logger.warning(
f"D17 hints 解析失败,回退到无 hints 模式: {hints_err}"
)
experience_hints = []
result = await self.run_occ(
"generate_cavity",
{
@@ -546,6 +583,7 @@ class ProcessingService:
"is_foam_material": is_foam_material,
"process_params": process_params,
"export_out_dir": os.path.abspath(self.cad_exporter.output_dir),
"experience_hints": experience_hints, # D17 payload 通道
},
timeout=timeout,
)
+50 -19
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()
existing_perms = {p.code: p for p in result.scalars().all()}
if existing_perms:
logger.info("权限已初始化")
return
perm_map = {}
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
new_count += 1
logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限")
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)
new_count += 1
logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色")
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):
+22
View File
@@ -164,6 +164,28 @@ async def seeded_db(async_engine):
yield session
def pytest_addoption(parser):
"""D13 部署侧契约:仅在显式 --run-lockfile-check 时启用锁文件存在性断言。"""
parser.addoption(
"--run-lockfile-check",
action="store_true",
default=False,
help="启用 D13 锁文件部署侧契约测试(CI 镜像构建 job 使用)",
)
def pytest_collection_modifyitems(config, items):
"""默认跳过 D13 部署侧契约(仓库侧单测不应被尚未落地的锁文件阻断)。"""
if config.getoption("--run-lockfile-check", default=False):
return
skip_marker = pytest.mark.skip(
reason="D13 部署侧契约:默认 skip;CI 镜像构建 job 需传入 --run-lockfile-check 启用"
)
for item in items:
if "test_lockfile_generation" in item.nodeid:
item.add_marker(skip_marker)
@pytest.fixture(scope="function")
async def client(async_engine, seeded_db):
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
+417
View File
@@ -0,0 +1,417 @@
"""D17 Human-in-Loop 闭环:算法接缝回归测试。
覆盖:
- PartingCandidateGenerator:hints 注入 axis 优先级、method 标签
- PartingSchemeScorer:score_breakdown 新增 human_hint_bonus、total_score 加成
- MultiSchemeMoldPlanner:hints 透传、global_summary.applied_hints
- processing_service:OCC payload 装配 experience_hints(OCC-gated)
注:PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 本身
不直接 import OCC(OCC shape 留 lazy 在 occ_worker),可在无 OCC 环境直接测试。
processing_service.py 通过 occ_process_pool 间接 import OCC,那两个测试 OCC-gated。
"""
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock
import pytest
# OCC 条件探测(仅 processing_service 测试需要)
try:
import OCC # noqa: F401
HAS_OCC = True
except ImportError:
HAS_OCC = False
OCC_GATED = pytest.mark.skipif(
not HAS_OCC,
reason="D17 payload 测试依赖 processing_service(含 occ_process_pool),"
"OCC 缺失时无法 import;项目硬规则 OCC-gated",
)
# ── PartingCandidateGenerator 测试 ──
def _make_analysis(dims=(80.0, 60.0, 40.0), volume=50000.0):
"""构造 PartingCandidateGenerator 期望的 analysis dict。"""
return {
"bounding_box": {
"dimensions": list(dims),
"min": [0.0, 0.0, 0.0],
"max": list(dims),
"center": [d / 2 for d in dims],
},
"volume": volume,
"inertia_matrix": [[1000.0, 0.0, 0.0], [0.0, 800.0, 0.0], [0.0, 0.0, 600.0]],
"axis_normal_stats": {"X": 35.0, "Y": 35.0, "Z": 30.0},
}
def test_parting_candidate_generator_no_hints_default():
"""hints=None 应保持原有 3 轴评分(向后兼容)。"""
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
gen = PartingCandidateGenerator()
candidates = gen.generate_candidates(
analysis=_make_analysis(),
is_foam_material=False,
max_candidates=3,
)
assert len(candidates) == 3
# 没有 human_experience_primary 标签
for c in candidates:
assert c["method"] != "human_experience_primary"
def test_parting_candidate_generator_applies_hints_axis_weight():
"""hints={X: weight=0.9, sample_count=3} → X 轴 method 标签升级、priority_score +18。"""
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
gen = PartingCandidateGenerator()
candidates_no = gen.generate_candidates(
analysis=_make_analysis(),
is_foam_material=False,
max_candidates=3,
hints=None,
)
x_no = next(c for c in candidates_no if c["axis"] == "X")
x_no_score = x_no["priority_score"]
candidates_with = gen.generate_candidates(
analysis=_make_analysis(),
is_foam_material=False,
max_candidates=3,
hints={
"X": {"weight": 0.9, "sample_count": 3, "adopted_count": 5, "rejected_count": 1},
},
)
x_with = next(c for c in candidates_with if c["axis"] == "X")
# priority_score 提升 18 分(0.9 × 20)
assert abs(x_with["priority_score"] - (x_no_score + 18.0)) < 0.01
# method 标签变为 human_experience_primary
assert x_with["method"] == "human_experience_primary"
def test_parting_candidate_generator_low_sample_count_no_method_upgrade():
"""sample_count=1(信号不足)时 method 标签不升级。"""
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
gen = PartingCandidateGenerator()
candidates = gen.generate_candidates(
analysis=_make_analysis(),
is_foam_material=False,
max_candidates=3,
hints={
"Y": {"weight": 0.8, "sample_count": 1, "adopted_count": 1, "rejected_count": 0},
},
)
y = next(c for c in candidates if c["axis"] == "Y")
# sample_count < 2 → method 不升级(但 priority_score 仍加成 16 分)
assert y["method"] != "human_experience_primary"
# ── PartingSchemeScorer 测试 ──
def _make_scheme(axis: str = "X", method: str = "geometric_primary", score: float = 60.0):
"""构造 PartingSchemeScorer 期望的 scheme dict。"""
return {
"scheme_id": f"scheme_{axis}",
"axis": axis,
"parting": {"axis": axis},
"method": method,
"priority_score": score,
"cavity_data": {
"mold_cavities": {
"cavity": {"vertex_count": 100},
"core": {"vertex_count": 100},
},
"quality_checks": {
"undercut_regions": [],
"side_actions": {
"summary": {"total_mechanism_count": 0, "complexity": "simple"},
"slider_mechanisms": [],
"lifter_mechanisms": [],
"undercut_analysis": {"total_undercut_area": 0},
},
},
"manufacturing_info": {
"estimated_mold_size": {"length": 200, "width": 200, "height": 200},
"estimated_clamping_force": "150-300 吨",
},
},
"key_info": {
"quality_considerations": {"warpage_risk": "low"},
"geometric_characteristics": {"wall_thickness_range": "1.5 - 3.0 mm"},
},
}
def test_scheme_scorer_no_hints_no_bonus_field():
"""hints=None → score_breakdown 不含 human_hint_bonus(保持默认结构)。"""
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
scorer = PartingSchemeScorer()
scored = scorer.score_schemes([_make_scheme("X")])
# hints=None 时 bonus=0,但仍写入 score_breakdown 以让前端 diff 稳定
assert "human_hint_bonus" in scored[0]["score_breakdown"]
assert scored[0]["score_breakdown"]["human_hint_bonus"] == 0.0
def test_scheme_scorer_human_hint_bonus_added():
"""hints={Y: weight=1.0, sample_count=5} → score_breakdown.human_hint_bonus == 12.0。"""
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
scorer = PartingSchemeScorer()
hints = {"Y": {"weight": 1.0, "sample_count": 5, "adopted_count": 5, "rejected_count": 0}}
# 同一方案:有 hints vs 无 hints,total_score 差应等于 human_hint_bonus
scored_with = scorer.score_schemes([_make_scheme("Y")], hints=hints)
scored_without = scorer.score_schemes([_make_scheme("Y")], hints=None)
assert scored_with[0]["score_breakdown"]["human_hint_bonus"] == 12.0
delta = scored_with[0]["score"] - scored_without[0]["score"]
assert abs(delta - 12.0) < 0.01
def test_scheme_scorer_low_sample_count_halves_bonus():
"""sample_count=1 → bonus ×0.5 = 6.0(信号不足折半)。"""
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
scorer = PartingSchemeScorer()
hints = {"Z": {"weight": 1.0, "sample_count": 1, "adopted_count": 1, "rejected_count": 0}}
scored = scorer.score_schemes([_make_scheme("Z")], hints=hints)
assert scored[0]["score_breakdown"]["human_hint_bonus"] == 6.0
def test_scheme_scorer_zero_weight_no_bonus():
"""weight=0 → bonus=0(既不加分也不扣分)。"""
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
scorer = PartingSchemeScorer()
hints = {"X": {"weight": 0.0, "sample_count": 3, "adopted_count": 0, "rejected_count": 3}}
scored = scorer.score_schemes([_make_scheme("X")], hints=hints)
assert scored[0]["score_breakdown"]["human_hint_bonus"] == 0.0
# ── MultiSchemeMoldPlanner 测试(OCC-gated:直接 import OCC)──
@OCC_GATED
def test_multi_scheme_planner_passes_hints_through(monkeypatch):
"""generate_plan(hints=...) 应透传到 candidate_generator 和 scheme_scorer。"""
from moldinsight.core import multi_scheme_planner
captured = {"candidate_hints": None, "scorer_hints": None}
class FakeGenerator:
def __init__(self):
self.calls = []
def set_material(self, *_):
pass
def apply_process_params(self, *_):
pass
def analyze_product_geometry(self, _shape):
return {
"bounding_box": {"dimensions": [80, 60, 40]},
"volume": 50000,
"inertia_matrix": [[1000, 0, 0], [0, 800, 0], [0, 0, 600]],
}
class FakePlanner:
def generate_candidates(self, **kwargs):
captured["candidate_hints"] = kwargs.get("hints")
return [
{
"scheme_id": "scheme_1",
"axis": "X",
"direction": [1, 0, 0],
"title": "推荐候选方向",
"method": "geometric_primary",
"priority_score": 80.0,
"opening_span_mm": 40.0,
"projected_area_cm2": 32.0,
"reason": "test",
},
]
def score_schemes(self, schemes, *, hints=None):
captured["scorer_hints"] = hints
for s in schemes:
s["score"] = 80.0
s["score_breakdown"] = {"human_hint_bonus": 0.0}
return schemes
planner_obj = multi_scheme_planner.MultiSchemeMoldPlanner.__new__(
multi_scheme_planner.MultiSchemeMoldPlanner
)
planner_obj.candidate_generator = FakePlanner()
planner_obj.scheme_scorer = FakePlanner()
planner_obj.candidate_generator.generate_candidates = planner_obj.candidate_generator.generate_candidates
planner_obj.scheme_scorer.score_schemes = planner_obj.scheme_scorer.score_schemes
# 用 planner_obj.candidate_generator 与 scheme_scorer 是 FakePlanner 实例,所以
# generator.generate_candidates 会调用 FakePlanner.generate_candidates —— 但因为同
# 一实例两个方法都覆盖,下面显式覆写两次:
planner_obj.candidate_generator = type("G", (), {
"generate_candidates": lambda self, **kw: (
captured.update({"candidate_hints": kw.get("hints")}) or
[{"scheme_id": "scheme_1", "axis": "X", "direction": [1,0,0],
"title": "推荐", "method": "geo", "priority_score": 80.0,
"opening_span_mm": 40.0, "projected_area_cm2": 32.0, "reason": "test"}]
)
})()
planner_obj.scheme_scorer = type("S", (), {
"score_schemes": lambda self, schemes, *, hints=None: (
captured.update({"scorer_hints": hints}) or
[{**s, "score": 80.0, "score_breakdown": {"human_hint_bonus": 0.0}} for s in schemes]
)
})()
fake_hints = {"X": {"weight": 0.9, "sample_count": 3, "adopted_count": 5, "rejected_count": 1}}
result = planner_obj.generate_plan(
shape=MagicMock(),
material={"name": "ABS"},
is_foam_material=False,
hints=fake_hints,
)
assert captured["candidate_hints"] == fake_hints, "candidate_generator 未接收 hints"
assert captured["scorer_hints"] == fake_hints, "scheme_scorer 未接收 hints"
assert result["global_summary"]["applied_hints"] == fake_hints
@OCC_GATED
def test_multi_scheme_planner_applied_hints_default_empty():
"""generate_plan 不传 hints 时 global_summary.applied_hints 为空 dict。"""
from moldinsight.core import multi_scheme_planner
planner_obj = multi_scheme_planner.MultiSchemeMoldPlanner.__new__(
multi_scheme_planner.MultiSchemeMoldPlanner
)
planner_obj.candidate_generator = type("G", (), {
"generate_candidates": lambda self, **kw: [
{"scheme_id": "scheme_1", "axis": "X", "direction": [1,0,0],
"title": "推荐", "method": "geo", "priority_score": 80.0,
"opening_span_mm": 40.0, "projected_area_cm2": 32.0, "reason": "test"}
]
})()
planner_obj.scheme_scorer = type("S", (), {
"score_schemes": lambda self, schemes, *, hints=None: (
[{**s, "score": 80.0, "score_breakdown": {"human_hint_bonus": 0.0}} for s in schemes]
)
})()
result = planner_obj.generate_plan(
shape=MagicMock(),
material={"name": "ABS"},
is_foam_material=False,
)
assert result["global_summary"]["applied_hints"] == {}
# ── processing_service payload 装配测试(OCC-gated)──
@OCC_GATED
def test_processing_service_step_generate_cavity_includes_experience_hints(monkeypatch):
"""_step_generate_cavity 应在 run_occ payload 中装入 experience_hints。"""
import asyncio
from moldinsight.services import processing_service
# Mock experience_feedback_service
fake_hints = [{"scheme_axis": "X", "weight": 0.8, "sample_count": 4,
"adopted_count": 4, "rejected_count": 0}]
fake_ef_service = MagicMock()
fake_ef_service.resolve_for_process_params = AsyncMock(return_value=fake_hints)
monkeypatch.setattr(
processing_service, "experience_feedback_service", fake_ef_service, raising=False
)
# Mock run_occ 拦截 payload
captured_payload = {}
async def fake_run_occ(self, op_name, payload, timeout):
captured_payload["op_name"] = op_name
captured_payload["payload"] = payload
return {
"plan_result": {"candidate_schemes": [], "best_scheme_id": None,
"global_summary": {"applied_hints": {}}},
"export_manifest": None,
}
monkeypatch.setattr(
processing_service.ProcessingService, "run_occ", fake_run_occ
)
# Mock cad_exporter
monkeypatch.setattr(
processing_service.ProcessingService, "__init__",
lambda self: setattr(self, "cad_exporter", MagicMock(output_dir="/tmp"))
)
svc = processing_service.ProcessingService()
svc.cad_exporter = MagicMock(output_dir="/tmp")
async def run():
await svc._step_generate_cavity(
db_session=MagicMock(),
file_path="/tmp/x.stp",
selected_material={"name": "ABS"},
is_foam_material=False,
process_params={"material": "ABS", "draft_angle": 2.0,
"shrinkage_rate": 0.5, "parting_precision": 0.1,
"cavity_match": 95},
task_id="task-test-1",
timeout=60,
)
asyncio.run(run())
assert captured_payload["payload"]["experience_hints"] == fake_hints
@OCC_GATED
def test_processing_service_step_generate_cavity_empty_hints_on_error(monkeypatch):
"""experience_feedback_service 抛异常时 hints 应回退到空 list(不阻塞主流程)。"""
import asyncio
from moldinsight.services import processing_service
fake_ef_service = MagicMock()
fake_ef_service.resolve_for_process_params = AsyncMock(
side_effect=RuntimeError("DB down")
)
monkeypatch.setattr(
processing_service, "experience_feedback_service", fake_ef_service, raising=False
)
captured_payload = {}
async def fake_run_occ(self, op_name, payload, timeout):
captured_payload["payload"] = payload
return {
"plan_result": {"candidate_schemes": [], "best_scheme_id": None,
"global_summary": {"applied_hints": {}}},
"export_manifest": None,
}
monkeypatch.setattr(
processing_service.ProcessingService, "run_occ", fake_run_occ
)
svc = processing_service.ProcessingService()
svc.cad_exporter = MagicMock(output_dir="/tmp")
async def run():
await svc._step_generate_cavity(
db_session=MagicMock(),
file_path="/tmp/x.stp",
selected_material={"name": "ABS"},
is_foam_material=False,
process_params={"material": "ABS"},
task_id="task-test-1",
timeout=60,
)
asyncio.run(run())
# 抛异常时回退到空 list,主流程继续
assert captured_payload["payload"]["experience_hints"] == []
@@ -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
+105
View File
@@ -0,0 +1,105 @@
"""D13:锁文件生成契约(部署侧硬性要求)
锁文件 deploy/requirements-{base,moldinsight}.lock.txt 的存在性 + 体积下限
是部署侧硬性要求:
- 锁文件必须在 moldinsight conda 环境构建成功后落盘(见 deploy/generate_lockfiles.sh/.bat)
- 锁文件必须以 git 跟踪方式提交,CI / 离线构建 / 生产复现部署才能直接 `pip install -r`
- 若 lock.txt 缺失或异常空(仅镜像元数据 < 5 行),说明构建流程未走 D13 流程
CI 门禁建议:
- 仓库侧默认 pytest(`pytest tests/ -q`)**不**强制这些断言——锁文件属"部署侧产物",
首次构建未完成时不应阻塞日常单测
- 部署侧 / CI 镜像构建 job 用 `--run-lockfile-check` 显式开启本套件(见 conftest.py)
"""
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
DEPLOY_DIR = REPO_ROOT / "deploy"
LOCK_FILES = [
DEPLOY_DIR / "requirements-base.lock.txt",
DEPLOY_DIR / "requirements-moldinsight.lock.txt",
]
GENERATOR_SCRIPTS = [
DEPLOY_DIR / "generate_lockfiles.sh",
DEPLOY_DIR / "generate_lockfiles.bat",
]
def pytest_collection_modifyitems(config, items):
"""仅在显式传入 --run-lockfile-check 时启用 D13 部署侧契约。"""
if not config.getoption("--run-lockfile-check", default=False):
skip_marker = pytest.mark.skip(
reason="D13 部署侧契约:默认 skip;CI 镜像构建 job 需传入 --run-lockfile-check 启用"
)
for item in items:
if "test_lockfile_generation" in item.nodeid:
item.add_marker(skip_marker)
def pytest_addoption(parser):
parser.addoption(
"--run-lockfile-check",
action="store_true",
default=False,
help="启用 D13 锁文件部署侧契约测试(CI 镜像构建 job 使用)",
)
def pytest_collection_modifyitems(config, items):
"""仅在显式传入 --run-lockfile-check 时启用 D13 部署侧契约。
说明:本钩子保留作为冗余保护(conftest.py 已注册同名钩子),
即便测试单独跑 pytest tests/test_lockfile_generation.py 也能正确跳过。
"""
if not config.getoption("--run-lockfile-check", default=False):
skip_marker = pytest.mark.skip(
reason="D13 部署侧契约:默认 skip;CI 镜像构建 job 需传入 --run-lockfile-check 启用"
)
for item in items:
item.add_marker(skip_marker)
def pytest_addoption(parser):
parser.addoption(
"--run-lockfile-check",
action="store_true",
default=False,
help="启用 D13 锁文件部署侧契约测试(CI 镜像构建 job 使用)",
)
@pytest.mark.parametrize("lock_path", LOCK_FILES)
def test_lockfile_exists_and_is_substantive(lock_path):
"""锁文件必须存在且非空(≥5 行 pip freeze 产物),否则部署侧契约缺失。"""
assert lock_path.exists(), (
f"缺少锁文件 {lock_path.relative_to(REPO_ROOT)};"
f"请在 moldinsight conda 环境执行 deploy/generate_lockfiles.sh/.bat 后提交"
)
line_count = sum(1 for _ in lock_path.open(encoding="utf-8") if _.strip())
assert line_count >= 5, (
f"锁文件 {lock_path.relative_to(REPO_ROOT)} 体积异常(仅 {line_count} 行非空行),"
"可能是构建流程未走通,请重新生成"
)
@pytest.mark.parametrize("script_path", GENERATOR_SCRIPTS)
def test_lockfile_generator_script_present(script_path):
"""锁文件生成脚本必须随仓库分发,否则新机器无法落锁。"""
assert script_path.exists(), (
f"缺少生成脚本 {script_path.relative_to(REPO_ROOT)};"
"D13 流程入口文件缺失"
)
def test_lockfile_dockerfile_comment_points_to_generator():
"""Dockerfile.moldinsight 必须明确指向锁文件生成脚本。"""
dockerfile = (DEPLOY_DIR / "Dockerfile.moldinsight").read_text(encoding="utf-8")
assert "generate_lockfiles" in dockerfile, (
"Dockerfile.moldinsight 应在注释中指向 deploy/generate_lockfiles.sh 以引导锁文件生成流程"
)
+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