chore(repo): #330 工具目录 .trae/.dsh/.workbuddy 去跟踪 + .gitignore

依据《仓库入库规范》C7(工具私有状态不入库):
- git rm -r --cached .trae .dsh .workbuddy (15 文件, 仅去跟踪, 磁盘保留)
- .gitignore 增补 .trae/ .dsh/
This commit is contained in:
2026-10-15 22:56:27 +08:00
parent 8c35a7ae95
commit f87998e9b8
17 changed files with 2 additions and 3206 deletions

View File

@@ -1,56 +0,0 @@
---
name: csctalon-management-effectiveness
description: CSCTalon 客服管理系统管理效果/管理闭环设计规范。定义例外管理、归因不归责、管理成本核算、周报自动化、预警驱动、绩效只汇总不扣分等模式,用于提升"符合管理效果"层面。发现"报表没人看/管理动作重/例外被淹没/绩效扣分争议/周报手工汇总"时使用。
whenToUse: 设计管理看板、评审报表价值、规划绩效与预警、优化周报/复盘流程、梳理管理层体验时加载。
---
# CSCTalon 管理效果/管理闭环设计规范
> 定位: 系统是解决问题的, 不是制造麻烦的。管理层体验 = 看结果不翻过程、管例外不管全量、催闭环不催人。
## 1. 例外管理(主管视角核心)
- 主管首页只突出「需要动作」的项: 异常/超期/待回看/待审/积压, 正常数据折叠不打扰。
- 看板默认口径: 红色=要处理, 黄色=快到期, 绿色=正常不显示。
- 每个「例外」可一键跳转到处理页(详情/跟进/审核), 不要求主管跨页面拼信息。
## 2. 归因不归责(数据真实的地基)
- 投诉审计/退款挽回/绩效数据**只做分析汇总, 不做扣分依据**。
- 审计结论用于改进流程, 不挂钩个人绩效扣罚。
- 系统内任何"审计/评估"文案都强调改进导向, 避免逼人"优化数据"。
## 3. 管理成本核算(砍掉没人看的报表)
- 每张报表/看板必须回答: **谁看? 看完做什么决策?** 答不上就不上线。
- 管理动作(登记/审计/跟进/报表)字段能简则简, 抓核心。
- 已有报表定期审计: 使用频率低的合并/下线, 不累积僵尸页面。
## 4. 周报/复盘自动化
- 周报 10 项框架数据自动填充(接待/退款/投诉/措施/异常), 人只补结论。
- 周例会只审到期措施 + 例外项, 不每周重复全量审。
- 周报确认后固化快照, 可持续批注(活文档), 不下钻不强制派生报告。
## 5. 预警驱动(让异常自己浮现)
- 预警规则可配置(退款激增/工单积压/异常激增/投诉激增), 调度器定时扫描。
- 预警进待办中心 + 通知中心, 人从"盯数据"变"确认预警"。
- 预警幂等(同日同规则只一条), 避免刷屏。
## 6. 绩效只汇总不扣分
- 绩效看板呈现工作量/效率/成功率**汇总与趋势**(纵向比进步), 不排名惩罚。
- 人效算法口径(专项难度系数/本职转接)文档化, 数据变化可追溯解释。
- 不把投诉/异常数据直接折算进个人扣分。
## 7. 管理效果评审清单(梳理管理层体验时逐项过)
- [ ] 主管第一屏是否只突出例外(异常/超期/待审)?
- [ ] 是否每个例外一键可达处理页?
- [ ] 报表是否都能回答"谁看/看完做什么决策"?
- [ ] 是否有没人看的僵尸报表可下线?
- [ ] 周报是否自动填充、只补结论?
- [ ] 预警是否驱动处理而非人工盯数据?
- [ ] 绩效是否只汇总不扣分、口径可追溯?
- [ ] 管理动作字段是否最简?

View File

@@ -1,73 +0,0 @@
---
name: csctalon-ui-organization
description: CSCTalon 客服管理系统前端界面组织规范(Vue3+ElementPlus)。定义页面分层、四范式结构、菜单导航组织、状态色板、组件复用规则,用于新增页面与整体界面梳理。发现"界面组织混乱/菜单无序/风格不统一/组件重复"时使用。
whenToUse: 新增前端页面、重构菜单导航、统一页面风格、评审界面组织合理性时加载。
---
# CSCTalon 前端界面组织规范
> 适用技术栈: Vue 3 + Vite + Element Plus + ECharts + Pinia。目标是"不培训就会用、新页面照模板拼装"。
## 1. 页面分层(内容组织四层)
任何页面按此四层组织,禁止跳层:
1. **页头**: 页面标题(左) + 一句话副标题说明用途 + 主操作按钮(右, 右上角)。
2. **筛选区**: 顶部卡片内放 FilterBar/筛选控件(状态/日期/关键词), 变更即刷新。
3. **主体区**: 表格(列表页) 或 卡片栅格(看板页); 看板优先复用 dashboard 组件(KpiCard/ChartCard/SectionHeader)。
4. **页脚区**: 分页 + 总数统计(列表页); 看板页可不设。
## 2. 四大交互范式(页面模板)
| 范式 | 结构 | 适用 |
|------|------|------|
| 列表页 | 页头 + 筛选 + 表格 + 分页 + 导出 + 右上主按钮 | 异常/标签/发票/政策/排班/知识库 |
| 详情页 | 面包屑 + 元信息头(el-descriptions) + 主体 + 侧栏/底部时间线 | 异常详情/投诉审计/报告详情 |
| 表单页 | 必填 ≤3 项置顶 + 其余折叠"更多" + 保存草稿/提交 | 所有登记 |
| 导入页 | 模板下载 + 上传 + 预览复核 + 确认(回执成功/失败) | 标签批量/排班/发票 |
## 3. 菜单与导航组织(侧边栏)
- 一级菜单按角色可见性分层: 全员可见(运营看板/工作台/我的待办/数据中心/发票/知识库) → 管理层(决策看板/管理闭环) → 管理员(系统管理)。
- 同一业务域的页面放同一子菜单(如 管理闭环: 报告/周报/措施/异常/排班/预警/跨部门), 不散落。
- 菜单名与页面 title 一致; 命名避免歧义(如"设备专项标签"区别于"专项任务")。
- 隐藏路由(hidden: true)用于详情页/跳转页, 不进菜单。
## 4. 全局状态色板(统一语义)
| 状态族 | 颜色 | 语义 |
|--------|------|------|
| 待处理/待审/待回看 | warning(黄) | 需要动作 |
| 进行中/执行中 | primary(蓝) | 已启动 |
| 已完成/生效/闭环 | success(绿) | 结束 |
| 异常/失效/超期 | danger(红) | 需关注 |
| 归档/废止 | info(灰) | 只读留存 |
统一封装在 `web/src/utils/statusColor.js`(已存在), 页面禁止私写配色。
## 5. 组件复用(禁止重复造轮子)
- KPI 卡 → `KpiCard`; 图表卡 → `ChartCard` + `BaseChart`; 区块标题 → `SectionHeader`; 筛选条 → `FilterBar`; 三列队列 → `QueuePanel`。
- 新页面先引入公共组件拼装, 禁止内联 KPI 卡/图表卡样式。
- 需要新 props 时向后兼容扩展公共组件, 不在使用页私写覆盖类名。
## 6. 角色化工作台(第一屏差异化)
| 角色 | 第一屏 |
|------|--------|
| 系统管理员 | 全局 KPI + 系统告警 + 待审队列 |
| 主管 | 全局 KPI + 例外清单(异常/超期/待回看) + 待审 |
| 专项组长 | 本专项 KPI + 本专项待办 + 本组排班 |
| 专员 | 本人待办 + 本人数据 + 被指派专项 |
| 普通客服 | 待读政策 + 本人接待 + 提交发票入口 |
## 7. 审核清单(梳理界面时逐项过)
- [ ] 每个页面是否四层结构完整?
- [ ] 是否落在四大范式之一?
- [ ] 菜单是否按角色分层、业务归组、命名无歧义?
- [ ] 状态色是否用统一色板?
- [ ] 是否复用了公共组件而非内联样式?
- [ ] 必填项是否 ≤3、其余折叠?
- [ ] 空数据是否有 el-empty 提示?
- [ ] 加载是否有 v-loading?

View File

@@ -1,71 +0,0 @@
---
name: csctalon-workflow-design
description: CSCTalon 客服管理系统办公流程/工作流设计规范。定义状态机、统一待办中心、通知提醒、闭环分级、跨模块联动(异常→措施→回看)等设计模式,用于业务流程规划与评审。发现"流程断点/状态混乱/该闭环未闭环/待办分散/无提醒"时使用。
whenToUse: 设计新业务流程、评审现有流程是否闭环、规划状态机与待办通知、梳理跨模块联动时加载。
---
# CSCTalon 办公流程/工作流设计规范
> 核心原则: 系统第一屏应「推着人走」而非「人到处找事」; 流程要闭环但分级, 不搞一刀切。
## 1. 流程设计五问(任何新流程先回答)
1. **谁发起?** 录入入口是否 ≤30 秒、能自动则自动?
2. **谁处理?** 是否有明确负责人/接收人(缺省=进统一待办中心)?
3. **什么状态?** 状态机是否线性可预期(待处理→处理中→已完成/闭环)?
4. **何时提醒?** 到期/超期/新增是否有通知(站内信+角标)?
5. **如何闭环?** 结束态是否明确(关闭/归档/回看), 结果是否可追溯?
答不上的流程=不完整, 不上线。
## 2. 状态机规范
- 状态用 String 枚举(避免 SA Enum 迁移麻烦), Pydantic 层校验。
- 状态流转线性的: `待处理 → 处理中 → 已完成` 为主轴; `挂起/驳回/退回` 为分支。
- 每个状态变更落时间线记录(谁/何时/从哪到哪/备注), 复用跟进记录表。
- 状态色统一用 `statusColor.js` 色板(黄=需动作/蓝=进行中/绿=结束/红=异常/灰=归档)。
## 3. 统一待办中心(必做)
- 汇聚所有「该我处理的事」: 待办事项 / 待跟进异常 / 待回看措施 / 待审发票报销 / 待读政策 / 被指派工单。
- 每条带: 来源 + 优先级 + 截止 + 一键跳转; 按角色过滤(管理层看全部, 一线看本人)。
- 新模块的"待处理"数据必须接入统一待办中心, 禁止散落各页面。
## 4. 通知提醒(轻量站内信)
- 触发点: 到期(措施回看/异常跟进)、新增(异常登记/政策发布/审计指派)、状态变化(审核通过/被驳回)。
- 落 `notifications` 表(recipient_id/type/title/content/ref_type/ref_id/is_read), 前端角标 + 列表。
- 局域网内站内信即可, 不做外部推送。
## 5. 闭环分级(不过度管理)
| 事项重要度 | 闭环要求 |
|-----------|---------|
| 小事/日常 | 一句跟进记录即闭环(记录>不记录) |
| 中事/影响有限 | 跟进 + 状态流转 + 到期回看 |
| 大事/影响大 | 完整流程: 登记 → 复盘 → 输出措施 → 到期回看 → 结论归档 |
禁止对小事强制走完整流程(会逼人少登记, 数据失真)。
## 6. 跨模块联动(推荐链路)
```
异常事故(登记+跟进)
├─→ 转待办(todo_items) 当需要人执行
├─→ 转管理措施(measures) 当需要持续改进/到期回看
└─→ 转跨部门派发(upstream) 当根因在上游
投诉审计 → 审计结论 → 转管理措施
政策发布 → 通知相关角色 + 关联知识库话术同步
发票提交 → 主管审核 → 报销 → 导出
```
## 7. 流程评审清单(梳理流程时逐项过)
- [ ] 五问是否全部可答?
- [ ] 状态机是否线性可预期、流转落时间线?
- [ ] 待处理项是否进统一待办中心?
- [ ] 到期/新增是否有通知?
- [ ] 结束态是否明确、可追溯?
- [ ] 是否按重要度分级闭环而非一刀切?
- [ ] 是否与异常/措施/待办/上游正确联动?
- [ ] 录入是否 ≤30 秒、能自动则自动?

2
.gitignore vendored
View File

@@ -152,4 +152,6 @@ work-data/
certs/
# C7 工具私有状态: AI 工具记忆/会话状态, 写给工具自己用的不入库
.workbuddy/
.trae/
.dsh/

View File

@@ -1,81 +0,0 @@
# #327 MCP 只读数据出口 — 实现方案
## 背景与目标
#319 P3 最后一步:对外(第三方/AI Agent)提供受控只读数据出口,替代各处临时拼接的不可审计接口。
**已确认边界**:SSE 传输 + 首批 2 个工具(device_behavior / recharge_traffic)+ 会话级只读兜底。排班考勤延后。
## 技术选型
- **不引新依赖**(无 mcp/fastmcp):用 FastAPI 原生 `StreamingResponse` 实现 SSE 端点,自实现 JSON-RPC 2.0 消息循环(MCP over SSE 协议)。
- **认证**:复用 `external_service.authenticate` + `check_rate_limit` + `touch_last_used`。
- **只读兜底**:每个工具调用前 `SET TRANSACTION READ ONLY`(PG 会话级硬约束),连接池归还时自动 reset。
- **审计**:复用 `S.record_external_access`,每次 MCP 工具调用写 `system_events`。
## 文件变更
### 新增
1. **`backend/app/mcp/__init__.py`** — MCP 协议层(空,标记包)
2. **`backend/app/mcp/server.py`** — MCP SSE Server 核心
- `McpSseServer` 类:管理 SSE 连接、JSON-RPC 消息路由、工具分发
- `handle_message()`:JSON-RPC 2.0 入口(initialize / tools/list / tools/call)
- `tool_registry`:工具注册表(名称 → handler → schema → scope)
- `set_readonly(db)`:会话级只读兜底
3. **`backend/app/mcp/tools.py`** — 首批 2 个工具实现
- `device_behavior(sn)` → `ADS.supply_device_behavior()`
- `recharge_traffic(sn, start, end)` → `ADS.supply_recharge()`
- 每个工具声明:name / description / inputSchema / scope / handler
4. **`backend/app/api/v1/mcp.py`** — FastAPI 路由
- `GET /mcp/sse` — SSE 端点(客户端连接,返回 `text/event-stream`)
- `POST /mcp/messages` — 客户端→服务器 JSON-RPC 消息(MCP over SSE 标准)
- 认证:从 query param `?token=` 或 `Authorization: Bearer` 取 token(SSE 连接无法带 Header,兼容两种)
- 限流/审计/scope 校验复用 `_auth` 模式
### 修改
5. **`backend/app/api/v1/router.py`** — `include_router(mcp_router, prefix="/mcp")`
6. **`backend/app/services/external_service.py`** — `SCOPES` 新增 `"mcp"`(MCP 专用 scope,与既有 device/recharge 区分,便于独立授权)
### 测试
7. **`backend/tests/test_mcp_server.py`** — 单元测试
- MCP initialize 握手
- tools/list 返回白名单
- tools/call 无 token → 401
- tools/call 无效 scope → 403
- tools/call device_behavior 正常返回
- tools/call recharge_traffic 正常返回
- 只读兜底验证(尝试写操作 → 报错)
## 关键设计决策
| 决策 | 理由 |
|---|---|
| 不引 mcp/fastmcp 依赖 | 项目零 MCP 先例,最小实现降低风险;后续若需完整 MCP 生态可再引入 |
| 自实现 JSON-RPC 2.0 | MCP over SSE 协议简单(initialize/list/call 三类消息),200 行内可覆盖 |
| scope 新增 `"mcp"` | 与既有 device/recharge 区分,允许管理员单独授权 MCP 通道(可只给 MCP 不给 REST) |
| 只读兜底用 PG 会话级 | `SET TRANSACTION READ ONLY` 是数据库层硬约束,比应用层判断更可靠;连接池归还自动 reset 无副作用 |
| SSE 端点支持 query token | SSE 连接(EventSource)无法自定义 Header,兼容 `?token=` 与 `Authorization: Bearer` 两种 |
## 安全约束
- 所有工具调用前强制 `SET TRANSACTION READ ONLY`
- 工具白名单硬编码在 `tools.py`,新增工具需显式注册(代码评审把关)
- 审计日志记录 tool_name + 参数摘要 + 调用方 token prefix
- 限流复用 Redis 固定窗口(每 token 独立计数)
## 验证方案
1. **单元测试**:pytest 覆盖握手/鉴权/白名单/只读/正常调用
2. **本地冒烟**:启动 uvicorn,用 `curl -N` 连 SSE 端点,验证 initialize → tools/list → tools/call 全流程
3. **生产冒烟**:发布后用一个测试 token 调用 device_behavior,确认返回结构与 REST 端点一致
4. **回归**:pytest 全量 0 失败 + ruff 通过 + C2 门禁 0 违规
## 验收对应
- [x] MCP 工具清单与白名单登记文档(`tools.py` 内注册表即文档,头部注释说明)
- [x] external_token 鉴权 + 只读兜底 + 审计日志落地
- [x] 端到端:外部客户端经 MCP 查询 device_behavior + recharge_traffic 成功
- [x] pytest 全量 0 失败 + ruff 通过 + 标准发布

