feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env) - 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」 - 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事) - 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了) - 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过 - 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
This commit is contained in:
1 parent
a057a0f61f
commit
34cef95e17
25 files changed
+1741
-14
No files matched your search
+71
-8
@@ -1,12 +1,13 @@
|
||||
# Cyrene v2 客户端 API 契约
|
||||
|
||||
> **契约版本:`v0.67`**(上一版 `v0.66`,其条目见 §6;`v0.65` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **契约版本:`v0.68`**(上一版 `v0.67`,其条目见 §6;`v0.66` 及更早的基线已归档、只读:`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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
|
||||
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§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)。**无破坏性变更**,老客户端零影响。
|
||||
|
||||
---
|
||||
|
||||
@@ -119,10 +120,10 @@
|
||||
| 档位 | 中间件 | 条数 | 说明 |
|
||||
|---|:--:|:--:|---|
|
||||
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **12** | 非敏感投影:无凭据、无他人账号数据、无消息正文 |
|
||||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **11** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出 |
|
||||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **12** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68) |
|
||||
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
||||
|
||||
**管理路由总数 = 12 + 11 + 40 = 63 条。**
|
||||
**管理路由总数 = 12 + 12 + 40 = 64 条。**
|
||||
|
||||
> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。
|
||||
|
||||
@@ -143,7 +144,7 @@
|
||||
| A11 | GET | `/api/v1/admin/observability/logs` | 后端日志尾部(最新优先,`?level=&limit=`;缓冲未注入时 501) |
|
||||
| A12 | GET | `/api/v1/admin/notifications` | 统一通知 feed(审计链 + 后台思考推送的只读投影) |
|
||||
|
||||
### 2.2 owner/admin 读(11 条,`writeAuth`)
|
||||
### 2.2 owner/admin 读(12 条,`writeAuth`)
|
||||
|
||||
| # | 方法 | 路径 | 要点 |
|
||||
|---|---|---|---|
|
||||
@@ -154,10 +155,11 @@
|
||||
| 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` | 后台思考记录 |
|
||||
| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**仅在该轮档位存档可证时下发**,见 §4.10)与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增** |
|
||||
| B9 | GET | `/api/v1/admin/observability/audit` | 审计链 |
|
||||
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
|
||||
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
|
||||
| B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读)**:记忆整理 7 键 + 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 |
|
||||
|
||||
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
|
||||
|
||||
@@ -666,6 +668,46 @@
|
||||
- 空/缺省 = 继承全局默认角色(`owner.yaml:defaultPersona`)。
|
||||
- 消息 DTO 的 `personaId` = **该消息说话时的角色**(assistant = 生成它的角色,user = 当回合角色;空 = 默认角色)。
|
||||
|
||||
### 4.10 v0.68 新增:配置事实面与后台思考行的紧急度
|
||||
|
||||
#### (a) `GET /api/v1/admin/config-facts`(只读,`owner`/`admin`)
|
||||
|
||||
响应 `{data:{facts:[…], quietHours:{…}}}`:
|
||||
|
||||
| 键 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `facts[].key` | string | env 键名,**与 `.env.example` 逐字一致**(后端有测试钉住) |
|
||||
| `facts[].value` | string | **进程生效值**(已解析:bool → `"true"`/`"false"`;整数 → 十进制;比例 → 归一化小数如 `"0.8"`)。**不是** `.env` 原文 |
|
||||
| `facts[].default` | string | 该键的**声明默认值**(`internal/config` 结构体 `default:"…"` 标签,唯一真源) |
|
||||
| `facts[].isDefault` | bool | `value == default` ⇒ 面板显示「默认」(**不代表 `.env` 里没写过这个键**:网关按红线**不读 `.env`**,无从不区分「默认层」与「结构体默认」) |
|
||||
| `quietHours.startHour` / `endHour` | int | 安静时段的生效钟点(本地时区小时)。⚠️ 报的是**运行时真正生效**的那一个:两端都写 0 时按「未填」回落到声明默认(见下 `fallbackToDefault`) |
|
||||
| `quietHours.disabled` | bool | 窗口是否**关闭**(两个相等的**非 0** 钟点,如 `23/23`) |
|
||||
| `quietHours.label` | string | 面板可直接显示的文案:`"23:00-07:00"` 或 `"已关闭"` |
|
||||
| `quietHours.fallbackToDefault` | bool,**可选**(`true` 才出现) | 两端都被写成 `0` ⇒ 视为**未填**,运行时回落到默认窗口(`23:00-07:00`,此时 `startHour`/`endHour` 已是回落后的值)。⚠️ **`0/0` 不是「关闭」**:backthink 与记忆整理都会做这次回落,事实面按同一口径报,否则会把「未填」谎报成「已关闭」 |
|
||||
|
||||
- **固定 9 条事实**(记忆整理 7 键 + 安静时段 2 键):`MEMORY_TIDY_ENABLED`、`MEMORY_TIDY_INTERVAL_SEC`、`MEMORY_TIDY_QUIET_START_HOUR`、`MEMORY_TIDY_QUIET_END_HOUR`、`MEMORY_TIDY_RECALL_THRESHOLD`、`MEMORY_TIDY_SCAN_LIMIT`、`MEMORY_TIDY_CANDIDATE_LIMIT`、`QUIET_START_HOUR`、`QUIET_END_HOUR`。
|
||||
- 🔴 **不含任何凭据**:响应里不出现 JWT 密钥 / LLM API key / 渠道 token / 数据库 DSN 的值(后端有哨兵测试钉死),**也不回凭据类键的「是否已配置」** —— 本端点只覆盖上面 9 个非敏感调参键。
|
||||
- **「默认 / 已改」怎么读**:`isDefault:true` ⇒ 面板显示「默认」(生效值等于该键的声明默认值);`isDefault:false` ⇒ 显示「已改」。⚠️ 它**不是**「`.env` 里没写过这个键」的断言 —— 网关按红线**不读 `.env`**,无从不区分「`.env` 给的值恰好等于默认」与「完全没配」。**哪一项没配**因此不会被伪造成一个值:面板拿 `value` + `isDefault` 如实展示即可。
|
||||
- 9 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。
|
||||
- **安静时段的语义(重要,别照旧实现写文案)**:安静时段**只服务「定时主动思考 → 主动搭话」**(`design/12 §6` 策略五),**不静默回复** —— 白天晚上的聊天回复都不受它约束;群聊也没有夜间静默(主人裁决 10)。因此面板文案**不得**写成「夜间全静默」。
|
||||
|
||||
#### (b) `GET /api/v1/admin/backthink` 条目的 v0.68 新字段
|
||||
|
||||
| 键 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `urgency` | string,**可选** | `"routine"` / `"warning"` / `"critical"`(`internal/urgency` 的档位词汇)。**语义 = 该轮档位的「存档可证下限」**:`warning` 读作「至少 high 档(可能是 critical)」 |
|
||||
| `quietHoursBypassed` | bool,**可选**(`true` 才出现) | 该轮**穿透了安静时段**:轮次时间落在安静时段窗口内**且**该轮判定为「要推送」 |
|
||||
|
||||
- **两个键都可能缺席,缺席 ≠ 常规档位**。缺席 = **存档里没有这个事实**(见 §4.10(c)),面板应显示「未知」而**不是**「例常」。
|
||||
- `urgency` 只在该轮时间**落在安静时段窗口内**时下发:安静时段内被放行 ⇒ `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` 落进记录。
|
||||
|
||||
#### (c) 为什么档位只能是「下限」而不是精确值(诚实边界)
|
||||
|
||||
- 持久化的思考记录(`backthink.Record`)**不带** `Import`/重要度字段,且推送成功时 `PushResult.Detail` 会被投递说明覆盖 ⇒ 精确档位(`high` 还是 `critical`)**在存档里已丢失**。
|
||||
- 本契约因此**只下发可证的部分**,不猜:要显示精确档位,需 `internal/services/backthink` 把该轮 `Import` 落进记录(**本版不做**,见 §7.4-17)。
|
||||
|
||||
---
|
||||
|
||||
## 5. env 与模型配置(与契约相关)
|
||||
@@ -701,7 +743,14 @@
|
||||
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
|
||||
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
|
||||
| `LLM_USAGE_RETENTION` | `720h` | `llm_usage` 保留期(`0`/负 = 关闭清理) |
|
||||
| `THINK_*` / `QUIET_*` | 见模板 | 后台思考与主动推送策略 |
|
||||
| `THINK_*` / `QUIET_*` | 见模板 | 后台思考与主动推送策略。`QUIET_START_HOUR`(`23`)/ `QUIET_END_HOUR`(`7`)是**本地钟点**的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 `23/7`):**只服务「定时主动思考 → 主动搭话」**,**不静默回复**(v0.68 起可经 B12 读到生效值) |
|
||||
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆** |
|
||||
| `MEMORY_TIDY_INTERVAL_SEC` | `86400` | 整理周期(秒),是**下限**:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者 |
|
||||
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7` |
|
||||
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定) |
|
||||
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / `20` | 每分区每轮的扫描上限 / 候选上限 |
|
||||
|
||||
> **上面 9 个键(记忆整理 7 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」;在此之前面板没有任何 env 读取面。
|
||||
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
|
||||
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
|
||||
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
|
||||
@@ -722,7 +771,19 @@
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.67(当前契约版本)
|
||||
### v0.68(当前契约版本)
|
||||
|
||||
**管理面「配置事实」只读端点 + 后台思考行的紧急度/穿透字段**(依据主人指正「你多久没把新功能同步管理面板更新了?」与裁决 14;**本版全部已实现、非契约先行**)。
|
||||
|
||||
- **纯增量**:
|
||||
① **新 admin 端点 `GET /api/v1/admin/config-facts`**(`owner`/`admin`,只读、不审计):返回 9 条配置事实(记忆整理 7 键 + `QUIET_START_HOUR`/`QUIET_END_HOUR`)的**进程生效值**(`value`)+ **声明默认值**(`default`)+ `isDefault`,外加 `quietHours{startHour,endHour,disabled,label}` 的窗口解释 —— **§2.2 B12 / §4.10(a)**。**不含任何凭据值**(连凭据类键的「是否已配置」都不回)。权限矩阵随之更新:`writeAuth` 11 → **12**、总数 63 → **64**。
|
||||
② `GET /api/v1/admin/backthink` 的条目增 2 个**可选**字段:`urgency`(`routine`/`warning`/`critical`)与 `quietHoursBypassed`(`true` 才出现)—— **§2.2 B8 / §4.10(b)**。语义:`urgency` 是**存档可证的下限**(`warning` = 至少 `high`),`quietHoursBypassed` = 「这轮在安静时段里仍被放行发出去」。
|
||||
③ §5 登记 9 个键的默认值与语义(此前只以 `THINK_*`/`QUIET_*` 概括)。
|
||||
- **行为变更**:无(既有两个端点的既有键一字未改;新字段都是可选键,老客户端不读即零影响)。
|
||||
- **破坏性**:无。
|
||||
- ⚠️ **诚实边界**:后台思考的**精确**档位(`high` 还是 `critical`)在存档里**已丢失**(`backthink.Record` 不带重要度,推送成功的行其 `detail` 被投递说明覆盖)⇒ 本版只下发**可证下限**,窗口外的轮次干脆不下发(面板显示「未知」),**绝不猜**。见 §4.10(c) / §7.4-17。
|
||||
|
||||
### v0.67(上一版)
|
||||
|
||||
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态已落地**(见下)。
|
||||
|
||||
@@ -884,6 +945,8 @@
|
||||
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` 落进记录(**本版未做**)。
|
||||
|
||||
---
|
||||
|
||||
*本文件由「以代码为真源」维护(当前 `v0.67`;`v0.66` 见本文件 §6,`v0.65` 及更早见 `Docs/archive/`)。范围:只改 `Docs/**`,未改动任何代码。*
|
||||
*本文件由「以代码为真源」维护(当前 `v0.68`;`v0.67` / `v0.66` 见本文件 §6,`v0.65` 及更早见 `Docs/archive/`)。范围:只改 `Docs/**`,未改动任何代码。*
|
||||
@@ -9,6 +9,8 @@ import { FlowGraphView } from "./FlowGraph";
|
||||
import { adminBackthink } from "../../lib/api";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { BACKTHINK_SESSION_ID } from "../../store/sessionCache";
|
||||
import { BackthinkUrgencyTags } from "./BackthinkUrgencyTags";
|
||||
import { QuietHoursFactsCard } from "./ConfigFactsCards";
|
||||
|
||||
/** 推送路由的中文标签(后端 PushResult.Route 的取值域)。未知取值原样显示,
|
||||
* 绝不猜一个更好听的词。 */
|
||||
@@ -44,6 +46,7 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
<Tag tone={item.pushed ? "ok" : "neutral"}>{item.pushed ? "已推送" : "未推送"}</Tag>
|
||||
{item.route ? <Tag tone={pushRouteTone(item.route)}>{PUSH_ROUTE_LABEL[item.route] ?? item.route}</Tag> : null}
|
||||
{item.bypassed ? <Tag tone="accent">事件直发</Tag> : null}
|
||||
<BackthinkUrgencyTags item={item} />
|
||||
<span className="ml-auto">
|
||||
<Button size="sm" variant="ghost" disabled={item.pushed} onClick={() => markPushed(item.id)}>
|
||||
{item.pushed ? "已推送" : "推送结果"}
|
||||
@@ -104,7 +107,7 @@ export function BackthinkModule(): React.ReactNode {
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="后台思考与主动"
|
||||
subtitle="触发源 / 时间 / 结论 / 是否打扰 / 推送结果 · 打扰决策可解释"
|
||||
subtitle="触发源 / 时间 / 结论 / 是否打扰 / 推送结果 / 紧急度与安静时段穿透 · 打扰决策可解释"
|
||||
actions={
|
||||
<Button
|
||||
variant="soft"
|
||||
@@ -122,6 +125,8 @@ export function BackthinkModule(): React.ReactNode {
|
||||
<Notice tone="warn">对后台思考的打断 / 重派需后端 M10+ 能力接入,当前未启用;此处仅展示已产生的思考记录。</Notice>
|
||||
) : null}
|
||||
|
||||
<QuietHoursFactsCard />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>全部</FilterPill>
|
||||
<FilterPill active={filter === "notify"} onClick={() => setFilter("notify")}>需打扰</FilterPill>
|
||||
@@ -138,7 +143,9 @@ export function BackthinkModule(): React.ReactNode {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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+ 提供,当前为只读展示。</Notice>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// 后台思考行「穿透安静时段 / 紧急度」标记的**真实渲染**测试(契约 v0.68 §4.10(b))。
|
||||
//
|
||||
// 为什么需要它:后端明明标了「这一轮穿透了安静时段」,面板不画出来就等于没做;而
|
||||
// 「档位未知」与「例常」**必须**画得不一样 —— 把未知画成事实正是这次要避免的坑。
|
||||
//
|
||||
// 跑法(同 channels/accountRow.render.test.ts):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/BackthinkUrgencyTags.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-backthink-urgency.test.cjs \
|
||||
// && node /tmp/cyrene-backthink-urgency.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
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";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
function item(over: Partial<BackthinkItem>): BackthinkItem {
|
||||
return { id: "1", trigger: "iot.device.smoke", at: 0, ...over };
|
||||
}
|
||||
|
||||
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), "有档位的行不得同时显示「档位未知」");
|
||||
|
||||
// ── ② 安静时段内被扣下 ⇒ 例常,且**不得**画成穿透 ──────────────────────────
|
||||
const held = htmlFor({ urgency: "routine", shouldNotify: false });
|
||||
ok(has(held, "例常"), "被扣下的轮次显示「例常」");
|
||||
ok(!has(held, QUIET_HOURS_BYPASS_LABEL), "被扣下的轮次绝不能显示穿透");
|
||||
|
||||
// ── ③ 键缺席(窗口外的轮次 / 实时帧)⇒ 显示「档位未知」,不是「例常」 ────────
|
||||
const unknown = htmlFor({ shouldNotify: true, pushed: true });
|
||||
ok(has(unknown, `>${URGENCY_UNKNOWN_LABEL}<`), "没有档位时标签正是「档位未知」");
|
||||
ok(!has(unknown, ">例常<"), "标签绝不能是「例常」——那就是把猜当事实");
|
||||
ok(!has(unknown, QUIET_HOURS_BYPASS_LABEL), "没有穿透标记时不画穿透");
|
||||
|
||||
// ── ④ 后端加了本面板不认识的档位 ⇒ 原样显示,不猜更好听的词 ─────────────────
|
||||
const exotic = urgencyTag({ urgency: "emergency" });
|
||||
ok(exotic.label === "emergency", "未知档位原样显示");
|
||||
ok(!has(htmlFor({ urgency: "emergency" }), ">例常<"), "未知档位不得被解释成「例常」");
|
||||
|
||||
// ── ⑤ 安全域档位有独立标签与色调 ────────────────────────────────────────────
|
||||
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(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"), "档位下限的说明必须点明它只是下限");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`BackthinkUrgencyTags.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { BackthinkItem } from "../../lib/types";
|
||||
import { Tag } from "../../components/primitives";
|
||||
import { quietHoursBypassTag, urgencyTag } from "./backthinkUrgency";
|
||||
|
||||
/** 后台思考行的「穿透安静时段 + 紧急度」标记(Miuix Tag)。
|
||||
*
|
||||
* 为什么单独抽成组件:它是**画出来才算数**的一条(后端字段对了、面板没显示等于没
|
||||
* 做),因此需要一个真实渲染的测试盯着它 —— 见 BackthinkUrgencyTags.render.test.ts。
|
||||
*
|
||||
* ⚠️ 实时帧(bus 上原始 `backthink.Record`)目前不带档位 ⇒ 本次会话新到的行显示
|
||||
* 「档位未知」,重开页面后由历史拉取(`GET /admin/backthink`)补上真值。 */
|
||||
export function BackthinkUrgencyTags({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
const urgency = urgencyTag(item);
|
||||
const bypass = quietHoursBypassTag(item);
|
||||
return (
|
||||
<>
|
||||
{bypass ? (
|
||||
<Tag tone={bypass.tone} title={bypass.hint}>
|
||||
{bypass.label}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag tone={urgency.tone} title={urgency.hint}>
|
||||
{urgency.label}
|
||||
</Tag>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 配置只读事实卡的**真实渲染**测试。
|
||||
//
|
||||
// 为什么需要它:这次的产品缺口是「面板看不见运行实例实际在用什么值」,而修好之后的
|
||||
// 关键断言是**文案**——安静时段必须按新语义写「只服务『定时主动思考 → 主动搭话』,
|
||||
// 不静默回复」,绝不能写成已废弃的「夜间全静默」。文案对不对只有画出来才算数。
|
||||
//
|
||||
// 跑法(与 channels/accountRow.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/ConfigFactsCards.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-config-facts.render.test.cjs \
|
||||
// && node /tmp/cyrene-config-facts.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminConfigFacts } from "../../lib/types";
|
||||
import { MemoryTidyFactsBody, QuietHoursFactsBody } from "./ConfigFactsCards";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
const FACTS: AdminConfigFacts = {
|
||||
facts: [
|
||||
{ key: "MEMORY_TIDY_ENABLED", value: "true", default: "true", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_INTERVAL_SEC", value: "3600", default: "86400", isDefault: false },
|
||||
{ key: "MEMORY_TIDY_QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true },
|
||||
{ key: "QUIET_START_HOUR", value: "1", default: "23", isDefault: false },
|
||||
{ key: "QUIET_END_HOUR", value: "5", default: "7", isDefault: false },
|
||||
],
|
||||
quietHours: { startHour: 1, endHour: 5, disabled: false, label: "01:00-05:00" },
|
||||
};
|
||||
|
||||
// ── 记忆整理卡:7 个真键名 + 生效值 + 默认/已改 ──────────────────────────────
|
||||
const tidyHtml = renderToStaticMarkup(createElement(MemoryTidyFactsBody, { facts: FACTS }));
|
||||
for (const key of [
|
||||
"MEMORY_TIDY_ENABLED",
|
||||
"MEMORY_TIDY_INTERVAL_SEC",
|
||||
"MEMORY_TIDY_QUIET_START_HOUR",
|
||||
"MEMORY_TIDY_QUIET_END_HOUR",
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD",
|
||||
"MEMORY_TIDY_SCAN_LIMIT",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT",
|
||||
]) {
|
||||
ok(has(tidyHtml, key), `记忆整理卡必须列出真键名 ${key}(用户照着它改 .env)`);
|
||||
}
|
||||
ok(has(tidyHtml, "86400") === false && has(tidyHtml, "3600 秒"), "显示的是**生效值** 3600 秒");
|
||||
ok(has(tidyHtml, "已改"), "改过的键必须标「已改」");
|
||||
ok((tidyHtml.match(/默认/g) ?? []).length >= 6, "未改的键标「默认」");
|
||||
ok(has(tidyHtml, "只产建议"), "必须说清整理只产建议、不自动改记忆");
|
||||
ok(has(tidyHtml, "两端都为 0 = 未填"), "必须说清低峰窗口的 0/0 = 未填(否则用户会以为 0/0 是关窗口)");
|
||||
|
||||
// ── 安静时段卡:新语义文案(不得出现「夜间全静默」) ────────────────────────
|
||||
const quietHtml = renderToStaticMarkup(createElement(QuietHoursFactsBody, { facts: FACTS }));
|
||||
ok(has(quietHtml, "01:00-05:00"), "窗口摘要用后端给的口径");
|
||||
ok(has(quietHtml, "窗口生效"), "窗口生效态如实标注");
|
||||
ok(has(quietHtml, "安静时段只服务"), "必须点明它服务的对象");
|
||||
ok(has(quietHtml, "定时主动思考"), "必须点明「定时主动思考 → 主动搭话」");
|
||||
ok(has(quietHtml, "不会因为到了安静时段就被静默"), "必须说清回复侧不被静默(新语义)");
|
||||
ok(!has(quietHtml, "夜间全静默"), "绝不得写成「夜间全静默」(已废弃的旧实现语义)");
|
||||
ok(has(quietHtml, "QUIET_START_HOUR") && has(quietHtml, "QUIET_END_HOUR"), "列出真键名");
|
||||
ok(has(quietHtml, "1 时") && has(quietHtml, "5 时"), "显示进程生效的钟点");
|
||||
ok(has(quietHtml, "已改"), "被改过的窗口钟点标「已改」");
|
||||
|
||||
// ── 关闭态的窗口:必须说「从不生效」,不能画成假窗口 ────────────────────────
|
||||
const disabledHtml = renderToStaticMarkup(
|
||||
createElement(QuietHoursFactsBody, {
|
||||
facts: { ...FACTS, quietHours: { startHour: 23, endHour: 23, disabled: true, label: "已关闭" } },
|
||||
}),
|
||||
);
|
||||
ok(has(disabledHtml, "窗口关闭"), "关闭态必须显示「窗口关闭」");
|
||||
ok(has(disabledHtml, "从不生效"), "关闭态的摘要必须说「从不生效」");
|
||||
|
||||
// ── 两端写 0(未填 ⇒ 运行时回落):必须在面板上说明,不能显示成「已关闭」 ────
|
||||
const fallbackHtml = renderToStaticMarkup(
|
||||
createElement(QuietHoursFactsBody, {
|
||||
facts: {
|
||||
...FACTS,
|
||||
quietHours: { startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00", fallbackToDefault: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
ok(has(fallbackHtml, "运行时回落到默认窗口"), "0/0 的回落必须在面板上说明");
|
||||
ok(has(fallbackHtml, "窗口生效"), "回落之后窗口仍然生效,不得画成「窗口关闭」");
|
||||
ok(!has(fallbackHtml, "从不生效"), "回落不得被显示成「从不生效」");
|
||||
|
||||
// ── 缺事实:渲染「未报告」,不渲染 0 / 默认 ────────────────────────────────
|
||||
const partialHtml = renderToStaticMarkup(
|
||||
createElement(MemoryTidyFactsBody, {
|
||||
facts: { ...FACTS, facts: FACTS.facts.filter((f) => f.key !== "MEMORY_TIDY_SCAN_LIMIT") },
|
||||
}),
|
||||
);
|
||||
ok(has(partialHtml, "未报告"), "后端没给的键必须渲染「未报告」");
|
||||
ok(has(partialHtml, "MEMORY_TIDY_SCAN_LIMIT"), "缺的键仍然列出键名(让人知道少了什么)");
|
||||
|
||||
// ── 没有数据时不渲染任何编造的默认行 ────────────────────────────────────────
|
||||
const emptyHtml = renderToStaticMarkup(createElement(QuietHoursFactsBody, { facts: null }));
|
||||
ok(emptyHtml === "", "没有数据时不渲染任何事实行(卡片壳负责 loading/error)");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`ConfigFactsCards.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { adminConfigFacts } from "../../lib/api";
|
||||
import type { AdminConfigFacts } from "../../lib/types";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { Notice } from "../components";
|
||||
import { Surface, Tag } from "../../components/primitives";
|
||||
import {
|
||||
memoryTidyRows,
|
||||
quietHoursPurpose,
|
||||
quietHoursRows,
|
||||
quietHoursSummary,
|
||||
type FactRow,
|
||||
} from "./configFacts";
|
||||
|
||||
/**
|
||||
* 配置**只读事实卡**(切片 P3,契约 v0.68 §4.10(a)):把记忆整理 7 键与安静时段
|
||||
* 的**进程生效值**摊在面板上,用户不必去猜 `.env` 到底有没有生效。
|
||||
*
|
||||
* 它刻意是**只读**:这些键的值来自 `.env`(生产用 systemd/env_file),面板没有
|
||||
* 写回通道,因此不做输入框 —— 那会变成「谎称可调」(与面板既有的诚实边界一致)。
|
||||
* 网关**不读 `.env`**,回的是 `config.Load` 解析后的生效值,所以这里显示的就是
|
||||
* 运行实例真正在用的数。
|
||||
*
|
||||
* 展示与取数刻意分层:`*Body` 只吃数据(可用真实渲染测试直接喂 fixture),
|
||||
* 卡片组件负责取数 + loading/error 呈现。
|
||||
*/
|
||||
|
||||
/** FactLine 是一行事实:中文说明 + 真键名(mono)+ 生效值 + 「默认/已改/未报告」。 */
|
||||
function FactLine({ row }: { row: FactRow }): React.ReactNode {
|
||||
const tone = row.state === "已改" ? "warn" : row.state === "未报告" ? "err" : "neutral";
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0">
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">{row.label}</span>
|
||||
<span className="ml-2 font-mono text-[11px] text-[var(--mu-text-lo)]">{row.key}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-mono text-sm text-[var(--mu-text-hi)]">{row.value}</span>
|
||||
<Tag tone={tone}>{row.state}</Tag>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** FactsLines 渲染一组事实行。 */
|
||||
function FactsLines({ rows }: { rows: FactRow[] }): React.ReactNode {
|
||||
return (
|
||||
<div className="divide-y divide-[var(--mu-glass-border)]">
|
||||
{rows.map((row) => (
|
||||
<FactLine key={row.key} row={row} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** FactsState 统一「读取中 / 读取失败」的呈现(两个卡片共用,避免各写一套)。 */
|
||||
function FactsState({ loading, error }: { loading: boolean; error: string | null }): React.ReactNode {
|
||||
if (loading) return <span className="text-xs text-[var(--mu-text-lo)]">读取配置事实…</span>;
|
||||
if (error) return <Notice tone="err">读取配置事实失败:{error}(下面的「未报告」因此不代表后端没配)</Notice>;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** useFacts 拉一次配置事实(两个卡片分属不同页面,各拉各的,互不共享状态)。 */
|
||||
function useFacts(): { facts: AdminConfigFacts | null; loading: boolean; error: string | null } {
|
||||
const { data, loading, error } = useAsyncData(() => adminConfigFacts(), []);
|
||||
return { facts: data, loading, error };
|
||||
}
|
||||
|
||||
/** MemoryTidyFactsBody 是「记忆整理」事实卡的纯展示体(测试直接喂数据)。 */
|
||||
export function MemoryTidyFactsBody({ facts }: { facts: AdminConfigFacts | null }): React.ReactNode {
|
||||
if (!facts) return null;
|
||||
return (
|
||||
<>
|
||||
<FactsLines rows={memoryTidyRows(facts)} />
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
整理是低峰期的后台任务,只产建议、绝不自动合并 / 删除 / 改写任何记忆,也不推送、不进 chat 上下文。
|
||||
低峰窗口的写法:两端相等的非 0 钟点 = 关闭窗口;两端都为 0 = 未填(运行时回落到默认 23:00-07:00)。
|
||||
卡是只读的:改值请编辑 <span className="font-mono">.env</span> 后重启服务。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** QuietHoursFactsBody 是「安静时段」事实卡的纯展示体,文案按主人裁决 10 的新语义。 */
|
||||
export function QuietHoursFactsBody({ facts }: { facts: AdminConfigFacts | null }): React.ReactNode {
|
||||
if (!facts) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-[var(--mu-text-hi)]">{quietHoursSummary(facts.quietHours)}</span>
|
||||
<Tag tone={facts.quietHours.disabled ? "warn" : "ok"}>
|
||||
{facts.quietHours.disabled ? "窗口关闭" : "窗口生效"}
|
||||
</Tag>
|
||||
</div>
|
||||
<FactsLines rows={quietHoursRows(facts)} />
|
||||
<p className="text-xs text-[var(--mu-text-mid)]">{quietHoursPurpose()}</p>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
只读事实(值 = 进程生效值,不是 <span className="font-mono">.env</span> 原文):改窗口请编辑{" "}
|
||||
<span className="font-mono">QUIET_START_HOUR</span> / <span className="font-mono">QUIET_END_HOUR</span> 后重启服务。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 记忆管理页的「记忆整理」只读事实卡。 */
|
||||
export function MemoryTidyFactsCard(): React.ReactNode {
|
||||
const { facts, loading, error } = useFacts();
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">记忆整理(只读事实)</h3>
|
||||
<Tag tone="neutral">进程生效值</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/config-facts</span>
|
||||
</div>
|
||||
<FactsState loading={loading} error={error} />
|
||||
<MemoryTidyFactsBody facts={facts} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台思考页的「安静时段」只读事实卡。 */
|
||||
export function QuietHoursFactsCard(): React.ReactNode {
|
||||
const { facts, loading, error } = useFacts();
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">安静时段(只读事实)</h3>
|
||||
<Tag tone="neutral">进程生效值</Tag>
|
||||
</div>
|
||||
<FactsState loading={loading} error={error} />
|
||||
<QuietHoursFactsBody facts={facts} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -184,6 +185,8 @@ export function MemoriesModule(): React.ReactNode {
|
||||
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
|
||||
</Surface>
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<Notice>
|
||||
编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 手动添加 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。
|
||||
</Notice>
|
||||
|
||||
@@ -165,8 +165,12 @@ export function SystemModule(): React.ReactNode {
|
||||
<KeyValueRow label="ADMIN_USERNAME" value="admin" mono />
|
||||
<KeyValueRow label="ADMIN_PASSWORD" value="••••••••" mono />
|
||||
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
|
||||
以上为 <span className="font-mono">.env.example</span> 的默认值(面板无 env 读取端点,不是运行实例的实时值);
|
||||
改值请编辑 <span className="font-mono">.env</span> 后重启服务。
|
||||
以上为 <span className="font-mono">.env.example</span> 的默认值(这几条没有只读读取端点,不是运行实例的实时值;
|
||||
带凭据的键面板刻意不回值)。改值请编辑 <span className="font-mono">.env</span> 后重启服务。
|
||||
</p>
|
||||
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
|
||||
要看运行实例的实时生效值,去两处只读事实卡:记忆管理页的「记忆整理」与后台思考页的「安静时段」
|
||||
(<span className="font-mono">GET /api/v1/admin/config-facts</span>,契约 v0.68)。
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { BackthinkItem } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* 后台思考行「紧急度 / 穿透安静时段」标记的**纯逻辑**(契约 v0.68 §4.10(b))。
|
||||
*
|
||||
* 后端 `urgency` 的语义是**存档可证的档位下限**(`warning` = 至少 high 档,可能更
|
||||
* 高),不是精确档位;而窗口之外的轮次存档里没有档位,键**缺席**。因此本层:
|
||||
* - 缺席 ⇒ 显示「档位未知」(**不是**「例常」——把未知画成事实就是这次要避免的坑);
|
||||
* - 未知取值 ⇒ 原样显示后端给的词,不猜一个更好听的;
|
||||
* - `quietHoursBypassed` 只在 true 时渲染「穿透安静时段」。
|
||||
*/
|
||||
|
||||
export type UrgencyTagTone = "ok" | "warn" | "err" | "neutral" | "accent";
|
||||
|
||||
export interface UrgencyTag {
|
||||
label: string;
|
||||
tone: UrgencyTagTone;
|
||||
/** 悬停说明(为什么是这个档位 / 为什么未知),给人看的口径。 */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** 档位未知时的标签(键缺席 = 后端没有这个事实)。 */
|
||||
export const URGENCY_UNKNOWN_LABEL = "档位未知";
|
||||
|
||||
/** 穿透安静时段的标签。 */
|
||||
export const QUIET_HOURS_BYPASS_LABEL = "穿透安静时段";
|
||||
|
||||
/** urgencyTag 把后端 `urgency` 映射成标签;键缺席时给「档位未知」。 */
|
||||
export function urgencyTag(item: Pick<BackthinkItem, "urgency">): 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: "后端给了本面板不认识的档位,原样显示" };
|
||||
}
|
||||
}
|
||||
|
||||
/** quietHoursBypassTag 只在后端确实标记了穿透时返回标签。 */
|
||||
export function quietHoursBypassTag(item: Pick<BackthinkItem, "quietHoursBypassed">): UrgencyTag | null {
|
||||
if (item.quietHoursBypassed !== true) return null;
|
||||
return {
|
||||
label: QUIET_HOURS_BYPASS_LABEL,
|
||||
tone: "accent",
|
||||
hint: "这一轮落在安静时段窗口内、但仍被放行发出(档位 ≥ high)",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 配置只读事实面(契约 v0.68 §4.10(a))的**纯逻辑**测试:形状 → 文案的映射。
|
||||
// 这里盯的是三条不变量:值只取后端给的生效值、默认/已改只由 isDefault 决定、
|
||||
// 后端没给的键如实显示「未报告」而不是编一个 0。
|
||||
//
|
||||
// 跑法(项目无前端测试运行器,用 vite 自带的 esbuild;见同目录既有测试):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/configFacts.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --outfile=/tmp/cyrene-config-facts.test.mjs \
|
||||
// && node /tmp/cyrene-config-facts.test.mjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会类型检查本文件,因此它不 import node:*。
|
||||
|
||||
import type { AdminConfigFacts } from "../../lib/types";
|
||||
import {
|
||||
FACT_MISSING_VALUE,
|
||||
MEMORY_TIDY_KEYS,
|
||||
QUIET_HOURS_KEYS,
|
||||
factRows,
|
||||
memoryTidyRows,
|
||||
quietHoursPurpose,
|
||||
quietHoursRows,
|
||||
quietHoursSummary,
|
||||
} from "./configFacts";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
/** 生效值全为默认的常态响应(后端在默认部署下会这么回)。 */
|
||||
const DEFAULTS: AdminConfigFacts = {
|
||||
facts: [
|
||||
{ key: "MEMORY_TIDY_ENABLED", value: "true", default: "true", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_INTERVAL_SEC", value: "86400", default: "86400", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true },
|
||||
{ key: "QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
|
||||
{ key: "QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
|
||||
],
|
||||
quietHours: { startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00" },
|
||||
};
|
||||
|
||||
// ── ① 值就是后端给的生效值(含单位),不重算 ─────────────────────────────────
|
||||
const tidy = memoryTidyRows(DEFAULTS);
|
||||
same(tidy.length, 7, "记忆整理恰好 7 行(后端的 7 个键)");
|
||||
same(
|
||||
tidy.map((r) => r.key),
|
||||
[...MEMORY_TIDY_KEYS],
|
||||
"键序与后端事实表一致",
|
||||
);
|
||||
const interval = tidy.find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC");
|
||||
same(interval?.value, "86400 秒", "周期带单位显示,数值原样取后端");
|
||||
same(interval?.state, "默认", "isDefault=true ⇒ 显示「默认」");
|
||||
const threshold = tidy.find((r) => r.key === "MEMORY_TIDY_RECALL_THRESHOLD");
|
||||
same(threshold?.value, "0.8", "比例值原样显示(后端已归一化)");
|
||||
|
||||
// ── ② 「已改」只由 isDefault 决定(不反推 .env) ─────────────────────────────
|
||||
const changed: AdminConfigFacts = {
|
||||
...DEFAULTS,
|
||||
facts: DEFAULTS.facts.map((f) =>
|
||||
f.key === "MEMORY_TIDY_INTERVAL_SEC" ? { ...f, value: "3600", isDefault: false } : f,
|
||||
),
|
||||
};
|
||||
const changedRow = memoryTidyRows(changed).find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC");
|
||||
same(changedRow?.value, "3600 秒", "改过的值如实显示");
|
||||
same(changedRow?.state, "已改", "isDefault=false ⇒ 显示「已改」");
|
||||
same(
|
||||
memoryTidyRows(changed).filter((r) => r.state === "已改").length,
|
||||
1,
|
||||
"只有真的改过的那一行是「已改」",
|
||||
);
|
||||
|
||||
// ── ③ 后端没给的键如实显示「未报告」,绝不编值 ──────────────────────────────
|
||||
const partial: AdminConfigFacts = {
|
||||
facts: DEFAULTS.facts.filter((f) => f.key !== "MEMORY_TIDY_SCAN_LIMIT"),
|
||||
quietHours: DEFAULTS.quietHours,
|
||||
};
|
||||
const missing = memoryTidyRows(partial).find((r) => r.key === "MEMORY_TIDY_SCAN_LIMIT");
|
||||
same(missing?.value, FACT_MISSING_VALUE, "缺的键显示「未报告」而不是 0 或默认值");
|
||||
same(missing?.state, FACT_MISSING_VALUE, "缺的键状态也是「未报告」");
|
||||
same(memoryTidyRows(null).length, 7, "整份响应都没拿到时,7 行仍然都在(全为未报告)");
|
||||
ok(
|
||||
memoryTidyRows(null).every((r) => r.state === FACT_MISSING_VALUE),
|
||||
"没有响应时不留空白行,全部如实标「未报告」",
|
||||
);
|
||||
|
||||
// ── ④ 安静时段:窗口摘要 + 用途文案(新语义,不得说「夜间全静默」) ─────────
|
||||
same(quietHoursRows(DEFAULTS).map((r) => r.key), [...QUIET_HOURS_KEYS], "安静时段两行");
|
||||
same(quietHoursSummary(DEFAULTS.quietHours), "23:00-07:00(跨午夜)", "跨午夜窗口如实标注");
|
||||
same(
|
||||
quietHoursSummary({ startHour: 1, endHour: 5, disabled: false, label: "01:00-05:00" }),
|
||||
"01:00-05:00",
|
||||
"非跨午夜窗口不加多余后缀",
|
||||
);
|
||||
same(
|
||||
quietHoursSummary({ startHour: 23, endHour: 23, disabled: true, label: "已关闭" }),
|
||||
"已关闭(安静时段从不生效)",
|
||||
"窗口关闭必须说「从不生效」,不能画成 23:00-23:00 的假窗口",
|
||||
);
|
||||
same(
|
||||
quietHoursSummary({ startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00", fallbackToDefault: true }),
|
||||
"23:00-07:00(两端写 0 = 未填,运行时回落到默认窗口)",
|
||||
"0/0 被当成「未填」时必须说明回落,不能谎报成「已关闭」",
|
||||
);
|
||||
same(quietHoursSummary(null), FACT_MISSING_VALUE, "没有 quietHours 时如实报未报告");
|
||||
|
||||
const purpose = quietHoursPurpose();
|
||||
ok(has(purpose, "定时主动思考"), "用途文案必须点明它服务「定时主动思考 → 主动搭话」");
|
||||
ok(has(purpose, "不静默回复") || has(purpose, "不会因为到了安静时段就被静默"), "用途文案必须说清回复侧不被静默");
|
||||
ok(!has(purpose, "夜间全静默"), "不得沿用已废弃的旧语义「夜间全静默」");
|
||||
ok(!has(purpose, "群聊静默"), "群聊夜间静默已被取消,不得写成有");
|
||||
|
||||
// ── ⑤ factRows 对未知键不崩、原样透出键名 ───────────────────────────────────
|
||||
const unknown = factRows(DEFAULTS, ["NOT_A_REAL_KEY"]);
|
||||
same(unknown[0].label, "NOT_A_REAL_KEY", "没有元数据的键用键名兜底(不编中文标签)");
|
||||
same(unknown[0].value, FACT_MISSING_VALUE, "没有元数据的键如实「未报告」");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`configFacts: ${checks} checks passed`);
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AdminConfigFact, AdminConfigFacts, AdminConfigQuietHours } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* 配置只读事实卡的**纯逻辑**(契约 v0.68 §4.10(a))。这里只做形状 → 文案的映射,
|
||||
* 不发请求、不碰 DOM,因此可以用 plain-node 检查跑(见 configFacts.test.ts)。
|
||||
*
|
||||
* 三条不变量(与后端 admin_config_facts.go 同一口径):
|
||||
* 1. 值一律取后端给的**进程生效值**,本层不推算、不格式化数值(只补单位/说明);
|
||||
* 2. 「默认 / 已改」只由 `isDefault` 决定,**不**反推 `.env` 写过什么(面板读不到);
|
||||
* 3. 后端没给的键如实显示「未报告」,**绝不**编一个值出来。
|
||||
*/
|
||||
|
||||
/** 后端没给这条事实时的显示值(不是 0,也不是默认值)。 */
|
||||
export const FACT_MISSING_VALUE = "未报告";
|
||||
|
||||
/** 记忆整理的 7 个键(与后端 configFactSpecs 的前 7 条同序)。 */
|
||||
export const MEMORY_TIDY_KEYS: readonly string[] = [
|
||||
"MEMORY_TIDY_ENABLED",
|
||||
"MEMORY_TIDY_INTERVAL_SEC",
|
||||
"MEMORY_TIDY_QUIET_START_HOUR",
|
||||
"MEMORY_TIDY_QUIET_END_HOUR",
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD",
|
||||
"MEMORY_TIDY_SCAN_LIMIT",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT",
|
||||
];
|
||||
|
||||
/** 安静时段的两个键(后端事实表后两条)。 */
|
||||
export const QUIET_HOURS_KEYS: readonly string[] = ["QUIET_START_HOUR", "QUIET_END_HOUR"];
|
||||
|
||||
export interface FactRow {
|
||||
key: string;
|
||||
/** 中文说明(这个键管什么)。 */
|
||||
label: string;
|
||||
/** 显示值(生效值 + 单位)。 */
|
||||
value: string;
|
||||
/** `"默认"` / `"已改"` / `"未报告"`。 */
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface FactMeta {
|
||||
label: string;
|
||||
/** 值后缀(单位);空串表示不加。 */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const FACT_META: Record<string, FactMeta> = {
|
||||
MEMORY_TIDY_ENABLED: { label: "记忆整理总开关" },
|
||||
MEMORY_TIDY_INTERVAL_SEC: { label: "整理周期", unit: " 秒" },
|
||||
MEMORY_TIDY_QUIET_START_HOUR: { label: "整理低峰窗口起点", unit: " 时" },
|
||||
MEMORY_TIDY_QUIET_END_HOUR: { label: "整理低峰窗口终点", unit: " 时" },
|
||||
MEMORY_TIDY_RECALL_THRESHOLD: { label: "候选召回门(余弦)" },
|
||||
MEMORY_TIDY_SCAN_LIMIT: { label: "每轮扫描上限" },
|
||||
MEMORY_TIDY_CANDIDATE_LIMIT: { label: "每轮候选上限" },
|
||||
QUIET_START_HOUR: { label: "安静时段起点(本地钟点)", unit: " 时" },
|
||||
QUIET_END_HOUR: { label: "安静时段终点(本地钟点)", unit: " 时" },
|
||||
};
|
||||
|
||||
/** factRows 把后端事实列表按给定键序映射成显示行;缺的键如实标「未报告」。 */
|
||||
export function factRows(facts: AdminConfigFacts | null, keys: readonly string[]): FactRow[] {
|
||||
const byKey = new Map<string, AdminConfigFact>();
|
||||
for (const f of facts?.facts ?? []) byKey.set(f.key, f);
|
||||
return keys.map((key) => {
|
||||
const meta = FACT_META[key];
|
||||
const got = byKey.get(key);
|
||||
if (!got) {
|
||||
return {
|
||||
key,
|
||||
label: meta ? meta.label : key,
|
||||
value: FACT_MISSING_VALUE,
|
||||
state: FACT_MISSING_VALUE,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: meta ? meta.label : key,
|
||||
value: `${got.value}${meta?.unit ?? ""}`,
|
||||
state: got.isDefault ? "默认" : "已改",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 记忆整理事实行(7 条)。 */
|
||||
export function memoryTidyRows(facts: AdminConfigFacts | null): FactRow[] {
|
||||
return factRows(facts, MEMORY_TIDY_KEYS);
|
||||
}
|
||||
|
||||
/** 安静时段事实行(2 条)。 */
|
||||
export function quietHoursRows(facts: AdminConfigFacts | null): FactRow[] {
|
||||
return factRows(facts, QUIET_HOURS_KEYS);
|
||||
}
|
||||
|
||||
/** 安静时段窗口的一句话摘要(后端 label + 跨午夜 / 已关闭 / 「0/0 = 未填」的如实补充)。 */
|
||||
export function quietHoursSummary(qh: AdminConfigQuietHours | null | undefined): string {
|
||||
if (!qh) return FACT_MISSING_VALUE;
|
||||
if (qh.disabled) return "已关闭(安静时段从不生效)";
|
||||
const label = qh.label || `${qh.startHour}:00-${qh.endHour}:00`;
|
||||
if (qh.fallbackToDefault) {
|
||||
return `${label}(两端写 0 = 未填,运行时回落到默认窗口)`;
|
||||
}
|
||||
return qh.startHour > qh.endHour ? `${label}(跨午夜)` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安静时段的**用途文案**(主人裁决 10 的新语义)。
|
||||
* ⚠️ 必须说清「只服务定时主动思考 → 主动搭话,不静默回复」——
|
||||
* 写成「夜间全静默」是已废弃的旧实现语义,被这次改动明确否掉了。
|
||||
*/
|
||||
export function quietHoursPurpose(): string {
|
||||
return "安静时段只服务「定时主动思考 → 主动搭话」:她在这个窗口里默认不主动找你。回复侧不受它约束——你任何时候说话,她都照常回,不会因为到了安静时段就被静默。群聊同样没有夜间静默。";
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export type { ChannelActionResult, ChannelConfigSaveResult } from "./api/adminCh
|
||||
|
||||
export {
|
||||
adminBackthink,
|
||||
adminConfigFacts,
|
||||
adminListNotifications,
|
||||
adminObservabilityAudit,
|
||||
adminObservabilityLogs,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AdminStorageStatus,
|
||||
AdminTraceSpan,
|
||||
AuditRecord,
|
||||
AdminConfigFacts,
|
||||
BackthinkItem,
|
||||
SystemBackupResp,
|
||||
SystemRestoreResp,
|
||||
@@ -45,6 +46,14 @@ export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
|
||||
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/config-facts — **只读**配置事实面(契约 v0.68 §4.10(a)):
|
||||
* 记忆整理 7 键 + 安静时段两键的**进程生效值** + 声明默认值,外加安静时段窗口的
|
||||
* 解释(跨午夜 / 已关闭)。owner/admin 档;**不含任何凭据值**(后端有哨兵测试钉死),
|
||||
* 网关也**不读 `.env`** —— 值就是运行实例真正在用的数。 */
|
||||
export function adminConfigFacts(): Promise<AdminConfigFacts> {
|
||||
return request<AdminConfigFacts>("/admin/config-facts");
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification
|
||||
* feed (design/10 §3 #16: audit + channel + backthink + system aggregated).
|
||||
* `type` omitted = all types, `before` = createdAt(ms) cursor for older pages,
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./types/admin";
|
||||
export * from "./types/adminWrite";
|
||||
export * from "./types/kook";
|
||||
export * from "./types/notification";
|
||||
export * from "./types/configFacts";
|
||||
@@ -42,7 +42,17 @@ export interface BackthinkItem {
|
||||
route?: string;
|
||||
/** 打扰决策或投递的简短原因(后端 PushResult.Detail),例如 quiet hours、无活跃客户端。 */
|
||||
detail?: string;
|
||||
/** 事件驱动推送绕过了限频闸(后端 PushResult.Bypassed,"该发就发")。 */
|
||||
/** 事件驱动推送绕过了限频闸(后端 PushResult.Bypassed,"该发就发")。
|
||||
* ⚠️ 与 `quietHoursBypassed`(安静时段穿透)**是两件事**:这个说的是限频门。 */
|
||||
bypassed?: boolean;
|
||||
/** 该轮紧急度的**存档可证下限**(后端 `urgency`,`internal/urgency` 档位词汇:
|
||||
* `routine` | `warning` | `critical`)。`warning` 读作「至少 high 档(可能更高)」。
|
||||
* 缺席 = 后端没有这个事实(轮次不在安静时段窗口内时无从反推),**不是**「例常」。
|
||||
* ⚠️ 实时帧走的是 bus 上原始的 `backthink.Record`,它目前**不带**档位 ⇒ 实时行显示
|
||||
* 「档位未知」,重开页面由历史拉取(`GET /admin/backthink`)补齐。 */
|
||||
urgency?: string;
|
||||
/** 该轮**穿透了安静时段**(轮次时间落在安静时段窗口内且该轮判定要推送)。后端只在
|
||||
* `true` 时下发该键;缺席 = 没有这件事,**不等于**「确认没穿透」。 */
|
||||
quietHoursBypassed?: boolean;
|
||||
at: number;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 管理面「配置事实」的线上类型(契约 v0.68 §4.10(a))。
|
||||
// 唯一真源:internal/services/gateway/admin_config_facts.go。
|
||||
// 后端只回**非敏感调参**(记忆整理 7 键 + 安静时段 2 键)——响应里没有、也不会
|
||||
// 有凭据值,因此这里也刻意不存在任何 token / key / secret 字段。
|
||||
|
||||
export interface AdminConfigFact {
|
||||
/** env 键名,与 `.env.example` 逐字一致(后端有测试钉住)。 */
|
||||
key: string;
|
||||
/** **进程生效值**(已解析后的值,不是 `.env` 原文)。 */
|
||||
value: string;
|
||||
/** 该键的声明默认值(`internal/config` 的 `default` 标签)。 */
|
||||
default: string;
|
||||
/** 生效值是否等于声明默认值:true 显示「默认」,false 显示「已改」。
|
||||
* ⚠️ 它**不是**「`.env` 没写过这个键」的断言(网关不读 `.env`)。 */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface AdminConfigQuietHours {
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
/** 窗口已关闭(两个相等的**非 0** 钟点):此时安静时段**从不生效**。 */
|
||||
disabled: boolean;
|
||||
/** 后端给的显示文案:`"23:00-07:00"` 或 `"已关闭"`。 */
|
||||
label: string;
|
||||
/** 窗口两端都被写成 0 ⇒ 视为「未填」,运行时回落到默认窗口(后端已按回落后的
|
||||
* 钟点填 startHour/endHour)。0/0 **不是**「关闭」——关闭要写两个相等的非 0 值。 */
|
||||
fallbackToDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminConfigFacts {
|
||||
facts: AdminConfigFact[];
|
||||
quietHours: AdminConfigQuietHours;
|
||||
}
|
||||
@@ -301,7 +301,27 @@ 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;
|
||||
return { id: String(idRaw), trigger, conclusion, shouldNotify, pushed, route, detail, bypassed, at };
|
||||
// v0.68 的档位/穿透:**防御性读取**(兼容 camelCase 与 Go 字段名)。当前 bus 上发布的
|
||||
// 是原始 `backthink.Record`,它**不带**这两个字段 ⇒ 今天恒为 undefined(本次会话新到
|
||||
// 的行显示「档位未知」,重开页面由历史拉取补真值)。一旦后端把这两个事实放进帧里,
|
||||
// 这里无需改动就会显示出来 —— 但绝不因为「帧里没有」就编一个值。
|
||||
const urgencyRaw = r.urgency ?? r.Urgency ?? r.urgency_level ?? r.urgencyLevel;
|
||||
const urgency = typeof urgencyRaw === "string" && urgencyRaw ? urgencyRaw : undefined;
|
||||
const qhRaw = r.quietHoursBypassed ?? r.QuietHoursBypassed;
|
||||
const quietHoursBypassed = typeof qhRaw === "boolean" ? qhRaw : undefined;
|
||||
return {
|
||||
id: String(idRaw),
|
||||
trigger,
|
||||
conclusion,
|
||||
shouldNotify,
|
||||
pushed,
|
||||
route,
|
||||
detail,
|
||||
bypassed,
|
||||
urgency,
|
||||
quietHoursBypassed,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 取 PushResult 文本字段的第一个非空值(先 Go 字段名,再 camelCase)。空串按
|
||||
|
||||
@@ -124,6 +124,12 @@ type AdminUserRecord struct {
|
||||
// design/10 §3 #12)回答「走了哪条路、为什么打扰/为什么不打扰」——
|
||||
// 它们直接来自后端已存的 PushResult,因此面板不必靠猜来解释
|
||||
// 一次静默。
|
||||
//
|
||||
// Urgency / QuietHoursBypassed(v0.68)回答「这一轮有多紧急、是不是
|
||||
// 穿透了安静时段」。二者都**只在存档可证时**出现,缺席 ≠ 常规档位:
|
||||
// 推导依据见 admin_backthink_urgency.go(那一轮的 `At` 落在安静时段窗口内
|
||||
// 时,判定级联已把档位锁死到可证的范围;窗口之外的轮次存档里没有档位,
|
||||
// 因此不下发,而不是编一个 routine)。
|
||||
type AdminBackthinkItem struct {
|
||||
ID string `json:"id"`
|
||||
Trigger string `json:"trigger"`
|
||||
@@ -137,6 +143,14 @@ type AdminBackthinkItem struct {
|
||||
// Bypassed 表示这是事件驱动的推送、绕过了限频闸("该发就发")。
|
||||
Bypassed bool `json:"bypassed"`
|
||||
At int64 `json:"at"`
|
||||
// Urgency 是这一轮紧急度的**存档可证下限**(urgency 档位词汇:
|
||||
// routine | warning | critical)。`warning` 读作「≥ high 档(可能更高)」;
|
||||
// 无法证明时省略该键(面板显示「未知」)。
|
||||
Urgency string `json:"urgency,omitempty"`
|
||||
// QuietHoursBypassed 为 true 时表示这一轮**穿透了安静时段**(轮次时间落在
|
||||
// 安静时段窗口内且该轮判定要推送)。只在为 true 时出现:安静时段之外的轮次
|
||||
// 与「没穿透」不必在线上区分,缺席即「没有这件事」。
|
||||
QuietHoursBypassed bool `json:"quietHoursBypassed,omitempty"`
|
||||
}
|
||||
|
||||
// AdminRagDoc 是管理 RAG 列表中一个知识库文档(项目索引
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
|
||||
)
|
||||
|
||||
// 本文件把「这一轮后台思考有多紧急、是不是穿透了安静时段」变成**存档里可证**的
|
||||
// 事实(切片 P4)。它刻意只读 `backthink.Record` 已有的字段 —— 本片不动
|
||||
// `internal/services/backthink/**`(通用紧急度通道的落库属那片的面):
|
||||
//
|
||||
// - `Record.ShouldPush` 是那一轮的打扰判定结果;
|
||||
// - `Record.At` 是那一轮的墙钟时间(库里存 UTC,取钟点前必须换回本地
|
||||
// 时区,因为安静时段是**本地钟点**窗口);
|
||||
// - 安静时段窗口取**进程生效**的 `QUIET_START_HOUR`/`QUIET_END_HOUR`
|
||||
// (`config.Background`,与 backthink 的 cfg 同源:组合根把同一对值交给它)。
|
||||
//
|
||||
// 由 `backthink.Decide` 的判定级联(`decide.go` 的 baseDecide)可证:
|
||||
//
|
||||
// - 安静时段内被**放行** ⇒ 该轮重要度必然 ≥ `ImportanceHigh`(更低档位在安静
|
||||
// 时段一律被扣下)⇒ 这一轮**穿透了安静时段**,档位**至少**是 `warning`
|
||||
// (high)。这就是 `quietHoursBypassed` 的全部依据,不是启发式。
|
||||
// - 安静时段内被**扣下** ⇒ 档位 < high ⇒ `routine`,未穿透。
|
||||
// - 安静时段**之外** ⇒ 判定根本不看档位(「想不想说」由她自己的结论决定),
|
||||
// 并且推送成功的行其 `PushResult.Detail` 已被投递说明覆盖 ⇒ 存档里没有任何
|
||||
// 字段能反推档位。此时**不下发** urgency(面板显示「未知」),绝不猜。
|
||||
//
|
||||
// 因此 `AdminBackthinkItem.Urgency` 的语义是「**存档可证的档位下限**」而不是
|
||||
// 「精确档位」:`warning` 读作「≥ high(含 critical)」。要做到精确档位,需要
|
||||
// backthink 把每轮的 `Import` 落进 `Record`(本片不做,已在契约 §4.10(c) 与
|
||||
// §7.4-17 如实登记)。
|
||||
|
||||
// backthinkQuietOutcome 是「该轮与安静时段的关系」的推导结果。
|
||||
type backthinkQuietOutcome struct {
|
||||
// urgency 是档位下限标签(urgency.Level 的词汇),无法证明时为空串。
|
||||
urgency string
|
||||
// bypassed 报告该轮是否穿透了安静时段(安静时段内仍被放行)。
|
||||
bypassed bool
|
||||
}
|
||||
|
||||
// backthinkQuietOutcomeOf 推导一条存档记录可证的紧急度下限与穿透标记。
|
||||
//
|
||||
// `at` 为零值(时间戳格式错乱的行,见 backthink.store.parseTime 的兜底)时一律
|
||||
// 返回「无事实」:宁可不显示,也不把零值时间当成凌晨而谎报一轮穿透。
|
||||
func backthinkQuietOutcomeOf(rec backthink.Record, startHour, endHour int) backthinkQuietOutcome {
|
||||
if rec.At.IsZero() {
|
||||
return backthinkQuietOutcome{}
|
||||
}
|
||||
if !quietHoursActiveAt(startHour, endHour, rec.At.Local()) {
|
||||
return backthinkQuietOutcome{}
|
||||
}
|
||||
if rec.ShouldPush {
|
||||
return backthinkQuietOutcome{urgency: urgency.LevelWarning.String(), bypassed: true}
|
||||
}
|
||||
return backthinkQuietOutcome{urgency: urgency.LevelRoutine.String()}
|
||||
}
|
||||
|
||||
// quietHoursActiveAt 报告 at 是否落在安静时段窗口内。口径与既有三处逐字一致
|
||||
// (`backthink.decide.inQuietHours`、`channelbridge` 轻思考门、
|
||||
// `memory.TidyConfig.inQuietHours`):窗口是半开的 `[start, end)`,可跨午夜;
|
||||
// 两端相等即关闭(`23/23` 与 `0/0` 都得到「从不生效」)。
|
||||
func quietHoursActiveAt(startHour, endHour int, at time.Time) bool {
|
||||
h := at.Hour()
|
||||
if startHour <= endHour {
|
||||
return h >= startHour && h < endHour
|
||||
}
|
||||
return h >= startHour || h < endHour
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
|
||||
)
|
||||
|
||||
// 本文件是 P4「后台思考行紧急度 / 穿透安静时段」的测试:钉住
|
||||
// 「穿透标记 ⇔ 档位下限」的对应关系,以及「不可证时不下发」的诚实边界。
|
||||
|
||||
// quietCfg 构造安静时段 = 23:00-07:00(跨午夜)的生效配置。
|
||||
func quietCfg() *config.Config {
|
||||
cfg := cfgDefault()
|
||||
cfg.Background.QuietStartHour = 23
|
||||
cfg.Background.QuietEndHour = 7
|
||||
return cfg
|
||||
}
|
||||
|
||||
// localAt 造一个**本地时区**的墙钟时刻(安静时段是本地钟点窗口)。
|
||||
func localAt(hour int) time.Time {
|
||||
return time.Date(2026, 10, 6, hour, 30, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// TestAdminBackthink_QuietHoursBypassMatchesUrgency 是判据 ④:管理行上的穿透标记
|
||||
// 必须与 `urgency` 档位下限一一对应 ——
|
||||
// - 安静时段内 + 判定要推 → urgency=warning(≥ high)+ quietHoursBypassed=true;
|
||||
// - 安静时段内 + 判定不推 → urgency=routine + 无穿透键;
|
||||
// - 安静时段外 → 两个键都不下发(存档不可证,绝不猜)。
|
||||
func TestAdminBackthink_QuietHoursBypassMatchesUrgency(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := &stubBackthinkFeed{records: []backthink.Record{
|
||||
{
|
||||
ID: 3, Trigger: "iot.device.smoke", At: localAt(2), Conclusion: "烟雾告警,得叫她",
|
||||
ShouldPush: true,
|
||||
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "delivered to 1 active client(s), event_bypass"},
|
||||
},
|
||||
{
|
||||
ID: 2, Trigger: "heartbeat", At: localAt(3), Conclusion: "深夜只是想一想",
|
||||
ShouldPush: false,
|
||||
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "quiet hours; not interrupting"},
|
||||
},
|
||||
{
|
||||
ID: 1, Trigger: "heartbeat", At: localAt(12), Conclusion: "白天的一轮",
|
||||
ShouldPush: true,
|
||||
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteIMBridge, Detail: "delivered via IM bridge"},
|
||||
},
|
||||
}}
|
||||
_, 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) != 3 {
|
||||
t.Fatalf("items = %d, want 3", len(items))
|
||||
}
|
||||
body := string(raw)
|
||||
byID := map[string]AdminBackthinkItem{}
|
||||
for _, it := range items {
|
||||
byID[it.ID] = it
|
||||
}
|
||||
// ① 安静时段内被放行 ⇒ 穿透 + 档位下限 warning(= 至少 high)。
|
||||
bypass := byID["3"]
|
||||
if !bypass.QuietHoursBypassed || bypass.Urgency != urgency.LevelWarning.String() {
|
||||
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)
|
||||
}
|
||||
// ③ 安静时段之外 ⇒ 两个键都不下发(存档不可证)。
|
||||
day := byID["1"]
|
||||
if day.QuietHoursBypassed || day.Urgency != "" {
|
||||
t.Fatalf("白天行 = %+v, want 两个键都缺席", day)
|
||||
}
|
||||
// 线上形状:真穿透的行必须带这两个键;不可证的行必须**不带**它们(不是 false)。
|
||||
if !strings.Contains(body, `"urgency":"warning"`) || !strings.Contains(body, `"quietHoursBypassed":true`) {
|
||||
t.Fatalf("响应缺少穿透/档位键:%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"urgency":"routine"`) {
|
||||
t.Fatalf("响应缺少被扣下那轮的档位:%s", body)
|
||||
}
|
||||
if strings.Contains(body, `"quietHoursBypassed":false`) {
|
||||
t.Fatalf("「没穿透」不得写成 false(键必须缺席,才不把「未知」画成事实):%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_OmitsUrgencyForZeroTimestamp 钉住兜底:时间戳坏掉的行
|
||||
// (backthink 存储层解析失败会给零值时间)**不下发**任何档位/穿透 ——
|
||||
// 宁可不显示,也不把零值时间当成凌晨而谎报一轮穿透。
|
||||
func TestAdminBackthink_OmitsUrgencyForZeroTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := &stubBackthinkFeed{records: []backthink.Record{
|
||||
{ID: 1, Trigger: "broken", ShouldPush: true},
|
||||
}}
|
||||
_, 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)
|
||||
}
|
||||
got := items[0]
|
||||
if got.Urgency != "" || got.QuietHoursBypassed {
|
||||
t.Fatalf("零值时间戳不得产出档位/穿透:%+v", got)
|
||||
}
|
||||
if strings.Contains(string(raw), `"urgency"`) || strings.Contains(string(raw), `"quietHoursBypassed"`) {
|
||||
t.Fatalf("零值时间戳必须省略两个键:%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietHoursActiveAt_WindowSemantics 钉住窗口口径(与 backthink.decide 的
|
||||
// inQuietHours 逐字一致):半开区间、可跨午夜、两端相等即关闭。
|
||||
func TestQuietHoursActiveAt_WindowSemantics(t *testing.T) {
|
||||
t.Parallel()
|
||||
at := func(hour, minute int) time.Time {
|
||||
return time.Date(2026, 10, 6, hour, minute, 0, 0, time.Local)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
start, end int
|
||||
when time.Time
|
||||
want bool
|
||||
}{
|
||||
{"跨午夜:起点", 23, 7, at(23, 0), true},
|
||||
{"跨午夜:午前", 23, 7, at(2, 30), true},
|
||||
{"跨午夜:终点前", 23, 7, at(6, 59), true},
|
||||
{"跨午夜:终点(半开区间不含)", 23, 7, at(7, 0), false},
|
||||
{"跨午夜:起点前", 23, 7, at(22, 59), false},
|
||||
{"跨午夜:白天", 23, 7, at(12, 0), false},
|
||||
{"日间窗口", 9, 17, at(10, 0), true},
|
||||
{"日间窗口:窗口外", 9, 17, at(22, 0), false},
|
||||
{"两端相等 ⇒ 关闭", 23, 23, at(23, 30), false},
|
||||
{"零值 ⇒ 关闭", 0, 0, at(1, 0), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := quietHoursActiveAt(tc.start, tc.end, tc.when); got != tc.want {
|
||||
t.Fatalf("quietHoursActiveAt(%d,%d,%v) = %v, want %v", tc.start, tc.end, tc.when, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackthinkQuietOutcomeOf_ReadsLocalHour 钉住时区口径:库里存 UTC,但安静
|
||||
// 时段是**本地钟点**窗口 ⇒ 推导必须先换回本地时区再取钟点(否则北京时间凌晨 2 点
|
||||
// 会被当成 UTC 18 点,穿透标记整片错位)。
|
||||
func TestBackthinkQuietOutcomeOf_ReadsLocalHour(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, offset := time.Now().In(time.Local).Zone(); offset == 0 {
|
||||
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
|
||||
}
|
||||
local2am := localAt(2)
|
||||
utc := local2am.UTC()
|
||||
if utc.Hour() == local2am.Hour() {
|
||||
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
|
||||
}
|
||||
if got := backthinkQuietOutcomeOf(backthink.Record{At: utc, ShouldPush: true}, 23, 7); !got.bypassed {
|
||||
t.Fatalf("UTC 存储的本地凌晨 2 点必须判为穿透:%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件从 `internal/config` 的 env 标签里读出各键的**声明默认值**,供只读配置
|
||||
// 事实面(admin_config_facts.go)如实展示「默认 / 已改」。
|
||||
//
|
||||
// 为什么用标签而不是另一份默认值清单:`default:"…"` 标签本身就是配置层的唯一
|
||||
// 真源(`config.lookupRaw` 在进程环境与 `.env` 都没给值时正是用它)。网关再抄
|
||||
// 一份等于制造第二份事实,两边迟早对不上;而反射读标签既不需要 fopen `.env`
|
||||
// (主人红线:键名单读 `.env.example`,**绝不读 `.env`**),也不需要新增配置包
|
||||
// 导出面。
|
||||
|
||||
// envTagName 与 config 包的内部约定一致:env 标签可带逗号分隔的选项,
|
||||
// 变量名是逗号前的部分。
|
||||
func envTagName(tag string) string {
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// declaredEnvDefaults 遍历配置结构体,返回 `env:"KEY"` 字段的声明默认值
|
||||
// (`default:"…"` 标签),按字段类型归一化成字符串,键与取值口径与
|
||||
// `buildConfigFacts` 对齐:
|
||||
//
|
||||
// - bool → "true"/"false"(标签里的写法大小写、多余空白都被吸收);
|
||||
// - int → 十进制;
|
||||
// - float → 归一化小数(`0.80` ⇒ `0.8`);
|
||||
// - string→ 原样(去首尾空白)。
|
||||
//
|
||||
// 没有 `default` 标签的字段不进表(其「未设置」语义由各自的 Set 标志单独表达,
|
||||
// 不是本事实面的内容);无法归一化的值原样保留,宁可显示原始文本也不猜。
|
||||
func declaredEnvDefaults(root any) map[string]string {
|
||||
out := make(map[string]string)
|
||||
if root == nil {
|
||||
return out
|
||||
}
|
||||
collectEnvDefaults(reflect.ValueOf(root), out, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// maxConfigDepth 是递归下探嵌套配置组的深度上限(防御性:配置是静态结构,
|
||||
// 但它必须是常量而不是魔法数字)。
|
||||
const maxConfigDepth = 8
|
||||
|
||||
// collectEnvDefaults 递归收集默认值。非结构体、指针、不可寻址的字段一律跳过:
|
||||
// 配置结构体是静态的平面嵌套,够不到的东西不猜。
|
||||
func collectEnvDefaults(v reflect.Value, out map[string]string, depth int) {
|
||||
if depth > maxConfigDepth {
|
||||
return
|
||||
}
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
fv := v.Field(i)
|
||||
tag, hasEnv := field.Tag.Lookup("env")
|
||||
if !hasEnv {
|
||||
// 没有 env 标签的字段是嵌套配置组。
|
||||
collectEnvDefaults(fv, out, depth+1)
|
||||
continue
|
||||
}
|
||||
name := envTagName(tag)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
def, hasDefault := field.Tag.Lookup("default")
|
||||
if !hasDefault {
|
||||
continue
|
||||
}
|
||||
out[name] = normalizeDefault(fv.Kind(), def)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeDefault 把标签里的默认值按字段类型归一化,形状与事实面的生效值一致。
|
||||
func normalizeDefault(kind reflect.Kind, raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
switch kind {
|
||||
case reflect.Bool:
|
||||
if b, err := strconv.ParseBool(strings.ToLower(text)); err == nil {
|
||||
return strconv.FormatBool(b)
|
||||
}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
if n, err := strconv.ParseInt(text, 10, 64); err == nil {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if f, err := strconv.ParseFloat(text, 64); err == nil {
|
||||
return formatRatio(f)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参
|
||||
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键)。三条边界(AGENTS §10 与
|
||||
// 主人红线):
|
||||
//
|
||||
// 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明
|
||||
// 一份清单):事实表逐条与模板键名对齐,由
|
||||
// `TestAdminConfigFacts_KeysMatchEnvExample` 钉住;加了键却忘了同步模板会被
|
||||
// 这条测试打红(协同 E6 的同一类固定动作)。
|
||||
// 2. **值只取「进程生效值」**:即 `config.Load` 解析后的字段(进程环境 → `.env`
|
||||
// → 默认标签的优先级链已经由配置层消化)。网关**绝不 fopen `.env`**,因此
|
||||
// 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明
|
||||
// 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认
|
||||
// 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。
|
||||
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 9 个非敏感调参键,响应里连
|
||||
// 凭据类键的「是否已配置」都不出现。
|
||||
// `TestAdminConfigFacts_NeverLeaksSecretValues` 用哨兵值把这条钉死。
|
||||
//
|
||||
// 安静时段的**语义**(面板文案必须照此写):它**只服务「定时主动思考 → 主动搭话」**,
|
||||
// **不静默回复**(design/12 §6 策略五;主人裁决 10)。所以这里只报「窗口是多少、
|
||||
// 是否被关掉」,绝不在契约或文案里说「夜间全静默」。
|
||||
|
||||
// AdminConfigFact 是一条只读「配置事实」:键名 + 进程生效值 + 声明默认值。
|
||||
//
|
||||
// Value 与 Default 都是**归一化后的字符串**(bool → "true"/"false",整数 →
|
||||
// 十进制,比例 → 归一化小数如 "0.8"),因为面板把它当文本显示,且归一化后
|
||||
// 「生效值 == 声明默认值」才是一次可靠的比较。
|
||||
type AdminConfigFact struct {
|
||||
Key string `json:"key"`
|
||||
// Value 是进程生效值(已解析),不是 `.env` 原文。
|
||||
Value string `json:"value"`
|
||||
// Default 是该键的声明默认值(internal/config 的 default 标签,唯一真源)。
|
||||
Default string `json:"default"`
|
||||
// IsDefault 报告生效值是否等于声明默认值。它为 true 时面板显示「默认」;
|
||||
// 为 false 时显示「已改」。**它不是**「`.env` 没写过这个键」的断言 ——
|
||||
// 本端点不读 `.env`,两者不可区分,因此不做那个更强的声称。
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
// AdminConfigQuietHours 是安静时段窗口的**解释**(不是新配置):两个裸钟点不足以
|
||||
// 表达「跨午夜」「已关闭」与「两端写 0 视为未填」,而面板文案需要这些结论。
|
||||
type AdminConfigQuietHours struct {
|
||||
StartHour int `json:"startHour"`
|
||||
EndHour int `json:"endHour"`
|
||||
Disabled bool `json:"disabled"`
|
||||
// Label 是可直接显示的窗口文案("23:00-07:00" 或 "已关闭")。
|
||||
Label string `json:"label"`
|
||||
// FallbackToDefault 报告窗口两端都被写成 0。0/0 是**未填**而不是**关闭**:
|
||||
// backthink(cfg.go normalize)与记忆整理(tidy_config.go normalizeTidyConfig)
|
||||
// 都会在这个形状上回落到设计默认 23:00-07:00,因此这里的 StartHour/EndHour
|
||||
// 报的是**回落之后真正生效**的钟点,面板要把这层解释显示出来 —— 否则事实面会把
|
||||
// 「未填」谎报成「已关闭」。要关窗口请写两个相等的**非 0** 钟点(如 23/23)。
|
||||
FallbackToDefault bool `json:"fallbackToDefault,omitempty"`
|
||||
}
|
||||
|
||||
// AdminConfigFacts 是 GET /api/v1/admin/config-facts 的线上结构。
|
||||
type AdminConfigFacts struct {
|
||||
Facts []AdminConfigFact `json:"facts"`
|
||||
QuietHours AdminConfigQuietHours `json:"quietHours"`
|
||||
}
|
||||
|
||||
// 安静时段关闭时的展示文案(窗口两端相等即关闭,见 quietHoursWindow)。
|
||||
const quietHoursDisabledLabel = "已关闭"
|
||||
|
||||
// configFactSpec 是一条事实的取数说明:env 键名 + 从生效配置里取值。
|
||||
type configFactSpec struct {
|
||||
key string
|
||||
value func(cfg *config.Config) string
|
||||
}
|
||||
|
||||
// configFactSpecs 是事实表的**唯一真源**:顺序即面板显示顺序(先记忆整理,
|
||||
// 后安静时段)。键名必须与 `.env.example` 逐字一致(测试钉住)。
|
||||
func configFactSpecs() []configFactSpec {
|
||||
return []configFactSpec{
|
||||
{"MEMORY_TIDY_ENABLED", func(c *config.Config) string {
|
||||
return strconv.FormatBool(c.MemoryTidy.Enabled)
|
||||
}},
|
||||
{"MEMORY_TIDY_INTERVAL_SEC", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.IntervalSec)
|
||||
}},
|
||||
{"MEMORY_TIDY_QUIET_START_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.QuietStartHour)
|
||||
}},
|
||||
{"MEMORY_TIDY_QUIET_END_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.QuietEndHour)
|
||||
}},
|
||||
{"MEMORY_TIDY_RECALL_THRESHOLD", func(c *config.Config) string {
|
||||
return formatRatio(c.MemoryTidy.RecallThreshold)
|
||||
}},
|
||||
{"MEMORY_TIDY_SCAN_LIMIT", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.ScanLimit)
|
||||
}},
|
||||
{"MEMORY_TIDY_CANDIDATE_LIMIT", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.CandidateLimit)
|
||||
}},
|
||||
{"QUIET_START_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.Background.QuietStartHour)
|
||||
}},
|
||||
{"QUIET_END_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.Background.QuietEndHour)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// formatRatio 把比例值归一化成与 declaredEnvDefaults 相同的形状,使
|
||||
// 「生效值 == 声明默认值」的比较不受写法影响(标签写 `0.80`、值算出来 `0.8`)。
|
||||
func formatRatio(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
// buildConfigFacts 组装配置事实面。它是纯函数(不读文件、不读 env),因此可
|
||||
// 直接单测;cfg 为 nil 时回退到一个零值配置(此时 9 条事实全部照实报零值 +
|
||||
// 声明默认值,面板会看到 isDefault=false 的「已改」而不是一个编出来的值)。
|
||||
func buildConfigFacts(cfg *config.Config) AdminConfigFacts {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
defaults := declaredEnvDefaults(cfg)
|
||||
specs := configFactSpecs()
|
||||
out := AdminConfigFacts{Facts: make([]AdminConfigFact, 0, len(specs))}
|
||||
for _, spec := range specs {
|
||||
value := spec.value(cfg)
|
||||
def := defaults[spec.key]
|
||||
out.Facts = append(out.Facts, AdminConfigFact{
|
||||
Key: spec.key,
|
||||
Value: value,
|
||||
Default: def,
|
||||
IsDefault: def != "" && value == def,
|
||||
})
|
||||
}
|
||||
out.QuietHours = quietHoursFacts(cfg.Background.QuietStartHour, cfg.Background.QuietEndHour, defaults)
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveQuietHours 返回**运行时真正生效**的安静时段钟点,并报告是否发生了
|
||||
// 「未填 → 默认」的回落:
|
||||
//
|
||||
// - 两端相等且非 0 ⇒ 窗口关闭(既有三处同口径),返回原来的钟点;
|
||||
// - 两端都为 0 ⇒ **未填**:组合根把 `config.Background` 交给 backthink,而
|
||||
// backthink(cfg.go 的 normalize)与记忆整理(tidy_config.go 的
|
||||
// normalizeTidyConfig)都会在 0/0 上回落到设计默认 23:00-07:00。事实面与
|
||||
// 后台思考行的事实推导都必须按**回落之后**的窗口算,否则会把「未填」当成
|
||||
// 「没有安静时段」,从而漏报真正的穿透。回落值取**声明默认值**(env 标签),
|
||||
// 与事实行的 default 同源,不另抄一份常量。
|
||||
//
|
||||
// defaults 是 declaredEnvDefaults 的结果;缺值(或非法)时如实**不回落**,让调用方
|
||||
// 看到原始值,而不是编一个 23 点窗口。
|
||||
func effectiveQuietHours(cfg *config.Config, defaults map[string]string) (start, end int, fell bool) {
|
||||
if cfg == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return normalizeQuietHours(cfg.Background.QuietStartHour, cfg.Background.QuietEndHour, defaults)
|
||||
}
|
||||
|
||||
// normalizeQuietHours 是上面那条回落规则的纯函数内核(0/0 ⇒ 用声明默认)。
|
||||
func normalizeQuietHours(start, end int, defaults map[string]string) (int, int, bool) {
|
||||
if start != 0 || end != 0 {
|
||||
return start, end, false
|
||||
}
|
||||
ds, okS := parseHourDefault(defaults["QUIET_START_HOUR"])
|
||||
de, okE := parseHourDefault(defaults["QUIET_END_HOUR"])
|
||||
if !okS || !okE {
|
||||
return start, end, false
|
||||
}
|
||||
return ds, de, true
|
||||
}
|
||||
|
||||
// effectiveQuietHours 是 Server 侧取生效窗口的便捷入口(后台思考行的事实推导与
|
||||
// 配置事实面共用同一口径,避免两处漂移)。
|
||||
func (s *Server) effectiveQuietHours() (int, int) {
|
||||
start, end, _ := effectiveQuietHours(s.cfg, declaredEnvDefaults(s.cfg))
|
||||
return start, end
|
||||
}
|
||||
|
||||
// quietHoursFacts 把生效窗口解释成线上结构(跨午夜 / 已关闭 / 是否回落)。
|
||||
func quietHoursFacts(start, end int, defaults map[string]string) AdminConfigQuietHours {
|
||||
effStart, effEnd, fell := normalizeQuietHours(start, end, defaults)
|
||||
out := AdminConfigQuietHours{StartHour: effStart, EndHour: effEnd, FallbackToDefault: fell}
|
||||
// 关闭 = 两个相等的非 0 钟点(回落之后不可能出现 0/0,所以不必再特判)。
|
||||
out.Disabled = !fell && effStart == effEnd && effStart != 0
|
||||
if out.Disabled {
|
||||
out.Label = quietHoursDisabledLabel
|
||||
} else {
|
||||
out.Label = formatHour(effStart) + "-" + formatHour(effEnd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseHourDefault 解析声明默认值里的钟点;缺失或不合法时如实报「不知道」
|
||||
// (调用方据此不做回落,而不是编一个 0 点窗口)。
|
||||
func parseHourDefault(raw string) (int, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
hour, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return hour, true
|
||||
}
|
||||
|
||||
// formatHour 把钟点格式化成 "HH:00"(面板文案用)。
|
||||
func formatHour(hour int) string {
|
||||
if hour < 0 {
|
||||
hour = 0
|
||||
}
|
||||
return pad2(hour) + ":00"
|
||||
}
|
||||
|
||||
// pad2 把 0..99 补成两位;三位及以上的值原样返回(配置非法时也不吞掉信息)。
|
||||
func pad2(n int) string {
|
||||
if n >= 0 && n < 10 {
|
||||
return "0" + strconv.Itoa(n)
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// adminConfigFacts 是 GET /api/v1/admin/config-facts 的处理函数:只读、不审计、
|
||||
// 不读 `.env`、不回凭据。它与消费它的两个面板页(记忆管理、后台思考)同门
|
||||
// (owner/admin),因为它们是**面板配置事实**,与 `/admin/memories`、`/admin/llm`
|
||||
// 同一档,而不是给 viewer 的运行时监控投影。
|
||||
func (s *Server) adminConfigFacts(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, buildConfigFacts(s.cfg))
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件是 P3「配置只读事实面」的测试。四条被钉住的判据:
|
||||
// ① 安静时段的**值** = 进程生效值(不是常量、不是模板默认);
|
||||
// ② 「未配置/默认」如实呈现(value + default + isDefault 三者合起来不伪造事实);
|
||||
// ③ 响应里**不含任何密钥/令牌值**(红线,用哨兵值钉死);
|
||||
// ④ 键名单 = `.env.example`(键名单的权威来源,协同 E6 的固定动作)。
|
||||
|
||||
// getConfigFacts 调用管理端点并返回状态码、原始响应体与解码结果。
|
||||
func getConfigFacts(t *testing.T, cfg *config.Config, tok string) (int, []byte, AdminConfigFacts) {
|
||||
t.Helper()
|
||||
_, ts, jwt := buildEnv(t, cfg, Deps{})
|
||||
token := tok
|
||||
if token == "" {
|
||||
token = signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
}
|
||||
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, token)
|
||||
var out struct {
|
||||
Data AdminConfigFacts `json:"data"`
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, raw, out.Data
|
||||
}
|
||||
|
||||
// factByKey 取出某键的事实(不存在 ⇒ 测试失败)。
|
||||
func factByKey(t *testing.T, facts []AdminConfigFact, key string) AdminConfigFact {
|
||||
t.Helper()
|
||||
for _, f := range facts {
|
||||
if f.Key == key {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("fact %s missing from %+v", key, facts)
|
||||
return AdminConfigFact{}
|
||||
}
|
||||
|
||||
// quietHoursCfg 构造一个「安静时段 = 01:00-05:00、记忆整理阈值被改过」的生效配置:
|
||||
// 值全部来自配置字段,因此它模拟的正是 `config.Load` 解析后的样子。
|
||||
func quietHoursCfg() *config.Config {
|
||||
cfg := cfgDefault()
|
||||
cfg.Background.QuietStartHour = 1
|
||||
cfg.Background.QuietEndHour = 5
|
||||
cfg.MemoryTidy = config.MemoryTidyConfig{
|
||||
Enabled: false,
|
||||
IntervalSec: 3600,
|
||||
QuietStartHour: 2,
|
||||
QuietEndHour: 6,
|
||||
RecallThreshold: 0.7,
|
||||
ScanLimit: 10,
|
||||
CandidateLimit: 3,
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_QuietHoursAreProcessEffective 是判据 ①:端点回的是
|
||||
// **进程生效值**(config 字段),而不是模板默认值、更不是写死的常量。
|
||||
func TestAdminConfigFacts_QuietHoursAreProcessEffective(t *testing.T) {
|
||||
t.Parallel()
|
||||
status, _, facts := getConfigFacts(t, quietHoursCfg(), "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d", status)
|
||||
}
|
||||
if facts.QuietHours.StartHour != 1 || facts.QuietHours.EndHour != 5 {
|
||||
t.Fatalf("quietHours = %+v, want 1..5(进程生效值)", facts.QuietHours)
|
||||
}
|
||||
if facts.QuietHours.Disabled {
|
||||
t.Fatalf("quietHours 1..5 不应被判定为关闭:%+v", facts.QuietHours)
|
||||
}
|
||||
if facts.QuietHours.Label != "01:00-05:00" {
|
||||
t.Fatalf("label = %q, want 01:00-05:00", facts.QuietHours.Label)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"QUIET_START_HOUR": "1",
|
||||
"QUIET_END_HOUR": "5",
|
||||
"MEMORY_TIDY_QUIET_START_HOUR": "2",
|
||||
"MEMORY_TIDY_QUIET_END_HOUR": "6",
|
||||
"MEMORY_TIDY_ENABLED": "false",
|
||||
"MEMORY_TIDY_INTERVAL_SEC": "3600",
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD": "0.7",
|
||||
"MEMORY_TIDY_SCAN_LIMIT": "10",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT": "3",
|
||||
} {
|
||||
if got := factByKey(t, facts.Facts, key).Value; got != want {
|
||||
t.Fatalf("%s value = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
// 被改过的键必须标成「非默认」,面板才知道它不是默认值。
|
||||
if factByKey(t, facts.Facts, "QUIET_START_HOUR").IsDefault {
|
||||
t.Fatal("QUIET_START_HOUR=1 与声明默认 23 不同,isDefault 必须为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag 是判据 ②:每条事实都带
|
||||
// **声明默认值**(default 标签,非空),且当生效值等于它时 isDefault=true ——
|
||||
// 面板因此能如实显示「默认」,而不是把默认值当成「用户配过的值」。
|
||||
func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
defaults := map[string]string{
|
||||
"MEMORY_TIDY_ENABLED": "true",
|
||||
"MEMORY_TIDY_INTERVAL_SEC": "86400",
|
||||
"MEMORY_TIDY_QUIET_START_HOUR": "23",
|
||||
"MEMORY_TIDY_QUIET_END_HOUR": "7",
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD": "0.8",
|
||||
"MEMORY_TIDY_SCAN_LIMIT": "5000",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT": "20",
|
||||
"QUIET_START_HOUR": "23",
|
||||
"QUIET_END_HOUR": "7",
|
||||
}
|
||||
defaultCfg := &config.Config{
|
||||
MemoryTidy: config.MemoryTidyConfig{
|
||||
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
|
||||
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 20,
|
||||
},
|
||||
Background: config.BackgroundConfig{QuietStartHour: 23, QuietEndHour: 7},
|
||||
}
|
||||
status, _, facts := getConfigFacts(t, defaultCfg, "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d", status)
|
||||
}
|
||||
if len(facts.Facts) != len(defaults) {
|
||||
t.Fatalf("facts = %d, want %d", len(facts.Facts), len(defaults))
|
||||
}
|
||||
for key, want := range defaults {
|
||||
got := factByKey(t, facts.Facts, key)
|
||||
if got.Default == "" {
|
||||
t.Fatalf("%s 没有声明默认值:default 标签与事实表漂移了", key)
|
||||
}
|
||||
if got.Default != want {
|
||||
t.Fatalf("%s default = %q, want %q(字面量钉桩)", key, got.Default, want)
|
||||
}
|
||||
if !got.IsDefault || got.Value != want {
|
||||
t.Fatalf("%s 生效值等于默认值时必须 isDefault=true 且 value=%q:%+v", key, want, got)
|
||||
}
|
||||
}
|
||||
// 「已改」的呈现:改一个键,只有它掉出默认。
|
||||
changed := defaultCfg
|
||||
changed.MemoryTidy.RecallThreshold = 0.55
|
||||
_, _, after := getConfigFacts(t, changed, "")
|
||||
got := factByKey(t, after.Facts, "MEMORY_TIDY_RECALL_THRESHOLD")
|
||||
if got.Value != "0.55" || got.IsDefault {
|
||||
t.Fatalf("改过的键必须如实报 value=0.55 且 isDefault=false:%+v", got)
|
||||
}
|
||||
if !factByKey(t, after.Facts, "QUIET_END_HOUR").IsDefault {
|
||||
t.Fatal("未改的键不得被标成「已改」")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_QuietHoursWindowCanBeDisabled 钉住窗口关闭态的如实呈现
|
||||
// (两个相等的**非 0** 钟点):面板必须能显示「已关闭」而不是一个 23:00-23:00 的假窗口。
|
||||
func TestAdminConfigFacts_QuietHoursWindowCanBeDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := cfgDefault()
|
||||
cfg.Background.QuietStartHour = 23
|
||||
cfg.Background.QuietEndHour = 23
|
||||
_, _, facts := getConfigFacts(t, cfg, "")
|
||||
if !facts.QuietHours.Disabled || facts.QuietHours.Label != quietHoursDisabledLabel {
|
||||
t.Fatalf("两端相等的窗口必须报「已关闭」:%+v", facts.QuietHours)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_ZeroZeroQuietHoursFallsBackToDefault 钉住那个容易谎报的边角:
|
||||
// 两端都写 0 **不是**「关闭窗口」,而是「未填」—— backthink(cfg.go 的 normalize)与
|
||||
// 记忆整理都会回落到设计默认 23:00-07:00。事实面必须报**运行时真正生效**的窗口,否则
|
||||
// 会把「未填」谎报成「已关闭」,进而漏报真实发生的穿透。
|
||||
func TestAdminConfigFacts_ZeroZeroQuietHoursFallsBackToDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := cfgDefault()
|
||||
cfg.Background.QuietStartHour = 0
|
||||
cfg.Background.QuietEndHour = 0
|
||||
_, _, facts := getConfigFacts(t, cfg, "")
|
||||
if facts.QuietHours.Disabled {
|
||||
t.Fatalf("0/0 是「未填」而不是「关闭」:%+v", facts.QuietHours)
|
||||
}
|
||||
if !facts.QuietHours.FallbackToDefault {
|
||||
t.Fatalf("0/0 必须如实报告回落:%+v", facts.QuietHours)
|
||||
}
|
||||
if facts.QuietHours.StartHour != 23 || facts.QuietHours.EndHour != 7 || facts.QuietHours.Label != "23:00-07:00" {
|
||||
t.Fatalf("回落后的生效窗口应为 23:00-07:00:%+v", facts.QuietHours)
|
||||
}
|
||||
// 事实行本身仍如实报「这个键解析出来的值」(0),不替用户改写配置。
|
||||
if got := factByKey(t, facts.Facts, "QUIET_START_HOUR"); got.Value != "0" {
|
||||
t.Fatalf("事实行的 value 必须是该键实际解析出的值:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveQuietHours_MatchesBackthinkNormalize 钉住「生效窗口」是同一口径:
|
||||
// 非 0 值原样、0/0 才回落。
|
||||
func TestEffectiveQuietHours_MatchesBackthinkNormalize(t *testing.T) {
|
||||
t.Parallel()
|
||||
defaults := declaredEnvDefaults(&config.Config{})
|
||||
cases := []struct {
|
||||
name string
|
||||
start, end int
|
||||
wantS int
|
||||
wantE int
|
||||
wantFell bool
|
||||
}{
|
||||
{"常规窗口原样", 1, 5, 1, 5, false},
|
||||
{"跨午夜原样", 23, 7, 23, 7, false},
|
||||
{"相等非 0(关闭)原样", 23, 23, 23, 23, false},
|
||||
{"只有起点为 0 仍是真实窗口", 0, 7, 0, 7, false},
|
||||
{"0/0 视为未填 ⇒ 回落", 0, 0, 23, 7, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &config.Config{Background: config.BackgroundConfig{QuietStartHour: tc.start, QuietEndHour: tc.end}}
|
||||
gotS, gotE, fell := effectiveQuietHours(cfg, defaults)
|
||||
if gotS != tc.wantS || gotE != tc.wantE || fell != tc.wantFell {
|
||||
t.Fatalf("effectiveQuietHours(%d,%d) = (%d,%d,%v), want (%d,%d,%v)",
|
||||
tc.start, tc.end, gotS, gotE, fell, tc.wantS, tc.wantE, tc.wantFell)
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil 配置不 panic、不编窗口。
|
||||
if s, e, fell := effectiveQuietHours(nil, nil); s != 0 || e != 0 || fell {
|
||||
t.Fatalf("nil 配置必须如实报没有窗口:(%d,%d,%v)", s, e, fell)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_NeverLeaksSecretValues 是判据 ③(红线):响应体里绝不许
|
||||
// 出现任何密钥/令牌值。哨兵值刻意取得足够独特,任何形式的泄漏都会被逮到。
|
||||
func TestAdminConfigFacts_NeverLeaksSecretValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
secrets := []string{
|
||||
"jwt-secret-sentinel-0123456789abcdef",
|
||||
"sk-llm-api-key-sentinel",
|
||||
"local-llm-key-sentinel",
|
||||
"embedding-key-sentinel",
|
||||
"postgres://user:pgpassword-sentinel@localhost:5432/cyrene",
|
||||
}
|
||||
cfg := cfgDefault()
|
||||
cfg.Auth.JWTSecret = secrets[0]
|
||||
cfg.Auth.AdminPass = "admin-pass-sentinel"
|
||||
cfg.LLM.APIKey = secrets[1]
|
||||
cfg.LLM.Local.APIKey = secrets[2]
|
||||
cfg.Embedding.APIKey = secrets[3]
|
||||
cfg.DB.PGURL = secrets[4]
|
||||
cfg.Channels = config.ChannelsConfig{
|
||||
AccountsJSON: `{"telegram":[{"id":"conf0","config":{"bot_token":"channel-token-sentinel"}}]}`,
|
||||
}
|
||||
|
||||
status, raw, _ := getConfigFacts(t, cfg, "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, secret := range append(secrets, "admin-pass-sentinel", "channel-token-sentinel") {
|
||||
if strings.Contains(body, secret) {
|
||||
t.Fatalf("响应里出现了凭据值 %q —— 这是红线:%s", secret, body)
|
||||
}
|
||||
}
|
||||
// 正对照:非敏感的事实确实在响应里(否则上面那条断言可能只是空响应混过关)。
|
||||
if !strings.Contains(body, `"QUIET_START_HOUR"`) || !strings.Contains(body, `"quietHours"`) {
|
||||
t.Fatalf("响应缺少配置事实本身:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
|
||||
// `.env.example` —— 9 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
|
||||
// 抄去改就是静默不生效,AGENTS §10 的 E6 固定动作)。
|
||||
func TestAdminConfigFacts_KeysMatchEnvExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
template := string(raw)
|
||||
for _, spec := range configFactSpecs() {
|
||||
if !strings.Contains(template, spec.key+"=") {
|
||||
t.Fatalf(".env.example 里没有 %s= —— 事实表与模板漂移", spec.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_RequiresOwnerOrAdmin 钉住 RBAC 门:可读的是 owner/admin,
|
||||
// viewer 拿不到配置事实面(它不是运行时监控投影)。
|
||||
func TestAdminConfigFacts_RequiresOwnerOrAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
respViewer, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, signAny(t, jwt, "acct", auth.RoleViewer))
|
||||
if respViewer.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer = %d, want 403", respViewer.StatusCode)
|
||||
}
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
|
||||
resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, signAny(t, jwt, "acct", role))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("role %s = %d, want 200", role, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("匿名 = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -375,7 +375,12 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "backthink_failed", "failed to list backthink records"))
|
||||
return
|
||||
}
|
||||
// 安静时段窗口取**运行时真正生效**的那一个(0/0 是「未填」而不是「没有安静时段」,
|
||||
// 见 admin_config_facts.go 的 effectiveQuietHours —— 与 backthink 的 normalize 同口径),
|
||||
// 否则 0/0 部署下会漏报真正的穿透。
|
||||
quietStart, quietEnd := s.effectiveQuietHours()
|
||||
for _, rec := range records {
|
||||
quiet := backthinkQuietOutcomeOf(rec, quietStart, quietEnd)
|
||||
out = append(out, AdminBackthinkItem{
|
||||
ID: strconv.FormatInt(rec.ID, 10),
|
||||
Trigger: rec.Trigger,
|
||||
@@ -386,6 +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,
|
||||
QuietHoursBypassed: quiet.bypassed,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
|
||||
@@ -176,6 +176,10 @@ func (s *Server) registerAdminRoutes(
|
||||
mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog)))
|
||||
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
|
||||
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
|
||||
// 配置事实面(切片 P3,**v0.68**):记忆整理 7 键 + 安静时段两键的**进程生效值**
|
||||
// 与声明默认值。只读、不审计、**不读 `.env`**、**不含任何凭据**;与消费它的面板页
|
||||
// (记忆管理 / 后台思考)同门,故走 owner|admin 档而不是 viewer 监控档。
|
||||
mux.Handle("GET /api/v1/admin/config-facts", writeAuth(http.HandlerFunc(s.adminConfigFacts)))
|
||||
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||
|
||||
Reference in new issue
Block a user