feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600
主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。 - 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频 - 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红) - 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住) - 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版 - ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍** - 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
This commit is contained in:
1 parent
d6ed9cfa2b
commit
236b202fda
13 files changed
+633
-54
No files matched your search
+9
-6
@@ -172,7 +172,7 @@
|
||||
| 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 |
|
||||
| B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读;v0.75 追加 3 键)**:记忆整理 7 键 + 后台思考心跳 3 键(`ENABLE_BACKGROUND_THINKING`/`THINK_INTERVAL_SEC`/`THINK_QUIET_INTERVAL_SEC`)+ 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 |
|
||||
| B13 | GET | `/api/v1/admin/flow/graph` | **链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 |
|
||||
| B14 | POST | `/api/v1/admin/memories/tidy/dry-run` | **记忆整理「试算」(v0.75 新增,只读语义的 POST)**:跑同一套整理规则、把候选**直接回在响应里**,**零副作用**(不写任何表)。请求体可为空。响应 `{data:{dryRun:true,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated?,candidates[]}}`;`candidates` 恒为非 null 数组(空数组 = 「算过了,没有建议」,与「没算出来」的错误响应**必须能区分**)。失败:未接线 → `500 tidy_unavailable`、配置关闭 → `409 tidy_disabled`、语义召回不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`。**不审计**(只读计算)。语义与形状见 §4.12 |
|
||||
|
||||
@@ -716,10 +716,11 @@
|
||||
| `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 个非敏感调参键。
|
||||
- **固定 12 条事实**(记忆整理 7 键 + 后台思考心跳 3 键 + 安静时段 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`、`ENABLE_BACKGROUND_THINKING`、`THINK_INTERVAL_SEC`、`THINK_QUIET_INTERVAL_SEC`、`QUIET_START_HOUR`、`QUIET_END_HOUR`。
|
||||
- 🆕 **v0.75 追加(同一端点、无版本化形状变更)**:`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` / `THINK_QUIET_INTERVAL_SEC` 三键并入事实表(design/12 §N A4)—— 安静时段降频上线后,面板必须能读到「普通心跳周期」与「安静窗口内的心跳周期」。⚠️ `THINK_QUIET_INTERVAL_SEC` **≤0 = 关闭降频**(回落普通间隔,不是「半夜不思考」),事实面照原值回,不替用户改写。⚠️ 这几个键都是**启动期**读取的:改了要**重启**才生效(不可热改)。⚠️ `ENABLE_BACKGROUND_THINKING` 是**未接线**的死配置(全仓无消费者,见 §5「配了不生效」):它被列进事实表是为了**让面板看得见**,`value` 只是它解析出来的值,**不代表它会生效**;面板文案必须标明(见 `frontend/web/src/admin/modules/configFacts.ts`)。
|
||||
- 🔴 **不含任何凭据**:响应里不出现 JWT 密钥 / LLM API key / 渠道 token / 数据库 DSN 的值(后端有哨兵测试钉死),**也不回凭据类键的「是否已配置」** —— 本端点只覆盖上面 12 个非敏感调参键。
|
||||
- **「默认 / 已改」怎么读**:`isDefault:true` ⇒ 面板显示「默认」(生效值等于该键的声明默认值);`isDefault:false` ⇒ 显示「已改」。⚠️ 它**不是**「`.env` 里没写过这个键」的断言 —— 网关按红线**不读 `.env`**,无从不区分「`.env` 给的值恰好等于默认」与「完全没配」。**哪一项没配**因此不会被伪造成一个值:面板拿 `value` + `isDefault` 如实展示即可。
|
||||
- 9 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。
|
||||
- 12 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。
|
||||
- **安静时段的语义(重要,别照旧实现写文案)**:安静时段**只服务「定时主动思考 → 主动搭话」**(`design/12 §6` 策略五),**不静默回复** —— 白天晚上的聊天回复都不受它约束;群聊也没有夜间静默(主人裁决 10)。因此面板文案**不得**写成「夜间全静默」。
|
||||
|
||||
#### (b) `GET /api/v1/admin/backthink` 条目的紧急度字段(v0.68 引入;**v0.70 起是精确档位**)
|
||||
@@ -867,14 +868,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_*` | 见模板 | 后台思考与主动推送策略。`QUIET_START_HOUR`(`23`)/ `QUIET_END_HOUR`(`7`)是**本地钟点**的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 `23/7`):**只服务「定时主动思考 → 主动搭话」**,**不静默回复**(v0.68 起可经 B12 读到生效值) |
|
||||
| `THINK_*` / `QUIET_*` | 见模板 | 后台思考与主动推送策略。`QUIET_START_HOUR`(`23`)/ `QUIET_END_HOUR`(`7`)是**本地钟点**的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 `23/7`):**只服务「定时主动思考 → 主动搭话」**,**不静默回复**(v0.68 起可经 B12 读到生效值)。`THINK_INTERVAL_SEC`(`900`)/ **`THINK_QUIET_INTERVAL_SEC`(`3600`,v0.75 新增)**:落在安静窗口内时,**周期性心跳**改用后者降频、窗外回落前者;**`≤0` = 关闭降频**(回落普通间隔,**不是**「半夜不思考」)。⚠️ 它**只降周期性心跳**:事件驱动(安全域/急性事件)照旧立刻触发、照旧穿透安静时段 |
|
||||
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆**。**v0.75 起**:关闭时试算端点(B14)也**拒绝作答**(`409 tidy_disabled`),不绕过这个选择 |
|
||||
| `MEMORY_TIDY_INTERVAL_SEC` | `86400` | 整理周期(秒),是**下限**:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者 |
|
||||
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7`。⚠️ 窗口**只管心跳调度**:试算端点(B14)**不受它约束**(否则「现在就想看」正是它存在的理由) |
|
||||
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定)。试算端点(B14)用**同一个**值 |
|
||||
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / `20` | 每分区每轮的扫描上限 / 候选上限。试算端点(B14)用**同一对**上限(不另造一套) |
|
||||
|
||||
> **上面 9 个键(记忆整理 7 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」;在此之前面板没有任何 env 读取面。
|
||||
> **上面 12 个键(记忆整理 7 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入);在此之前面板没有任何 env 读取面。
|
||||
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
|
||||
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
|
||||
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
|
||||
@@ -887,6 +888,7 @@
|
||||
**与契约相关但「配了不生效」的键**:
|
||||
|
||||
- **`CHAT_MAX_PARALLEL_TURNS`:未接线**。编排器有构造期旋钮 `Deps.MaxParallelTurns`,但**组合根未从任何 env 读它** ⇒ **生产恒为默认 2**(`.env` 里配了不生效)。**本文档不把它登记为可用旋钮**。
|
||||
- **`ENABLE_BACKGROUND_THINKING` / `THINK_SILENCE_TIMEOUT_SEC`:未接线**(全仓无消费者;`DevDocs/audit/07-backthink-automation-iot.md` §1-17,是否接线待主人拍板)。`ENABLE_BACKGROUND_THINKING` 自 v0.75 起被列进 B12 的只读事实面 —— 那是为了**让面板看得见这个死开关**,**不是**「它已生效」的声称:`facts[].value` 只是解析值,配 `false` 也不会关掉后台思考。`THINK_SILENCE_TIMEOUT_SEC` **未列入**事实面。
|
||||
- 旧 `MCP_*` 扁平键:生产装配只读 `MCP_SERVERS_JSON`。
|
||||
- `GETUI_*`:SDK 未接入,预留占位、当前不生效。
|
||||
|
||||
@@ -902,6 +904,7 @@
|
||||
**记忆整理「试算」端点 —— 新增 `POST /api/v1/admin/memories/tidy/dry-run`:跑同一套整理规则、把候选直接回在响应里,且一行都不写**(依据主人 2026-10-05 要求「现在就想看到在 736 条上它会建议合并哪些,而不等到 23:00」;对齐 `design/23 §4`「低峰定期后台任务 + 手动可触发的只读试算」)。
|
||||
|
||||
- **纯增量**:新增 1 条 `writeAuth`(owner/admin)管理的**只读语义 POST**(§2.2 B14 / §4.12)。既有端点、既有帧、既有 env 键语义**一字未改**。
|
||||
- **同版追加(无形状变更)**:`GET /api/v1/admin/config-facts` 的事实表由 9 条扩到 **12 条** —— 并入 `ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` / **`THINK_QUIET_INTERVAL_SEC`(安静窗口内心跳周期,默认 `3600`;`≤0` = 关闭降频)**,支撑 design/12 §N「静默时段降频」(§2.2 B12 / §4.10(a))。`facts[]` 形状与 `quietHours` **一字未改**,只是多了 3 个元素 ⇒ 老客户端零影响。
|
||||
- **零副作用是硬约束**:端点走的是与低峰定时轮**共用**的规则路径(同一召回门 + 同一包含判定 + 同一规则否决 + 同一每分区候选上限),但**候选不落库** —— 不写 `memory_tidy_candidates`、不写 `memory_tidy_runs`、**绝不改 `memory_items` 的任何列**。后端有「调用前后三张表逐行快照比对」的单测钉死(另有一条断言「试算候选 == 真跑一轮落库的候选」)。
|
||||
- **失败绝不伪装成「没有建议」**:未接线 → `500 tidy_unavailable`、`MEMORY_TIDY_ENABLED=false` → `409 tidy_disabled`、语义召回(embedding)不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`、其余内部失败 → `500 tidy_dry_run_failed`。**不回** `candidates:[]` 来表示失败 —— 空数组唯一含义是「算过了,这一轮没有建议」。
|
||||
- **试算专属的严格**:语义召回不可用时,**试算报错而不静默降级**到词项余弦(否则面板会把「语义路径没跑起来」读成「没有候选」)。低峰定时轮与手动轮的降级口径**不变**。
|
||||
|
||||
@@ -39,6 +39,9 @@ const FACTS: AdminConfigFacts = {
|
||||
{ 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: "ENABLE_BACKGROUND_THINKING", value: "true", default: "true", isDefault: true },
|
||||
{ key: "THINK_INTERVAL_SEC", value: "7200", default: "900", isDefault: false },
|
||||
{ key: "THINK_QUIET_INTERVAL_SEC", value: "0", default: "3600", isDefault: false },
|
||||
{ key: "QUIET_START_HOUR", value: "1", default: "23", isDefault: false },
|
||||
{ key: "QUIET_END_HOUR", value: "5", default: "7", isDefault: false },
|
||||
],
|
||||
@@ -76,6 +79,16 @@ ok(has(quietHtml, "QUIET_START_HOUR") && has(quietHtml, "QUIET_END_HOUR"), "列
|
||||
ok(has(quietHtml, "1 时") && has(quietHtml, "5 时"), "显示进程生效的钟点");
|
||||
ok(has(quietHtml, "已改"), "被改过的窗口钟点标「已改」");
|
||||
|
||||
// ── 心跳节拍(design/12 §N A4):3 个键 + 「降频只作用于心跳」+ 「改需重启」 ──
|
||||
ok(has(quietHtml, "THINK_QUIET_INTERVAL_SEC"), "必须列出降频键真名(用户照着它改 .env)");
|
||||
ok(has(quietHtml, "THINK_INTERVAL_SEC"), "必须列出普通心跳周期键名");
|
||||
ok(has(quietHtml, "ENABLE_BACKGROUND_THINKING"), "必须列出自主思考总开关键名");
|
||||
ok(has(quietHtml, "0 秒"), "≤0 的生效值如实显示(关闭降频,不编默认值)");
|
||||
ok(has(quietHtml, "7200 秒"), "普通心跳周期如实显示生效值");
|
||||
ok(has(quietHtml, "例行自主思考的心跳节拍"), "心跳节拍必须与安静时段同卡说明");
|
||||
ok(has(quietHtml, "事件驱动"), "必须说清降频不影响事件驱动通路(安全域照旧立刻触发)");
|
||||
ok(has(quietHtml, "重启服务"), "必须写明这些键改后要重启(不许假装可热改)");
|
||||
|
||||
// ── 关闭态的窗口:必须说「从不生效」,不能画成假窗口 ────────────────────────
|
||||
const disabledHtml = renderToStaticMarkup(
|
||||
createElement(QuietHoursFactsBody, {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { Notice } from "../components";
|
||||
import { Surface, Tag } from "../../components/primitives";
|
||||
import {
|
||||
heartbeatPurpose,
|
||||
heartbeatRows,
|
||||
memoryTidyRows,
|
||||
quietHoursPurpose,
|
||||
quietHoursRows,
|
||||
@@ -93,9 +95,16 @@ export function QuietHoursFactsBody({ facts }: { facts: AdminConfigFacts | null
|
||||
</div>
|
||||
<FactsLines rows={quietHoursRows(facts)} />
|
||||
<p className="text-xs text-[var(--mu-text-mid)]">{quietHoursPurpose()}</p>
|
||||
<div className="border-t border-[var(--mu-glass-border)] pt-2">
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">例行自主思考的心跳节拍</span>
|
||||
<FactsLines rows={heartbeatRows(facts)} />
|
||||
</div>
|
||||
<p className="text-xs text-[var(--mu-text-mid)]">{heartbeatPurpose()}</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> 后重启服务。
|
||||
只读事实(值 = 进程生效值,不是 <span className="font-mono">.env</span> 原文):这些键都在启动期读取,
|
||||
改值请编辑 <span className="font-mono">QUIET_START_HOUR</span> /{" "}
|
||||
<span className="font-mono">QUIET_END_HOUR</span> / <span className="font-mono">THINK_INTERVAL_SEC</span> /{" "}
|
||||
<span className="font-mono">THINK_QUIET_INTERVAL_SEC</span> 后重启服务(不支持热改)。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
|
||||
import type { AdminConfigFacts } from "../../lib/types";
|
||||
import {
|
||||
BACKTHINK_HEARTBEAT_KEYS,
|
||||
FACT_MISSING_VALUE,
|
||||
MEMORY_TIDY_KEYS,
|
||||
QUIET_HOURS_KEYS,
|
||||
factRows,
|
||||
heartbeatPurpose,
|
||||
heartbeatRows,
|
||||
memoryTidyRows,
|
||||
quietHoursPurpose,
|
||||
quietHoursRows,
|
||||
@@ -52,6 +55,9 @@ const DEFAULTS: AdminConfigFacts = {
|
||||
{ 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: "ENABLE_BACKGROUND_THINKING", value: "true", default: "true", isDefault: true },
|
||||
{ key: "THINK_INTERVAL_SEC", value: "900", default: "900", isDefault: true },
|
||||
{ key: "THINK_QUIET_INTERVAL_SEC", value: "3600", default: "3600", isDefault: true },
|
||||
{ key: "QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
|
||||
{ key: "QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
|
||||
],
|
||||
@@ -133,5 +139,42 @@ const unknown = factRows(DEFAULTS, ["NOT_A_REAL_KEY"]);
|
||||
same(unknown[0].label, "NOT_A_REAL_KEY", "没有元数据的键用键名兜底(不编中文标签)");
|
||||
same(unknown[0].value, FACT_MISSING_VALUE, "没有元数据的键如实「未报告」");
|
||||
|
||||
// ── ⑥ 后台思考心跳:3 行 + 静默降频的用途文案(design/12 §N A4) ─────────────
|
||||
same(
|
||||
heartbeatRows(DEFAULTS).map((r) => r.key),
|
||||
[...BACKTHINK_HEARTBEAT_KEYS],
|
||||
"心跳 3 行,键序与后端事实表一致",
|
||||
);
|
||||
same(
|
||||
heartbeatRows(DEFAULTS).find((r) => r.key === "THINK_QUIET_INTERVAL_SEC")?.value,
|
||||
"3600 秒",
|
||||
"降频间隔带单位显示,数值原样取后端",
|
||||
);
|
||||
same(
|
||||
heartbeatRows(DEFAULTS).find((r) => r.key === "ENABLE_BACKGROUND_THINKING")?.value,
|
||||
"true",
|
||||
"总开关原样显示后端布尔值",
|
||||
);
|
||||
// ⚠️ 诚实边界:这个开关**未接线**(全仓无消费者,DevDocs/audit/07 §1-17)。
|
||||
// 面板必须说清「配了不生效」,否则它会把一个死开关显示成生效开关。
|
||||
ok(
|
||||
has(
|
||||
heartbeatRows(DEFAULTS).find((r) => r.key === "ENABLE_BACKGROUND_THINKING")?.label ?? "",
|
||||
"未接线",
|
||||
),
|
||||
"未接线的总开关必须在标签里标明,不能显示成生效开关",
|
||||
);
|
||||
same(heartbeatRows(null).length, 3, "整份响应都没拿到时,3 行仍然都在(全为未报告)");
|
||||
ok(
|
||||
heartbeatRows(null).every((r) => r.state === FACT_MISSING_VALUE),
|
||||
"没有响应时全部如实标「未报告」,不编 0 或默认值",
|
||||
);
|
||||
|
||||
const hbPurpose = heartbeatPurpose();
|
||||
ok(has(hbPurpose, "只把「例行自主思考」的心跳放慢"), "必须点明降频只作用于周期性心跳");
|
||||
ok(has(hbPurpose, "事件驱动"), "必须点明事件驱动通路不受影响(安全域照旧立刻触发)");
|
||||
ok(has(hbPurpose, "≤0"), "必须说明 ≤0 = 关闭降频");
|
||||
ok(has(hbPurpose, "不是「半夜完全不思考」"), "不得把 ≤0 说成「半夜完全不思考」");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`configFacts: ${checks} checks passed`);
|
||||
@@ -27,6 +27,17 @@ export const MEMORY_TIDY_KEYS: readonly string[] = [
|
||||
/** 安静时段的两个键(后端事实表后两条)。 */
|
||||
export const QUIET_HOURS_KEYS: readonly string[] = ["QUIET_START_HOUR", "QUIET_END_HOUR"];
|
||||
|
||||
/**
|
||||
* 后台思考心跳的 3 个键(design/12 §N A4;与后端 configFactSpecs 同序)。
|
||||
* ⚠️ 它们与 `QUIET_*` 同属「例行自主思考」的节奏旋钮:env 是**启动期**读的,
|
||||
* 改了要重启才生效(面板文案必须写明,不许假装可热改)。
|
||||
*/
|
||||
export const BACKTHINK_HEARTBEAT_KEYS: readonly string[] = [
|
||||
"ENABLE_BACKGROUND_THINKING",
|
||||
"THINK_INTERVAL_SEC",
|
||||
"THINK_QUIET_INTERVAL_SEC",
|
||||
];
|
||||
|
||||
export interface FactRow {
|
||||
key: string;
|
||||
/** 中文说明(这个键管什么)。 */
|
||||
@@ -51,6 +62,11 @@ const FACT_META: Record<string, FactMeta> = {
|
||||
MEMORY_TIDY_RECALL_THRESHOLD: { label: "候选召回门(余弦)" },
|
||||
MEMORY_TIDY_SCAN_LIMIT: { label: "每轮扫描上限" },
|
||||
MEMORY_TIDY_CANDIDATE_LIMIT: { label: "每轮候选上限" },
|
||||
// ⚠️ 未接线(全仓无消费者,见 DevDocs/audit/07 §1-17):面板必须照实说,
|
||||
// 不许把一个配了不生效的开关显示成「生效值」。
|
||||
ENABLE_BACKGROUND_THINKING: { label: "自主思考总开关(未接线,配了不生效)" },
|
||||
THINK_INTERVAL_SEC: { label: "心跳周期", unit: " 秒" },
|
||||
THINK_QUIET_INTERVAL_SEC: { label: "静默时段心跳周期(≤0 = 不降频)", unit: " 秒" },
|
||||
QUIET_START_HOUR: { label: "安静时段起点(本地钟点)", unit: " 时" },
|
||||
QUIET_END_HOUR: { label: "安静时段终点(本地钟点)", unit: " 时" },
|
||||
};
|
||||
@@ -89,6 +105,23 @@ export function quietHoursRows(facts: AdminConfigFacts | null): FactRow[] {
|
||||
return factRows(facts, QUIET_HOURS_KEYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台思考心跳事实行(3 条,design/12 §N A4)。
|
||||
* 与安静时段同卡渲染:它们都是「例行自主思考」的节拍旋钮。
|
||||
*/
|
||||
export function heartbeatRows(facts: AdminConfigFacts | null): FactRow[] {
|
||||
return factRows(facts, BACKTHINK_HEARTBEAT_KEYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台思考节拍的**一句话用途文案**(design/12 §N A2/A3)。
|
||||
* 必须同时说清两件容易误读的事:降频**只作用于周期性心跳**;
|
||||
* `≤0` 是「不降频」而不是「半夜不思考」。
|
||||
*/
|
||||
export function heartbeatPurpose(): string {
|
||||
return "安静时段只把「例行自主思考」的心跳放慢(默认 900 秒 → 3600 秒),事件驱动的那条路不受影响:安全域 / 急性事件照旧立刻触发,照样能穿透安静时段。静默间隔写成 ≤0 就是关闭降频(回落普通间隔),不是「半夜完全不思考」。";
|
||||
}
|
||||
|
||||
/** 安静时段窗口的一句话摘要(后端 label + 跨午夜 / 已关闭 / 「0/0 = 未填」的如实补充)。 */
|
||||
export function quietHoursSummary(qh: AdminConfigQuietHours | null | undefined): string {
|
||||
if (!qh) return FACT_MISSING_VALUE;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoad_BackgroundThinkingDefaults 锁定后台思考心跳(design/12 §N A1)的 env
|
||||
// 契约默认值:普通心跳 900s、安静窗口内心跳 3600s。后者必须与设计稿一致(1 小时),
|
||||
// 否则「没配这个键」与「配了但解析失败」会走向不同的行为。
|
||||
func TestLoad_BackgroundThinkingDefaults(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
got := cfg.Background
|
||||
if !got.Enabled {
|
||||
t.Error("ENABLE_BACKGROUND_THINKING 默认应为 true")
|
||||
}
|
||||
if got.IntervalSec != 900 {
|
||||
t.Errorf("THINK_INTERVAL_SEC = %d, want 900", got.IntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec != 3600 {
|
||||
t.Errorf("THINK_QUIET_INTERVAL_SEC = %d, want 3600(design/12 §N A1)", got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour != 23 || got.QuietEndHour != 7 {
|
||||
t.Errorf("安静时段 = %d-%d, want 23-7", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_BackgroundThinkingEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的生效
|
||||
// (键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。
|
||||
// `THINK_QUIET_INTERVAL_SEC=0` 是**合法值**(关闭降频,design/12 §N A3),因此它必须
|
||||
// 原样解析成 0,不能被默认值覆盖。
|
||||
func TestLoad_BackgroundThinkingEnvOverrides(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("THINK_INTERVAL_SEC", "1200")
|
||||
t.Setenv("THINK_QUIET_INTERVAL_SEC", "0")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Background.IntervalSec != 1200 {
|
||||
t.Fatalf("THINK_INTERVAL_SEC 未生效:%d", cfg.Background.IntervalSec)
|
||||
}
|
||||
if cfg.Background.QuietIntervalSec != 0 {
|
||||
t.Fatalf("THINK_QUIET_INTERVAL_SEC=0 必须原样解析成 0(关闭降频),得到 %d",
|
||||
cfg.Background.QuietIntervalSec)
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -216,18 +216,26 @@ type VoiceConfig struct {
|
||||
// single source shared by the backthink service and the channel bridge's
|
||||
// minimal-disturbance quiet-hours gate (design/12 §6 策略五). Equal non-zero hours
|
||||
// disable the window. The Push*/Kind fields tune the proactive-push frequency of
|
||||
// design/11 §6 + design/12 §6 and are mapped into backthink.Config.
|
||||
// design/11 §6 + design/12 §6 and are mapped into backthink.Config. QuietIntervalSec
|
||||
// 是安静窗口内的心跳周期(design/12 §N A1):窗口只降低**周期性心跳**的频率,
|
||||
// 事件驱动通路完全不受影响(§N A2)。
|
||||
type BackgroundConfig struct {
|
||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"`
|
||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
|
||||
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
|
||||
PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"`
|
||||
PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"`
|
||||
PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"`
|
||||
RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"`
|
||||
KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"`
|
||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"`
|
||||
// QuietIntervalSec 是**安静窗口内**的心跳周期(秒),默认 3600(1 小时):
|
||||
// 当前时刻落在安静时段(QuietStartHour–QuietEndHour)内时,心跳改用该间隔降频,
|
||||
// 窗外仍用 IntervalSec(design/12 §N A1)。**≤0 = 关闭降频**(回落 IntervalSec,
|
||||
// 语义唯一真源 = internal/services/backthink/heartbeat.go 的 heartbeatInterval);
|
||||
// 它不是「半夜完全不思考」的开关,见 design/12 §N A3。
|
||||
QuietIntervalSec int `env:"THINK_QUIET_INTERVAL_SEC" default:"3600"`
|
||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
|
||||
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
|
||||
PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"`
|
||||
PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"`
|
||||
PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"`
|
||||
RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"`
|
||||
KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"`
|
||||
}
|
||||
|
||||
// MemoryTidyConfig 是记忆整理(design/23-记忆整理与去重)的开关与配额旋钮。
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
// - 周期性心跳(config.Background.IntervalSec),请求注入的运行时
|
||||
// 反思,并允许调用工具(与主人对话**同一套**工具面:同一个
|
||||
// loop / dispatcher / 能力注册表,见 thinker.go 的 LoopThinker),
|
||||
// 但默认不得发出 chat 风格的消息。
|
||||
// 但默认不得发出 chat 风格的消息。心跳周期**按当前时刻选**:
|
||||
// 落在安静窗口内降频到 config.Background.QuietIntervalSec,
|
||||
// 窗外回落 IntervalSec(design/12 §N A1/A3,见 heartbeat.go)。
|
||||
// 降频**只作用于周期性心跳**,事件驱动通路照旧立刻触发(§N A2)。
|
||||
// - 进程内总线上的异步事件(iot.device_update、
|
||||
// reminder.trigger、system.event),它们唤醒一轮思考。
|
||||
//
|
||||
@@ -211,6 +214,8 @@ func (b *Backthink) Start(ctx context.Context) {
|
||||
|
||||
b.log.Info("backthink started",
|
||||
"interval", b.cfg.Interval.String(),
|
||||
"quiet_interval", b.cfg.QuietInterval.String(),
|
||||
"quiet_interval_enabled", b.quietIntervalEnabled(),
|
||||
"quiet_hours", quietHoursLabel(b.cfg.QuietStartHour, b.cfg.QuietEndHour),
|
||||
"event_triggers", b.deps.Bus != nil,
|
||||
"session_id", b.sessionID)
|
||||
@@ -324,33 +329,6 @@ func (b *Backthink) shouldThink(tr Trigger) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程
|
||||
// 启动时立即思考一次——产出一句拟人化的启动问候——
|
||||
// 随后稳定为每 tick 一轮思考;ctx 被取消时
|
||||
// 退出。可接受的副作用是每次进程重启都会唤醒
|
||||
// 一轮即时思考。
|
||||
func (b *Backthink) tickLoop(ctx context.Context) {
|
||||
defer b.wg.Done()
|
||||
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
|
||||
// 启动记录。
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
b.think(ctx, startupTrigger())
|
||||
ticker := time.NewTicker(b.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
b.think(ctx, heartbeatTrigger())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// think 跑一轮无人值守的思考:调用运行时、读取用户
|
||||
// 状态、判断是否打扰、值得时推送并持久化记录。
|
||||
// 只要注入的 Thinker、StateProvider 与 Pusher
|
||||
|
||||
@@ -60,6 +60,15 @@ type Config struct {
|
||||
// IntervalSec 是心跳周期秒数,映射自
|
||||
// config.BackgroundConfig.IntervalSec。设置了 Interval 时忽略。
|
||||
IntervalSec int
|
||||
// QuietInterval 是**安静窗口内**的心跳周期(design/12 §N A1)。
|
||||
// 为零时由 QuietIntervalSec 推导;**≤0 表示关闭降频**(回落 Interval),
|
||||
// 因此这里刻意不做默认填充——生产默认 3600 由 env 标签给出
|
||||
// (config.BackgroundConfig.QuietIntervalSec)。
|
||||
QuietInterval time.Duration
|
||||
// QuietIntervalSec 是安静窗口内的心跳周期秒数,映射自
|
||||
// config.BackgroundConfig.QuietIntervalSec。设置了 QuietInterval 时忽略。
|
||||
// ≤0 = 关闭降频(回落 Interval,design/12 §N A3)。
|
||||
QuietIntervalSec int
|
||||
// QuietStartHour 与 QuietEndHour 以本地钟点约束免打扰窗口。
|
||||
// 两个相等的非零钟点会禁用该窗口。
|
||||
QuietStartHour int
|
||||
@@ -89,6 +98,7 @@ type Config struct {
|
||||
func ConfigFromBackground(bg config.BackgroundConfig) Config {
|
||||
return Config{
|
||||
IntervalSec: bg.IntervalSec,
|
||||
QuietIntervalSec: bg.QuietIntervalSec,
|
||||
QuietStartHour: bg.QuietStartHour,
|
||||
QuietEndHour: bg.QuietEndHour,
|
||||
PushLimit: bg.PushLimit,
|
||||
@@ -107,6 +117,12 @@ func normalize(cfg Config) Config {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
|
||||
}
|
||||
// QuietInterval 不从零值套默认:≤0 是本设计里「关闭降频」的**语义**
|
||||
// (design/12 §N A3),把 0 改写成 3600 会让用户设的 0 静默失效。
|
||||
// 生产默认 3600 由 config 层的 env 标签供值。
|
||||
if cfg.QuietInterval <= 0 {
|
||||
cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * time.Second
|
||||
}
|
||||
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
|
||||
cfg.QuietStartHour = defaultQuietStartHour
|
||||
cfg.QuietEndHour = defaultQuietEndHour
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package backthink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件承载心跳节拍(design/12 §N A):安静窗口内降频、窗口外恢复。
|
||||
//
|
||||
// 两条不变量(照 design/12 §N 写死,改它必须先改设计):
|
||||
// - **窗口只降低周期性心跳的频率**;事件驱动通路(onEvent → think)
|
||||
// 完全不经这里,因此它照旧立刻触发、照样穿透安静时段(§N A2)。
|
||||
// - **寂静 ≠ 停摆**:`QuietInterval ≤ 0` 只表示「关闭降频」(回落普通间隔),
|
||||
// 绝不表示「半夜完全不思考」(§N A3)。
|
||||
|
||||
// quietHourMin 与 quietHourMax 是安静时段钟点的合法取值域(0..23)。
|
||||
// 越界即视为窗口非法 ⇒ 不降频(design/12 §N A3)。
|
||||
const (
|
||||
quietHourMin = 0
|
||||
quietHourMax = 23
|
||||
)
|
||||
|
||||
// quietIntervalEnabled 报告是否配置了降频间隔。`≤0` = 关闭降频:
|
||||
// 这是「回落普通间隔」而不是「半夜不思考」,故这里只做一次符号判定。
|
||||
func (b *Backthink) quietIntervalEnabled() bool {
|
||||
return b.cfg.QuietInterval > 0
|
||||
}
|
||||
|
||||
// quietWindowDisabled 报告安静窗口是否**不可用**(两端相等非 0,或钟点非法)。
|
||||
// 口径与既有三处一致之处在于「相等非 0 = 关闭」;「非法钟点」是本服务按
|
||||
// design/12 §N A3 补的:越界钟点在既有 inQuietHours 里会算出荒谬窗口
|
||||
// (如 5..25 恒真),把降频错误地全时段打开,因此这里先判非法 ⇒ 不降频。
|
||||
func (b *Backthink) quietWindowDisabled() bool {
|
||||
start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour
|
||||
if !validQuietHour(start) || !validQuietHour(end) {
|
||||
return true
|
||||
}
|
||||
return start == end
|
||||
}
|
||||
|
||||
// validQuietHour 报告钟点是否落在合法取值域 0..23(含两端)。
|
||||
func validQuietHour(hour int) bool {
|
||||
return hour >= quietHourMin && hour <= quietHourMax
|
||||
}
|
||||
|
||||
// heartbeatInterval 按**当前时刻**选心跳间隔:落在安静窗口内用
|
||||
// QuietInterval,否则用 Interval(design/12 §N A1)。
|
||||
//
|
||||
// 它是纯函数式的(只读 cfg 与入参),因此 tickLoop 可以在**每一拍之前**
|
||||
// 重算它——跨过窗口边界后下一拍自动切换,而不是启动时定死一次(§N A3)。
|
||||
// 安静判定直接复用既有的 inQuietHours,跨午夜口径与另外三处逐字一致。
|
||||
func (b *Backthink) heartbeatInterval(at time.Time) time.Duration {
|
||||
if !b.quietIntervalEnabled() || b.quietWindowDisabled() {
|
||||
return b.cfg.Interval
|
||||
}
|
||||
if b.inQuietHours(at) {
|
||||
return b.cfg.QuietInterval
|
||||
}
|
||||
return b.cfg.Interval
|
||||
}
|
||||
|
||||
// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程
|
||||
// 启动时立即思考一次——产出一句拟人化的启动问候——
|
||||
// 随后稳定为每拍一轮思考;ctx 被取消时退出。可接受的副作用是每次进程重启都会唤醒
|
||||
// 一轮即时思考。
|
||||
//
|
||||
// 节拍在**每拍前**用 heartbeatInterval(b.now()) 重算(design/12 §N A3):
|
||||
// 因此心跳在安静窗口内降频、出窗口后自动恢复。用 time.Timer 而非
|
||||
// 固定 Ticker 正是为了这个「每拍换间隔」的能力。
|
||||
func (b *Backthink) tickLoop(ctx context.Context) {
|
||||
defer b.wg.Done()
|
||||
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
|
||||
// 启动记录。
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
b.think(ctx, startupTrigger())
|
||||
for {
|
||||
timer := time.NewTimer(b.heartbeatInterval(b.now()))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
b.think(ctx, heartbeatTrigger())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package backthink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件钉住 design/12 §N「静默时段降频」:心跳在安静窗口内降频、窗外回落,
|
||||
// 且降频**只作用于周期性心跳**——事件驱动通路照旧立刻触发(§N A2)。
|
||||
//
|
||||
// 三条边界必须同时成立,缺一条就是「暗坑」:
|
||||
// 1. 每拍**重算**间隔(不是启动时定死)⇒ 跨窗口边界真的切换;
|
||||
// 2. `QuietInterval ≤ 0` = 关闭降频(回落普通间隔),**不是**「半夜不思考」;
|
||||
// 3. 安静判定与既有三处 `inQuietHours` 同源(不另写一份越走越偏的口径)。
|
||||
|
||||
// fakeClock 是可注入的墙钟:测试在运行中挪动它,以驱动「跨窗口边界」。
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.at
|
||||
}
|
||||
|
||||
func (c *fakeClock) set(at time.Time) {
|
||||
c.mu.Lock()
|
||||
c.at = at
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// atHour 造一个本地钟点为 h:m 的时刻(UTC 承载,Hour() 即 h)。
|
||||
func atHour(h, m int) time.Time {
|
||||
return time.Date(2026, 10, 5, h, m, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// intervalFor 在给定配置上取 at 时刻的心跳间隔(经 normalize 后的真实路径)。
|
||||
func intervalFor(cfg Config, at time.Time) time.Duration {
|
||||
b := &Backthink{cfg: normalize(cfg)}
|
||||
return b.heartbeatInterval(at)
|
||||
}
|
||||
|
||||
// TestHeartbeatInterval_SelectsByWindow 覆盖 design/12 §N A6 的 ①~④:
|
||||
// 窗口内用 quiet 间隔、窗口外用普通间隔、`≤0` 回落普通、窗口关闭不降频。
|
||||
func TestHeartbeatInterval_SelectsByWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
const normal = 15 * time.Minute
|
||||
const quiet = time.Hour
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
at time.Time
|
||||
want time.Duration
|
||||
}{
|
||||
{
|
||||
name: "①窗口内降频(跨午夜 23:00-07:00 的 23:30)",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet},
|
||||
at: atHour(23, 30),
|
||||
want: quiet,
|
||||
},
|
||||
{
|
||||
name: "②窗口外回落普通间隔(14:00)",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet},
|
||||
at: atHour(14, 0),
|
||||
want: normal,
|
||||
},
|
||||
{
|
||||
name: "窗口起点(23:00)算在窗口内",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet},
|
||||
at: atHour(23, 0),
|
||||
want: quiet,
|
||||
},
|
||||
{
|
||||
name: "窗口终点(07:00)算在窗口外",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet},
|
||||
at: atHour(7, 0),
|
||||
want: normal,
|
||||
},
|
||||
{
|
||||
name: "白天窗口(01:00-05:00)内的 02:00 降频",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 1, QuietEndHour: 5},
|
||||
at: atHour(2, 0),
|
||||
want: quiet,
|
||||
},
|
||||
{
|
||||
name: "③QuietInterval=0 ⇒ 关闭降频(窗口内也回落)",
|
||||
cfg: Config{Interval: normal, QuietInterval: 0},
|
||||
at: atHour(23, 30),
|
||||
want: normal,
|
||||
},
|
||||
{
|
||||
name: "③QuietInterval 为负 ⇒ 同样关闭降频",
|
||||
cfg: Config{Interval: normal, QuietInterval: -time.Minute},
|
||||
at: atHour(23, 30),
|
||||
want: normal,
|
||||
},
|
||||
{
|
||||
name: "④窗口关闭(23/23)不降频",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 23, QuietEndHour: 23},
|
||||
at: atHour(23, 30),
|
||||
want: normal,
|
||||
},
|
||||
{
|
||||
name: "④钟点非法(25/07)不降频(否则可能全时段降频)",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 25, QuietEndHour: 7},
|
||||
at: atHour(0, 30),
|
||||
want: normal,
|
||||
},
|
||||
{
|
||||
name: "0/0 视为未填 ⇒ 回落默认窗口 23:00-07:00(窗口内降频)",
|
||||
cfg: Config{Interval: normal, QuietInterval: quiet},
|
||||
at: atHour(23, 30),
|
||||
want: quiet,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := intervalFor(tc.cfg, tc.at); got != tc.want {
|
||||
t.Fatalf("heartbeatInterval(%s) = %v, want %v", tc.at.Format("15:04"), got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours 钉住降频用的是**同一套**
|
||||
// 安静窗口口径(design/12 §N A3:与既有三处逐字一致):静默间隔开启且窗口合法时,
|
||||
// quiet 档位**当且仅当** inQuietHours。另写一份判定(如写成 `h > start && h <= end`)
|
||||
// 会在跨午夜窗口的边界小时上立刻暴露。
|
||||
func TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours(t *testing.T) {
|
||||
t.Parallel()
|
||||
const normal = 900 * time.Second
|
||||
const quiet = 3600 * time.Second
|
||||
windows := []struct{ start, end int }{{23, 7}, {1, 5}, {0, 7}, {22, 3}}
|
||||
for _, w := range windows {
|
||||
b := &Backthink{cfg: normalize(Config{
|
||||
Interval: normal, QuietInterval: quiet, QuietStartHour: w.start, QuietEndHour: w.end,
|
||||
})}
|
||||
for hour := 0; hour < 24; hour++ {
|
||||
at := atHour(hour, 30)
|
||||
inWindow := b.inQuietHours(at)
|
||||
want := normal
|
||||
if inWindow {
|
||||
want = quiet
|
||||
}
|
||||
if got := b.heartbeatInterval(at); got != want {
|
||||
t.Fatalf("窗口 %02d:00-%02d:00 的 %02d:30:inQuietHours=%v 但间隔=%v(want %v)",
|
||||
w.start, w.end, hour, inWindow, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary 钉住「每拍重算」
|
||||
// (design/12 §N A3):注入时钟从窗口内走到窗口外,心跳必须**加速**到普通间隔。
|
||||
// 若间隔只在启动时算一次,第一拍之后仍是 quiet 间隔 ⇒ 拍数远达不到阈值 ⇒ 红。
|
||||
func TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
const normal = 50 * time.Millisecond
|
||||
const quiet = 500 * time.Millisecond
|
||||
clock := &fakeClock{at: atHour(23, 30)} // 起点在窗口内
|
||||
thinker := &mockThinker{text: "nothing to report"}
|
||||
b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{
|
||||
Thinker: thinker,
|
||||
Now: clock.now,
|
||||
})
|
||||
|
||||
b.Start(context.Background())
|
||||
defer func() { _ = b.Stop(context.Background()) }()
|
||||
|
||||
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮
|
||||
clock.set(atHour(12, 0)) // 走出窗口
|
||||
|
||||
// 阈值取 8 拍:只在启动时算一次(quiet=500ms)的话,3 秒内最多 6 拍;
|
||||
// 每拍重算则 500ms 首拍 + 之后每 50ms 一拍 ⇒ 约 900ms 即达标。
|
||||
eventually(t, 3*time.Second, func() bool { return thinker.count()-1 >= 8 })
|
||||
}
|
||||
|
||||
// TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow 是上面那条的反向:时钟从窗口外
|
||||
// 走进窗口内,心跳应放慢到 quiet 间隔(20ms ⇒ 600ms)。
|
||||
func TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
const normal = 20 * time.Millisecond
|
||||
const quiet = 600 * time.Millisecond
|
||||
clock := &fakeClock{at: atHour(12, 0)} // 起点在窗口外
|
||||
thinker := &mockThinker{text: "nothing to report"}
|
||||
b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{
|
||||
Thinker: thinker,
|
||||
Now: clock.now,
|
||||
})
|
||||
|
||||
b.Start(context.Background())
|
||||
defer func() { _ = b.Stop(context.Background()) }()
|
||||
|
||||
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 3 }) // 快节奏已建立
|
||||
clock.set(atHour(23, 30)) // 走进窗口
|
||||
|
||||
base := thinker.count()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
// 已武装的那一拍最多再多跑 1-2 次,之后应被 quiet 间隔挡住;若降频没生效,
|
||||
// 20ms 的节奏会在 300ms 里再跑约 15 拍。
|
||||
if got := thinker.count() - base; got > 3 {
|
||||
t.Fatalf("进入安静窗口后 300ms 内又跑了 %d 拍,quiet 间隔未生效", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackthink_EventTrigger_NotSlowedByQuietInterval 是 design/12 §N A2 / A6-⑤:
|
||||
// 安静窗口只降低**周期性心跳**的频率;事件驱动通路照旧**立刻**触发,不必等下一拍。
|
||||
// 心跳间隔被刻意设成小时级,事件仍须在秒级内跑完一轮思考。
|
||||
func TestBackthink_EventTrigger_NotSlowedByQuietInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
thinker := &mockThinker{text: "washing finished, clothes ready"}
|
||||
bs := bus.New()
|
||||
b, _ := newBackthink(t, Config{Interval: time.Hour, QuietInterval: 4 * time.Hour}, Deps{
|
||||
Bus: bs,
|
||||
Thinker: thinker,
|
||||
Now: func() time.Time { return nightAt() }, // 当前在安静窗口内
|
||||
})
|
||||
|
||||
b.Start(context.Background())
|
||||
defer func() { _ = b.Stop(context.Background()) }()
|
||||
|
||||
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮
|
||||
base := thinker.count()
|
||||
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "reminder due"); err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
eventually(t, time.Second, func() bool { return thinker.count() > base })
|
||||
}
|
||||
|
||||
// TestBackthink_StartupLog_ReportsQuietInterval 是 design/12 §N A6-⑥:启动日志必须
|
||||
// **同时**打印普通间隔与安静间隔(并标明降频是否启用),否则主人无法从日志确认
|
||||
// 降频到底有没有生效。
|
||||
func TestBackthink_StartupLog_ReportsQuietInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
thinker := &mockThinker{text: "nothing to report"}
|
||||
b, _ := newBackthink(t, Config{Interval: 15 * time.Minute, QuietInterval: time.Hour}, Deps{
|
||||
Thinker: thinker,
|
||||
Log: slog.New(slog.NewTextHandler(&buf, nil)),
|
||||
})
|
||||
|
||||
b.Start(context.Background())
|
||||
defer func() { _ = b.Stop(context.Background()) }()
|
||||
|
||||
out := buf.String()
|
||||
for _, want := range []string{"interval=15m0s", "quiet_interval=1h0m0s", "quiet_interval_enabled=true"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("启动日志缺少 %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFromBackground_QuietInterval 钉住 env → backthink 的映射与 ≤0 语义
|
||||
// (design/12 §N A1/A3):3600 秒 ⇒ 1 小时的降频间隔;`≤0` 必须原样保留成
|
||||
// 「关闭降频」——normalize **不得**把它改写成默认值,否则主人设的 0 会静默失效
|
||||
// (那正是 §N A3 要禁的暗坑)。
|
||||
func TestConfigFromBackground_QuietInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
on := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: 3600}))}
|
||||
if on.cfg.QuietInterval != time.Hour {
|
||||
t.Fatalf("QuietIntervalSec=3600 ⇒ QuietInterval=%v, want 1h", on.cfg.QuietInterval)
|
||||
}
|
||||
if !on.quietIntervalEnabled() {
|
||||
t.Fatal("QuietIntervalSec=3600 必须视为降频已启用")
|
||||
}
|
||||
for _, sec := range []int{0, -1} {
|
||||
off := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: sec}))}
|
||||
if off.quietIntervalEnabled() {
|
||||
t.Fatalf("QuietIntervalSec=%d 必须被视为「关闭降频」", sec)
|
||||
}
|
||||
if got := off.heartbeatInterval(atHour(23, 30)); got != off.cfg.Interval {
|
||||
t.Fatalf("QuietIntervalSec=%d 时窗口内心跳应为普通间隔 %v,得到 %v", sec, off.cfg.Interval, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
)
|
||||
|
||||
// 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参
|
||||
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键)。三条边界(AGENTS §10 与
|
||||
// 主人红线):
|
||||
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
|
||||
// (AGENTS §10 与主人红线):
|
||||
//
|
||||
// 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明
|
||||
// 一份清单):事实表逐条与模板键名对齐,由
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明
|
||||
// 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认
|
||||
// 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。
|
||||
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 9 个非敏感调参键,响应里连
|
||||
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 12 个非敏感调参键,响应里连
|
||||
// 凭据类键的「是否已配置」都不出现。
|
||||
// `TestAdminConfigFacts_NeverLeaksSecretValues` 用哨兵值把这条钉死。
|
||||
//
|
||||
@@ -77,7 +77,7 @@ type configFactSpec struct {
|
||||
}
|
||||
|
||||
// configFactSpecs 是事实表的**唯一真源**:顺序即面板显示顺序(先记忆整理,
|
||||
// 后安静时段)。键名必须与 `.env.example` 逐字一致(测试钉住)。
|
||||
// 后安静时段与后台思考心跳)。键名必须与 `.env.example` 逐字一致(测试钉住)。
|
||||
func configFactSpecs() []configFactSpec {
|
||||
return []configFactSpec{
|
||||
{"MEMORY_TIDY_ENABLED", func(c *config.Config) string {
|
||||
@@ -101,6 +101,17 @@ func configFactSpecs() []configFactSpec {
|
||||
{"MEMORY_TIDY_CANDIDATE_LIMIT", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.CandidateLimit)
|
||||
}},
|
||||
// 后台思考心跳(design/12 §N A4):安静窗口只降低周期性心跳的频率,
|
||||
// 而 env 是**启动期**读的 ⇒ 面板照实列出「生效值 + 声明默认」并写明改需重启。
|
||||
{"ENABLE_BACKGROUND_THINKING", func(c *config.Config) string {
|
||||
return strconv.FormatBool(c.Background.Enabled)
|
||||
}},
|
||||
{"THINK_INTERVAL_SEC", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.Background.IntervalSec)
|
||||
}},
|
||||
{"THINK_QUIET_INTERVAL_SEC", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.Background.QuietIntervalSec)
|
||||
}},
|
||||
{"QUIET_START_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.Background.QuietStartHour)
|
||||
}},
|
||||
@@ -117,7 +128,7 @@ func formatRatio(v float64) string {
|
||||
}
|
||||
|
||||
// buildConfigFacts 组装配置事实面。它是纯函数(不读文件、不读 env),因此可
|
||||
// 直接单测;cfg 为 nil 时回退到一个零值配置(此时 9 条事实全部照实报零值 +
|
||||
// 直接单测;cfg 为 nil 时回退到一个零值配置(此时 12 条事实全部照实报零值 +
|
||||
// 声明默认值,面板会看到 isDefault=false 的「已改」而不是一个编出来的值)。
|
||||
func buildConfigFacts(cfg *config.Config) AdminConfigFacts {
|
||||
if cfg == nil {
|
||||
|
||||
@@ -106,6 +106,35 @@ func TestAdminConfigFacts_QuietHoursAreProcessEffective(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_BackthinkHeartbeatKeys 钉住 design/12 §N A4 新增的三条
|
||||
// 后台思考心跳事实(`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` /
|
||||
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 12。键名与
|
||||
// `.env.example` 逐字一致那一半由 TestAdminConfigFacts_KeysMatchEnvExample 钉。
|
||||
func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := cfgDefault()
|
||||
cfg.Background.Enabled = false
|
||||
cfg.Background.IntervalSec = 7200
|
||||
cfg.Background.QuietIntervalSec = 0 // ≤0 = 关闭降频;事实面必须如实报 0
|
||||
_, _, facts := getConfigFacts(t, cfg, "")
|
||||
if len(facts.Facts) != 12 {
|
||||
t.Fatalf("facts = %d, want 12(记忆整理 7 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"ENABLE_BACKGROUND_THINKING": "false",
|
||||
"THINK_INTERVAL_SEC": "7200",
|
||||
"THINK_QUIET_INTERVAL_SEC": "0",
|
||||
} {
|
||||
got := factByKey(t, facts.Facts, key)
|
||||
if got.Value != want {
|
||||
t.Fatalf("%s value = %q, want %q(进程生效值)", key, got.Value, want)
|
||||
}
|
||||
if got.IsDefault {
|
||||
t.Fatalf("%s 被改过,isDefault 必须为 false:%+v", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag 是判据 ②:每条事实都带
|
||||
// **声明默认值**(default 标签,非空),且当生效值等于它时 isDefault=true ——
|
||||
// 面板因此能如实显示「默认」,而不是把默认值当成「用户配过的值」。
|
||||
@@ -119,6 +148,9 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD": "0.8",
|
||||
"MEMORY_TIDY_SCAN_LIMIT": "5000",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT": "20",
|
||||
"ENABLE_BACKGROUND_THINKING": "true",
|
||||
"THINK_INTERVAL_SEC": "900",
|
||||
"THINK_QUIET_INTERVAL_SEC": "3600",
|
||||
"QUIET_START_HOUR": "23",
|
||||
"QUIET_END_HOUR": "7",
|
||||
}
|
||||
@@ -127,7 +159,10 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
|
||||
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
|
||||
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 20,
|
||||
},
|
||||
Background: config.BackgroundConfig{QuietStartHour: 23, QuietEndHour: 7},
|
||||
Background: config.BackgroundConfig{
|
||||
Enabled: true, IntervalSec: 900, QuietIntervalSec: 3600,
|
||||
QuietStartHour: 23, QuietEndHour: 7,
|
||||
},
|
||||
}
|
||||
status, _, facts := getConfigFacts(t, defaultCfg, "")
|
||||
if status != http.StatusOK {
|
||||
@@ -274,7 +309,7 @@ func TestAdminConfigFacts_NeverLeaksSecretValues(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
|
||||
// `.env.example` —— 9 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
|
||||
// `.env.example` —— 12 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
|
||||
// 抄去改就是静默不生效,AGENTS §10 的 E6 固定动作)。
|
||||
func TestAdminConfigFacts_KeysMatchEnvExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Reference in new issue
Block a user