From ba21ed855081e0530488d12e460211964be8bb09 Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sun, 26 Apr 2026 09:00:49 +0800 Subject: [PATCH] chore: sync workspace updates --- MEMORY.md | 132 +- api/app/api/project_requirement.py | 163 -- api/app/api/router.py | 21 +- api/app/api/v1/admin.py | 223 --- api/app/api/v1/atp_models.py | 226 +++ api/app/api/v1/calendar.py | 107 - api/app/api/v1/chat.py | 53 - api/app/api/v1/lightning.py | 24 + api/app/api/v1/mermaids.py | 148 -- api/app/api/v1/mind_map.py | 108 - api/app/api/v1/requirements.py | 153 -- api/app/api/v1/task_monitor.py | 37 +- api/app/api/v1/todos.py | 116 -- api/app/core/config.py | 9 + api/app/core/database.py | 4 +- api/app/models/__init__.py | 6 +- api/app/models/atp_model.py | 151 ++ api/app/models/chat.py | 79 - api/app/models/mermaid_diagram.py | 56 - api/app/models/mind_map.py | 35 - api/app/schemas/atp_model.py | 153 ++ api/app/schemas/chat.py | 62 - api/app/schemas/lightning.py | 59 +- api/app/schemas/mermaid.py | 85 - api/app/schemas/mind_map.py | 46 - api/app/schemas/task_monitor.py | 72 +- api/app/schemas/todo.py | 7 - api/app/services/admin_service.py | 12 +- api/app/services/atp_model_service.py | 1001 ++++++++++ api/app/services/chat_service.py | 236 --- api/app/services/legacy_admin_rbac_service.py | 21 +- api/app/services/legacy_authz_service.py | 59 +- api/app/services/lightning_service.py | 347 +++- api/app/services/mermaid_service.py | 525 ----- api/app/services/mind_map_service.py | 373 ---- api/app/services/model_service.py | 12 +- api/app/services/requirement_service.py | 12 +- api/app/services/seed_service.py | 243 +-- api/app/services/task_monitor_service.py | 647 +++--- api/app/services/todo_service.py | 45 +- api/app/services/topic_registry.py | 3 +- memory/2026-04-26.md | 434 ++++ package-lock.json | 7 + web/package-lock.json | 7 + web/package.json | 1 + web/src/app/admin/agent/page.tsx | 1441 -------------- web/src/app/admin/api-tester/page.tsx | 643 ------ web/src/app/admin/chat/page.tsx | 412 ---- web/src/app/admin/files/page.tsx | 82 +- web/src/app/admin/knowledge-set/page.tsx | 5 - web/src/app/admin/layout.tsx | 64 - web/src/app/admin/lightning-currents/page.tsx | 22 + web/src/app/admin/mcp-server/page.tsx | 7 - web/src/app/admin/menus/page.tsx | 13 +- web/src/app/admin/mermaid-mgr/[id]/page.tsx | 10 - .../_components/mermaid-editor.tsx | 423 ---- web/src/app/admin/mermaid-mgr/page.tsx | 566 ------ .../mindmap/_components/mindmap-editor.tsx | 688 ------- web/src/app/admin/mindmap/edit/[id]/page.tsx | 28 - web/src/app/admin/mindmap/edit/page.tsx | 5 - web/src/app/admin/mindmap/page.tsx | 460 ----- .../app/admin/models/models-page-content.tsx | 1757 ----------------- web/src/app/admin/models/page.tsx | 7 - web/src/app/admin/orchestration/page.tsx | 7 - web/src/app/admin/page.tsx | 104 +- .../app/admin/power-lines/atp-viewer/page.tsx | 1364 +++++++++++++ web/src/app/admin/power-lines/page.tsx | 18 +- web/src/app/admin/requirements/[id]/page.tsx | 791 -------- .../_components/requirement-list-view.tsx | 571 ------ .../_lib/requirement-list-shared.tsx | 299 --- web/src/app/admin/requirements/new/page.tsx | 273 --- web/src/app/admin/requirements/page.tsx | 25 - web/src/app/admin/schedule/page.tsx | 909 --------- web/src/app/admin/task-monitor/page.tsx | 608 +++--- web/src/components/atp-maxgraph-viewer.tsx | 319 +++ web/src/components/mermaid-viewer.tsx | 77 - web/src/components/power-line-cesium-map.tsx | 213 +- web/src/lib/atp/parse-atp-text.ts | 346 ++++ web/src/lib/atp/sample.ts | 10 + web/src/lib/atp/types.ts | 53 + web/src/types/auth.ts | 518 ++--- 81 files changed, 5944 insertions(+), 13514 deletions(-) delete mode 100644 api/app/api/project_requirement.py create mode 100644 api/app/api/v1/atp_models.py delete mode 100644 api/app/api/v1/calendar.py delete mode 100644 api/app/api/v1/chat.py delete mode 100644 api/app/api/v1/mermaids.py delete mode 100644 api/app/api/v1/mind_map.py delete mode 100644 api/app/api/v1/requirements.py delete mode 100644 api/app/api/v1/todos.py create mode 100644 api/app/models/atp_model.py delete mode 100644 api/app/models/chat.py delete mode 100644 api/app/models/mermaid_diagram.py delete mode 100644 api/app/models/mind_map.py create mode 100644 api/app/schemas/atp_model.py delete mode 100644 api/app/schemas/chat.py delete mode 100644 api/app/schemas/mermaid.py delete mode 100644 api/app/schemas/mind_map.py create mode 100644 api/app/services/atp_model_service.py delete mode 100644 api/app/services/chat_service.py delete mode 100644 api/app/services/mermaid_service.py delete mode 100644 api/app/services/mind_map_service.py delete mode 100644 web/src/app/admin/agent/page.tsx delete mode 100644 web/src/app/admin/api-tester/page.tsx delete mode 100644 web/src/app/admin/chat/page.tsx delete mode 100644 web/src/app/admin/knowledge-set/page.tsx delete mode 100644 web/src/app/admin/mcp-server/page.tsx delete mode 100644 web/src/app/admin/mermaid-mgr/[id]/page.tsx delete mode 100644 web/src/app/admin/mermaid-mgr/_components/mermaid-editor.tsx delete mode 100644 web/src/app/admin/mermaid-mgr/page.tsx delete mode 100644 web/src/app/admin/mindmap/_components/mindmap-editor.tsx delete mode 100644 web/src/app/admin/mindmap/edit/[id]/page.tsx delete mode 100644 web/src/app/admin/mindmap/edit/page.tsx delete mode 100644 web/src/app/admin/mindmap/page.tsx delete mode 100644 web/src/app/admin/models/models-page-content.tsx delete mode 100644 web/src/app/admin/models/page.tsx delete mode 100644 web/src/app/admin/orchestration/page.tsx create mode 100644 web/src/app/admin/power-lines/atp-viewer/page.tsx delete mode 100644 web/src/app/admin/requirements/[id]/page.tsx delete mode 100644 web/src/app/admin/requirements/_components/requirement-list-view.tsx delete mode 100644 web/src/app/admin/requirements/_lib/requirement-list-shared.tsx delete mode 100644 web/src/app/admin/requirements/new/page.tsx delete mode 100644 web/src/app/admin/requirements/page.tsx delete mode 100644 web/src/app/admin/schedule/page.tsx create mode 100644 web/src/components/atp-maxgraph-viewer.tsx delete mode 100644 web/src/components/mermaid-viewer.tsx create mode 100644 web/src/lib/atp/parse-atp-text.ts create mode 100644 web/src/lib/atp/sample.ts create mode 100644 web/src/lib/atp/types.ts diff --git a/MEMORY.md b/MEMORY.md index 627539c..56bfa44 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -173,7 +173,8 @@ - `admin.wxapp` 已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口,确保可见、可达且不被误删。 - `单词统计` 菜单迁移采用最小改动策略:保留菜单编码 `admin.knowledge_mastery`(`/admin/vocabulary-proficiency`,权限 `vocabulary.read`),并由 `web/src/app/admin/vocabulary-proficiency/page.tsx` 承载词条总量、状态分布、缺失字段与最近更新趋势统计能力;已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口。 - `队列管理` 菜单迁移采用最小改动策略:新增菜单编码 `admin.queue_mgr`(`/admin/jobqueue`,权限 `todo.read`),并由 `web/src/app/admin/jobqueue/page.tsx` 复用 `todos` 页面承载队列任务清单能力;已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口。 -- `提示词管理` 菜单迁移沿用系统消息能力:保留菜单编码 `admin.system_message` 与权限 `system_message.read/system_message.manage`,菜单文案统一为“提示词管理”,默认路由迁移为 `/admin/prompt`,并由 `web/src/app/admin/prompt/page.tsx` 复用 `system-message` 页面承载提示词内容、等级、有效期与发布状态维护能力。 +- `提示词管理` 菜单能力已于 2026-04-26 下线:`admin.system_message`、`system_message.read/system_message.manage`、`/admin/prompt`、`/admin/system-message` 与 `/api/v1/admin/system-messages*` 均不再作为有效功能入口;历史数据库表不主动删除。 +- `收件箱`、`代码评审`、`Git管理` 功能已于 2026-04-26 下线:`admin.inbox`、`admin.code_review`、`admin.git_desktop` 仅保留在 removed/disabled 过滤集合中,用于屏蔽存量菜单;前端路由 `/admin/inbox`、`/admin/code-review`、`/admin/git-desktop` 不再提供页面。 - `历史答卷` 菜单迁移采用最小改动策略:保留菜单编码 `admin.history`(`/admin/history`,权限 `question_bank.read`),并由 `web/src/app/admin/history/page.tsx` 复用 `question-bank` 页面承载历史答卷查询与管理能力;已加入后端与前端受保护菜单集合与后台首页入口。 - `脚本管理` 菜单迁移采用最小改动策略:保留菜单编码 `admin.cron_task_mgr`(`/admin/cron`,权限 `todo.read`),菜单文案统一为“脚本管理”,并继续由 `web/src/app/admin/cron/page.tsx` 复用 `todos` 页面承载脚本任务清单能力。 - `百度网盘` 菜单迁移采用最小改动策略:新增菜单编码 `admin.baidu_pan`(`/admin/baidu-pan`,权限 `file.read`),并由 `web/src/app/admin/baidu-pan/page.tsx` 复用 `files` 页面承载目录浏览、上传、重命名、移动、删除与下载能力;已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口。 @@ -217,6 +218,7 @@ ## 数据库连接口径(2026-04-23) - API 默认数据库连接切换为本地 PostgreSQL:优先读取 `DATABASE_URL`;若未设置则由 `DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD` 组装。 + - 默认参数口径: - Docker 内:`DB_HOST=db`、`DB_PORT=5432`、`DB_NAME=postgres`、`DB_USERNAME=fquiz`、`DB_PASSWORD=fquiz`。 - 本机直连(非 Docker):`DB_HOST=127.0.0.1`、`DB_PORT=5433`、`DB_NAME=postgres`、`DB_USERNAME=fquiz`、`DB_PASSWORD=fquiz`。 @@ -225,6 +227,17 @@ - API 启动初始化口径:`seed_defaults` 对本地目标执行;为兼容老表状态约束,初始管理员状态写入值统一为 `ENABLED`(不使用 `active`)。 - 用户表兼容口径:用户主键列对齐旧库 `users.user_id`,与用户关联的外键统一引用 `users.user_id`(不再引用 `users.id`)。 +## 发布验收口径(2026-04-26) + +- 发布链路默认执行: + - `docker compose build` + - `docker compose up -d` +- 最小运行态验收: + - `docker compose ps`(关键服务 `api/web/celery-worker/celery-beat/db/redis/minio` 为 Up,关键依赖健康)。 + - `curl -fsS http://127.0.0.1:8000/health` 返回 API 健康 JSON。 + - `curl -I -fsS http://127.0.0.1:3000/` 返回 `HTTP/1.1 200 OK`。 + - 结合 `docker compose logs --tail` 抽样检查 `api/web/celery-worker/celery-beat` 启动日志是否正常。 + ## 前端组件栈口径(2026-04-22) - 组件库基线从 `Radix UI` 切换为 `Ant Design`,`web` 依赖已移除 `@radix-ui/themes` / `@radix-ui/react-dialog` / `@radix-ui/react-select`,新增 `antd`。 @@ -650,6 +663,16 @@ - 工程约束: - `web/public/cesium` 属于构建生成资产,不纳入 Git 版本管理(由 `.gitignore` 忽略)。 +## 线路走向图专题口径(2026-04-26) + +- `线路管理` 页面地图视图已升级为“走向图”专题视图(保留表格/走向图双视图切换)。 +- 走向图默认关闭通用底图能力(Cesium `baseLayer: false`),仅突出线路业务语义: + - 按杆塔 `seq_no` 连线展示线路走向; + - 展示杆塔点位、起点/终点标识; + - 支持按风险着色、塔号显隐、居中重置。 +- 走向图统计信息固定包含:有效坐标、缺失坐标、断点段数、线路估算长度(Haversine)。 +- 对序号断档采用分段折线渲染,避免缺失坐标导致的跨段误连线。 + ## 雷电流数据管理口径(2026-04-25) - 雷电流模块一期后端入口统一在 `/api/v1/lightning-currents`,权限码为: @@ -809,3 +832,110 @@ - 待办:统计状态/优先级分布,输出超期待办(`due_date/expire_time` 触发)。 - 权限分层:接口按 `requirement.*` 与 `todo.*` 权限分别返回对应数据块,避免越权暴露。 - 菜单保护:`admin.task_monitor` 已纳入前后端受保护菜单集合,避免在菜单管理中误删。 + +## 文件管理单挂载 UI 口径(2026-04-26) + +- `web/src/app/admin/files/page.tsx` 默认按“单挂载”交互:不再展示左侧挂载点列表,不提供前端挂载点切换。 +- 当前挂载上下文统一以接口返回的 `current_mount` 为准;文件操作仍透传 `mount_code`,保持与后端接口契约一致。 +- 后端仍保留多挂载点模型能力(`file_storage_mounts`),本次仅收敛前端交互层。 + +## 任务监控口径更新(2026-04-26) + +- `/admin/task-monitor` 与 `GET /api/v1/admin/task-monitor/overview` 已收口为 **Celery 运行态监控**,不再承载需求/待办风险聚合。 +- 接口查询参数: + - `task_limit`:返回任务明细上限。 + - `history_limit`:历史任务扫描上限(Redis result backend)。 +- 数据来源口径: + - 队列积压(`pending_count`)在 Redis broker 下按队列 `LLEN` 读取,非 Redis broker 回退为 `0`。 + - 历史任务状态(`SUCCESS/FAILURE/RETRY/REVOKED`)在 Redis result backend 下通过 `SCAN celery-task-meta-*` 采样聚合。 +- 接口与页面权限统一为:`celery.read` 或 `celery.manage`。 +- 前端展示结构固定为三块: + - Worker 概览(在线状态、并发、预取、活跃/预留/定时任务数) + - Queue 概览(pending、consumer、active/reserved/scheduled) + - Task 明细(状态、队列、worker、ETA/开始/完成、错误摘要) + +## 后台页面顶部信息口径(2026-04-26) + +- 后台壳层 `web/src/app/admin/layout.tsx` 不再渲染内容区顶部公共信息块(Breadcrumb + 页面标题 + 页面描述)。 +- 后台页面默认直接进入业务内容区,避免在每个页面重复展示“模块标题 + 描述文案”。 + +## ATP 模型管理口径(2026-04-26) + +- ATP 功能一期定位为“ATPDraw 产物版本管理 + ATP 引擎调用”: + - 模型台账:`atp_model` + - 版本管理:`atp_model_version` + - 运行记录:`atp_simulation_run` +- 后端 API 统一前缀:`/api/v1/atp/models`,包含: + - 引擎状态:`GET /engine/status` + - 模型 CRUD:`GET/POST/PATCH/DELETE /` + - 版本管理:`GET/POST/PATCH /{model_id}/versions*` + `POST /activate` + - 运行管理:`GET/POST /{model_id}/runs*` +- 权限口径: + - `atp.read`:查看模型/版本/运行 + - `atp.manage`:维护模型与版本、激活版本 + - `atp.run`:执行仿真 +- 菜单口径: + - 新增 `admin.atp_models`,路由 `/admin/power-lines/atp-viewer`,默认绑定 admin 角色。 +- 推送订阅口径: + - 主题 `admin.atp-models`(权限 `atp.read/atp.run/atp.manage`)。 +- 配置口径: + - `atp_engine_mode`:`wine|native` + - `atp_engine_executable` + - `atp_storage_root` + - `atp_engine_workdir` + - `atp_engine_default_timeout_seconds` + - `atp_engine_max_timeout_seconds` + +## 功能下线口径(2026-04-26) + +- 以下后台功能已下线,不再作为有效入口、默认菜单、默认权限或公开 API 提供: + - AI 聊天:`/admin/chat`、`/api/v1/chat*`、`chat.use` + - 编排管理:`/admin/orchestration` + - MCP管理:`/admin/mcp-server` + - 模型管理/API测试:`/admin/models`、`/admin/api-tester`、`/api/v1/admin/models*`、`/api/v1/admin/model-routes*`、`model.read/model.manage` + - 知识集/文件管理:`/admin/knowledge-set`、`/admin/files`、`/api/v1/admin/files*`、`file.read/file.manage` + - 流程图:`/admin/mermaid-mgr`、`/api/v1/mermaids*`、legacy `/api/mermaids*` + - 思维导图:`/admin/mindmap`、`/api/v1/mindmap*` + - 需求管理:`/admin/requirements`、`/api/v1/requirements*`、`/api/project/requirement*`、`requirement.*` + - 日程管理:`/admin/schedule`、`/api/v1/calendar*`、`/api/v1/todos*`、`todo.*` +- `admin.agent/admin.mcp_server/admin.files/admin.requirements/admin.schedule/admin.mindmap/admin.mermaid_mgr/admin.chat/admin.api_tester/admin.models/admin.orchestration` 仅保留在 removed/disabled 过滤集合中,用于屏蔽历史菜单数据。 +- 本次不执行数据库 drop;历史表若已存在,作为历史数据保留。模型注册表/路由表仍保留给内部 LLM 网关和寿命倒计时等内部能力使用,但无后台管理页面/API。 +- `/admin/task-monitor` 已独立为 Celery Worker/Queue/Task 监控,不再读取需求/待办数据。 + +## 文件管理恢复口径(2026-04-26) + +- 文件管理模块恢复路径: + - 前端页面:`/admin/files`(后台首页卡片入口 `/files`)。 + - 后端接口:`/api/v1/admin/files` 及其 `directories/delete/rename/move/upload/download/download-zip` 子接口。 +- 权限口径:`file.read`(读)与 `file.manage`(写操作)。 +- 订阅口径:`admin.files` topic 已恢复,文件操作后触发前端刷新。 +- 模型与 seed:`file_storage_backends` / `file_storage_mounts` / `file_index_entries` 已恢复注册;默认 seed 会创建 `files.vfs.default`、`files.s3.default` 与 `main` 挂载。 +- 交互口径:页面保持“单挂载点”模式,不展示左侧挂载点切换面板。 + +## ATP 查看器口径(2026-04-26) + +- ATP 文本查看能力已落地在线路模块子路由:`/power-lines/atp-viewer`(内部路由 `web/src/app/admin/power-lines/atp-viewer/page.tsx`)。 +- 技术栈固定为“前端本地解析 + 前端只读渲染”: + - 解析:`web/src/lib/atp/parse-atp-text.ts` + - 渲染:`@maxgraph/core` + `web/src/components/atp-maxgraph-viewer.tsx` +- 当前目标是“查看保真优先”,明确不包含仿真内核调用。 +- 解析覆盖常见元件行格式,复杂 ATP 控制卡/模型卡默认容错跳过并输出 warnings,不阻断基础图形查看。 + +## 雷电地形倾角口径(2026-04-26) + +- 地面倾角计算接口固定为: + - `POST /api/v1/lightning-currents/stats/tower-terrain` +- 入参口径: + - `dem_grid_m` 必须为 3x3 高程矩阵(中心点 + 邻域 8 点)。 + - `cell_size_m` 为 DEM 栅格间距(米)。 + - `dem_resolution_m` 可单独传入用于质量评分(不传时默认等于 `cell_size_m`)。 +- 算法口径: + - 梯度:Horn 3x3(`algorithm_version=horn_3x3.v1`)。 + - 输出:`slope_deg`、`aspect_deg`、`slope_mean/p95/max`、`slope_along/cross_line_deg`、`relief_m_50`、`terrain_exposure_index`、`quality_score/level`。 + - 坡向定义为“顺坡向(下坡方向)方位角”,0-360°,顺时针(0=北,90=东)。 +- 持久化口径: + - `persist=true` 时要求 `tower_id`,且调用方需具备 `tower.manage` 或 `lightning.manage`(admin 放行)。 + - 持久化位置:`power_line_tower.raw_extra_json.terrain_metrics`,并同步更新 `slope_1/slope_2`(纵坡/横坡绝对值)。 +- 缓冲区分析联动: + - `GET /api/v1/lightning-currents/stats/tower-buffer` 返回 `terrain_metrics`(若杆塔已有地形指标)。 + - 风险分级引入地形暴露权重:`ng_for_risk = ng * (1 + 0.25 * exposure)`,但原始返回字段 `ng_per_km2_year` 保持未加权值。 diff --git a/api/app/api/project_requirement.py b/api/app/api/project_requirement.py deleted file mode 100644 index 9ca245e..0000000 --- a/api/app/api/project_requirement.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from ..core.database import get_db -from ..core.dependencies import CurrentUser, require_any_permission, require_permission -from ..schemas.auth import MessageResponse -from ..services.requirement_service import ( - analyze_requirement_legacy, - design_requirement_legacy, - get_history_options_legacy, - get_pending_requirement_legacy, - get_requirement_legacy, - list_lifecycle_legacy, - review_requirement_legacy, - search_requirements_legacy, - update_status_legacy, -) - -router = APIRouter(prefix="/api/project/requirement", tags=["project-requirement"]) - - -class RequirementSearchLegacyRequest(BaseModel): - title: str | None = None - projectName: str | None = None - status: str | None = None - priority: str | None = None - pageNum: int = Field(default=1, ge=1) - pageSize: int = Field(default=10, ge=1, le=500) - - -class RequirementAnalyzeLegacyRequest(BaseModel): - descr: str | None = None - progressPercent: int | None = Field(default=None, ge=0, le=100) - - -class RequirementReviewLegacyRequest(BaseModel): - decision: Literal["TO_REVISION", "TO_OPEN"] - descr: str | None = None - comment: str | None = None - - -@router.get("/pending") -def get_pending_requirement( - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> dict | None: - return get_pending_requirement_legacy(db) - - -@router.post("/search") -def search_requirements( - payload: RequirementSearchLegacyRequest, - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> dict: - return search_requirements_legacy( - db, - page_num=payload.pageNum, - page_size=payload.pageSize, - project_name=payload.projectName, - status_value=payload.status, - priority_value=payload.priority, - title=payload.title, - ) - - -@router.get("/get/{requirement_id}") -def get_requirement( - requirement_id: str, - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> dict: - return get_requirement_legacy(db, requirement_id) - - -@router.post("/{requirement_id}/status", response_model=MessageResponse) -def update_requirement_status( - requirement_id: str, - status_value: str = Query(..., alias="status"), - result_msg: str | None = Query(default=None, alias="resultMsg"), - progress_percent: int | None = Query(default=None, alias="progressPercent"), - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> MessageResponse: - update_status_legacy( - db, - requirement_id=requirement_id, - status_value=status_value, - result_msg=result_msg, - progress_percent=progress_percent, - actor_user_id=current_user.user.id, - ) - return MessageResponse(message="Requirement status updated") - - -@router.post("/{requirement_id}/analyze") -def analyze_requirement( - requirement_id: str, - payload: RequirementAnalyzeLegacyRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> dict: - return analyze_requirement_legacy( - db, - requirement_id=requirement_id, - descr=payload.descr, - progress_percent=payload.progressPercent, - actor_user_id=current_user.user.id, - ) - - -@router.post("/{requirement_id}/design") -def design_requirement( - requirement_id: str, - payload: RequirementAnalyzeLegacyRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> dict: - return design_requirement_legacy( - db, - requirement_id=requirement_id, - descr=payload.descr, - actor_user_id=current_user.user.id, - ) - - -@router.post("/{requirement_id}/review") -def review_requirement( - requirement_id: str, - payload: RequirementReviewLegacyRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> dict: - return review_requirement_legacy( - db, - requirement_id=requirement_id, - decision=payload.decision, - descr=payload.descr, - comment=payload.comment, - actor_user_id=current_user.user.id, - ) - - -@router.get("/{requirement_id}/lifecycle") -def get_requirement_lifecycle( - requirement_id: str, - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> list[dict]: - return list_lifecycle_legacy(db, requirement_id) - - -@router.get("/history-options") -def get_requirement_history_options( - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> dict: - return get_history_options_legacy(db) diff --git a/api/app/api/router.py b/api/app/api/router.py index 33c3cf9..a3c74bc 100644 --- a/api/app/api/router.py +++ b/api/app/api/router.py @@ -1,22 +1,16 @@ from fastapi import APIRouter -from .project_requirement import router as project_requirement_router from .v1.admin import router as admin_router from .v1.admin_files import router as admin_files_router +from .v1.atp_models import router as atp_models_router from .v1.auth import router as auth_router -from .v1.calendar import router as calendar_router -from .v1.chat import router as chat_router from .v1.diary import router as diary_router from .v1.life_countdown import router as life_countdown_router from .v1.lightning import router as lightning_router from .v1.lines import router as lines_router -from .v1.mermaids import router as mermaids_router -from .v1.mind_map import router as mind_map_router from .v1.question_bank import router as question_bank_router -from .v1.requirements import router as requirements_router from .v1.system_params import router as system_params_router from .v1.task_monitor import router as task_monitor_router -from .v1.todos import router as todos_router from .v1.token_usage import router as token_usage_router from .v1.users import router as users_router from .v1.vocabulary import router as vocabulary_router @@ -28,28 +22,19 @@ v1_router.include_router(auth_router) v1_router.include_router(users_router) v1_router.include_router(admin_router) v1_router.include_router(admin_files_router) -v1_router.include_router(requirements_router) -v1_router.include_router(todos_router) +v1_router.include_router(atp_models_router) v1_router.include_router(task_monitor_router) v1_router.include_router(token_usage_router) v1_router.include_router(system_params_router) -v1_router.include_router(chat_router) -v1_router.include_router(calendar_router) v1_router.include_router(diary_router) v1_router.include_router(life_countdown_router) v1_router.include_router(lightning_router) v1_router.include_router(lines_router) v1_router.include_router(question_bank_router) -v1_router.include_router(mind_map_router) -v1_router.include_router(mermaids_router) v1_router.include_router(vocabulary_router) v1_router.include_router(wine_router) v1_router.include_router(ws_router) -legacy_mermaid_router = APIRouter(prefix="/api") -legacy_mermaid_router.include_router(mermaids_router) - - @v1_router.get("/ping") def ping() -> dict[str, str]: return {"message": "pong"} @@ -57,5 +42,3 @@ def ping() -> dict[str, str]: api_router = APIRouter() api_router.include_router(v1_router) -api_router.include_router(project_requirement_router) -api_router.include_router(legacy_mermaid_router) diff --git a/api/app/api/v1/admin.py b/api/app/api/v1/admin.py index fcaf955..64a8b01 100644 --- a/api/app/api/v1/admin.py +++ b/api/app/api/v1/admin.py @@ -16,29 +16,6 @@ from ...schemas.admin import ( RolePublic, RoleUpdateRequest, ) -from ...schemas.model_registry import ( - ModelApiKeyListResponse, - ModelApiKeyPublic, - ModelCreateRequest, - ModelHealthCheckListResponse, - ModelHealthCheckPublic, - ModelListResponse, - ModelRegistryPublic, - ModelRotateKeyRequest, - ModelRouteRuleCreateRequest, - ModelRouteRuleListResponse, - ModelRouteRulePublic, - ModelRouteRuleUpdateRequest, - ModelSummaryResponse, - ModelTestChatRequest, - ModelTestChatResponse, - ModelTestRunListResponse, - ModelTestRunPublic, - ModelTestRunRequest, - ModelTransitionRequest, - ModelUpdateRequest, - ModelUsageIngestRequest, -) from ...services.admin_service import ( build_menu_tree, list_audit_logs, @@ -58,27 +35,6 @@ from ...services.legacy_admin_rbac_service import ( update_menu, update_role, ) -from ...services.model_service import ( - create_model, - create_route_rule, - delete_model, - delete_route_rule, - get_model_detail, - get_model_summary, - ingest_model_usage, - list_model_health_checks, - list_model_keys, - list_model_tests, - list_models, - list_route_rules, - rotate_model_key, - run_model_health_check, - run_model_test, - run_model_test_chat, - transition_model_status, - update_model, - update_route_rule, -) router = APIRouter(prefix="/admin", tags=["admin"]) @@ -179,185 +135,6 @@ def get_audit_logs( ) -@router.get("/models/summary", response_model=ModelSummaryResponse) -def get_models_summary( - _: CurrentUser = Depends(require_any_permission("model.read", "model.manage")), - db: Session = Depends(get_db), -) -> ModelSummaryResponse: - return get_model_summary(db) - - -@router.get("/models", response_model=ModelListResponse) -def get_models( - status_filter: str | None = Query(default=None, alias="status"), - keyword: str | None = Query(default=None), - _: CurrentUser = Depends(require_any_permission("model.read", "model.manage")), - db: Session = Depends(get_db), -) -> ModelListResponse: - return list_models(db, status_filter=status_filter, keyword=keyword) - - -@router.get("/models/{model_id}", response_model=ModelRegistryPublic) -def get_model( - model_id: int, - _: CurrentUser = Depends(require_any_permission("model.read", "model.manage")), - db: Session = Depends(get_db), -) -> ModelRegistryPublic: - return get_model_detail(db, model_id) - - -@router.post("/models", response_model=ModelRegistryPublic) -def create_model_endpoint( - payload: ModelCreateRequest, - current_user: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelRegistryPublic: - return create_model(db, payload, actor=current_user.user) - - -@router.patch("/models/{model_id}", response_model=ModelRegistryPublic) -def update_model_endpoint( - model_id: int, - payload: ModelUpdateRequest, - _: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelRegistryPublic: - return update_model(db, model_id, payload) - - -@router.post("/models/{model_id}/transition", response_model=ModelRegistryPublic) -def transition_model_endpoint( - model_id: int, - payload: ModelTransitionRequest, - current_user: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelRegistryPublic: - return transition_model_status(db, model_id, payload, actor=current_user.user) - - -@router.delete("/models/{model_id}") -def delete_model_endpoint( - model_id: int, - _: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> dict[str, bool]: - delete_model(db, model_id) - return {"success": True} - - -@router.get("/models/{model_id}/keys", response_model=ModelApiKeyListResponse) -def get_model_keys( - model_id: int, - _: CurrentUser = Depends(require_any_permission("model.read", "model.manage")), - db: Session = Depends(get_db), -) -> ModelApiKeyListResponse: - return list_model_keys(db, model_id) - - -@router.post("/models/{model_id}/rotate-key", response_model=ModelApiKeyPublic) -def rotate_model_key_endpoint( - model_id: int, - payload: ModelRotateKeyRequest, - current_user: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelApiKeyPublic: - return rotate_model_key(db, model_id, payload, actor=current_user.user) - - -@router.post("/models/{model_id}/health-check", response_model=ModelHealthCheckPublic) -def run_model_health_check_endpoint( - model_id: int, - _: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelHealthCheckPublic: - return run_model_health_check(db, model_id) - - -@router.get("/models/{model_id}/health-checks", response_model=ModelHealthCheckListResponse) -def get_model_health_checks( - model_id: int, - limit: int = Query(default=20, ge=1, le=100), - _: CurrentUser = Depends(require_any_permission("model.read", "model.manage")), - db: Session = Depends(get_db), -) -> ModelHealthCheckListResponse: - return list_model_health_checks(db, model_id, limit=limit) - - -@router.post("/models/{model_id}/tests", response_model=ModelTestRunPublic) -def run_model_test_endpoint( - model_id: int, - payload: ModelTestRunRequest, - current_user: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelTestRunPublic: - return run_model_test(db, model_id, payload, actor=current_user.user) - - -@router.post("/models/{model_id}/test-chat", response_model=ModelTestChatResponse) -def run_model_test_chat_endpoint( - model_id: int, - payload: ModelTestChatRequest, - current_user: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelTestChatResponse: - return run_model_test_chat(db, model_id, payload, actor=current_user.user) - - -@router.get("/models/{model_id}/tests", response_model=ModelTestRunListResponse) -def get_model_tests( - model_id: int, - limit: int = Query(default=20, ge=1, le=100), - _: CurrentUser = Depends(require_any_permission("model.read", "model.manage")), - db: Session = Depends(get_db), -) -> ModelTestRunListResponse: - return list_model_tests(db, model_id, limit=limit) - - -@router.post("/models/usage") -def ingest_model_usage_endpoint( - payload: ModelUsageIngestRequest, - _: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> dict[str, bool]: - return ingest_model_usage(db, payload) - - -@router.get("/model-routes", response_model=ModelRouteRuleListResponse) -def get_model_routes( - _: CurrentUser = Depends(require_any_permission("model.read", "model.manage")), - db: Session = Depends(get_db), -) -> ModelRouteRuleListResponse: - return list_route_rules(db) - - -@router.post("/model-routes", response_model=ModelRouteRulePublic) -def create_model_route_endpoint( - payload: ModelRouteRuleCreateRequest, - _: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelRouteRulePublic: - return create_route_rule(db, payload) - - -@router.patch("/model-routes/{route_rule_id}", response_model=ModelRouteRulePublic) -def update_model_route_endpoint( - route_rule_id: int, - payload: ModelRouteRuleUpdateRequest, - _: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> ModelRouteRulePublic: - return update_route_rule(db, route_rule_id, payload) - - -@router.delete("/model-routes/{route_rule_id}") -def delete_model_route_endpoint( - route_rule_id: int, - _: CurrentUser = Depends(require_permission("model.manage")), - db: Session = Depends(get_db), -) -> dict[str, bool]: - return delete_route_rule(db, route_rule_id) - - @router.get("/menus", response_model=MenuListResponse) def get_menus( _: CurrentUser = Depends(require_any_permission("menu.read", "menu.manage")), diff --git a/api/app/api/v1/atp_models.py b/api/app/api/v1/atp_models.py new file mode 100644 index 0000000..d3aa094 --- /dev/null +++ b/api/app/api/v1/atp_models.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from ...core.database import get_db +from ...core.dependencies import CurrentUser, require_any_permission, require_permission +from ...schemas.atp_model import ( + AtpEngineStatusResponse, + AtpModelCreateRequest, + AtpModelListResponse, + AtpModelSummary, + AtpModelUpdateRequest, + AtpModelVersionCreateRequest, + AtpModelVersionDetail, + AtpModelVersionListResponse, + AtpModelVersionUpdateRequest, + AtpSimulationRunDetail, + AtpSimulationRunListResponse, + AtpSimulationRunRequest, +) +from ...services.atp_model_service import ( + activate_model_version, + create_model, + create_model_version, + delete_model, + get_engine_status, + get_model_by_id, + get_model_run_detail, + get_model_version_by_id, + list_model_runs, + list_model_versions, + list_models, + run_model_version, + serialize_model, + serialize_version_detail, + update_model, + update_model_version, +) + +router = APIRouter(prefix="/atp/models", tags=["atp-models"]) + + +@router.get("/engine/status", response_model=AtpEngineStatusResponse) +def get_atp_engine_status_endpoint( + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), +) -> AtpEngineStatusResponse: + return get_engine_status() + + +@router.get("", response_model=AtpModelListResponse) +def get_atp_model_list( + keyword: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelListResponse: + return list_models(db, keyword=keyword, status_filter=status_filter) + + +@router.post("", response_model=AtpModelSummary) +def create_atp_model_endpoint( + payload: AtpModelCreateRequest, + current_user: CurrentUser = Depends(require_permission("atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelSummary: + created = create_model(db, payload, actor_user_id=current_user.user.id) + if not created: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Model code already exists") + return created + + +@router.get("/{model_id}", response_model=AtpModelSummary) +def get_atp_model_detail( + model_id: str, + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelSummary: + item = get_model_by_id(db, model_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + + version_count = int(len(item.versions)) + run_count = int(len(item.runs)) + last_run = item.runs[0] if item.runs else None + return serialize_model( + item, + version_count=version_count, + run_count=run_count, + last_run_status=last_run.status if last_run else None, + last_run_date=last_run.create_date if last_run else None, + ) + + +@router.patch("/{model_id}", response_model=AtpModelSummary) +def update_atp_model_endpoint( + model_id: str, + payload: AtpModelUpdateRequest, + current_user: CurrentUser = Depends(require_permission("atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelSummary: + updated = update_model(db, model_id, payload, actor_user_id=current_user.user.id) + if not updated: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + return updated + + +@router.delete("/{model_id}") +def delete_atp_model_endpoint( + model_id: str, + _: CurrentUser = Depends(require_permission("atp.manage")), + db: Session = Depends(get_db), +) -> dict[str, bool]: + deleted, version_count = delete_model(db, model_id) + if not deleted: + item = get_model_by_id(db, model_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Model has {version_count} versions, delete versions first", + ) + return {"success": True} + + +@router.get("/{model_id}/versions", response_model=AtpModelVersionListResponse) +def get_atp_model_versions( + model_id: str, + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelVersionListResponse: + if not get_model_by_id(db, model_id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + return list_model_versions(db, model_id=model_id, limit=limit, offset=offset) + + +@router.post("/{model_id}/versions", response_model=AtpModelVersionDetail) +def create_atp_model_version_endpoint( + model_id: str, + payload: AtpModelVersionCreateRequest, + current_user: CurrentUser = Depends(require_permission("atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelVersionDetail: + return create_model_version(db, model_id=model_id, payload=payload, actor_user_id=current_user.user.id) + + +@router.get("/{model_id}/versions/{version_id}", response_model=AtpModelVersionDetail) +def get_atp_model_version_detail( + model_id: str, + version_id: str, + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelVersionDetail: + item = get_model_version_by_id(db, model_id=model_id, version_id=version_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") + return serialize_version_detail(item) + + +@router.patch("/{model_id}/versions/{version_id}", response_model=AtpModelVersionDetail) +def update_atp_model_version_endpoint( + model_id: str, + version_id: str, + payload: AtpModelVersionUpdateRequest, + current_user: CurrentUser = Depends(require_permission("atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelVersionDetail: + return update_model_version( + db, + model_id=model_id, + version_id=version_id, + payload=payload, + actor_user_id=current_user.user.id, + ) + + +@router.post("/{model_id}/versions/{version_id}/activate", response_model=AtpModelSummary) +def activate_atp_model_version_endpoint( + model_id: str, + version_id: str, + current_user: CurrentUser = Depends(require_permission("atp.manage")), + db: Session = Depends(get_db), +) -> AtpModelSummary: + return activate_model_version( + db, + model_id=model_id, + version_id=version_id, + actor_user_id=current_user.user.id, + ) + + +@router.get("/{model_id}/runs", response_model=AtpSimulationRunListResponse) +def get_atp_model_runs( + model_id: str, + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpSimulationRunListResponse: + if not get_model_by_id(db, model_id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + return list_model_runs(db, model_id=model_id, limit=limit, offset=offset) + + +@router.post("/{model_id}/runs", response_model=AtpSimulationRunDetail) +def run_atp_model_endpoint( + model_id: str, + payload: AtpSimulationRunRequest, + current_user: CurrentUser = Depends(require_any_permission("atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpSimulationRunDetail: + return run_model_version(db, model_id=model_id, payload=payload, actor_user_id=current_user.user.id) + + +@router.get("/{model_id}/runs/{run_id}", response_model=AtpSimulationRunDetail) +def get_atp_model_run_detail( + model_id: str, + run_id: str, + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpSimulationRunDetail: + if not get_model_by_id(db, model_id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + return get_model_run_detail(db, model_id=model_id, run_id=run_id) diff --git a/api/app/api/v1/calendar.py b/api/app/api/v1/calendar.py deleted file mode 100644 index 970b8cb..0000000 --- a/api/app/api/v1/calendar.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator - -from fastapi import APIRouter, Depends, HTTPException, Query, status -from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session - -from ...core.database import get_db -from ...core.dependencies import CurrentUser, require_any_permission, require_permission -from ...schemas.calendar_event import ( - CalendarEventCreateRequest, - CalendarEventPageResponse, - CalendarEventQueryRequest, - CalendarEventSummary, - CalendarEventUpdateRequest, -) -from ...services.calendar_event_service import ( - complete_calendar_event, - create_calendar_event, - delete_calendar_event, - get_calendar_event_by_id, - search_calendar_events, - serialize_calendar_event, - stream_generate_calendar_event, - update_calendar_event, -) - -router = APIRouter(prefix="/calendar", tags=["calendar"]) - - -@router.post("/search", response_model=CalendarEventPageResponse) -def search_calendar_endpoint( - payload: CalendarEventQueryRequest, - current_user: CurrentUser = Depends(require_permission("todo.read")), - db: Session = Depends(get_db), -) -> CalendarEventPageResponse: - return search_calendar_events(db, payload, actor=current_user.user) - - -@router.get("/get/{event_id}", response_model=CalendarEventSummary) -def get_calendar_endpoint( - event_id: str, - current_user: CurrentUser = Depends(require_permission("todo.read")), - db: Session = Depends(get_db), -) -> CalendarEventSummary: - event = get_calendar_event_by_id(db, event_id, actor=current_user.user) - if not event: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Calendar event not found") - return serialize_calendar_event(event) - - -@router.post("/create", response_model=CalendarEventSummary) -def create_calendar_endpoint( - payload: CalendarEventCreateRequest, - current_user: CurrentUser = Depends(require_any_permission("todo.create", "todo.manage")), - db: Session = Depends(get_db), -) -> CalendarEventSummary: - return create_calendar_event(db, payload, actor=current_user.user) - - -@router.put("/update", response_model=CalendarEventSummary) -def update_calendar_endpoint( - payload: CalendarEventUpdateRequest, - current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")), - db: Session = Depends(get_db), -) -> CalendarEventSummary: - return update_calendar_event(db, payload, actor=current_user.user) - - -@router.delete("/delete/{event_id}") -def delete_calendar_endpoint( - event_id: str, - current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")), - db: Session = Depends(get_db), -) -> dict[str, bool]: - return delete_calendar_event(db, event_id, actor=current_user.user) - - -@router.post("/{event_id}/complete", response_model=CalendarEventSummary) -def complete_calendar_endpoint( - event_id: str, - current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")), - db: Session = Depends(get_db), -) -> CalendarEventSummary: - return complete_calendar_event(db, event_id, actor=current_user.user) - - -@router.get("/generate/stream") -async def generate_calendar_stream_endpoint( - descr: str = Query(min_length=1), - _: CurrentUser = Depends(require_permission("todo.read")), - db: Session = Depends(get_db), -) -> StreamingResponse: - async def event_gen() -> AsyncGenerator[str, None]: - async for chunk in stream_generate_calendar_event(db, descr=descr): - yield f"data: {chunk}\n\n" - - return StreamingResponse( - event_gen(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) diff --git a/api/app/api/v1/chat.py b/api/app/api/v1/chat.py deleted file mode 100644 index 224aa83..0000000 --- a/api/app/api/v1/chat.py +++ /dev/null @@ -1,53 +0,0 @@ -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from ...core.database import get_db -from ...core.dependencies import CurrentUser, require_permission -from ...schemas.chat import ( - ChatMessageCreateRequest, - ChatMessageListResponse, - ChatSendResponse, - ChatSessionCreateRequest, - ChatSessionListResponse, - ChatSessionPublic, -) -from ...services.chat_service import create_session, list_messages, list_sessions, send_message - -router = APIRouter(prefix="/chat", tags=["chat"]) - - -@router.get("/sessions", response_model=ChatSessionListResponse) -def get_chat_sessions( - current_user: CurrentUser = Depends(require_permission("chat.use")), - db: Session = Depends(get_db), -) -> ChatSessionListResponse: - return list_sessions(db, actor=current_user.user) - - -@router.post("/sessions", response_model=ChatSessionPublic) -def create_chat_session( - payload: ChatSessionCreateRequest, - current_user: CurrentUser = Depends(require_permission("chat.use")), - db: Session = Depends(get_db), -) -> ChatSessionPublic: - return create_session(db, payload, actor=current_user.user) - - -@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse) -def get_chat_messages( - session_id: str, - limit: int = Query(default=200, ge=1, le=500), - current_user: CurrentUser = Depends(require_permission("chat.use")), - db: Session = Depends(get_db), -) -> ChatMessageListResponse: - return list_messages(db, session_id=session_id, actor=current_user.user, limit=limit) - - -@router.post("/sessions/{session_id}/messages", response_model=ChatSendResponse) -def send_chat_message( - session_id: str, - payload: ChatMessageCreateRequest, - current_user: CurrentUser = Depends(require_permission("chat.use")), - db: Session = Depends(get_db), -) -> ChatSendResponse: - return send_message(db, session_id=session_id, payload=payload, actor=current_user.user) diff --git a/api/app/api/v1/lightning.py b/api/app/api/v1/lightning.py index 6f67536..d38208b 100644 --- a/api/app/api/v1/lightning.py +++ b/api/app/api/v1/lightning.py @@ -18,11 +18,14 @@ from ...schemas.lightning import ( LightningDistributionReportResponse, LightningDistributionStatsResponse, LightningSyntheticCompareResponse, + LightningTowerTerrainComputeRequest, + LightningTowerTerrainComputeResponse, LightningTowerBufferStatsResponse, ) from ...services.lightning_service import ( build_lightning_distribution_report, compare_measured_and_synthetic_distribution, + compute_tower_terrain_metrics, delete_lightning_event, get_lightning_distribution_stats, get_lightning_event_by_id, @@ -296,6 +299,27 @@ def get_lightning_tower_buffer_statistics( ) +@router.post("/stats/tower-terrain", response_model=LightningTowerTerrainComputeResponse) +def compute_lightning_tower_terrain( + payload: LightningTowerTerrainComputeRequest, + current_user: CurrentUser = Depends( + require_any_permission("lightning.read", "lightning.manage", "tower.read", "tower.manage") + ), + db: Session = Depends(get_db), +) -> LightningTowerTerrainComputeResponse: + can_persist = ( + "admin" in current_user.role_codes + or "lightning.manage" in current_user.permission_codes + or "tower.manage" in current_user.permission_codes + ) + return compute_tower_terrain_metrics( + db, + payload=payload, + actor_user_id=current_user.user.id, + can_persist=can_persist, + ) + + @router.get("/stats/compare-synthetic", response_model=LightningSyntheticCompareResponse) def get_lightning_synthetic_compare( min_lat: float | None = Query(default=None), diff --git a/api/app/api/v1/mermaids.py b/api/app/api/v1/mermaids.py deleted file mode 100644 index 29b0e54..0000000 --- a/api/app/api/v1/mermaids.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator - -from fastapi import APIRouter, Depends, HTTPException, Query, status -from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session - -from ...core.database import get_db -from ...core.dependencies import CurrentUser, require_any_permission -from ...schemas.mermaid import ( - MermaidChatStreamRequest, - MermaidDiagramCreateRequest, - MermaidDiagramDataPatchRequest, - MermaidDiagramPageResponse, - MermaidDiagramQueryRequest, - MermaidDiagramSummary, - MermaidDiagramUpdateRequest, - MermaidGroupListResponse, -) -from ...services.mermaid_service import ( - create_mermaid_diagram, - delete_mermaid_diagram, - get_mermaid_diagram_summary, - list_mermaid_groups, - search_mermaid_diagrams, - stream_chat_mermaid_code, - stream_generate_mermaid_code, - update_mermaid_diagram, - update_mermaid_diagram_data, -) - -router = APIRouter(prefix="/mermaids/diagrams", tags=["mermaids"]) - - -@router.post("/search", response_model=MermaidDiagramPageResponse) -def search_mermaid_endpoint( - payload: MermaidDiagramQueryRequest, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MermaidDiagramPageResponse: - return search_mermaid_diagrams(db, payload, actor=current_user.user) - - -@router.get("/groups", response_model=MermaidGroupListResponse) -def list_mermaid_group_endpoint( - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MermaidGroupListResponse: - return list_mermaid_groups(db, actor=current_user.user) - - -@router.get("/get/{diagram_id}", response_model=MermaidDiagramSummary) -def get_mermaid_endpoint( - diagram_id: str, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MermaidDiagramSummary: - item = get_mermaid_diagram_summary(db, diagram_id, actor=current_user.user) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mermaid diagram not found") - return item - - -@router.post("/create", response_model=MermaidDiagramSummary) -def create_mermaid_endpoint( - payload: MermaidDiagramCreateRequest, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MermaidDiagramSummary: - return create_mermaid_diagram(db, payload, actor=current_user.user) - - -@router.put("/update", response_model=MermaidDiagramSummary) -def update_mermaid_endpoint( - payload: MermaidDiagramUpdateRequest, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MermaidDiagramSummary: - return update_mermaid_diagram(db, payload, actor=current_user.user) - - -@router.delete("/delete/{diagram_id}") -def delete_mermaid_endpoint( - diagram_id: str, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> dict[str, bool]: - return delete_mermaid_diagram(db, diagram_id, actor=current_user.user) - - -@router.patch("/{diagram_id}/data", response_model=MermaidDiagramSummary) -def update_mermaid_data_endpoint( - diagram_id: str, - payload: MermaidDiagramDataPatchRequest, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MermaidDiagramSummary: - return update_mermaid_diagram_data(db, diagram_id, payload, actor=current_user.user) - - -@router.get("/generate/stream") -async def generate_mermaid_stream_endpoint( - advice: str = Query(min_length=1), - diagram_data: str | None = Query(default=None, alias="diagramData"), - model_name: str | None = Query(default=None, alias="modelName"), - _: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> StreamingResponse: - async def event_gen() -> AsyncGenerator[str, None]: - async for chunk in stream_generate_mermaid_code( - db, - advice=advice, - diagram_data=diagram_data, - model_name=model_name, - ): - yield f"data: {chunk}\n\n" - - return StreamingResponse( - event_gen(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@router.post("/chat/stream") -async def chat_mermaid_stream_endpoint( - payload: MermaidChatStreamRequest, - _: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> StreamingResponse: - async def event_gen() -> AsyncGenerator[str, None]: - async for chunk in stream_chat_mermaid_code(db, payload): - yield f"data: {chunk}\n\n" - - return StreamingResponse( - event_gen(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) diff --git a/api/app/api/v1/mind_map.py b/api/app/api/v1/mind_map.py deleted file mode 100644 index b6fd78c..0000000 --- a/api/app/api/v1/mind_map.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator - -from fastapi import APIRouter, Depends, HTTPException, Query, status -from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session - -from ...core.database import get_db -from ...core.dependencies import CurrentUser, require_any_permission, require_permission -from ...schemas.mind_map import ( - MindMapBasicInfoUpdateRequest, - MindMapCreateRequest, - MindMapDataUpdateRequest, - MindMapPageResponse, - MindMapQueryRequest, - MindMapSummary, -) -from ...services.mind_map_service import ( - create_mind_map, - delete_mind_map, - get_mind_map_by_id, - search_mind_maps, - serialize_mind_map, - stream_generate_mind_map, - update_mind_map_basic_info, - update_mind_map_data, -) - -router = APIRouter(prefix="/mindmap", tags=["mindmap"]) - - -@router.post("/search", response_model=MindMapPageResponse) -def search_mind_map_endpoint( - payload: MindMapQueryRequest, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MindMapPageResponse: - return search_mind_maps(db, payload, actor=current_user.user) - - -@router.get("/get/{mind_map_id}", response_model=MindMapSummary) -def get_mind_map_endpoint( - mind_map_id: str, - current_user: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> MindMapSummary: - item = get_mind_map_by_id(db, mind_map_id, actor=current_user.user) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mind map not found") - return serialize_mind_map(item) - - -@router.post("/create", response_model=MindMapSummary) -def create_mind_map_endpoint( - payload: MindMapCreateRequest, - current_user: CurrentUser = Depends(require_permission("question_bank.manage")), - db: Session = Depends(get_db), -) -> MindMapSummary: - return create_mind_map(db, payload, actor=current_user.user) - - -@router.put("/update-basic-info", response_model=MindMapSummary) -def update_mind_map_basic_info_endpoint( - payload: MindMapBasicInfoUpdateRequest, - current_user: CurrentUser = Depends(require_permission("question_bank.manage")), - db: Session = Depends(get_db), -) -> MindMapSummary: - return update_mind_map_basic_info(db, payload, actor=current_user.user) - - -@router.put("/update-data", response_model=MindMapSummary) -def update_mind_map_data_endpoint( - payload: MindMapDataUpdateRequest, - current_user: CurrentUser = Depends(require_permission("question_bank.manage")), - db: Session = Depends(get_db), -) -> MindMapSummary: - return update_mind_map_data(db, payload, actor=current_user.user) - - -@router.delete("/delete/{mind_map_id}") -def delete_mind_map_endpoint( - mind_map_id: str, - current_user: CurrentUser = Depends(require_permission("question_bank.manage")), - db: Session = Depends(get_db), -) -> dict[str, bool]: - return delete_mind_map(db, mind_map_id, actor=current_user.user) - - -@router.get("/generate/stream") -async def generate_mind_map_stream_endpoint( - descr: str = Query(min_length=1), - _: CurrentUser = Depends(require_any_permission("question_bank.read", "question_bank.manage")), - db: Session = Depends(get_db), -) -> StreamingResponse: - async def event_gen() -> AsyncGenerator[str, None]: - async for chunk in stream_generate_mind_map(db, descr=descr): - yield f"data: {chunk}\n\n" - - return StreamingResponse( - event_gen(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) diff --git a/api/app/api/v1/requirements.py b/api/app/api/v1/requirements.py deleted file mode 100644 index 161c1db..0000000 --- a/api/app/api/v1/requirements.py +++ /dev/null @@ -1,153 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from ...core.database import get_db -from ...core.dependencies import CurrentUser, get_current_user, require_any_permission, require_permission -from ...schemas.auth import MessageResponse -from ...schemas.requirement import ( - RequirementAssignRequest, - RequirementCommentCreateRequest, - RequirementCommentPublic, - RequirementCreateRequest, - RequirementEventPublic, - RequirementListResponse, - RequirementSummary, - RequirementTransitionRequest, - RequirementUpdateRequest, -) -from ...services.requirement_service import ( - add_requirement_comment, - assign_requirement, - claim_requirement, - create_requirement, - delete_requirement, - get_requirement_by_id, - list_requirement_comments, - list_requirement_events, - list_requirements, - serialize_requirement, - transition_requirement, - update_requirement, -) - -router = APIRouter(prefix="/requirements", tags=["requirements"]) - - -@router.get("", response_model=RequirementListResponse) -def get_requirement_list( - keyword: str | None = Query(default=None), - status_filter: str | None = Query(default=None, alias="status"), - priority: str | None = Query(default=None), - assignee_user_id: str | None = Query(default=None), - project_name: str | None = Query(default=None), - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> RequirementListResponse: - return list_requirements( - db, - keyword=keyword, - status=status_filter, - priority=priority, - assignee_user_id=assignee_user_id, - project_name=project_name, - ) - - -@router.post("", response_model=RequirementSummary) -def create_requirement_endpoint( - payload: RequirementCreateRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.create", "requirement.manage")), - db: Session = Depends(get_db), -) -> RequirementSummary: - return create_requirement(db, payload, actor=current_user.user) - - -@router.get("/{requirement_id}", response_model=RequirementSummary) -def get_requirement_detail( - requirement_id: str, - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> RequirementSummary: - requirement = get_requirement_by_id(db, requirement_id) - if not requirement: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found") - return serialize_requirement(requirement) - - -@router.patch("/{requirement_id}", response_model=RequirementSummary) -def update_requirement_endpoint( - requirement_id: str, - payload: RequirementUpdateRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> RequirementSummary: - return update_requirement(db, requirement_id, payload, actor=current_user.user) - - -@router.post("/{requirement_id}/assign", response_model=RequirementSummary) -def assign_requirement_endpoint( - requirement_id: str, - payload: RequirementAssignRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> RequirementSummary: - return assign_requirement(db, requirement_id, payload, actor=current_user.user) - - -@router.post("/{requirement_id}/claim", response_model=RequirementSummary) -def claim_requirement_endpoint( - requirement_id: str, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> RequirementSummary: - return claim_requirement(db, requirement_id, actor=current_user.user) - - -@router.post("/{requirement_id}/transition", response_model=RequirementSummary) -def transition_requirement_endpoint( - requirement_id: str, - payload: RequirementTransitionRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> RequirementSummary: - return transition_requirement(db, requirement_id, payload, actor=current_user.user) - - -@router.delete("/{requirement_id}", response_model=MessageResponse) -def delete_requirement_endpoint( - requirement_id: str, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> MessageResponse: - deleted = delete_requirement(db, requirement_id, actor=current_user.user) - if not deleted: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found") - return MessageResponse(message="Requirement deleted") - - -@router.get("/{requirement_id}/comments", response_model=list[RequirementCommentPublic]) -def get_requirement_comments( - requirement_id: str, - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> list[RequirementCommentPublic]: - return list_requirement_comments(db, requirement_id) - - -@router.post("/{requirement_id}/comments", response_model=RequirementCommentPublic) -def create_requirement_comment( - requirement_id: str, - payload: RequirementCommentCreateRequest, - current_user: CurrentUser = Depends(require_any_permission("requirement.process", "requirement.manage")), - db: Session = Depends(get_db), -) -> RequirementCommentPublic: - return add_requirement_comment(db, requirement_id, payload, actor=current_user.user) - - -@router.get("/{requirement_id}/events", response_model=list[RequirementEventPublic]) -def get_requirement_events( - requirement_id: str, - _: CurrentUser = Depends(require_permission("requirement.read")), - db: Session = Depends(get_db), -) -> list[RequirementEventPublic]: - return list_requirement_events(db, requirement_id) diff --git a/api/app/api/v1/task_monitor.py b/api/app/api/v1/task_monitor.py index bf04334..f4102ab 100644 --- a/api/app/api/v1/task_monitor.py +++ b/api/app/api/v1/task_monitor.py @@ -1,7 +1,5 @@ from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session -from ...core.database import get_db from ...core.dependencies import CurrentUser, require_any_permission from ...schemas.task_monitor import TaskMonitorOverviewResponse from ...services.task_monitor_service import build_task_monitor_overview @@ -11,37 +9,16 @@ router = APIRouter(prefix="/admin/task-monitor", tags=["admin-task-monitor"]) @router.get("/overview", response_model=TaskMonitorOverviewResponse) def get_task_monitor_overview( - risk_limit: int = Query(default=20, ge=1, le=200), - stale_hours: int = Query(default=48, ge=1, le=24 * 30), - current_user: CurrentUser = Depends( + task_limit: int = Query(default=100, ge=1, le=500), + history_limit: int = Query(default=100, ge=0, le=500), + _: CurrentUser = Depends( require_any_permission( - "requirement.read", - "requirement.process", - "requirement.manage", - "todo.read", - "todo.process", - "todo.manage", + "celery.read", + "celery.manage", ) ), - db: Session = Depends(get_db), ) -> TaskMonitorOverviewResponse: - is_admin = "admin" in current_user.role_codes - permission_codes = current_user.permission_codes - - can_read_requirements = is_admin or bool( - {"requirement.read", "requirement.process", "requirement.manage"} & permission_codes - ) - can_read_todos = is_admin or bool( - {"todo.read", "todo.process", "todo.manage"} & permission_codes - ) - can_manage_todos = is_admin or "todo.manage" in permission_codes - return build_task_monitor_overview( - db, - actor=current_user.user, - can_read_requirements=can_read_requirements, - can_read_todos=can_read_todos, - can_manage_todos=can_manage_todos, - risk_limit=risk_limit, - stale_hours=stale_hours, + task_limit=task_limit, + history_limit=history_limit, ) diff --git a/api/app/api/v1/todos.py b/api/app/api/v1/todos.py deleted file mode 100644 index 9b5503e..0000000 --- a/api/app/api/v1/todos.py +++ /dev/null @@ -1,116 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from ...core.database import get_db -from ...core.dependencies import CurrentUser, require_any_permission, require_permission -from ...schemas.todo import ( - TodoCreateRequest, - TodoListResponse, - TodoMindMapInitResponse, - TodoSummary, - TodoTransitionRequest, - TodoUpdateRequest, -) -from ...services.todo_service import ( - complete_todo, - create_todo, - delete_todo, - get_todo_by_id, - init_todo_mindmap, - list_todos, - serialize_todo, - transition_todo, - update_todo, -) - -router = APIRouter(prefix="/todos", tags=["todos"]) - - -@router.get("", response_model=TodoListResponse) -def get_todo_list( - title: str | None = Query(default=None), - keyword: str | None = Query(default=None), - status_filter: str | None = Query(default=None, alias="status"), - priority: str | None = Query(default=None), - page_num: int = Query(default=0, ge=0), - page_size: int = Query(default=20, ge=1, le=200), - current_user: CurrentUser = Depends(require_permission("todo.read")), - db: Session = Depends(get_db), -) -> TodoListResponse: - return list_todos( - db, - title=title or keyword, - status_filter=status_filter, - priority=priority, - page_num=page_num, - page_size=page_size, - actor=current_user.user, - ) - - -@router.post("", response_model=TodoSummary) -def create_todo_endpoint( - payload: TodoCreateRequest, - current_user: CurrentUser = Depends(require_any_permission("todo.create", "todo.manage")), - db: Session = Depends(get_db), -) -> TodoSummary: - return create_todo(db, payload, actor=current_user.user) - - -@router.get("/{todo_id}", response_model=TodoSummary) -def get_todo_detail( - todo_id: str, - current_user: CurrentUser = Depends(require_permission("todo.read")), - db: Session = Depends(get_db), -) -> TodoSummary: - todo = get_todo_by_id(db, todo_id, actor=current_user.user) - if not todo: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found") - return serialize_todo(todo) - - -@router.patch("/{todo_id}", response_model=TodoSummary) -def update_todo_endpoint( - todo_id: str, - payload: TodoUpdateRequest, - current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")), - db: Session = Depends(get_db), -) -> TodoSummary: - return update_todo(db, todo_id, payload, actor=current_user.user) - - -@router.post("/{todo_id}/transition", response_model=TodoSummary) -def transition_todo_endpoint( - todo_id: str, - payload: TodoTransitionRequest, - current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")), - db: Session = Depends(get_db), -) -> TodoSummary: - return transition_todo(db, todo_id, payload, actor=current_user.user) - - -@router.post("/{todo_id}/complete", response_model=TodoSummary) -def complete_todo_endpoint( - todo_id: str, - current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")), - db: Session = Depends(get_db), -) -> TodoSummary: - return complete_todo(db, todo_id, actor=current_user.user) - - -@router.post("/{todo_id}/init-mindmap", response_model=TodoMindMapInitResponse) -def init_todo_mindmap_endpoint( - todo_id: str, - current_user: CurrentUser = Depends(require_permission("todo.read")), - db: Session = Depends(get_db), -) -> TodoMindMapInitResponse: - return init_todo_mindmap(db, todo_id, actor=current_user.user) - - -@router.delete("/{todo_id}") -def delete_todo_endpoint( - todo_id: str, - current_user: CurrentUser = Depends(require_any_permission("todo.manage", "todo.process")), - db: Session = Depends(get_db), -) -> dict[str, bool]: - return delete_todo(db, todo_id, actor=current_user.user) diff --git a/api/app/core/config.py b/api/app/core/config.py index 2d84830..30cbd68 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -55,6 +55,13 @@ class Settings(BaseSettings): wine_default_timeout_seconds: int = 300 wine_max_timeout_seconds: int = 1800 + atp_engine_mode: Literal["wine", "native"] = "wine" + atp_engine_executable: str = "atp/tpbig.exe" + atp_storage_root: str = "./data/wine/atp-models" + atp_engine_workdir: str = "runs" + atp_engine_default_timeout_seconds: int = 600 + atp_engine_max_timeout_seconds: int = 3600 + initial_admin_email: str | None = None initial_admin_username: str = "admin" initial_admin_password: str | None = None @@ -75,6 +82,8 @@ class Settings(BaseSettings): "scheduler_expire_interval_seconds", "wine_default_timeout_seconds", "wine_max_timeout_seconds", + "atp_engine_default_timeout_seconds", + "atp_engine_max_timeout_seconds", ) @classmethod def validate_positive_numbers(cls, value: int) -> int: diff --git a/api/app/core/database.py b/api/app/core/database.py index f136cc2..8b7955d 100644 --- a/api/app/core/database.py +++ b/api/app/core/database.py @@ -49,10 +49,10 @@ def get_db() -> Generator[Session, None, None]: def init_db() -> None: # Import models so metadata includes every table before create_all. from ..models import ( + atp_model, audit_log, auth_session, calendar_event, - chat, diary, file_storage, hot_search, @@ -62,8 +62,6 @@ def init_db() -> None: line, line_tower, menu, - mermaid_diagram, - mind_map, model_registry, object_group, question_bank, diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index e05cd51..33776dc 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -4,13 +4,13 @@ Import all model modules during package initialization so SQLAlchemy can resolve string-based relationships regardless of route/service import order. """ -from . import audit_log, auth_session, calendar_event, chat, diary, file_storage, hot_search, life_countdown, lightning_event, lightning_sample, line, line_tower, menu, mermaid_diagram, mind_map, model_registry, object_group, question_bank, rbac, requirement, system_param, todo, user, vocabulary_word +from . import atp_model, audit_log, auth_session, calendar_event, diary, file_storage, hot_search, life_countdown, lightning_event, lightning_sample, line, line_tower, menu, model_registry, object_group, question_bank, rbac, requirement, system_param, todo, user, vocabulary_word __all__ = [ + "atp_model", "audit_log", "auth_session", "calendar_event", - "chat", "diary", "file_storage", "hot_search", @@ -20,8 +20,6 @@ __all__ = [ "line", "line_tower", "menu", - "mermaid_diagram", - "mind_map", "model_registry", "object_group", "question_bank", diff --git a/api/app/models/atp_model.py b/api/app/models/atp_model.py new file mode 100644 index 0000000..07f3aa5 --- /dev/null +++ b/api/app/models/atp_model.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.database import Base +from .base import utcnow + +class AtpModel(Base): + __tablename__ = "atp_model" + __table_args__ = ( + UniqueConstraint("code", name="uq_atp_model_code"), + Index("idx_atp_model_status", "status"), + Index("idx_atp_model_source", "source_type"), + ) + + id: Mapped[str] = mapped_column( + String(32), + primary_key=True, + default=lambda: uuid4().hex, + ) + code: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + source_type: Mapped[str] = mapped_column(String(32), default="atpdraw", index=True) + description: Mapped[str] = mapped_column(Text(), default="") + status: Mapped[str] = mapped_column(String(20), default="enabled", index=True) + tags_json: Mapped[list[str]] = mapped_column(JSON, default=list) + latest_version_no: Mapped[int] = mapped_column(Integer, default=0) + active_version_no: Mapped[int | None] = mapped_column(Integer) + create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) + create_user: Mapped[str | None] = mapped_column(String(64), index=True) + update_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + update_user: Mapped[str | None] = mapped_column(String(64), index=True) + + versions: Mapped[list[AtpModelVersion]] = relationship( + "AtpModelVersion", + back_populates="model", + lazy="selectin", + cascade="all, delete-orphan", + order_by="AtpModelVersion.version_no.desc()", + ) + runs: Mapped[list[AtpSimulationRun]] = relationship( + "AtpSimulationRun", + back_populates="model", + lazy="selectin", + cascade="all, delete-orphan", + order_by="AtpSimulationRun.create_date.desc()", + ) + + +class AtpModelVersion(Base): + __tablename__ = "atp_model_version" + __table_args__ = ( + UniqueConstraint("model_id", "version_no", name="uq_atp_model_version_model_no"), + Index("idx_atp_model_version_status", "status"), + Index("idx_atp_model_version_model_status", "model_id", "status"), + Index("idx_atp_model_version_content_hash", "content_hash"), + ) + + id: Mapped[str] = mapped_column( + String(32), + primary_key=True, + default=lambda: uuid4().hex, + ) + model_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("atp_model.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + version_no: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + version_tag: Mapped[str | None] = mapped_column(String(64), index=True) + status: Mapped[str] = mapped_column(String(20), default="draft", index=True) + entry_file: Mapped[str | None] = mapped_column(String(255)) + change_note: Mapped[str] = mapped_column(Text(), default="") + artifact_manifest_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + graph_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + atp_text: Mapped[str] = mapped_column(Text(), default="") + content_hash: Mapped[str] = mapped_column(String(64), index=True) + create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) + create_user: Mapped[str | None] = mapped_column(String(64), index=True) + update_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + update_user: Mapped[str | None] = mapped_column(String(64), index=True) + + model: Mapped[AtpModel] = relationship("AtpModel", back_populates="versions", lazy="selectin") + runs: Mapped[list[AtpSimulationRun]] = relationship( + "AtpSimulationRun", + back_populates="version", + lazy="selectin", + order_by="AtpSimulationRun.create_date.desc()", + ) + + +class AtpSimulationRun(Base): + __tablename__ = "atp_simulation_run" + __table_args__ = ( + Index("idx_atp_simulation_run_status", "status"), + Index("idx_atp_simulation_run_model", "model_id", "create_date"), + ) + + id: Mapped[str] = mapped_column( + String(32), + primary_key=True, + default=lambda: uuid4().hex, + ) + model_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("atp_model.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + version_id: Mapped[str | None] = mapped_column( + String(32), + ForeignKey("atp_model_version.id", ondelete="SET NULL"), + index=True, + ) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + engine_mode: Mapped[str] = mapped_column(String(20), default="wine", index=True) + engine_command: Mapped[str | None] = mapped_column(String(1000)) + working_dir: Mapped[str | None] = mapped_column(String(1000)) + timeout_seconds: Mapped[int] = mapped_column(Integer, default=600) + exit_code: Mapped[int | None] = mapped_column(Integer) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + duration_ms: Mapped[int | None] = mapped_column(Integer) + stdout_text: Mapped[str | None] = mapped_column(Text()) + stderr_text: Mapped[str | None] = mapped_column(Text()) + error_message: Mapped[str | None] = mapped_column(Text()) + create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) + create_user: Mapped[str | None] = mapped_column(String(64), index=True) + update_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + update_user: Mapped[str | None] = mapped_column(String(64), index=True) + + model: Mapped[AtpModel] = relationship("AtpModel", back_populates="runs", lazy="selectin") + version: Mapped[AtpModelVersion | None] = relationship("AtpModelVersion", back_populates="runs", lazy="selectin") diff --git a/api/app/models/chat.py b/api/app/models/chat.py deleted file mode 100644 index 84719cf..0000000 --- a/api/app/models/chat.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import TYPE_CHECKING -from uuid import uuid4 - -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from ..core.database import Base -from .base import utcnow - -if TYPE_CHECKING: - from .user import User - - -class ChatSession(Base): - __tablename__ = "chat_sessions" - - id: Mapped[str] = mapped_column( - String(36), - primary_key=True, - default=lambda: str(uuid4()), - ) - owner_user_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("users.user_id", ondelete="CASCADE"), - index=True, - ) - title: Mapped[str] = mapped_column(String(200), default="新会话") - system_prompt: Mapped[str] = mapped_column(Text(), default="") - model_code: Mapped[str | None] = mapped_column(String(64), index=True) - last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=utcnow, - onupdate=utcnow, - ) - - owner: Mapped[User] = relationship("User", lazy="selectin") - messages: Mapped[list[ChatMessage]] = relationship( - "ChatMessage", - back_populates="session", - lazy="selectin", - cascade="all, delete-orphan", - order_by="ChatMessage.created_at.asc(), ChatMessage.id.asc()", - ) - - -class ChatMessage(Base): - __tablename__ = "chat_messages" - - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - session_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("chat_sessions.id", ondelete="CASCADE"), - index=True, - ) - author_user_id: Mapped[str | None] = mapped_column( - String(36), - ForeignKey("users.user_id", ondelete="SET NULL"), - index=True, - ) - role: Mapped[str] = mapped_column(String(16), index=True) - content: Mapped[str] = mapped_column(Text()) - is_error: Mapped[bool] = mapped_column(Boolean, default=False, index=True) - model_code: Mapped[str | None] = mapped_column(String(64), index=True) - provider: Mapped[str | None] = mapped_column(String(64), index=True) - provider_model: Mapped[str | None] = mapped_column(String(128), index=True) - prompt_tokens: Mapped[int | None] = mapped_column(Integer) - completion_tokens: Mapped[int | None] = mapped_column(Integer) - total_tokens: Mapped[int | None] = mapped_column(Integer) - latency_ms: Mapped[int | None] = mapped_column(Integer) - error_message: Mapped[str | None] = mapped_column(Text()) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) - - session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages", lazy="selectin") - author: Mapped[User | None] = relationship("User", lazy="selectin") diff --git a/api/app/models/mermaid_diagram.py b/api/app/models/mermaid_diagram.py deleted file mode 100644 index a3e272b..0000000 --- a/api/app/models/mermaid_diagram.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, Index, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column - -from ..core.database import Base -from .base import utcnow - - -class MermaidDiagram(Base): - __tablename__ = "mermaid_diagram" - __table_args__ = ( - Index("idx_mermaid_diagram_name", "diagram_name"), - Index("idx_mermaid_diagram_create_date", "create_date"), - ) - - id: Mapped[str] = mapped_column( - String(32), - primary_key=True, - default=lambda: uuid4().hex, - ) - diagram_name: Mapped[str] = mapped_column(String(255), nullable=False) - description: Mapped[str | None] = mapped_column(Text(), default="") - diagram_data: Mapped[str | None] = mapped_column(Text(), default="") - create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) - create_user: Mapped[str | None] = mapped_column(String(64), index=True) - update_date: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=utcnow, - onupdate=utcnow, - ) - update_user: Mapped[str | None] = mapped_column(String(64), index=True) - - -class MermaidDiagramHistory(Base): - __tablename__ = "mermaid_diagram_history" - __table_args__ = ( - Index("idx_mermaid_history_diagram_id", "diagram_id"), - Index("idx_mermaid_history_create_date", "create_date"), - ) - - id: Mapped[str] = mapped_column( - "history_id", - String(32), - primary_key=True, - default=lambda: uuid4().hex, - ) - diagram_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True) - version_num: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - diagram_data: Mapped[str | None] = mapped_column(Text(), default="") - description: Mapped[str | None] = mapped_column(String(255)) - create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) - create_user: Mapped[str | None] = mapped_column(String(64), index=True) diff --git a/api/app/models/mind_map.py b/api/app/models/mind_map.py deleted file mode 100644 index c77ad02..0000000 --- a/api/app/models/mind_map.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, Index, String, Text -from sqlalchemy.orm import Mapped, mapped_column - -from ..core.database import Base -from .base import utcnow - - -class MindMap(Base): - __tablename__ = "mind_map" - __table_args__ = ( - Index("idx_mind_map_name", "map_name"), - Index("idx_mind_map_create_date", "create_date"), - ) - - id: Mapped[str] = mapped_column( - String(32), - primary_key=True, - default=lambda: uuid4().hex, - ) - map_name: Mapped[str] = mapped_column(String(255), nullable=False) - descr: Mapped[str | None] = mapped_column(Text(), default="") - map_data: Mapped[str | None] = mapped_column(Text(), default="") - create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) - create_user: Mapped[str | None] = mapped_column(String(64), index=True) - update_date: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=utcnow, - onupdate=utcnow, - ) - update_user: Mapped[str | None] = mapped_column(String(64), index=True) diff --git a/api/app/schemas/atp_model.py b/api/app/schemas/atp_model.py new file mode 100644 index 0000000..63e1249 --- /dev/null +++ b/api/app/schemas/atp_model.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + +AtpModelStatus = Literal["enabled", "disabled"] +AtpModelSourceType = Literal["atpdraw", "atp", "manual"] +AtpModelVersionStatus = Literal["draft", "released", "archived"] +AtpSimulationRunStatus = Literal["pending", "running", "success", "failed"] +AtpEngineMode = Literal["wine", "native"] + + +class AtpModelSummary(BaseModel): + id: str + code: str + name: str + source_type: AtpModelSourceType + description: str + status: AtpModelStatus + tags_json: list[str] = Field(default_factory=list) + latest_version_no: int = 0 + active_version_no: int | None = None + version_count: int = 0 + run_count: int = 0 + last_run_status: AtpSimulationRunStatus | None = None + last_run_date: datetime | None = None + create_date: datetime + create_user: str | None = None + update_date: datetime + update_user: str | None = None + + +class AtpModelListResponse(BaseModel): + items: list[AtpModelSummary] + total: int + + +class AtpModelCreateRequest(BaseModel): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=255) + source_type: AtpModelSourceType = "atpdraw" + description: str = Field(default="", max_length=8000) + status: AtpModelStatus = "enabled" + tags_json: list[str] = Field(default_factory=list, max_length=128) + + +class AtpModelUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + source_type: AtpModelSourceType | None = None + description: str | None = Field(default=None, max_length=8000) + status: AtpModelStatus | None = None + tags_json: list[str] | None = Field(default=None, max_length=128) + + +class AtpModelVersionSummary(BaseModel): + id: str + model_id: str + version_no: int + version_tag: str | None = None + status: AtpModelVersionStatus + entry_file: str | None = None + change_note: str + artifact_manifest_json: dict[str, Any] = Field(default_factory=dict) + content_hash: str + atp_text_size: int + create_date: datetime + create_user: str | None = None + update_date: datetime + update_user: str | None = None + + +class AtpModelVersionDetail(AtpModelVersionSummary): + atp_text: str + graph_json: dict[str, Any] = Field(default_factory=dict) + + +class AtpModelVersionListResponse(BaseModel): + items: list[AtpModelVersionSummary] + total: int + + +class AtpModelVersionCreateRequest(BaseModel): + version_tag: str | None = Field(default=None, max_length=64) + status: AtpModelVersionStatus = "released" + entry_file: str | None = Field(default=None, max_length=255) + change_note: str = Field(default="", max_length=8000) + artifact_manifest_json: dict[str, Any] = Field(default_factory=dict) + graph_json: dict[str, Any] = Field(default_factory=dict) + atp_text: str = Field(min_length=1) + + +class AtpModelVersionUpdateRequest(BaseModel): + version_tag: str | None = Field(default=None, max_length=64) + status: AtpModelVersionStatus | None = None + entry_file: str | None = Field(default=None, max_length=255) + change_note: str | None = Field(default=None, max_length=8000) + artifact_manifest_json: dict[str, Any] | None = None + graph_json: dict[str, Any] | None = None + atp_text: str | None = Field(default=None, min_length=1) + + +class AtpSimulationRunSummary(BaseModel): + id: str + model_id: str + version_id: str | None = None + version_no: int | None = None + status: AtpSimulationRunStatus + engine_mode: AtpEngineMode + engine_command: str | None = None + working_dir: str | None = None + timeout_seconds: int + exit_code: int | None = None + started_at: datetime | None = None + finished_at: datetime | None = None + duration_ms: int | None = None + error_message: str | None = None + stdout_size: int = 0 + stderr_size: int = 0 + create_date: datetime + create_user: str | None = None + + +class AtpSimulationRunDetail(AtpSimulationRunSummary): + stdout_text: str | None = None + stderr_text: str | None = None + + +class AtpSimulationRunListResponse(BaseModel): + items: list[AtpSimulationRunSummary] + total: int + + +class AtpSimulationRunRequest(BaseModel): + version_id: str | None = None + version_no: int | None = Field(default=None, ge=1) + timeout_seconds: int | None = Field(default=None, ge=1) + extra_args: list[str] = Field(default_factory=list, max_length=32) + environment: dict[str, str] = Field(default_factory=dict, max_length=16) + dry_run: bool = False + + +class AtpEngineStatusResponse(BaseModel): + mode: AtpEngineMode + available: bool + executable_path: str + resolved_executable: str | None = None + storage_root: str + workdir: str + default_timeout_seconds: int + max_timeout_seconds: int + error: str | None = None diff --git a/api/app/schemas/chat.py b/api/app/schemas/chat.py deleted file mode 100644 index a8491a0..0000000 --- a/api/app/schemas/chat.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Literal - -from pydantic import BaseModel, Field - -ChatRole = Literal["system", "user", "assistant"] - - -class ChatSessionCreateRequest(BaseModel): - title: str | None = Field(default=None, min_length=1, max_length=200) - system_prompt: str | None = Field(default=None, max_length=4000) - - -class ChatSessionPublic(BaseModel): - id: str - owner_user_id: str - title: str - system_prompt: str - model_code: str | None = None - last_message_at: datetime | None = None - created_at: datetime - updated_at: datetime - - -class ChatSessionListResponse(BaseModel): - items: list[ChatSessionPublic] - total: int - - -class ChatMessageCreateRequest(BaseModel): - content: str = Field(min_length=1, max_length=20000) - - -class ChatMessagePublic(BaseModel): - id: int - session_id: str - author_user_id: str | None = None - role: ChatRole - content: str - is_error: bool - model_code: str | None = None - provider: str | None = None - provider_model: str | None = None - prompt_tokens: int | None = None - completion_tokens: int | None = None - total_tokens: int | None = None - latency_ms: int | None = None - error_message: str | None = None - created_at: datetime - - -class ChatMessageListResponse(BaseModel): - items: list[ChatMessagePublic] - total: int - - -class ChatSendResponse(BaseModel): - session: ChatSessionPublic - user_message: ChatMessagePublic - assistant_message: ChatMessagePublic diff --git a/api/app/schemas/lightning.py b/api/app/schemas/lightning.py index ebcc56a..7e74dac 100644 --- a/api/app/schemas/lightning.py +++ b/api/app/schemas/lightning.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator LightningPolarity = Literal["positive", "negative", "mixed", "unknown"] @@ -191,6 +191,26 @@ class LightningTowerBufferEventItem(LightningDistributionEventBrief): distance_km: float +class LightningTowerTerrainMetrics(BaseModel): + slope_deg: float | None = None + aspect_deg: float | None = None + slope_mean_deg: float | None = None + slope_p95_deg: float | None = None + slope_max_deg: float | None = None + slope_along_line_deg: float | None = None + slope_cross_line_deg: float | None = None + relief_m_50: float | None = None + dem_source: str | None = None + dem_resolution_m: float | None = None + quality_score: float | None = None + quality_level: str | None = None + terrain_exposure_index: float | None = None + windward_factor: float | None = None + algorithm_version: str | None = None + computed_at: datetime | None = None + land_cover_type: str | None = None + + class LightningTowerBufferStatsResponse(BaseModel): tower_id: str | None = None tower_no: str | None = None @@ -208,6 +228,43 @@ class LightningTowerBufferStatsResponse(BaseModel): risk_level: str recommended_action: str events: list[LightningTowerBufferEventItem] = Field(default_factory=list) + terrain_metrics: LightningTowerTerrainMetrics | None = None + + +class LightningTowerTerrainComputeRequest(BaseModel): + tower_id: str | None = Field(default=None, min_length=1, max_length=64) + longitude: float | None = None + latitude: float | None = None + altitude_m: float | None = None + dem_grid_m: list[list[float]] + cell_size_m: float = Field(default=10.0, gt=0.1, le=500) + search_radius_m: float = Field(default=50.0, gt=1.0, le=5000) + dem_source: str | None = Field(default=None, max_length=128) + dem_resolution_m: float | None = Field(default=None, gt=0) + wind_direction_deg: float | None = Field(default=None, ge=0, lt=360) + land_cover_type: str | None = Field(default=None, max_length=64) + persist: bool = False + + @field_validator("dem_grid_m") + @classmethod + def validate_dem_grid(cls, value: list[list[float]]) -> list[list[float]]: + if len(value) != 3: + raise ValueError("dem_grid_m 必须是 3x3 高程矩阵") + if any(len(row) != 3 for row in value): + raise ValueError("dem_grid_m 必须是 3x3 高程矩阵") + return value + + +class LightningTowerTerrainComputeResponse(BaseModel): + tower_id: str | None = None + tower_no: str | None = None + line_id: str | None = None + center_longitude: float + center_latitude: float + method: str = "horn_3x3" + persisted: bool = False + terrain_metrics: LightningTowerTerrainMetrics + warnings: list[str] = Field(default_factory=list) class LightningSyntheticDatasetStats(BaseModel): diff --git a/api/app/schemas/mermaid.py b/api/app/schemas/mermaid.py deleted file mode 100644 index de44dfb..0000000 --- a/api/app/schemas/mermaid.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Literal - -from pydantic import AliasChoices, BaseModel, ConfigDict, Field - - -class MermaidRequestModel(BaseModel): - model_config = ConfigDict(populate_by_name=True, extra="ignore") - - -class MermaidGroupSummary(BaseModel): - id: str - name: str - label: str - type: str | None = None - descr: str | None = None - - -class MermaidGroupListResponse(BaseModel): - items: list[MermaidGroupSummary] - total: int - - -class MermaidDiagramSummary(BaseModel): - id: str - diagram_name: str - description: str | None = None - diagram_data: str | None = None - group_name: str | None = None - group_label: str | None = None - tag_names: list[str] = Field(default_factory=list) - tag_labels: list[str] = Field(default_factory=list) - create_date: datetime - create_user: str | None = None - update_date: datetime - update_user: str | None = None - - -class MermaidDiagramPageResponse(BaseModel): - items: list[MermaidDiagramSummary] - total: int - page_num: int - page_size: int - - -class MermaidDiagramQueryRequest(MermaidRequestModel): - key_word: str | None = Field(default=None, max_length=255, validation_alias=AliasChoices("key_word", "keyWord")) - group: str | None = Field(default=None, max_length=128) - tags: list[str] | None = None - page_num: int = Field(default=0, ge=0, validation_alias=AliasChoices("page_num", "pageNum")) - page_size: int = Field(default=20, ge=1, le=500, validation_alias=AliasChoices("page_size", "pageSize")) - - -class MermaidDiagramCreateRequest(MermaidRequestModel): - diagram_name: str = Field(min_length=1, max_length=255, validation_alias=AliasChoices("diagram_name", "diagramName")) - description: str | None = Field(default="", max_length=20000) - diagram_data: str | None = Field(default="", max_length=200000, validation_alias=AliasChoices("diagram_data", "diagramData")) - group: str | None = Field(default=None, max_length=128) - tags: list[str] = Field(default_factory=list) - - -class MermaidDiagramUpdateRequest(MermaidRequestModel): - id: str = Field(min_length=1, max_length=32) - diagram_name: str | None = Field(default=None, min_length=1, max_length=255, validation_alias=AliasChoices("diagram_name", "diagramName")) - description: str | None = Field(default=None, max_length=20000) - diagram_data: str | None = Field(default=None, max_length=200000, validation_alias=AliasChoices("diagram_data", "diagramData")) - group: str | None = Field(default=None, max_length=128) - tags: list[str] | None = None - - -class MermaidDiagramDataPatchRequest(MermaidRequestModel): - diagram_data: str = Field(min_length=1, max_length=200000, validation_alias=AliasChoices("diagram_data", "diagramData")) - - -class MermaidChatMessage(BaseModel): - role: Literal["user", "assistant"] - content: str = Field(min_length=1, max_length=20000) - - -class MermaidChatStreamRequest(MermaidRequestModel): - model_name: str | None = Field(default=None, max_length=128, validation_alias=AliasChoices("model_name", "modelName")) - diagram_data: str | None = Field(default=None, max_length=200000, validation_alias=AliasChoices("diagram_data", "diagramData")) - messages: list[MermaidChatMessage] = Field(default_factory=list) diff --git a/api/app/schemas/mind_map.py b/api/app/schemas/mind_map.py deleted file mode 100644 index 323e71d..0000000 --- a/api/app/schemas/mind_map.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from pydantic import BaseModel, Field - - -class MindMapSummary(BaseModel): - id: str - map_name: str - descr: str | None = None - map_data: str | None = None - create_date: datetime - create_user: str | None = None - update_date: datetime - update_user: str | None = None - - -class MindMapPageResponse(BaseModel): - items: list[MindMapSummary] - total: int - page_num: int - page_size: int - - -class MindMapQueryRequest(BaseModel): - map_name: str | None = Field(default=None, max_length=255) - page_num: int = Field(default=0, ge=0) - page_size: int = Field(default=20, ge=1, le=200) - - -class MindMapCreateRequest(BaseModel): - map_name: str = Field(min_length=1, max_length=255) - descr: str | None = Field(default="", max_length=20000) - map_data: str | None = Field(default=None) - - -class MindMapBasicInfoUpdateRequest(BaseModel): - id: str = Field(min_length=1, max_length=32) - map_name: str = Field(min_length=1, max_length=255) - descr: str | None = Field(default="", max_length=20000) - - -class MindMapDataUpdateRequest(BaseModel): - id: str = Field(min_length=1, max_length=32) - map_data: str diff --git a/api/app/schemas/task_monitor.py b/api/app/schemas/task_monitor.py index 0f1a249..af3ec1f 100644 --- a/api/app/schemas/task_monitor.py +++ b/api/app/schemas/task_monitor.py @@ -11,40 +11,50 @@ class TaskMonitorBucketItem(BaseModel): count: int -class TaskMonitorRequirementRiskItem(BaseModel): - id: str - title: str - status: str - priority: str - updated_at: datetime - stale_hours: int +class TaskMonitorWorkerItem(BaseModel): + worker: str + online: bool = True + queue_names: list[str] = Field(default_factory=list) + max_concurrency: int = 0 + prefetch_count: int = 0 + uptime_seconds: int = 0 + processed_total: int = 0 + active_count: int = 0 + reserved_count: int = 0 + scheduled_count: int = 0 -class TaskMonitorTodoRiskItem(BaseModel): - id: str - title: str - status: str - priority: str - due_date: datetime | None = None - expire_time: datetime | None = None - overdue_hours: int +class TaskMonitorQueueItem(BaseModel): + name: str + pending_count: int = 0 + consumer_count: int = 0 + active_count: int = 0 + reserved_count: int = 0 + scheduled_count: int = 0 + + +class TaskMonitorTaskItem(BaseModel): + task_id: str + name: str + state: str + queue_name: str | None = None + worker: str | None = None + retries: int = 0 + eta: datetime | None = None + started_at: datetime | None = None + done_at: datetime | None = None + runtime_seconds: float | None = None + error: str | None = None class TaskMonitorOverviewResponse(BaseModel): generated_at: datetime - - requirement_total: int = 0 - requirement_active: int = 0 - requirement_completed: int = 0 - requirement_status_buckets: list[TaskMonitorBucketItem] = Field(default_factory=list) - requirement_priority_buckets: list[TaskMonitorBucketItem] = Field(default_factory=list) - high_priority_requirements: list[TaskMonitorRequirementRiskItem] = Field(default_factory=list) - stale_requirements: list[TaskMonitorRequirementRiskItem] = Field(default_factory=list) - - todo_total: int = 0 - todo_active: int = 0 - todo_completed: int = 0 - todo_overdue: int = 0 - todo_status_buckets: list[TaskMonitorBucketItem] = Field(default_factory=list) - todo_priority_buckets: list[TaskMonitorBucketItem] = Field(default_factory=list) - overdue_todos: list[TaskMonitorTodoRiskItem] = Field(default_factory=list) + broker_url: str = "" + result_backend: str = "" + workers_online: int = 0 + worker_concurrency_total: int = 0 + queue_pending_total: int = 0 + task_state_buckets: list[TaskMonitorBucketItem] = Field(default_factory=list) + workers: list[TaskMonitorWorkerItem] = Field(default_factory=list) + queues: list[TaskMonitorQueueItem] = Field(default_factory=list) + tasks: list[TaskMonitorTaskItem] = Field(default_factory=list) diff --git a/api/app/schemas/todo.py b/api/app/schemas/todo.py index 751b262..f9a09e7 100644 --- a/api/app/schemas/todo.py +++ b/api/app/schemas/todo.py @@ -58,10 +58,3 @@ class TodoTransitionRequest(BaseModel): status: TodoStatus note: str | None = Field(default=None, max_length=2000) is_sync: bool = False - - -class TodoMindMapInitResponse(BaseModel): - id: str - map_name: str - descr: str | None = None - map_data: str diff --git a/api/app/services/admin_service.py b/api/app/services/admin_service.py index 1e8e478..7e6c268 100644 --- a/api/app/services/admin_service.py +++ b/api/app/services/admin_service.py @@ -37,6 +37,16 @@ REMOVED_MENU_CODES = { "admin.inbox", "admin.code_review", "admin.git_desktop", + "admin.agent", + "admin.mcp_server", + "admin.requirements", + "admin.schedule", + "admin.mindmap", + "admin.mermaid_mgr", + "admin.chat", + "admin.api_tester", + "admin.models", + "admin.orchestration", "admin.mdresolve", "admin.data_query", "admin.hot_search", @@ -393,7 +403,7 @@ def update_menu(db: Session, menu_id: int, payload: MenuUpdateRequest) -> MenuPu def delete_menu(db: Session, menu_id: int) -> bool: menu = get_menu_by_id(db, menu_id) - if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.agent", "admin.mcp_server", "admin.files", "admin.requirements", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.schedule", "admin.mindmap", "admin.mermaid_mgr", "admin.syslog", "admin.chat", "admin.api_tester", "admin.models", "admin.orchestration", "admin.wine_runner"}: + if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.task_monitor", "admin.atp_models", "admin.files", "admin.syslog", "admin.wine_runner"}: return False child_exists = db.scalar(select(Menu.id).where(Menu.parent_id == menu_id)) if child_exists is not None: diff --git a/api/app/services/atp_model_service.py b/api/app/services/atp_model_service.py new file mode 100644 index 0000000..db8943c --- /dev/null +++ b/api/app/services/atp_model_service.py @@ -0,0 +1,1001 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import time +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from ..core.config import get_settings +from ..models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun +from ..models.base import utcnow +from ..schemas.atp_model import ( + AtpEngineStatusResponse, + AtpModelCreateRequest, + AtpModelListResponse, + AtpModelSummary, + AtpModelUpdateRequest, + AtpModelVersionCreateRequest, + AtpModelVersionDetail, + AtpModelVersionListResponse, + AtpModelVersionSummary, + AtpModelVersionUpdateRequest, + AtpSimulationRunDetail, + AtpSimulationRunListResponse, + AtpSimulationRunRequest, + AtpSimulationRunSummary, +) +from .push_service import publish_topic + + +settings = get_settings() +ATP_TOPIC = "admin.atp-models" +VALID_MODEL_STATUS = {"enabled", "disabled"} +VALID_VERSION_STATUS = {"draft", "released", "archived"} +LOG_MAX_CHARS = 200_000 +FILENAME_SANITIZE_PATTERN = re.compile(r"[^A-Za-z0-9._-]+") + + +def _fire_and_forget(coro: Any) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(coro) + + +def _normalize_optional_str(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + +def _normalize_tags(values: list[str] | None) -> list[str]: + if not values: + return [] + dedup: dict[str, None] = {} + for candidate in values: + normalized = candidate.strip() + if not normalized: + continue + dedup[normalized] = None + return list(dedup.keys())[:128] + + +def _hash_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8", errors="ignore")).hexdigest() + + +def _truncate_output(value: str | None) -> str | None: + if value is None: + return None + if len(value) <= LOG_MAX_CHARS: + return value + return f"{value[:LOG_MAX_CHARS]}\n...[truncated]" + + +def _safe_entry_filename(raw_name: str | None, *, model_code: str, version_no: int) -> str: + fallback = f"{model_code}_v{version_no}.atp" + if not raw_name: + return fallback + + filename = Path(raw_name).name.strip() + if not filename: + return fallback + + cleaned = FILENAME_SANITIZE_PATTERN.sub("_", filename) + cleaned = cleaned.strip("._") + if not cleaned: + return fallback + + if len(cleaned) > 220: + stem, suffix = os.path.splitext(cleaned) + cleaned = f"{stem[:200]}{suffix[:20]}" + + return cleaned + + +def _resolve_timeout(payload_timeout: int | None) -> int: + timeout_seconds = payload_timeout or settings.atp_engine_default_timeout_seconds + if timeout_seconds > settings.atp_engine_max_timeout_seconds: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"timeout_seconds cannot exceed {settings.atp_engine_max_timeout_seconds}", + ) + return timeout_seconds + + +def _resolve_engine_mode() -> str: + mode = settings.atp_engine_mode.strip().lower() + return "native" if mode == "native" else "wine" + + +def _resolve_storage_root() -> Path: + root = Path(settings.atp_storage_root).expanduser() + return root.resolve(strict=False) + + +def _resolve_engine_workdir() -> Path: + configured = Path(settings.atp_engine_workdir).expanduser() + if configured.is_absolute(): + return configured.resolve(strict=False) + return (_resolve_storage_root() / configured).resolve(strict=False) + + +def _resolve_binary(raw_path: str) -> str | None: + configured = raw_path.strip() + if not configured: + return None + + resolved = shutil.which(configured) + if resolved: + return resolved + + candidate = Path(configured).expanduser() + if candidate.exists() and candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate.resolve()) + return None + + +def _resolve_wine_engine_executable() -> tuple[str | None, str | None, str | None]: + wine_binary = _resolve_binary(settings.wine_binary_path) + if not wine_binary: + return None, None, "Wine binary not found" + + allowed_root = Path(settings.wine_allowed_root).expanduser().resolve(strict=False) + configured = Path(settings.atp_engine_executable).expanduser() + if not configured.is_absolute(): + configured = (allowed_root / configured).resolve(strict=False) + else: + configured = configured.resolve(strict=False) + + if not configured.is_relative_to(allowed_root): + return wine_binary, None, f"ATP engine executable must be inside {allowed_root}" + if not configured.exists() or not configured.is_file(): + return wine_binary, None, f"ATP engine executable not found: {configured}" + + return wine_binary, str(configured), None + + +def _resolve_native_engine_executable() -> tuple[str | None, str | None]: + resolved = _resolve_binary(settings.atp_engine_executable) + if not resolved: + return None, "ATP engine executable not found" + return resolved, None + + +def get_engine_status() -> AtpEngineStatusResponse: + mode = _resolve_engine_mode() + storage_root = str(_resolve_storage_root()) + workdir = str(_resolve_engine_workdir()) + + if mode == "wine": + wine_binary, resolved_engine, error = _resolve_wine_engine_executable() + available = error is None + executable_path = settings.atp_engine_executable.strip() + resolved_binary = f"{wine_binary} -> {resolved_engine}" if wine_binary and resolved_engine else wine_binary + return AtpEngineStatusResponse( + mode="wine", + available=available, + executable_path=executable_path, + resolved_executable=resolved_binary, + storage_root=storage_root, + workdir=workdir, + default_timeout_seconds=settings.atp_engine_default_timeout_seconds, + max_timeout_seconds=settings.atp_engine_max_timeout_seconds, + error=error, + ) + + resolved_engine, error = _resolve_native_engine_executable() + return AtpEngineStatusResponse( + mode="native", + available=error is None, + executable_path=settings.atp_engine_executable.strip(), + resolved_executable=resolved_engine, + storage_root=storage_root, + workdir=workdir, + default_timeout_seconds=settings.atp_engine_default_timeout_seconds, + max_timeout_seconds=settings.atp_engine_max_timeout_seconds, + error=error, + ) + + +def serialize_model( + item: AtpModel, + *, + version_count: int, + run_count: int, + last_run_status: str | None, + last_run_date, +) -> AtpModelSummary: + return AtpModelSummary( + id=item.id, + code=item.code, + name=item.name, + source_type=item.source_type, # type: ignore[arg-type] + description=item.description, + status=item.status, # type: ignore[arg-type] + tags_json=item.tags_json or [], + latest_version_no=item.latest_version_no, + active_version_no=item.active_version_no, + version_count=version_count, + run_count=run_count, + last_run_status=last_run_status, # type: ignore[arg-type] + last_run_date=last_run_date, + create_date=item.create_date, + create_user=item.create_user, + update_date=item.update_date, + update_user=item.update_user, + ) + + +def serialize_version(item: AtpModelVersion) -> AtpModelVersionSummary: + return AtpModelVersionSummary( + id=item.id, + model_id=item.model_id, + version_no=item.version_no, + version_tag=item.version_tag, + status=item.status, # type: ignore[arg-type] + entry_file=item.entry_file, + change_note=item.change_note, + artifact_manifest_json=item.artifact_manifest_json or {}, + content_hash=item.content_hash, + atp_text_size=len(item.atp_text or ""), + create_date=item.create_date, + create_user=item.create_user, + update_date=item.update_date, + update_user=item.update_user, + ) + + +def serialize_version_detail(item: AtpModelVersion) -> AtpModelVersionDetail: + summary = serialize_version(item) + return AtpModelVersionDetail( + **summary.model_dump(), + atp_text=item.atp_text or "", + graph_json=item.graph_json or {}, + ) + + +def serialize_run(item: AtpSimulationRun) -> AtpSimulationRunSummary: + version_no = item.version.version_no if item.version is not None else None + stdout_text = item.stdout_text or "" + stderr_text = item.stderr_text or "" + + return AtpSimulationRunSummary( + id=item.id, + model_id=item.model_id, + version_id=item.version_id, + version_no=version_no, + status=item.status, # type: ignore[arg-type] + engine_mode=item.engine_mode, # type: ignore[arg-type] + engine_command=item.engine_command, + working_dir=item.working_dir, + timeout_seconds=item.timeout_seconds, + exit_code=item.exit_code, + started_at=item.started_at, + finished_at=item.finished_at, + duration_ms=item.duration_ms, + error_message=item.error_message, + stdout_size=len(stdout_text), + stderr_size=len(stderr_text), + create_date=item.create_date, + create_user=item.create_user, + ) + + +def serialize_run_detail(item: AtpSimulationRun) -> AtpSimulationRunDetail: + summary = serialize_run(item) + return AtpSimulationRunDetail( + **summary.model_dump(), + stdout_text=item.stdout_text, + stderr_text=item.stderr_text, + ) + + +def list_models( + db: Session, + *, + keyword: str | None, + status_filter: str | None, +) -> AtpModelListResponse: + stmt = select(AtpModel) + total_stmt = select(func.count()).select_from(AtpModel) + + normalized_keyword = (keyword or "").strip() + if normalized_keyword: + like = f"%{normalized_keyword}%" + predicate = or_(AtpModel.code.ilike(like), AtpModel.name.ilike(like), AtpModel.description.ilike(like)) + stmt = stmt.where(predicate) + total_stmt = total_stmt.where(predicate) + + normalized_status = (status_filter or "").strip().lower() + if normalized_status: + if normalized_status not in VALID_MODEL_STATUS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid status filter: {status_filter}") + stmt = stmt.where(AtpModel.status == normalized_status) + total_stmt = total_stmt.where(AtpModel.status == normalized_status) + + total = int(db.scalar(total_stmt) or 0) + items = db.execute(stmt.order_by(AtpModel.update_date.desc(), AtpModel.code.asc())).scalars().all() + model_ids = [item.id for item in items] + + version_count_map = _load_model_version_count_map(db, model_ids) + run_count_map = _load_model_run_count_map(db, model_ids) + last_run_map = _load_model_last_run_map(db, model_ids) + + return AtpModelListResponse( + items=[ + serialize_model( + item, + version_count=version_count_map.get(item.id, 0), + run_count=run_count_map.get(item.id, 0), + last_run_status=last_run_map.get(item.id, (None, None))[0], + last_run_date=last_run_map.get(item.id, (None, None))[1], + ) + for item in items + ], + total=total, + ) + + +def get_model_by_id(db: Session, model_id: str) -> AtpModel | None: + return db.execute(select(AtpModel).where(AtpModel.id == model_id)).scalar_one_or_none() + + +def get_model_by_code(db: Session, code: str) -> AtpModel | None: + normalized = code.strip().lower() + if not normalized: + return None + return db.execute(select(AtpModel).where(func.lower(AtpModel.code) == normalized)).scalar_one_or_none() + + +def create_model( + db: Session, + payload: AtpModelCreateRequest, + *, + actor_user_id: str, +) -> AtpModelSummary | None: + if get_model_by_code(db, payload.code): + return None + + now = utcnow() + item = AtpModel( + code=payload.code.strip(), + name=payload.name.strip(), + source_type=payload.source_type, + description=payload.description.strip(), + status=payload.status, + tags_json=_normalize_tags(payload.tags_json), + latest_version_no=0, + active_version_no=None, + create_user=actor_user_id, + update_user=actor_user_id, + create_date=now, + update_date=now, + ) + db.add(item) + db.commit() + + saved = get_model_by_id(db, item.id) + if not saved: + return None + + _publish_change("model.created", {"action": "created", "model_id": saved.id}) + return serialize_model(saved, version_count=0, run_count=0, last_run_status=None, last_run_date=None) + + +def update_model( + db: Session, + model_id: str, + payload: AtpModelUpdateRequest, + *, + actor_user_id: str, +) -> AtpModelSummary | None: + item = get_model_by_id(db, model_id) + if not item: + return None + + update_data = payload.model_dump(exclude_unset=True) + + if "name" in update_data and update_data["name"] is not None: + item.name = str(update_data["name"]).strip() + if "source_type" in update_data and update_data["source_type"] is not None: + item.source_type = str(update_data["source_type"]) + if "description" in update_data and update_data["description"] is not None: + item.description = str(update_data["description"]).strip() + if "status" in update_data and update_data["status"] is not None: + item.status = str(update_data["status"]) + if "tags_json" in update_data: + item.tags_json = _normalize_tags(update_data["tags_json"]) + + item.update_user = actor_user_id + item.update_date = utcnow() + db.commit() + + saved = get_model_by_id(db, model_id) + if not saved: + return None + + version_count = _load_model_version_count_map(db, [saved.id]).get(saved.id, 0) + run_count = _load_model_run_count_map(db, [saved.id]).get(saved.id, 0) + last_run_status, last_run_date = _load_model_last_run_map(db, [saved.id]).get(saved.id, (None, None)) + _publish_change("model.updated", {"action": "updated", "model_id": saved.id}) + return serialize_model( + saved, + version_count=version_count, + run_count=run_count, + last_run_status=last_run_status, + last_run_date=last_run_date, + ) + + +def delete_model(db: Session, model_id: str) -> tuple[bool, int]: + item = get_model_by_id(db, model_id) + if not item: + return False, 0 + + version_count = int(db.scalar(select(func.count()).select_from(AtpModelVersion).where(AtpModelVersion.model_id == model_id)) or 0) + if version_count > 0: + return False, version_count + + db.delete(item) + db.commit() + _publish_change("model.deleted", {"action": "deleted", "model_id": model_id}) + return True, 0 + + +def list_model_versions( + db: Session, + *, + model_id: str, + limit: int, + offset: int, +) -> AtpModelVersionListResponse: + total = int( + db.scalar( + select(func.count()) + .select_from(AtpModelVersion) + .where(AtpModelVersion.model_id == model_id) + ) + or 0 + ) + items = db.execute( + select(AtpModelVersion) + .where(AtpModelVersion.model_id == model_id) + .order_by(AtpModelVersion.version_no.desc(), AtpModelVersion.id.desc()) + .offset(offset) + .limit(limit) + ).scalars().all() + + return AtpModelVersionListResponse(items=[serialize_version(item) for item in items], total=total) + + +def get_model_version_by_id(db: Session, *, model_id: str, version_id: str) -> AtpModelVersion | None: + return db.execute( + select(AtpModelVersion).where( + AtpModelVersion.model_id == model_id, + AtpModelVersion.id == version_id, + ) + ).scalar_one_or_none() + + +def create_model_version( + db: Session, + *, + model_id: str, + payload: AtpModelVersionCreateRequest, + actor_user_id: str, +) -> AtpModelVersionDetail: + model = get_model_by_id(db, model_id) + if not model: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + + max_version_no = int( + db.scalar( + select(func.max(AtpModelVersion.version_no)).where(AtpModelVersion.model_id == model_id) + ) + or 0 + ) + next_version_no = max_version_no + 1 + now = utcnow() + content = payload.atp_text + + item = AtpModelVersion( + model_id=model_id, + version_no=next_version_no, + version_tag=_normalize_optional_str(payload.version_tag), + status=payload.status, + entry_file=_normalize_optional_str(payload.entry_file), + change_note=payload.change_note.strip(), + artifact_manifest_json=payload.artifact_manifest_json, + graph_json=payload.graph_json, + atp_text=content, + content_hash=_hash_text(content), + create_user=actor_user_id, + update_user=actor_user_id, + create_date=now, + update_date=now, + ) + db.add(item) + + model.latest_version_no = max(model.latest_version_no, next_version_no) + if model.active_version_no is None and payload.status != "archived": + model.active_version_no = next_version_no + model.update_user = actor_user_id + model.update_date = now + + db.commit() + + saved = get_model_version_by_id(db, model_id=model_id, version_id=item.id) + if not saved: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Version save failed") + + _publish_change( + "version.created", + { + "action": "version_created", + "model_id": model_id, + "version_id": saved.id, + "version_no": saved.version_no, + }, + ) + return serialize_version_detail(saved) + + +def update_model_version( + db: Session, + *, + model_id: str, + version_id: str, + payload: AtpModelVersionUpdateRequest, + actor_user_id: str, +) -> AtpModelVersionDetail: + item = get_model_version_by_id(db, model_id=model_id, version_id=version_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") + + model = get_model_by_id(db, model_id) + if not model: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + + update_data = payload.model_dump(exclude_unset=True) + + if "version_tag" in update_data: + item.version_tag = _normalize_optional_str(update_data["version_tag"]) + if "status" in update_data and update_data["status"] is not None: + item.status = str(update_data["status"]) + if "entry_file" in update_data: + item.entry_file = _normalize_optional_str(update_data["entry_file"]) + if "change_note" in update_data and update_data["change_note"] is not None: + item.change_note = str(update_data["change_note"]).strip() + if "artifact_manifest_json" in update_data and update_data["artifact_manifest_json"] is not None: + item.artifact_manifest_json = dict(update_data["artifact_manifest_json"]) + if "graph_json" in update_data and update_data["graph_json"] is not None: + item.graph_json = dict(update_data["graph_json"]) + if "atp_text" in update_data and update_data["atp_text"] is not None: + content = str(update_data["atp_text"]) + item.atp_text = content + item.content_hash = _hash_text(content) + + now = utcnow() + item.update_user = actor_user_id + item.update_date = now + + if item.status == "archived" and model.active_version_no == item.version_no: + model.active_version_no = None + model.latest_version_no = max(model.latest_version_no, item.version_no) + model.update_user = actor_user_id + model.update_date = now + + db.commit() + + saved = get_model_version_by_id(db, model_id=model_id, version_id=version_id) + if not saved: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Version load failed") + + _publish_change( + "version.updated", + { + "action": "version_updated", + "model_id": model_id, + "version_id": saved.id, + "version_no": saved.version_no, + }, + ) + return serialize_version_detail(saved) + + +def activate_model_version( + db: Session, + *, + model_id: str, + version_id: str, + actor_user_id: str, +) -> AtpModelSummary: + model = get_model_by_id(db, model_id) + if not model: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + + version = get_model_version_by_id(db, model_id=model_id, version_id=version_id) + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") + if version.status == "archived": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Archived version cannot be activated") + + model.active_version_no = version.version_no + model.latest_version_no = max(model.latest_version_no, version.version_no) + model.update_user = actor_user_id + model.update_date = utcnow() + + db.commit() + + version_count = _load_model_version_count_map(db, [model.id]).get(model.id, 0) + run_count = _load_model_run_count_map(db, [model.id]).get(model.id, 0) + last_run_status, last_run_date = _load_model_last_run_map(db, [model.id]).get(model.id, (None, None)) + + _publish_change( + "version.activated", + { + "action": "version_activated", + "model_id": model.id, + "version_id": version.id, + "version_no": version.version_no, + }, + ) + return serialize_model( + model, + version_count=version_count, + run_count=run_count, + last_run_status=last_run_status, + last_run_date=last_run_date, + ) + + +def list_model_runs( + db: Session, + *, + model_id: str, + limit: int, + offset: int, +) -> AtpSimulationRunListResponse: + total = int( + db.scalar( + select(func.count()) + .select_from(AtpSimulationRun) + .where(AtpSimulationRun.model_id == model_id) + ) + or 0 + ) + + runs = db.execute( + select(AtpSimulationRun) + .where(AtpSimulationRun.model_id == model_id) + .order_by(AtpSimulationRun.create_date.desc(), AtpSimulationRun.id.desc()) + .offset(offset) + .limit(limit) + ).scalars().all() + + return AtpSimulationRunListResponse(items=[serialize_run(item) for item in runs], total=total) + + +def get_model_run_by_id(db: Session, *, model_id: str, run_id: str) -> AtpSimulationRun | None: + return db.execute( + select(AtpSimulationRun).where( + AtpSimulationRun.model_id == model_id, + AtpSimulationRun.id == run_id, + ) + ).scalar_one_or_none() + + +def get_model_run_detail(db: Session, *, model_id: str, run_id: str) -> AtpSimulationRunDetail: + run = get_model_run_by_id(db, model_id=model_id, run_id=run_id) + if not run: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found") + return serialize_run_detail(run) + + +def run_model_version( + db: Session, + *, + model_id: str, + payload: AtpSimulationRunRequest, + actor_user_id: str, +) -> AtpSimulationRunDetail: + model = get_model_by_id(db, model_id) + if not model: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + + version = _resolve_target_version(db, model=model, payload=payload) + timeout_seconds = _resolve_timeout(payload.timeout_seconds) + + run = AtpSimulationRun( + model_id=model.id, + version_id=version.id, + status="pending", + engine_mode=_resolve_engine_mode(), + timeout_seconds=timeout_seconds, + create_user=actor_user_id, + update_user=actor_user_id, + ) + db.add(run) + db.flush() + + now = utcnow() + run.started_at = now + run.status = "running" + run.update_date = now + + command, working_dir, error = _build_run_command(model=model, version=version, run=run, payload=payload) + run.engine_command = " ".join(command) if command else None + run.working_dir = str(working_dir) if working_dir else None + + if error: + run.status = "failed" + run.error_message = error + run.finished_at = utcnow() + run.duration_ms = 0 + run.update_user = actor_user_id + run.update_date = utcnow() + db.commit() + _publish_change( + "run.failed", + { + "action": "run_failed", + "model_id": model.id, + "version_id": version.id, + "run_id": run.id, + "reason": error, + }, + ) + saved = get_model_run_by_id(db, model_id=model.id, run_id=run.id) + if not saved: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Run save failed") + return serialize_run_detail(saved) + + if payload.dry_run: + run.status = "success" + run.exit_code = 0 + run.stdout_text = json.dumps( + { + "dry_run": True, + "command": command, + "working_dir": str(working_dir), + "timeout_seconds": timeout_seconds, + }, + ensure_ascii=False, + indent=2, + ) + run.stderr_text = "" + run.finished_at = utcnow() + run.duration_ms = 0 + run.update_user = actor_user_id + run.update_date = utcnow() + db.commit() + _publish_change( + "run.finished", + { + "action": "run_dry_finished", + "model_id": model.id, + "version_id": version.id, + "run_id": run.id, + "status": run.status, + }, + ) + saved = get_model_run_by_id(db, model_id=model.id, run_id=run.id) + if not saved: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Run save failed") + return serialize_run_detail(saved) + + env = os.environ.copy() + env.update(payload.environment) + + started_perf = time.perf_counter() + try: + result = subprocess.run( + command, + cwd=str(working_dir), + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout_seconds, + check=False, + ) + run.exit_code = result.returncode + run.stdout_text = _truncate_output(result.stdout) + run.stderr_text = _truncate_output(result.stderr) + + if result.returncode == 0: + run.status = "success" + run.error_message = None + else: + run.status = "failed" + run.error_message = f"ATP engine exited with code {result.returncode}" + except subprocess.TimeoutExpired as exc: + run.status = "failed" + run.exit_code = None + run.stdout_text = _truncate_output((exc.stdout or "") if isinstance(exc.stdout, str) else "") + run.stderr_text = _truncate_output((exc.stderr or "") if isinstance(exc.stderr, str) else "") + run.error_message = f"Execution timed out after {timeout_seconds} seconds" + except OSError as exc: + run.status = "failed" + run.exit_code = None + run.stdout_text = None + run.stderr_text = None + run.error_message = str(exc) + + duration_ms = int((time.perf_counter() - started_perf) * 1000) + run.duration_ms = max(duration_ms, 0) + run.finished_at = utcnow() + run.update_user = actor_user_id + run.update_date = utcnow() + + db.commit() + + _publish_change( + "run.finished", + { + "action": "run_finished", + "model_id": model.id, + "version_id": version.id, + "run_id": run.id, + "status": run.status, + "exit_code": run.exit_code, + }, + ) + + saved = get_model_run_by_id(db, model_id=model.id, run_id=run.id) + if not saved: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Run save failed") + return serialize_run_detail(saved) + + +def _resolve_target_version(db: Session, *, model: AtpModel, payload: AtpSimulationRunRequest) -> AtpModelVersion: + if payload.version_id: + matched = get_model_version_by_id(db, model_id=model.id, version_id=payload.version_id) + if not matched: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") + return matched + + if payload.version_no is not None: + matched = db.execute( + select(AtpModelVersion).where( + AtpModelVersion.model_id == model.id, + AtpModelVersion.version_no == payload.version_no, + ) + ).scalar_one_or_none() + if not matched: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") + return matched + + if model.active_version_no is not None: + matched = db.execute( + select(AtpModelVersion).where( + AtpModelVersion.model_id == model.id, + AtpModelVersion.version_no == model.active_version_no, + ) + ).scalar_one_or_none() + if matched is not None: + return matched + + matched = db.execute( + select(AtpModelVersion) + .where(AtpModelVersion.model_id == model.id) + .order_by(AtpModelVersion.version_no.desc(), AtpModelVersion.id.desc()) + .limit(1) + ).scalar_one_or_none() + if not matched: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No version available for simulation") + return matched + + +def _build_run_command( + *, + model: AtpModel, + version: AtpModelVersion, + run: AtpSimulationRun, + payload: AtpSimulationRunRequest, +) -> tuple[list[str] | None, Path | None, str | None]: + storage_root = _resolve_storage_root() + workdir_base = _resolve_engine_workdir() + + try: + storage_root.mkdir(parents=True, exist_ok=True) + workdir_base.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return None, None, f"Failed to prepare ATP storage directory: {exc}" + + run_dir = workdir_base / model.code / f"v{version.version_no}" / run.id + try: + run_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return None, None, f"Failed to prepare run directory: {exc}" + + entry_filename = _safe_entry_filename(version.entry_file, model_code=model.code, version_no=version.version_no) + input_path = run_dir / entry_filename + + try: + input_path.write_text(version.atp_text or "", encoding="utf-8") + except OSError as exc: + return None, run_dir, f"Failed to write ATP input file: {exc}" + + mode = _resolve_engine_mode() + extra_args = [arg for arg in payload.extra_args if arg] + + if mode == "wine": + wine_binary, resolved_engine, error = _resolve_wine_engine_executable() + if error or not wine_binary or not resolved_engine: + return None, run_dir, error or "Wine ATP engine unavailable" + command = [wine_binary, resolved_engine, str(input_path), *extra_args] + return command, run_dir, None + + resolved_engine, error = _resolve_native_engine_executable() + if error or not resolved_engine: + return None, run_dir, error or "Native ATP engine unavailable" + command = [resolved_engine, str(input_path), *extra_args] + return command, run_dir, None + + +def _load_model_version_count_map(db: Session, model_ids: list[str]) -> dict[str, int]: + if not model_ids: + return {} + rows = db.execute( + select(AtpModelVersion.model_id, func.count()) + .where(AtpModelVersion.model_id.in_(model_ids)) + .group_by(AtpModelVersion.model_id) + ).all() + return {str(model_id): int(count) for model_id, count in rows} + + +def _load_model_run_count_map(db: Session, model_ids: list[str]) -> dict[str, int]: + if not model_ids: + return {} + rows = db.execute( + select(AtpSimulationRun.model_id, func.count()) + .where(AtpSimulationRun.model_id.in_(model_ids)) + .group_by(AtpSimulationRun.model_id) + ).all() + return {str(model_id): int(count) for model_id, count in rows} + + +def _load_model_last_run_map(db: Session, model_ids: list[str]) -> dict[str, tuple[str | None, Any]]: + if not model_ids: + return {} + + rows = db.execute( + select(AtpSimulationRun) + .where(AtpSimulationRun.model_id.in_(model_ids)) + .order_by(AtpSimulationRun.model_id.asc(), AtpSimulationRun.create_date.desc(), AtpSimulationRun.id.desc()) + ).scalars().all() + + result: dict[str, tuple[str | None, Any]] = {} + for row in rows: + if row.model_id in result: + continue + result[row.model_id] = (row.status, row.create_date) + return result + + +def _publish_change(event_name: str, payload: dict[str, Any]) -> None: + _fire_and_forget( + publish_topic( + ATP_TOPIC, + name=event_name, + payload=payload, + requires_refetch=[], + dedupe_key=f"atp:{event_name}:{payload.get('model_id', '-')}:" + f"{payload.get('version_id', payload.get('run_id', '-'))}", + ) + ) diff --git a/api/app/services/chat_service.py b/api/app/services/chat_service.py deleted file mode 100644 index c86985c..0000000 --- a/api/app/services/chat_service.py +++ /dev/null @@ -1,236 +0,0 @@ -from __future__ import annotations - -from fastapi import HTTPException, status -from sqlalchemy import select -from sqlalchemy.orm import Session - -from ..core.config import get_settings -from ..models.base import utcnow -from ..models.chat import ChatMessage, ChatSession -from ..models.user import User -from ..schemas.chat import ( - ChatMessageCreateRequest, - ChatMessageListResponse, - ChatMessagePublic, - ChatSendResponse, - ChatSessionCreateRequest, - ChatSessionListResponse, - ChatSessionPublic, -) -from .llm_gateway import create_assistant_reply - -settings = get_settings() - - -def list_sessions(db: Session, *, actor: User) -> ChatSessionListResponse: - sessions = db.execute( - select(ChatSession) - .where(ChatSession.owner_user_id == actor.id) - .order_by(ChatSession.updated_at.desc(), ChatSession.created_at.desc()) - ).scalars().all() - return ChatSessionListResponse(items=[serialize_session(item) for item in sessions], total=len(sessions)) - - -def create_session( - db: Session, - payload: ChatSessionCreateRequest, - *, - actor: User, -) -> ChatSessionPublic: - title = (payload.title or "").strip() or "新会话" - system_prompt = (payload.system_prompt or "").strip() or settings.chat_default_system_prompt - session = ChatSession( - owner_user_id=actor.id, - title=title, - system_prompt=system_prompt, - ) - db.add(session) - db.commit() - - saved = get_owned_session(db, session.id, actor_id=actor.id) - if not saved: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Chat session save failed") - return serialize_session(saved) - - -def list_messages( - db: Session, - *, - session_id: str, - actor: User, - limit: int = 200, -) -> ChatMessageListResponse: - session = get_owned_session(db, session_id, actor_id=actor.id) - if not session: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found") - - safe_limit = max(1, min(limit, 500)) - messages = db.execute( - select(ChatMessage) - .where(ChatMessage.session_id == session.id) - .order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc()) - .limit(safe_limit) - ).scalars().all() - return ChatMessageListResponse(items=[serialize_message(item) for item in messages], total=len(messages)) - - -def send_message( - db: Session, - *, - session_id: str, - payload: ChatMessageCreateRequest, - actor: User, -) -> ChatSendResponse: - session = get_owned_session(db, session_id, actor_id=actor.id) - if not session: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found") - - normalized_content = payload.content.strip() - if not normalized_content: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message content cannot be empty") - - user_message = ChatMessage( - session_id=session.id, - author_user_id=actor.id, - role="user", - content=normalized_content, - ) - db.add(user_message) - db.flush() - - context_messages = _load_context_messages(db, session_id=session.id, exclude_message_id=user_message.id) - - assistant_message: ChatMessage - try: - result = create_assistant_reply( - db, - user_message=normalized_content, - context_messages=context_messages, - system_prompt=session.system_prompt or settings.chat_default_system_prompt, - ) - assistant_message = ChatMessage( - session_id=session.id, - role="assistant", - content=result.content, - model_code=result.model_code, - provider=result.provider, - provider_model=result.provider_model, - prompt_tokens=result.prompt_tokens, - completion_tokens=result.completion_tokens, - total_tokens=result.total_tokens, - latency_ms=result.latency_ms, - is_error=False, - ) - session.model_code = result.model_code - except HTTPException as exc: - assistant_message = ChatMessage( - session_id=session.id, - role="assistant", - content=f"模型调用失败:{exc.detail}", - is_error=True, - error_message=str(exc.detail), - ) - except Exception as exc: # pragma: no cover - defensive fallback - assistant_message = ChatMessage( - session_id=session.id, - role="assistant", - content="模型调用失败:unexpected_error", - is_error=True, - error_message=str(exc), - ) - - db.add(assistant_message) - - # Set a meaningful title after first user turn. - if session.title in {"新会话", "New Chat"}: - session.title = _derive_title(normalized_content) - - now = utcnow() - session.last_message_at = now - session.updated_at = now - db.commit() - - saved_session = get_owned_session(db, session.id, actor_id=actor.id) - saved_user_message = db.execute(select(ChatMessage).where(ChatMessage.id == user_message.id)).scalar_one_or_none() - saved_assistant_message = db.execute(select(ChatMessage).where(ChatMessage.id == assistant_message.id)).scalar_one_or_none() - if not saved_session or not saved_user_message or not saved_assistant_message: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Chat message save failed") - - return ChatSendResponse( - session=serialize_session(saved_session), - user_message=serialize_message(saved_user_message), - assistant_message=serialize_message(saved_assistant_message), - ) - - -def get_owned_session(db: Session, session_id: str, *, actor_id: str) -> ChatSession | None: - return db.execute( - select(ChatSession).where( - ChatSession.id == session_id, - ChatSession.owner_user_id == actor_id, - ) - ).scalar_one_or_none() - - -def serialize_session(session: ChatSession) -> ChatSessionPublic: - return ChatSessionPublic( - id=session.id, - owner_user_id=session.owner_user_id, - title=session.title, - system_prompt=session.system_prompt, - model_code=session.model_code, - last_message_at=session.last_message_at, - created_at=session.created_at, - updated_at=session.updated_at, - ) - - -def serialize_message(message: ChatMessage) -> ChatMessagePublic: - return ChatMessagePublic( - id=message.id, - session_id=message.session_id, - author_user_id=message.author_user_id, - role=message.role, - content=message.content, - is_error=message.is_error, - model_code=message.model_code, - provider=message.provider, - provider_model=message.provider_model, - prompt_tokens=message.prompt_tokens, - completion_tokens=message.completion_tokens, - total_tokens=message.total_tokens, - latency_ms=message.latency_ms, - error_message=message.error_message, - created_at=message.created_at, - ) - - -def _load_context_messages( - db: Session, - *, - session_id: str, - exclude_message_id: int | None = None, -) -> list[tuple[str, str]]: - limit = max(1, settings.chat_context_message_limit) - messages = db.execute( - select(ChatMessage) - .where(ChatMessage.session_id == session_id) - .order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc()) - .limit(limit) - ).scalars().all() - messages.reverse() - result: list[tuple[str, str]] = [] - for item in messages: - if item.role not in {"user", "assistant"}: - continue - if exclude_message_id is not None and item.id == exclude_message_id: - continue - result.append((item.role, item.content)) - return result - - -def _derive_title(content: str) -> str: - compact = " ".join(content.split()) - if len(compact) <= 32: - return compact or "新会话" - return f"{compact[:32]}..." diff --git a/api/app/services/legacy_admin_rbac_service.py b/api/app/services/legacy_admin_rbac_service.py index 87fb57f..6bded18 100644 --- a/api/app/services/legacy_admin_rbac_service.py +++ b/api/app/services/legacy_admin_rbac_service.py @@ -34,6 +34,16 @@ REMOVED_MENU_CODES = { "admin.inbox", "admin.code_review", "admin.git_desktop", + "admin.agent", + "admin.mcp_server", + "admin.requirements", + "admin.schedule", + "admin.mindmap", + "admin.mermaid_mgr", + "admin.chat", + "admin.api_tester", + "admin.models", + "admin.orchestration", "admin.mdresolve", "admin.data_query", "admin.hot_search", @@ -64,33 +74,24 @@ PROTECTED_MENU_CODES = { "admin.menus", "admin.system_params", "admin.wxapp", - "admin.agent", - "admin.mcp_server", "admin.files", "admin.filedetector", "admin.baidu_pan", - "admin.requirements", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.task_monitor", + "admin.atp_models", "admin.data_query", "admin.hot_search", - "admin.schedule", "admin.cron_task_mgr", "admin.todos", - "admin.mindmap", "admin.mdresolve", - "admin.mermaid_mgr", "admin.tag", "admin.knowledge_point_mgr", "admin.job_mgr", "admin.syslog", - "admin.chat", "admin.jwt_generator", - "admin.api_tester", - "admin.models", - "admin.orchestration", "admin.wine_runner", # quiz legacy defaults "sys_mgr", diff --git a/api/app/services/legacy_authz_service.py b/api/app/services/legacy_authz_service.py index b921079..7531ad6 100644 --- a/api/app/services/legacy_authz_service.py +++ b/api/app/services/legacy_authz_service.py @@ -18,27 +18,21 @@ DEFAULT_ADMIN_PERMISSION_CODES: set[str] = { "menu.manage", "system_param.read", "system_param.manage", - "model.read", - "model.manage", "file.read", "file.manage", - "chat.use", "line.read", "line.manage", "tower.read", "tower.manage", "lightning.read", "lightning.manage", + "atp.read", + "atp.manage", + "atp.run", + "celery.read", + "celery.manage", "wine.read", "wine.manage", - "requirement.read", - "requirement.create", - "requirement.process", - "requirement.manage", - "todo.read", - "todo.create", - "todo.process", - "todo.manage", "question_bank.read", "question_bank.manage", "vocabulary.read", @@ -58,6 +52,16 @@ DISABLED_MENU_CODES: set[str] = { "admin.inbox", "admin.code_review", "admin.git_desktop", + "admin.agent", + "admin.mcp_server", + "admin.requirements", + "admin.schedule", + "admin.mindmap", + "admin.mermaid_mgr", + "admin.chat", + "admin.api_tester", + "admin.models", + "admin.orchestration", "admin.mdresolve", "admin.data_query", "admin.hot_search", @@ -93,22 +97,26 @@ MENU_CODE_PERMISSION_MAP: dict[str, set[str]] = { "admin.menus": {"menu.read", "menu.manage"}, "admin.system_params": {"system_param.read", "system_param.manage"}, "admin.files": {"file.read", "file.manage"}, - "admin.chat": {"chat.use"}, - "admin.requirements": {"requirement.read", "requirement.create", "requirement.process", "requirement.manage"}, - "admin.task_monitor": {"requirement.read", "todo.read"}, + "admin.task_monitor": {"celery.read", "celery.manage"}, + "admin.atp_models": {"atp.read", "atp.manage", "atp.run"}, "admin.lightning_currents": {"lightning.read", "lightning.manage"}, "admin.lightning_distribution": {"lightning.read", "lightning.manage"}, - "admin.schedule": {"todo.read", "todo.create", "todo.process", "todo.manage"}, - "admin.mermaid_mgr": {"question_bank.read", "question_bank.manage"}, - "admin.models": {"model.read", "model.manage"}, - "admin.api_tester": {"model.read", "model.manage"}, - "admin.mcp_server": {"model.read", "model.manage"}, - "admin.agent": {"model.read", "model.manage"}, "admin.wine_runner": {"wine.read", "wine.manage"}, "dashboard": {"menu.read"}, } SYNTHETIC_LEGACY_MENU_ROWS: list[dict[str, Any]] = [ + { + "menu_id": "admin.files", + "menu_name": "admin.files", + "menu_label": "文件管理", + "menu_type": "MENU", + "parent_id": None, + "url": "/admin/files", + "menu_icon": "FolderTree", + "seq": 54, + "state": "ENABLED", + }, { "menu_id": "admin.task_monitor", "menu_name": "admin.task_monitor", @@ -120,6 +128,17 @@ SYNTHETIC_LEGACY_MENU_ROWS: list[dict[str, Any]] = [ "seq": 53, "state": "ENABLED", }, + { + "menu_id": "admin.atp_models", + "menu_name": "admin.atp_models", + "menu_label": "ATP模型管理", + "menu_type": "MENU", + "parent_id": None, + "url": "/admin/power-lines/atp-viewer", + "menu_icon": "Experiment", + "seq": 54, + "state": "ENABLED", + }, { "menu_id": "admin.wine_runner", "menu_name": "admin.wine_runner", diff --git a/api/app/services/lightning_service.py b/api/app/services/lightning_service.py index 8773aca..f251fcb 100644 --- a/api/app/services/lightning_service.py +++ b/api/app/services/lightning_service.py @@ -39,6 +39,9 @@ from ..schemas.lightning import ( LightningSyntheticDatasetStats, LightningTowerBufferEventItem, LightningTowerBufferStatsResponse, + LightningTowerTerrainComputeRequest, + LightningTowerTerrainComputeResponse, + LightningTowerTerrainMetrics, ) from .push_service import publish_topic @@ -54,6 +57,7 @@ STANDARD_WAVE_SHAPES: tuple[tuple[str, float, float], ...] = ( ) TOKEN_SPLIT_PATTERN = re.compile(r"[,\t; ]+") DEGREE_TO_KM = 111.32 +TERRAIN_ALGORITHM_VERSION = "horn_3x3.v1" @dataclass @@ -880,14 +884,23 @@ def get_tower_buffer_stats( area_km2 = math.pi * (radius_km ** 2) ng = strike_count / (area_km2 * data_years) if strike_count > 0 else 0.0 positive_ratio = (positive_count / strike_count) if strike_count > 0 else 0.0 + terrain_metrics = _build_tower_terrain_metrics_from_tower(tower) + terrain_exposure = ( + terrain_metrics.terrain_exposure_index + if terrain_metrics is not None and terrain_metrics.terrain_exposure_index is not None + else 0.0 + ) + ng_for_risk = ng * (1.0 + 0.25 * max(0.0, min(1.0, terrain_exposure))) risk_level = _derive_tower_risk_level( strike_count=strike_count, exceed_design_count=exceed_count, max_abs_current_ka=max_abs if strike_count > 0 else None, design_current_ka=design_current_ka, - ng_per_km2_year=ng, + ng_per_km2_year=ng_for_risk, ) recommended_action = _tower_risk_recommendation(risk_level) + if terrain_metrics is not None and terrain_exposure >= 0.7: + recommended_action = f"{recommended_action} 地形暴露指数较高,建议同步复核边坡防护与接地体布置。" sorted_events = sorted(events, key=lambda item: ((item.abs_current_ka or 0.0), -item.distance_km), reverse=True) return LightningTowerBufferStatsResponse( @@ -907,6 +920,126 @@ def get_tower_buffer_stats( risk_level=risk_level, recommended_action=recommended_action, events=sorted_events[:include_events_limit], + terrain_metrics=terrain_metrics, + ) + + +def compute_tower_terrain_metrics( + db: Session, + *, + payload: LightningTowerTerrainComputeRequest, + actor_user_id: str, + can_persist: bool, +) -> LightningTowerTerrainComputeResponse: + warnings: list[str] = [] + tower: LineTower | None = None + + if payload.tower_id: + tower = db.execute(select(LineTower).where(LineTower.id == payload.tower_id)).scalar_one_or_none() + if not tower: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="杆塔不存在") + + center_lon = payload.longitude if payload.longitude is not None else (float(tower.longitude) if tower and tower.longitude is not None else None) + center_lat = payload.latitude if payload.latitude is not None else (float(tower.latitude) if tower and tower.latitude is not None else None) + if center_lon is None or center_lat is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少中心经纬度,请传入 tower_id 或经纬度") + + z = payload.dem_grid_m + cell_size_m = float(payload.cell_size_m) + dem_resolution_m = float(payload.dem_resolution_m) if payload.dem_resolution_m is not None else cell_size_m + + dz_dx, dz_dy = _compute_horn_gradient(z, cell_size_m=cell_size_m) + slope_rad = math.atan(math.sqrt(dz_dx * dz_dx + dz_dy * dz_dy)) + slope_deg = math.degrees(slope_rad) + aspect_deg = _compute_aspect_deg(dz_dx, dz_dy) + relief_m_50 = max(max(row) for row in z) - min(min(row) for row in z) + + neighbor_slopes = _compute_neighbor_slopes(z, cell_size_m=cell_size_m) + slope_mean_deg = (sum(neighbor_slopes) / len(neighbor_slopes)) if neighbor_slopes else None + slope_p95_deg = _percentile(neighbor_slopes, 0.95) if neighbor_slopes else None + slope_max_deg = max(neighbor_slopes) if neighbor_slopes else None + + line_azimuth_deg = _infer_tower_line_azimuth_deg(db, tower) if tower else None + slope_along_line_deg: float | None = None + slope_cross_line_deg: float | None = None + if line_azimuth_deg is not None: + slope_along_line_deg = math.degrees(math.atan(_directional_gradient(dz_dx, dz_dy, line_azimuth_deg))) + slope_cross_line_deg = math.degrees(math.atan(_directional_gradient(dz_dx, dz_dy, (line_azimuth_deg + 90.0) % 360.0))) + elif tower is not None and tower.line_id: + warnings.append("未找到可用相邻杆塔坐标,无法推导线路方向纵坡/横坡") + + windward_factor: float | None = None + if payload.wind_direction_deg is not None and aspect_deg is not None: + diff = _angle_difference_deg(aspect_deg, float(payload.wind_direction_deg)) + windward_factor = max(0.0, math.cos(math.radians(diff))) + + terrain_exposure_index = min( + 1.0, + max(0.0, slope_deg / 45.0) * ((0.6 + 0.4 * windward_factor) if windward_factor is not None else 1.0), + ) + + quality_score, quality_level = _evaluate_terrain_quality( + dem_resolution_m=dem_resolution_m, + search_radius_m=float(payload.search_radius_m), + warnings=warnings, + ) + + metrics = LightningTowerTerrainMetrics( + slope_deg=round(slope_deg, 6), + aspect_deg=(round(aspect_deg, 6) if aspect_deg is not None else None), + slope_mean_deg=(round(slope_mean_deg, 6) if slope_mean_deg is not None else None), + slope_p95_deg=(round(slope_p95_deg, 6) if slope_p95_deg is not None else None), + slope_max_deg=(round(slope_max_deg, 6) if slope_max_deg is not None else None), + slope_along_line_deg=(round(slope_along_line_deg, 6) if slope_along_line_deg is not None else None), + slope_cross_line_deg=(round(slope_cross_line_deg, 6) if slope_cross_line_deg is not None else None), + relief_m_50=round(relief_m_50, 6), + dem_source=(payload.dem_source or "manual-grid"), + dem_resolution_m=round(dem_resolution_m, 6), + quality_score=round(quality_score, 2), + quality_level=quality_level, + terrain_exposure_index=round(terrain_exposure_index, 6), + windward_factor=(round(windward_factor, 6) if windward_factor is not None else None), + algorithm_version=TERRAIN_ALGORITHM_VERSION, + computed_at=utcnow(), + land_cover_type=payload.land_cover_type, + ) + + persisted = False + if payload.persist: + if tower is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="persist=true 时必须传入 tower_id") + if not can_persist: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="缺少 tower.manage/lightning.manage 权限,无法持久化") + + raw_extra = dict(tower.raw_extra_json or {}) + terrain_payload = metrics.model_dump(mode="json", exclude_none=True) + terrain_payload["search_radius_m"] = float(payload.search_radius_m) + if line_azimuth_deg is not None: + terrain_payload["line_azimuth_deg"] = round(line_azimuth_deg, 6) + raw_extra["terrain_metrics"] = terrain_payload + + tower.raw_extra_json = raw_extra + if slope_along_line_deg is not None: + tower.slope_1 = abs(float(slope_along_line_deg)) + if slope_cross_line_deg is not None: + tower.slope_2 = abs(float(slope_cross_line_deg)) + if payload.altitude_m is not None: + tower.altitude_m = payload.altitude_m + tower.update_user = actor_user_id + tower.update_date = utcnow() + db.commit() + persisted = True + + return LightningTowerTerrainComputeResponse( + tower_id=tower.id if tower else None, + tower_no=tower.tower_no if tower else None, + line_id=tower.line_id if tower else None, + center_longitude=float(center_lon), + center_latitude=float(center_lat), + method="horn_3x3", + persisted=persisted, + terrain_metrics=metrics, + warnings=warnings, ) @@ -1377,6 +1510,218 @@ def _infer_data_years(db: Session, filters: list[Any]) -> float: return max(delta_days / 365.25, 1.0) +def _build_tower_terrain_metrics_from_tower(tower: LineTower | None) -> LightningTowerTerrainMetrics | None: + if tower is None: + return None + + raw_extra = tower.raw_extra_json or {} + terrain_payload = raw_extra.get("terrain_metrics") if isinstance(raw_extra, dict) else None + if isinstance(terrain_payload, dict): + try: + return LightningTowerTerrainMetrics.model_validate(terrain_payload) + except Exception: + pass + + slope_along = float(tower.slope_1) if tower.slope_1 is not None else None + slope_cross = float(tower.slope_2) if tower.slope_2 is not None else None + if slope_along is None and slope_cross is None: + return None + + slope_candidates = [abs(value) for value in (slope_along, slope_cross) if value is not None] + slope_deg = max(slope_candidates) if slope_candidates else None + terrain_exposure = min(1.0, (slope_deg / 45.0)) if slope_deg is not None else None + return LightningTowerTerrainMetrics( + slope_deg=slope_deg, + slope_max_deg=slope_deg, + slope_along_line_deg=slope_along, + slope_cross_line_deg=slope_cross, + terrain_exposure_index=terrain_exposure, + algorithm_version="tower-legacy", + ) + + +def _compute_horn_gradient(grid_m: list[list[float]], *, cell_size_m: float) -> tuple[float, float]: + if len(grid_m) != 3 or any(len(row) != 3 for row in grid_m): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="DEM 网格必须是 3x3") + if cell_size_m <= 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="cell_size_m 必须大于 0") + + z1, z2, z3 = float(grid_m[0][0]), float(grid_m[0][1]), float(grid_m[0][2]) + z4, z5, z6 = float(grid_m[1][0]), float(grid_m[1][1]), float(grid_m[1][2]) + z7, z8, z9 = float(grid_m[2][0]), float(grid_m[2][1]), float(grid_m[2][2]) + + dz_dx = ((z3 + 2.0 * z6 + z9) - (z1 + 2.0 * z4 + z7)) / (8.0 * cell_size_m) + dz_dy = ((z1 + 2.0 * z2 + z3) - (z7 + 2.0 * z8 + z9)) / (8.0 * cell_size_m) + return dz_dx, dz_dy + + +def _compute_aspect_deg(dz_dx: float, dz_dy: float) -> float | None: + if math.isclose(dz_dx, 0.0, abs_tol=1e-12) and math.isclose(dz_dy, 0.0, abs_tol=1e-12): + return None + downslope_east = -dz_dx + downslope_north = -dz_dy + return (math.degrees(math.atan2(downslope_east, downslope_north)) + 360.0) % 360.0 + + +def _compute_neighbor_slopes(grid_m: list[list[float]], *, cell_size_m: float) -> list[float]: + center = float(grid_m[1][1]) + slopes: list[float] = [] + for row in range(3): + for col in range(3): + if row == 1 and col == 1: + continue + delta_h = float(grid_m[row][col]) - center + distance = cell_size_m * math.sqrt(float((row - 1) ** 2 + (col - 1) ** 2)) + if distance <= 0: + continue + slopes.append(math.degrees(math.atan(abs(delta_h) / distance))) + return slopes + + +def _percentile(values: list[float], quantile: float) -> float: + if not values: + return 0.0 + if quantile <= 0: + return min(values) + if quantile >= 1: + return max(values) + + sorted_values = sorted(values) + position = (len(sorted_values) - 1) * quantile + lower = int(math.floor(position)) + upper = int(math.ceil(position)) + if lower == upper: + return float(sorted_values[lower]) + lower_value = float(sorted_values[lower]) + upper_value = float(sorted_values[upper]) + weight = position - lower + return lower_value * (1.0 - weight) + upper_value * weight + + +def _infer_tower_line_azimuth_deg(db: Session, tower: LineTower | None) -> float | None: + if ( + tower is None + or tower.line_id is None + or tower.seq_no is None + or tower.longitude is None + or tower.latitude is None + ): + return None + + rows = db.execute( + select(LineTower.seq_no, LineTower.longitude, LineTower.latitude) + .where( + LineTower.line_id == tower.line_id, + LineTower.longitude.is_not(None), + LineTower.latitude.is_not(None), + ) + .order_by(LineTower.seq_no.asc()) + ).all() + if not rows: + return None + + previous: tuple[float, float] | None = None + next_point: tuple[float, float] | None = None + for row in rows: + seq_no = int(row.seq_no) + point = (float(row.longitude), float(row.latitude)) + if seq_no < tower.seq_no: + previous = point + continue + if seq_no > tower.seq_no: + next_point = point + break + + if previous is not None and next_point is not None: + start_lon, start_lat = previous + end_lon, end_lat = next_point + elif next_point is not None: + start_lon = float(tower.longitude) + start_lat = float(tower.latitude) + end_lon, end_lat = next_point + elif previous is not None: + start_lon, start_lat = previous + end_lon = float(tower.longitude) + end_lat = float(tower.latitude) + else: + return None + + if math.isclose(start_lon, end_lon, abs_tol=1e-12) and math.isclose(start_lat, end_lat, abs_tol=1e-12): + return None + + start_lat_rad = math.radians(start_lat) + end_lat_rad = math.radians(end_lat) + delta_lon_rad = math.radians(end_lon - start_lon) + x = math.sin(delta_lon_rad) * math.cos(end_lat_rad) + y = ( + math.cos(start_lat_rad) * math.sin(end_lat_rad) + - math.sin(start_lat_rad) * math.cos(end_lat_rad) * math.cos(delta_lon_rad) + ) + if math.isclose(x, 0.0, abs_tol=1e-12) and math.isclose(y, 0.0, abs_tol=1e-12): + return None + return (math.degrees(math.atan2(x, y)) + 360.0) % 360.0 + + +def _directional_gradient(dz_dx: float, dz_dy: float, azimuth_deg: float) -> float: + azimuth_rad = math.radians(azimuth_deg % 360.0) + direction_east = math.sin(azimuth_rad) + direction_north = math.cos(azimuth_rad) + return dz_dx * direction_east + dz_dy * direction_north + + +def _angle_difference_deg(angle_1_deg: float, angle_2_deg: float) -> float: + return abs((angle_1_deg - angle_2_deg + 180.0) % 360.0 - 180.0) + + +def _evaluate_terrain_quality( + *, + dem_resolution_m: float, + search_radius_m: float, + warnings: list[str], +) -> tuple[float, str]: + score = 100.0 + + def add_warning(message: str) -> None: + if message not in warnings: + warnings.append(message) + + if dem_resolution_m <= 5.0: + score -= 0.0 + elif dem_resolution_m <= 10.0: + score -= 5.0 + elif dem_resolution_m <= 12.5: + score -= 10.0 + elif dem_resolution_m <= 30.0: + score -= 25.0 + add_warning("DEM 分辨率大于 12.5m,微地形倾角结果可能偏平滑。") + elif dem_resolution_m <= 90.0: + score -= 45.0 + add_warning("DEM 分辨率较低(30-90m),建议使用 10m 及以上数据复核。") + else: + score -= 60.0 + add_warning("DEM 分辨率超过 90m,当前结果仅可用于粗略评估。") + + if search_radius_m < 30.0: + score -= 15.0 + add_warning("地形检索半径过小(<30m),建议提高到 50m 左右。") + elif search_radius_m < 50.0: + score -= 8.0 + elif search_radius_m > 300.0: + score -= 10.0 + add_warning("地形检索半径较大(>300m),局部坡度可能被稀释。") + elif search_radius_m > 150.0: + score -= 5.0 + + score = max(0.0, min(100.0, score)) + if score >= 80.0: + quality_level = "HIGH" + elif score >= 60.0: + quality_level = "MEDIUM" + else: + quality_level = "LOW" + return score, quality_level + + def _derive_tower_risk_level( *, strike_count: int, diff --git a/api/app/services/mermaid_service.py b/api/app/services/mermaid_service.py deleted file mode 100644 index 4c76763..0000000 --- a/api/app/services/mermaid_service.py +++ /dev/null @@ -1,525 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator -from datetime import datetime -from uuid import uuid4 - -from fastapi import HTTPException, status -from sqlalchemy import delete, func, select -from sqlalchemy.orm import Session - -from ..models.base import utcnow -from ..models.mermaid_diagram import MermaidDiagram -from ..models.model_registry import ModelApiKey, ModelRegistry -from ..models.object_group import ObjectGroup, ObjectGroupRelation -from ..models.user import User -from ..schemas.mermaid import ( - MermaidChatStreamRequest, - MermaidDiagramCreateRequest, - MermaidDiagramDataPatchRequest, - MermaidDiagramPageResponse, - MermaidDiagramQueryRequest, - MermaidDiagramSummary, - MermaidDiagramUpdateRequest, - MermaidGroupListResponse, - MermaidGroupSummary, -) -from .llm_gateway import create_assistant_reply, create_reply_with_model - -MERMAID_GROUP_TYPE = "MERMAID" - -MERMAID_GENERATE_SYSTEM_PROMPT = """You are an expert in Mermaid diagrams. -Please update or generate Mermaid code based on the user advice. -IMPORTANT RULES: -1. Return ONLY the raw Mermaid code. -2. Do NOT wrap the code in markdown code blocks. -3. Do NOT include any conversational text.""" - -MERMAID_CHAT_SYSTEM_PROMPT_TEMPLATE = """You are an expert in Mermaid diagrams. -Current Mermaid Code: -{diagram_data} - -IMPORTANT RULES: -1. Return ONLY the raw Mermaid code. -2. Do NOT wrap the code in markdown code blocks. -3. Do NOT include any conversational text.""" - - -def search_mermaid_diagrams( - db: Session, - payload: MermaidDiagramQueryRequest, - *, - actor: User, -) -> MermaidDiagramPageResponse: - filters = [MermaidDiagram.create_user == actor.username] - - keyword = _normalize_str(payload.key_word) - if keyword: - filters.append(MermaidDiagram.diagram_name.ilike(f"%{keyword}%")) - - normalized_group = _normalize_str(payload.group) - if normalized_group: - group_obj_ids = db.execute( - select(ObjectGroupRelation.obj_id) - .join(ObjectGroup, ObjectGroup.id == ObjectGroupRelation.group_id) - .where( - ObjectGroup.create_user == actor.username, - ObjectGroup.type == MERMAID_GROUP_TYPE, - ObjectGroup.name == normalized_group, - ) - ).scalars().all() - if not group_obj_ids: - return MermaidDiagramPageResponse( - items=[], - total=0, - page_num=payload.page_num, - page_size=payload.page_size, - ) - filters.append(MermaidDiagram.id.in_(set(group_obj_ids))) - - total_stmt = select(func.count()).select_from(MermaidDiagram).where(*filters) - total = int(db.execute(total_stmt).scalar_one() or 0) - - items = db.execute( - select(MermaidDiagram) - .where(*filters) - .order_by(MermaidDiagram.update_date.desc(), MermaidDiagram.create_date.desc()) - .offset(payload.page_num * payload.page_size) - .limit(payload.page_size) - ).scalars().all() - - group_map = _build_group_map(db, [item.id for item in items]) - return MermaidDiagramPageResponse( - items=[serialize_mermaid_diagram(item, group_map.get(item.id)) for item in items], - total=total, - page_num=payload.page_num, - page_size=payload.page_size, - ) - - -def list_mermaid_groups( - db: Session, - *, - actor: User, -) -> MermaidGroupListResponse: - groups = db.execute( - select(ObjectGroup) - .where( - ObjectGroup.create_user == actor.username, - ObjectGroup.type == MERMAID_GROUP_TYPE, - ) - .order_by(ObjectGroup.label.asc(), ObjectGroup.name.asc()) - ).scalars().all() - return MermaidGroupListResponse( - items=[serialize_mermaid_group(item) for item in groups], - total=len(groups), - ) - - -def get_mermaid_diagram_by_id( - db: Session, - diagram_id: str, - *, - actor: User | None = None, -) -> MermaidDiagram | None: - item = db.execute(select(MermaidDiagram).where(MermaidDiagram.id == diagram_id)).scalar_one_or_none() - if not item: - return None - if actor and item.create_user != actor.username: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to access mermaid diagram") - return item - - -def get_mermaid_diagram_summary( - db: Session, - diagram_id: str, - *, - actor: User, -) -> MermaidDiagramSummary | None: - item = get_mermaid_diagram_by_id(db, diagram_id, actor=actor) - if not item: - return None - group_map = _build_group_map(db, [item.id]) - return serialize_mermaid_diagram(item, group_map.get(item.id)) - - -def create_mermaid_diagram( - db: Session, - payload: MermaidDiagramCreateRequest, - *, - actor: User, -) -> MermaidDiagramSummary: - now = utcnow() - item = MermaidDiagram( - id=uuid4().hex, - diagram_name=payload.diagram_name.strip(), - description=_normalize_str(payload.description) or "", - diagram_data=_normalize_str(payload.diagram_data) or "", - create_user=actor.username, - update_user=actor.username, - create_date=now, - update_date=now, - ) - db.add(item) - db.flush() - - _replace_mermaid_group_relation( - db, - diagram_id=item.id, - group_name=payload.group, - actor=actor, - now=now, - ) - - db.commit() - saved = get_mermaid_diagram_summary(db, item.id, actor=actor) - if not saved: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Mermaid diagram save failed") - return saved - - -def update_mermaid_diagram( - db: Session, - payload: MermaidDiagramUpdateRequest, - *, - actor: User, -) -> MermaidDiagramSummary: - item = get_mermaid_diagram_by_id(db, payload.id, actor=actor) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mermaid diagram not found") - - now = utcnow() - update_data = payload.model_dump(exclude_unset=True) - - if "diagram_name" in update_data and update_data["diagram_name"] is not None: - item.diagram_name = str(update_data["diagram_name"]).strip() - if "description" in update_data: - item.description = _normalize_str(update_data["description"]) or "" - if "diagram_data" in update_data: - item.diagram_data = _normalize_str(update_data["diagram_data"]) or "" - - if "group" in update_data: - _replace_mermaid_group_relation( - db, - diagram_id=item.id, - group_name=update_data["group"], - actor=actor, - now=now, - ) - - item.update_user = actor.username - item.update_date = now - db.commit() - - saved = get_mermaid_diagram_summary(db, item.id, actor=actor) - if not saved: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Mermaid diagram load failed") - return saved - - -def update_mermaid_diagram_data( - db: Session, - diagram_id: str, - payload: MermaidDiagramDataPatchRequest, - *, - actor: User, -) -> MermaidDiagramSummary: - item = get_mermaid_diagram_by_id(db, diagram_id, actor=actor) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mermaid diagram not found") - - item.diagram_data = payload.diagram_data.strip() - item.update_user = actor.username - item.update_date = utcnow() - db.commit() - - saved = get_mermaid_diagram_summary(db, diagram_id, actor=actor) - if not saved: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Mermaid diagram load failed") - return saved - - -def delete_mermaid_diagram( - db: Session, - diagram_id: str, - *, - actor: User, -) -> dict[str, bool]: - item = get_mermaid_diagram_by_id(db, diagram_id, actor=actor) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mermaid diagram not found") - - db.execute(delete(ObjectGroupRelation).where(ObjectGroupRelation.obj_id == item.id)) - db.delete(item) - db.commit() - return {"success": True} - - -async def stream_generate_mermaid_code( - db: Session, - *, - advice: str, - diagram_data: str | None, - model_name: str | None, -) -> AsyncGenerator[str, None]: - normalized_advice = (advice or "").strip() - if not normalized_advice: - yield "[ERROR]Advice cannot be empty" - return - - system_prompt = ( - f"{MERMAID_GENERATE_SYSTEM_PROMPT}\n\n" - f"Current Mermaid Code:\n{(diagram_data or '').strip()}" - ) - - try: - result = _generate_mermaid_reply( - db=db, - user_message=normalized_advice, - context_messages=[], - system_prompt=system_prompt, - model_name=model_name, - ) - except HTTPException as exc: - yield f"[ERROR]{exc.detail}" - return - except Exception as exc: # pragma: no cover - defensive fallback - yield f"[ERROR]服务异常: {exc}" - return - - for chunk in _chunk_text(result, chunk_size=120): - yield chunk - - -async def stream_chat_mermaid_code( - db: Session, - payload: MermaidChatStreamRequest, -) -> AsyncGenerator[str, None]: - normalized_messages: list[tuple[str, str]] = [] - for item in payload.messages: - content = item.content.strip() - if not content: - continue - normalized_messages.append((item.role, content)) - - if not normalized_messages: - yield "[ERROR]Messages cannot be empty" - return - - user_message: str | None = None - context_messages: list[tuple[str, str]] = [] - for role, content in normalized_messages: - if role == "user": - user_message = content - continue - if role in {"assistant"}: - context_messages.append((role, content)) - - if not user_message: - yield "[ERROR]Last user message is required" - return - - # Use all messages except last user turn as context. - context_messages = [ - (role, content) - for role, content in normalized_messages[:-1] - if role in {"user", "assistant"} - ] - - system_prompt = MERMAID_CHAT_SYSTEM_PROMPT_TEMPLATE.format( - diagram_data=(payload.diagram_data or "").strip(), - ) - - try: - result = _generate_mermaid_reply( - db=db, - user_message=user_message, - context_messages=context_messages, - system_prompt=system_prompt, - model_name=payload.model_name, - ) - except HTTPException as exc: - yield f"[ERROR]{exc.detail}" - return - except Exception as exc: # pragma: no cover - defensive fallback - yield f"[ERROR]服务异常: {exc}" - return - - for chunk in _chunk_text(result, chunk_size=120): - yield chunk - - -def serialize_mermaid_group(item: ObjectGroup) -> MermaidGroupSummary: - return MermaidGroupSummary( - id=item.id, - name=item.name, - label=item.label, - type=item.type, - descr=item.descr, - ) - - -def serialize_mermaid_diagram( - item: MermaidDiagram, - group: ObjectGroup | None, -) -> MermaidDiagramSummary: - return MermaidDiagramSummary( - id=item.id, - diagram_name=item.diagram_name, - description=item.description, - diagram_data=item.diagram_data, - group_name=group.name if group else None, - group_label=group.label if group else None, - tag_names=[], - tag_labels=[], - create_date=item.create_date, - create_user=item.create_user, - update_date=item.update_date, - update_user=item.update_user, - ) - - -def _build_group_map(db: Session, diagram_ids: list[str]) -> dict[str, ObjectGroup]: - if not diagram_ids: - return {} - - relation_rows = db.execute( - select(ObjectGroupRelation.obj_id, ObjectGroupRelation.group_id) - .where(ObjectGroupRelation.obj_id.in_(diagram_ids)) - .order_by(ObjectGroupRelation.rela_id.asc()) - ).all() - if not relation_rows: - return {} - - obj_to_group_id: dict[str, str] = {} - for obj_id, group_id in relation_rows: - if obj_id not in obj_to_group_id: - obj_to_group_id[obj_id] = group_id - - group_ids = sorted(set(obj_to_group_id.values())) - groups = db.execute(select(ObjectGroup).where(ObjectGroup.id.in_(group_ids))).scalars().all() - group_map = {group.id: group for group in groups} - return {obj_id: group_map[group_id] for obj_id, group_id in obj_to_group_id.items() if group_id in group_map} - - -def _replace_mermaid_group_relation( - db: Session, - *, - diagram_id: str, - group_name: str | None, - actor: User, - now: datetime, -) -> None: - db.execute(delete(ObjectGroupRelation).where(ObjectGroupRelation.obj_id == diagram_id)) - normalized_group_name = _normalize_str(group_name) - if not normalized_group_name: - return - - group = db.execute( - select(ObjectGroup).where( - ObjectGroup.create_user == actor.username, - ObjectGroup.type == MERMAID_GROUP_TYPE, - ObjectGroup.name == normalized_group_name, - ) - ).scalar_one_or_none() - - if not group: - group = ObjectGroup( - id=uuid4().hex, - name=normalized_group_name, - label=normalized_group_name, - type=MERMAID_GROUP_TYPE, - descr="", - create_user=actor.username, - update_user=actor.username, - create_date=now, - update_date=now, - ) - db.add(group) - db.flush() - - relation = ObjectGroupRelation( - rela_id=uuid4().hex, - group_id=group.id, - obj_id=diagram_id, - ) - db.add(relation) - - -def _generate_mermaid_reply( - db: Session, - *, - user_message: str, - context_messages: list[tuple[str, str]], - system_prompt: str, - model_name: str | None, -) -> str: - model = _resolve_enabled_model_by_name(db, model_name=model_name) - if model: - result = create_reply_with_model( - model=model, - user_message=user_message, - context_messages=context_messages, - system_prompt=system_prompt, - ) - return result.content.strip() - - result = create_assistant_reply( - db, - user_message=user_message, - context_messages=context_messages, - system_prompt=system_prompt, - ) - return result.content.strip() - - -def _resolve_enabled_model_by_name( - db: Session, - *, - model_name: str | None, -) -> ModelRegistry | None: - normalized = _normalize_str(model_name) - if not normalized: - return None - - candidates = db.execute( - select(ModelRegistry) - .where( - ModelRegistry.name == normalized, - ModelRegistry.status == "ENABLED", - ) - .order_by(ModelRegistry.id.desc()) - ).scalars().all() - - for model in candidates: - active_key = db.scalar( - select(ModelApiKey.id).where( - ModelApiKey.model_id == model.id, - ModelApiKey.is_active.is_(True), - ) - ) - if active_key is not None: - return model - - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Model not found or unavailable: {normalized}", - ) - - -def _normalize_str(value: str | None) -> str | None: - if value is None: - return None - normalized = str(value).strip() - return normalized or None - - -def _chunk_text(text: str, *, chunk_size: int) -> list[str]: - if not text: - return [] - chunks: list[str] = [] - start = 0 - while start < len(text): - chunks.append(text[start:start + chunk_size]) - start += chunk_size - return chunks diff --git a/api/app/services/mind_map_service.py b/api/app/services/mind_map_service.py deleted file mode 100644 index f849f12..0000000 --- a/api/app/services/mind_map_service.py +++ /dev/null @@ -1,373 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import AsyncGenerator -from uuid import uuid4 - -from fastapi import HTTPException, status -from sqlalchemy import func, select -from sqlalchemy.orm import Session - -from ..models.base import utcnow -from ..models.mind_map import MindMap -from ..models.user import User -from ..schemas.mind_map import ( - MindMapBasicInfoUpdateRequest, - MindMapCreateRequest, - MindMapDataUpdateRequest, - MindMapPageResponse, - MindMapQueryRequest, - MindMapSummary, -) -from .llm_gateway import create_assistant_reply - -MIND_MAP_GENERATION_PROMPT = """你是思维导图生成助手。 -请根据用户描述,输出一个 JSON 对象,不要输出额外文字。 -JSON 结构必须符合下述格式: -{ - "nodeData": { "id": "root", "topic": "中心主题", "root": true }, - "nodeChild": [ - { - "nodeData": { "id": "n1", "topic": "一级主题" }, - "nodeChild": [ - { "nodeData": { "id": "n1-1", "topic": "二级主题" }, "nodeChild": [] } - ] - } - ] -} -要求: -1. 所有节点都使用 nodeData + nodeChild; -2. topic 用简洁中文短语; -3. 保持层次清晰,避免超过 4 层; -4. 输出必须是可解析 JSON。""" - - -def build_initial_mind_map_data(title: str) -> str: - topic = (title or "").strip() or "新思维导图" - data = { - "nodeData": { - "id": "root", - "topic": topic, - "root": True, - }, - "nodeChild": [], - } - return json.dumps(data, ensure_ascii=False) - - -def serialize_mind_map(item: MindMap) -> MindMapSummary: - return MindMapSummary( - id=item.id, - map_name=item.map_name, - descr=item.descr, - map_data=item.map_data, - create_date=item.create_date, - create_user=item.create_user, - update_date=item.update_date, - update_user=item.update_user, - ) - - -def search_mind_maps( - db: Session, - payload: MindMapQueryRequest, - *, - actor: User, -) -> MindMapPageResponse: - filters = [MindMap.create_user == actor.username] - keyword = (payload.map_name or "").strip() - if keyword: - filters.append(MindMap.map_name.ilike(f"%{keyword}%")) - - total_stmt = select(func.count()).select_from(MindMap).where(*filters) - total = int(db.execute(total_stmt).scalar_one() or 0) - - items = db.execute( - select(MindMap) - .where(*filters) - .order_by(MindMap.create_date.desc()) - .offset(payload.page_num * payload.page_size) - .limit(payload.page_size) - ).scalars().all() - - return MindMapPageResponse( - items=[serialize_mind_map(item) for item in items], - total=total, - page_num=payload.page_num, - page_size=payload.page_size, - ) - - -def get_mind_map_by_id( - db: Session, - mind_map_id: str, - *, - actor: User | None = None, -) -> MindMap | None: - item = db.execute(select(MindMap).where(MindMap.id == mind_map_id)).scalar_one_or_none() - if not item: - return None - if actor and item.create_user != actor.username: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to access mind map") - return item - - -def create_mind_map( - db: Session, - payload: MindMapCreateRequest, - *, - actor: User, - fixed_id: str | None = None, -) -> MindMapSummary: - now = utcnow() - map_name = payload.map_name.strip() - item = MindMap( - id=(fixed_id or uuid4().hex), - map_name=map_name, - descr=_normalize_str(payload.descr) or "", - map_data=_normalize_map_data(payload.map_data, map_name=map_name), - create_user=actor.username, - update_user=actor.username, - create_date=now, - update_date=now, - ) - db.add(item) - db.commit() - - saved = get_mind_map_by_id(db, item.id) - if not saved: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Mind map save failed") - return serialize_mind_map(saved) - - -def update_mind_map_basic_info( - db: Session, - payload: MindMapBasicInfoUpdateRequest, - *, - actor: User, -) -> MindMapSummary: - item = get_mind_map_by_id(db, payload.id, actor=actor) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mind map not found") - - item.map_name = payload.map_name.strip() - item.descr = _normalize_str(payload.descr) or "" - item.update_user = actor.username - item.update_date = utcnow() - db.commit() - - saved = get_mind_map_by_id(db, payload.id, actor=actor) - if not saved: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Mind map load failed") - return serialize_mind_map(saved) - - -def update_mind_map_data( - db: Session, - payload: MindMapDataUpdateRequest, - *, - actor: User, -) -> MindMapSummary: - item = get_mind_map_by_id(db, payload.id, actor=actor) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mind map not found") - - item.map_data = _normalize_map_data(payload.map_data, map_name=item.map_name) - item.update_user = actor.username - item.update_date = utcnow() - db.commit() - - saved = get_mind_map_by_id(db, payload.id, actor=actor) - if not saved: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Mind map load failed") - return serialize_mind_map(saved) - - -def delete_mind_map( - db: Session, - mind_map_id: str, - *, - actor: User, -) -> dict[str, bool]: - item = get_mind_map_by_id(db, mind_map_id, actor=actor) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mind map not found") - - db.delete(item) - db.commit() - return {"success": True} - - -async def stream_generate_mind_map( - db: Session, - *, - descr: str, -) -> AsyncGenerator[str, None]: - text = descr.strip() - if not text: - yield "[ERROR]思维导图描述不能为空" - return - - yield "connected" - try: - result = create_assistant_reply( - db, - user_message=text, - context_messages=[], - system_prompt=MIND_MAP_GENERATION_PROMPT, - ) - content = result.content.strip() - except HTTPException as exc: - yield f"[ERROR]{exc.detail}" - return - except Exception as exc: # pragma: no cover - defensive fallback - yield f"[ERROR]服务异常: {exc}" - return - - for chunk in _chunk_text(content, chunk_size=120): - yield chunk - - try: - generated = _coerce_generated_mind_map(content) - yield "[PARSE_RESULT]" - yield f"[MINDMAP]{json.dumps(generated, ensure_ascii=False)}" - except Exception as exc: - yield f"[ERROR]解析JSON失败: {exc}" - - -def _normalize_map_data(map_data: str | None, *, map_name: str) -> str: - value = (map_data or "").strip() - if not value: - return build_initial_mind_map_data(map_name) - - try: - parsed = json.loads(value) - except json.JSONDecodeError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid map_data JSON: {exc}") from exc - - if not isinstance(parsed, dict): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="map_data must be a JSON object") - return json.dumps(parsed, ensure_ascii=False) - - -def _coerce_generated_mind_map(content: str) -> dict[str, object]: - parsed = _load_json_object(content) - if "nodeData" in parsed: - return _normalize_node_tree(parsed, is_root=True) - if "root" in parsed and isinstance(parsed["root"], dict): - return _normalize_root_tree(parsed["root"], is_root=True) - raise ValueError("JSON 格式不符合思维导图结构") - - -def _normalize_node_tree(node: dict[str, object], *, is_root: bool) -> dict[str, object]: - node_data_raw = node.get("nodeData") - node_data = node_data_raw if isinstance(node_data_raw, dict) else {} - topic = _pick_topic(node_data, fallback="中心主题" if is_root else "未命名主题") - node_id = _pick_node_id(node_data, fallback="root" if is_root else None) - - child_source = node.get("nodeChild") - if not isinstance(child_source, list): - alt_children = node.get("children") - child_source = alt_children if isinstance(alt_children, list) else [] - - normalized_children = [ - _normalize_node_tree(child, is_root=False) - for child in child_source - if isinstance(child, dict) - ] - normalized_node_data: dict[str, object] = { - "id": node_id, - "topic": topic, - } - if is_root: - normalized_node_data["root"] = True - - return { - "nodeData": normalized_node_data, - "nodeChild": normalized_children, - } - - -def _normalize_root_tree(node: dict[str, object], *, is_root: bool) -> dict[str, object]: - data = node.get("data") - data_obj = data if isinstance(data, dict) else {} - topic = _pick_topic(data_obj, fallback="中心主题" if is_root else "未命名主题") - node_id = _pick_node_id(node, fallback="root" if is_root else None) - - children_obj = node.get("children") - children = children_obj if isinstance(children_obj, list) else [] - - normalized_children = [ - _normalize_root_tree(child, is_root=False) - for child in children - if isinstance(child, dict) - ] - normalized_node_data: dict[str, object] = { - "id": node_id, - "topic": topic, - } - if is_root: - normalized_node_data["root"] = True - - return { - "nodeData": normalized_node_data, - "nodeChild": normalized_children, - } - - -def _pick_topic(data: dict[str, object], *, fallback: str) -> str: - for key in ("topic", "text", "name", "title"): - value = data.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return fallback - - -def _pick_node_id(data: dict[str, object], *, fallback: str | None) -> str: - value = data.get("id") - if isinstance(value, str) and value.strip(): - return value.strip() - if fallback: - return fallback - return uuid4().hex[:8] - - -def _load_json_object(content: str) -> dict[str, object]: - text = content.strip() - text = _strip_markdown_fence(text) - try: - parsed = json.loads(text) - except json.JSONDecodeError: - start = text.find("{") - end = text.rfind("}") - if start < 0 or end <= start: - raise - parsed = json.loads(text[start : end + 1]) - - if not isinstance(parsed, dict): - raise ValueError("JSON 顶层必须是对象") - return parsed - - -def _strip_markdown_fence(content: str) -> str: - text = content.strip() - if text.startswith("```json"): - text = text[7:] - elif text.startswith("```"): - text = text[3:] - if text.endswith("```"): - text = text[:-3] - return text.strip() - - -def _chunk_text(text: str, *, chunk_size: int) -> list[str]: - if not text: - return [] - return [text[index : index + chunk_size] for index in range(0, len(text), chunk_size)] - - -def _normalize_str(value: str | None) -> str | None: - if value is None: - return None - normalized = value.strip() - return normalized or None diff --git a/api/app/services/model_service.py b/api/app/services/model_service.py index 89b4c02..ef577bf 100644 --- a/api/app/services/model_service.py +++ b/api/app/services/model_service.py @@ -55,7 +55,7 @@ from ..schemas.token_usage import ( from .llm_gateway import create_reply_with_model from .push_service import publish_topic -MODEL_TOPIC = "admin.models" +MODEL_TOPIC = "model.registry" GLOBAL_ROUTE_KEY = "__global__" VALID_STATUSES = ("DRAFT", "ENABLED", "DISABLED", "DEPRECATED") VALID_ROUTE_TYPES = ("GLOBAL", "CAPABILITY", "BUSINESS", "AGENT") @@ -293,7 +293,7 @@ def delete_model(db: Session, model_id: int) -> None: MODEL_TOPIC, name="models.changed", payload={"action": "deleted", "model_id": deleted_model_id, "model_code": deleted_model_code}, - requires_refetch=["/api/v1/admin/models", "/api/v1/admin/models/summary", "/api/v1/admin/model-routes"], + requires_refetch=[], dedupe_key=f"models:deleted:{deleted_model_id}", ) ) @@ -606,7 +606,7 @@ def ingest_model_usage(db: Session, payload: ModelUsageIngestRequest) -> dict[st MODEL_TOPIC, name="models.usage_ingested", payload={"model_code": model_code, "request_count": payload.request_count}, - requires_refetch=["/api/v1/admin/models", "/api/v1/admin/models/summary"], + requires_refetch=[], dedupe_key=f"models:usage:{model_code}", ) ) @@ -721,7 +721,7 @@ def delete_route_rule(db: Session, route_rule_id: int) -> dict[str, bool]: "route_type": deleted_route_type, "route_key": deleted_route_key, }, - requires_refetch=["/api/v1/admin/model-routes", "/api/v1/admin/models", "/api/v1/admin/models/summary"], + requires_refetch=[], dedupe_key=f"model_routes:deleted:{deleted_rule_id}", ) ) @@ -1328,7 +1328,7 @@ def _publish_model_changed(action: str, *, model: ModelRegistry, extra_payload: MODEL_TOPIC, name="models.changed", payload=payload, - requires_refetch=["/api/v1/admin/models", "/api/v1/admin/models/summary", "/api/v1/admin/model-routes"], + requires_refetch=[], dedupe_key=f"models:{action}:{model.id}", ) ) @@ -1346,7 +1346,7 @@ def _publish_route_changed(action: str, *, rule: ModelRouteRule) -> None: "route_key": rule.route_key, "target_model_code": rule.target_model_code, }, - requires_refetch=["/api/v1/admin/model-routes", "/api/v1/admin/models", "/api/v1/admin/models/summary"], + requires_refetch=[], dedupe_key=f"model_routes:{action}:{rule.id}", ) ) diff --git a/api/app/services/requirement_service.py b/api/app/services/requirement_service.py index d1094ef..9540f82 100644 --- a/api/app/services/requirement_service.py +++ b/api/app/services/requirement_service.py @@ -303,10 +303,7 @@ def delete_requirement(db: Session, requirement_id: str, *, actor: User) -> bool "code": deleted_id, "actor_user_id": actor.id, }, - requires_refetch=[ - "/api/v1/requirements", - f"/api/v1/requirements/{deleted_id}", - ], + requires_refetch=[], dedupe_key=f"requirements:deleted:{deleted_id}", ) ) @@ -502,12 +499,7 @@ def _publish_requirement_change( TOPIC_NAME, name=event_name, payload=payload, - requires_refetch=[ - "/api/v1/requirements", - f"/api/v1/requirements/{requirement.id}", - f"/api/v1/requirements/{requirement.id}/comments", - f"/api/v1/requirements/{requirement.id}/events", - ], + requires_refetch=[], dedupe_key=f"requirements:{action}:{requirement.id}", ) ) diff --git a/api/app/services/seed_service.py b/api/app/services/seed_service.py index c7375e7..b6c6647 100644 --- a/api/app/services/seed_service.py +++ b/api/app/services/seed_service.py @@ -20,19 +20,8 @@ DEFAULT_PERMISSIONS: dict[str, str] = { "menu.manage": "Manage menus", "system_param.read": "Read system parameters", "system_param.manage": "Manage system parameters", - "model.read": "Read model registry and routing summary", - "model.manage": "Manage model registry, routes, keys, and health checks", "file.read": "Read file mounts and indexed entries", "file.manage": "Manage file operations and storage sync", - "chat.use": "Use AI chat feature", - "requirement.read": "Read requirements", - "requirement.create": "Create requirements", - "requirement.process": "Process requirements", - "requirement.manage": "Manage all requirements", - "todo.read": "Read todos", - "todo.create": "Create todos", - "todo.process": "Process todos", - "todo.manage": "Manage all todos", "question_bank.read": "Read question bank entries", "question_bank.manage": "Manage question bank entries", "vocabulary.read": "Read vocabulary words", @@ -43,6 +32,11 @@ DEFAULT_PERMISSIONS: dict[str, str] = { "tower.manage": "Manage line towers", "lightning.read": "Read lightning current events and features", "lightning.manage": "Manage lightning current events and data imports", + "atp.read": "Read ATP models and versions", + "atp.manage": "Manage ATP models and version artifacts", + "atp.run": "Run ATP simulations", + "celery.read": "Read Celery workers, queues, and task statuses", + "celery.manage": "Manage Celery worker control operations", "wine.read": "Read Wine executor status", "wine.manage": "Run Windows executables through Wine", } @@ -60,19 +54,8 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = { "menu.manage", "system_param.read", "system_param.manage", - "model.read", - "model.manage", "file.read", "file.manage", - "chat.use", - "requirement.read", - "requirement.create", - "requirement.process", - "requirement.manage", - "todo.read", - "todo.create", - "todo.process", - "todo.manage", "question_bank.read", "question_bank.manage", "vocabulary.read", @@ -83,6 +66,11 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = { "tower.manage", "lightning.read", "lightning.manage", + "atp.read", + "atp.manage", + "atp.run", + "celery.read", + "celery.manage", "wine.read", "wine.manage", ], @@ -159,71 +147,6 @@ DEFAULT_MENUS: list[dict[str, object]] = [ "cacheable": False, "permission_code": "system_param.read", }, - { - "code": "admin.agent", - "name": "编排管理", - "path": "/admin/orchestration", - "icon": "Bot", - "parent_code": None, - "type": "menu", - "sort_order": 63, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "model.read", - }, - { - "code": "admin.mcp_server", - "name": "MCP管理", - "path": "/admin/mcp-server", - "icon": "Server", - "parent_code": None, - "type": "menu", - "sort_order": 63, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "model.read", - }, - { - "code": "admin.mermaid_mgr", - "name": "流程图", - "path": "/admin/mermaid-mgr", - "icon": "Workflow", - "parent_code": None, - "type": "menu", - "sort_order": 54, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "question_bank.read", - }, - { - "code": "admin.files", - "name": "知识集管理", - "path": "/admin/knowledge-set", - "icon": "FolderTree", - "parent_code": None, - "type": "menu", - "sort_order": 54, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "file.read", - }, - { - "code": "admin.requirements", - "name": "需求管理", - "path": "/admin/requirements", - "icon": "ClipboardList", - "parent_code": None, - "type": "menu", - "sort_order": 50, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "requirement.read", - }, { "code": "admin.power_lines", "name": "线路管理", @@ -263,32 +186,6 @@ DEFAULT_MENUS: list[dict[str, object]] = [ "cacheable": False, "permission_code": "lightning.read", }, - { - "code": "admin.mindmap", - "name": "思维导图", - "path": "/admin/mindmap", - "icon": "ChartBar", - "parent_code": None, - "type": "menu", - "sort_order": 51, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "question_bank.read", - }, - { - "code": "admin.schedule", - "name": "日程管理", - "path": "/admin/schedule", - "icon": "CalendarDays", - "parent_code": None, - "type": "menu", - "sort_order": 52, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "todo.read", - }, { "code": "admin.task_monitor", "name": "任务监控", @@ -300,7 +197,33 @@ DEFAULT_MENUS: list[dict[str, object]] = [ "status": "enabled", "visible": True, "cacheable": False, - "permission_code": "requirement.read", + "permission_code": "celery.read", + }, + { + "code": "admin.atp_models", + "name": "ATP模型管理", + "path": "/admin/power-lines/atp-viewer", + "icon": "Experiment", + "parent_code": None, + "type": "menu", + "sort_order": 54, + "status": "enabled", + "visible": True, + "cacheable": False, + "permission_code": "atp.read", + }, + { + "code": "admin.files", + "name": "文件管理", + "path": "/admin/files", + "icon": "FolderTree", + "parent_code": None, + "type": "menu", + "sort_order": 55, + "status": "enabled", + "visible": True, + "cacheable": False, + "permission_code": "file.read", }, { "code": "admin.syslog", @@ -315,45 +238,6 @@ DEFAULT_MENUS: list[dict[str, object]] = [ "cacheable": False, "permission_code": "menu.read", }, - { - "code": "admin.chat", - "name": "AI 聊天", - "path": "/admin/chat", - "icon": "MessagesSquare", - "parent_code": None, - "type": "menu", - "sort_order": 58, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "chat.use", - }, - { - "code": "admin.api_tester", - "name": "API测试", - "path": "/admin/api-tester", - "icon": "TestTube2", - "parent_code": None, - "type": "menu", - "sort_order": 63, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "model.read", - }, - { - "code": "admin.models", - "name": "模型管理", - "path": "/admin/models", - "icon": "Bot", - "parent_code": None, - "type": "menu", - "sort_order": 64, - "status": "enabled", - "visible": True, - "cacheable": False, - "permission_code": "model.read", - }, { "code": "admin.wine_runner", "name": "Wine执行器", @@ -370,10 +254,11 @@ DEFAULT_MENUS: list[dict[str, object]] = [ ] ROLE_MENU_BINDINGS: dict[str, list[str]] = { - "admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.agent", "admin.mcp_server", "admin.files", "admin.requirements", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.mindmap", "admin.schedule", "admin.task_monitor", "admin.mermaid_mgr", "admin.syslog", "admin.chat", "admin.api_tester", "admin.models", "admin.wine_runner"], + "admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.task_monitor", "admin.atp_models", "admin.files", "admin.syslog", "admin.wine_runner"], "user": ["dashboard"], } + def _default_file_storage_backends() -> list[dict[str, object]]: minio_enabled = bool(settings.minio_enabled) return [ @@ -504,31 +389,6 @@ def _seed_role_menus(db: Session, role_map: dict[str, Role], menu_map: dict[str, db.flush() -def _seed_initial_admin(db: Session) -> None: - if not settings.initial_admin_email or not settings.initial_admin_password: - return - - admin_role = db.scalar(select(Role).where(Role.code == "admin")) - if not admin_role: - return - - admin_email = settings.initial_admin_email.lower() - user = db.scalar(select(User).where(User.email == admin_email)) - if not user: - user = User( - email=admin_email, - username=settings.initial_admin_username, - password_hash=hash_password(settings.initial_admin_password), - status="ENABLED", - ) - db.add(user) - db.flush() - - role_codes = {role.code for role in user.roles} - if "admin" not in role_codes: - user.roles.append(admin_role) - - def _seed_file_storage(db: Session) -> None: backend_map: dict[str, FileStorageBackend] = {} @@ -584,3 +444,28 @@ def _seed_file_storage(db: Session) -> None: mount.root_path = str(mount_info["root_path"]) if mount_info.get("is_enabled") is not None: mount.is_enabled = bool(mount_info["is_enabled"]) + + +def _seed_initial_admin(db: Session) -> None: + if not settings.initial_admin_email or not settings.initial_admin_password: + return + + admin_role = db.scalar(select(Role).where(Role.code == "admin")) + if not admin_role: + return + + admin_email = settings.initial_admin_email.lower() + user = db.scalar(select(User).where(User.email == admin_email)) + if not user: + user = User( + email=admin_email, + username=settings.initial_admin_username, + password_hash=hash_password(settings.initial_admin_password), + status="ENABLED", + ) + db.add(user) + db.flush() + + role_codes = {role.code for role in user.roles} + if "admin" not in role_codes: + user.roles.append(admin_role) diff --git a/api/app/services/task_monitor_service.py b/api/app/services/task_monitor_service.py index feb50be..31976b6 100644 --- a/api/app/services/task_monitor_service.py +++ b/api/app/services/task_monitor_service.py @@ -1,300 +1,445 @@ from __future__ import annotations -import math -from datetime import UTC, datetime, timedelta +import json +from collections.abc import Iterable +from datetime import datetime, timezone +from urllib.parse import urlsplit, urlunsplit -from sqlalchemy import and_, func, or_, select -from sqlalchemy.orm import Session +try: + import redis +except Exception: # pragma: no cover - optional dependency in non-runtime environments + redis = None +from ..core.celery_app import celery_app +from ..core.config import get_settings from ..models.base import utcnow -from ..models.requirement import Requirement -from ..models.todo import Todo -from ..models.user import User from ..schemas.task_monitor import ( TaskMonitorBucketItem, TaskMonitorOverviewResponse, - TaskMonitorRequirementRiskItem, - TaskMonitorTodoRiskItem, + TaskMonitorQueueItem, + TaskMonitorTaskItem, + TaskMonitorWorkerItem, ) -REQUIREMENT_STATUS_LABELS: dict[str, str] = { - "PENDING_ANALYSIS": "待分析", - "PENDING_REVIEW": "待评审", - "PENDING_REVISION": "待修订", - "OPEN": "待处理", - "IN_PROGRESS": "处理中", - "COMPLETED": "已完成", - "CLOSED": "已关闭", - "CANCELLED": "已取消", +STATE_LABELS = { + "PENDING": "待执行", + "RECEIVED": "已接收", + "STARTED": "执行中", + "SCHEDULED": "定时中", + "RETRY": "重试中", + "SUCCESS": "成功", + "FAILURE": "失败", + "REVOKED": "已撤销", } -REQUIREMENT_DONE_STATUSES = {"COMPLETED", "CLOSED", "CANCELLED"} -HIGH_PRIORITY_VALUES = {"HIGH", "URGENT"} -TODO_STATUS_LABELS: dict[str, str] = { - "SCHEDULED": "已计划", - "IN_PROGRESS": "处理中", - "COMPLETED": "已完成", - "CANCELLED": "已取消", - "EXPIRED": "已过期", -} -TODO_ACTIVE_STATUSES = {"SCHEDULED", "IN_PROGRESS"} - -PRIORITY_LABELS: dict[str, str] = { - "LOW": "低", - "MEDIUM": "中", - "HIGH": "高", - "URGENT": "紧急", +STATE_PRIORITY = { + "STARTED": 0, + "RECEIVED": 1, + "SCHEDULED": 2, + "RETRY": 3, + "FAILURE": 4, + "SUCCESS": 5, + "REVOKED": 6, + "PENDING": 7, } -def build_task_monitor_overview( - db: Session, - *, - actor: User, - can_read_requirements: bool, - can_read_todos: bool, - can_manage_todos: bool, - risk_limit: int, - stale_hours: int, -) -> TaskMonitorOverviewResponse: +def build_task_monitor_overview(*, task_limit: int, history_limit: int) -> TaskMonitorOverviewResponse: + settings = get_settings() now = utcnow() - overview = TaskMonitorOverviewResponse(generated_at=now) + overview = TaskMonitorOverviewResponse( + generated_at=now, + broker_url=_mask_url(settings.resolved_celery_broker_url), + result_backend=_mask_url(settings.resolved_celery_result_backend), + ) - if can_read_requirements: - _fill_requirement_metrics( - db, - overview=overview, - now=now, - risk_limit=risk_limit, - stale_hours=stale_hours, - ) + inspector = celery_app.control.inspect(timeout=1.0) + stats = _safe_inspect_call(inspector.stats) + active = _safe_inspect_call(inspector.active) + reserved = _safe_inspect_call(inspector.reserved) + scheduled = _safe_inspect_call(inspector.scheduled) + active_queues = _safe_inspect_call(inspector.active_queues) + ping = _safe_inspect_call(inspector.ping) - if can_read_todos: - _fill_todo_metrics( - db, - overview=overview, - now=now, - actor=actor, - can_manage_todos=can_manage_todos, - risk_limit=risk_limit, + worker_names = sorted(set(stats) | set(active) | set(reserved) | set(scheduled) | set(active_queues) | set(ping)) + overview.workers = [ + _build_worker_item( + worker, + stats=stats.get(worker) or {}, + active_tasks=active.get(worker) or [], + reserved_tasks=reserved.get(worker) or [], + scheduled_tasks=scheduled.get(worker) or [], + queues=active_queues.get(worker) or [], + online=worker in ping if ping else True, ) + for worker in worker_names + ] + overview.workers_online = sum(1 for item in overview.workers if item.online) + overview.worker_concurrency_total = sum(item.max_concurrency for item in overview.workers) + + runtime_tasks = [ + *_build_task_items(active, state="STARTED", now=now), + *_build_task_items(reserved, state="RECEIVED", now=now), + *_build_task_items(scheduled, state="SCHEDULED", now=now), + ] + runtime_tasks_by_id = {item.task_id: item for item in runtime_tasks if item.task_id} + + history_tasks = _load_history_task_items(settings.resolved_celery_result_backend, limit=history_limit) + for item in history_tasks: + if not item.task_id or item.task_id in runtime_tasks_by_id: + continue + runtime_tasks_by_id[item.task_id] = item + + all_tasks = sorted( + runtime_tasks_by_id.values(), + key=lambda item: ( + STATE_PRIORITY.get(item.state, 99), + -_task_sort_timestamp(item).timestamp(), + item.task_id, + ), + ) + overview.tasks = all_tasks[:task_limit] + overview.task_state_buckets = _build_state_buckets(runtime_tasks_by_id.values()) + + queue_names = _collect_queue_names(active_queues, runtime_tasks_by_id.values()) + queue_pending_counts = _load_queue_pending_counts(settings.resolved_celery_broker_url, queue_names) + overview.queues = _build_queue_items( + active_queues=active_queues, + tasks=runtime_tasks_by_id.values(), + pending_counts=queue_pending_counts, + ) + overview.queue_pending_total = sum(item.pending_count for item in overview.queues) return overview -def _fill_requirement_metrics( - db: Session, +def _safe_inspect_call(call): + try: + result = call() + except Exception: + return {} + if not isinstance(result, dict): + return {} + return result + + +def _build_worker_item( + worker: str, *, - overview: TaskMonitorOverviewResponse, - now: datetime, - risk_limit: int, - stale_hours: int, -) -> None: - status_rows = db.execute( - select(Requirement.status, func.count(Requirement.id)) - .group_by(Requirement.status) - .order_by(Requirement.status.asc()) - ).all() - - status_counts: dict[str, int] = {} - for raw_status, raw_count in status_rows: - status = _normalize_requirement_status(raw_status) - status_counts[status] = status_counts.get(status, 0) + int(raw_count or 0) - - overview.requirement_status_buckets = _build_buckets( - status_counts, - label_map=REQUIREMENT_STATUS_LABELS, + stats: dict, + active_tasks: list[dict], + reserved_tasks: list[dict], + scheduled_tasks: list[dict], + queues: list[dict], + online: bool, +) -> TaskMonitorWorkerItem: + pool = stats.get("pool") if isinstance(stats.get("pool"), dict) else {} + max_concurrency = _to_int( + pool.get("max-concurrency") + or pool.get("max_concurrency") + or len(pool.get("processes") or []) ) - overview.requirement_total = sum(status_counts.values()) - overview.requirement_completed = sum( - count for status, count in status_counts.items() if status in REQUIREMENT_DONE_STATUSES + total = stats.get("total") if isinstance(stats.get("total"), dict) else {} + + return TaskMonitorWorkerItem( + worker=worker, + online=online, + queue_names=sorted({_queue_name_from_queue(item) for item in queues if _queue_name_from_queue(item)}), + max_concurrency=max_concurrency, + prefetch_count=_to_int(stats.get("prefetch_count")), + uptime_seconds=_to_int(stats.get("uptime")), + processed_total=sum(_to_int(value) for value in total.values()), + active_count=len(active_tasks), + reserved_count=len(reserved_tasks), + scheduled_count=len(scheduled_tasks), ) - overview.requirement_active = max(0, overview.requirement_total - overview.requirement_completed) - - priority_rows = db.execute( - select(Requirement.priority, func.count(Requirement.id)) - .group_by(Requirement.priority) - .order_by(Requirement.priority.asc()) - ).all() - priority_counts: dict[str, int] = {} - for raw_priority, raw_count in priority_rows: - priority = _normalize_priority(raw_priority) - priority_counts[priority] = priority_counts.get(priority, 0) + int(raw_count or 0) - overview.requirement_priority_buckets = _build_buckets(priority_counts, label_map=PRIORITY_LABELS) - - high_priority_rows = db.execute( - select(Requirement) - .where( - Requirement.status.notin_(sorted(REQUIREMENT_DONE_STATUSES)), - Requirement.priority.in_(sorted(HIGH_PRIORITY_VALUES)), - ) - .order_by(Requirement.update_date.asc(), Requirement.id.asc()) - .limit(risk_limit) - ).scalars().all() - overview.high_priority_requirements = [ - TaskMonitorRequirementRiskItem( - id=item.id, - title=item.title, - status=_normalize_requirement_status(item.status), - priority=_normalize_priority(item.priority), - updated_at=item.update_date, - stale_hours=_hours_between(now, item.update_date), - ) - for item in high_priority_rows - ] - - stale_before = now - timedelta(hours=stale_hours) - stale_rows = db.execute( - select(Requirement) - .where( - Requirement.status.notin_(sorted(REQUIREMENT_DONE_STATUSES)), - Requirement.update_date <= stale_before, - ) - .order_by(Requirement.update_date.asc(), Requirement.id.asc()) - .limit(risk_limit) - ).scalars().all() - overview.stale_requirements = [ - TaskMonitorRequirementRiskItem( - id=item.id, - title=item.title, - status=_normalize_requirement_status(item.status), - priority=_normalize_priority(item.priority), - updated_at=item.update_date, - stale_hours=_hours_between(now, item.update_date), - ) - for item in stale_rows - ] -def _fill_todo_metrics( - db: Session, +def _build_task_items(tasks_by_worker: dict, *, state: str, now: datetime) -> list[TaskMonitorTaskItem]: + items: list[TaskMonitorTaskItem] = [] + for worker, raw_tasks in tasks_by_worker.items(): + for raw_item in raw_tasks or []: + task = raw_item.get("request") if state == "SCHEDULED" else raw_item + if not isinstance(task, dict): + continue + task_id = str(task.get("id") or "").strip() + if not task_id: + continue + + eta = _parse_datetime(raw_item.get("eta")) if state == "SCHEDULED" else _parse_datetime(task.get("eta")) + started_at = _timestamp_to_datetime(task.get("time_start")) + runtime_seconds = None + if started_at and state == "STARTED": + runtime_seconds = max(0.0, round((now - started_at).total_seconds(), 3)) + + items.append( + TaskMonitorTaskItem( + task_id=task_id, + name=str(task.get("name") or task.get("task") or "-"), + state=state, + queue_name=_queue_name_from_task(task), + worker=str(worker), + retries=_to_int(task.get("retries")), + eta=eta, + started_at=started_at, + runtime_seconds=runtime_seconds, + ) + ) + return items + + +def _build_queue_items( *, - overview: TaskMonitorOverviewResponse, - now: datetime, - actor: User, - can_manage_todos: bool, - risk_limit: int, -) -> None: - filters = [] - if not can_manage_todos: - filters.append(Todo.create_user == actor.username) + active_queues: dict, + tasks: Iterable[TaskMonitorTaskItem], + pending_counts: dict[str, int], +) -> list[TaskMonitorQueueItem]: + queue_names: set[str] = set() + consumer_counts: dict[str, int] = {} + active_counts: dict[str, int] = {} + reserved_counts: dict[str, int] = {} + scheduled_counts: dict[str, int] = {} - status_rows = db.execute( - select(Todo.status, func.count(Todo.id)) - .where(*filters) - .group_by(Todo.status) - .order_by(Todo.status.asc()) - ).all() - status_counts: dict[str, int] = {} - for raw_status, raw_count in status_rows: - status = _normalize_todo_status(raw_status) - status_counts[status] = status_counts.get(status, 0) + int(raw_count or 0) + for queues in active_queues.values(): + for queue in queues or []: + name = _queue_name_from_queue(queue) + if not name: + continue + queue_names.add(name) + consumer_counts[name] = consumer_counts.get(name, 0) + 1 - overview.todo_status_buckets = _build_buckets(status_counts, label_map=TODO_STATUS_LABELS) - overview.todo_total = sum(status_counts.values()) - overview.todo_completed = status_counts.get("COMPLETED", 0) - overview.todo_active = sum(count for status, count in status_counts.items() if status in TODO_ACTIVE_STATUSES) + for task in tasks: + if not task.queue_name: + continue + queue_names.add(task.queue_name) + if task.state == "STARTED": + active_counts[task.queue_name] = active_counts.get(task.queue_name, 0) + 1 + elif task.state == "RECEIVED": + reserved_counts[task.queue_name] = reserved_counts.get(task.queue_name, 0) + 1 + elif task.state == "SCHEDULED": + scheduled_counts[task.queue_name] = scheduled_counts.get(task.queue_name, 0) + 1 - priority_rows = db.execute( - select(Todo.priority, func.count(Todo.id)) - .where(*filters) - .group_by(Todo.priority) - .order_by(Todo.priority.asc()) - ).all() - priority_counts: dict[str, int] = {} - for raw_priority, raw_count in priority_rows: - priority = _normalize_priority(raw_priority) - priority_counts[priority] = priority_counts.get(priority, 0) + int(raw_count or 0) - overview.todo_priority_buckets = _build_buckets(priority_counts, label_map=PRIORITY_LABELS) - - overdue_filters = [ - *filters, - Todo.status.in_(sorted(TODO_ACTIVE_STATUSES)), - _build_todo_overdue_clause(now), - ] - overview.todo_overdue = int( - db.scalar( - select(func.count(Todo.id)).where(*overdue_filters), - ) - or 0 - ) - - overdue_rows = db.execute( - select(Todo) - .where(*overdue_filters) - .order_by(func.coalesce(Todo.expire_time, Todo.due_date, Todo.update_date).asc(), Todo.id.asc()) - .limit(risk_limit) - ).scalars().all() - overview.overdue_todos = [ - TaskMonitorTodoRiskItem( - id=item.id, - title=item.title, - status=_normalize_todo_status(item.status), - priority=_normalize_priority(item.priority), - due_date=item.due_date, - expire_time=item.expire_time, - overdue_hours=_hours_between(now, _resolve_todo_deadline(item)), - ) - for item in overdue_rows - ] - - -def _build_todo_overdue_clause(now: datetime): - return or_( - and_(Todo.due_date.is_not(None), Todo.due_date <= now), - and_(Todo.expire_time.is_not(None), Todo.expire_time <= now), + return sorted( + [ + TaskMonitorQueueItem( + name=name, + pending_count=max(0, _to_int(pending_counts.get(name))), + consumer_count=consumer_counts.get(name, 0), + active_count=active_counts.get(name, 0), + reserved_count=reserved_counts.get(name, 0), + scheduled_count=scheduled_counts.get(name, 0), + ) + for name in queue_names + ], + key=lambda item: (-item.pending_count, item.name), ) -def _build_buckets(counts: dict[str, int], *, label_map: dict[str, str]) -> list[TaskMonitorBucketItem]: +def _build_state_buckets(tasks: Iterable[TaskMonitorTaskItem]) -> list[TaskMonitorBucketItem]: + counts: dict[str, int] = {} + for task in tasks: + counts[task.state] = counts.get(task.state, 0) + 1 return [ - TaskMonitorBucketItem( - key=key, - label=label_map.get(key, key), - count=count, - ) - for key, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) + TaskMonitorBucketItem(key=state, label=STATE_LABELS.get(state, state), count=count) + for state, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) ] -def _resolve_todo_deadline(todo: Todo) -> datetime: - candidates = [value for value in [todo.due_date, todo.expire_time] if value is not None] - if not candidates: - return todo.update_date - return min(candidates) +def _collect_queue_names(active_queues: dict, tasks: Iterable[TaskMonitorTaskItem]) -> list[str]: + queue_names: set[str] = set() + + for queues in active_queues.values(): + for queue in queues or []: + name = _queue_name_from_queue(queue) + if name: + queue_names.add(name) + + for task in tasks: + if task.queue_name: + queue_names.add(task.queue_name) + + return sorted(queue_names) -def _hours_between(now: datetime, then: datetime) -> int: - now_utc = _to_utc(now) - then_utc = _to_utc(then) - diff_seconds = max(0, (now_utc - then_utc).total_seconds()) - return int(max(1, math.ceil(diff_seconds / 3600))) +def _load_queue_pending_counts(broker_url: str, queue_names: list[str]) -> dict[str, int]: + if not queue_names or not _is_redis_url(broker_url): + return {name: 0 for name in queue_names} + + client = _build_redis_client(broker_url) + if client is None: + return {name: 0 for name in queue_names} + + counts: dict[str, int] = {} + try: + for queue_name in queue_names: + counts[queue_name] = _to_int(client.llen(queue_name)) + except Exception: + return {name: 0 for name in queue_names} + return counts -def _to_utc(value: datetime) -> datetime: - if value.tzinfo is None: - return value.replace(tzinfo=UTC) - return value.astimezone(UTC) +def _load_history_task_items(result_backend_url: str, *, limit: int) -> list[TaskMonitorTaskItem]: + if limit <= 0 or not _is_redis_url(result_backend_url): + return [] + + client = _build_redis_client(result_backend_url) + if client is None: + return [] + + scan_max = max(200, limit * 20) + keys: list[str] = [] + cursor = 0 + + try: + while True: + cursor, batch = client.scan(cursor=cursor, match="celery-task-meta-*", count=200) + keys.extend(str(key) for key in batch) + if cursor == 0 or len(keys) >= scan_max: + break + except Exception: + return [] + + items: list[TaskMonitorTaskItem] = [] + for key in keys[:scan_max]: + try: + payload_raw = client.get(key) + except Exception: + continue + if not payload_raw: + continue + + try: + payload = json.loads(payload_raw) + except (TypeError, json.JSONDecodeError): + continue + if not isinstance(payload, dict): + continue + + state = str(payload.get("status") or "").strip().upper() + if not state: + continue + + task_id = str(payload.get("task_id") or key.removeprefix("celery-task-meta-")).strip() + if not task_id: + continue + + done_at = _parse_datetime(payload.get("date_done")) + error = _build_error(payload.get("result"), payload.get("traceback")) if state in {"FAILURE", "RETRY", "REVOKED"} else None + + items.append( + TaskMonitorTaskItem( + task_id=task_id, + name=str(payload.get("name") or "-"), + state=state, + done_at=done_at, + error=error, + ) + ) + + items.sort(key=lambda item: -_task_sort_timestamp(item).timestamp()) + return items[:limit] -def _normalize_requirement_status(raw_status: str | None) -> str: - normalized = (raw_status or "").strip().upper() - if not normalized: - return "PENDING_ANALYSIS" - if normalized == "CANCELLED": - return "CANCELLED" - return normalized +def _queue_name_from_queue(queue: dict) -> str: + if not isinstance(queue, dict): + return "" + return str(queue.get("name") or queue.get("routing_key") or "").strip() -def _normalize_todo_status(raw_status: str | None) -> str: - normalized = (raw_status or "").strip().upper() - return normalized or "SCHEDULED" +def _queue_name_from_task(task: dict) -> str | None: + delivery_info = task.get("delivery_info") if isinstance(task.get("delivery_info"), dict) else {} + queue_name = delivery_info.get("routing_key") or task.get("queue") + if not queue_name: + return None + return str(queue_name) -def _normalize_priority(raw_priority: str | None) -> str: - normalized = (raw_priority or "").strip().upper() - if normalized == "URGENT": - return "URGENT" - if normalized in {"LOW", "MEDIUM", "HIGH"}: - return normalized - return "MEDIUM" +def _is_redis_url(value: str) -> bool: + scheme = urlsplit(value).scheme + return scheme in {"redis", "rediss"} + + +def _build_redis_client(redis_url: str): + if redis is None: + return None + try: + return redis.Redis.from_url( + redis_url, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + except Exception: + return None + + +def _parse_datetime(value: object) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + normalized = value.strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _timestamp_to_datetime(value: object) -> datetime | None: + try: + timestamp = float(value) + except (TypeError, ValueError): + return None + if timestamp <= 0: + return None + try: + return datetime.fromtimestamp(timestamp, timezone.utc) + except (OSError, OverflowError, ValueError): + return None + + +def _task_sort_timestamp(item: TaskMonitorTaskItem) -> datetime: + for candidate in [item.started_at, item.done_at, item.eta]: + if candidate is None: + continue + if candidate.tzinfo is None: + return candidate.replace(tzinfo=timezone.utc) + return candidate.astimezone(timezone.utc) + return datetime.fromtimestamp(0, timezone.utc) + + +def _build_error(result, traceback_value) -> str | None: + result_text = None + if result is not None: + result_text = str(result).strip() + + traceback_text = None + if isinstance(traceback_value, str): + traceback_text = traceback_value.strip() + + for candidate in [result_text, traceback_text]: + if not candidate: + continue + if len(candidate) <= 400: + return candidate + return f"{candidate[:397]}..." + return None + + +def _to_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _mask_url(value: str) -> str: + parsed = urlsplit(value) + if not parsed.password: + return value + username = parsed.username or "" + hostname = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + netloc = f"{username}:***@{hostname}{port}" + return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) diff --git a/api/app/services/todo_service.py b/api/app/services/todo_service.py index 85f0769..9a63363 100644 --- a/api/app/services/todo_service.py +++ b/api/app/services/todo_service.py @@ -6,18 +6,15 @@ from sqlalchemy.orm import Session from fastapi import HTTPException, status from ..models.base import utcnow -from ..models.mind_map import MindMap from ..models.todo import Todo from ..models.user import User from ..schemas.todo import ( TodoCreateRequest, TodoListResponse, - TodoMindMapInitResponse, TodoSummary, TodoTransitionRequest, TodoUpdateRequest, ) -from .mind_map_service import build_initial_mind_map_data from .push_service import publish_topic TOPIC_NAME = "todos" @@ -219,44 +216,6 @@ def complete_todo( return serialize_todo(saved) -def init_todo_mindmap( - db: Session, - todo_id: str, - *, - actor: User, -) -> TodoMindMapInitResponse: - todo = get_todo_by_id(db, todo_id, actor=actor) - if not todo: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found") - - mind_map = db.execute(select(MindMap).where(MindMap.id == todo_id)).scalar_one_or_none() - if mind_map: - if mind_map.create_user != actor.username: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to access mind map") - else: - now = utcnow() - mind_map = MindMap( - id=todo_id, - map_name=todo.title, - descr=todo.descr or "", - map_data=build_initial_mind_map_data(todo.title), - create_user=actor.username, - update_user=actor.username, - create_date=now, - update_date=now, - ) - db.add(mind_map) - db.commit() - db.refresh(mind_map) - - return TodoMindMapInitResponse( - id=mind_map.id, - map_name=mind_map.map_name, - descr=mind_map.descr, - map_data=mind_map.map_data or "", - ) - - def expire_overdue_todos(db: Session) -> int: now = utcnow() todos = db.execute( @@ -298,7 +257,7 @@ def delete_todo(db: Session, todo_id: str, *, actor: User, syncing: bool = False TOPIC_NAME, name="todos.deleted", payload={"action": "deleted", "todo_id": deleted_id, "actor_user": actor.username}, - requires_refetch=["/api/v1/todos"], + requires_refetch=[], dedupe_key=f"todos:deleted:{deleted_id}", ) ) @@ -335,7 +294,7 @@ def _publish_todo_change(event_name: str, todo: Todo, *, action: str) -> None: TOPIC_NAME, name=event_name, payload=payload, - requires_refetch=["/api/v1/todos"], + requires_refetch=[], dedupe_key=f"todos:{action}:{todo.id}", ) ) diff --git a/api/app/services/topic_registry.py b/api/app/services/topic_registry.py index b0646b1..a8d1451 100644 --- a/api/app/services/topic_registry.py +++ b/api/app/services/topic_registry.py @@ -23,11 +23,10 @@ TOPIC_RULES: dict[str, TopicRule] = { "admin.menus": TopicRule(any_permission_codes={"menu.read", "menu.manage"}), "admin.system-params": TopicRule(any_permission_codes={"system_param.read", "system_param.manage"}), "admin.files": TopicRule(any_permission_codes={"file.read", "file.manage"}), + "admin.atp-models": TopicRule(any_permission_codes={"atp.read", "atp.run", "atp.manage"}), "admin.audit_logs": TopicRule(any_permission_codes={"menu.read", "menu.manage"}), "admin.question_bank": TopicRule(any_permission_codes={"question_bank.read", "question_bank.manage"}), "admin.vocabulary": TopicRule(any_permission_codes={"vocabulary.read", "vocabulary.manage"}), - "requirements": TopicRule(any_permission_codes={"requirement.read", "requirement.process", "requirement.manage"}), - "todos": TopicRule(any_permission_codes={"todo.read", "todo.process", "todo.manage"}), } diff --git a/memory/2026-04-26.md b/memory/2026-04-26.md index caaeb6b..d73843f 100644 --- a/memory/2026-04-26.md +++ b/memory/2026-04-26.md @@ -16,3 +16,437 @@ - 风险与影响: - 仅影响后台左侧导航展示文案,不涉及菜单权限、路由与接口逻辑。 + +## Work Log - 打包镜像并发布更新(2026-04-26) + +- 背景: + - 用户要求执行“打包镜像并发布更新”。 + +- 本次执行: + - `docker compose build` + - `docker compose up -d` + +- 验证: + - `docker compose ps`:`api/web/celery/db/redis/minio` 均为 Up,`api/db/redis` healthy。 + - `docker compose logs --tail=80 api`:Uvicorn 启动完成,`GET /health` 返回 200。 + - `docker compose logs --tail=80 web`:Next.js Ready。 + - `curl -fsS http://127.0.0.1:8000/health`:返回 `{"status":"ok","service":"fquiz-api","version":"0.1.0"}`。 + - `curl -I -fsS http://127.0.0.1:3000/`:返回 `HTTP/1.1 200 OK`。 + +- 风险与影响: + - 本次为镜像重建与容器滚动重启,存在短时服务切换窗口;未执行数据库结构变更。 + +## Work Log - 下线收件箱、提示词管理、代码评审与 Git管理(2026-04-26) + +- 背景: + - 用户要求删除“收件箱、提示词管理、代码评审、Git管理”功能。 + +- 本次改动: + - 前端: + - 删除后台页面入口: + - `web/src/app/admin/inbox/page.tsx` + - `web/src/app/admin/prompt/page.tsx` + - `web/src/app/admin/system-message/page.tsx` + - `web/src/app/admin/code-review/page.tsx` + - `web/src/app/admin/git-desktop/page.tsx` + - `web/src/app/admin/page.tsx` 删除对应后台首页卡片。 + - `web/src/app/admin/menus/page.tsx` 删除对应受保护菜单编码。 + - `web/src/types/auth.ts` 删除系统消息/提示词管理相关类型。 + - 后端: + - 删除提示词管理 API、schema、service、model: + - `api/app/api/v1/system_messages.py` + - `api/app/schemas/system_message.py` + - `api/app/services/system_message_service.py` + - `api/app/models/system_message.py` + - `api/app/api/router.py` 取消 `/api/v1/admin/system-messages*` 路由挂载。 + - `api/app/core/database.py`、`api/app/models/__init__.py` 取消 `system_message` 模型导入。 + - `api/app/services/seed_service.py` 删除默认权限、默认菜单和 admin 菜单绑定。 + - `api/app/services/admin_service.py`、`api/app/services/legacy_admin_rbac_service.py`、`api/app/services/legacy_authz_service.py` 将四个历史菜单编码纳入已下线过滤,避免存量菜单继续展示。 + - `api/app/services/topic_registry.py` 删除 `admin.system-messages` 订阅规则。 + +- 验证: + - `python3 -m py_compile api/app/api/router.py api/app/core/database.py api/app/models/__init__.py api/app/services/seed_service.py api/app/services/admin_service.py api/app/services/legacy_admin_rbac_service.py api/app/services/legacy_authz_service.py api/app/services/topic_registry.py` -> 通过。 + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过(清理 `.next` 后复跑)。 + - `npx eslint src/app/admin/page.tsx src/app/admin/menus/page.tsx src/types/auth.ts` -> 通过。 + - `npm run build:web` -> 通过,构建路由列表已不再包含 `/admin/inbox`、`/admin/prompt`、`/admin/system-message`、`/admin/code-review`、`/admin/git-desktop`。 + - `git diff --check`(命中文件)-> 通过。 + - `npx @ant-design/cli lint src/app/admin/page.tsx --format json`:默认 npx cache 报 `ENOTEMPTY`;改用 `/tmp` 独立 cache 后命令长时间无返回,已终止卡住进程,未得到有效 AntD CLI lint 结果。 + +- 风险与影响: + - 本次仅下线入口、路由、默认权限/菜单和提示词管理执行链路,不执行数据库 drop;存量 `system_messages` 表如果已存在,会作为历史数据保留。 + - `admin.system_message`、`admin.inbox`、`admin.code_review`、`admin.git_desktop` 被保留在 removed/disabled 集合中,仅用于过滤历史菜单。 + +## Work Log - 文件管理页去掉左侧挂载点(2026-04-26) + +- 背景: + - 用户要求:文件管理仅会有本地/SFTP/S3 三种后端,不会出现多挂载点场景,去掉左侧挂载点。 + +- 本次改动(最小闭环): + - `web/src/app/admin/files/page.tsx` + - 删除左侧“挂载点”卡片与挂载点列表按钮组。 + - 删除前端挂载点切换状态与逻辑(`mountCode`、`handleSelectMount`)。 + - 文件列表查询改为仅按 `path` 拉取,由后端返回 `current_mount` 作为当前挂载上下文。 + - 保留文件操作接口参数 `mount_code`,统一使用当前响应中的 `current_mount.code`。 + +- 验证: + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + +- 风险与影响: + - 前端不再支持多挂载点手动切换;若后续恢复多挂载点产品形态,需要补回挂载点切换入口。 + - 后端接口与数据模型未改动,仍兼容多挂载点能力。 + +## Work Log - 任务监控功能彻底收口(2026-04-26) + +- 背景: + - 用户要求“补充任务监控功能”并“彻底收口”。 + +- 本次改动: + - 后端接口对齐 Celery 监控语义: + - `api/app/api/v1/task_monitor.py` + - 查询参数统一为 `task_limit` / `history_limit`。 + - 权限校验统一为 `celery.read` / `celery.manage`。 + - 移除旧的需求/待办聚合参数与依赖(`risk_limit` / `stale_hours`)。 + - 前端页面按 Celery schema 重写: + - `web/src/app/admin/task-monitor/page.tsx` + - 请求参数改为 `task_limit` / `history_limit`。 + - 页面展示改为 Worker / Queue / Task 三块监控表格。 + - 统计卡片改为在线 Worker、总并发、队列待处理、采样任务数。 + - 状态分布改为任务状态桶(`STARTED/RECEIVED/SCHEDULED/...`)。 + - 页面权限改为 `celery.read` / `celery.manage`。 + - 后台首页入口口径同步: + - `web/src/app/admin/page.tsx` + - “任务监控”卡片文案改为 Celery 监控描述。 + - 可见权限改为 `celery.read` / `celery.manage`。 + +- 验证: + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + - `python3 -m compileall api/app/api/v1/task_monitor.py api/app/services/task_monitor_service.py api/app/schemas/task_monitor.py` -> 通过。 + +- 风险与影响: + - 任务监控口径从“需求/待办风险聚合”切换为“Celery 运行态监控”;若历史调用仍传 `risk_limit/stale_hours`,将不再生效。 + - 页面和接口权限都已切至 `celery.*`,需要对应角色具备 Celery 权限才可访问。 + +## Work Log - 任务监控补齐 Celery 队列积压与历史状态(2026-04-26) + +- 背景: + - 用户确认“任务监控要监控 Celery 队列和任务状态”,需要从方案落到可运行实现。 + +- 本次补齐: + - 后端 `api/app/services/task_monitor_service.py`: + - `inspect.active/reserved/scheduled/active_queues/stats/ping` 聚合 Worker 与实时任务。 + - Broker 为 Redis 时按队列执行 `LLEN`,输出真实 `pending_count`(不再用 reserved/scheduled 近似值)。 + - Result backend 为 Redis 时扫描 `celery-task-meta-*`,补齐 `SUCCESS/FAILURE/RETRY/REVOKED` 历史状态。 + - 任务明细按状态优先级 + 时间排序,支持 `task_limit/history_limit`。 + - 接口与权限: + - `api/app/api/v1/task_monitor.py` 改为 `task_limit/history_limit` 参数。 + - 权限校验统一为 `celery.read` / `celery.manage`。 + - 权限映射与菜单: + - `api/app/services/seed_service.py` 增加 `celery.read/celery.manage`,并将 `admin.task_monitor` 权限改为 `celery.read`。 + - `api/app/services/legacy_authz_service.py` 同步 `admin.task_monitor -> celery.*` 映射。 + - 前端 `web/src/app/admin/task-monitor/page.tsx`: + - 展示 Worker/Queue/Task 三块监控视图; + - 自动刷新、任务上限、历史扫描上限可配置; + - 状态 Tag 与失败摘要展示。 + +- 验证: + - `python3 -m py_compile api/app/schemas/task_monitor.py api/app/services/task_monitor_service.py api/app/api/v1/task_monitor.py api/app/services/seed_service.py api/app/services/legacy_authz_service.py` -> 通过。 + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + +- 风险与影响: + - Redis 扫描历史任务采用 `SCAN` + 限流(`history_limit * 20`)策略;历史量极大时仅展示采样窗口内数据。 + - 当前未新增 DB 持久化索引,历史任务名称在 result backend 未携带时会显示为 `-`。 + +## Work Log - 移除后台页面顶部标题与描述信息(2026-04-26) + +- 背景: + - 用户要求去掉所有后台页面顶部的标题和描述信息(例如“线路管理”“按角色权限访问当前模块,完成查询、维护、协作与操作留痕”)。 + +- 本次改动(最小闭环): + - `web/src/app/admin/layout.tsx` + - 删除后台壳层内容区顶部公共信息块(Breadcrumb + 页面标题 + 页面描述)。 + - 同步清理对应的 breadcrumb/title 计算逻辑与无用导入。 + +- 验证: + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + +- 风险与影响: + - 仅影响后台页面顶部展示,不涉及页面业务逻辑、接口、权限与路由行为。 + +## Work Log - 下线 AI/编排/MCP/模型/API测试/知识集/图谱/需求/日程功能(2026-04-26) + +- 背景: + - 用户要求删除“AI 聊天、编排管理、MCP管理、模型管理、API测试、知识集管理、流程图、思维导图、需求管理、日程管理”功能。 + +- 本次改动: + - 前端入口与页面删除: + - 删除 `/admin/chat`、`/admin/agent`、`/admin/orchestration`、`/admin/mcp-server`、`/admin/models`、`/admin/api-tester`、`/admin/files`、`/admin/knowledge-set`、`/admin/mermaid-mgr`、`/admin/mindmap`、`/admin/requirements`、`/admin/schedule` 对应页面文件。 + - `web/src/app/admin/page.tsx` 删除对应后台首页卡片,仅保留用户/角色/菜单/系统参数/任务监控/线路/雷电/系统日志/Wine 执行器。 + - `web/src/app/admin/menus/page.tsx` 删除已下线菜单的前端受保护编码。 + - `web/src/types/auth.ts` 删除 File/Model/TokenUsage/Chat/MindMap/Mermaid/Requirement/Todo 等已下线前端 DTO 类型。 + - 后端公开 API 下线: + - `api/app/api/router.py` 取消挂载 chat/admin-files/calendar/mermaid/mindmap/requirements/todos/project_requirement 等路由。 + - 删除对应 route/schema/service/model 文件:chat、file_storage、mermaid、mind_map;删除 public requirements/todos/calendar/project_requirement API。 + - `api/app/api/v1/admin.py` 删除模型管理与模型路由管理端点。 + - 默认权限/菜单收口: + - `api/app/services/seed_service.py` 删除 `chat.use`、`file.*`、`model.*`、`requirement.*`、`todo.*` 默认权限,删除对应默认菜单与 admin 绑定。 + - `api/app/services/admin_service.py`、`legacy_admin_rbac_service.py`、`legacy_authz_service.py` 将已下线菜单编码放入 removed/disabled 过滤集合,屏蔽存量菜单。 + - `api/app/services/topic_registry.py` 删除已下线模块订阅规则。 + - 任务监控适配: + - `/admin/task-monitor` 保留,但口径固定为 Celery Worker/Queue/Task 监控,不再读取需求/待办数据。 + - `api/app/services/model_service.py` 保留内部模型注册/路由基础设施,但移除已下线 `/api/v1/admin/models*` 刷新提示,并将 topic 改为内部 `model.registry`。 + +- 验证: + - `python3 -m compileall api/app` -> 通过。 + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过(删除 `.next` 后复跑,避免旧路由类型缓存)。 + - `npx eslint src/app/admin/page.tsx src/app/admin/menus/page.tsx src/app/admin/task-monitor/page.tsx src/types/auth.ts` -> 通过。 + - `npm run build:web` -> 通过;构建路由列表仅保留 `/admin`、`/admin/users`、`/admin/roles`、`/admin/menus`、`/admin/system-params`、`/admin/task-monitor`、`/admin/power-lines`、`/admin/lightning-currents`、`/admin/lightning-distribution`、`/admin/syslog`、`/admin/wine-runner`。 + - `git diff --check` -> 通过。 + +- 风险与影响: + - 本次不执行数据库 drop;历史表和历史数据保留。 + - 本机 Python 与 `.venv` 均缺少 FastAPI,`PYTHONPATH=api python -c "from app.api.router import api_router"` 无法作为导入验证;已用 `compileall` 覆盖语法级后端检查。 + - 模型注册表/路由表仍保留给内部 LLM 网关与寿命倒计时等内部能力使用,但后台模型管理/API 测试页面和 admin API 已移除。 + +## Work Log - 打包镜像并发布功能下线更新(2026-04-26) + +- 背景: + - 用户要求“打包镜像并发布更新”。 + +- 本次执行: + - `docker compose build` + - `fquiz-api`、`fquiz-web`、`fquiz-celery-worker`、`fquiz-celery-beat` 构建完成。 + - Web 镜像构建阶段 `next build` 通过,路由列表仅保留当前有效后台模块。 + - `docker compose up -d` + - 重建并启动 `api/web/celery-worker/celery-beat`。 + - `db/redis/minio` 继续复用运行中容器。 + +- 验证: + - `docker compose ps`: + - `fquiz-api` Up 且 healthy。 + - `fquiz-web` Up。 + - `fquiz-celery-worker` Up。 + - `fquiz-celery-beat` Up。 + - `fquiz-db`、`fquiz-redis` healthy。 + - `curl -fsS http://127.0.0.1:8000/health` -> `{"status":"ok","service":"fquiz-api","version":"0.1.0"}`。 + - `curl -I -fsS http://127.0.0.1:3000/` -> `HTTP/1.1 200 OK`。 + - `docker compose logs --tail=120 api`:Uvicorn 启动完成,`/health` 返回 200。 + - `docker compose logs --tail=100 web`:Next.js Ready。 + - `docker compose logs --tail=80 celery-worker`:worker 连接 Redis 并 ready。 + - `docker compose logs --tail=80 celery-beat`:beat 启动完成。 + - OpenAPI 运行态检查:`/api/v1/chat`、`/api/v1/admin/files`、`/api/v1/calendar`、`/api/v1/mermaids`、`/api/v1/mindmap`、`/api/v1/requirements`、`/api/v1/todos`、`/api/project/requirement`、`/api/v1/admin/models`、`/api/v1/admin/model-routes` 均无匹配。 + +- 风险与影响: + - 发布过程重启了 API、Web、Celery Worker、Celery Beat,存在短时服务切换窗口。 + - Celery Worker 日志仍有 root 用户运行的既有安全警告,服务状态正常但后续生产化可考虑设置非 root 用户。 + +## Work Log - 还原文件管理模块(2026-04-26) + +- 背景: + - 用户要求恢复系统文件管理模块,并延续此前“单挂载点交互”约束。 + +- 本次改动: + - 恢复文件管理核心代码: + - `api/app/api/v1/admin_files.py` + - `api/app/models/file_storage.py` + - `api/app/schemas/file_storage.py` + - `api/app/services/file_service.py` + - `api/app/services/storage_driver.py` + - `web/src/app/admin/files/page.tsx` + - 恢复后端接线: + - `api/app/api/router.py` 重新挂载 `admin_files_router`。 + - `api/app/models/__init__.py`、`api/app/core/database.py` 重新注册 `file_storage` 模型。 + - 恢复权限/菜单/订阅口径: + - `api/app/services/seed_service.py` 恢复 `file.read`/`file.manage`、`admin.files` 菜单、默认存储后端与挂载 seed。 + - `api/app/services/legacy_authz_service.py` 恢复 `admin.files` 权限映射与 synthetic legacy menu。 + - `api/app/services/topic_registry.py` 恢复 `admin.files` topic 订阅规则。 + - `api/app/services/admin_service.py` / `legacy_admin_rbac_service.py` 恢复 `admin.files` 为受保护菜单,并从 removed/disabled 过滤中放开。 + - `web/src/app/admin/menus/page.tsx` 恢复 `admin.files` 为前端受保护菜单。 + - `web/src/app/admin/page.tsx` 恢复“文件管理”入口卡片。 + - `web/src/types/auth.ts` 恢复文件管理相关类型定义。 + - 文件管理页面按单挂载点收口: + - 去掉左侧挂载点列表与挂载切换状态,仅保留当前挂载上下文下的目录浏览与文件操作。 + +- 验证: + - `python3 -m compileall api/app/api/router.py api/app/api/v1/admin_files.py api/app/core/database.py api/app/models/__init__.py api/app/models/file_storage.py api/app/schemas/file_storage.py api/app/services/file_service.py api/app/services/storage_driver.py api/app/services/seed_service.py api/app/services/topic_registry.py api/app/services/legacy_authz_service.py api/app/services/legacy_admin_rbac_service.py api/app/services/admin_service.py` -> 通过。 + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + - `npm --workspace web exec eslint src/app/admin/files/page.tsx src/app/admin/page.tsx src/app/admin/menus/page.tsx src/types/auth.ts --max-warnings=0` -> 通过。 + +- 风险与影响: + - 现有仓库仍包含大量其他未提交改动;本次仅在文件管理恢复相关文件完成闭环,未触及其他下线模块恢复。 + - 若现网数据库已经移除历史 `menu`/`role_menu_rela` 数据,需要重启后触发 seed 才能自动补齐 `admin.files` 菜单和默认挂载。 + +## Work Log - ATP 仿真模型管理一期落地(2026-04-26) + +- 背景: + - 用户确认按“ATPDraw 产物版本管理 + ATP 引擎调用”方案实现。 + +- 本次改动: + - 后端新增 ATP 模块(模型/版本/运行): + - 新增数据模型: + - `api/app/models/atp_model.py` + - `atp_model`(模型台账) + - `atp_model_version`(版本与产物元数据) + - `atp_simulation_run`(仿真运行记录) + - 新增 schema: + - `api/app/schemas/atp_model.py` + - 新增服务: + - `api/app/services/atp_model_service.py` + - 模型 CRUD、版本管理、激活版本、运行记录查询 + - ATP 引擎状态检查与同步执行落库(支持 `wine/native`) + - 新增 API: + - `api/app/api/v1/atp_models.py` + - 路由前缀:`/api/v1/atp/models` + - 关键端点: + - `GET /engine/status` + - `GET/POST/PATCH/DELETE /` + - `GET/POST/PATCH /{model_id}/versions*` + - `POST /{model_id}/versions/{version_id}/activate` + - `GET/POST /{model_id}/runs*` + - 后端接线与权限菜单: + - `api/app/api/router.py` 挂载 ATP 路由。 + - `api/app/core/config.py` 新增 ATP 引擎配置: + - `atp_engine_mode` + - `atp_engine_executable` + - `atp_storage_root` + - `atp_engine_workdir` + - `atp_engine_default_timeout_seconds` + - `atp_engine_max_timeout_seconds` + - `api/app/models/__init__.py`、`api/app/core/database.py` 注册 ATP 模型。 + - `api/app/services/seed_service.py` 新增权限: + - `atp.read` / `atp.manage` / `atp.run` + - 新增默认菜单 `admin.atp_models`(`/admin/power-lines/atp-viewer`) + - `api/app/services/legacy_authz_service.py`、`legacy_admin_rbac_service.py`、`admin_service.py`、`topic_registry.py` 同步 ATP 菜单权限映射、受保护菜单与订阅主题(`admin.atp-models`)。 + - 前端 ATP 页面升级: + - `web/src/app/admin/power-lines/atp-viewer/page.tsx` + - 从“纯查看器”升级为“模型台账 + 版本管理 + 仿真运行 + 文本转换预览”四块。 + - 支持模型创建/编辑/删除、版本创建/激活、版本加载回编辑区、运行任务执行与日志查看。 + - 保留 ATP 文本解析与 maxGraph 渲染能力。 + - `web/src/types/auth.ts` 增加 ATP 模块类型定义。 + - `web/src/app/admin/page.tsx` ATP 卡片升级为“ATP模型管理”,权限切到 `atp.*`。 + - `web/src/app/admin/power-lines/page.tsx` 线路页入口文案改为“ATP模型管理”,并按 `atp.*` 权限显示。 + - `web/src/app/admin/menus/page.tsx` 新增 `admin.atp_models` 受保护菜单码。 + +- 验证: + - 后端: + - `python3 -m compileall api/app` -> 通过。 + - 前端: + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + - `npm --workspace web exec eslint src/app/admin/power-lines/atp-viewer/page.tsx src/app/admin/page.tsx src/app/admin/menus/page.tsx src/types/auth.ts --max-warnings=0` -> 通过。 + - `npm run build:web` -> 通过;路由包含 `/admin/power-lines/atp-viewer`。 + +- 风险与影响: + - ATP 运行为同步执行(请求内完成),长耗时任务会占用 API worker;后续可升级为 Celery 异步执行。 + - `atp_engine_executable` 默认值仅为占位,实际运行需按部署环境配置可执行 ATP 引擎路径。 + +## Work Log - 线路管理地图专题化为线路走向图(2026-04-26) + +- 背景: + - 用户要求“线路管理页面的图用于展示线路在地图上的分布走向,不要通用地图功能”。 + +- 本次改动(A 方案,最小闭环): + - `web/src/components/power-line-cesium-map.tsx` + - Cesium Viewer 改为专题化初始化:显式 `baseLayer: false`,关闭 `skyBox/skyAtmosphere`,弱化通用地图能力。 + - 保留线路业务核心可视化:按杆塔 `seq_no` 连线、杆塔点位、起点/终点标识。 + - 新增线路走向专题控件:`按风险着色`、`显示塔号`、`居中重置`。 + - 新增走向统计信息:有效坐标数、缺失坐标数、断点段数、线路估算长度(Haversine)。 + - 对缺失坐标导致的序号断档按分段折线绘制,避免误连成单条连续线路。 + - `web/src/app/admin/power-lines/page.tsx` + - 视图切换文案由“地图”改为“走向图”。 + - 当前视图状态文案同步改为“走向图”。 + +- 验证: + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + +- 风险与影响: + - 本次仅修改前端展示逻辑,不涉及后端接口、权限、路由与数据库结构。 + - 因专题化禁用了默认底图,视觉上会更偏“线路走向专题视图”;若后续需要叠加真实底图,应单独评估并显式配置地图源。 + +## Work Log - 打包镜像并发布线路走向图更新(2026-04-26) + +- 背景: + - 用户要求执行“打包镜像并发布更新”。 + +- 本次执行: + - `docker compose build` + - `docker compose up -d` + +- 验证: + - `docker compose ps`:`api/web/celery-worker/celery-beat/db/redis/minio` 均为 `Up`,`api/db/redis` healthy。 + - `curl -fsS http://127.0.0.1:8000/health`:返回 `{"status":"ok","service":"fquiz-api","version":"0.1.0"}`。 + - `curl -I -fsS http://127.0.0.1:3000/`:返回 `HTTP/1.1 200 OK`。 + - `docker compose logs --tail=120 api`:Uvicorn 启动完成,`/health` 返回 200。 + - `docker compose logs --tail=100 web`:Next.js Ready。 + - `docker compose logs --tail=80 celery-worker`:worker 连接 Redis 并 ready(保留 root 运行警告)。 + - `docker compose logs --tail=80 celery-beat`:beat 启动完成。 + +- 风险与影响: + - 本次为镜像重建与容器重启,存在短时服务切换窗口。 + - `celery-worker` 仍以 root 运行,功能可用但生产化建议后续切换为非 root 用户。 + +## Work Log - ATP 文本转 JSON 并用 maxGraph 查看(2026-04-26) + +- 背景: + - 用户要求开发“ATP 转 JSON,然后给 maxGraph 渲染”的查看能力,重点是展示,不涉及仿真。 + +- 本次改动(前端最小闭环): + - 新增依赖: + - `web/package.json` 增加 `@maxgraph/core`。 + - 新增 ATP 解析能力: + - `web/src/lib/atp/types.ts`:定义 ATP 图 JSON 结构(nodes/edges/stats/warnings)。 + - `web/src/lib/atp/parse-atp-text.ts`:实现 ATP 文本解析器(支持常见 `R1 BUS_A BUS_B 10` 与 `BUS_A BUS_B R 10` 两类行格式)。 + - `web/src/lib/atp/sample.ts`:提供可直接试跑的 ATP 示例文本。 + - 新增 maxGraph 只读渲染组件: + - `web/src/components/atp-maxgraph-viewer.tsx`:渲染节点/正交连线,支持适配视图与缩放,不提供编辑能力。 + - 新增业务页面: + - `web/src/app/admin/power-lines/atp-viewer/page.tsx`:提供上传 ATP 文本、转换 JSON、JSON 预览、maxGraph 图形查看、导出 JSON。 + - 增加入口: + - `web/src/app/admin/power-lines/page.tsx`:在“线路管理”卡片增加 `ATP查看器` 入口按钮。 + - `web/src/app/admin/page.tsx`:后台首页新增 `ATP查看器` 卡片入口(沿用线路模块权限)。 + +- 验证: + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + - `cd web && npx eslint src/components/atp-maxgraph-viewer.tsx src/lib/atp/parse-atp-text.ts src/lib/atp/types.ts src/lib/atp/sample.ts src/app/admin/power-lines/atp-viewer/page.tsx` -> 通过。 + - 构建产物确认:`web/.next/server/app/admin/power-lines/atp-viewer/page.js` 与相关 `rsc/html` 已生成。 + +- 风险与影响: + - 解析器当前是“查看优先”的通用文本解析,不覆盖 ATP/ATPDraw 全量语法;复杂模型卡、控制卡可能被忽略并进入 warnings。 + - 本次不涉及后端 API/数据库改动,不影响现有线路与雷电业务接口。 + +## Work Log - 雷电模块补齐地面倾角计算链路(2026-04-26) + +- 背景: + - 用户要求按“高程+坐标+3x3 邻域(Horn)”方案改造雷电分布统计中的地形倾角能力。 + +- 本次改动: + - 后端 schema 与响应扩展: + - `api/app/schemas/lightning.py` + - 新增 `LightningTowerTerrainMetrics` + - 新增 `LightningTowerTerrainComputeRequest/Response` + - `LightningTowerBufferStatsResponse` 增加 `terrain_metrics` + - 新增 `dem_grid_m` 的 3x3 校验 + - 后端服务能力补齐: + - `api/app/services/lightning_service.py` + - 补齐 Horn 梯度、坡向、邻域坡度统计、分位数、线路方向纵横坡、质量评分、角差/方向导数等辅助函数。 + - 新增 `compute_tower_terrain_metrics(...)`,支持: + - 基于 3x3 DEM 计算倾角/坡向/纵横坡/起伏量/暴露指数 + - 可选按权限持久化到 `power_line_tower.raw_extra_json.terrain_metrics` + - `get_tower_buffer_stats(...)` 增加地形指标读取与风险修正(地形暴露指数参与 Ng 风险权重) + - 后端 API 新增: + - `api/app/api/v1/lightning.py` + - 新增 `POST /api/v1/lightning-currents/stats/tower-terrain` + - 权限:读权限可计算,`persist=true` 需 `tower.manage` 或 `lightning.manage`(或 admin) + - 前端类型与展示: + - `web/src/types/auth.ts` + - 新增 `LightningTowerTerrainMetrics` + - 新增 `LightningTowerTerrainComputeRequest/Response` + - 扩展 `LightningTowerBufferStatsResponse.terrain_metrics` + - `web/src/app/admin/lightning-currents/page.tsx` + - 杆塔缓冲区结果区新增地形字段展示:倾角、坡向、暴露指数、纵/横坡、质量等级/评分、DEM 分辨率与来源 + +- 验证: + - `python3 -m py_compile api/app/services/lightning_service.py api/app/api/v1/lightning.py api/app/schemas/lightning.py` -> 通过。 + - `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。 + +- 风险与影响: + - 当前计算输入 DEM 由调用方提供(3x3 高程矩阵),尚未接入自动 DEM 切片检索;生产精度仍依赖上游 DEM 分辨率与采样质量。 + - 杆塔缓冲区风险等级已引入地形暴露权重,可能导致部分杆塔风险分级相对旧口径发生变化。 diff --git a/package-lock.json b/package-lock.json index fef3ed8..e14f639 100644 --- a/package-lock.json +++ b/package-lock.json @@ -180,6 +180,12 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT" }, + "node_modules/@maxgraph/core": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@maxgraph/core/-/core-0.23.0.tgz", + "integrity": "sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==", + "license": "Apache-2.0" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1523,6 +1529,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@maxgraph/core": "^0.23.0", "@tanstack/react-query": "^5.90.5", "antd": "^5.29.3", "cesium": "^1.140.0", diff --git a/web/package-lock.json b/web/package-lock.json index d8f90e9..10fa8a1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@maxgraph/core": "^0.23.0", "@tanstack/react-query": "^5.90.5", "antd": "^5.29.3", "cesium": "^1.140.0", @@ -669,6 +670,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@maxgraph/core": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@maxgraph/core/-/core-0.23.0.tgz", + "integrity": "sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==", + "license": "Apache-2.0" + }, "node_modules/@next/env": { "version": "16.2.3", "license": "MIT" diff --git a/web/package.json b/web/package.json index 6fb7f5f..b1c4369 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "lint": "eslint" }, "dependencies": { + "@maxgraph/core": "^0.23.0", "@tanstack/react-query": "^5.90.5", "antd": "^5.29.3", "cesium": "^1.140.0", diff --git a/web/src/app/admin/agent/page.tsx b/web/src/app/admin/agent/page.tsx deleted file mode 100644 index 291fde0..0000000 --- a/web/src/app/admin/agent/page.tsx +++ /dev/null @@ -1,1441 +0,0 @@ -"use client"; - -import { - ApiOutlined, - CheckCircleOutlined, - DeleteOutlined, - EditOutlined, - FileTextOutlined, - NodeIndexOutlined, - PlusOutlined, - ReloadOutlined, - RobotOutlined, - SettingOutlined, - StopOutlined, -} from "@ant-design/icons"; -import { Alert as AntAlert, Empty, Popconfirm, Spin, Tag, Tabs, type TabsProps } from "antd"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react"; - -import { useAuth } from "@/components/auth-provider"; -import { Button, Card, Checkbox, Dialog, Select, Table, TextArea, TextField } from "@/components/ui-antd"; -import { readApiError } from "@/lib/api"; -import type { - ModelHealthStatus, - ModelListResponse, - ModelRegistryItem, - ModelRouteRuleItem, - ModelRouteRuleListResponse, - ModelRouteType, - ModelStatus, - ModelTestRunItem, - ModelTestRunListResponse, - ModelTestStatus, -} from "@/types/auth"; - -const MODEL_STATUS_LABELS: Record = { - DRAFT: "草稿", - ENABLED: "已启用", - DISABLED: "已停用", - DEPRECATED: "已归档", -}; - -const MODEL_STATUS_TRANSITIONS: Record = { - DRAFT: ["ENABLED", "DISABLED", "DEPRECATED"], - ENABLED: ["DISABLED", "DEPRECATED"], - DISABLED: ["ENABLED", "DEPRECATED"], - DEPRECATED: ["DISABLED"], -}; - -const HEALTH_STATUS_LABELS: Record = { - HEALTHY: "健康", - DEGRADED: "退化", - UNHEALTHY: "不健康", -}; - -const TEST_STATUS_LABELS: Record = { - PASSED: "通过", - FAILED: "失败", -}; - -const ROUTE_TYPE_OPTIONS: ModelRouteType[] = ["GLOBAL", "CAPABILITY", "BUSINESS", "AGENT"]; -const PROVIDER_OPTIONS: Array<{ value: string; label: string }> = [ - { value: "openai", label: "OpenAI" }, - { value: "anthropic", label: "Anthropic" }, - { value: "google", label: "Google" }, - { value: "deepseek", label: "DeepSeek" }, - { value: "qwen", label: "Qwen" }, - { value: "grok", label: "Grok" }, - { value: "azure-openai", label: "Azure OpenAI" }, - { value: "other", label: "其他" }, -]; - -const MODELS_PATH = "/api/v1/admin/models"; -const ROUTES_PATH = "/api/v1/admin/model-routes"; -const GLOBAL_ROUTE_KEY = "__global__"; - -type AgentCreateFormState = { - code: string; - name: string; - provider: string; - provider_model: string; - status: ModelStatus; - capabilities: string; - description: string; - base_url: string; - api_key: string; -}; - -const EMPTY_AGENT_CREATE_FORM: AgentCreateFormState = { - code: "", - name: "", - provider: "openai", - provider_model: "", - status: "DRAFT", - capabilities: "", - description: "", - base_url: "", - api_key: "", -}; - -type AgentInstructionFormState = { - description: string; - capabilities: string; -}; - -type AgentSettingsFormState = { - name: string; - provider: string; - provider_model: string; - base_url: string; -}; - -type AgentRouteFormState = { - route_type: ModelRouteType; - route_key: string; - target_model_code: string; - priority: string; - enabled: boolean; - note: string; -}; - -const EMPTY_ROUTE_FORM: AgentRouteFormState = { - route_type: "AGENT", - route_key: "", - target_model_code: "", - priority: "100", - enabled: true, - note: "", -}; - -type AgentTestFormState = { - kind: string; - input_tokens: string; - output_tokens: string; -}; - -const EMPTY_TEST_FORM: AgentTestFormState = { - kind: "SMOKE", - input_tokens: "16", - output_tokens: "32", -}; - -function parseCapabilities(value: string): string[] { - return value - .split(",") - .map((item) => item.trim().toLowerCase()) - .filter(Boolean) - .filter((item, index, arr) => arr.indexOf(item) === index) - .sort(); -} - -function formatModelStatus(status: ModelStatus): string { - return `${MODEL_STATUS_LABELS[status]}(${status})`; -} - -function formatHealthStatus(status: ModelHealthStatus | null): string { - if (!status) { - return "未检测"; - } - return `${HEALTH_STATUS_LABELS[status]}(${status})`; -} - -function formatTestStatus(status: ModelTestStatus): string { - return `${TEST_STATUS_LABELS[status]}(${status})`; -} - -function getModelStatusTagColor(status: ModelStatus): string { - switch (status) { - case "ENABLED": - return "green"; - case "DISABLED": - return "orange"; - case "DEPRECATED": - return "default"; - case "DRAFT": - default: - return "blue"; - } -} - -function getHealthStatusTagColor(status: ModelHealthStatus | null): string { - switch (status) { - case "HEALTHY": - return "green"; - case "DEGRADED": - return "orange"; - case "UNHEALTHY": - return "red"; - default: - return "default"; - } -} - -function getTestStatusTagColor(status: ModelTestStatus): string { - return status === "PASSED" ? "green" : "red"; -} - -function formatDateTime(value: string | null): string { - if (!value) { - return "-"; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - return date.toLocaleString(); -} - -async function invalidateAgentQueries(queryClient: ReturnType, modelId?: number): Promise { - await queryClient.invalidateQueries({ queryKey: [MODELS_PATH] }); - await queryClient.invalidateQueries({ queryKey: [ROUTES_PATH] }); - if (modelId) { - await queryClient.invalidateQueries({ queryKey: ["admin.agent.tests", modelId] }); - } -} - -export default function AdminAgentPage() { - const queryClient = useQueryClient(); - const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); - - const canRead = hasPermission("model.read") || hasPermission("model.manage"); - const canManage = hasPermission("model.manage"); - - const [error, setError] = useState(""); - const [success, setSuccess] = useState(""); - - const [keyword, setKeyword] = useState(""); - const [showArchived, setShowArchived] = useState(false); - const [selectedModelId, setSelectedModelId] = useState(null); - - const [activeTab, setActiveTab] = useState("instructions"); - - const [showCreateModal, setShowCreateModal] = useState(false); - const [createForm, setCreateForm] = useState(EMPTY_AGENT_CREATE_FORM); - - const [instructionForm, setInstructionForm] = useState({ - description: "", - capabilities: "", - }); - - const [settingsForm, setSettingsForm] = useState({ - name: "", - provider: "", - provider_model: "", - base_url: "", - }); - - const [showRouteModal, setShowRouteModal] = useState(false); - const [editingRouteId, setEditingRouteId] = useState(null); - const [routeForm, setRouteForm] = useState(EMPTY_ROUTE_FORM); - - const [testForm, setTestForm] = useState(EMPTY_TEST_FORM); - - const loadModels = useCallback(async () => { - const response = await fetchWithAuth(MODELS_PATH); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ModelListResponse; - }, [fetchWithAuth]); - - const loadRoutes = useCallback(async () => { - const response = await fetchWithAuth(ROUTES_PATH); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ModelRouteRuleListResponse; - }, [fetchWithAuth]); - - const modelsQuery = useQuery({ - queryKey: [MODELS_PATH], - queryFn: loadModels, - enabled: !!user && canRead, - }); - - const routesQuery = useQuery({ - queryKey: [ROUTES_PATH], - queryFn: loadRoutes, - enabled: !!user && canRead, - }); - - const models = modelsQuery.data?.items ?? []; - const routes = routesQuery.data?.items ?? []; - - const filteredAgents = useMemo(() => { - const needle = keyword.trim().toLowerCase(); - return models.filter((model) => { - if (!showArchived && model.status === "DEPRECATED") { - return false; - } - if (!needle) { - return true; - } - const haystack = `${model.code} ${model.name} ${model.provider} ${model.provider_model} ${model.description}`.toLowerCase(); - return haystack.includes(needle); - }); - }, [keyword, models, showArchived]); - - const archivedCount = useMemo(() => models.filter((model) => model.status === "DEPRECATED").length, [models]); - - useEffect(() => { - if (filteredAgents.length === 0) { - setSelectedModelId(null); - return; - } - if (selectedModelId === null || !filteredAgents.some((model) => model.id === selectedModelId)) { - setSelectedModelId(filteredAgents[0]?.id ?? null); - } - }, [filteredAgents, selectedModelId]); - - const selectedAgent = useMemo( - () => models.find((model) => model.id === selectedModelId) ?? null, - [models, selectedModelId], - ); - - const boundRoutes = useMemo(() => { - if (!selectedAgent) { - return []; - } - return routes - .filter((item) => item.target_model_code === selectedAgent.code) - .sort((a, b) => a.priority - b.priority || a.id - b.id); - }, [routes, selectedAgent]); - - useEffect(() => { - if (!selectedAgent) { - return; - } - setInstructionForm({ - description: selectedAgent.description, - capabilities: selectedAgent.capabilities.join(", "), - }); - setSettingsForm({ - name: selectedAgent.name, - provider: selectedAgent.provider, - provider_model: selectedAgent.provider_model, - base_url: selectedAgent.base_url ?? "", - }); - setRouteForm((previous) => ({ - ...previous, - target_model_code: selectedAgent.code, - })); - setTestForm(EMPTY_TEST_FORM); - }, [selectedAgent]); - - const testHistoryQuery = useQuery({ - queryKey: ["admin.agent.tests", selectedAgent?.id ?? 0], - queryFn: async () => { - if (!selectedAgent) { - return { items: [], total: 0 } as ModelTestRunListResponse; - } - const response = await fetchWithAuth(`/api/v1/admin/models/${selectedAgent.id}/tests?limit=20`); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ModelTestRunListResponse; - }, - enabled: !!selectedAgent && !!user && canRead, - }); - - const createAgentMutation = useMutation({ - mutationFn: async () => { - const payload = { - code: createForm.code.trim().toLowerCase(), - name: createForm.name.trim(), - provider: createForm.provider.trim(), - provider_model: createForm.provider_model.trim(), - status: createForm.status, - capabilities: parseCapabilities(createForm.capabilities), - description: createForm.description.trim(), - base_url: createForm.base_url.trim() || null, - api_key: createForm.api_key.trim() || null, - }; - - const response = await fetchWithAuth(MODELS_PATH, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ModelRegistryItem; - }, - onSuccess: async (created) => { - setError(""); - setSuccess("Agent 已创建"); - setShowCreateModal(false); - setCreateForm(EMPTY_AGENT_CREATE_FORM); - await invalidateAgentQueries(queryClient, created.id); - setSelectedModelId(created.id); - setActiveTab("instructions"); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "创建 Agent 失败"); - }, - }); - - const updateModelMutation = useMutation({ - mutationFn: async ({ modelId, payload }: { modelId: number; payload: Record }) => { - const response = await fetchWithAuth(`/api/v1/admin/models/${modelId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ModelRegistryItem; - }, - onSuccess: async (updated) => { - setError(""); - setSuccess(`Agent「${updated.name}」已更新`); - await invalidateAgentQueries(queryClient, updated.id); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "更新 Agent 失败"); - }, - }); - - const transitionMutation = useMutation({ - mutationFn: async ({ modelId, status }: { modelId: number; status: ModelStatus }) => { - const response = await fetchWithAuth(`/api/v1/admin/models/${modelId}/transition`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ModelRegistryItem; - }, - onSuccess: async (updated) => { - setError(""); - setSuccess(`状态已流转为 ${formatModelStatus(updated.status)}`); - await invalidateAgentQueries(queryClient, updated.id); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "状态流转失败"); - }, - }); - - const healthCheckMutation = useMutation({ - mutationFn: async (modelId: number) => { - const response = await fetchWithAuth(`/api/v1/admin/models/${modelId}/health-check`, { - method: "POST", - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return response.json(); - }, - onSuccess: async () => { - setError(""); - setSuccess("健康检查已触发"); - await invalidateAgentQueries(queryClient, selectedAgent?.id ?? undefined); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "健康检查失败"); - }, - }); - - const deleteModelMutation = useMutation({ - mutationFn: async (modelId: number) => { - const response = await fetchWithAuth(`/api/v1/admin/models/${modelId}`, { - method: "DELETE", - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return response.json(); - }, - onSuccess: async () => { - setError(""); - setSuccess("Agent 已删除"); - await invalidateAgentQueries(queryClient); - setSelectedModelId(null); - setActiveTab("instructions"); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "删除 Agent 失败"); - }, - }); - - const saveRouteMutation = useMutation({ - mutationFn: async () => { - const payload = { - route_type: routeForm.route_type, - route_key: routeForm.route_type === "GLOBAL" ? null : routeForm.route_key.trim() || null, - target_model_code: routeForm.target_model_code.trim().toLowerCase(), - priority: Number(routeForm.priority || 100), - enabled: routeForm.enabled, - note: routeForm.note.trim() || null, - }; - - if (editingRouteId) { - const response = await fetchWithAuth(`/api/v1/admin/model-routes/${editingRouteId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return response.json(); - } - - const response = await fetchWithAuth(ROUTES_PATH, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return response.json(); - }, - onSuccess: async () => { - setError(""); - setSuccess(editingRouteId ? "路由规则已更新" : "路由规则已创建"); - setShowRouteModal(false); - setEditingRouteId(null); - setRouteForm((previous) => ({ - ...EMPTY_ROUTE_FORM, - target_model_code: previous.target_model_code, - })); - await invalidateAgentQueries(queryClient, selectedAgent?.id ?? undefined); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "保存路由规则失败"); - }, - }); - - const deleteRouteMutation = useMutation({ - mutationFn: async (routeId: number) => { - const response = await fetchWithAuth(`/api/v1/admin/model-routes/${routeId}`, { - method: "DELETE", - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return response.json(); - }, - onSuccess: async () => { - setError(""); - setSuccess("路由规则已删除"); - await invalidateAgentQueries(queryClient, selectedAgent?.id ?? undefined); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "删除路由规则失败"); - }, - }); - - const runTestMutation = useMutation({ - mutationFn: async () => { - if (!selectedAgent) { - throw new Error("未选中 Agent"); - } - const response = await fetchWithAuth(`/api/v1/admin/models/${selectedAgent.id}/tests`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - kind: testForm.kind.trim().toUpperCase() || "SMOKE", - input_tokens: Number(testForm.input_tokens || 0), - output_tokens: Number(testForm.output_tokens || 0), - }), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ModelTestRunItem; - }, - onSuccess: async (run) => { - setError(""); - setSuccess(`测试完成:${formatTestStatus(run.status)}`); - await invalidateAgentQueries(queryClient, selectedAgent?.id ?? undefined); - }, - onError: (candidate) => { - setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "执行测试失败"); - }, - }); - - const instructionDirty = - !!selectedAgent && - (instructionForm.description !== selectedAgent.description || - parseCapabilities(instructionForm.capabilities).join(",") !== selectedAgent.capabilities.join(",")); - - const settingsDirty = - !!selectedAgent && - (settingsForm.name !== selectedAgent.name || - settingsForm.provider !== selectedAgent.provider || - settingsForm.provider_model !== selectedAgent.provider_model || - settingsForm.base_url !== (selectedAgent.base_url ?? "")); - - const openCreateRoute = useCallback(() => { - if (!selectedAgent) { - return; - } - setEditingRouteId(null); - setRouteForm({ - ...EMPTY_ROUTE_FORM, - target_model_code: selectedAgent.code, - }); - setShowRouteModal(true); - }, [selectedAgent]); - - const openEditRoute = useCallback((route: ModelRouteRuleItem) => { - setEditingRouteId(route.id); - setRouteForm({ - route_type: route.route_type, - route_key: route.route_type === "GLOBAL" ? "" : route.route_key, - target_model_code: route.target_model_code, - priority: String(route.priority), - enabled: route.enabled, - note: route.note ?? "", - }); - setShowRouteModal(true); - }, []); - - const transitionCandidates = selectedAgent ? MODEL_STATUS_TRANSITIONS[selectedAgent.status] : []; - - const loading = modelsQuery.isLoading || routesQuery.isLoading; - const loadingMessage = modelsQuery.isLoading ? "加载 Agent 列表中..." : "加载路由规则中..."; - - const tabItems = useMemo(() => { - const items: TabsProps["items"] = [ - { - key: "instructions", - label: ( - - - 说明 - - ), - children: ( -
-