feat(gateway)!: 后台思考档位精确化(+ 面板三态 + 契约 v0.69/v0.70)
- gateway:优先读落库的 rec.Urgency(精确档位,与安静时段窗口无关);仅空串才回落到既有下限推导,并打 urgencyLowerBound 标记(**只在推导值上为 true**)
- 面板三态:精确(例常/紧急(高)/紧急·安全域)|下限(一律加 ≥ 前缀)|未知;下限悬停说明写清「为什么旧行只能是下限」
- 实时帧零协议改动:Record 的 JSON 键就是 urgency ⇒ 帧自然带上;wsParse 早已防御性读取(只加一处 urgencyLowerBound 防御读)
- 契约:v0.69(directMsgPerMin 透传修复,逐字照抄附录 A)与 v0.70(精确化 + 更正 §4.10(b)「实时帧不带这两键」)一起登记
- 变异实测:旧行不标下限 ⇒ 3 条测试红(结构体对比);前端去掉 ≥ ⇒ 渲染测试红
- ⚠️ 诚实边界(§7.3 已登记):实时旧记录刷新前显示「档位未知」、刷新后显示「≥ 例常」——同一条记录两路下的已知差异
This commit is contained in:
1 parent
f9c5b56a2f
commit
a0abf0199a
11 files changed
+390
-131
No files matched your search
+51
-14
@@ -1,6 +1,6 @@
|
||||
# Cyrene v2 客户端 API 契约
|
||||
|
||||
> **契约版本:`v0.68`**(上一版 `v0.67`,其条目见 §6;`v0.66` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **契约版本:`v0.70`**(上一版 `v0.69`,其条目见 §6;`v0.68` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
@@ -8,6 +8,8 @@
|
||||
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
|
||||
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— **当前尚未实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**;已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
|
||||
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
|
||||
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
|
||||
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
|
||||
|
||||
---
|
||||
|
||||
@@ -135,7 +137,7 @@
|
||||
| A2 | GET | `/api/v1/admin/sessions` | 会话清单 |
|
||||
| A3 | GET | `/api/v1/admin/plugins` | 能力/插件清单 |
|
||||
| A4 | GET | `/api/v1/admin/plugins/installed` | 已安装插件清单 |
|
||||
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 已实现**(本次落地;落地前基线 `f91a83f`):各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b)。**不含任何凭据** |
|
||||
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 已实现**(本次落地;落地前基线 `f91a83f`):各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b);各元素**可能**带可选 `directMsgPerMin:number`(**v0.69 新增,已实现**):该账号**非主人私聊(陌生人)**每分钟每发送者上限,是账号**顶层**字段(与 `groupMsgPerMin` 同一条写路径,**不在 `config` 里**)。语义:**键缺席或 `0` ⇒ 保护性默认 6 条/分**(陌生人必须有流控,缺省绝不解释成「关闭」)、**正值 ⇒ 该值**、**负值 ⇒ 关闭该限制**(主人的显式选择)。⚠️ 因 `omitempty`,**`0` 与未设置线上同形**(既有约定,未改)**不含任何凭据** |
|
||||
| A6 | GET | `/api/v1/admin/iot` | IoT 设备清单 |
|
||||
| A7 | GET | `/api/v1/admin/automations` | 自动化与提醒清单 |
|
||||
| A8 | GET | `/api/v1/admin/rag` | 知识库文档清单(名字/描述 + 切分/嵌入状态);`?query=` 搜索亦只读 |
|
||||
@@ -155,7 +157,7 @@
|
||||
| B5 | GET | `/api/v1/admin/llm/usage` | LLM 用量汇总(切片 U1,v0.64)。`?since=&until=&purpose=`;缺省近 24h、跨度上限 90 天(超 → `400`);`hitRate = cacheHit/(cacheHit+cacheMiss)`,分母 0 ⇒ `0`。**不含凭据与消息正文**。未接线 → `500 usage_unavailable` |
|
||||
| B6 | GET | `/api/v1/admin/llm/catalog` | 模型目录候选(`?refresh=1` 强制在线刷新)。既无快照又抓不到 → `503 catalog_unavailable` |
|
||||
| B7 | GET | `/api/v1/admin/users` | 账号/API key 清单 |
|
||||
| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**仅在该轮档位存档可证时下发**,见 §4.10)与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增** |
|
||||
| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**默认是精确档位**,带 `urgencyLowerBound:true` 时才是下限,见 §4.10(b))与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增;档位的精确语义与 `urgencyLowerBound` 为 v0.70** |
|
||||
| B9 | GET | `/api/v1/admin/observability/audit` | 审计链 |
|
||||
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
|
||||
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
|
||||
@@ -209,6 +211,7 @@
|
||||
| C40 | GET | `/api/v1/admin/system/storage` | 存储状态**只读**(v0.60)。`{data:{main,documents,vector}}`;**不含凭据**;未接活连接 → `500 storage_unavailable` |
|
||||
|
||||
> **C28 的 patch 语义(v0.54 起)**:`config` 内**键省略 = 保留已存值**;给出非空值 = 覆盖;**显式 `null`/`""` = 清除该键**(`0`/`false` 是真实取值,不算空)。顶层便捷字段(`token`/`baseURL` 等)只在非空时并入补丁,**要清 `bot_token` 必须走 `config` 的显式 `null`/`""`**。
|
||||
> **C28 的账号级限流字段(v0.69 修正)**:`groupMsgPerMin` / `msgPerMin` / `maxMessages` / **`directMsgPerMin`** 都是账号**顶层**字段(**不在 `config` 里**,C28 的 patch 三态**不适用**于它们)。它们是**值语义**:请求体省略 ⇒ `0` = 未设置(对 `directMsgPerMin` 即保护性默认 6 条/分)。**要保住已设的值必须把它回送**;面板先由 `GET /admin/channels` 回显、再原样写回(`frontend/web/src/admin/modules/channels/channelUtils.ts` 的 `limitInitialOf` / `limitRequestBody`)。⚠️ v0.68 及更早的后端**完全忽略** `directMsgPerMin`:老客户端不受影响,但**要表达「关闭流控(-1)」必须对 v0.69+ 后端发**。
|
||||
> **C18 破坏性**:只覆盖 `.env` 声明过非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`),`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改;组集合不增减。
|
||||
> **C19/C20 的 `accountId`(v0.66,多账号平台必需)**:请求体 = `{name, accountId?}`(`name` 必填:平台名或精确适配器名)。
|
||||
> - 给出 `accountId` ⇒ **只**驱动该账号的适配器(账号 id 非敏感;`"default"` 映射到该平台的裸适配器名,即无 id 账号的注册口径)。账号**完全不存在** ⇒ `404 account_not_found`(文案附该平台已知账号 id 列表);账号**已配置但当前没有活适配器** ⇒ `409 account_not_registered`(常见原因:缺该账号必需凭据、或保存后尚未重载)。两种都**零连接**,**绝不静默回落到第一条**(「点了连接却连上别的账号」正是此改动要修的缺口)。
|
||||
@@ -691,22 +694,28 @@
|
||||
- 9 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。
|
||||
- **安静时段的语义(重要,别照旧实现写文案)**:安静时段**只服务「定时主动思考 → 主动搭话」**(`design/12 §6` 策略五),**不静默回复** —— 白天晚上的聊天回复都不受它约束;群聊也没有夜间静默(主人裁决 10)。因此面板文案**不得**写成「夜间全静默」。
|
||||
|
||||
#### (b) `GET /api/v1/admin/backthink` 条目的 v0.68 新字段
|
||||
#### (b) `GET /api/v1/admin/backthink` 条目的紧急度字段(v0.68 引入;**v0.70 起是精确档位**)
|
||||
|
||||
| 键 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `urgency` | string,**可选** | `"routine"` / `"warning"` / `"critical"`(`internal/urgency` 的档位词汇)。**语义 = 该轮档位的「存档可证下限」**:`warning` 读作「至少 high 档(可能是 critical)」 |
|
||||
| `urgency` | string,**可选** | `"routine"` / `"warning"` / `"critical"`(`internal/urgency` 的档位词汇)。**默认 = 该轮实际生效的精确档位**(随记录落库)。**只有与 `urgencyLowerBound:true` 同时出现时才读作「下限」**(`warning` = 至少 high、可能更高) |
|
||||
| `urgencyLowerBound` | bool,**可选**(`true` 才出现,**v0.70 新增**) | 该行**没有落库档位**(`urgency` 列出现之前的旧行,或从未走到档位判定点的行)⇒ `urgency` 是由安静时段窗口规则**反推出的下限**,不是精确档位 |
|
||||
| `quietHoursBypassed` | bool,**可选**(`true` 才出现) | 该轮**穿透了安静时段**:轮次时间落在安静时段窗口内**且**该轮判定为「要推送」 |
|
||||
|
||||
- **两个键都可能缺席,缺席 ≠ 常规档位**。缺席 = **存档里没有这个事实**(见 §4.10(c)),面板应显示「未知」而**不是**「例常」。
|
||||
- `urgency` 只在该轮时间**落在安静时段窗口内**时下发:安静时段内被放行 ⇒ `warning`(由 `backthink.Decide` 的级联可证:安静时段只放行 ≥ `high` 的轮次)且 `quietHoursBypassed:true`;安静时段内被扣下 ⇒ `routine`。窗口之外的轮次判定不看档位、存档也不记录档位,故**不下发**。窗口口径与 B12 的 `quietHours` **同一份**(含「`0/0` 视为未填、回落到默认」这条),因此 0/0 的部署也不会漏报穿透。
|
||||
- **取值优先级(v0.70,客户端按这个顺序读)**:
|
||||
1. `urgency` 存在且 `urgencyLowerBound` 缺席 ⇒ **精确档位**,可直接显示(`critical` 即安全域,`warning` 即 high);
|
||||
2. `urgency` 存在且 `urgencyLowerBound:true` ⇒ **下限**,只可显示为「≥ X」(`≥ 高` = 至少 high、可能更高;`≥ 例常` = 只确定不低于最低档),**不得**显示成精确值;
|
||||
3. `urgency` 缺席 ⇒ **未知**(存档里没有这个事实),面板显示「未知」而**不是**「例常」——把未知画成「例常」与把下限画成精确值,是同一类「把猜当事实」。
|
||||
- 三个键都可能缺席,缺席 ≠ 常规档位。旧行(`urgency` 缺席)出现 `urgencyLowerBound:true` 的情况**不存在**:没有值就没有可标记的下限。
|
||||
- 旧行(无落库档位)与 `quietHoursBypassed` 的口径(**v0.68 起不变**):只在该轮时间**落在安静时段窗口内**时给出下限 —— 窗口内被放行 ⇒ `warning`(由 `backthink.Decide` 的级联可证:安静时段只放行 ≥ `high` 的轮次)且 `quietHoursBypassed:true`;窗口内被扣下 ⇒ `routine`。窗口之外的旧行判定不看档位、存档里也没有档位,故**不下发**。窗口口径与 B12 的 `quietHours` **同一份**(含「`0/0` 视为未填、回落到默认」这条),因此 0/0 的部署也不会漏报穿透。
|
||||
- `quietHoursBypassed` 与既有的 `bypassed`(v0.66 前已有,含义是**事件驱动绕过限频闸**)是**两件事**,别混用:前者说「安静时段」,后者说「限频门」。
|
||||
- ⚠️ **实时帧不带这两个键**:`/ws/chat` 下行的 `backthink` 帧其 data 是 bus 上发布的原始 `backthink.Record`(Go 字段名 `ID`/`Trigger`/`ShouldPush`/`PushResult`…,**没有** `urgency`/`quietHoursBypassed`)⇒ 客户端拿到实时行时这两个事实**不可得**(管理面板因此先显示「档位未知」,重开页面由 `GET /admin/backthink` 补真值)。**本版刻意不改 WS 帧**(改它就是动既有帧形状,属另一件事);要精确档位需 backthink 把 `Import` 落进记录。
|
||||
- ⚠️ **实时帧(v0.70 更正)**:`/ws/chat` 下行的 `backthink` 帧其 data 是 bus 上发布的原始 `backthink.Record`(Go 字段名 `ID`/`Trigger`/`ShouldPush`/`PushResult`…)。**该记录自 v0.70 起带 `urgency`(该轮实际生效的精确档位,非下限)** ⇒ 实时行同样可显示精确档位,不必等重开页面。**但实时帧仍不带 `urgencyLowerBound` 与 `quietHoursBypassed`**:这两个键是网关读历史时按安静时段窗口推导的,**不在记录里**(`urgencyLowerBound` 对实时行也不会出现——实时行要么有精确档位、要么无档位)。⚠️ 原 v0.68 表述「实时帧不带这两个键」对 `urgency` **已过期**,本版更正;对 `quietHoursBypassed` 仍然成立。
|
||||
|
||||
#### (c) 为什么档位只能是「下限」而不是精确值(诚实边界)
|
||||
#### (c) 档位的来源与诚实边界(**v0.70 重写**)
|
||||
|
||||
- 持久化的思考记录(`backthink.Record`)**不带** `Import`/重要度字段,且推送成功时 `PushResult.Detail` 会被投递说明覆盖 ⇒ 精确档位(`high` 还是 `critical`)**在存档里已丢失**。
|
||||
- 本契约因此**只下发可证的部分**,不猜:要显示精确档位,需 `internal/services/backthink` 把该轮 `Import` 落进记录(**本版不做**,见 §7.4-17)。
|
||||
- **精确档位(默认路径)**:`backthink.Record.Urgency` 由 backthink 在档位判定点原样落库(该轮实际生效的档位,含她自报紧急度上抬后的结果)⇒ 网关**优先读它**并**原样下发**,与安静时段窗口、与时间戳都无关。因此「白天的一轮」如今也有精确档位(v0.68 时代窗口之外一律显示「未知」)。
|
||||
- **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。
|
||||
- 每轮 `Import` 落库(`urgency` 列)在 X2a 切片实现(`213aff2`);「该轮实际生效」的取数点见 `internal/services/backthink/backthink.go` 的判定点。⚠️ 本版**不改** `quietHoursBypassed` 的语义。
|
||||
|
||||
---
|
||||
|
||||
@@ -771,7 +780,29 @@
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.68(当前契约版本)
|
||||
### v0.70(当前契约版本)
|
||||
|
||||
**后台思考行的紧急度精确化 —— `urgency` 默认即精确档位,另加 `urgencyLowerBound` 标记下限**(依据主人 2026-10-05 裁定「要**精确**」;P4 只能给下限,本版把精确值送到两面:管理 REST 与实时帧)。
|
||||
|
||||
- **纯增量**:
|
||||
① `GET /api/v1/admin/backthink` 条目的 `urgency` **默认语义改为该轮实际生效的精确档位**(backthink 在档位判定点随记录落库,含她自报紧急度上抬后的结果)—— **§2.2 B8 / §4.10(b)(c)**。
|
||||
② 新增**可选** `urgencyLowerBound:true`(只在为 `true` 时出现):标记该 `urgency` 是**旧行的下限推导值**而不是精确档位(旧行 = `urgency` 列出现之前的行,以及从未走到档位判定点的行)。客户端据此区分「精确档位」/「≥ 下限」/「未知」三态。
|
||||
③ **实时 `backthink` 帧带上记录里的 `urgency`**(精确值)⇒ 本次会话新到的行不必等重开页面就有档位。⚠️ 实时帧仍不带 `urgencyLowerBound` / `quietHoursBypassed`(两者由网关按安静时段窗口推导,不在记录里)—— 更正 §4.10(b) 原「实时帧不带这两个键」的表述。
|
||||
④ §7.4-17 补「客户端不得把『下限』显示成精确值、不得把缺席显示成『例常』」。面板三态文案与悬停说明见 `frontend/web/src/admin/modules/backthinkUrgency.ts`。
|
||||
- **行为变更**:`urgency` 的**读法**变了(v0.68/0.69 一律读作下限;v0.70 默认读作精确值,唯有带 `urgencyLowerBound:true` 才是下限)。取值域(`routine`/`warning`/`critical`)与 `quietHoursBypassed` 语义**一字未改**。
|
||||
- **破坏性**:无(新键可选;把 `urgency` 当「下限」显示的老客户端最坏是显示得比事实保守,不会谎报精确值)。
|
||||
- ⚠️ **诚实边界**:旧行(本列之前落下的)**没有**精确档位可显示 —— 它们仍走下限额推导并带 `urgencyLowerBound:true`;未知仍是未知(不编值)。见 §4.10(c) / §7.4-17。
|
||||
|
||||
### v0.69(上一版)
|
||||
|
||||
**`directMsgPerMin` 的 gateway 写入路径修复**(切片 P1-T;缺口 = 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分)。
|
||||
|
||||
- **纯增量 / 缺口修复**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它 —— §2.2 A5 行 / §2.3 C28 注 / §7.2。
|
||||
- **行为变更**:无(对不带该键的老客户端,行为与 v0.68 相同)。
|
||||
- **破坏性**:无。
|
||||
- ⚠️ **诚实边界**:账号级限流字段仍是**值语义**(请求体省略 ⇒ `0` = 未设置),要保住已设的值必须回送 —— 见 §7.3。
|
||||
|
||||
### v0.68(上一版)
|
||||
|
||||
**管理面「配置事实」只读端点 + 后台思考行的紧急度/穿透字段**(依据主人指正「你多久没把新功能同步管理面板更新了?」与裁决 14;**本版全部已实现、非契约先行**)。
|
||||
|
||||
@@ -906,6 +937,7 @@
|
||||
| **🔴 v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **未实现**(**契约已先行登记**):端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5;权威设计 `design/24 §10`。⚠️ 同批的**行为变更**(QQ 语音 `content` 不再含 `[语音]`)与**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)也**随该切片一起生效**,现在**尚未生效** |
|
||||
| **🔴 v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **未实现**(**契约已先行登记**):形状与错误码见 §2.4(a);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`;⚠️ 两个端点的 **RBAC 档位设计未定**(见 §7.4-12) |
|
||||
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
|
||||
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
|
||||
| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** |
|
||||
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
|
||||
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
|
||||
@@ -925,6 +957,8 @@
|
||||
- **`GET /api/v1/media/{name}` 读侧要 Bearer owner/admin(v0.67 登记)**:音频 URL **不能**直接贴进 `<audio src>`(浏览器不带 token)⇒ 客户端须带 token 取字节(Web:`fetch` → `blob:`)。这是**有意**的(语音是私密内容,与图片读侧「128-bit 名即凭据」的取向刻意相反)。
|
||||
- **微信(`weixin-ilink`)入站语音没有音频副本、不可回放(v0.67 登记)**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `voice.source:"platform"` 且**无 `name`/`url`**,只保留 🎤 标记。
|
||||
- **`416` 不是 JSON 信封(v0.67 登记)**:`GET /api/v1/media/{name}` 的 `Range` 错误由 Go 标准库直写 `text/plain`,**不套** `{"error":{…}}` ⇒ 客户端别拿 `error.code` 解析它(§0.3 / §4.1 / §4.5)。
|
||||
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
|
||||
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
|
||||
|
||||
### 7.4 「未确证」清单(客户端请以实测/代码为准)
|
||||
|
||||
@@ -945,8 +979,11 @@
|
||||
15. **(v0.67 登记)`GET /admin/system/storage` 的 `documents` 段是否拆出语音副本**:设计**本期不拆**(不新增 `kind` 列 / 不按 ext 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。
|
||||
16. **(v0.67 登记 · 前向提示)`voice.url` 指向的可能不是原件,而是转码后的副本**:`design/24 §10.14` 已把「源格式不可播 ⇒ 转码成可播副本(唯一必需的路 = `amr → wav`)」立为口径,转码能力规格见 `design/25-转码能力.md`(**该能力尚未接线**)⇒ 客户端**不要假定 `url` 一定是某一种音频格式**,一律**按响应的 `Content-Type` / `mime` 如实处理**,播不了就照实提示(**绝不给一个播不了的 `url` 假装能播**)。⚠️ 本条**不在 `design/24 §10.11` 的差异清单内**(§10.11 与 §10.13 均无「零转码」绑定表述),登记它是因为「`url` 的字节格式」会直接影响播放端;**最终口径以 `design/25` 为准**。
|
||||
|
||||
17. **(v0.68 · 本版限定)`urgency` 是「档位下限」,不是精确档位**:`warning` 读作「至少 `high`(也可能是 `critical`)」;**窗口之外的轮次不下发该键**(`backthink.Record` 不带重要度、推送成功的行 `detail` 被投递说明覆盖 ⇒ 无损精确档位已不可能)。客户端**不要**把「键缺席」显示成「例常 / routine」,也不要据此推断安全域。要精确档位需 backthink 侧把每轮 `Import` 落进记录(**本版未做**)。
|
||||
17. **(v0.68 登记 · v0.70 更正)`urgency` 的「下限」与「精确」之分**:
|
||||
- **v0.68 原文(当时成立,现已过期)**:`urgency` 一律是「档位下限」,`warning` 读作「至少 `high`(也可能是 `critical`)」;**窗口之外的轮次不下发该键**(`backthink.Record` 不带重要度、推送成功的行 `detail` 被投递说明覆盖 ⇒ 无损精确档位已不可能),要精确档位需 backthink 侧把每轮 `Import` 落进记录(当时未做)。
|
||||
- **v0.70 更正**:每轮 `Import` **已落库** ⇒ `urgency` **默认就是精确档位**(含窗口之外/白天的轮次);只有与 `urgencyLowerBound:true` 同时出现时才是**下限**(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 `backthink` 帧也带 `urgency`(精确值),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(见 §4.10(b) 更正)。
|
||||
- **客户端两条硬约束(v0.70 新增)**:① **不得把带 `urgencyLowerBound:true` 的「下限」显示成精确档位**(`≥ 高` ≠ `高`;同形就等于把推导冒充事实);② **不得把键缺席显示成「例常 / routine」**(缺席 = 存档里没有这个事实),也不得据此推断安全域。
|
||||
|
||||
---
|
||||
|
||||
*本文件由「以代码为真源」维护(当前 `v0.68`;`v0.67` / `v0.66` 见本文件 §6,`v0.65` 及更早见 `Docs/archive/`)。范围:只改 `Docs/**`,未改动任何代码。*
|
||||
*本文件由「以代码为真源」维护(当前 `v0.70`;`v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.70` 同步了 `internal/services/gateway/**`、`frontend/web/src/**` 与本文件;`v0.69` 同步了 `internal/services/gateway/**` / `internal/config/**` 与本文件。*
|
||||
@@ -145,7 +145,7 @@ export function BackthinkModule(): React.ReactNode {
|
||||
|
||||
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 两条路径同名同义:历史拉取取 AdminBackthinkItem 的同名键,实时帧取后端原始 PushResult 的 Route / Detail / Bypassed(解析器已读,见 lib/wsParse.ts#parseBackthinkItem,兼容 camelCase 变体),因此本次会话新到的行同样显示推送路由与推送说明,不再只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。
|
||||
<br />
|
||||
新增的「穿透安静时段 / 紧急度」标记(契约 v0.68):穿透 = 这一轮落在安静时段窗口内、仍然被放行发出(只有 high 及以上档位做得到);紧急度是后端 urgency 档位,且只是「档位下限」(显示「≥ 高」= 至少 high,记录里分不出 high 与 critical)。显示「档位未知」是如实的:轮次不在安静时段窗口内时,持久化记录不保存档位 —— 它不是「例常」。⚠️ 实时帧走的是 bus 上原始的 backthink.Record,不带这两个字段,因此本次会话新到的行先显示「档位未知」,重开页面后由历史拉取补上真值。</Notice>
|
||||
新增的「穿透安静时段 / 紧急度」标记(契约 v0.70):穿透 = 这一轮落在安静时段窗口内、仍然被放行发出(只有 high 及以上档位做得到);紧急度**三态画得不一样** —— 把下限当精确值、把未知当「例常」都是把猜当事实:① **精确档位**(「例常」/「紧急(高)」/「紧急·安全域」)= 该轮实际生效的档位,随记录落库;② **≥ 下限**(带 `urgencyLowerBound` 的旧行:该行没有存档档位,此值由安静时段窗口规则推导 —— 「≥ 高」只表示至少 high、可能更高);③ **档位未知** = 存档里没有这个事实,**不是**「例常」。⚠️ 实时帧带上记录里的精确档位(`backthink.Record` 自 X2a 起带 `urgency`),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(后两者由网关按安静时段窗口推导)⇒ 本次会话新到的行先显示精确档位与推送结果,穿透标记与旧行的下限标记要等重开页面由历史拉取补齐。</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 后台思考行「穿透安静时段 / 紧急度」标记的**真实渲染**测试(契约 v0.68 §4.10(b))。
|
||||
// 后台思考行「穿透安静时段 / 紧急度」标记的**真实渲染**测试(契约 v0.70 §4.10(b))。
|
||||
//
|
||||
// 为什么需要它:后端明明标了「这一轮穿透了安静时段」,面板不画出来就等于没做;而
|
||||
// 「档位未知」与「例常」**必须**画得不一样 —— 把未知画成事实正是这次要避免的坑。
|
||||
// 为什么需要它:后端明明标了「这一轮穿透了安静时段」、或明明给了档位,面板不画出来就等于
|
||||
// 没做;而**精确档位** / **≥ 下限** / **未知** 三态**必须**画得不一样 —— 把下限画成精确
|
||||
// 值、把未知画成「例常」,正是这条链路要避免的两个坑。
|
||||
//
|
||||
// 跑法(同 channels/accountRow.render.test.ts):
|
||||
//
|
||||
@@ -17,7 +18,13 @@ import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { BackthinkItem } from "../../lib/types";
|
||||
import { BackthinkUrgencyTags } from "./BackthinkUrgencyTags";
|
||||
import { URGENCY_UNKNOWN_LABEL, QUIET_HOURS_BYPASS_LABEL, urgencyTag, quietHoursBypassTag } from "./backthinkUrgency";
|
||||
import {
|
||||
URGENCY_UNKNOWN_LABEL,
|
||||
URGENCY_LOWER_BOUND_PREFIX,
|
||||
QUIET_HOURS_BYPASS_LABEL,
|
||||
urgencyTag,
|
||||
quietHoursBypassTag,
|
||||
} from "./backthinkUrgency";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
@@ -38,40 +45,64 @@ function htmlFor(over: Partial<BackthinkItem>): string {
|
||||
return renderToStaticMarkup(createElement(BackthinkUrgencyTags, { item: item(over) }));
|
||||
}
|
||||
|
||||
// ── ① 穿透行:安静时段内被放行 ⇒ 同时出「穿透安静时段」与档位下限「≥ 高」 ──────
|
||||
const bypassed = htmlFor({ urgency: "warning", quietHoursBypassed: true, shouldNotify: true, pushed: true });
|
||||
ok(has(bypassed, QUIET_HOURS_BYPASS_LABEL), "穿透的行必须画出「穿透安静时段」");
|
||||
ok(has(bypassed, "紧急(≥ 高)"), "档位下限必须显示为「≥ 高」,不能假装是精确档位");
|
||||
ok(!has(bypassed, URGENCY_UNKNOWN_LABEL), "有档位的行不得同时显示「档位未知」");
|
||||
/** 标签在 HTML 里是元素唯一内容 ⇒ 用 `>标签<` 判定「画出来的就是这个词」。 */
|
||||
function tag(text: string): string {
|
||||
return `>${text}<`;
|
||||
}
|
||||
|
||||
// ── ② 安静时段内被扣下 ⇒ 例常,且**不得**画成穿透 ──────────────────────────
|
||||
const held = htmlFor({ urgency: "routine", shouldNotify: false });
|
||||
ok(has(held, "例常"), "被扣下的轮次显示「例常」");
|
||||
ok(!has(held, QUIET_HOURS_BYPASS_LABEL), "被扣下的轮次绝不能显示穿透");
|
||||
// ── ① 精确档位(新行,`urgencyLowerBound` 缺席)⇒ 不带下限前缀 ────────────────
|
||||
const exactWarn = htmlFor({ urgency: "warning", quietHoursBypassed: true, shouldNotify: true, pushed: true });
|
||||
ok(has(exactWarn, tag("紧急(高)")), "精确 warning 必须显示为「紧急(高)」");
|
||||
ok(!has(exactWarn, tag(URGENCY_LOWER_BOUND_PREFIX + "紧急(高)")), "精确档位绝不能带「≥ 」下限前缀");
|
||||
ok(has(exactWarn, QUIET_HOURS_BYPASS_LABEL), "穿透的行必须画出「穿透安静时段」");
|
||||
ok(!has(exactWarn, URGENCY_UNKNOWN_LABEL), "有档位的行不得同时显示「档位未知」");
|
||||
const exactCritical = htmlFor({ urgency: "critical", quietHoursBypassed: true });
|
||||
ok(has(exactCritical, tag("紧急·安全域")), "精确 critical 显示安全域标签");
|
||||
ok(urgencyTag({ urgency: "critical" }).tone === "err", "critical 用 err 色调");
|
||||
ok(urgencyTag({ urgency: "warning" }).tone === "warn", "精确 warning 用 warn 色调");
|
||||
ok(!has(htmlFor({ urgency: "routine", shouldNotify: false }), QUIET_HOURS_BYPASS_LABEL), "没标记穿透就不画穿透");
|
||||
ok(!has(htmlFor({ urgency: "routine", shouldNotify: false }), tag(URGENCY_LOWER_BOUND_PREFIX + "例常")), "精确 routine 不带下限前缀");
|
||||
|
||||
// ── ③ 键缺席(窗口外的轮次 / 实时帧)⇒ 显示「档位未知」,不是「例常」 ────────
|
||||
// ── ② ≥ 下限(旧行,`urgencyLowerBound:true`)⇒ 必须与精确档位画得不一样 ──────
|
||||
const lowerWarn = htmlFor({ urgency: "warning", urgencyLowerBound: true, quietHoursBypassed: true });
|
||||
ok(has(lowerWarn, tag(URGENCY_LOWER_BOUND_PREFIX + "紧急(高)")), "下限 warning 必须显示为「≥ 紧急(高)」");
|
||||
ok(!has(lowerWarn, tag("紧急(高)")), "下限**不得**与精确档位同形(否则读者会把下限当精确值)");
|
||||
ok(has(lowerWarn, QUIET_HOURS_BYPASS_LABEL), "旧行的穿透标记照画");
|
||||
const lowerRoutine = htmlFor({ urgency: "routine", urgencyLowerBound: true, shouldNotify: false });
|
||||
ok(has(lowerRoutine, tag(URGENCY_LOWER_BOUND_PREFIX + "例常")), "下限 routine 显示「≥ 例常」");
|
||||
ok(!has(lowerRoutine, tag("例常")), "下限 routine 不得与精确「例常」同形");
|
||||
ok(
|
||||
urgencyTag({ urgency: "warning", urgencyLowerBound: true }).label !== urgencyTag({ urgency: "warning" }).label,
|
||||
"同一个档位词:下限与精确的标签必须不同",
|
||||
);
|
||||
|
||||
// ── ③ 未知(键缺席)⇒ 显示「档位未知」,绝不写成「例常」 ─────────────────────
|
||||
const unknown = htmlFor({ shouldNotify: true, pushed: true });
|
||||
ok(has(unknown, `>${URGENCY_UNKNOWN_LABEL}<`), "没有档位时标签正是「档位未知」");
|
||||
ok(!has(unknown, ">例常<"), "标签绝不能是「例常」——那就是把猜当事实");
|
||||
ok(has(unknown, tag(URGENCY_UNKNOWN_LABEL)), "没有档位时标签正是「档位未知」");
|
||||
ok(!has(unknown, tag("例常")), "标签绝不能是「例常」——那就是把猜当事实");
|
||||
ok(!has(unknown, QUIET_HOURS_BYPASS_LABEL), "没有穿透标记时不画穿透");
|
||||
ok(urgencyTag({}).label === URGENCY_UNKNOWN_LABEL, "缺档位时标签是未知");
|
||||
ok(!has(htmlFor({ urgency: "", urgencyLowerBound: true }), URGENCY_LOWER_BOUND_PREFIX), "空串档位是「未知」,不因带下限标记就编一个档位");
|
||||
|
||||
// ── ④ 后端加了本面板不认识的档位 ⇒ 原样显示,不猜更好听的词 ─────────────────
|
||||
const exotic = urgencyTag({ urgency: "emergency" });
|
||||
ok(exotic.label === "emergency", "未知档位原样显示");
|
||||
ok(!has(htmlFor({ urgency: "emergency" }), ">例常<"), "未知档位不得被解释成「例常」");
|
||||
ok(urgencyTag({ urgency: "emergency", urgencyLowerBound: true }).label === "≥ emergency", "未知档位同样带下限前缀");
|
||||
ok(!has(htmlFor({ urgency: "emergency" }), tag("例常")), "未知档位不得被解释成「例常」");
|
||||
|
||||
// ── ⑤ 安全域档位有独立标签与色调 ────────────────────────────────────────────
|
||||
ok(has(htmlFor({ urgency: "critical", quietHoursBypassed: true }), "紧急·安全域"), "critical 显示安全域标签");
|
||||
ok(urgencyTag({ urgency: "critical" }).tone === "err", "critical 用 err 色调");
|
||||
ok(urgencyTag({ urgency: "warning" }).tone === "warn", "warning(下限)用 warn 色调");
|
||||
// ── ⑤ 两种色调 / 穿透标记的显式语义 ────────────────────────────────────────
|
||||
ok(urgencyTag({ urgency: "routine" }).tone === "neutral", "routine 用中性色调");
|
||||
ok(urgencyTag({}).label === URGENCY_UNKNOWN_LABEL, "缺档位时标签是未知");
|
||||
ok(urgencyTag({ urgency: "warning", urgencyLowerBound: true }).tone === "warn", "下限 warning 仍用 warn 色调");
|
||||
ok(quietHoursBypassTag({ quietHoursBypassed: false }) === null, "显式 false 不画穿透(后端只发 true,双向都不画)");
|
||||
ok(quietHoursBypassTag({}) === null, "键缺席不画穿透");
|
||||
|
||||
// ── ⑥ 两个标记同源可追:提示文字解释「为什么是这个档位」 ─────────────────────
|
||||
ok(has(bypassed, "title="), "标记带悬停说明(title),让人能追到判据");
|
||||
ok(has(urgencyTag({ urgency: "warning" }).hint, "至少 high") || has(urgencyTag({ urgency: "warning" }).hint, "high"), "档位下限的说明必须点明它只是下限");
|
||||
// ── ⑥ 悬停说明必须说清「为什么是精确 / 为什么只能是下限 / 为什么未知」 ────────
|
||||
ok(has(exactWarn, "title="), "标记带悬停说明(title),让人能追到判据");
|
||||
ok(has(urgencyTag({ urgency: "warning", urgencyLowerBound: true }).hint, "至少 high"), "下限的说明必须点明它只是下限");
|
||||
ok(has(urgencyTag({ urgency: "warning", urgencyLowerBound: true }).hint, "不是精确档位"), "下限的说明必须点明它不是精确档位");
|
||||
ok(has(urgencyTag({ urgency: "warning", urgencyLowerBound: true }).hint, "没有存档档位"), "下限的说明必须写清为什么旧行只能是下限");
|
||||
ok(!has(urgencyTag({ urgency: "warning" }).hint, "下限"), "精确档位的说明不得自称下限");
|
||||
ok(has(urgencyTag({}).hint, "不是「例常」"), "未知的说明必须点明它不是「例常」");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`BackthinkUrgencyTags.render: ${checks} checks passed`);
|
||||
@@ -7,8 +7,10 @@ import { quietHoursBypassTag, urgencyTag } from "./backthinkUrgency";
|
||||
* 为什么单独抽成组件:它是**画出来才算数**的一条(后端字段对了、面板没显示等于没
|
||||
* 做),因此需要一个真实渲染的测试盯着它 —— 见 BackthinkUrgencyTags.render.test.ts。
|
||||
*
|
||||
* ⚠️ 实时帧(bus 上原始 `backthink.Record`)目前不带档位 ⇒ 本次会话新到的行显示
|
||||
* 「档位未知」,重开页面后由历史拉取(`GET /admin/backthink`)补上真值。 */
|
||||
* ⚠️ 实时帧(bus 上原始 `backthink.Record`)自 X2a 起带 `urgency`(该轮实际生效的
|
||||
* **精确**档位),因此实时行也显示档位;`urgencyLowerBound` 与 `quietHoursBypassed`
|
||||
* 只由历史拉取(`GET /admin/backthink`)下发(两者都是网关按安静时段窗口推导的,
|
||||
* 不在记录里)⇒ 本次会话新到的旧行可能先显示「档位未知」,重开页面后由历史补齐。 */
|
||||
export function BackthinkUrgencyTags({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
const urgency = urgencyTag(item);
|
||||
const bypass = quietHoursBypassTag(item);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { BackthinkItem } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* 后台思考行「紧急度 / 穿透安静时段」标记的**纯逻辑**(契约 v0.68 §4.10(b))。
|
||||
* 后台思考行「紧急度 / 穿透安静时段」标记的**纯逻辑**(契约 v0.70 §4.10(b))。
|
||||
*
|
||||
* 后端 `urgency` 的语义是**存档可证的档位下限**(`warning` = 至少 high 档,可能更
|
||||
* 高),不是精确档位;而窗口之外的轮次存档里没有档位,键**缺席**。因此本层:
|
||||
* - 缺席 ⇒ 显示「档位未知」(**不是**「例常」——把未知画成事实就是这次要避免的坑);
|
||||
* - 未知取值 ⇒ 原样显示后端给的词,不猜一个更好听的;
|
||||
* 三种态必须画得**不一样**,否则就是把猜当事实:
|
||||
* - **精确档位**:后端 `urgency` 就是该轮实际生效的档位(随记录落库,`urgencyLowerBound` 缺席);
|
||||
* - **≥ 下限**:`urgencyLowerBound:true` ⇒ 该行没有落库档位(旧行 / 未走到档位判定点的行),
|
||||
* 此值由安静时段窗口规则推导:`≥ 高` = 至少 high、可能更高;`≥ 例常` = 只确定不低于最低档;
|
||||
* - **未知**:`urgency` 键缺席 ⇒ 存档里没有这个事实(**不是**「例常」)。
|
||||
* - `quietHoursBypassed` 只在 true 时渲染「穿透安静时段」。
|
||||
*/
|
||||
|
||||
@@ -15,40 +16,62 @@ export type UrgencyTagTone = "ok" | "warn" | "err" | "neutral" | "accent";
|
||||
export interface UrgencyTag {
|
||||
label: string;
|
||||
tone: UrgencyTagTone;
|
||||
/** 悬停说明(为什么是这个档位 / 为什么未知),给人看的口径。 */
|
||||
/** 悬停说明(为什么是这个档位 / 为什么只是下限 / 为什么未知),给人看的口径。 */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** 档位未知时的标签(键缺席 = 后端没有这个事实)。 */
|
||||
export const URGENCY_UNKNOWN_LABEL = "档位未知";
|
||||
|
||||
/** 下限标签的前缀:推导值一律带它,保证与精确档位**一眼可辨**。 */
|
||||
export const URGENCY_LOWER_BOUND_PREFIX = "≥ ";
|
||||
|
||||
/** 穿透安静时段的标签。 */
|
||||
export const QUIET_HOURS_BYPASS_LABEL = "穿透安静时段";
|
||||
|
||||
/** urgencyTag 把后端 `urgency` 映射成标签;键缺席时给「档位未知」。 */
|
||||
export function urgencyTag(item: Pick<BackthinkItem, "urgency">): UrgencyTag {
|
||||
/** 精确档位的标签:后端 `urgency` 词汇 → 人话。 */
|
||||
const EXACT_LABEL: Record<string, string> = {
|
||||
routine: "例常",
|
||||
warning: "紧急(高)",
|
||||
critical: "紧急·安全域",
|
||||
};
|
||||
|
||||
/** 精确档位的色调(安全域 err / 高 warn / 其余中性)。 */
|
||||
function exactTone(level: string): UrgencyTagTone {
|
||||
if (level === "critical") return "err";
|
||||
if (level === "warning") return "warn";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
/** 下限的悬停说明:点明它是怎么推出来的、以及为什么它**不是**精确值。 */
|
||||
const LOWER_BOUND_HINT =
|
||||
"这一行没有存档档位(档位列出现之前的旧记录,或从未走到档位判定点的行)⇒ 精确档位已无从得知," +
|
||||
"此值是安静时段窗口规则推导出的下限:窗口内被放行 ⇒ 至少 high(可能是安全域);" +
|
||||
"被扣下 ⇒ 只确定不低于最低档。不是精确档位。";
|
||||
|
||||
/** urgencyTag 把后端 `urgency` + `urgencyLowerBound` 映射成标签。 */
|
||||
export function urgencyTag(item: Pick<BackthinkItem, "urgency" | "urgencyLowerBound">): UrgencyTag {
|
||||
const level = item.urgency;
|
||||
switch (level) {
|
||||
case "routine":
|
||||
return { label: "例常", tone: "neutral", hint: "安静时段内被扣下的轮次:档位低于 high,不穿透" };
|
||||
case "warning":
|
||||
return {
|
||||
label: "紧急(≥ 高)",
|
||||
tone: "warn",
|
||||
hint: "安静时段内仍被放行 ⇒ 档位至少 high;记录里无法区分 high 与 critical,故显示下限",
|
||||
};
|
||||
case "critical":
|
||||
return { label: "紧急·安全域", tone: "err", hint: "安全域事件(可穿透工作/忙碌与安静时段)" };
|
||||
case undefined:
|
||||
case "":
|
||||
return {
|
||||
label: URGENCY_UNKNOWN_LABEL,
|
||||
tone: "neutral",
|
||||
hint: "这一轮的档位在存档里不可证(轮次不在安静时段窗口内,记录不保存档位)——不是「例常」",
|
||||
};
|
||||
default:
|
||||
return { label: level, tone: "neutral", hint: "后端给了本面板不认识的档位,原样显示" };
|
||||
if (level === undefined || level === "") {
|
||||
return {
|
||||
label: URGENCY_UNKNOWN_LABEL,
|
||||
tone: "neutral",
|
||||
hint: "这一轮的档位在存档里不存在(该行没有落库档位,且不在安静时段窗口内 —— 窗口之外判定不看档位)——不是「例常」",
|
||||
};
|
||||
}
|
||||
if (item.urgencyLowerBound === true) {
|
||||
return {
|
||||
// 未知取值原样显示(后端加了本面板不认识的档位时不猜一个更好听的词),但下限前缀照加。
|
||||
label: URGENCY_LOWER_BOUND_PREFIX + (EXACT_LABEL[level] ?? level),
|
||||
tone: exactTone(level),
|
||||
hint: LOWER_BOUND_HINT,
|
||||
};
|
||||
}
|
||||
const exact = EXACT_LABEL[level];
|
||||
if (exact === undefined) {
|
||||
return { label: level, tone: "neutral", hint: "后端给了本面板不认识的档位,原样显示" };
|
||||
}
|
||||
return { label: exact, tone: exactTone(level), hint: `该轮实际生效的档位(精确值):${level}` };
|
||||
}
|
||||
|
||||
/** quietHoursBypassTag 只在后端确实标记了穿透时返回标签。 */
|
||||
|
||||
@@ -45,12 +45,18 @@ export interface BackthinkItem {
|
||||
/** 事件驱动推送绕过了限频闸(后端 PushResult.Bypassed,"该发就发")。
|
||||
* ⚠️ 与 `quietHoursBypassed`(安静时段穿透)**是两件事**:这个说的是限频门。 */
|
||||
bypassed?: boolean;
|
||||
/** 该轮紧急度的**存档可证下限**(后端 `urgency`,`internal/urgency` 档位词汇:
|
||||
* `routine` | `warning` | `critical`)。`warning` 读作「至少 high 档(可能更高)」。
|
||||
* 缺席 = 后端没有这个事实(轮次不在安静时段窗口内时无从反推),**不是**「例常」。
|
||||
* ⚠️ 实时帧走的是 bus 上原始的 `backthink.Record`,它目前**不带**档位 ⇒ 实时行显示
|
||||
* 「档位未知」,重开页面由历史拉取(`GET /admin/backthink`)补齐。 */
|
||||
/** 该轮紧急度档位(后端 `urgency`,`internal/urgency` 档位词汇:
|
||||
* `routine` | `warning` | `critical`)。**默认是精确档位**(该轮实际生效的档位,随记录落库);
|
||||
* 与 `urgencyLowerBound:true` 同时出现时才是**推导出的下限**(`≥ 高` = 至少 high、可能更高)。
|
||||
* 缺席 = 后端没有这个事实,**不是**「例常」。
|
||||
* ⚠️ 实时帧走的是 bus 上原始的 `backthink.Record`,自 X2a 起该记录带上 `urgency`(同样是
|
||||
* 精确值);但 `urgencyLowerBound` / `quietHoursBypassed` 只由历史拉取(`GET /admin/backthink`)
|
||||
* 下发(后两者是网关按安静时段窗口推导的,不在记录里)。 */
|
||||
urgency?: string;
|
||||
/** 后端标记「`urgency` 只是下限」时为 true:该行没有落库档位(`urgency` 列出现之前的旧行,
|
||||
* 或从未走到档位判定点的行),值由安静时段窗口规则反推。后端只在为 `true` 时下发该键;
|
||||
* 缺席(且 `urgency` 存在)= 精确档位。**不得把带此标记的值显示成精确档位。** */
|
||||
urgencyLowerBound?: boolean;
|
||||
/** 该轮**穿透了安静时段**(轮次时间落在安静时段窗口内且该轮判定要推送)。后端只在
|
||||
* `true` 时下发该键;缺席 = 没有这件事,**不等于**「确认没穿透」。 */
|
||||
quietHoursBypassed?: boolean;
|
||||
|
||||
@@ -301,12 +301,14 @@ export function parseBackthinkItem(v: unknown): BackthinkItem | null {
|
||||
const detail = firstPushText(pr.Detail, pr.detail, r.detail, r.push_detail);
|
||||
const bypassedRaw = pr.Bypassed ?? pr.bypassed ?? r.bypassed ?? r.push_bypassed;
|
||||
const bypassed = typeof bypassedRaw === "boolean" ? bypassedRaw : undefined;
|
||||
// v0.68 的档位/穿透:**防御性读取**(兼容 camelCase 与 Go 字段名)。当前 bus 上发布的
|
||||
// 是原始 `backthink.Record`,它**不带**这两个字段 ⇒ 今天恒为 undefined(本次会话新到
|
||||
// 的行显示「档位未知」,重开页面由历史拉取补真值)。一旦后端把这两个事实放进帧里,
|
||||
// 这里无需改动就会显示出来 —— 但绝不因为「帧里没有」就编一个值。
|
||||
// v0.68 的档位/穿透:**防御性读取**(兼容 camelCase 与 Go 字段名)。X2a 起 bus 上发布的
|
||||
// 原始 `backthink.Record` 已带 `urgency`(该轮实际生效的**精确**档位)⇒ 实时行同样显示档位;
|
||||
// `urgencyLowerBound` / `quietHoursBypassed` 不在记录里(由网关按安静时段窗口推导),
|
||||
// 这里照旧防御性读取,一旦帧里带上就无需改动地显示出来 —— 但绝不因为「帧里没有」就编一个值。
|
||||
const urgencyRaw = r.urgency ?? r.Urgency ?? r.urgency_level ?? r.urgencyLevel;
|
||||
const urgency = typeof urgencyRaw === "string" && urgencyRaw ? urgencyRaw : undefined;
|
||||
const lbRaw = r.urgencyLowerBound ?? r.UrgencyLowerBound;
|
||||
const urgencyLowerBound = typeof lbRaw === "boolean" ? lbRaw : undefined;
|
||||
const qhRaw = r.quietHoursBypassed ?? r.QuietHoursBypassed;
|
||||
const quietHoursBypassed = typeof qhRaw === "boolean" ? qhRaw : undefined;
|
||||
return {
|
||||
@@ -319,6 +321,7 @@ export function parseBackthinkItem(v: unknown): BackthinkItem | null {
|
||||
detail,
|
||||
bypassed,
|
||||
urgency,
|
||||
urgencyLowerBound,
|
||||
quietHoursBypassed,
|
||||
at,
|
||||
};
|
||||
|
||||
@@ -125,11 +125,11 @@ type AdminUserRecord struct {
|
||||
// 它们直接来自后端已存的 PushResult,因此面板不必靠猜来解释
|
||||
// 一次静默。
|
||||
//
|
||||
// Urgency / QuietHoursBypassed(v0.68)回答「这一轮有多紧急、是不是
|
||||
// 穿透了安静时段」。二者都**只在存档可证时**出现,缺席 ≠ 常规档位:
|
||||
// 推导依据见 admin_backthink_urgency.go(那一轮的 `At` 落在安静时段窗口内
|
||||
// 时,判定级联已把档位锁死到可证的范围;窗口之外的轮次存档里没有档位,
|
||||
// 因此不下发,而不是编一个 routine)。
|
||||
// Urgency / UrgencyLowerBound / QuietHoursBypassed 回答「这一轮有多紧急、
|
||||
// 是不是穿透了安静时段」。三者都**只在存档可证时**出现,缺席 ≠ 常规档位:
|
||||
// 取值来源与优先级见 admin_backthink_urgency.go —— 优先读每轮落库的**精确**
|
||||
// 档位;只有 `urgencyLowerBound:true` 的行才是**推导出的下限**(旧行按安静时段
|
||||
// 窗口规则反推),窗口之外且无落库档位的旧行干脆不下发,而不是编一个 routine。
|
||||
type AdminBackthinkItem struct {
|
||||
ID string `json:"id"`
|
||||
Trigger string `json:"trigger"`
|
||||
@@ -143,10 +143,17 @@ type AdminBackthinkItem struct {
|
||||
// Bypassed 表示这是事件驱动的推送、绕过了限频闸("该发就发")。
|
||||
Bypassed bool `json:"bypassed"`
|
||||
At int64 `json:"at"`
|
||||
// Urgency 是这一轮紧急度的**存档可证下限**(urgency 档位词汇:
|
||||
// routine | warning | critical)。`warning` 读作「≥ high 档(可能更高)」;
|
||||
// 无法证明时省略该键(面板显示「未知」)。
|
||||
// Urgency 是这一轮的紧急度档位(urgency 档位词汇:routine | warning |
|
||||
// critical)。**默认读到的是该轮实际生效的精确档位**(每一轮随记录落库);
|
||||
// 只有与 `urgencyLowerBound:true` 同时出现时才读作「≥ 该档(可能更高)」;
|
||||
// 无法证明时省略该键(面板显示「未知」,**不是**「例常」)。
|
||||
Urgency string `json:"urgency,omitempty"`
|
||||
// UrgencyLowerBound 为 true 时表示 Urgency 是**推导出的下限**而不是精确档位:
|
||||
// 该行没有落库档位(`backthink_records.urgency` 列出现之前的旧行,或从未走到
|
||||
// 档位判定点的行),值由安静时段窗口规则反推(见 admin_backthink_urgency.go)。
|
||||
// 只在为 true 时出现:精确档位与「无档位」都不必在线上区分。客户端**不得**把
|
||||
// 它显示成精确档位。
|
||||
UrgencyLowerBound bool `json:"urgencyLowerBound,omitempty"`
|
||||
// QuietHoursBypassed 为 true 时表示这一轮**穿透了安静时段**(轮次时间落在
|
||||
// 安静时段窗口内且该轮判定要推送)。只在为 true 时出现:安静时段之外的轮次
|
||||
// 与「没穿透」不必在线上区分,缺席即「没有这件事」。
|
||||
|
||||
@@ -8,53 +8,64 @@ import (
|
||||
)
|
||||
|
||||
// 本文件把「这一轮后台思考有多紧急、是不是穿透了安静时段」变成**存档里可证**的
|
||||
// 事实(切片 P4)。它刻意只读 `backthink.Record` 已有的字段 —— 本片不动
|
||||
// `internal/services/backthink/**`(通用紧急度通道的落库属那片的面):
|
||||
// 事实。档位有两个来源,**优先级明确**(X2b):
|
||||
//
|
||||
// - `Record.ShouldPush` 是那一轮的打扰判定结果;
|
||||
// - `Record.At` 是那一轮的墙钟时间(库里存 UTC,取钟点前必须换回本地
|
||||
// 时区,因为安静时段是**本地钟点**窗口);
|
||||
// - 安静时段窗口取**进程生效**的 `QUIET_START_HOUR`/`QUIET_END_HOUR`
|
||||
// (`config.Background`,与 backthink 的 cfg 同源:组合根把同一对值交给它)。
|
||||
// 1. **精确档位**:`Record.Urgency` 由 backthink 在档位判定点原样落库,是那一轮
|
||||
// **实际生效**的档位。非空 ⇒ 直接下发,**不标记下限**,也不受安静时段窗口约束
|
||||
// (它与 `At` 无关,是独立落库的事实,因此时间戳坏掉的行同样如实下发)。
|
||||
// 2. **下限推导**(只对 `Record.Urgency` 为空的行:该列出现之前的旧行,以及从未走到
|
||||
// 档位判定点的行 —— 系统事件降级、思考失败):用安静时段窗口规则推导,
|
||||
// 结果**必须**带 `urgencyLowerBound` 标记,绝不能被读成精确档位。推导依据:
|
||||
// - `Record.ShouldPush` 是那一轮的打扰判定结果;
|
||||
// - `Record.At` 是那一轮的墙钟时间(库里存 UTC,取钟点前必须换回本地时区,
|
||||
// 因为安静时段是**本地钟点**窗口);
|
||||
// - 安静时段窗口取**进程生效**的 `QUIET_START_HOUR`/`QUIET_END_HOUR`
|
||||
// (`config.Background`,与 backthink 的 cfg 同源:组合根把同一对值交给它)。
|
||||
//
|
||||
// 由 `backthink.Decide` 的判定级联(`decide.go` 的 baseDecide)可证:
|
||||
// 由 `backthink.Decide` 的判定级联(`decide.go` 的 baseDecide)可证:
|
||||
// - 安静时段内被**放行** ⇒ 该轮重要度必然 ≥ `ImportanceHigh`(更低档位在安静
|
||||
// 时段一律被扣下)⇒ 档位**至少** warning(high)。这就是 `quietHoursBypassed`
|
||||
// 的全部依据,不是启发式。
|
||||
// - 安静时段内被**扣下** ⇒ 档位不足以放行(也可能是她自己的结论要求不推,
|
||||
// 见 `Decide` 的首个分支)⇒ 只能给出「不低于最低档」这个下限。
|
||||
// - 安静时段**之外** ⇒ 判定根本不看档位(「想不想说」由她自己的结论决定),
|
||||
// 并且推送成功的行其 `PushResult.Detail` 已被投递说明覆盖 ⇒ 存档里没有任何
|
||||
// 字段能反推档位。此时**不下发** urgency(面板显示「未知」),绝不猜。
|
||||
//
|
||||
// - 安静时段内被**放行** ⇒ 该轮重要度必然 ≥ `ImportanceHigh`(更低档位在安静
|
||||
// 时段一律被扣下)⇒ 这一轮**穿透了安静时段**,档位**至少**是 `warning`
|
||||
// (high)。这就是 `quietHoursBypassed` 的全部依据,不是启发式。
|
||||
// - 安静时段内被**扣下** ⇒ 档位 < high ⇒ `routine`,未穿透。
|
||||
// - 安静时段**之外** ⇒ 判定根本不看档位(「想不想说」由她自己的结论决定),
|
||||
// 并且推送成功的行其 `PushResult.Detail` 已被投递说明覆盖 ⇒ 存档里没有任何
|
||||
// 字段能反推档位。此时**不下发** urgency(面板显示「未知」),绝不猜。
|
||||
//
|
||||
// 因此 `AdminBackthinkItem.Urgency` 的语义是「**存档可证的档位下限**」而不是
|
||||
// 「精确档位」:`warning` 读作「≥ high(含 critical)」。要做到精确档位,需要
|
||||
// backthink 把每轮的 `Import` 落进 `Record`(本片不做,已在契约 §4.10(c) 与
|
||||
// §7.4-17 如实登记)。
|
||||
// fail-safe:`At` 为零值(时间戳格式错乱的行,见 backthink.store.parseTime 的兜底)
|
||||
// 时不判下限、也不判穿透 —— 宁可不显示,也不把零值时间当成凌晨而谎报一轮穿透。
|
||||
|
||||
// backthinkQuietOutcome 是「该轮与安静时段的关系」的推导结果。
|
||||
// backthinkQuietOutcome 是「该轮紧急度与安静时段的关系」的推导结果。
|
||||
type backthinkQuietOutcome struct {
|
||||
// urgency 是档位下限标签(urgency.Level 的词汇),无法证明时为空串。
|
||||
// urgency 是档位标签(urgency.Level 的词汇),无事实时为空串。
|
||||
urgency string
|
||||
// urgencyLowerBound 报告 urgency 是**推导出的下限**而不是该轮的精确档位。
|
||||
// 只在旧行(没有落库档位)上为 true;精确档位与「无档位」都是 false。
|
||||
urgencyLowerBound bool
|
||||
// bypassed 报告该轮是否穿透了安静时段(安静时段内仍被放行)。
|
||||
bypassed bool
|
||||
}
|
||||
|
||||
// backthinkQuietOutcomeOf 推导一条存档记录可证的紧急度下限与穿透标记。
|
||||
//
|
||||
// `at` 为零值(时间戳格式错乱的行,见 backthink.store.parseTime 的兜底)时一律
|
||||
// 返回「无事实」:宁可不显示,也不把零值时间当成凌晨而谎报一轮穿透。
|
||||
// backthinkQuietOutcomeOf 推导一条存档记录可证的紧急度与穿透标记。
|
||||
func backthinkQuietOutcomeOf(rec backthink.Record, startHour, endHour int) backthinkQuietOutcome {
|
||||
if rec.At.IsZero() {
|
||||
return backthinkQuietOutcome{}
|
||||
}
|
||||
if !quietHoursActiveAt(startHour, endHour, rec.At.Local()) {
|
||||
return backthinkQuietOutcome{}
|
||||
// 精确档位优先:X2a 起该列随每轮落库,读到即事实(与窗口、与 At 都无关)。
|
||||
out := backthinkQuietOutcome{urgency: rec.Urgency}
|
||||
if rec.At.IsZero() || !quietHoursActiveAt(startHour, endHour, rec.At.Local()) {
|
||||
return out
|
||||
}
|
||||
if rec.ShouldPush {
|
||||
return backthinkQuietOutcome{urgency: urgency.LevelWarning.String(), bypassed: true}
|
||||
out.bypassed = true
|
||||
}
|
||||
return backthinkQuietOutcome{urgency: urgency.LevelRoutine.String()}
|
||||
if out.urgency != "" {
|
||||
return out
|
||||
}
|
||||
// 旧行:窗口规则给出的**下限**,必须带上标记。
|
||||
out.urgencyLowerBound = true
|
||||
out.urgency = urgency.LevelRoutine.String()
|
||||
if rec.ShouldPush {
|
||||
out.urgency = urgency.LevelWarning.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quietHoursActiveAt 报告 at 是否落在安静时段窗口内。口径与既有三处逐字一致
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -12,8 +13,9 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
|
||||
)
|
||||
|
||||
// 本文件是 P4「后台思考行紧急度 / 穿透安静时段」的测试:钉住
|
||||
// 「穿透标记 ⇔ 档位下限」的对应关系,以及「不可证时不下发」的诚实边界。
|
||||
// 本文件是 P4「后台思考行紧急度 / 穿透安静时段」与其 X2b 扩展(精确档位 + 下限标记)
|
||||
// 的测试:钉住「穿透标记 ⇔ 档位」的对应关系、「精确 vs 只是下限 vs 未知」三态在线上的
|
||||
// 可分辨性,以及「不可证时不下发」的诚实边界。
|
||||
|
||||
// quietCfg 构造安静时段 = 23:00-07:00(跨午夜)的生效配置。
|
||||
func quietCfg() *config.Config {
|
||||
@@ -67,22 +69,22 @@ func TestAdminBackthink_QuietHoursBypassMatchesUrgency(t *testing.T) {
|
||||
}
|
||||
// ① 安静时段内被放行 ⇒ 穿透 + 档位下限 warning(= 至少 high)。
|
||||
bypass := byID["3"]
|
||||
if !bypass.QuietHoursBypassed || bypass.Urgency != urgency.LevelWarning.String() {
|
||||
t.Fatalf("穿透行 = %+v, want urgency=%s + quietHoursBypassed=true", bypass, urgency.LevelWarning)
|
||||
if !bypass.QuietHoursBypassed || bypass.Urgency != urgency.LevelWarning.String() || !bypass.UrgencyLowerBound {
|
||||
t.Fatalf("穿透行 = %+v, want urgency=%s + quietHoursBypassed=true + 下限标记", bypass, urgency.LevelWarning)
|
||||
}
|
||||
// ② 安静时段内被扣下 ⇒ routine,且不得标成穿透。
|
||||
held := byID["2"]
|
||||
if held.QuietHoursBypassed || held.Urgency != urgency.LevelRoutine.String() {
|
||||
t.Fatalf("静默行 = %+v, want urgency=routine 且不穿透", held)
|
||||
if held.QuietHoursBypassed || held.Urgency != urgency.LevelRoutine.String() || !held.UrgencyLowerBound {
|
||||
t.Fatalf("静默行 = %+v, want urgency=routine(带下限标记)且不穿透", held)
|
||||
}
|
||||
// ③ 安静时段之外 ⇒ 两个键都不下发(存档不可证)。
|
||||
day := byID["1"]
|
||||
if day.QuietHoursBypassed || day.Urgency != "" {
|
||||
if day.QuietHoursBypassed || day.Urgency != "" || day.UrgencyLowerBound {
|
||||
t.Fatalf("白天行 = %+v, want 两个键都缺席", day)
|
||||
}
|
||||
// 线上形状:真穿透的行必须带这两个键;不可证的行必须**不带**它们(不是 false)。
|
||||
if !strings.Contains(body, `"urgency":"warning"`) || !strings.Contains(body, `"quietHoursBypassed":true`) {
|
||||
t.Fatalf("响应缺少穿透/档位键:%s", body)
|
||||
// 线上形状:真穿透的行必须带这三个键;不可证的行必须**不带**它们(不是 false)。
|
||||
if !strings.Contains(body, `"urgency":"warning"`) || !strings.Contains(body, `"urgencyLowerBound":true`) || !strings.Contains(body, `"quietHoursBypassed":true`) {
|
||||
t.Fatalf("响应缺少穿透/档位/下限键:%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"urgency":"routine"`) {
|
||||
t.Fatalf("响应缺少被扣下那轮的档位:%s", body)
|
||||
@@ -166,3 +168,139 @@ func TestBackthinkQuietOutcomeOf_ReadsLocalHour(t *testing.T) {
|
||||
t.Fatalf("UTC 存储的本地凌晨 2 点必须判为穿透:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackthinkQuietOutcomeOf_ExactUrgencyWinsOverWindow 钉住取值优先级(X2b):
|
||||
// 落库档位非空 ⇒ **精确值**(与 `At`、窗口都无关,也**不标**下限);空 ⇒ 窗口推导
|
||||
// (一定**标**下限);窗口外且无档位 ⇒ 无事实(空,不标)。
|
||||
func TestBackthinkQuietOutcomeOf_ExactUrgencyWinsOverWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 精确档位:零值时间、白天、窗口内一律逐字下发,且都不是下限。
|
||||
for _, at := range []time.Time{{}, localAt(12), localAt(2)} {
|
||||
got := backthinkQuietOutcomeOf(
|
||||
backthink.Record{At: at, Urgency: urgency.LevelCritical.String(), ShouldPush: false}, 23, 7)
|
||||
if got.urgency != urgency.LevelCritical.String() || got.urgencyLowerBound {
|
||||
t.Fatalf("at=%v 的精确档位 = %+v, want critical 且无下限标记", at, got)
|
||||
}
|
||||
}
|
||||
// 旧行(空档位)落在窗口内 ⇒ 值来自推导,必须标下限。
|
||||
pushed := backthinkQuietOutcomeOf(backthink.Record{At: localAt(2), ShouldPush: true}, 23, 7)
|
||||
if pushed.urgency != urgency.LevelWarning.String() || !pushed.urgencyLowerBound || !pushed.bypassed {
|
||||
t.Fatalf("窗口内放行的旧行 = %+v, want warning + 下限标记 + 穿透", pushed)
|
||||
}
|
||||
held := backthinkQuietOutcomeOf(backthink.Record{At: localAt(2), ShouldPush: false}, 23, 7)
|
||||
if held.urgency != urgency.LevelRoutine.String() || !held.urgencyLowerBound || held.bypassed {
|
||||
t.Fatalf("窗口内扣下的旧行 = %+v, want routine + 下限标记 + 不穿透", held)
|
||||
}
|
||||
// 窗口外的旧行:没有事实,什么都不下发(也不标下限)。
|
||||
day := backthinkQuietOutcomeOf(backthink.Record{At: localAt(12), ShouldPush: true}, 23, 7)
|
||||
if day.urgency != "" || day.urgencyLowerBound || day.bypassed {
|
||||
t.Fatalf("窗口外的旧行 = %+v, want 全空", day)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_ExactUrgencyIsNotLowerBound 是 X2b 的线上判据:带落库档位的新行
|
||||
// ⇒ 精确档位且**不带**下限标记;空档位的旧行 ⇒ 窗口推导且**带**下限标记;窗口外且
|
||||
// 无档位 ⇒ 键缺席(未知)。三态必须在线形状上可分辨 —— 「不许谎报」就是这条。
|
||||
func TestAdminBackthink_ExactUrgencyIsNotLowerBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := &stubBackthinkFeed{records: []backthink.Record{
|
||||
{ // ① 新行 + 安静时段内 + 放行 ⇒ 精确 critical(不是「≥ 高」的下限),仍算穿透。
|
||||
ID: 40, Trigger: "iot.device.smoke", At: localAt(2), Conclusion: "烟雾告警", Urgency: "critical",
|
||||
ShouldPush: true,
|
||||
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "delivered"},
|
||||
},
|
||||
{ // ② 新行 + 白天 ⇒ 精确 routine(窗口规则给不出任何档位,精确值照样可得)。
|
||||
ID: 39, Trigger: "heartbeat", At: localAt(12), Conclusion: "白天的一轮", Urgency: "routine",
|
||||
ShouldPush: false,
|
||||
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "conclusion requested no push"},
|
||||
},
|
||||
{ // ③ 新行 + 时间戳坏掉 ⇒ 精确档位与窗口无关,如实下发;但不判穿透。
|
||||
ID: 38, Trigger: "broken clock", Urgency: "warning", ShouldPush: true,
|
||||
},
|
||||
{ // ④ 旧行(空档位)+ 安静时段内 + 放行 ⇒ 下限 warning + 下限标记。
|
||||
ID: 37, Trigger: "legacy.pushed", At: localAt(2), Conclusion: "旧行(窗口内放行)", ShouldPush: true,
|
||||
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteIMBridge, Detail: "delivered via IM bridge"},
|
||||
},
|
||||
{ // ⑤ 旧行(空档位)+ 安静时段内 + 扣下 ⇒ 下限 routine + 下限标记。
|
||||
ID: 36, Trigger: "legacy.held", At: localAt(3), Conclusion: "旧行(窗口内扣下)", ShouldPush: false,
|
||||
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "quiet hours; not interrupting"},
|
||||
},
|
||||
{ // ⑥ 旧行(空档位)+ 白天 ⇒ 键缺席(未知),绝不猜一个 routine。
|
||||
ID: 35, Trigger: "legacy.day", At: localAt(12), Conclusion: "旧行(白天)", ShouldPush: true,
|
||||
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Detail: "delivered"},
|
||||
},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
|
||||
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if len(items) != 6 {
|
||||
t.Fatalf("items = %d, want 6", len(items))
|
||||
}
|
||||
byID := make(map[string]AdminBackthinkItem, len(items))
|
||||
for _, it := range items {
|
||||
byID[it.ID] = it
|
||||
}
|
||||
for _, want := range []struct {
|
||||
id, urgency string
|
||||
bypassed bool
|
||||
}{
|
||||
{"40", urgency.LevelCritical.String(), true},
|
||||
{"39", urgency.LevelRoutine.String(), false},
|
||||
{"38", urgency.LevelWarning.String(), false},
|
||||
} {
|
||||
got := byID[want.id]
|
||||
if got.Urgency != want.urgency || got.UrgencyLowerBound || got.QuietHoursBypassed != want.bypassed {
|
||||
t.Fatalf("精确行 %s = %+v, want urgency=%s + 无下限标记 + bypassed=%v", want.id, got, want.urgency, want.bypassed)
|
||||
}
|
||||
}
|
||||
for _, want := range []struct {
|
||||
id, urgency string
|
||||
bypassed bool
|
||||
}{
|
||||
{"37", urgency.LevelWarning.String(), true},
|
||||
{"36", urgency.LevelRoutine.String(), false},
|
||||
} {
|
||||
got := byID[want.id]
|
||||
if got.Urgency != want.urgency || !got.UrgencyLowerBound || got.QuietHoursBypassed != want.bypassed {
|
||||
t.Fatalf("旧行 %s = %+v, want urgency=%s + 下限标记 + bypassed=%v", want.id, got, want.urgency, want.bypassed)
|
||||
}
|
||||
}
|
||||
if day := byID["35"]; day.Urgency != "" || day.UrgencyLowerBound || day.QuietHoursBypassed {
|
||||
t.Fatalf("窗口外的旧行 = %+v, want 三个键都缺席", day)
|
||||
}
|
||||
// 线上形状(按 id 看**实际出现了哪些键**):下限键只许出现在旧行上,且恒为 true。
|
||||
wire := wireKeysByID(t, raw)
|
||||
for _, id := range []string{"40", "39", "38", "35"} {
|
||||
if _, ok := wire[id]["urgencyLowerBound"]; ok {
|
||||
t.Fatalf("id=%s 不是下限行,却带了 urgencyLowerBound:%v", id, wire[id])
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"37", "36"} {
|
||||
if v, ok := wire[id]["urgencyLowerBound"]; !ok || v != true {
|
||||
t.Fatalf("id=%s 是下限行,urgencyLowerBound 必须显式为 true:%v", id, wire[id])
|
||||
}
|
||||
}
|
||||
if _, ok := wire["35"]["urgency"]; ok {
|
||||
t.Fatalf("窗口外且无档位的旧行不得带 urgency:%v", wire["35"])
|
||||
}
|
||||
}
|
||||
|
||||
// wireKeysByID 按 id 给出每个条目在原始 JSON 里**实际出现了哪些键**——解码到结构体后
|
||||
// 「键缺席」与「false」不可区分,而本切片的判据正是「键在不在」(对齐既有测试的口径)。
|
||||
func wireKeysByID(t *testing.T, raw []byte) map[string]map[string]any {
|
||||
t.Helper()
|
||||
var list struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatalf("decode raw: %v (%s)", err, raw)
|
||||
}
|
||||
out := make(map[string]map[string]any, len(list.Data))
|
||||
for _, m := range list.Data {
|
||||
id, _ := m["id"].(string)
|
||||
out[id] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -391,8 +391,9 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
|
||||
Detail: rec.PushResult.Detail,
|
||||
Bypassed: rec.PushResult.Bypassed,
|
||||
At: rec.At.UnixMilli(),
|
||||
// 紧急度下限 / 是否穿透安静时段(v0.68):只报存档可证的部分。
|
||||
// 紧急度(优先精确档位,旧行带下限标记)/ 是否穿透安静时段:只报存档可证的部分。
|
||||
Urgency: quiet.urgency,
|
||||
UrgencyLowerBound: quiet.urgencyLowerBound,
|
||||
QuietHoursBypassed: quiet.bypassed,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user