View File

@@ -1,9 +0,0 @@
# 说明
本目录下的设计文档已迁移至仓库根目录 `docs/arch/_pending/`(保留 git 历史)。
迁移目的:统一"AI 协作治理规范" 4.2 的架构文档资产化管理,设计文档进 `docs/arch/` 并接受架构锁定流程。
- 迁移日期:2026-09-01
- 迁移去向:`docs/arch/_pending/`(状态 PENDING,待人工审核锁定)
- 原 20 份文档经 `git mv` 迁移,历史完整保留

View File

@@ -1,169 +0,0 @@
# AI 分析助手性能优化方案(规范流程版)
## Context(为什么改)
生产环境(192.168.10.21,DeepSeek `deepseek-v4-flash`)AI 助手用户反馈"分析耗时较长",但实际诉求是"按既定逻辑 + 系统数据做简单判断"。根因是**架构代差**:全量喂模型 + 让模型自由组织中文报告,简单问句也要走完整 LLM 链路。
### 生产实测(2026-09-12 ~ 09-15,16 条 `ai task done` 日志)
| 指标 | 实测范围 | 平均 | 占比 |
|---|---|---|---|
| `build_ms`(数据底座组装) | 360-3042ms | ~700ms(热)/ 2s(冷) | ~15% |
| `llm_ms`(模型生成) | 1479-6893ms | ~4000ms | **~75%** |
| `total_ms` | 2188-9935ms | ~5000ms | 100% |
**关键发现**(推翻前置假设):
1. `LLM_THINKING` 未显式配置 → 走默认 `disabled`(**非思考模式误配**,先前担心的 low/high 不存在)
2. **`llm_ms` 是绝对大头**(75%),`build_ms` 是次要矛盾
3. `out_chars` 与 `llm_ms` 强正相关(2308 字符→6484ms;171 字符→1479ms)→ **输出长度直接决定耗时**
4. `AI_SYNC_WAIT_S=3.0` + `total_ms` 平均 5s → 几乎全走轮询 → 前端体感更长
**优化优先级**(数据驱动):层 1 降参数 > 层 3 规则层秒回 > 层 2 精确取数。
---
## 规范流程(按 AGENTS.md 工单/界面/文档纪律)
| 环节 | 动作 | 凭证 |
|---|---|---|
| ① 工单 | 在 GitHub `tanzk/csctalon` 建 issue,标题"AI 助手性能优化:参数调优+规则层+精确取数",正文含本方案摘要 + 验收标准 | issue 号 `#XX`(实施时填) |
| ② 方案审核 | 本计划经人工终审(用户确认)后开工 | 计划文件 + 用户确认 |
| ③ 实现 | 每层独立 commit,提交信息带 `#XX` | commit sha |
| ④ 测试 | pytest + npm test + 生产灰度对比 | 测试输出 |
| ⑤ 关闭凭证 | 工单关闭前写 `## 关闭凭证核验 (YYYY-MM-DD)` 评论,逐条对应验收标准 + commit sha + 实测命令 | 评论 |
**文档性质**:本方案属 **REQ**(有功能边界、验收标准、归属工单),非 META。
**界面纪律**:层 3 规则层不改前端 UI(返回结构兼容),不触发界面设计说明三层落地;若后续加"规则层秒回"角标则需补设计说明。
---
## 层 1:参数调优(立竿见影,优先级最高)
生产数据证明 `llm_ms` 是大头,降输入/输出 token + 提同步等待时间见效最快。
| 配置项 | 当前默认 | 改为 | 依据 |
|---|---|---|---|
| `AI_MAX_TOKENS` | 1500 | **800** | out_chars 实测多在 1200-2000,800 token 足够;省一半生成时间 |
| `AI_ANALYZE_MAX_CHARS` | 12000 | **6000** | 喂 4000 输入 token 降首 token 延迟 |
| `AI_SYNC_WAIT_S` | 3.0 | **8.0** | total_ms 平均 5s,提到 8s 让多数请求一次返回不走轮询 |
| `LLM_TIMEOUT_S` | 120.0 | 保持 | 云端生成长尾兜底 |
**改文件**:
- [backend/app/core/config.py](file:///Users/tanzk/dev/csctalon/backend/app/core/config.py#L132-L180)(3 个默认值)
- [deploy/env.production.example](file:///Users/tanzk/dev/csctalon/deploy/env.production.example#L117-L127)(补 3 个示例值,避免生产 .env 与代码默认脱节——本次生产就是 .env 没显式配走默认值)
**同步改生产**:SSH 192.168.10.21 `/opt/csctalon/.env` 追加 3 行 + `docker compose restart backend`。
**测试影响**:`test_ai_assistant.py` L77-L120 只断言结构/字段不断言长度,**不回归**。
**验收**:改后同问句 `total_ms` 应从 ~5s 降到 ~2.5s,多数请求走 `success` 不走 `running`。
---
## 层 2:问题路由 + 精确取数(次优先,build_ms 边际收益小但降输入 token)
**复用 [ai_focus_service._KEYWORD_TO_SCOPE](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_focus_service.py#L31-L36)**(scope 值 `refund/complaint/reception` 与 feed 顶层段名**逐字符对齐**,见 [external.py:436-438](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L436-L438)),**不复用 `GLOSSARY_ROUTE`**(粒度错位)。
**改 [backend/app/services/ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py)**:
1. 新增 `_keyword_to_scope(question) -> str | None`:复用 `_KEYWORD_TO_SCOPE` 映射表(导出复用,勿复制防漂移)
2. 新增 `_select_feed_sections(feed, scope) -> dict`:恒量段(period/date/date_start/date_end/generated_at/_spec/range)始终保留 + 命中 scope 对应业务段;scope=None 回退全量(兼容)
3. `build_messages` 内:`_build_feed` → `_select_feed_sections` → `_trim_feed`
**缓存策略**:brief 仍走 `_build_brief`(命中 `today:kpi`/`refund:do`/`complaint:*` 既有 key,**铁律 #11**),段选取只在内存 dict 切片零成本。**不**按段单独调聚合函数(today_kpi 一次出 reception+refund,分调反多一次 miss)。
**测试**:`test_build_messages_structure`(L78)走全量兜底不断言段,不回归;新增 `test_build_messages_scope_refund_only` 断言问"今日退款"→ feed 不含 complaint 顶层段。
**风险**:问"今日退款"保留整段 refund(含 `a_route.litigation_*`)不漏涉诉率。
---
## 层 3:简单判断走规则层(最贴合诉求,纯取数问句秒回)
**入口**:[ai.py ai_analyze](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/ai.py#L174-L270) 内 `_require_enabled` 之后、`ai_task_manager.submit` 之前**短路**。命中规则 → 直接返回 `R(data={status:success, result_md, **window_info})`,**不入队** task_manager(毫秒级)。
**新增 `backend/app/services/ai_rule_engine.py`**,导出:
```python
def try_rule_answer(db: Session, question: str, eff: dict) -> str | None
```
逻辑:关键词正则匹配 → 调 `ai_feed._build_feed`(命中缓存秒取,含坏缓存自愈)→ 取字段 → 模板拼 Markdown。未命中/取数为空 → 返回 None 回退 LLM 路径。
**模板放代码常量**(非 `ai_focus_configs`——那是 admin 可改的"主观偏好",规则模板是"客观取数",混放会让运维误改取数口径,违反 System 铁律精神 [ai_feed.py:22-34](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py#L22-L34))。
**首批规则**(关键词正则 → 模板):
- `今日|昨日|本周|本月.*(退款|提现).*(多少笔|笔数|情况|金额)` → `refund.total_count/total_amount/a_route.litigation_ratio`
- `...投诉.*(积压|登记|处理|多少)` → `complaint.{source}.registered/handled/backlog`
- `...接待.*(量|多少|情况|会话)` → `reception.total/by_specialty`
**前端兼容**:返回结构与现有 `success` 一致,[AssistantDrawer.vue:328](file:///Users/tanzk/dev/csctalon/web/src/components/AssistantDrawer.vue#L312-L345) `if (d.status === 'success')` 天然命中,**零改**。
**测试**:
- `test_rule_engine_refund_count`(问"今日退款多少笔"→ 命中、返回含 total_count)
- `test_rule_engine_miss_falls_back_to_llm`(问"分析下经营策略"→ 返回 None)
- `test_rule_engine_respects_window`(问"昨日投诉积压"→ _AiWindow 解析昨日窗口,规则层取昨日数据)
**风险**:模板数字必须来自 feed(禁编造);取数 None 时模板写"暂无"不写 0;规则层在 `_build_feed` 坏缓存自愈([ai_feed.py:70-81](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py#L70-L81))之后取数(同函数天然安全)。
---
## 实施顺序
1. **建工单** `#XX`(实施时填实际 issue 号)
2. **层 1**(1 commit):改 config.py + env.production.example + 同步生产 .env 重启 → 看日志 llm_ms 是否降
3. **层 2**(1 commit):改 ai_feed.py + 新增测试 → pytest
4. **层 3**(1 commit):新增 ai_rule_engine.py + 改 ai.py 短路 + 新增测试 → pytest
5. **前端测试**:`npm test -- AssistantDrawer.spec.js` 不回归
6. **生产灰度**:层 1 上线后对比同问句 total_ms;层 3 上线后"今日退款多少笔"应 <500ms
7. **关闭凭证核验**:工单评论逐条对应验收标准 + commit sha + 实测命令
---
## 验证方案
**后端**:
```bash
cd backend && pytest tests/test_ai_assistant.py -v
# 期望:现有用例全过 + 新增 3 个规则层/段选取用例过
```
**前端**:
```bash
cd web && npm test -- AssistantDrawer.spec.js
# 期望:L105-L166 轮询/失败重试用例不回归
```
**生产灰度**(层 1 后):
```bash
ssh tanzk@192.168.10.21 'cd /opt/csctalon && tail -f logs/backend/app.log | grep "ai task .* done"'
# 期望:llm_ms 从 ~4000 降到 ~2000,total_ms 从 ~5000 降到 ~2500
```
**端到端**(层 3 后):
```bash
curl -X POST http://192.168.10.21:8000/api/v1/ai/analyze \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"question":"今日退款多少笔","period":"daily"}'
# 期望:<500ms 返回 success,不走 LLM
```
---
## 风险汇总
| 风险 | 缓解 |
|---|---|
| 规则层模板数字编造 | 模板取数必须来自 feed,None→"暂无" |
| 精确取数漏段 | refund 整段保留,`a_route.litigation_*` 天然在内 |
| 缓存 key 漂移 | 全部复用 `today:kpi`/`refund:do`/`complaint:*`,勿新建(铁律 #11) |
| 800 token 截尾 | 层 2 输入变小后输出同步收缩;复杂问句仍走 LLM |
| 生产 .env 改动风险 | 改前备份,改后 restart backend,失败可回滚 |
## Critical Files
- [backend/app/api/v1/ai.py](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/ai.py) — 层 3 规则层短路入口
- [backend/app/services/ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py) — 层 2 段选取
- [backend/app/services/ai_focus_service.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_focus_service.py) — 复用 `_KEYWORD_TO_SCOPE`(L31)
- [backend/app/core/config.py](file:///Users/tanzk/dev/csctalon/backend/app/core/config.py) — 层 1 参数默认值(L132-L180)
- [deploy/env.production.example](file:///Users/tanzk/dev/csctalon/deploy/env.production.example) — 层 1 生产示例
- `backend/app/services/ai_rule_engine.py` — 层 3 新增
- [backend/tests/test_ai_assistant.py](file:///Users/tanzk/dev/csctalon/backend/tests/test_ai_assistant.py) — 测试基线 + 新增用例
- `/opt/csctalon/.env`(生产)— 层 1 同步

View File

@@ -1,502 +0,0 @@
# AI 助手数据底座重构:双层结构 + 日颗粒度 + DB 化 Prompt Seed
## Context(为什么改)
工单 #98 性能优化已上线(参数调优 + 段选取 + 规则层),但用户实测"昨日整体经营分析"报告暴露**数据源层 + Prompt 配置化**问题:
1. **专项分布脏数据**:报告显示"内部投诉/外部投诉 暂无(lead 为空)0 会话/0 设备",根因是 [external.py:624-709](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L624) `_reception_specialty` 只过滤 `is_active=True`,但 specialties 表里这两条历史专项(已合并到"投诉专项")的 `is_active` 仍为 True。
2. **数据不同源**:`ai_feed._build_feed` ([ai_feed.py:46-88](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py#L46)) 走 `external._build_brief`,自拼 `_reception_specialty`/`_complaint_enhanced`,与今日值班看板前端用的 `A.today_kpi`/`A.today_reception_breakdown`/`A.today_staff_efficiency` 不同源 → "看板一个数、AI 报告另一个数"。
3. **缓存 TTL bug**:[external.py:419](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L419) `cached_call(..., 60, ...)` 固定 60s,历史日 brief 也 60s 过期重算。
4. **AI 主观推断**:"处理量 238 > 登记量 190 → 在消化存量"是无证据归因。现有 `focus-complaint` seed 里甚至写了"处理滞后于登记说明积压恶化"——这条规则本身就是错的,需要 DB 层修正。
5. **硬编码**:`_BUCKET` 映射 (external.py:633-640) 把专项名硬编码到 today_staff_efficiency 字段名;`LEGACY_COMPLAINT_SPECIALTIES` 常量靠名字硬编码过滤。
6. **业务判定基准缺失**:AI 只列数字不下好坏判断;缺工作时间说明(08:00~23:59,非工作时间 AI 协助);这些本应是运营可改的 prompt seed,但当前写死在代码里就改不了。
用户确认方案:① 日报双层结构(顶层=今日看板原始数据,底层=AI 增量分析);② 周/月/区间按日颗粒度(汇总+日走势);③ 历史日懒加载快照(首次访问重算+24h 缓存,后续命中);④ **业务判定基准/工作时间/防主观推断铁律/AI 增量分析段全部 DB 化(运营可改可排序可启停),不写死代码**;⑤ **数据层去硬编码**(specialty is_active 治理 + efficiency_field 字段);⑥ 一次性完整改造 P0+P1。
**分层原则**(对齐既有设计文档 `docs/design/20260907-ai-assistant-focus-configurable-0.md`):
- **代码层(不可改)**:`_SYSTEM_PROMPT` 防幻觉底线 + `ai_glossary.GLOSSARY` 技术口径定义(三源分类/四路/登记处理口径/时区/金额单位/专项 vs 行政组长)
- **DB 层(可改)**:`ai_focus_configs` 表的 Focus seed(运营关注点/业务判定/工作时间/防主观推断指导/AI 增量分析段)
## 关键文件
- [backend/app/services/ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py) — `_build_feed` 改造 + `_select_feed_sections` 重写 + `_SYSTEM_PROMPT` 兜底铁律 + 周/月 `_range_feed` 新增
- [backend/app/services/ai_glossary.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_glossary.py) — 不改动(已是技术口径代码常量,按设计分层保留)
- [backend/app/api/v1/external.py](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py) — `_reception_specialty` 移除 `_BUCKET` 硬编码改为读 `s.efficiency_field` + `_build_brief` TTL bug (line 419)
- [backend/app/services/cache_warmer.py](file:///Users/tanzk/dev/csctalon/backend/app/services/cache_warmer.py) — 新增 AI 路径 `today:staff:...:200:...` 预热条目
- [backend/app/models/specialty.py](file:///Users/tanzk/dev/csctalon/backend/app/models/specialty.py) — Specialty 模型加 `efficiency_field` 字段
- [backend/app/api/v1/ai.py](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/ai.py#L173) — `/ai/analyze` 入口(不改动,仅对齐参数)
- [backend/app/services/ai_focus_service.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_focus_service.py) — 不改动(现有 `_load_all`/`focus_for_question`/CRUD 已支持 DB 化 prompt seed 注入,新增 seed 自动生效)
复用既有函数:`A.today_kpi` (analytics.py:3480) / `A.today_reception_breakdown` (analytics.py:4305) / `A.today_refund_breakdown` (analytics.py:5457) / `A.today_staff_efficiency` (analytics.py:5825) / `complaints.complaint_summary` / `complaints.complaint_backlog` / `cache_ttl_for` (redis_client.py) / `focus_for_question` (ai_focus_service.py:83)
---
## A. 数据层去硬编码(P0 必做)
### A.1 DB 治理:specialty is_active 标记
通过 alembic migration 把 specialties 表"内部投诉"/"外部投诉"两条记录的 `is_active` 置 False(DB 治理),代码层 `_reception_specialty` 只过滤 `is_active=True` 即可,不再依赖 `LEGACY_COMPLAINT_SPECIALTIES` 常量名硬编码。
**保留 `LEGACY_COMPLAINT_SPECIALTIES` / `COMPLAINT_SPECIALTIES_ALL` 常量**([analytics.py:1457-1461](file:///Users/tanzk/dev/csctalon/backend/app/services/analytics.py#L1457)):用于 SCD 历史归因查询(analytics.py:3087 / 3110),识别"曾经属于投诉类"的专项(包括已合并的),这是合理的口径定义不是硬编码。
### A.2 specialties 表加 `efficiency_field` 字段
[external.py:633-640](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L633) `_BUCKET` dict 把专项名硬编码到 today_staff_efficiency 的 sp_*/pp_* 字段名。改为 DB 字段:
- Specialty 模型加 `efficiency_field: Mapped[str | None]` (String(64), nullable, comment="对应 today_staff_efficiency 的字段名, 格式 'sp_xxx,pp_xxx'")
- Alembic migration 给 6 个正式专项填值:
- 咨询接待 → `sp_zixun,pp_zixun`
- 套餐专项 → `sp_taocan,pp_taocan`
- 退款挽回 → `sp_recovery,pp_recovery`
- 退款处理 → `sp_processing,pp_processing`
- 投诉专项 → `sp_internal,pp_internal`(投诉类合并到此桶)
- 内部投诉/外部投诉 → NULL(is_active=False 不参与)
- `_reception_specialty` 改为从 `s.efficiency_field` 读字段名,移除 `_BUCKET` dict
- 未配置 `efficiency_field` 的专项兜底跳过 sessions/devices 统计(不再硬兜底到"咨询接待"桶,避免错误归并)
### A.3 修复 `_reception_specialty` 兜底逻辑
原 [external.py:645-646](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L645) 注释里说"临时专项兜底到咨询接待桶"——这是错误归并。改为:`efficiency_field` 为空的专项(如临时专项、未配置字段)sessions/devices 显示 0 并在 `_spec` 注释说明"未配置 efficiency_field, 不计入会话/设备统计",不再硬兜底。
---
## B. 数据源切换(P0 必做:日报双层结构)
### B.1 `ai_feed._build_feed` 改造
废弃走 `_build_brief` 路径,直接调今日看板服务函数,与前端今日看板页逐字段对齐。
```python
# ai_feed.py 新 _build_feed(db, period, date, start, end) -> dict
win = _window(period, date, start=start, end=end) # 复用 external._window
s, e = win["start"], win["end"]
today_data = {
"period": win["period"], "date": win.get("date"),
"date_start": win["date_start"], "date_end": win["date_end"],
"generated_at": _now_cn().isoformat(),
"kpi": cached_call(f"today:kpi:None:{s}:{e}", cache_ttl_for(s,e,60), A.today_kpi, db, None, s, e),
"reception": cached_call(f"today:reception:None:{s}:{e}", cache_ttl_for(s,e,60), A.today_reception_breakdown, db, None, s, e),
"refund": cached_call(f"today:refund:None:{s}:{e}", cache_ttl_for(s,e,60), A.today_refund_breakdown, db, None, s, e),
"complaint": _complaint_block(db, s, e), # 调 complaints.summary + backlog 路由本体
"staff": _compact_specialty_staff(db, s, e), # today_staff_efficiency + _compact_specialty 压缩
"_spec": _spec_brief(),
}
```
关键点:
- **同源同缓存**:复用今日看板路由层 key(`today:kpi:None:{s}:{e}` 等),与前端共享 Redis,"与看板完全一致"。
- **TTL 自动感知**:`cache_ttl_for(s, e, 60)` 已实现历史日 24h、今天 60s。
- **`_complaint_block`**:调 `complaints.complaint_summary` + `complaints.complaint_backlog`(已 60s 缓存),与今日看板投诉接口同源。
- **`_compact_specialty_staff`**:调 `A.today_staff_efficiency(topn=200, aggregate_by="person")`(前端默认 topn=30,AI 用 200 拿更多人),再用现有 `_compact_specialty` 压缩 by_specialty 段(去逐人名单)。
- **`today_queue` 不进 daily feed**:实时队列历史日为空,AI 不需要;在 `_spec.today_queue` 注释"实时队列, 历史日为空, AI 应跳过"。
### B.2 顶层 `today_data` 结构示意
```json
{
"period": "daily", "date": "2026-09-14",
"date_start": "2026-09-14T00:00:00+08:00", "date_end": "2026-09-14T23:59:59+08:00",
"generated_at": "2026-09-15T10:00:00+08:00",
"kpi": {
"reception": {"value": 5850, "yoy_value": 6368, "yoy_pct": -8.1, "device_count": 2913, "breakdown": {...}, "ai_takeover": {...}},
"refund_count": {"value": 256, "yoy_value": 230, "yoy_pct": 11.3},
"refund_amount": {"value": 34016.20, "yoy_value": 27372.76, "yoy_pct": 24.3},
"actual_cashflow": {"value": ..., "yoy_value": ...},
"cash_routes_today": {"route_a": {...}, "route_b": {...}, "route_c": {...}, "route_d": {...}, "operational": ..., "diff": ...},
"complaint": {"value": 190, "yoy_value": ...},
"staff_on_duty": {"value": 8, "yoy_value": 9},
"ticket_done": {"value": 200, "real_done_today": 50, "real_done_yoy": 45},
"metric_levels": {"refund_amount": {"level": "warning", "target": ..., "reason": ...}}
},
"reception": {"by_specialty": [{"specialty": "投诉专项", "lead": "洪佳", "lead_role": "专项负责人", "member_count": 5, "sessions": 9, "devices": 5}]},
"refund": {"total_amount": {...}, "routes": {"route_a": {...}, ...}, "a_route": {"litigation_ratio": {...}, "litigation_amount": ...}},
"complaint": {"total": {"registered": 190, "handled": 238, "backlog": 77}, "by_source": [{...}, {...}, {...}]},
"staff": {"by_specialty": [...]},
"_spec": {"money_unit": "元", "complaint": "registered=窗口内新登记...", "today_queue": "历史日为空"}
}
```
### B.3 `_select_feed_sections` 重写
新顶层结构变为 `kpi`/`reception`/`refund`/`complaint`/`staff`。`kpi` 内已含所有指标汇总+环比,单独切 `kpi` 就够答大多数问句。
新映射:
- `scope="reception"` → 保留 `kpi`(只含 reception 相关子字段)+ `reception`
- `scope="refund"` → `kpi`(refund_count/refund_amount/cash_routes_today/actual_cashflow)+ `refund`
- `scope="complaint"` → `kpi`(complaint)+ `complaint`
- `scope=None` → 全量
实现:`_select_feed_sections` 内对 `kpi` 做子字段过滤(按 scope 选 key),其余顶层段按 scope 直接切片。`_CONSTANT_FEED_KEYS` 扩展加入 `kpi`(裁剪后)。
### B.4 `_build_brief` 不删
[external.py:312-325](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L312) `external_brief` 路由对外开放(鉴权+scope 校验+access log),依赖 `_build_brief`。**保留 `_build_brief` 但做最小修复**:修 TTL bug (line 419);`_reception_specialty` 的修复已在 A 节完成(移除 `_BUCKET` + DB is_active 治理自动生效)。`ai_feed` 不再调它即可,二者解耦。
---
## C. 历史日懒加载快照
### C.1 复用 `today:*` 命名空间
不新建 `ai:snapshot:{date}`。理由:
1. `today:*` 已被今日看板前端、`cache_warmer` 预热,历史日首次访问大概率已命中。
2. 设计目标"与看板完全一致"——共享 key 是强约束,隔离反而可能漂移。
3. `cache_ttl_for` 已实现历史日 24h、今天 60s 自动切换。
### C.2 流程
```
1. 用户问"昨天的情况" → ai.py /ai/analyze 解析出 date=昨天
2. _build_feed 调 cached_call("today:kpi:None:2026-09-14T00:00:00:2026-09-14T23:59:59", 24h, A.today_kpi, ...)
3. Redis 未命中 → A.today_kpi 重算(_resolve_windows 内部 date=历史日 → is_realtime=False, 整天窗口)
4. cached_call 写回 Redis TTL=24h
5. 后续访问直接命中缓存, 毫秒级返回
```
无需额外代码,复用 `cache_ttl_for` 即可。
### C.3 `today_queue` 等实时接口
历史日天然返回空(实时队列只看当前未结)。在 `_spec.today_queue` 注释明示"历史日为空,AI 应跳过该段不陈述"。`today_staff_efficiency` 历史日有值(按人聚合窗口内数据),正常用。
---
## D. 周/月/区间按日颗粒度
### D.1 双层结构
```python
range_data = {
"period": "weekly|monthly|range",
"date_start": win.start, "date_end": win.end,
"summary": _range_summary(db, s, e), # 窗口总量(复用 today_kpi 多日模式)
"daily_trend": _daily_trend(db, s, e), # 按日时序数组
"_spec": _spec_brief(),
}
```
### D.2 汇总:复用 `today_kpi` 多日模式
`today_kpi(db, start=s, end=e)` 已支持多日窗口([analytics.py:3491](file:///Users/tanzk/dev/csctalon/backend/app/services/analytics.py#L3491) 调 `_resolve_windows` 走 start+end 分支),返回的 `value` 即窗口总量、`yoy_value` 即上一等长周期总量。**直接复用,不重新做 sum 各日**(避免双计/口径漂移)。
```python
def _range_summary(db, s, e):
return cached_call(f"today:kpi:None:{s}:{e}", cache_ttl_for(s,e,60), A.today_kpi, db, None, s, e)
```
### D.3 日走势数组
```python
def _daily_trend(db, s, e):
days = _days_range(s, e) # 北京时区日历日列表
out = []
for d in days:
ds, de = f"{d}T00:00:00", f"{d}T23:59:59"
k = cached_call(f"today:kpi:None:{ds}:{de}", cache_ttl_for(ds,de,60), A.today_kpi, db, None, ds, de)
c = _complaint_block(db, ds, de) # 复用 complaint 段
out.append({
"date": d,
"reception": k["reception"]["value"],
"devices": k["reception"]["device_count"],
"ai_takeover_pct": k["reception"]["ai_takeover"]["session_pct"],
"refund_count": k["refund_count"]["value"],
"refund_amount": k["refund_amount"]["value"],
"actual_cashflow": k["actual_cashflow"]["value"],
"complaint_registered": c["total"]["registered"],
"complaint_handled": c["total"]["handled"],
"complaint_backlog": c["total"]["backlog"],
})
return out
```
JSON 样例:
```json
"daily_trend": [
{"date":"2026-09-08","reception":1200,"devices":800,"ai_takeover_pct":15.0,"refund_count":40,"refund_amount":9500.00,"actual_cashflow":9000.00,"complaint_registered":12,"complaint_handled":15,"complaint_backlog":28},
{"date":"2026-09-09","reception":1180,"devices":790,"ai_takeover_pct":16.2,"refund_count":38,"refund_amount":8800.00,"actual_cashflow":8600.00,"complaint_registered":10,"complaint_handled":14,"complaint_backlog":24}
]
```
### D.4 月报 30 天 token 控制
30 天 × 9 字段 ≈ 270 数字 + JSON 结构字符 ≈ 2-3k token,可控。**但前提是不带成员名单/专项明细**——日走势只放关键指标,明细走 `summary` 段(一份)。
降级策略(超过 `AI_ANALYZE_MAX_CHARS`):
- 30 天 → 前 14 天逐日 + 后 16 天按周聚合(4 周),结构不变,date 标签换 `W37(09-08~09-14)`。
- 仍超 → 只保留 6 个核心指标(reception / refund_amount / complaint_registered / complaint_handled / complaint_backlog / ai_takeover_pct),砍掉 devices / refund_count / actual_cashflow。
### D.5 与 `today_kpi` weekly/monthly 关系
复用 `today_kpi` 自身的多日模式拿窗口总量(start+end 一把调);日走势是额外的按日循环,不冲突。`today_kpi` 不需要改。
---
## E. Prompt 层 DB 化(核心改动:去硬编码 → ai_focus_configs seed)
### E.1 分层原则(对齐既有设计文档)
- **代码层(不可改)**:`_SYSTEM_PROMPT` 防幻觉底线 + `ai_glossary.GLOSSARY` 技术口径定义
- **DB 层(可改)**:`ai_focus_configs` 表的 Focus seed(运营可改可排序可启停)
### E.2 新增 4 条 ai_focus_configs seed(通过 alembic migration 插入)
**Seed 1: business-benchmark**(业务判定基准)
```python
{
"code": "business-benchmark", "scope_key": "global", "sort_order": 2,
"title": "业务判定基准",
"content": (
"业务判定基准(方向性判据, 用于判定'好/坏'与'风险/机会'): "
"退款越少越好(金额/笔数); 均单价越低越好; 退款单数越少越好; "
"接待量越多越好; 接待会话数越多越好; "
"投诉越少越好; 投诉严重性 外部投诉 > 商户号投诉 > 内部投诉(即外部投诉 1 件 > 商户号 10 件); "
"投诉处理越及时越好(handle - register 间隔越短越好); "
"退款挽回率越高越好(挽回成功/分母); "
"积压投诉越低越好(窗口内登记未闭环). "
"陈述'环比涨跌'后必须按此基准标注'利好/利空', 不得只报数字不评判."
),
}
```
**Seed 2: work-time-ai-assist**(工作时间与 AI 协助)
```python
{
"code": "work-time-ai-assist", "scope_key": "global", "sort_order": 3,
"title": "工作时间与 AI 协助",
"content": (
"客服工作时间 08:00~23:59; 非工作时间(00:00~07:59)由 AI 协助处理. "
"解读'接待量为 0 / 完工单为 0'时, 必须先核对窗口是否落在非工作时间, "
"不得直接判定'无人值班'或'系统故障'. "
"AI 接管率(ai_takeover)是可控指标前置: 接待量可升可控, AI 接管率上升=利好."
),
}
```
**Seed 3: inference-discipline**(防主观推断指导)
```python
{
"code": "inference-discipline", "scope_key": "global", "sort_order": 4,
"title": "防主观推断指导",
"content": (
"防主观推断指导: "
"① 不同口径的数字(如 registered=窗口内新登记 vs handled=窗口内完成含历史单)禁止直接相减或比较大小; "
"必须显式标注口径差异后再下结论. "
"② 禁止做'处理量 > 登记量 → 消化存量'这类无证据因果推断; "
"如需描述关系, 只能说'本期 handled 高于 registered, 差额源于窗口外登记、窗口内办结的历史单'等可验证陈述. "
"③ 数据未提供的归因(如'退款上升因 XX 活动')禁止编造, 一律写'原因需结合运营侧确认'. "
"④ yoy_value/yoy_pct 是上一等长周期汇总, 不是另一天的独立明细, 不得当独立日数据展开陈述."
),
}
```
**Seed 4: ai-incremental-analysis**(AI 增量分析任务段,开放题触发)
```python
{
"code": "ai-incremental-analysis", "scope_key": "global", "sort_order": 5,
"title": "AI 增量分析任务",
"content": (
"当用户问'分析/总览/怎么看/风险/异常'类开放题时(纯取数问句跳过本段), 增量分析任务: "
"- 口径差异提醒: 当数据里出现 registered 与 handled 这类不同口径数字(见口径卡), 必须显式标注口径, 禁止直接相减或推断因果; "
"- 环比异常预警: yoy_pct 超 ±30% 的指标, 必须点名并给出可能原因方向(数据/运营/外部, 不下结论); "
"- 风险与机会: 按 business_benchmark 卡的方向判定(如'退款金额上升=风险'), 每类最多 1 条; "
"- 下钻建议: 给出'看哪个看板卡片/下钻到哪一层'的可执行建议(不重复数据)."
),
}
```
### E.3 更新现有 seed(修正错误口径)
**修正 focus-complaint seed**(现有内容里有"处理滞后于登记说明积压恶化"是错误推断,且专项列表过时):
- 原文:"②登记(窗口新增)与处理(真实办结)要分开看, 处理滞后于登记说明积压恶化"
- 改为:"②登记(窗口新增)与处理(真实办结)是不同口径, 禁止直接相减或推断因果; 处理>登记属正常(含历史单办结), 不必然意味'消化存量'"
**修正 focus-reception seed**(专项列表过时,内部/外部投诉已合并):
- 原文:"②六个专项(咨询/套餐/退款处理/退款挽回/内部投诉/外部投诉)的会话与设备规模"
- 改为:"②五个正式专项(咨询接待/套餐专项/退款处理/退款挽回/投诉专项)的会话与设备规模, 历史专项(内部投诉/外部投诉)已合并到投诉专项"
### E.4 `_SYSTEM_PROMPT` 兜底铁律(不可改,防幻觉底线)
在 [ai_feed.py:23-35](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py#L23) `_SYSTEM_PROMPT` 末尾追加(一句话兜底,详细在 Focus seed):
```
8. 防主观推断底线: 不同口径数字(如投诉 registered/handled)禁止直接相减或做因果推断; 禁止"处理量高→消化存量"这类无证据归因.
9. 业务判定方向: 退款↓利好 / 接待↑利好 / 投诉↓利好 / 挽回↑利好 / 积压↓利好; 比较后必须标注利好/利空.
10. 工作时间 08:00~23:59; 非工作时间由 AI 协助; 解读空值前先核对窗口时段.
```
### E.5 不改动 `ai_glossary.GLOSSARY`
现有 8 个 key(complaint_sources/complaint_reg_handled/refund_routes/refund_litigation/reception_metric/timezone/money_unit/lead_systems)属于技术口径定义,按设计分层保留代码常量。`glossary_for_question` 的"基础卡强制注入"逻辑也保留(路由逻辑不是 seed 内容)。
### E.6 现有 `focus_for_question` 自动加载(无需改服务层)
`ai_focus_service._load_all(db)` ([ai_focus_service.py:43-60](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_focus_service.py#L43)) 已实现 60s 进程缓存 + 按 scope_key 路由 + global 兜底全带。新 seed 写进 DB 后自动被加载,无需改服务层代码。运营通过 `/ai/focus-configs` REST 路由([ai.py:306-329](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/ai.py#L306))增删改启停。
---
## F. 前序修复(P0 必做)
### F.1 修 `today_kpi` 缓存 TTL bug
[external.py:419](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L419):
```python
# 改前
kpi = cached_call(f"today:kpi:None:{start_s}:{end_s}", 60, A.today_kpi, db, None, start_s, end_s)
# 改后
kpi = cached_call(f"today:kpi:None:{start_s}:{end_s}", cache_ttl_for(start_s, end_s, 60), A.today_kpi, db, None, start_s, end_s)
```
第 424 行 `refund_decision_overview` 已正确用了 `cache_ttl_for`,对照即可。修后历史日 brief 不再 60s 过期重算。
### F.2 数据源切换是否自动解决 LEGACY 问题
部分解决:`ai_feed` 改走 `A.today_reception_breakdown` / `A.today_staff_efficiency`(这俩内部按 specialty_id 聚合,不依赖 LEGACY 常量),AI 报告里的专项分布自动干净。但 `external /brief` 仍依赖 `_reception_specialty`,必须靠 A 节的 DB 治理(is_active=False)+ efficiency_field 字段移除 `_BUCKET` 硬编码。
---
## G. 风险与回归
### G.1 `_build_brief` 依赖方
- `external_brief` 路由(external.py:312-325):对外开放,鉴权+access log,**不能删**。
- 修复策略:保留 `_build_brief` 函数体,只修 TTL bug (F.1);`_reception_specialty` 的修复靠 A 节 DB 治理自动生效;`ai_feed` 不再调它即可,二者解耦。
- 回归:`curl -H "Authorization: ..." /api/v1/external/brief?period=daily` 验证专项分布不再出"内部投诉/外部投诉"。
### G.2 LLM token 上限
- 单日 feed:`today_kpi` 全量约 1-2k 字符 + reception/refund/complaint/staff 各几百字符,压缩后约 4-5k 字符(< `AI_ANALYZE_MAX_CHARS=6000` 默认),可控。
- 月报 30 天日走势:约 2-3k token,加 summary 段共约 5-6k token,仍可控。
- 风险点:`today_staff_efficiency` 的 `data` 数组(200 人 × 多字段)会爆。**日走势里不带 staff.data**,只带 `summary.staff_on_duty`;staff 明细只在 daily 模式且 scope=reception 时带(且经 `_compact_specialty` 压缩)。
### G.3 缓存 key 冲突
`ai_feed` 改造后**共享**今日看板的 `today:*` key(设计目标"与看板完全一致"),不冲突。但要注意:
- `today:staff:None:{s}:{e}:200:0:person:None:None:None` 这个 key 与前端今日看板 staff 接口默认参数(topn=30)对齐不一致——AI 用 topn=200 会生成不同 key,**这是设计上的隔离**(AI 要更多人),需在 `cache_warmer.py` 加预热条目(否则 AI 路径冷加载)。
- 不需要在 `ai_feed` 里再做"_compact_specialty 自愈删坏缓存"逻辑([ai_feed.py:62-82](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py#L62))——`today_kpi` 直接调,无 `_build_brief` 的 routes 拼装环节,坏缓存场景消失。但保留自愈无害,可留作安全网。
### G.4 DB 治理的回滚风险
- 把"内部投诉"/"外部投诉"两条记录的 `is_active=False` 是**数据修复**,对应 alembic migration 的 downgrade 应该把 is_active 恢复为 True。
- specialty 表加 `efficiency_field` 字段是 nullable,未配置的专项不影响现有逻辑(_reception_specialty 兜底跳过 sessions/devices 统计)。
- 现有 `COMPLAINT_SPECIALTIES_ALL` 常量仍包含 LEGACY 名字,用于 SCD 历史归因查询——保持不变,不影响。
### G.5 Focus seed 注入顺序
新增 4 条 seed 的 sort_order:focus-global=1 / business-benchmark=2 / work-time-ai-assist=3 / inference-discipline=4 / ai-incremental-analysis=5。`focus_for_question` 按 sort_order 排序注入,确保基准/工作时间/防推断/增量段顺序合理。
---
## H. 实施步骤(一次性完整改造 P0+P1)
### 步骤 1: DB 治理 alembic migration(数据层去硬编码)
新建 alembic migration:
- upgrade:① specialties 表加 `efficiency_field` 字段(String(64), nullable);② 给 6 个正式专项填 efficiency_field 值;③ 把"内部投诉"/"外部投诉"两条记录的 is_active 置 False;④ ai_focus_configs 表插入 4 条新 seed(business-benchmark / work-time-ai-assist / inference-discipline / ai-incremental-analysis);⑤ UPDATE focus-complaint / focus-reception 两条现有 seed 修正口径。
- downgrade:反向操作。
验证:`docker exec csctalon-db psql -U postgres -d csctalon -c "SELECT name, is_active, efficiency_field FROM specialties ORDER BY display_order;"` — 应见 5 条 is_active=True + 2 条 is_active=False;`SELECT code, scope_key, sort_order, enabled FROM ai_focus_configs ORDER BY sort_order;` — 应见 8 条 seed。
### 步骤 2: Specialty 模型加 efficiency_field 字段
改 [backend/app/models/specialty.py](file:///Users/tanzk/dev/csctalon/backend/app/models/specialty.py) Specialty 模型,加 `efficiency_field: Mapped[str | None]` (String(64), nullable, comment="对应 today_staff_efficiency 的字段名, 格式 'sp_xxx,pp_xxx'")。
### 步骤 3: 修 `_reception_specialty` 移除 `_BUCKET` 硬编码
改 [external.py:624-709](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L624) `_reception_specialty`:
- 移除 `_BUCKET` dict (line 633-640)
- 改为从 `s.efficiency_field` 读字段名(如 `sp_zixun,pp_zixun` 分割得到 sp_field/pp_field)
- `efficiency_field` 为空的专项 sessions/devices 显示 0,不再硬兜底到"咨询接待"桶
- 由于 DB 已把"内部投诉"/"外部投诉" is_active=False,`select(Specialty).where(Specialty.is_active.is_(True))` 自动排除,不再需要 `~Specialty.name.in_(LEGACY_COMPLAINT_SPECIALTIES)` 过滤
### 步骤 4: 修 `today_kpi` 缓存 TTL bug
改 [external.py:419](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py#L419):`60` → `cache_ttl_for(start_s, end_s, 60)`。
### 步骤 5: `ai_feed._build_feed` 改造为直接调今日看板服务
改 [ai_feed.py:46-88](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py#L46):
- 替换 `_build_brief` 调用为直接调 `A.today_kpi` / `A.today_reception_breakdown` / `A.today_refund_breakdown` / `complaints` summary/backlog
- 新增 `_complaint_block(db, s, e)` helper(调 complaints 路由本体)
- 新增 `_compact_specialty_staff(db, s, e)` helper(调 today_staff_efficiency topn=200 + _compact_specialty)
- 保留 `_compact_specialty`(仍需压缩 staff by_specialty)
- 重写 `_select_feed_sections` 按 B.3 新映射
### 步骤 6: `_SYSTEM_PROMPT` 追加兜底铁律
改 [ai_feed.py:23-35](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py#L23) `_SYSTEM_PROMPT` 末尾追加铁律 8/9/10(E.4)。
### 步骤 7: 周/月/区间按日颗粒度
改 [ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py) 新增:
- `_range_feed(db, period, start, end)` → 组装 summary + daily_trend + _spec
- `_daily_trend(db, s, e)` → 按日循环调 today_kpi + complaint_block
- `_days_range(s, e)` → 北京时区日历日列表 helper
- `build_messages` 按 period 分流:daily → `_build_feed`;weekly/monthly/range → `_range_feed`
- 月报 token 降级策略(D.4)
### 步骤 8: cache_warmer 预热 AI 路径的 staff topn=200 key
改 [cache_warmer.py](file:///Users/tanzk/dev/csctalon/backend/app/services/cache_warmer.py) `_warm_analytics` 加 `today:staff:None:{s}:{e}:200:0:person:None:None:None` 预热条目。
### 步骤 9: 部署 + 验证
```bash
# 部署到生产
ssh tanzk@192.168.10.21 "cd /opt/csctalon && sudo ./upgrade.sh -git"
# 验证 1: 专项分布修复
ssh tanzk@192.168.10.21 "sudo docker exec csctalon-backend python -c \"
from app.api.v1.external import _reception_specialty
from app.core.database import SessionLocal
db = SessionLocal()
r = _reception_specialty(db, '2026-09-14T00:00:00', '2026-09-14T23:59:59')
print([(s['specialty'], s['sessions']['value'], s['devices']['value']) for s in r])
\""
# 期望:5 条正式专项(咨询接待/套餐专项/退款处理/退款挽回/投诉专项),不再含'内部投诉'/'外部投诉'
# 验证 2: TTL 修复
ssh tanzk@192.168.10.21 "sudo docker exec csctalon-db redis-cli TTL 'today:kpi:None:2026-09-14T00:00:00:2026-09-14T23:59:59'"
# 期望:86400 附近
# 验证 3: Focus seed 注入
ssh tanzk@192.168.10.21 "sudo docker exec csctalon-db psql -U postgres -d csctalon -c \"SELECT code, scope_key, sort_order, enabled FROM ai_focus_configs ORDER BY sort_order;\""
# 期望:8 条 seed(4 条原有 + 4 条新增),全 enabled=true
# 验证 4: AI 报告不再有"消化存量"推断 + 带利好/利空标注
# 前端发"昨日整体经营分析"问句,检查 AI 输出:
# - 含"registered 与 handled 不同口径"显式说明
# - 含利好/利空标注(如"退款金额+24.3% 利空")
# - 不再出现"处理量高→消化存量"推断
# - 含工作时间核对(如"08:00~23:59 工作时间")
# 验证 5: AI 数据与今日看板一致
# 前端今日看板同窗口对比 AI 报告数字
# 验证 6: 周/月报按日颗粒度
# 前端发"本周接待量走势",检查 AI 输出 7 天日走势 + 窗口总量
# 前端发"本月经营总览",检查 AI 输出 30 天日走势或降级为前 14 天 + 后周聚合
# 验证 7: cache_warmer 预热 AI 路径
ssh tanzk@192.168.10.21 "sudo docker exec csctalon-db redis-cli KEYS 'today:staff:None:*:200:0:person:None:None:None'"
# 期望:返回 1 个 key(今日)+ 1 个 key(昨日,历史日 24h 缓存)
```
---
## 关键文件清单
### 必须修改
- /Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py — `_build_feed` 改造 + `_select_feed_sections` 重写 + `_SYSTEM_PROMPT` 兜底铁律 + 周/月 `_range_feed`/`_daily_trend`/`_days_range` 新增
- /Users/tanzk/dev/csctalon/backend/app/api/v1/external.py — `_reception_specialty` 移除 `_BUCKET` 改读 `s.efficiency_field` (line 624-709) + `_build_brief` TTL bug (line 419)
- /Users/tanzk/dev/csctalon/backend/app/models/specialty.py — Specialty 模型加 `efficiency_field` 字段
- /Users/tanzk/dev/csctalon/backend/app/services/cache_warmer.py — 新增 AI 路径 staff topn=200 预热条目
### 新建
- /Users/tanzk/dev/csctalon/backend/alembic/versions/xxx_ai_feed_refactor.py — DB 治理 migration(specialty 字段+数据修复 + ai_focus_configs 新 seed + 现有 seed 修正)
### 不修改(已对齐分层)
- /Users/tanzk/dev/csctalon/backend/app/services/ai_glossary.py — 技术口径代码常量(按设计分层保留)
- /Users/tanzk/dev/csctalon/backend/app/services/ai_focus_service.py — DB 化 Focus 服务(已支持,无需改)
- /Users/tanzk/dev/csctalon/backend/app/api/v1/ai.py — `/ai/analyze` 入口(参数已对齐)

View File

@@ -1,151 +0,0 @@
# AI 报告系统优化方案
## Context
当前 AI 报告系统已完成多 provider 框架(#127-#131):122b 专供预生成、27b 供实时问答。但存在三个核心问题:
1. **对比类报告含当天不完整数据**——周报/月报用"本周一到此刻"做窗口,当天才过了几小时就拿来和昨天全天比,口径不对等
2. **命中=返回全文,未命中=实时跑27b**——用户只想看退款部分却拿到整份报告,或未命中走27b实时分析(质量不如122b预生成草稿)
3. **缺少统一 AI 管理入口**——模板、模型、报告分散在不同页面
用户确认分三批实施,本方案覆盖全部三批。
---
## 批次 A:对比报告截止前一天 + trigger_at 调半夜
### A1. 新增窗口计算模块
**新建** `backend/app/services/ai_report_window.py`:
- `compute_pregen_window(period, ref_date=None) -> dict`:纯计算,无 IO
- weekly: 本周周一 ~ 昨天;若今天是周一→退化为上周一~上周日
- monthly: 本月1日 ~ 昨天;若今天是1号→退化为上月1日~上月末
- daily: 返回 None(日报保持现行 date=None 实时数据)
- 返回 `{start, end, snapshot_date, label}`
- `compute_compare_window(start, end) -> tuple[str, str]`:等长前对比窗口(紧贴 start 之前取同长度天数)
### A2. 改 generate_for_template
**改** [ai_report_worker.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_report_worker.py) L196-211:
weekly/monthly 模板不再走 `build_messages(period=template.period, date=None)`,改为:
- 调 `compute_pregen_window(template.period)` 算出 start/end
- 调 `build_messages(period="range", date=None, start=start, end=end)`
- `_window` 在 start/end 模式下 end < today → `date_end = f"{e}T23:59:59+08:00"` 整天,`is_realtime=False`([external.py](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/external.py) L360-366)
- 落库 `save_report(start=start, end=end, snapshot_date=今日)`,snapshot_date 保持今日让 matcher 命中
- daily 模板保持现行 `date=None`(随同步刷新,当天实时数据不变)
### A3. trigger_at 调半夜 + warm_idle 时段放宽
**改** [ai_report_worker.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_report_worker.py):
- `warm_idle()` L438:移除 `_DAYTIME_HOURS` 限制(当前8-23才扫描,凌晨不触发 trigger_at)
- `warm_on_startup()` L405:保持白天限制(避免重启时大量并发)
- **Alembic 迁移**:`UPDATE ai_report_templates SET trigger_at='1-02:00' WHERE code IN ('weekly_compare_last','monthly_overview')`
- daily_general_morning 保持 `09:00`(refresh_on_sync 覆盖白天实时刷新)
### A4. feed 增对比段(可选,A 批先不接入)
`_build_feed` 已支持 `start/end` 透传([ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py) L92),`_enrich_with_daily_trend` 在 start/end 模式下自动走日走势路径(L124-130)。等长对比窗口在批次 B 随草稿结构化一起接入。
---
## 批次 B:草稿结构化 + 摘取层 + 三级语义命中
### B1. 草稿结构化标记
**段落锚点(全树统一)**:
- `## 接待分析` / `## 退款分析` / `## 投诉分析` / `## 趋势对比` / `## 结论建议`
**改** [ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py) `_SYSTEM_PROMPT` L35-58 第3条:增加"周报/月报必须按 H2 锚点结构化输出"约束。
**Alembic 迁移**:
- `UPDATE weekly_compare_last SET max_tokens=4000`(当前2000不够完整草稿)
- `UPDATE monthly_overview SET max_tokens=5000`
- `UPDATE daily_general_morning SET max_tokens=2500`
### B2. 新增摘取服务
**新建** `backend/app/services/ai_extract_service.py`:
- `split_sections(answer_md) -> dict[str, str]`:按 `## ` H2 锚点切分,兜底按段落分
- `extract_section(db, question, report, template) -> dict`:
1. split_sections 切分草稿
2. 关键词路由:`keywords_to_scopes(question)` → scope → 段落(复用 [ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py) L497 `_select_feed_sections` 同款逻辑)
3. 调 27b(`provider_code=None` 走 default 链,`timeout_s=30`,`max_tokens=1200`):从命中段落摘取+按问句排版
4. 输出 JSON `{result_md, confidence, need_clarify, clarify_options}`
### B3. 新增语义匹配服务
**新建** `backend/app/services/ai_semantic_matcher.py`:
- `semantic_match(db, question, candidates) -> dict`:27b 判定问句与候选报告的语义相关性,输出 `{hit_report, confidence}`
### B4. 改命中链路
**改** [ai.py](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/ai.py) L272-299:
```
L1 关键词匹配(现行 match_template_report 不变)
→ 命中 → 走摘取层(不返回全文)
→ 未命中 ↓
L2 27b 语义匹配(semantic_match, confidence≥0.6 命中)
→ 命中 → 走摘取层
→ 未命中 ↓
L3 兜底: 返回 clarify_options(当日报告 H2 标题列表)
或走原实时 LLM 路径(保留兼容)
```
### B5. 前端改造
**改** [AssistantDrawer.vue](file:///Users/tanzk/dev/csctalon/web/src/components/AssistantDrawer.vue):
- 新增 `status === "clarify"` 状态:渲染 `clarify_options` 为可点按钮,点击后重新发 /ai/analyze
---
## 批次 C:月报逐天小时粒度 + AI 管理页面
### C1. feed 逐天小时粒度
**改** [ai_feed.py](file:///Users/tanzk/dev/csctalon/backend/app/services/ai_feed.py) `_enrich_with_daily_trend`:
- 在 `span > 14`(月报路径)下,对 daily_trend 每条额外调 `cached_call(f"today:hourly:None:{d}:{d}", ..., A.today_hourly_trend, ...)` 取当日 hourly
- 新增 `_compact_daily_hourly_summary(day_hourly) -> dict`:每天只取工作时段合计 + 峰谷小时(~6标量/天 × 30天 = 180字段,~6KB)
### C2. AI 管理页面
**新建** `web/src/views/system/AiAdmin.vue`(5 Tab):
- Tab 1 报告列表:复用 `/admin/ai/reports` API([admin_ai.py](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/admin_ai.py) L160)
- Tab 2 模板管理:内嵌 [AiTemplates.vue](file:///Users/tanzk/dev/csctalon/web/src/views/system/AiTemplates.vue)
- Tab 3 模型/算力节点:内嵌 LlmProviders 管理组件([llm_providers.py](file:///Users/tanzk/dev/csctalon/backend/app/api/v1/llm_providers.py) API)
- Tab 4 提示词管理:内嵌 AiFocusConfigs + 模板 question_template 编辑
- **设计说明三层落地**(AGENTS.md 铁律 7):代码注释 + 页面 ⓘ + review-checklist
**改** `web/src/router/index.js`:新增 `system/ai-admin` 路由
### C3. 维度扩充
先与用户确认扩充清单再实施(AGENTS.md:不确定停下来提问)。
---
## 工单规划
| 工单 | 批次 | 标题 | 关键文件 |
|---|---|---|---|
| #132 | A | 周报/月报预生成窗口截止前一天 | ai_report_worker.py, ai_report_window.py(新) |
| #133 | A | trigger_at 调半夜 + warm_idle 凌晨可触发 | ai_report_worker.py, 迁移 f8g9h0i1j2k3 |
| #134 | B | 草稿结构化标记 + 模板 max_tokens 放宽 | ai_feed.py(_SYSTEM_PROMPT), 迁移 g3h4i5j6k7l8 |
| #135 | B | 摘取层 + 三级语义命中链路 | ai_extract_service.py(新), ai_semantic_matcher.py(新), ai.py, AssistantDrawer.vue |
| #136 | C | 月报逐天小时粒度 | ai_feed.py(_compact_daily_hourly_summary) |
| #137 | C | AI 管理页面(5 Tab) | AiAdmin.vue(新), router/index.js |
| #138 | C | 数据维度扩充(先确认清单) | ai_feed.py |
**依赖**:#132→#133(批次A) | #134→#135(批次B) | #136/#137/#138 独立(批次C)
## 验证方式
每批完成后:
1. **ruff + pytest**:`ruff check backend/ && pytest -k ai`
2. **本地 docker cp 到 csctalon-backend 容器验证**
3. **部署生产**:`ssh tanzk@192.168.10.21 "sudo /opt/csctalon/current/deploy/upgrade.sh --git"`
4. **端到端实测**:
- A:手动触发周报预生成,查 `ai_reports.start/end` 截止昨天、trigger_at=02:00 凌晨自动触发
- B:用户提问"退款情况"→命中预生成→只返回退款段落(非全文);提问模糊→返回 clarify 选项
- C:月报 feed 含 `hourly_summary`;AI 管理页面 5 Tab 可用
5. **凭证评论关闭工单**(逐条可复现核验方式)

View File

@@ -1,174 +0,0 @@
# 投诉与专项优化实施方案
## Context
用户反馈运营看板和人员管理的四个问题:
1. 人员编辑的专项可选择项不全(只有3个硬编码选项),需做成可配置字典
2. 投诉子类分布将内部/商户号/外部平台混在一起,需分开展示
3. 投诉处理人排行只显示内部投诉,需单列外部投诉表
4. 未闭环投诉显示"200条"但列表为空,数据不一致
---
## 问题1: 人员编辑专项选项不全 → 改为动态字典
### 现状
- 后端 `STANDARD_SPECIALTIES` (specialty.py:25-32) 定义6个专项,API `/specialties` 已返回完整列表
- 前端 `Persons.vue` 已加载 `specialtiesList` 用于筛选下拉框(第95行)
- 但任职分配表单(第470-472行)和批量编辑表单(第533-534行)硬编码了3个选项
### 修改
**文件: `web/src/views/system/Persons.vue`**
1. 第470-472行,将硬编码替换为动态选项:
```html
<el-select v-model="assignmentForm.specialty" clearable filterable style="width: 100%">
<el-option v-for="s in specialtiesList" :key="s.id" :label="s.name" :value="s.name" />
</el-select>
```
2. 第533-534行,同样替换:
```html
<el-select v-model="batchForm.specialty" clearable filterable style="width: calc(100% - 100px)">
<el-option v-for="s in specialtiesList" :key="s.id" :label="s.name" :value="s.name" />
</el-select>
```
### 验证
- 打开人员管理 → 点击编辑 → 专项下拉框应显示6个选项(咨询接待/套餐专项/退款处理/退款挽回/内部投诉/外部投诉)
- 批量编辑对话框同样包含完整选项
---
## 问题2: 投诉子类分布 → 按三大类分开三张饼图
### 现状
- 后端 `complaint_subs` 数据已包含 `source_type` 字段(内部投诉/商户号投诉/外部投诉)
- 前端 `complaintSubPieOption` (Today.vue:1437-1486) 已按 source_type 分组着色,但所有子类混在一个饼图中
### 修改
**文件: `web/src/views/dashboards/Today.vue`**
1. 模板中(第227-252行),将单个饼图替换为三列并排:
- 内部投诉子类(商品/服务)→ 蓝色系
- 外部投诉-商户号(微信投诉)→ 紫色系
- 外部投诉-平台(12315/工信部/消协等)→ 红色系
2. 将原 `complaintSubPieOption` 替换为三个 computed:
- `complaintSubInternalPieOption` — 过滤 `source_type === '内部投诉'`
- `complaintSubMerchantPieOption` — 过滤 `source_type === '商户号投诉'`
- `complaintSubExternalPieOption` — 过滤 `source_type === '外部投诉'`
3. 提取公共饼图构建函数 `buildSubPieOption(rows, colorPalette, sourceLabel)` 避免重复
4. 投诉根因柱图移至投诉处理人排行上方独占一行
### 验证
- 运营看板选择有投诉数据的日期,确认显示三个独立饼图
- 内部投诉饼图:商品投诉、服务投诉
- 商户号投诉饼图:微信投诉
- 外部平台投诉饼图:全国12315平台、工信部投诉、公共服务平台、消费者协会
---
## 问题3: 投诉处理人排行 → 拆分为内部/外部两表
### 现状
- 后端 SQL(analytics.py:3249-3319)UNION ALL 三张表,返回 `source_type` 区分来源
- 前端显示单个表格,包含"按来源"列(内部/外部/卡商)
- 用户要求因数量级不同分开展示
### 修改
**后端: `backend/app/services/analytics.py`**
1. 在现有 handler 聚合逻辑(第3323-3366行)之后,新增按 source_type 拆分:
- `complaint_internal_handlers` — 仅 `source_type == '内部投诉'`
- `complaint_external_handlers` — `source_type == '外部投诉'` + `'卡商群投诉'`
2. 在返回字典中(第3654-3665行)新增两个字段:
- `complaint_internal_handlers`
- `complaint_external_handlers`
- 保留原 `complaint_handlers` 向后兼容
**前端: `web/src/views/dashboards/Today.vue`**
3. complaintData 初始化(第2121行)新增字段:
- `complaint_internal_handlers`
- `complaint_external_handlers`
4. 模板中(第265-332行)替换为两个并列表格:
- 左:内部投诉处理人排行(Top 15)
- 右:外部投诉处理人排行(Top 15)
### 验证
- 运营看板确认显示两个独立处理人排行表
- 内部表只含 service_tickets 内部投诉处理人
- 外部表含 external_complaints + card_complaints 处理人
---
## 问题4: 未闭环投诉列表为空
### 现状
- 用户看到"未闭环 200条"标签但列表无数据
- 后端 `complaint_pending_total` 和 `complaint_pending_list` 来自同一变量 `complaint_pending`,理论上应一致
- `_INCLUDE_COMPLAINT_FILES = False`(analytics.py:17)影响 complaint_sub/roots 聚合,但不影响 complaint_pending 的三表直接查询
### 修改
**后端: `backend/app/services/analytics.py`**
1. 在 complaint_pending 构建完成后(第3644行),增加数据一致性校验日志:
```python
import logging
_log = logging.getLogger(__name__)
if complaint_pending_total > 0 and len(complaint_pending) == 0:
_log.warning(f"complaint_pending 数据不一致: total={complaint_pending_total}, list空")
complaint_pending_total = 0
```
2. 在 complaint_pending 查询的 external_complaints/card_complaints 部分(第3495行前)增加注释,说明这些查询不受 `_INCLUDE_COMPLAINT_FILES` 影响
**前端: `web/src/views/dashboards/Today.vue`**
3. complaintData 赋值(第2129-2130行)增加防御性校验:
```javascript
complaint_pending_total: rdata.complaint_pending_total ?? null,
complaint_pending_list: (() => {
const list = rdata.complaint_pending_list || []
const total = rdata.complaint_pending_total
if (total > 0 && list.length === 0) {
console.warn('[complaint_pending] 数据不一致: total=' + total + ', list为空')
}
return list
})(),
```
4. 模板中(第369-376行之后)增加边界情况提示:当 `complaint_pending_total > 0` 但 `complaint_pending_list` 为空时,显示警告提示并提供刷新按钮
### 验证
- 清除浏览器缓存后重新加载运营看板
- 选择今日视图,确认"未闭环 N 条"标签与下方列表数据一致
- 若不一致,应看到新增的警告提示(而非空白表格)
- 检查后端日志,确认无 `complaint_pending 数据不一致` 警告
---
## 变更文件汇总
| 文件 | 问题 | 变更类型 |
|------|------|----------|
| `web/src/views/system/Persons.vue` | 问题1 | 修改第470-472行、第533-534行,硬编码→动态选项 |
| `web/src/views/dashboards/Today.vue` | 问题2 | 重构第227-252行模板,新增3个computed替代原pie |
| `web/src/views/dashboards/Today.vue` | 问题3 | 拆分第265-332行处理人表,新增complaintData字段 |
| `web/src/views/dashboards/Today.vue` | 问题4 | 第2129-2130行防御性校验,新增边界提示模板 |
| `backend/app/services/analytics.py` | 问题3 | 新增按source_type拆分的handler聚合+返回字段 |
| `backend/app/services/analytics.py` | 问题4 | 数据一致性校验日志+注释 |
## 实施顺序
1. **问题1** — 最简单,仅改前端两处模板,无后端依赖
2. **问题4** — 防御性修改,后端+前端增加校验,不影响现有功能
3. **问题3** — 后端拆分handler聚合 + 前端新增表格,前后端协调
4. **问题2** — 前端重构饼图区域,UI布局调整较大,在问题3基础上叠加

View File

@@ -1,379 +0,0 @@
# 运营指标阈值化与预警通知机制 设计方案
> 版本: v0.3 · 2026-08-21
> 状态: M1~M3 已实施(最小闭环), M4 通道架构已就绪(站内信已接, 群机器人/邮件可配置接入), M5 扩展待定
> 范围: 指标注册表 + 多级阈值(优秀/达标/预警/告警) + 预警引擎 + 通知通道抽象
## 0. 实施状态(2026-08-21)
| 里程碑 | 状态 | 说明 |
|---|---|---|
| M1 指标与阈值基础 | ✅ 已实施 | 5 个指标注册(recovery_rate/refund_count/ticket_backlog/external_complaint_7d/internal_complaint_daily) + 四级阈值(metric_thresholds 留痕) |
| M2 预警引擎 | ✅ 已实施 | scan_alerts 注册表驱动: 四级分级/区间幂等/升级/恢复自愈/数据健康检查; 定时每小时自动扫描 |
| M3 站内通知 | ✅ 已实施 | 告警触发/恢复 → 站内信(notify) + 投递记录(alert_deliveries) |
| M4 推送通道 | 🟡 架构已就绪 | notification_channels 表 + Channel 抽象(站内信已接; webhook 群机器人/邮件通道类已实现, 配置即用); 前端通道管理+测试按钮 |
| M5 扩展联动 | ⏳ 待定 | 指标扩充/聚合/与 Incident/Measure 联动 |
**实现与设计差异(§8.1)**: 告警记录复用现有 `alerts` 表(扩展 metric_code/metric_value/threshold_value/period_start/period_end/data_ok/recovered_at 字段), 未新建 alert_events 表。
**新增后端文件**: `app/models/metric.py` / `app/models/notification_channel.py` / `app/services/metric_registry_service.py` / `app/services/notification_channel.py` / `app/api/v1/metrics.py` / `app/api/v1/channels.py`; 迁移 `n9a8b7c6d5e4f_add_metric_alerting.py`(含指标/阈值/站内信种子数据)。
**前端**: `web/src/views/management/Alerts.vue` 升级为四 Tab(指标水位/预警规则/预警记录/通知通道); 新增 `web/src/api/metric.js`、`web/src/api/channel.js`。
---
## 1. 背景与目标
当前系统的看板(运营看板/今日看板/投诉分析)已实时计算大量运营指标(退款挽回率、三源投诉、积压工单、首响时长等),但**指标的"好坏"完全靠人肉看数字判断**——没有"低于多少要盯、低于多少要报警"的系统化机制。
目标:
1. **指标化监测**: 运营指标有明确的"正常/预警/告警"水位线,系统按周期自动评估
2. **阈值参数化**: 优秀/达标/预警/告警四级阈值全部由系统参数配置(可改、可留痕),不硬编码
3. **预警自动生成**: 指标越线自动生成预警记录,走完整生命周期(待确认→已确认→已恢复)
4. **通知多渠道**: 预警触发后站内信通知(已有),并预留企业微信/钉钉/飞书群机器人、邮件等推送通道(可扩充)
核心原则:**预警监测的指标口径必须与看板同源同口径**,先有指标注册表,再有阈值与规则,避免"看板一个数、预警另一个数"。
---
## 2. 现状盘点
### 2.1 已有基础(可复用)
| 模块 | 位置 | 现状 |
|---|---|---|
| 预警规则/记录 | `app/models/alert.py` AlertRule/Alert | 已有: name/type/condition(JSON)/threshold/enabled; 记录含 level/status(pending/confirmed/ignored) |
| 预警扫描 | `app/api/v1/alerts.py` scan_alerts | 4 种硬编码规则(退款激增/工单积压/事件激增/投诉激增), 单一 threshold, level 由 threshold×2 推断, **当日幂等** |
| 定时调度 | `app/services/bms_scheduler.py` | 已有每小时预警扫描钩子(当日幂等), 可复用 |
| 站内信 | `app/services/notifier.py` notify() | 统一入口, 支持按用户/角色, Notification 表, 前端有角标 |
| 前端预警中心 | `web/src/views/management/Alerts.vue` | 规则 CRUD + 预警记录 + 手动扫描 + 确认/忽略 |
| 指标计算 | `app/services/analytics.py` | 各看板指标已实时计算(如 refund recovery), 口径已沉淀 |
### 2.2 缺口
1. **阈值单一数值** → 无法表达"达标(绿)/预警(黄)/告警(红)"三级水位
2. **规则类型硬编码** → 加一个新指标预警必须改 scan_alerts 代码, 不可配置
3. **只支持"≥阈值"的激增类判断** → 无法表达"挽回率 < 60%"这类"越低越差"指标
4. **指标无注册表** → 口径散落, 规则无法引用统一指标定义
5. **预警只入库不通知** → 不调 notify(), 无任何推送通道
6. **无告警生命周期闭环** → 无"恢复/解除"概念, 无效告警会堆积
7. **无数据健康保护** → SCRM 同步失败导致指标=0 会误报"恶化告警"
---
## 3. 总体架构
```
┌──────────────────────────────────────────────────────┐
│ 指标注册表 (metric_registry) │
│ 每个指标: code / 口径函数 / 周期 / 方向 / 依赖数据源 │
└───────────────┬──────────────────────────────────────┘
│ 引用
┌───────────────▼──────────────────────────────────────┐
│ 阈值参数 (metric_thresholds) │
│ metric_code + 优秀线/达标线/预警线/告警线 + 生效区间 │
└───────────────┬──────────────────────────────────────┘
│ 计算
┌───────────────▼──────────────────────────────────────┐
│ 预警引擎 (alert_engine) │
│ 周期扫描 → 数据健康检查 → 指标求值 → 与阈值比较 │
│ → 幂等/升级/静默 → 生成告警记录 │
└───────────────┬──────────────────────────────────────┘
│ 触发
┌───────────────▼──────────────────────────────────────┐
│ 通知分发 (notification_channel) │
│ 站内信(先) → 企业微信/钉钉/飞书群机器人 → 邮件(后) │
└──────────────────────────────────────────────────────┘
```
分层职责:
- **指标注册表**: 定义"算什么"(口径), 是唯一事实源, 看板与预警共用
- **阈值参数**: 定义"多好算好"(水位线), 可配置可留痕
- **预警引擎**: 定义"何时喊"(触发), 与指标/阈值解耦, 新增指标无需改引擎
- **通知通道**: 定义"喊给谁、怎么喊", 通道可插拔
---
## 4. 指标注册表(核心)
### 4.1 设计要点
- 每个指标注册一条元数据:**计算函数** + **周期** + **方向** + **依赖数据源**
- 指标值由统一求值器调用注册的计算函数得到, **与看板共用同一函数**(同源同口径)
- 注册方式: 代码注册(函数式) + 数据库元数据(阈值引用), 避免重复实现
### 4.2 首批指标清单(试点范围)
| code | 指标名 | 方向 | 周期 | 依赖数据源 | 口径来源 |
|---|---|---|---|---|---|
| recovery_rate | 退款挽回率 | 越高越好 | 日 | SCRM cs_record | analytics 退款挽回计算 |
| refund_count | 当日退款笔数 | 越低越好 | 日 | refund | 看板 refund kpi |
| ticket_backlog | 待处理投诉工单积压 | 越低越好 | 日 | service_tickets | 投诉 backlog.total |
| external_complaint_7d | 近7天外部投诉量 | 越低越好 | 日 | external_complaints | 投诉 supplementary |
| internal_complaint_daily | 当日内部投诉量 | 越低越好 | 日 | service_tickets | 投诉 internal.total |
| first_response_sec | 首次响应时长(均值) | 越低越好 | 日 | scrm_session | SCRM 绩效 |
| transfer_rate | 会话转接率 | 双向 | 日 | scrm_session_transfer | SCRM 绩效 |
| sync_health | 数据同步成功率 | 越高越好 | 日 | scrm_sync_log/bms_sync_log | 同步日志 |
> 指标清单按需扩充: 值班在线率、工作效率值、四路退款占比、套餐充值量等。
> 重要:**先有指标, 后有阈值**; 未注册的指标不参与预警。
---
## 5. 三级阈值与规则模型
### 5.1 阈值语义(四级: 优秀/达标/预警/告警)
| 级别 | 含义 | 运营动作 | 颜色 |
|---|---|---|---|
| 优秀(excellent) | 指标超出预期, 明显优于正常水位 | 通报表扬, 提炼可复制的经验 | 蓝 |
| 达标(green) | 指标处于正常区间 | 无需处理 | 绿 |
| 预警(yellow) | 指标偏离正常,需关注 | 盯一盯,排查原因 | 黄 |
| 告警(red) | 指标严重偏离,需干预 | 立即处理,升级 | 红 |
- **越高越好型**(如挽回率): 阈值 = 下限线。`value ≥ 优秀线` → 优秀; `value < 预警线` → 预警; `value < 告警线` → 告警; 其余 → 达标
- **越低越好型**(如退款笔数/积压): 阈值 = 上限线。`value ≤ 优秀线` → 优秀; `value > 预警线` → 预警; `value > 告警线` → 告警; 其余 → 达标
- **优秀线可选**: 不配置优秀线则退化为"达标/预警/告警"三级, 不产生"优秀"状态
- **仅 预警/告警 触发告警记录与通知**; 优秀/达标仅展示水位状态(优秀可另行配置"通报表扬"通知, 默认不推)
- 方向由指标注册表声明, 阈值表只存数值, 引擎按方向解释
### 5.2 规则 = 指标 × 阈值 × 调度
规则配置项:
```
metric_code 引用指标注册表
warning_threshold 预警线
critical_threshold 告警线
period 日/周/月
enabled 是否启用
channels 通知通道列表(默认站内信)
recipient_roles 通知角色(admin/supervisor)或指定用户
silence_window 静默时段(如 00:00-08:00 不推送,可选)
```
> 兼容现有 AlertRule: 规则仍存 alert_rules 表, 字段扩展(见 §8), 现有 4 类硬编码规则迁移为注册表指标+阈值。
---
## 6. 预警引擎
### 6.1 扫描流程
```
1. 数据健康检查(防假告警)
- 指标依赖的数据源今日同步是否成功(sync_health)
- 数据量是否合理(如 SCRM 会话量低于前3日均值 20% → 视为数据异常)
- 数据异常 → 跳过该指标, 生成"数据异常"提示(不产生告警)
2. 指标求值(调用注册表计算函数, 与看板同口径)
3. 与多级阈值比较 → 确定级别(优秀/达标/预警/告警; 无越线时若之前有告警则生成"已恢复"记录)
4. 幂等去重: 同规则同周期同级别最多一条 pending
5. 升级: 连续 N 个周期处于预警且未处理 → 升级为告警
6. 生成 Alert 记录 + 通知分发(§7)
```
### 6.2 关键规则
| 规则 | 说明 |
|---|---|
| 当日幂等 | 同规则同周期同级别不重复生成(复用现有机制) |
| 连续恶化升级 | 预警持续 N 天未处理且指标继续恶化 → 自动升级告警 |
| 恢复自愈 | 指标回到达标线 → 自动生成/更新"已恢复"状态并通知 |
| 静默窗口 | 配置的时段内不推送(仅入库), 避免夜间打扰 |
| 数据保护 | 数据不完整/异常时不告警, 单独标记(防假告警, 见 6.3) |
| 批量聚合 | 同批多条同类预警合并为一条摘要(可选, 后期) |
### 6.3 数据健康保护(重要)
- 预警求值前先跑 `sync_health` 检查: 今日 SCRM/BMS 是否同步成功
- 指标值出现"断崖式"变化(如为 0 或骤降 80%)时, 先判定为数据异常而非业务恶化
- 数据异常与业务告警分开展示, 避免"假告警"干扰决策
---
## 7. 通知通道抽象
### 7.1 通道接口
```python
class NotificationChannel(Protocol):
def send(self, ctx: AlertContext, recipient: list) -> ChannelResult: ...
```
- `站内信(InAppChannel)` — 复用现有 `notifier.notify()`, **首发实现**
- `企业微信群机器人 / 钉钉 / 飞书群机器人(WebhookChannel)` — 二期
- `邮件(EmailChannel)` — 三期
### 7.2 配置模型
- 通道注册表: `notification_channels` 表, 每个通道有 type + config(webhook URL/密钥, 加密存储)
- 规则级配置: 每条规则选择通知通道 + 接收对象(角色/指定用户)
- 测试能力: 每个通道提供"发送测试"按钮(前端), 验证配置可用
- 发送失败降级: 群机器人失败 → 自动降级为站内信, 并记发送日志
### 7.3 通知去重
- 同一告警不重复推送: 以 Alert.id 为幂等键, 推送状态记录在 alert_deliveries 表
- 恢复通知与触发通知各推送一次
---
## 8. 数据模型(表结构草案)
### 8.1 新建
```sql
-- 指标注册表(元数据; 计算函数在代码层注册)
CREATE TABLE metric_registry (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) UNIQUE NOT NULL, -- 指标code, 与代码注册对应
name_cn VARCHAR(128) NOT NULL,
direction VARCHAR(8) NOT NULL, -- higher_better / lower_better
period VARCHAR(8) NOT NULL DEFAULT 'day', -- day/week/month
data_sources JSONB DEFAULT '[]', -- 依赖数据源, 供健康检查
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 阈值参数(四级水位: 优秀/达标/预警/告警, 可留痕)
CREATE TABLE metric_thresholds (
id BIGSERIAL PRIMARY KEY,
metric_code VARCHAR(64) REFERENCES metric_registry(code),
excellent_value DOUBLE PRECISION, -- 优秀线(可选, 不配则无优秀状态)
warning_value DOUBLE PRECISION NOT NULL, -- 预警线
critical_value DOUBLE PRECISION NOT NULL, -- 告警线
unit VARCHAR(16), -- % / 笔 / 秒 ...
valid_from TIMESTAMPTZ, -- 生效区间(支持历史调参留痕)
valid_to TIMESTAMPTZ,
updated_by INTEGER REFERENCES users(id),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 告警记录(扩展现有 alert_alerts 或新建 alert_events)
CREATE TABLE alert_events (
id BIGSERIAL PRIMARY KEY,
metric_code VARCHAR(64),
rule_id BIGINT, -- 关联 alert_rules
level VARCHAR(16), -- warning / critical / recovered
metric_value DOUBLE PRECISION,
threshold_value DOUBLE PRECISION,
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
status VARCHAR(16) DEFAULT 'pending', -- pending/confirmed/ignored/recovered
data_ok BOOLEAN DEFAULT TRUE, -- 数据健康标记(防假告警)
detail TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 通知通道配置
CREATE TABLE notification_channels (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(32) UNIQUE NOT NULL, -- inapp / wecom_robot / dingtalk_robot / feishu_robot / email
name_cn VARCHAR(64),
channel_type VARCHAR(16),
config JSONB, -- webhook等, 敏感字段加密
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 通知投递记录(幂等)
CREATE TABLE alert_deliveries (
id BIGSERIAL PRIMARY KEY,
alert_id BIGINT,
channel VARCHAR(32),
status VARCHAR(16), -- success / failed / degraded
detail TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 指标历史值(回溯"为什么触发/持续多久")
CREATE TABLE metric_daily_snapshots (
id BIGSERIAL PRIMARY KEY,
metric_code VARCHAR(64),
value DOUBLE PRECISION,
period_date DATE,
level VARCHAR(16), -- excellent/green/warning/critical
UNIQUE (metric_code, period_date)
);
```
### 8.2 复用与兼容
- `alert_rules` 扩展字段: metric_code / warning_value / critical_value / channels / recipient_roles / silence_window(JSONB), 现有 4 类规则迁移
- `Alert` 记录增加 metric_code/level 对齐(或迁移到 alert_events)
- 前端 Alerts.vue 扩展: 规则管理升级为"指标+阈值"配置, 预警记录增加级别/恢复状态
---
## 9. API 与前端规划
### 9.1 API(后端)
```
GET /api/v1/metrics 指标注册表列表
GET /api/v1/metrics/{code}/current 指标当前值+当前级别(前端展示水位)
PUT /api/v1/metrics/{code}/thresholds 配置/修改三级阈值(留痕)
POST /api/v1/alerts/scan 手动触发扫描(已有, 扩展)
POST /api/v1/alerts/{id}/recover 手动标记恢复
GET /api/v1/alerts/events 告警事件列表(含数据异常)
GET /api/v1/channels 通道列表
POST /api/v1/channels/{code}/test 发送测试通知
```
### 9.2 前端(Alerts.vue 升级)
1. **指标水位视图**: 指标列表 + 当前值 + 优秀/达标/预警/告警状态徽标 + 趋势
2. **阈值配置**: 多级阈值可视化编辑, 修改留痕
3. **告警事件流**: 级别/状态/恢复时间/数据异常标记
4. **通道管理**: 启停 + 测试发送
5. **通知中心**: 已有站内信角标复用; 预警类通知可点击跳转预警详情
---
## 10. 与现有模块联动
| 联动 | 说明 |
|---|---|
| 影响事件(Incident) | 高严重度告警(告警级)可一键/自动生成影响事件, 今日看板横幅已支持 |
| 管理措施(Measure) | 告警可转"管理措施"或"待办", 形成"发现→措施→回看"闭环 |
| 数据同步(sync) | 预警依赖数据同步; 同步失败本身也是监测指标(sync_health) |
| 工作台 | 待处理告警进入工作台待办, 管理端可见 |
---
## 11. 实施里程碑
| 阶段 | 内容 | 交付物 |
|---|---|---|
| M1 指标与阈值基础 | metric_registry + metric_thresholds 表; 指标求值器(复用 analytics 函数); 首批 3~5 个指标注册(含退款挽回率) | 指标列表 API + 当前值/级别 |
| M2 预警引擎 | 扩展 scan_alerts 为注册表驱动; 三级阈值比较; 当日幂等 + 恢复自愈; 数据健康检查; 定时扫描接入 | 告警事件 + 恢复通知 |
| M3 站内通知 | 告警生成/恢复 → notify() 站内信; 前端 Alerts.vue 升级(水位视图+阈值配置+事件流) | 站内信闭环 |
| M4 推送通道 | notification_channels 表 + 通道抽象 + 企业微信/钉钉/飞书群机器人 + 邮件; 测试发送; 失败降级 | 多通道推送 |
| M5 扩展 | 指标清单扩充; 连续升级/聚合; 与 Incident/Measure 联动 | 运营指标全覆盖 |
> M1~M3 是"最小闭环"(用户先只做站内信, 推送通道架构先抽象、后续接入)。
---
## 12. 风险与权衡
| 风险 | 应对 |
|---|---|
| 指标口径与看板不一致 | 指标注册表强制复用看板计算函数, 不另写 |
| 假告警(数据未同步) | 数据健康检查前置, 数据异常与业务告警分离 |
| 告警风暴/打扰 | 当日幂等 + 静默窗口 + 恢复自愈 + (后期)聚合 |
| 硬编码规则迁移成本 | 现有 4 类规则映射到注册表指标, 阈值表迁移, 保留 condition 字段兼容 |
| 阈值配置失误 | 阈值修改留痕(valid_from/valid_to + updated_by), 可回看可回滚 |
| 推送通道失败 | 发送失败降级站内信, 投递记录可查 |
| 性能 | 指标求值缓存(复用 Redis), 日终批量计算 + 快照表 |
---
## 待评审问题
1. 首批指标清单(§4.2)是否合理? 是否有遗漏的高优先级指标?
2. ~~三级阈值是否够用~~ → **已确认改为四级(优秀/达标/预警/告警)**; 是否再引入"红线"级(合规/安全等硬性底线)待定, 运营指标暂不引入
3. 告警是否需要在运营看板/今日看板顶部展示(类似影响事件横幅)?
4. M1~M3 最小闭环是否按此顺序推进?

File diff suppressed because it is too large Load Diff

View File

@@ -1,191 +0,0 @@
# 按天匹配专项的工作量分计算改造
## Context
当前人均效率计算在 `today_staff_efficiency` 中,使用 `Person.specialty`(单一当前值)判定所有工作桶的"本职/转接"系数。如果人员在查询周期内变更了专项(如 8月1-10日咨询接待 → 8月11日起退款挽回),旧工作会被错误地按新专项系数计算。
改造目标:按天匹配专项,对查询周期内每一天使用该天实际生效的专项系数。
## 方案:按天加权平均系数
### 核心思路
不改变 SQL 聚合粒度(仍是按人汇总),而是在 Python 侧预建**按天专项映射表**,对每个专项桶计算**天数加权平均系数**。
**前提假设**:工作分布在各天大致均匀(对于多天查询,这个近似足够准确;对于单天查询,系数完全精确)。
### 具体步骤
#### 1. 预建按天专项映射表
在 `today_staff_efficiency` 的工作量计算循环之前(约 line 4801),新增一段代码:
```python
# 1. 收集所有有工作记录的人员的 person_id
work_person_ids = set()
for r in rows:
if r.person_id:
work_person_ids.add(r.person_id)
# 2. 查询 PersonSpecialtyAssignment,获取查询周期内每天的专项
from datetime import date as _date_cls, timedelta
query_start = ts.date() if hasattr(ts, 'date') else _date_cls.fromisoformat(str(ts)[:10])
query_end = te.date() if hasattr(te, 'date') else _date_cls.fromisoformat(str(te)[:10])
# 生成日期列表
date_list = []
d = query_start
while d <= query_end:
date_list.append(d)
d += timedelta(days=1)
# 一次性查出所有 work_person_ids 的专项分配历史
specialty_assignments = db.execute(
select(
PersonSpecialtyAssignment.person_id,
PersonSpecialtyAssignment.effective_from,
PersonSpecialtyAssignment.effective_to,
Specialty.name.label("specialty_name"),
Specialty.difficulty_weight,
)
.select_from(PersonSpecialtyAssignment)
.join(Specialty, PersonSpecialtyAssignment.specialty_id == Specialty.id)
.where(
PersonSpecialtyAssignment.person_id.in_(work_person_ids),
PersonSpecialtyAssignment.is_primary == True,
# 专项分配区间与查询区间有交集
or_(
PersonSpecialtyAssignment.effective_to.is_(None),
PersonSpecialtyAssignment.effective_to >= query_start,
),
PersonSpecialtyAssignment.effective_from <= query_end,
)
).all()
# 3. 构建 person_id → {date: specialty_name} 映射
person_daily_specialty: dict[int, dict[_date_cls, str]] = {}
for sa in specialty_assignments:
pid = sa.person_id
sp_name = sa.specialty_name
eff_from = sa.effective_from
eff_to = sa.effective_to or query_end
if pid not in person_daily_specialty:
person_daily_specialty[pid] = {}
for d in date_list:
if eff_from <= d <= eff_to:
# 如果同一天有多个专项分配,取 is_primary=True 的(上面已过滤)
person_daily_specialty[pid][d] = sp_name
# 4. 构建 person_id → {specialty_name: days_count}
person_specialty_days: dict[int, dict[str, int]] = {}
for pid, daily_map in person_daily_specialty.items():
counts: dict[str, int] = {}
for sp in daily_map.values():
counts[sp] = counts.get(sp, 0) + 1
person_specialty_days[pid] = counts
```
#### 2. 改造工作量分系数计算
在现有循环中(约 line 4864-4887),将单一 `hr_cat` 替换为按桶计算的加权系数。
**修改前**(line 4864-4887):
```python
hr_cat = _hr_map(r.sp, position)
mgmt = (position in ("主管", "组长", "副主管")) or hr_cat == "管理层"
for (bucket_key, cat_name, sp_set) in CAT_BUCKETS:
sv, pv = cat_data[bucket_key]
if sv == 0 and pv == 0:
continue
if mgmt:
own = True
coef = CAT_DEFAULT_WEIGHT[cat_name]
elif hr_cat is not None and hr_cat in sp_set:
own = True
coef = WEIGHT[hr_cat]
else:
own = False
coef = TRANSFER_PENALTY
wp = sv * coef
work_score += wp
```
**修改后**:
```python
position = r.pos or ""
# 管理岗判定不变
mgmt = (position in ("主管", "组长", "副主管"))
# 当前主专项(用于展示,不用于计算)
hr_cat = _hr_map(r.sp, position)
# 获取该人员的按天专项分布
pid = r.person_id
sp_days = person_specialty_days.get(pid, {}) if pid else {}
total_days = sum(sp_days.values()) if sp_days else 0
for (bucket_key, cat_name, sp_set) in CAT_BUCKETS:
sv, pv = cat_data[bucket_key]
if sv == 0 and pv == 0:
bd[cat_name] = {...}
continue
if mgmt:
# 管理岗: 跨类豁免,用大类默认系数(不变)
own = True
coef = CAT_DEFAULT_WEIGHT[cat_name]
elif total_days > 0:
# 按天加权平均系数
weighted_coef = 0.0
own_days = 0
for sp_name, days in sp_days.items():
sp_cat = _hr_map(sp_name, position)
if sp_cat and sp_cat in sp_set:
weighted_coef += WEIGHT.get(sp_cat, 1.0) * days
own_days += days
else:
weighted_coef += TRANSFER_PENALTY * days
coef = weighted_coef / total_days
own = own_days > total_days / 2 # 大部分天数是本职则标记为本职
else:
# 无按天数据,回退到原逻辑
if hr_cat is not None and hr_cat in sp_set:
own = True
coef = WEIGHT.get(hr_cat, 1.0)
else:
own = False
coef = TRANSFER_PENALTY
wp = sv * coef; ep = pv * coef
work_score += wp; eff_score += ep
bd[cat_name] = {
"sessions": sv, "people": pv,
"weight": round(coef, 3), "coef": round(coef, 3), "is_own": own,
"work_contrib": round(wp, 1), "eff_contrib": round(ep, 1),
}
```
### 关键设计决策
1. **天数加权平均**:假设工作在各天均匀分布,用天数比例加权。对于单天查询,结果完全精确;对于多天查询,误差在可接受范围。
2. **管理岗不变**:主管/组长/副主管的跨类豁免逻辑不变,仍使用 `CAT_DEFAULT_WEIGHT`。
3. **回退兼容**:当 `PersonSpecialtyAssignment` 无数据时(新人/未分配专项),回退到原单一 `hr_cat` 逻辑。
4. **性能**:一次 SQL 查询 + Python 字典构建,不增加 SQL 查询次数。
## 变更文件
| 文件 | 变更 |
|------|------|
| `backend/app/services/analytics.py` | 在 `today_staff_efficiency` 函数中新增按天专项映射构建 + 改造系数计算逻辑 |
## 验证
1. 修改覃克泳的专项:8月1-10日=咨询接待,8月11日起=退款挽回
2. 查询8月1-14日人均效率,确认:
- 咨询接待桶的系数接近 1.0(大部分天数本职)
- 退款挽回桶的系数接近 2.2(大部分天数本职)
3. 查询8月11日单天,确认退款挽回桶系数=2.2
4. 管理岗人员的系数不受影响

View File

@@ -1,105 +0,0 @@
# 收尾计划:AI 治理大任务前的尾巴清理
## Context
用户即将启动 AI 数据治理框架(#162~#165,四张工单、体量较大)。开工前需要把前面工作留下的尾巴收干净,避免"旧账+新账"混在一起。
用户原话:**"因为后续的任务比较大,前面的工作要及时收一下尾巴,然后持续执行"**。
本轮已确认范围(用户拍板):
- **只收尾巴,然后转大任务**(不做 C-P1-4 权限矩阵、不做 C-P1-8 容器非 root)
- wallet 备份:**表 + dump 都留到 09-26 到期日统一清**
## 收尾前的现状核查(本轮已完成,均为只读)
| 事项 | 核查结论 |
|---|---|
| wallet 备份 dump 可用性 | ✅ 已验证:`wallet_withdrawals_20260919.sql.gz` 解出 69691 行,其中 `COPY` 数据段 **69455 行**,与 `wallet_withdrawals_bak_20260919` 表行数**完全一致** → 备份可恢复 |
| #161 修复后是否仍有新重复 | ✅ 主表按 `(account_id,op_type,op_time,operator,op_amount) WHERE device_sn IS NULL` 分组重复组 = **0**;部分索引 `uq_wallet_withdrawals_null_sn` 已生效 |
| 同类风险(可空列进唯一键) | ✅ 已扫描生产库 59 个唯一索引(含可空列 19 个),报出 4 处"重复"经**逐组甄别全部为 NULL 组**(PG 对 NULL 不去重是规范行为,非违约)→ **生产无真违约**,无需修复 |
| 生产定时备份(C-P1-2) | ❌ **真实缺口**:root/tanzk crontab 均空、无 systemd timer、`/etc/cron.d` 无 csctalon 项 → **生产当前没有任何自动备份** |
| 备份目录占用 | ⚠️ `/opt/csctalon/backups` = **5.2G**(10 个文件,多为升级时 `upgrade.sh` 自动产生的全量包 760M+/个);磁盘可用 184G |
## 任务 1:补生产每日定时备份(C-P1-2 核心缺口)
**为什么**:`deploy/backup.sh` 早已写好且支持 `--env-file/--output/--keep`,`deploy/README.md §5.1` 也明确建议配 cron 每日跑 —— 但生产**从未配置**。当前仅靠 `upgrade.sh` 升级时顺带备份,意味着"不升级就没有备份",且无异地留存。这是审计 C-P1-2「无备份/恢复方案」的实际缺口。
**做法**(不新增脚本,复用既有 `backup.sh`):
1. 在生产服务器 `/etc/cron.d/csctalon-backup` 落一个 cron 文件(比 `crontab -e` 更可审计、可版本化):
```
# 每日 03:30 备份数据库+附件+登录态, 保留最近 14 份
30 3 * * * root /opt/csctalon/current/deploy/backup.sh --env-file /opt/csctalon/.env --output /opt/csctalon/backups --keep 14 >> /var/log/csctalon-backup.log 2>&1
```
- 用 `current` 软链 → 随版本升级自动用最新脚本
- `--keep 14`(README 推荐值):每日一份 × 14 天
2. 同步 `deploy/README.md §5.1`:把"建议配置"改为"已配置"的现状说明(一行),并补 `/etc/cron.d/` 方式(原文档只写了 `crontab -e`)。
3. 加 logrotate(可选但推荐):`/etc/logrotate.d/csctalon-backup` 轮转 `/var/log/csctalon-backup.log`,防日志无限增长。
**验证**:
- `sudo /opt/csctalon/current/deploy/backup.sh --env-file /opt/csctalon/.env --output /opt/csctalon/backups --keep 14` 手动跑一次 → 产出新包且健康
- `sudo -n cat /etc/cron.d/csctalon-backup` 确认落盘
- 检查 `run-parts`/cron 生效:`sudo systemctl status cron` + 次日看 `/var/log/csctalon-backup.log`
- 抽验备份包可恢复:`tar tzf <新包>` 列出 `csctalon.dump / uploads / browser_states / backup.info`
**风险控制**:
- `--keep 14` 会清理超出的 `csctalon-backup-*.tar.gz`(不匹配该前缀的 `pre-org-*.dump`、`wallet_*.sql.gz`、`ai_reports_*.dump` **不受影响**,因 `ls -1t csctalon-backup-*.tar.gz` 只匹配该前缀)→ 需在验证时确认手工 dump 仍在
- 5.2G 现状:新 cron 跑起来后靠 `--keep 14` 自然收敛(每日增量约 770M,14 份约 10G,磁盘 184G 可用,可接受)
## 任务 2:wallet 备份到期清理机制
**用户决定**:表 + dump 都留到 **2026-09-26** 统一清。
**做法**:
- 不在本轮删除任何东西
- 在 `/etc/cron.d/csctalon-backup` 同文件追加一条**一次性到期清理**,或在 `docs/审查行动项登记.md` 登记"09-26 清理"待办
- 推荐:**登记到行动项台账**(C-P1-7 备注补一行),比藏一条 cron 更可见;到期由人确认后删(备份删除是不可逆操作,人工确认更稳)
清理对象(09-26 执行):
```sql
DROP TABLE IF EXISTS wallet_withdrawals_bak_20260919; -- 69455 行
```
```bash
sudo rm -f /opt/csctalon/backups/wallet_withdrawals_20260919.sql.gz # 2.5M
sudo rm -f /opt/csctalon/backups/ai_reports_pre166_20260920.dump # 195K (#166 清理的备份)
sudo rm -f /opt/csctalon/backups/pre-org-20260914.dump # 108M (09-14 组织调整, 已过期)
```
## 任务 3:登记同类风险排查结论(闭环 C-P1-7 的"同类排查"尾巴)
**做法**:更新 `docs/审查行动项登记.md` 的 C-P1-7 行,备注补:
- 同类风险已全库扫描(59 个唯一索引 / 19 个含可空列)
- 4 处报警经甄别**全为 NULL 组**(PG 规范允许),**无真违约**
- 扫描脚本方法论(v2 复现索引真实表达式 + 部分条件,修正 v1 裸列名误报)
同时在 `docs/design/AI数据治理框架-20260919.md` 的探测器设计(D1)中,把本次扫描脚本沉淀为 D1 的实现依据(D1 = 唯一索引重复探测器,正好复用 v2 逻辑)。
## 任务 4:清理文档/工单尾巴
- `docs/审查行动项登记.md`:C-P1-7 状态由「待处理」→「✅ 已完成(2026-09-19~20)」,引用 #160/#161
- 确认 #162~#165 工单正文齐备(已建,open),本轮不动
## 不做(明确记录取舍)
| 项 | 为什么不做 |
|---|---|
| C-P1-4 权限矩阵收紧 | 跨后端 7 类端点 + 前端角色控制 + 测试,属独立工作包;用户选择先转大任务 |
| C-P1-8 容器非 root | Playwright/Chromium/Xvfb 非 root 运行需 `--no-sandbox` 与写盘权限验证,风险最高,需单独排期 |
| 删除 wallet 备份 | 未到 09-26 到期日,且删除不可逆 |
| 异地 rsync 备份 | README 有建议但需目标机;本轮先保证"有本地每日备份",异地另议 |
## 验证清单
1. 手动跑一次 `backup.sh` → 新包生成、`tar tzf` 内容齐全、手工 dump 文件未被误删
2. `/etc/cron.d/csctalon-backup` 落盘且语法正确(`sudo run-parts --test /etc/cron.d` 或等次日日志)
3. `docs/审查行动项登记.md` C-P1-7 已更新为已完成并含同类排查结论
4. 提交(携带 `#161`/`#160` 关联)+ 推送
5. 转 #162 开始治理框架 A(两表 + D1 探测器)
## 关键文件
- [deploy/backup.sh](file:///Users/tanzk/dev/csctalon/deploy/backup.sh) —— 既有备份脚本(复用,不改逻辑)
- [deploy/README.md](file:///Users/tanzk/dev/csctalon/deploy/README.md) —— §5.1 定时备份章节(补 `/etc/cron.d/` 方式 + 现状)
- [docs/审查行动项登记.md](file:///Users/tanzk/dev/csctalon/docs/审查行动项登记.md) —— C-P1-7 状态与备注
- [docs/design/AI数据治理框架-20260919.md](file:///Users/tanzk/dev/csctalon/docs/design/AI数据治理框架-20260919.md) —— D1 探测器实现依据(沉淀扫描脚本)
- `/tmp/scan_dup_v2.py` + `/tmp/triage_dup.py` —— 本轮扫描脚本(D1 参考实现)

View File

@@ -1,86 +0,0 @@
# Spec:`#191 收口` + `#299 收口`
**性质**:需求(REQ) · 一次性任务规格(非 META,不约束其他任务)
**日期**:2026-10-15
**关联工单**:`#191`(E 路恒 0 行治理,open)· `#299`(device_combo 历史回补,open)
---
## 一、已核实事实(2026-10-15,生产 VM400 / release `git-20261015081123`)
| 项 | 事实 | 取证方式 |
|---|---|---|
| #191 核验 | **未通过**(EXIT=1) | 容器内 `check_ai_issue_gates.py --issue 191 --allow-real-db` |
| E 路 scheduled 记录 | 近 **20 天零条** `trigger_type=scheduled`;7 条全部 `manual` | `SELECT ... FROM bms_sync_logs WHERE data_type='refund_record_report'` |
| E 路时效 | 最新业务日 `2026-10-10`,`lag=5`,`stale=True` | `check_freshness()` |
| 运行时配置 | 正确:低频源含 `refund_record_report`;`list_low_freq_schedules → {'refund_record_report': {'hour': 15, 'window_days': 3}}` | 容器内直调服务函数 |
| 容器启动日志 | 16:17 BJT 启动时「低频档回填:今日已执行的小时档 `['2026-10-15:6']`」→ **15 档未执行** | `docker logs csctalon-backend` |
## 二、根因(代码级,已定位到行)
`backend/app/services/bms_scheduler.py` 低频档分支(约 L836–842):
```python
elif should_run_low_freq(
now_cn.hour,
low_freq_hour=now_cn.hour,
done=False,
busy=sync_task_manager.is_busy(),
):
```
**未传 `deadline_hour`** → 取默认 `LOW_FREQ_DEADLINE_HOUR = 7`;而 `should_run_low_freq` 第 185 行
`if now_hour >= deadline_hour: return False`,故 **任何 per-source hour ≥ 7 的低频档恒不触发**
(默认 06:00 档因 6 < 7 仍正常,因此长期未暴露)。
到达 `:59` 后落 `elif now_cn.minute >= 59` 分支 → 记 `abandoned` 入 gap 队列,**无同步尝试 → 不写
`bms_sync_logs`**,与「零 scheduled 记录」现象完全吻合。
**影响**:E 路(退款记录报表,对账基准)自 #191 于 09-24 引入「按源可配小时」后**从未自动采集**,
数据仅靠人工手动触发维持,当前落后 5 天。这与 #191 工单注释(L804)声明的语义
「窗口 = 该源配置小时起 1 小时内」相矛盾。
## 三、变更 A:`#191` 根因修复
- **唯一改动点**:`backend/app/services/bms_scheduler.py` L836–842,补 `deadline_hour=now_cn.hour + 1`。
- **不改**:`should_run_low_freq` 本体、`:59` abandon 分支、默认 06:00 档行为、fetch 窗口逻辑。
- **语义恢复**:窗口 = `[该源配置小时, 该源配置小时 + 1)`,与工单注释一致。
- **测试**:
1. `backend/tests/test_source_freshness.py`(或 `test_bms_scheduler_t1.py`)新增用例:
`should_run_low_freq(15, low_freq_hour=15, deadline_hour=16, done=False, busy=False) is True`
与 `... deadline_hour=7 ...` 为 False(锁死根因)。
2. **漂移守卫**:断言 `bms_scheduler` 源码低频分支调用 `should_run_low_freq` 必带
`deadline_hour=now_cn.hour + 1`(仿既有「`expected_by_hour` 必须等于采集种子 `low_freq_hour`」守卫写法,防止两处口径再次分叉)。
3. `pytest` + `ruff` 全绿。
## 四、变更 B:`#299` 顺带项 5(覆盖率异常波动告警)——**待用户拍板**
原文:「同步覆盖缺口监控——低频档跑完对比『devices 无套餐比例』异常波动告警(成功≠完整)」。
- **建议实施,且落在监控模块(非采集核心)**:`backend/app/services/source_freshness_service.py`
新增只读巡检 `check_device_combo_coverage()`:
- 指标:设备无有效套餐比例(口径须与 #298/#320/#321 一致,套餐以 `dim_device_combo` 为单一真实源);
- 触发:比例超阈值(初值 **5%**,当前基线 **2.62%**)→ `record_system_event(category="sync",
action="device_combo_coverage", level="warning")`,按日节流(复用现有 `_emit_once`)。
- 该模块即 #191 交付的「数据源时效与空结果告警」巡检,由调度器每小时调用、**只读、零业务写入**,
不属于采集链路。
- **降级选项**:若不实施 → 在 #299 关闭凭证中显式写
`结论: 以 <理由> 降级验收`,禁止静默关闭。
## 五、发布与验收
- **发布**:`git push origin main` → 节点标准入口 `curl ... upgrade.sh | sudo bash -s -- --git`(禁手拼命令)。发布后核 `VERSION` 与迁移 head。
- **#191 关闭条件**(工单原文):
1. 生产**连续 2 个自然日** `scheduled` 档 `total_rows > 0` **且** `check_freshness` 的 E 路 `lag_days ≤ 1`、`stale=false`(核验脚本 EXIT=0);
2. 0 行场景能触发 `system_events` 告警(已有 `#5385` 历史证据);
3. 巡检可复现(`check_freshness` 输出);
4. 单测 + `ruff` + `pytest` 全绿;
5. 若验收窗口内因上游无法达成 → 允许降级并登记待观察项。
- **#299 关闭凭证**:A–F 逐条已有证据(探针结论、切片折半、全量回补 `min=2024-01-01`、残差 34,523 无流水 / 18,136 待 #321、台账 125 success + 64 split 无残留、17 单测)+ 顺带项 5 结论。
## 六、风险与约束
1. **变更 A 属采集调度链路** —— 触发 AGENTS.md「未经用户确认不得修改采集核心」铁律 → **须用户明示批准后再动手**。
2. E 路上游 T+1:修复后需观察 1 个自然日确认 15:00 档能取到数(09-24 实验结论复用,不重做探针)。
3. 变更 B 若被纳入调度触发路径则同属采集链路(亦须批准);仅作独立每小时只读巡检则属监控范围。
4. 存在 `abandoned` 已入 gap 队列的存量记录:修复后 05:00 缺口清点会重试,属正常收口,不额外处理。

View File

@@ -1,45 +0,0 @@
# Tasks:`#191 收口` + `#299 收口`
> 依赖 spec.md。**T1 之前需用户对「变更 A 是否放行」明示批准(铁律:未经确认不得修改采集核心)。**
## 阶段 0 · 用户拍板(阻塞门)
- [x] **T0** 用户确认两件事:
- (a) 放行变更 A(`bms_scheduler.py` 低频分支补 `deadline_hour=now_cn.hour + 1`);
- (b) 变更 B 选「实施」还是「降级验收」。 → **批准 spec ⇒ (a) 放行、(b) 实施**
## 阶段 1 · #191 根因修复(变更 A)
- [x] **T1** 在 `backend/app/services/bms_scheduler.py` 低频档分支(L836–842)补
`deadline_hour=now_cn.hour + 1`,并在该行上方补注释说明「窗口 = 该源配置小时起 1 小时」。
- [x] **T2** 新增/改单测(`backend/tests/test_source_freshness.py` 或 `test_bms_scheduler_t1.py`):
- 用例 1:`should_run_low_freq(15, low_freq_hour=15, deadline_hour=16, done=False, busy=False) is True`
- 用例 2:`should_run_low_freq(15, low_freq_hour=15, deadline_hour=7, done=False, busy=False) is False`(锁根因)
- 用例 3:源码漂移守卫 —— 断言调用点含 `deadline_hour=now_cn.hour + 1`
- [x] **T3** 本地回归:`backend/.venv/bin/python -m pytest tests/ -q`(全量,期望 0 失败)+ `ruff check`。
→ 全量 EXIT=0;`test_source_freshness.py` 19 passed;ruff 全绿。
- [x] **T4** 提交(commit message 带 `#191`)→ `git push origin main` → 节点标准入口
`curl -fsSL http://10.147.17.41:3000/tanzk/csctalon/raw/branch/main/deploy/upgrade.sh | sudo bash -s -- --git`。
→ commit `a8b68f6`;已推送 main;发布 `git-20261015081123 → git-20261015090830`。
- [x] **T5** 发布后核验:`VERSION` == 新 release;容器 healthy;日志出现「低频档触发 …15:xx 低频源=['refund_record_report']」。
→ current=`git-20261015090830`、healthy、域名 200、部署树含修复行 L845。
(「低频档触发」日志需等 10-16 15:00 档,见 T6)
- [ ] **T6** 观察窗口:次日起连续 2 个自然日 15:00 档 `bms_sync_logs(data_type=refund_record_report, trigger_type=scheduled)` 各 1 条且 `total_rows>0`。
- [ ] **T7** 跑核验脚本(scp + docker cp + `--issue 191 --allow-real-db`),期望 **EXIT=0**;若非 0 且原因在上游 → 按工单第 5 条降级并在凭证登记待观察项。
- [ ] **T8** 按工单纪律写 `## 关闭凭证核验 (YYYY-MM-DD)`(逐条对应关闭条件 1–5,给可复现命令/SQL),勾选正文 `[x]`,关闭 `#191`。
## 阶段 2 · #299 收口
- [x] **T9** (仅当 T0(b)=实施)在 `backend/app/services/source_freshness_service.py` 新增
`check_device_combo_coverage()` 只读巡检 + 阈值告警(初值 5%),并接入现有每小时巡检入口;
补单测;随阶段 1 一并发布。 → 提交 `a8b68f6`,2 个单测。
- [x] **T10** 写 `## 关闭凭证核验 (YYYY-MM-DD)` for `#299`(comment 3890)。
- [x] **T11** 勾选 `#299` 正文 `[ ]` → 关闭(closed_at 2026-10-15T17:19:29+08:00)。
## 明令不做
- 不改 `should_run_low_freq` 本体与 `:59` abandon 分支(避免扩大采集语义变更面)。
- 不改 E 路 fetch 窗口/tag 逻辑(09-24 实验结论已复核,不重做探针)。
- 不动 `docs/arch/A-20260907-001-*`(LOCKED)——本修复是把实现拉回该文档已锁定的「每天 1 次、窗口 1 小时」语义。
- 不为 #299 新增前端界面(原方案取舍:入口仅调度自动档 + admin API)。

View File

@@ -1,18 +0,0 @@
# 2026-08-20 工作日志
## 项目审计
对 CSCTalon 项目进行了全面审计,覆盖五大维度:
- **架构与配置**: Docker Compose 部署合理,.env 未入库,端口暴露受限
- **后端代码质量**: 分层清晰但 analytics.py 8508 行严重超标;30+ 处裸 except 吞异常;零测试覆盖;死依赖 passlib
- **前端代码质量**: Vue 3 懒加载路由 + DOMPurify XSS 防护良好;但无 TypeScript、无 ESLint;SyncDashboard.vue 2010 行超标
- **数据库**: 56 表完整,37 个 Alembic 迁移单 head,连接池配置合理,启动咨询锁防重复迁移
- **安全**: bcrypt + JWT token_version 吊销 + 登录限速 + SafeStaticFiles + Fernet 加密 + CSP + 操作日志,安全实现质量高
审计报告已输出至 docs/audit-report-20260820.html。
### 关键发现
- P0: 零测试覆盖(tests/ 目录为空)
- P1: 超大文件拆分、裸 except 清理、死依赖清理
- P2: TypeScript 引入、CI/CD、限速迁移 Redis
- P3: 加密 key 独立化、前端组件拆分、依赖精确锁定