Compare commits
5 Commits
64dc85bd14
...
6baa6b0d0a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6baa6b0d0a | |||
| 483f158424 | |||
| 505f3591ab | |||
| 2c9ba9d6b3 | |||
| 79441a8a87 |
@@ -28,6 +28,7 @@
|
|||||||
- **单数据库是刻意设计**:moldinsight 与 inventory 共享同一 PostgreSQL(如 `STPFile.product_id -> Product.id` 桥接),不拆库。
|
- **单数据库是刻意设计**:moldinsight 与 inventory 共享同一 PostgreSQL(如 `STPFile.product_id -> Product.id` 桥接),不拆库。
|
||||||
- **接口变更三件套**:优先用 Pydantic 请求模型(少用手写 `request.json()` 解析)→ 重新导出根目录 `openapi.json` → 前端 `npm run gen:api` 重新生成类型。三步缺一即契约漂移。
|
- **接口变更三件套**:优先用 Pydantic 请求模型(少用手写 `request.json()` 解析)→ 重新导出根目录 `openapi.json` → 前端 `npm run gen:api` 重新生成类型。三步缺一即契约漂移。
|
||||||
- **历史材料统一进 [docs/archive/](docs/archive/README.md)**,不与当前权威文档混放。
|
- **历史材料统一进 [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/弱口令默认值。
|
- **配置只走 `.env`**(参照 [.env.example](.env.example) 全键说明):`DB_*`、`SECRET_KEY` 等关键项不设代码兜底(惰性校验,缺失即报),不在代码里给 localhost/弱口令默认值。
|
||||||
|
|
||||||
## 3. 代码地图
|
## 3. 代码地图
|
||||||
@@ -120,7 +121,7 @@ frontend/ # Vue 3 独立工程:src/modules 按域组织
|
|||||||
migrations/ # 数据库迁移
|
migrations/ # 数据库迁移
|
||||||
scripts/ # 一次性迁移与工具脚本(migrations/ 数据迁移、db/ 索引与审计 SQL、tools/ 检查工具),非运行时代码
|
scripts/ # 一次性迁移与工具脚本(migrations/ 数据迁移、db/ 索引与审计 SQL、tools/ 检查工具),非运行时代码
|
||||||
tests/ # pytest:sqlite+aiosqlite 临时库;pythonocc 缺失时 OCC 契约测试自动 skip
|
tests/ # pytest:sqlite+aiosqlite 临时库;pythonocc 缺失时 OCC 契约测试自动 skip
|
||||||
deploy/ # Dockerfile.* / nginx / build 脚本
|
deploy/ # Dockerfile.* / nginx / build 脚本 / generate_lockfiles.{sh,bat}(D13 锁文件生成入口)
|
||||||
docs/ # 权威文档(本文件 §5 导航)
|
docs/ # 权威文档(本文件 §5 导航)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
# conda 运行时作为最终镜像的执行环境,自带全部动态库。
|
# conda 运行时作为最终镜像的执行环境,自带全部动态库。
|
||||||
FROM continuumio/miniconda3:24.7.1-0
|
FROM continuumio/miniconda3:24.7.1-0
|
||||||
|
|
||||||
# 锁定几何栈核心版本;pip 侧全量版本锁待首次镜像构建成功后由
|
# 锁定几何栈核心版本;pip 侧全量版本锁由
|
||||||
# `pip freeze > deploy/requirements-moldinsight.lock.txt` 生成(D13 遗留项)
|
# `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 \
|
RUN conda create -n moldinsight -c conda-forge -y \
|
||||||
python=3.12 \
|
python=3.12 \
|
||||||
pythonocc-core=7.9.0 \
|
pythonocc-core=7.9.0 \
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 中的实际版本(或保留 >=,按团队策略)"
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
| 加工 | `/api/design-cam`、`/api/check-collision`、`/api/optimize-toolpath`、`/api/design-electrodes`、`/api/simulate-machining` | machining_router.py |
|
| 加工 | `/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/export-mold`、`/api/export-download/{filepath}`、`/api/export-recommendations` | export_router.py |
|
||||||
| 铝价(模拟数据) | `/api/aluminum-price/current`、`/api/aluminum-price/history` | aluminum_price_routes.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 |
|
| 健康检查 | `/api/health`(有路由装载失败时 `status: degraded` 并列出失败清单;`pythonocc` 为真实探测) | health_router.py |
|
||||||
| 调试(仅 DEBUG) | `/api/debug/tasks` | debug_router.py |
|
| 调试(仅 DEBUG) | `/api/debug/tasks` | debug_router.py |
|
||||||
|
|
||||||
|
|||||||
@@ -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. 专题文档与主骨架的关系
|
## 7. 专题文档与主骨架的关系
|
||||||
|
|||||||
+15
-1
@@ -26,10 +26,24 @@
|
|||||||
## 2. 安装与环境
|
## 2. 安装与环境
|
||||||
|
|
||||||
- 后端依赖:`pip install -r requirements.txt`。
|
- 后端依赖:`pip install -r requirements.txt`。
|
||||||
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
|
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold` 或 `moldinsight`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
|
||||||
- 前端:`cd frontend && npm install`。
|
- 前端:`cd frontend && npm install`。
|
||||||
- 数据库迁移:`migrations/`(`alembic.ini` 在仓库根;2026-09-16 由 `alembic/` 改名——原目录名与 alembic 包重名,应用内 import 会被遮蔽导致启动期迁移静默失败);数据修复类一次性脚本在 `scripts/migrations/` 与 `scripts/db/`,**不是运行时代码**,勿在服务内引用。
|
- 数据库迁移:`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. 本地启动
|
## 3. 本地启动
|
||||||
|
|
||||||
后端三入口(均含 sys.path 修正,可从仓库根直接跑):
|
后端三入口(均含 sys.path 修正,可从仓库根直接跑):
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
|||||||
重点方向:
|
重点方向:
|
||||||
|
|
||||||
- ~~`advanced_router` 拆分与请求模型规范化~~(2026-09-17 批次 3 完成)
|
- ~~`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 依赖场景下的契约测试/集成测试继续补齐
|
- OCC 依赖场景下的契约测试/集成测试继续补齐
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,20 @@
|
|||||||
|
|
||||||
> 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。
|
> 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。
|
||||||
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。
|
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [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(**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 仪表盘聚合服务下沉完成: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**。)
|
> 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
@@ -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 安全与权限
|
### 2.1 安全与权限
|
||||||
- debug/history 路由补鉴权
|
- debug / history 路由补鉴权;任务访问控制收紧;无主数据不再默认放行
|
||||||
- 任务访问控制收紧
|
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5 → §3 D5)
|
||||||
- 无主数据不再默认放行
|
- bcrypt 超 72 字节显式拒绝 + 截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失明确报错
|
||||||
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5,2026-09-16 清偿,见 D5 条目)
|
|
||||||
- bcrypt 创建口令超 72 字节显式拒绝、验证侧截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失时明确报错,代码侧弱默认移除(D14 部分,2026-09-16)
|
|
||||||
|
|
||||||
### 2.2 静默失败与可用性
|
### 2.2 静默失败与可用性
|
||||||
- `detect-undercuts` 改为基于真实 shape 分析
|
- `detect-undercuts` 改为基于真实 shape 分析
|
||||||
- OCC 超时后重建 executor,避免全队列永久堵死
|
- OCC 超时后重建 executor(短期)→ D10 方案 B 进程化彻底替换
|
||||||
- 后台任务统一分派,补强引用与并发控制
|
- 后台任务统一分派,补强引用与并发控制
|
||||||
|
|
||||||
### 2.3 状态存储与缓存
|
### 2.3 状态存储与缓存
|
||||||
- Redis 任务状态改为 Hash 字段级更新,兼容旧格式
|
- Redis 任务状态改为 Hash 字段级更新,兼容旧格式
|
||||||
- 完成态任务视图增加缓存
|
- 内存回退彻底删除,PG 为任务状态单一事实源(原 D7)
|
||||||
- 导出缓存与持久化链路收口,支持重启后再导出
|
- 完成态任务视图缓存;导出缓存与持久化链路收口
|
||||||
|
|
||||||
### 2.4 架构与代码清理
|
### 2.4 架构与代码清理
|
||||||
- 删除旧单体入口与死代码
|
- 删除旧单体入口与死代码(`db_manager.create_tables` / `log_user_activity` / `CADExporter.export_mold_results` / `getAluminumPrice` 等)
|
||||||
- 设置惰性配置校验,提升可测试性
|
- 惰性配置校验,提升可测试性
|
||||||
- Generator 公共接口提取完成,补充契约测试
|
- Generator 公共接口提取 + 契约测试
|
||||||
|
- 共享 ORM 按模块拆分,跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1)
|
||||||
### 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)
|
|
||||||
|
|
||||||
详细历史过程保留在原始技术债文档中,后续将转入归档。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,24 +60,19 @@
|
|||||||
|
|
||||||
~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、OpenAPI 可读性差、参数校验不统一。
|
~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、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 后续)
|
### D3. shared/platform 边界仍需继续收敛 —— 主体已清偿(2026-09-17 批次 4 + 2026-09-18 后续)
|
||||||
|
|
||||||
已完成部分:
|
已完成部分(修复文件清单见 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md) 批次 4 / 后续专项):
|
||||||
- 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 §2.8 与 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1)
|
- 共享 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) 锁定
|
- 旧 `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)
|
- **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 语义注释口径)
|
优先级:**P3**(仅剩 identity/platform 语义注释口径)
|
||||||
|
|
||||||
### D4. 文档现状 / 规划 / 历史混放
|
### D4. 文档现状 / 规划 / 历史混放 —— 已清偿(2026-09-22)
|
||||||
|
|
||||||
现状:
|
修复内容(保留编号以维持引用稳定):
|
||||||
- 文档存在部署说明重叠、计划/总结/权威文档混放
|
- 已建立 `STATUS / ARCHITECTURE / ROADMAP / TECH_DEBT / DEPLOYMENT` 主骨架,每类信息单一归属;历史材料归档至 `docs/archive/`
|
||||||
- README 承担过多职责
|
- 本文档 §2 由"按批次回顾"精简为"按主题摘要",修复文件清单 / 迁移号 / 回归测试 / 测试基线等详细流水账整体迁入 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md)(避免与 §3 重复膨胀)
|
||||||
|
- §3 中对历史批次的引用(如 D3 → §2.8)改为 archive 指针;D2 等已清偿项补齐时间戳
|
||||||
|
|
||||||
影响:
|
~~原现状 / 影响~~:TECH_DEBT §2 与 §3 内容重复膨胀,文档目录结构清晰度受新成员评估影响。
|
||||||
- 新成员难以判断“哪篇才是当前有效说法”
|
|
||||||
- 状态、部署、规划容易发生漂移
|
|
||||||
|
|
||||||
建议:
|
|
||||||
- 建立 `STATUS / ARCHITECTURE / ROADMAP / DEPLOYMENT` 主骨架
|
|
||||||
- 历史材料迁入 `docs/archive/`
|
|
||||||
|
|
||||||
优先级:**P1**
|
|
||||||
|
|
||||||
### D5. `/api/status/{task_id}` 未鉴权(安全缺口)—— 已清偿(2026-09-16,批次 0)
|
### D5. `/api/status/{task_id}` 未鉴权(安全缺口)—— 已清偿(2026-09-16,批次 0)
|
||||||
|
|
||||||
@@ -222,13 +184,18 @@
|
|||||||
- 语义保持:仅切换 Pydantic v2 配置语法 + UTC 时区语义,字段 / OpenAPI / JWT 行为零变化
|
- 语义保持:仅切换 Pydantic v2 配置语法 + UTC 时区语义,字段 / OpenAPI / JWT 行为零变化
|
||||||
- 验证:`pytest tests/ -q` **126 passed, 4 skipped**,deprecation warning 全部清零
|
- 验证:`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 可用性随下次镜像构建验证)
|
- ~~从 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)
|
### D14. 配置漂移:弱默认 / 死配置 / 重复解析 —— 已清偿(2026-09-16 ~ 09-17,批次 1 / 3)
|
||||||
|
|
||||||
@@ -246,6 +213,40 @@
|
|||||||
|
|
||||||
~~原现状 / 影响~~:`vue-tsc -b`(`npm run build` 的类型检查步)因既有 TS6133 失败,前端无法出生产包(与批次 3 改动无关的既有问题)。
|
~~原现状 / 影响~~:`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. 当前推荐治理顺序
|
## 4. 当前推荐治理顺序
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(端口补充)三层。)
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
- [MOLD_ERP_ANALYSIS_REPORT.md](MOLD_ERP_ANALYSIS_REPORT.md)
|
- [MOLD_ERP_ANALYSIS_REPORT.md](MOLD_ERP_ANALYSIS_REPORT.md)
|
||||||
- [ZERO_FINISHED_INVENTORY_CERTIFICATE.md](ZERO_FINISHED_INVENTORY_CERTIFICATE.md)
|
- [ZERO_FINISHED_INVENTORY_CERTIFICATE.md](ZERO_FINISHED_INVENTORY_CERTIFICATE.md)
|
||||||
- [CONFLUENCE_ARCHIVE_STRUCTURE.md](CONFLUENCE_ARCHIVE_STRUCTURE.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/ai/](topics/ai/):已迁移的 AI 相关专题历史材料
|
||||||
- [topics/performance/](topics/performance/):已迁移的性能专题历史材料
|
- [topics/performance/](topics/performance/):已迁移的性能专题历史材料
|
||||||
- [topics/aluminum-foam/](topics/aluminum-foam/):已迁移的铝泡沫专题历史材料
|
- [topics/aluminum-foam/](topics/aluminum-foam/):已迁移的铝泡沫专题历史材料
|
||||||
|
|||||||
@@ -33,7 +33,17 @@
|
|||||||
<div class="result-card result-card-highlight" style="cursor: pointer;" @click="selectScheme(selectedScheme?.scheme_id)">
|
<div class="result-card result-card-highlight" style="cursor: pointer;" @click="selectScheme(selectedScheme?.scheme_id)">
|
||||||
<div class="summary-header">
|
<div class="summary-header">
|
||||||
<h3>推荐方案</h3>
|
<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>
|
||||||
<div class="info-list">
|
<div class="info-list">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
@@ -126,6 +136,15 @@
|
|||||||
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
|
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
|
||||||
💰 成本估算
|
💰 成本估算
|
||||||
</t-button>
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-if="canGiveFeedback"
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
@click="openFeedbackDialog"
|
||||||
|
title="老师傅经验反馈:标记采纳 / 建议调整 / 拒绝,下一次同指纹产品分析将自动应用"
|
||||||
|
>
|
||||||
|
👍 老师傅反馈
|
||||||
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
|
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
|
||||||
@@ -535,6 +554,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t-loading>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -543,8 +574,10 @@ import { reactive, computed, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { apiRequest } from '@/shared/api'
|
import { apiRequest } from '@/shared/api'
|
||||||
|
import { moldinsightApi } from '@/shared/api-client'
|
||||||
import { handleApiError, addNotification } from '@/shared/notification'
|
import { handleApiError, addNotification } from '@/shared/notification'
|
||||||
import { formatDateTime, formatNumber } from '@/shared/utils'
|
import { formatDateTime, formatNumber } from '@/shared/utils'
|
||||||
|
import HumanFeedbackDialog from './components/HumanFeedbackDialog.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -573,9 +606,12 @@ interface Scheme {
|
|||||||
summary?: string
|
summary?: string
|
||||||
reason?: string
|
reason?: string
|
||||||
score?: number
|
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
|
mold_structure_type?: string
|
||||||
offset_label?: string
|
offset_label?: string
|
||||||
|
axis?: string
|
||||||
|
method?: string
|
||||||
cavity_data?: CavityData
|
cavity_data?: CavityData
|
||||||
key_info?: KeyInfo
|
key_info?: KeyInfo
|
||||||
html_file?: string
|
html_file?: string
|
||||||
@@ -724,6 +760,18 @@ const state = reactive({
|
|||||||
costLoading: false,
|
costLoading: false,
|
||||||
costError: '',
|
costError: '',
|
||||||
costResult: null as CostEstimate | null,
|
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 = [
|
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(() => {
|
onMounted(() => {
|
||||||
if (!appStore.user) {
|
if (!appStore.user) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadTask()
|
loadTask()
|
||||||
|
loadExperienceHints()
|
||||||
})
|
})
|
||||||
|
|
||||||
const getPriorityText = (priority: string) => {
|
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>
|
||||||
@@ -403,4 +403,47 @@ export const moldinsightApi = {
|
|||||||
body: JSON.stringify(data),
|
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
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
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ ROUTE_MODULES = [
|
|||||||
("加工", "moldinsight.api.machining_router", False),
|
("加工", "moldinsight.api.machining_router", False),
|
||||||
("导出", "moldinsight.api.export_router", False),
|
("导出", "moldinsight.api.export_router", False),
|
||||||
("铝价", "moldinsight.api.aluminum_price_routes", False),
|
("铝价", "moldinsight.api.aluminum_price_routes", False),
|
||||||
|
("老师傅经验反馈", "moldinsight.api.experience_feedback_router", False), # D17 Human-in-Loop
|
||||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||||
("调试", "moldinsight.api.debug_router", True),
|
("调试", "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],
|
||||||
|
)
|
||||||
@@ -30,6 +30,7 @@ class MultiSchemeMoldPlanner:
|
|||||||
is_foam_material: bool = False,
|
is_foam_material: bool = False,
|
||||||
max_schemes: int = 3,
|
max_schemes: int = 3,
|
||||||
process_params: Optional[Dict[str, Any]] = None,
|
process_params: Optional[Dict[str, Any]] = None,
|
||||||
|
hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
generator = mold_generator_registry.get_by_type("aluminum_foam" if is_foam_material else "injection")
|
generator = mold_generator_registry.get_by_type("aluminum_foam" if is_foam_material else "injection")
|
||||||
generator.set_material(material["name"])
|
generator.set_material(material["name"])
|
||||||
@@ -37,10 +38,12 @@ class MultiSchemeMoldPlanner:
|
|||||||
|
|
||||||
analysis = generator.analyze_product_geometry(shape)
|
analysis = generator.analyze_product_geometry(shape)
|
||||||
analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape)
|
analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape)
|
||||||
|
# D17 Human-in-Loop 闭环:把老师傅经验 hints 注入候选方向生成
|
||||||
candidates = self.candidate_generator.generate_candidates(
|
candidates = self.candidate_generator.generate_candidates(
|
||||||
analysis=analysis,
|
analysis=analysis,
|
||||||
is_foam_material=is_foam_material,
|
is_foam_material=is_foam_material,
|
||||||
max_candidates=max_schemes,
|
max_candidates=max_schemes,
|
||||||
|
hints=hints,
|
||||||
)
|
)
|
||||||
|
|
||||||
schemes = []
|
schemes = []
|
||||||
@@ -62,7 +65,8 @@ class MultiSchemeMoldPlanner:
|
|||||||
if not schemes:
|
if not schemes:
|
||||||
raise ValueError("未能生成任何可用分模方案")
|
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 = {}
|
export_shapes = {}
|
||||||
for idx, scheme in enumerate(scored_schemes, start=1):
|
for idx, scheme in enumerate(scored_schemes, start=1):
|
||||||
scheme["raw_scheme_id"] = scheme.get("scheme_id")
|
scheme["raw_scheme_id"] = scheme.get("scheme_id")
|
||||||
@@ -80,6 +84,7 @@ class MultiSchemeMoldPlanner:
|
|||||||
"global_summary": {
|
"global_summary": {
|
||||||
"scheme_count": len(scored_schemes),
|
"scheme_count": len(scored_schemes),
|
||||||
"recommended_reason": best_scheme.get("summary", ""),
|
"recommended_reason": best_scheme.get("summary", ""),
|
||||||
|
"applied_hints": hints or {}, # D17:给前端展示"本次应用了哪几条经验"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ def _op_generate_cavity(payload):
|
|||||||
plan_result 里携带的 _export_shapes(TopoDS 对象)无法跨进程,子进程直接
|
plan_result 里携带的 _export_shapes(TopoDS 对象)无法跨进程,子进程直接
|
||||||
经 CADExporter 落盘为持久化 STEP,返回文件 manifest——与旧 _persist_step_exports
|
经 CADExporter 落盘为持久化 STEP,返回文件 manifest——与旧 _persist_step_exports
|
||||||
产物结构一致,主进程原样存入 export_artifacts。
|
产物结构一致,主进程原样存入 export_artifacts。
|
||||||
|
|
||||||
|
D17 Human-in-Loop:payload 顶层 experience_hints 透传给 planner.generate_plan
|
||||||
|
让同指纹历史老师傅反馈影响本次分模评分。payload 普通 dict 透传,pickle 安全。
|
||||||
"""
|
"""
|
||||||
parser = _cached("parser", _get_parser)
|
parser = _cached("parser", _get_parser)
|
||||||
planner = _cached("planner", _get_planner)
|
planner = _cached("planner", _get_planner)
|
||||||
@@ -130,6 +133,7 @@ def _op_generate_cavity(payload):
|
|||||||
material=payload["material"],
|
material=payload["material"],
|
||||||
is_foam_material=payload.get("is_foam_material", False),
|
is_foam_material=payload.get("is_foam_material", False),
|
||||||
process_params=payload.get("process_params"),
|
process_params=payload.get("process_params"),
|
||||||
|
hints=payload.get("experience_hints") or {},
|
||||||
)
|
)
|
||||||
export_shapes = plan_result.pop("_export_shapes", {}) or {}
|
export_shapes = plan_result.pop("_export_shapes", {}) or {}
|
||||||
export_manifest = _persist_export_shapes(payload, export_shapes)
|
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:
|
class PartingCandidateGenerator:
|
||||||
@@ -15,10 +15,31 @@ class PartingCandidateGenerator:
|
|||||||
analysis: Dict[str, Any],
|
analysis: Dict[str, Any],
|
||||||
is_foam_material: bool = False,
|
is_foam_material: bool = False,
|
||||||
max_candidates: int = 3,
|
max_candidates: int = 3,
|
||||||
|
hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])
|
bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])
|
||||||
|
|
||||||
axis_metrics = self._build_axis_metrics(bbox_dims, analysis, is_foam_material)
|
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_order = [item["axis"] for item in sorted(
|
||||||
axis_metrics,
|
axis_metrics,
|
||||||
key=lambda item: item["priority_score"],
|
key=lambda item: item["priority_score"],
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import re
|
|||||||
class PartingSchemeScorer:
|
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 = []
|
scored = []
|
||||||
for scheme in schemes:
|
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)
|
undercut_priority_bonus = self._build_undercut_priority_bonus(scheme, score_breakdown)
|
||||||
total_score = round(
|
total_score = round(
|
||||||
score_breakdown["manufacturability"] * 0.25
|
score_breakdown["manufacturability"] * 0.25
|
||||||
@@ -16,6 +21,7 @@ class PartingSchemeScorer:
|
|||||||
+ score_breakdown["parting_quality"] * 0.15
|
+ score_breakdown["parting_quality"] * 0.15
|
||||||
+ score_breakdown["machining_cost"] * 0.15
|
+ score_breakdown["machining_cost"] * 0.15
|
||||||
+ score_breakdown["risk"] * 0.10
|
+ score_breakdown["risk"] * 0.10
|
||||||
|
+ score_breakdown.get("human_hint_bonus", 0.0)
|
||||||
+ undercut_priority_bonus,
|
+ undercut_priority_bonus,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
@@ -44,7 +50,12 @@ class PartingSchemeScorer:
|
|||||||
scheme["title"] = "推荐方案" if rank == 1 else f"备选方案 {rank}"
|
scheme["title"] = "推荐方案" if rank == 1 else f"备选方案 {rank}"
|
||||||
return scored
|
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", {})
|
cavity_data = scheme.get("cavity_data", {})
|
||||||
key_info = scheme.get("key_info", {})
|
key_info = scheme.get("key_info", {})
|
||||||
candidate_priority = float(scheme.get("priority_score", 60.0))
|
candidate_priority = float(scheme.get("priority_score", 60.0))
|
||||||
@@ -125,14 +136,48 @@ class PartingSchemeScorer:
|
|||||||
risk_base += 4.0
|
risk_base += 4.0
|
||||||
risk = max(35.0, risk_base)
|
risk = max(35.0, risk_base)
|
||||||
|
|
||||||
|
human_hint_bonus = PartingSchemeScorer._compute_human_hint_bonus(scheme, hints)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"manufacturability": round(manufacturability, 2),
|
"manufacturability": round(manufacturability, 2),
|
||||||
"undercut_complexity": round(undercut_complexity, 2),
|
"undercut_complexity": round(undercut_complexity, 2),
|
||||||
"parting_quality": round(parting_quality, 2),
|
"parting_quality": round(parting_quality, 2),
|
||||||
"machining_cost": round(machining_cost, 2),
|
"machining_cost": round(machining_cost, 2),
|
||||||
"risk": round(risk, 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
|
@staticmethod
|
||||||
def _build_undercut_priority_bonus(
|
def _build_undercut_priority_bonus(
|
||||||
scheme: Dict[str, Any],
|
scheme: Dict[str, Any],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from moldinsight.models.stp_analysis import (
|
|||||||
DesignRecommendation,
|
DesignRecommendation,
|
||||||
AnalysisMetrics,
|
AnalysisMetrics,
|
||||||
)
|
)
|
||||||
|
from moldinsight.models.experience_feedback import ExperienceFeedback
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"STPFile",
|
"STPFile",
|
||||||
@@ -25,4 +26,5 @@ __all__ = [
|
|||||||
"FeatureDetection",
|
"FeatureDetection",
|
||||||
"DesignRecommendation",
|
"DesignRecommendation",
|
||||||
"AnalysisMetrics",
|
"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()
|
||||||
@@ -223,8 +223,13 @@ class ProcessingService:
|
|||||||
|
|
||||||
stage_started = time.perf_counter()
|
stage_started = time.perf_counter()
|
||||||
plan_result, export_artifacts = await self._step_generate_cavity(
|
plan_result, export_artifacts = await self._step_generate_cavity(
|
||||||
file_path, selected_material, is_foam_material, process_params,
|
db_session,
|
||||||
task_id, timeout=timeout_seconds,
|
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)
|
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
|
||||||
# 方案 B:各方案形状的持久化 STEP 已由子进程导出并返回 manifest(export_artifacts),
|
# 方案 B:各方案形状的持久化 STEP 已由子进程导出并返回 manifest(export_artifacts),
|
||||||
@@ -529,14 +534,46 @@ class ProcessingService:
|
|||||||
return mesh_result
|
return mesh_result
|
||||||
|
|
||||||
async def _step_generate_cavity(
|
async def _step_generate_cavity(
|
||||||
self, file_path: str, selected_material: dict, is_foam_material: bool,
|
self,
|
||||||
process_params: Dict[str, Any], task_id: str, timeout: float = 600,
|
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]]]:
|
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||||
"""生成多方案分模结果(方案 B:子进程内完成分模 + 方案形状 STEP 导出)。
|
"""生成多方案分模结果(方案 B:子进程内完成分模 + 方案形状 STEP 导出)。
|
||||||
|
|
||||||
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
|
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
|
||||||
异常直接向编排层传播。返回 (plan_result, export_manifest)。
|
异常直接向编排层传播。返回 (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(
|
result = await self.run_occ(
|
||||||
"generate_cavity",
|
"generate_cavity",
|
||||||
{
|
{
|
||||||
@@ -546,6 +583,7 @@ class ProcessingService:
|
|||||||
"is_foam_material": is_foam_material,
|
"is_foam_material": is_foam_material,
|
||||||
"process_params": process_params,
|
"process_params": process_params,
|
||||||
"export_out_dir": os.path.abspath(self.cad_exporter.output_dir),
|
"export_out_dir": os.path.abspath(self.cad_exporter.output_dir),
|
||||||
|
"experience_hints": experience_hints, # D17 payload 通道
|
||||||
},
|
},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,55 +65,86 @@ DEFAULT_PERMISSIONS = [
|
|||||||
{"code": "view_users", "name": "查看用户", "module": "admin"},
|
{"code": "view_users", "name": "查看用户", "module": "admin"},
|
||||||
{"code": "manage_users", "name": "管理用户", "module": "admin"},
|
{"code": "manage_users", "name": "管理用户", "module": "admin"},
|
||||||
{"code": "manage_roles", "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 = [
|
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": "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"]},
|
{"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):
|
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))
|
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:
|
perm_map = {p.code: p.id for p in existing_perms.values()}
|
||||||
logger.info("权限已初始化")
|
new_count = 0
|
||||||
return
|
|
||||||
|
|
||||||
perm_map = {}
|
|
||||||
for perm_data in DEFAULT_PERMISSIONS:
|
for perm_data in DEFAULT_PERMISSIONS:
|
||||||
|
if perm_data["code"] in existing_perms:
|
||||||
|
continue
|
||||||
perm = Permission(**perm_data)
|
perm = Permission(**perm_data)
|
||||||
session.add(perm)
|
session.add(perm)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
perm_map[perm.code] = perm.id
|
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
|
return perm_map
|
||||||
|
|
||||||
|
|
||||||
async def init_roles(session, 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))
|
result = await session.execute(select(Role))
|
||||||
existing_roles = result.scalars().all()
|
existing_roles = {r.code: r for r in result.scalars().all()}
|
||||||
|
|
||||||
if existing_roles:
|
new_count = 0
|
||||||
logger.info("角色已初始化")
|
|
||||||
return
|
|
||||||
|
|
||||||
for role_data in DEFAULT_ROLES:
|
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)
|
role = Role(**role_data)
|
||||||
session.add(role)
|
session.add(role)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
for perm_code in perm_codes:
|
||||||
for perm_id in perm_ids:
|
perm_id = perm_map.get(perm_code)
|
||||||
|
if perm_id is None:
|
||||||
|
continue
|
||||||
rp = RolePermission(role_id=role.id, permission_id=perm_id)
|
rp = RolePermission(role_id=role.id, permission_id=perm_id)
|
||||||
session.add(rp)
|
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):
|
async def create_admin_user(session):
|
||||||
|
|||||||
@@ -164,6 +164,28 @@ async def seeded_db(async_engine):
|
|||||||
yield session
|
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")
|
@pytest.fixture(scope="function")
|
||||||
async def client(async_engine, seeded_db):
|
async def client(async_engine, seeded_db):
|
||||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 以引导锁文件生成流程"
|
||||||
|
)
|
||||||
@@ -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 成功——
|
2. 单模块部署(inventory-only / moldinsight-only + auth)独立配置 mapper 成功——
|
||||||
跨模块 ORM relationship 已清零,任何一侧不注册对方模型也能工作;
|
跨模块 ORM relationship 已清零,任何一侧不注册对方模型也能工作;
|
||||||
3. 旧 shared.models.database 模块已删除且无兼容 facade(诚实原则:不留假象)。
|
3. 旧 shared.models.database 模块已删除且无兼容 facade(诚实原则:不留假象)。
|
||||||
@@ -20,6 +20,8 @@ EXPECTED_TABLES = {
|
|||||||
# moldinsight.models
|
# moldinsight.models
|
||||||
"stp_files", "geometry_data", "mesh_data", "html_files", "processing_tasks",
|
"stp_files", "geometry_data", "mesh_data", "html_files", "processing_tasks",
|
||||||
"mold_cavity_data", "feature_detections", "design_recommendations", "analysis_metrics",
|
"mold_cavity_data", "feature_detections", "design_recommendations", "analysis_metrics",
|
||||||
|
# D17:老师傅经验反馈(Human-in-Loop 闭环,2026-09)
|
||||||
|
"experience_feedback",
|
||||||
# inventory.models
|
# inventory.models
|
||||||
"products", "product_materials", "material_price_history", "material_suppliers",
|
"products", "product_materials", "material_price_history", "material_suppliers",
|
||||||
"suppliers", "customers", "warehouses", "inventory", "stock_movements",
|
"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 shared.models.identity # noqa: F401
|
||||||
import moldinsight.models # noqa: F401
|
import moldinsight.models # noqa: F401
|
||||||
import inventory.models # noqa: F401
|
import inventory.models # noqa: F401
|
||||||
|
|||||||
Reference in New Issue
Block a user