feat(models): add analysis-default flag for campaign intelligence
Introduce ModelPurpose.ANALYSIS and a globally-unique is_analysis_default marker on chat model configs so campaign analysis can resolve its model. Service rejects disabled or non-chat configs; repo clears the previous holder on set. Documented the analysis role in CONTEXT.md.
This commit is contained in:
parent
f08d74fd75
commit
e1e067bac4
15
.scratch/v0.7/issues/01-analysis-default-model-config.md
Normal file
15
.scratch/v0.7/issues/01-analysis-default-model-config.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# 01 — 模型配置中心「分析默认」
|
||||||
|
|
||||||
|
**What to build:** 管理员在模型配置中心能把一个「启用中的 chat 模型」标记为「分析默认」(全局唯一,设新的自动清旧的),API 透出该标记。后端新增 `ModelPurpose.ANALYSIS` 枚举与 `model_configs.is_analysis_default` 列(Alembic 迁移,batch mode,回填 false),服务层校验「停用配置不能设为分析默认」「非 chat 能力不能设为分析默认」。
|
||||||
|
|
||||||
|
**Blocked by:** None — can start immediately
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] `ModelPurpose` 枚举新增 `ANALYSIS`;CONTEXT.md 的「分析岗位」术语编辑随本 ticket 一并提交
|
||||||
|
- [ ] Alembic 迁移:`model_configs` 加 `is_analysis_default` 布尔列(server_default 回填 false)
|
||||||
|
- [ ] 仓储层设分析默认时互斥清除(参照 `clear_default` 模式);服务层校验:停用 → 拒绝;非 chat 能力 → 拒绝
|
||||||
|
- [ ] 创建/更新模型配置的 API 接受并返回 `is_analysis_default`
|
||||||
|
- [ ] 模型配置中心前端:chat 配置上可设置/展示「分析默认」标记
|
||||||
|
- [ ] 单元/集成测试:唯一性互斥、停用拒绝、非 chat 拒绝、API 读写
|
||||||
|
- [ ] `pytest` + `ruff` + `npx tsc --noEmit` + `npm run build` 全绿
|
||||||
14
.scratch/v0.7/issues/02-campaign-analysis-model-override.md
Normal file
14
.scratch/v0.7/issues/02-campaign-analysis-model-override.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# 02 — 活动级分析模型覆盖
|
||||||
|
|
||||||
|
**What to build:** 创建评估活动时,表单左列「时间与速度」区新增「分析模型」下拉(选项 = 启用中的 chat 配置,默认项「全局默认(当前默认配置名)」),不选则跟随全局默认。后端 `campaigns` 表加可空 `analysis_model_config_id` 列(迁移),创建 API 接受该字段并校验引用的配置存在(不存在 400),活动详情/列表响应透出该字段。
|
||||||
|
|
||||||
|
**Blocked by:** 01(「全局默认」语义与下拉数据来自 01 的分析默认标记)
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Alembic 迁移:`campaigns` 加可空 `analysis_model_config_id` 列
|
||||||
|
- [ ] `POST /api/campaigns` 接受 `analysis_model_config_id`(可空);引用不存在的配置返回 400
|
||||||
|
- [ ] 活动响应模型透出 `analysis_model_config_id`
|
||||||
|
- [ ] 创建表单加「分析模型」下拉:默认「全局默认(<名称>)」,选项为启用的 chat 配置;无全局默认时默认项提示「未配置」
|
||||||
|
- [ ] 集成测试:带覆盖创建、不带覆盖创建(存 NULL)、无效 id 400
|
||||||
|
- [ ] `pytest` + `ruff` + `npx tsc --noEmit` + `npm run build` 全绿
|
||||||
16
.scratch/v0.7/issues/03-analysis-agent-and-api.md
Normal file
16
.scratch/v0.7/issues/03-analysis-agent-and-api.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# 03 — 两阶段分析 Agent + 存储 + 分析 API
|
||||||
|
|
||||||
|
**What to build:** 对终态活动可触发生成结构化分析报告。新表 `campaign_analyses`(每活动一行 upsert:status=generating/completed/failed、result JSON、model_config_id 快照、error、triggered_by、时间戳)。新模块 `evaluation/analysis.py`:输入复用 `generate_campaign_report` 聚合结果(不重算,ADR-0002/0004 口径)+ 每场景最多 3 条截断的代表性失败对话;阶段一每场景并行一次 LLM 调用产场景叙述+问题草稿,阶段二汇总产总体结论+跨场景问题+优先级建议;输出 JSON schema 约束,解析失败记 failed;证据 run_id 白名单校验,虚构引用落库前剔除。模型解析 = 活动覆盖 ?? 全局分析默认,两者皆无 → 明确错误。API:`GET /api/campaigns/{id}/analysis`(未生成返回空态)、`POST /api/campaigns/{id}/analysis`(触发/覆盖重跑;进行中/计划中 400;无模型 400 且 detail 引导配置)。后台 asyncio 任务 + try/finally 关 Session,失败落 error。
|
||||||
|
|
||||||
|
**Blocked by:** 02(模型解析依赖活动覆盖列)
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Alembic 迁移:`campaign_analyses` 表(campaign_id 唯一)
|
||||||
|
- [ ] 仓储层 upsert / get_by_campaign
|
||||||
|
- [ ] `evaluation/analysis.py` 两阶段编排(阶段一 `asyncio.gather` 并行;输出 schema 见 spec)
|
||||||
|
- [ ] 失败样例选取:每场景 ≤3 条失败 turn(用户消息/回复/判定理由,截断)
|
||||||
|
- [ ] run_id 白名单:模型引用的虚构 id 落库前剔除
|
||||||
|
- [ ] 假 LLM 客户端单测:编排、解析失败→failed、白名单剔除、无失败样例场景仍产叙述、无模型→明确错误
|
||||||
|
- [ ] API 集成测试:GET 空态;POST→完成→GET 拿结果;进行中 400;无模型 400;重跑 upsert 不新增行
|
||||||
|
- [ ] `pytest` + `ruff` 全绿
|
||||||
14
.scratch/v0.7/issues/04-report-drawer-analysis-section.md
Normal file
14
.scratch/v0.7/issues/04-report-drawer-analysis-section.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# 04 — 报告抽屉「智能分析」区块
|
||||||
|
|
||||||
|
**What to build:** 活动报告抽屉顶部新增「智能分析」区块。状态行:生成中(spinner + 5s 轮询直到终态)、失败(错误原因 + 重试按钮)、未生成(「生成分析」按钮;无分析模型时引导去配置中心)。已生成时依次渲染:总体结论 callout → 问题诊断列表(严重度 Tag 高/中/低配色、涉及场景名、证据 chip 点击跳 `/reports?run={id}`)→ 分场景叙述 → 改善建议(按 priority 排序)。生成/重新生成按钮仅终态活动可用,进行中/计划中禁用并提示「活动完成后可生成」。显示所用分析模型名与生成时间。
|
||||||
|
|
||||||
|
**Blocked by:** 03(API 与结果结构)
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] `api.ts`:`CampaignAnalysis` 类型 + `campaignsApi.getAnalysis(id)` / `generateAnalysis(id)`
|
||||||
|
- [ ] 抽屉顶部「智能分析」区块按状态渲染(生成中轮询复用 `usePolling`)
|
||||||
|
- [ ] 四个结构化区块渲染:总体结论 / 问题诊断(严重度配色 + 场景名 + 证据 chip 跳转)/ 分场景叙述 / 改善建议排序
|
||||||
|
- [ ] 按钮状态:终态可点;进行中/计划中禁用 + 提示;无分析模型引导配置中心
|
||||||
|
- [ ] 展示分析模型名 + 生成时间
|
||||||
|
- [ ] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证全流程
|
||||||
14
.scratch/v0.7/issues/05-auto-trigger-and-markdown-export.md
Normal file
14
.scratch/v0.7/issues/05-auto-trigger-and-markdown-export.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# 05 — 正式线自动触发 + Markdown 导出纳入
|
||||||
|
|
||||||
|
**What to build:** 正式线活动(`time_scale == 1`)在 runner 写入 COMPLETED 的同一处自动 enqueue 分析后台任务(`triggered_by=auto`);加速活动(`time_scale > 1`)与失败/取消的活动不自动触发;无可解析分析模型时静默跳过(不阻塞活动完成)。活动 Markdown 导出(`render_campaign_markdown`)在该活动存在 completed 分析时追加分析区块:总体结论 / 问题诊断(含严重度、场景、证据 run 引用)/ 分场景叙述 / 改善建议。
|
||||||
|
|
||||||
|
**Blocked by:** 03(分析服务与存储)
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] runner 完成处钩子:`time_scale == 1` 且有可解析分析模型 → enqueue(用假分析服务断言调用/不调用两侧)
|
||||||
|
- [ ] 加速线、failed、cancelled 活动不自动触发
|
||||||
|
- [ ] 无分析模型时静默跳过,活动完成流程不受影响
|
||||||
|
- [ ] `render_campaign_markdown` 追加分析区块(仅 completed 分析);无分析时导出与现状一致
|
||||||
|
- [ ] 测试:自动触发三分支 + Markdown 含/不含分析区块两侧
|
||||||
|
- [ ] `pytest` + `ruff` 全绿
|
||||||
87
.scratch/v0.7/spec.md
Normal file
87
.scratch/v0.7/spec.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# v0.7 — 活动智能分析(分析 Agent + 结构化分析报告)
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
评测工程师拿到活动报告时,看到的是一堆聚合数字(通过率、可用性、时延、趋势、分场景汇总),但「这个数字意味着什么、哪里出了问题、接下来该改什么」需要人自己钻进几十条子运行里翻失败对话,耗时且依赖经验。尤其是正式线(实时 ×1)活动跑完后,团队期望直接拿到一份可读的专业研判:总体结论、问题清单、改进建议——目前平台给不出。
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
为活动引入「分析(Analysis)岗位」(CONTEXT.md 已有术语):一个两阶段分析 Agent,对活动聚合结果做活动级、跨场景的叙述性研判,产出**结构化分析报告**(总体结论 + 问题诊断 + 分场景叙述 + 改善建议),嵌在活动报告抽屉顶部展示,证据可点击下钻到具体子运行。正式线活动完成后自动生成;调试线(加速)活动按需手动生成,结果缓存可重新生成。分析模型在模型配置中心设「分析默认」,活动创建时可覆盖。
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. 作为评测工程师,我希望正式线活动一完成就自动得到分析报告,以便不用记得去点按钮。
|
||||||
|
2. 作为评测工程师,我希望调试线活动不自动消耗分析模型的 token,以便控制成本。
|
||||||
|
3. 作为评测工程师,我希望在报告抽屉里对终态活动随时手动生成/重新生成分析,以便在调整数据视角后拿到新研判。
|
||||||
|
4. 作为评测工程师,我希望进行中的活动不能生成分析,以便报告永远基于稳定数据。
|
||||||
|
5. 作为评测工程师,我希望报告开头有一段总体结论,以便 30 秒内知道这个活动行不行。
|
||||||
|
6. 作为评测工程师,我希望看到按严重度排列的问题诊断列表,每条注明涉及场景和证据,以便优先处理大问题。
|
||||||
|
7. 作为评测工程师,我希望问题证据能直接跳到对应子运行报告,以便核实模型研判是否属实。
|
||||||
|
8. 作为评测工程师,我希望每个场景有一段叙述性表现分析,以便理解数字背后的行为模式。
|
||||||
|
9. 作为评测工程师,我希望改善建议按优先级排序且可执行,以便直接转成下一步工作。
|
||||||
|
10. 作为评测工程师,我希望模型引用的证据都真实存在(不虚构 run_id),以便信任报告。
|
||||||
|
11. 作为管理员,我希望在模型配置中心把某个 chat 模型设为「分析默认」,以便全平台统一分析口径。
|
||||||
|
12. 作为管理员,我希望「分析默认」全局唯一、设置时自动互斥,以便不用手动清理旧标记。
|
||||||
|
13. 作为评测工程师,我希望创建活动时能为该活动单独指定分析模型,以便重要活动用更强的模型。
|
||||||
|
14. 作为评测工程师,我希望未配置分析模型时得到明确引导(去配置中心设置),以便知道为什么没有分析。
|
||||||
|
15. 作为评测工程师,我希望分析失败时看到失败原因并能重试,以便区分是模型故障还是数据问题。
|
||||||
|
16. 作为评测工程师,我希望导出的活动 Markdown 报告包含分析区块,以便离线分享完整报告。
|
||||||
|
17. 作为评测工程师,我希望分析所用的模型配置被快照记录,以便事后追溯报告出自哪个模型。
|
||||||
|
18. 作为评测工程师,我希望报告抽屉里能区分「生成中 / 已完成 / 失败」状态,以便知道后台任务进展。
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
- **分析岗位**:`ModelPurpose` 枚举新增 `ANALYSIS`。分析模型不按场景绑定(区别于出题/判卷),而是全局默认 + 活动覆盖(CONTEXT.md 术语定义)。
|
||||||
|
- **分析默认标记**:`model_configs` 表新增 `is_analysis_default` 布尔列(Alembic 迁移,batch mode,server_default 回填 false)。仓储层参照 `is_default` 的 `clear_default` 模式保证全局唯一;仅启用的 chat 能力配置可设为分析默认(校验与 `is_default and not enabled` 同款)。模型配置中心 UI 在 chat 配置上提供「分析默认」标记。
|
||||||
|
- **活动覆盖**:`campaigns` 表新增可空 `analysis_model_config_id` 列(迁移);创建活动 API 接受该字段(可空=跟随全局默认);创建表单左列「时间与速度」区加「分析模型」下拉(选项=启用的 chat 配置,默认项「全局默认(<默认配置名>)」)。
|
||||||
|
- **分析模型解析**:`活动.analysis_model_config_id ?? 全局分析默认`。两者都无 → 自动触发跳过;手动触发返回 409/400 并提示去配置中心。
|
||||||
|
- **分析存储**:新表 `campaign_analyses`,每活动一行(upsert 覆盖):`campaign_id` 唯一、`status`(generating/completed/failed)、`result`(结构化 JSON,可空)、`model_config_id`(快照)、`error`(可空)、`triggered_by`(auto/manual)、`created_at`/`updated_at`。后台任务与 Runs 同款 `asyncio.Task` + `try/finally` 关 Session。
|
||||||
|
- **API**:`GET /api/campaigns/{id}/analysis` → `{status, result?, error?, model_config_id?, updated_at?}`(从未生成时 status 为 none/空态);`POST /api/campaigns/{id}/analysis` → 触发生成(已存在则覆盖重跑);活动为进行中/计划中返回 400;无分析模型可解析返回 400 且 detail 引导配置。
|
||||||
|
- **自动触发**:`campaign_runner` 写入 COMPLETED 的同一处(campaign_runner.py:232 附近),若 `time_scale == 1` 且可解析到分析模型,则 enqueue 分析后台任务。失败/取消的活动不自动触发。
|
||||||
|
- **两阶段分析 Agent**(新模块 `evaluation/analysis.py`):
|
||||||
|
- 输入数据:复用 `generate_campaign_report` 的聚合结果(不重算,遵循 ADR-0002/0004 口径)+ 每场景最多 3 条代表性失败对话(取自失败子运行的 turn:用户消息/回复/判定理由,各截断到合理长度)。
|
||||||
|
- 阶段一:每个场景一次 LLM 调用(并行 `asyncio.gather`)→ 该场景叙述 + 问题点草稿。
|
||||||
|
- 阶段二:汇总各场景产出 + 全局统计 → 总体结论 + 跨场景问题 + 优先级建议。
|
||||||
|
- 输出用 JSON schema 约束(prompt 内嵌 schema + 解析校验,解析失败按失败处理可重试)。
|
||||||
|
- 证据白名单:`run_id` 必须在提供的数据集内,模型虚构的引用在落库前剔除。
|
||||||
|
- **结构化报告 schema**:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"overall": str, # 总体结论(一段话)
|
||||||
|
"problems": [{"severity": "high"|"medium"|"low",
|
||||||
|
"title": str, "description": str,
|
||||||
|
"scenario_ids": [str],
|
||||||
|
"evidence_run_ids": [str]}],
|
||||||
|
"scenario_narratives": [{"scenario_id": str, "narrative": str}],
|
||||||
|
"suggestions": [{"priority": int, "text": str}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **前端报告抽屉**:顶部新增「智能分析」区块——状态行(生成中 spinner / 失败 error+重试 / 未生成时的生成按钮与状态说明 + 分析模型名);完成后渲染:总体结论 callout → 问题诊断列表(严重度 Tag、场景名、证据 chip 点击跳 `/reports?run=`)→ 分场景叙述 → 改善建议(按 priority 排序)。生成/重新生成按钮仅终态活动可用,进行中禁用并提示「活动完成后可生成」。
|
||||||
|
- **Markdown 导出**:`render_campaign_markdown` 追加分析区块(有 completed 分析时),按 总体结论/问题诊断/分场景叙述/改善建议 拼段。
|
||||||
|
- **语言**:分析输出一律中文。
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
好测试只测外部行为,不测实现细节。
|
||||||
|
|
||||||
|
- **唯一新 seam:分析服务**(`evaluation/analysis.py`),用假 LLM 客户端注入(先例:`tests/unit/mock_channel.py` 的 MockChannel、llm_score 的假网关)。覆盖:两阶段编排(阶段一并行、阶段二汇总)、JSON 解析失败→ failed 状态、run_id 白名单剔除虚构引用、无失败样例的场景也能产出叙述、无分析模型可解析→明确错误。
|
||||||
|
- **API 集成测试**(先例 `tests/integration/test_campaigns_api.py`):GET 空态;POST 触发→后台完成→GET 拿到结果;进行中活动 POST 400;无分析模型 POST 400;覆盖重跑 upsert 不新增行。
|
||||||
|
- **自动触发**:runner 完成正式线活动后分析任务被 enqueue(用假分析服务断言调用);加速活动不触发。
|
||||||
|
- **分析默认唯一性**:仓储/服务层测试(设第二个分析默认时第一个被清;停用配置不能设为分析默认)——先例 `is_default` 现有测试。
|
||||||
|
- **迁移**:Alembic upgrade 后两表新列存在且回填正确。
|
||||||
|
- **前端**:无测试框架,`npx tsc --noEmit` + `npm run build` + 浏览器实操验证(生成流程、状态展示、证据跳转、终态禁用)。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- **工具调用循环 Agent**(function calling 让模型自主拉数据)——本批固定两阶段编排。
|
||||||
|
- **HTML/PDF 专业报告页**——本批结构化 JSON + 抽屉原生渲染 + Markdown 导出。
|
||||||
|
- **分析报告的历史版本**——重新生成即覆盖(upsert),不保留旧版。
|
||||||
|
- **分析结果的通知推送**(完成提醒、Webhook)。
|
||||||
|
- **跨活动对比分析**。
|
||||||
|
- **进行中活动的增量分析**。
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- 分析输入完全来自既有聚合口径(ADR-0002:失败按 0.0 计入;ADR-0004:取消不计入分母),分析 Agent 不重算数字,只做叙述性研判。
|
||||||
|
- 阶段一按场景并行,场景数 = 该活动 capability_summary 中出现的场景数;阶段二单次调用。
|
||||||
|
- 分析模型的上下文窗口/最大输出元数据沿用模型配置中心的描述性元数据,不改变网关请求参数(AGENTS.md 模型配置中心约定)。
|
||||||
@ -81,5 +81,9 @@ _Avoid_: 在线率、健康度
|
|||||||
_Avoid_: 功能、类型
|
_Avoid_: 功能、类型
|
||||||
|
|
||||||
**模型用途(Purpose)**:
|
**模型用途(Purpose)**:
|
||||||
需求侧属性:评测流程中的角色岗位(出题 generator / 判卷 judge / 向量 embedding / 审核 moderation)。场景通过模型绑定为每个岗位指派一个具备相应能力的模型;一个对话能力模型可同时胜任出题与判卷两个岗位。
|
需求侧属性:评测流程中的角色岗位(出题 generator / 判卷 judge / 向量 embedding / 审核 moderation / 分析 analysis)。场景通过模型绑定为每个岗位指派一个具备相应能力的模型;一个对话能力模型可同时胜任出题、判卷、分析等岗位。
|
||||||
_Avoid_: 能力、角色(role 留给对话消息的 role 字段)
|
_Avoid_: 能力、角色(role 留给对话消息的 role 字段)
|
||||||
|
|
||||||
|
**分析(Analysis)岗位**:
|
||||||
|
模型用途的一种:对一整次评估活动(Campaign)的聚合结果做复杂诊断,产出结构化的问题分析与服务质量改善建议。区别于判卷(judge 只裁单轮回复),分析是活动级、跨场景的叙述性研判。因活动跨场景,分析模型不按场景绑定,而是全局默认指派、活动创建时可覆盖。
|
||||||
|
_Avoid_: 判卷、总结
|
||||||
|
|||||||
@ -55,6 +55,7 @@ class ModelPurpose(str, Enum):
|
|||||||
JUDGE = "judge"
|
JUDGE = "judge"
|
||||||
EMBEDDING = "embedding"
|
EMBEDDING = "embedding"
|
||||||
MODERATION = "moderation"
|
MODERATION = "moderation"
|
||||||
|
ANALYSIS = "analysis"
|
||||||
|
|
||||||
|
|
||||||
class EvalTarget(BaseModel):
|
class EvalTarget(BaseModel):
|
||||||
|
|||||||
@ -146,6 +146,15 @@ class ModelConfigService:
|
|||||||
if is_default and not enabled:
|
if is_default and not enabled:
|
||||||
raise ModelConfigError("停用的模型配置不能设为默认")
|
raise ModelConfigError("停用的模型配置不能设为默认")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_analysis_default(capability: str, enabled: bool, is_analysis_default: bool) -> None:
|
||||||
|
if not is_analysis_default:
|
||||||
|
return
|
||||||
|
if not enabled:
|
||||||
|
raise ModelConfigError("停用的模型配置不能设为分析默认")
|
||||||
|
if capability != ModelCapability.CHAT.value:
|
||||||
|
raise ModelConfigError("分析默认模型必须是 chat 能力")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_modalities(modalities: list[str] | None) -> list[str]:
|
def normalize_modalities(modalities: list[str] | None) -> list[str]:
|
||||||
values = modalities if modalities is not None else [ModelModality.TEXT.value]
|
values = modalities if modalities is not None else [ModelModality.TEXT.value]
|
||||||
@ -199,6 +208,7 @@ class ModelConfigService:
|
|||||||
supports_reasoning: bool = False,
|
supports_reasoning: bool = False,
|
||||||
region: str = "",
|
region: str = "",
|
||||||
documentation_url: str | None = None,
|
documentation_url: str | None = None,
|
||||||
|
is_analysis_default: bool = False,
|
||||||
) -> ModelConfigDB:
|
) -> ModelConfigDB:
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
input_modalities = self.normalize_modalities(input_modalities)
|
input_modalities = self.normalize_modalities(input_modalities)
|
||||||
@ -206,6 +216,7 @@ class ModelConfigService:
|
|||||||
self.validate_common(name, provider, enabled, is_default)
|
self.validate_common(name, provider, enabled, is_default)
|
||||||
self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
|
self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
|
||||||
self.validate_metadata(context_window, max_output_tokens, documentation_url)
|
self.validate_metadata(context_window, max_output_tokens, documentation_url)
|
||||||
|
self.validate_analysis_default(capability, enabled, is_analysis_default)
|
||||||
if self.repo.get_by_name(name):
|
if self.repo.get_by_name(name):
|
||||||
raise ModelConfigError("模型配置名称已存在")
|
raise ModelConfigError("模型配置名称已存在")
|
||||||
config = ModelConfigDB(
|
config = ModelConfigDB(
|
||||||
@ -226,6 +237,7 @@ class ModelConfigService:
|
|||||||
api_key_encrypted=self.cipher.encrypt(api_key),
|
api_key_encrypted=self.cipher.encrypt(api_key),
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
is_default=is_default,
|
is_default=is_default,
|
||||||
|
is_analysis_default=is_analysis_default,
|
||||||
description=description.strip(),
|
description=description.strip(),
|
||||||
)
|
)
|
||||||
config.set_modalities(input_modalities, output_modalities)
|
config.set_modalities(input_modalities, output_modalities)
|
||||||
@ -256,6 +268,7 @@ class ModelConfigService:
|
|||||||
supports_reasoning: bool = False,
|
supports_reasoning: bool = False,
|
||||||
region: str = "",
|
region: str = "",
|
||||||
documentation_url: str | None = None,
|
documentation_url: str | None = None,
|
||||||
|
is_analysis_default: bool = False,
|
||||||
) -> ModelConfigDB:
|
) -> ModelConfigDB:
|
||||||
config = self.require(config_id)
|
config = self.require(config_id)
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
@ -264,6 +277,7 @@ class ModelConfigService:
|
|||||||
self.validate_common(name, provider, enabled, is_default)
|
self.validate_common(name, provider, enabled, is_default)
|
||||||
self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
|
self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
|
||||||
self.validate_metadata(context_window, max_output_tokens, documentation_url)
|
self.validate_metadata(context_window, max_output_tokens, documentation_url)
|
||||||
|
self.validate_analysis_default(capability, enabled, is_analysis_default)
|
||||||
references = self.repo.list_references(config_id)
|
references = self.repo.list_references(config_id)
|
||||||
if references and not enabled:
|
if references and not enabled:
|
||||||
raise ModelConfigError("模型配置正在被场景引用,不能停用")
|
raise ModelConfigError("模型配置正在被场景引用,不能停用")
|
||||||
@ -293,6 +307,7 @@ class ModelConfigService:
|
|||||||
config.documentation_url = documentation_url.strip() if documentation_url else None
|
config.documentation_url = documentation_url.strip() if documentation_url else None
|
||||||
config.enabled = enabled
|
config.enabled = enabled
|
||||||
config.is_default = is_default
|
config.is_default = is_default
|
||||||
|
config.is_analysis_default = is_analysis_default
|
||||||
config.description = description.strip()
|
config.description = description.strip()
|
||||||
if clear_api_key:
|
if clear_api_key:
|
||||||
config.api_key_encrypted = None
|
config.api_key_encrypted = None
|
||||||
|
|||||||
@ -145,6 +145,7 @@ class ModelConfigDB(SQLModel, table=True):
|
|||||||
api_key_encrypted: Optional[str] = None
|
api_key_encrypted: Optional[str] = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
is_default: bool = False
|
is_default: bool = False
|
||||||
|
is_analysis_default: bool = False
|
||||||
description: str = ""
|
description: str = ""
|
||||||
created_at: Optional[datetime] = Field(default_factory=utc_now)
|
created_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
updated_at: Optional[datetime] = Field(default_factory=utc_now)
|
updated_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
|
|||||||
@ -31,6 +31,8 @@ class ModelConfigRepository:
|
|||||||
def create(self, config: ModelConfigDB) -> ModelConfigDB:
|
def create(self, config: ModelConfigDB) -> ModelConfigDB:
|
||||||
if config.is_default:
|
if config.is_default:
|
||||||
self.clear_default(config.capability)
|
self.clear_default(config.capability)
|
||||||
|
if config.is_analysis_default:
|
||||||
|
self.clear_analysis_default()
|
||||||
self.session.add(config)
|
self.session.add(config)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.session.refresh(config)
|
self.session.refresh(config)
|
||||||
@ -39,6 +41,8 @@ class ModelConfigRepository:
|
|||||||
def update(self, config: ModelConfigDB) -> ModelConfigDB:
|
def update(self, config: ModelConfigDB) -> ModelConfigDB:
|
||||||
if config.is_default:
|
if config.is_default:
|
||||||
self.clear_default(config.capability, exclude_id=config.id)
|
self.clear_default(config.capability, exclude_id=config.id)
|
||||||
|
if config.is_analysis_default:
|
||||||
|
self.clear_analysis_default(exclude_id=config.id)
|
||||||
config.updated_at = utc_now()
|
config.updated_at = utc_now()
|
||||||
self.session.add(config)
|
self.session.add(config)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
@ -61,6 +65,15 @@ class ModelConfigRepository:
|
|||||||
item.updated_at = utc_now()
|
item.updated_at = utc_now()
|
||||||
self.session.add(item)
|
self.session.add(item)
|
||||||
|
|
||||||
|
def clear_analysis_default(self, exclude_id: str | None = None) -> None:
|
||||||
|
statement = select(ModelConfigDB).where(ModelConfigDB.is_analysis_default.is_(True))
|
||||||
|
for item in self.session.exec(statement).all():
|
||||||
|
if item.id == exclude_id:
|
||||||
|
continue
|
||||||
|
item.is_analysis_default = False
|
||||||
|
item.updated_at = utc_now()
|
||||||
|
self.session.add(item)
|
||||||
|
|
||||||
def list_references(self, config_id: str) -> list[ScenarioModelBindingDB]:
|
def list_references(self, config_id: str) -> list[ScenarioModelBindingDB]:
|
||||||
statement = select(ScenarioModelBindingDB).where(
|
statement = select(ScenarioModelBindingDB).where(
|
||||||
ScenarioModelBindingDB.model_config_id == config_id,
|
ScenarioModelBindingDB.model_config_id == config_id,
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class ModelConfigCreate(BaseModel):
|
|||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
is_default: bool = False
|
is_default: bool = False
|
||||||
|
is_analysis_default: bool = False
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
@ -56,6 +57,7 @@ class ModelConfigResponse(BaseModel):
|
|||||||
has_api_key: bool
|
has_api_key: bool
|
||||||
enabled: bool
|
enabled: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
|
is_analysis_default: bool
|
||||||
description: str
|
description: str
|
||||||
created_at: str | None
|
created_at: str | None
|
||||||
updated_at: str | None
|
updated_at: str | None
|
||||||
@ -83,6 +85,7 @@ class ModelConfigResponse(BaseModel):
|
|||||||
has_api_key=bool(config.api_key_encrypted),
|
has_api_key=bool(config.api_key_encrypted),
|
||||||
enabled=config.enabled,
|
enabled=config.enabled,
|
||||||
is_default=config.is_default,
|
is_default=config.is_default,
|
||||||
|
is_analysis_default=config.is_analysis_default,
|
||||||
description=config.description,
|
description=config.description,
|
||||||
created_at=iso_utc(config.created_at),
|
created_at=iso_utc(config.created_at),
|
||||||
updated_at=iso_utc(config.updated_at),
|
updated_at=iso_utc(config.updated_at),
|
||||||
|
|||||||
@ -100,6 +100,7 @@ export interface ModelConfig {
|
|||||||
has_api_key: boolean
|
has_api_key: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
|
is_analysis_default: boolean
|
||||||
description: string
|
description: string
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
@ -126,6 +127,7 @@ export interface ModelConfigPayload {
|
|||||||
clear_api_key?: boolean
|
clear_api_key?: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
|
is_analysis_default?: boolean
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,7 @@ export default function ModelConfigsPage() {
|
|||||||
documentation_url: null,
|
documentation_url: null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
|
is_analysis_default: false,
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
@ -144,6 +145,7 @@ export default function ModelConfigsPage() {
|
|||||||
clear_api_key: false,
|
clear_api_key: false,
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
is_default: config.is_default,
|
is_default: config.is_default,
|
||||||
|
is_analysis_default: config.is_analysis_default,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
})
|
})
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
@ -208,6 +210,7 @@ export default function ModelConfigsPage() {
|
|||||||
<span style={{ fontWeight: 500 }}>{value}</span>
|
<span style={{ fontWeight: 500 }}>{value}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{record.is_default && <Tag color="gold" style={{ margin: 0 }}>默认</Tag>}
|
{record.is_default && <Tag color="gold" style={{ margin: 0 }}>默认</Tag>}
|
||||||
|
{record.is_analysis_default && <Tag color="purple" style={{ margin: 0 }}>分析默认</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
<Tooltip title={record.endpoint_url}>
|
<Tooltip title={record.endpoint_url}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@ -427,6 +430,9 @@ export default function ModelConfigsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="capability" label="能力" rules={[{ required: true }]}>
|
<Form.Item name="capability" label="能力" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value !== 'chat') form.setFieldValue('is_analysis_default', false)
|
||||||
|
}}
|
||||||
options={selectedProtocolMeta.capabilities.map((value) => ({ value, label: capabilityLabels[value] }))}
|
options={selectedProtocolMeta.capabilities.map((value) => ({ value, label: capabilityLabels[value] }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -452,6 +458,18 @@ export default function ModelConfigsPage() {
|
|||||||
<Space size={24}>
|
<Space size={24}>
|
||||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||||
<Form.Item name="is_default" label="设为该能力默认配置" valuePropName="checked"><Switch /></Form.Item>
|
<Form.Item name="is_default" label="设为该能力默认配置" valuePropName="checked"><Switch /></Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => form.getFieldValue('capability') === 'chat' && (
|
||||||
|
<Form.Item
|
||||||
|
name="is_analysis_default"
|
||||||
|
label="设为分析默认"
|
||||||
|
tooltip="活动智能分析使用的全局默认模型,全局唯一"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
"""add analysis default flag to model configs
|
||||||
|
|
||||||
|
Revision ID: a3f9c2e71b48
|
||||||
|
Revises: e6c3d1a2f809
|
||||||
|
Create Date: 2026-08-02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "a3f9c2e71b48"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "e6c3d1a2f809"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("model_configs", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("is_analysis_default", sa.Boolean(), nullable=False, server_default=sa.false())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("model_configs", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("is_analysis_default")
|
||||||
@ -201,3 +201,38 @@ def test_model_config_metadata_defaults_and_validation(model_client):
|
|||||||
json=_payload(name="invalid-context", api_key=None, context_window=0),
|
json=_payload(name="invalid-context", api_key=None, context_window=0),
|
||||||
)
|
)
|
||||||
assert invalid_context.status_code == 422
|
assert invalid_context.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_default_via_api(model_client):
|
||||||
|
client, _ = model_client
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
"/api/model-configs",
|
||||||
|
json=_payload(name="分析模型一", api_key=None, is_default=False, is_analysis_default=True),
|
||||||
|
)
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["is_analysis_default"] is True
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
"/api/model-configs",
|
||||||
|
json=_payload(name="分析模型二", api_key=None, is_default=False, is_analysis_default=True),
|
||||||
|
)
|
||||||
|
assert second.status_code == 200
|
||||||
|
|
||||||
|
listed = client.get("/api/model-configs").json()
|
||||||
|
flags = {item["name"]: item["is_analysis_default"] for item in listed}
|
||||||
|
assert flags == {"分析模型一": False, "分析模型二": True}
|
||||||
|
|
||||||
|
rejected = client.post(
|
||||||
|
"/api/model-configs",
|
||||||
|
json=_payload(
|
||||||
|
name="向量分析",
|
||||||
|
capability="embedding",
|
||||||
|
model_name="embed-model",
|
||||||
|
api_key=None,
|
||||||
|
is_default=False,
|
||||||
|
is_analysis_default=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert rejected.status_code == 400
|
||||||
|
assert "分析默认" in rejected.json()["detail"]
|
||||||
|
|||||||
35
tests/unit/test_analysis_default_migration.py
Normal file
35
tests/unit/test_analysis_default_migration.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""Verify the analysis-default migration backfills existing configurations."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
from alembic.operations import Operations
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_default_migration_backfills_false(tmp_path, monkeypatch):
|
||||||
|
engine = sa.create_engine(f"sqlite:///{tmp_path / 'analysis_default.db'}")
|
||||||
|
metadata = sa.MetaData()
|
||||||
|
legacy_table = sa.Table(
|
||||||
|
"model_configs",
|
||||||
|
metadata,
|
||||||
|
sa.Column("id", sa.String(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
)
|
||||||
|
metadata.create_all(engine)
|
||||||
|
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(legacy_table.insert().values(id="existing", name="现有模型"))
|
||||||
|
operations = Operations(MigrationContext.configure(connection))
|
||||||
|
migration = importlib.import_module("migrations.versions.a3f9c2e71b48_add_analysis_default_to_model_configs")
|
||||||
|
monkeypatch.setattr(migration, "op", operations)
|
||||||
|
migration.upgrade()
|
||||||
|
|
||||||
|
columns = {column["name"] for column in sa.inspect(connection).get_columns("model_configs")}
|
||||||
|
assert "is_analysis_default" in columns
|
||||||
|
|
||||||
|
value = connection.execute(
|
||||||
|
sa.text("SELECT is_analysis_default FROM model_configs WHERE id = :id"),
|
||||||
|
{"id": "existing"},
|
||||||
|
).scalar_one()
|
||||||
|
assert value == 0
|
||||||
@ -17,7 +17,15 @@ def _service(session):
|
|||||||
return ModelConfigService(session, SecretCipher(Fernet.generate_key().decode("ascii")))
|
return ModelConfigService(session, SecretCipher(Fernet.generate_key().decode("ascii")))
|
||||||
|
|
||||||
|
|
||||||
def _create_config(service, *, name="评估模型", capability="chat", is_default=False, api_key="secret"):
|
def _create_config(
|
||||||
|
service,
|
||||||
|
*,
|
||||||
|
name="评估模型",
|
||||||
|
capability="chat",
|
||||||
|
is_default=False,
|
||||||
|
is_analysis_default=False,
|
||||||
|
api_key="secret",
|
||||||
|
):
|
||||||
return service.create(
|
return service.create(
|
||||||
name=name,
|
name=name,
|
||||||
provider="openai_compatible",
|
provider="openai_compatible",
|
||||||
@ -27,6 +35,7 @@ def _create_config(service, *, name="评估模型", capability="chat", is_defaul
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
is_default=is_default,
|
is_default=is_default,
|
||||||
|
is_analysis_default=is_analysis_default,
|
||||||
description="test",
|
description="test",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -199,3 +208,76 @@ def test_model_metadata_validation_and_runtime_snapshot(db_session):
|
|||||||
description="",
|
description="",
|
||||||
documentation_url="not-a-url",
|
documentation_url="not-a-url",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update(service, config_id, **overrides):
|
||||||
|
params = {
|
||||||
|
"name": "updated",
|
||||||
|
"provider": "openai_compatible",
|
||||||
|
"capability": "chat",
|
||||||
|
"endpoint_url": "https://models.example.com/v1/chat/completions",
|
||||||
|
"model_name": "test-model",
|
||||||
|
"api_key": None,
|
||||||
|
"clear_api_key": False,
|
||||||
|
"enabled": True,
|
||||||
|
"is_default": False,
|
||||||
|
"is_analysis_default": False,
|
||||||
|
"description": "",
|
||||||
|
}
|
||||||
|
params.update(overrides)
|
||||||
|
return service.update(config_id, **params)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_default_is_unique_across_configs(db_session):
|
||||||
|
service = _service(db_session)
|
||||||
|
first = _create_config(service, name="analysis-1", is_analysis_default=True)
|
||||||
|
second = _create_config(service, name="analysis-2", is_analysis_default=True)
|
||||||
|
|
||||||
|
assert service.require(first.id).is_analysis_default is False
|
||||||
|
assert service.require(second.id).is_analysis_default is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_default_update_clears_previous(db_session):
|
||||||
|
service = _service(db_session)
|
||||||
|
first = _create_config(service, name="analysis-1", is_analysis_default=True)
|
||||||
|
second = _create_config(service, name="analysis-2")
|
||||||
|
|
||||||
|
_update(service, second.id, is_analysis_default=True)
|
||||||
|
|
||||||
|
assert service.require(first.id).is_analysis_default is False
|
||||||
|
assert service.require(second.id).is_analysis_default is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_default_requires_chat_capability(db_session):
|
||||||
|
service = _service(db_session)
|
||||||
|
with pytest.raises(ModelConfigError, match="分析默认"):
|
||||||
|
_create_config(service, name="emb", capability="embedding", is_analysis_default=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_default_requires_enabled(db_session):
|
||||||
|
service = _service(db_session)
|
||||||
|
with pytest.raises(ModelConfigError, match="分析默认"):
|
||||||
|
service.create(
|
||||||
|
name="disabled-analysis",
|
||||||
|
provider="openai_compatible",
|
||||||
|
capability="chat",
|
||||||
|
endpoint_url="https://models.example.com/v1/chat/completions",
|
||||||
|
model_name="test-model",
|
||||||
|
api_key="secret",
|
||||||
|
enabled=False,
|
||||||
|
is_default=False,
|
||||||
|
is_analysis_default=True,
|
||||||
|
description="",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _create_config(service, name="to-disable", is_analysis_default=True)
|
||||||
|
with pytest.raises(ModelConfigError, match="分析默认"):
|
||||||
|
_update(service, config.id, enabled=False, is_analysis_default=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_binding_rejects_analysis_purpose(db_session):
|
||||||
|
service = _service(db_session)
|
||||||
|
config = _create_config(service)
|
||||||
|
with pytest.raises(ModelConfigError, match="不支持的模型用途"):
|
||||||
|
service.validate_bindings({ModelPurpose.ANALYSIS.value: config.id})
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user