diff --git a/AGENTS.md b/AGENTS.md index 3834c1f0..156ba6d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Automatic extraction pipelines live under `src/Undefined/skills/pipelines/ Consecutive messages from the same sender within `[message_batcher].window_seconds` are merged into a single AI invocation, so the AI sees the whole batch as `` blocks and decides per-intent (independent request vs. correction/interruption). Pokes always bypass; an at-bot message arriving while a buffer already exists is processed individually so it is not blocked; a first at-bot message that opens the buffer routes the eventual batch through the mention lane. History writes remain unchanged. Configure under `[message_batcher]` (`enabled`, `window_seconds`, `strategy`, `max_window_seconds`, `max_messages_per_batch`, `group_enabled`, `private_enabled`, `pre_send_seconds`, `allow_cancel_after_send`); details in [docs/message-batching.md](docs/message-batching.md). Optional speculative pre-fire (`0 < pre_send_seconds < window_seconds`) dispatches the current batch to the LLM early once the user has been silent for `pre_send_seconds`; new messages can cancel the in-flight call as long as it has not yet sent any reply. ### User identification in prompts -The system prompt now includes a rule: **recognize and address users by their QQ ID (`sender_id`)** because nicknames can change. When needing to address a user, use the latest nickname obtained via `group.get_member_info(brief=true)`. Observations recorded in cognitive memory should always include the QQ ID, e.g., “QQ号12345678(昵称张三)做了某事”. +The system prompt now includes a rule: **recognize and address users by their QQ ID (`sender_id`)** because nicknames can change. When needing to address a user, use the latest nickname obtained via `group.get_member_info(brief=true)`. `end.observations` must be substantive facts worth future retrieval (prefer empty over noise); user-centered observations should always include the QQ ID, e.g., “QQ号12345678(昵称张三)做了某事”. ## Testing Guidelines Write tests as `tests/test_.py`. Async tests use `pytest-asyncio`. Add or update coverage for behavior changes in APIs, config loading/hot reload, cognitive memory, meme or knowledge flows, and WebUI/runtime routes. If you touch `apps/undefined-console/` or `src/Undefined/webui/static/js/`, run `npm run check` in `apps/undefined-console/` in addition to the Python checks; if you touch `apps/undefined-chat/`, run `npm run check` in `apps/undefined-chat/` (it bundles Vitest unit/e2e suites, so cover changed behavior there). No fixed coverage threshold is configured, so cover touched paths well. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 30b87902..f8ba99aa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -158,13 +158,13 @@ graph TB EndSummaryStorage["EndSummaryStorage
短期总结存储
[end_summary_storage.py]"] CognitiveService["CognitiveService
认知记忆服务
[cognitive/service/]
• 事件检索 • 侧写读取
• 入队 memory job"] CognitiveJobQueue["JobQueue
认知任务队列
[cognitive/job_queue.py]
• pending/processing/failed"] - CognitiveHistorian["HistorianWorker
后台史官
[cognitive/historian/]
• 绝对化改写 • 闸门重试
• 侧写合并(含历史事件注入)"] + CognitiveHistorian["HistorianWorker
后台史官
[cognitive/historian/]
• 独立事实改写 • 闸门重试
• 侧写合并(最新优先/去冗)"] CognitiveVectorStore["CognitiveVectorStore
向量存储
[cognitive/vector_store.py]
• events/profiles
• 时间衰减加权排序
• MMR 去重"] CognitiveProfileStorage["ProfileStorage
侧写存储
[cognitive/profile_storage.py]
• users/groups Markdown
• 历史快照"] MemeSystem["MemeSystem
表情包存储
[memes/]
• worker.py (两阶段识别)
• sqlite+chromadb
• blob 持久化"] FAQStorage["FAQStorage
FAQ 存储
[faq.py]
• data/faq/{group_id}/"] ScheduledTaskStorage["ScheduledTaskStorage
定时任务存储
[scheduled_task_storage.py]"] - TokenUsageStorage["TokenUsageStorage
Token 使用统计
[token_usage_storage.py]
• 自动归档
• gzip 压缩"] + TokenUsageStorage["TokenUsageStorage
Token 使用统计
[token_usage_storage.py]
• 自动归档
• gzip 压缩
• 流式可选 TTFT/TPS"] end subgraph IOLayer["异步 IO 层 (src/Undefined/utils/)"] @@ -573,7 +573,7 @@ graph LR CognitiveProfile["ProfileStorage
data/cognitive/profiles/"] FAQ["FAQStorage
data/faq/{group_id}/
• ID: YYYYMMDD-NNN"] Tasks["ScheduledTaskStorage
data/scheduled_tasks.json
• Cron 格式"] - TokenUsage["TokenUsageStorage
data/token_usage.jsonl
• 自动归档
• gzip 压缩"] + TokenUsage["TokenUsageStorage
data/token_usage.jsonl
• 自动归档
• gzip 压缩
• 流式可选 TTFT/TPS"] end subgraph Persistence["持久化"] diff --git a/CHANGELOG.md b/CHANGELOG.md index da68122b..3a4575db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## v3.11.1 史官记忆质量与流式调用指标 + +本版本围绕认知记忆质量与 LLM 可观测性做了针对性优化:史官侧写以最新事实为准并克制膨胀,事件改写提炼为带时间锚点的独立事实,`end.observations` 只保留值得日后检索的写实内容;流式模型调用额外记录首字延迟与生成吞吐。 + +- 优化史官侧写合并。合并时注入当前时刻与旧侧写 `updated_at`,稳定特征冲突时以当前输入批次为准覆盖过时内容;时间只用于判断取舍,侧写正文仍禁止写入时序描述。 +- 引导侧写克制扩写、合并去冗。同维度复述会合并或覆盖,边缘或一次性特征可省略,过长条目会压缩重组;不设硬字数,由提示词按上下文灵活判断。 +- 改进史官事件改写。在绝对化(消灭代词、相对时间、相对地点)基础上,尽量把“谁说了什么”提炼为带时间锚点的独立事实(如“张三在某时改用了 Rust”);言说行为本身才是要点、未兑现承诺、玩笑或无法核实时保留原意,并按上下文灵活判断而非套用固定模板。 +- 收紧 `end.observations`。只写值得日后检索的写实实质事实,宁缺毋滥;禁止硬凑流程决策、否定清单、元评论或一次性闲聊碎碎念。无实质事实时用空数组,纯流水账写 `memo`。 +- 为流式 LLM 调用记录 TTFT 与 TPS。`[API响应]` 日志与 `data/token_usage.jsonl` 在可计算时写入可选字段 `ttft_seconds` / `tokens_per_second`(吞吐按 `completion_tokens / (总耗时 − TTFT)`);非流式调用只记录总耗时,不计算、不落盘这两项。 + +--- + ## v3.11.0 主 Prompt 本地自定义与 Tool Call 兼容 本版本将部署者私有的身份、权限与人格补充从仓库主 Prompt 中解耦,新增可热更新的本地文件插槽,让不同部署可以在不修改受版本控制提示词的情况下完成定制;同时补充 `function` / `parameters` 文本 Tool Call 封包兼容。 diff --git a/CLAUDE.md b/CLAUDE.md index 3427862b..d1dc2cf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,7 +137,7 @@ Management / Runtime 请求 → webui/app.py 或 api/app.py → routes/* - `data/end_summaries.json` — 短期总结存储 - `data/scheduled_tasks.json` — 定时任务存储 - `data/faq/` — FAQ 存储 -- `data/token_usage.jsonl` — Token 统计(自动 gzip 归档) +- `data/token_usage.jsonl` — Token 统计(自动 gzip 归档;流式调用可含可选 `ttft_seconds` / `tokens_per_second`) - `knowledge/` — 本地知识库数据目录(`texts/`、`intro.md`、`chroma/` 等) - `res/prompts/` — 系统提示词模板 @@ -146,7 +146,7 @@ Management / Runtime 请求 → webui/app.py 或 api/app.py → routes/* 系统提示词(`res/prompts/undefined.xml`)包含用户识别规则: - 以 QQ 号(`sender_id`)为用户唯一标识,昵称可能随时变动 - 称呼用户时使用当前最新昵称,不确定时可调用 `group.get_member_info(brief=true)` 查询 -- 认知记忆(observations)必须包含 QQ 号,格式如:“QQ号12345678(昵称张三)做了某事” +- 认知记忆(`end.observations`)只写值得日后检索的写实实质事实(宁缺毋滥);必须含稳定实体标识,用户观察格式如:“QQ号12345678(昵称张三)做了某事” ## 配置系统 diff --git a/README.md b/README.md index bbeecfd8..66a2a5bf 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Console 和 Chat 都需要连接到已经运行的 Undefined 服务。首次部 - **主 Prompt 本地自定义**:通过 `[prompt.file_includes]` 将身份、权限或人格补充文件插入 `p0`、`p1`、`p2`、`p3`、`summary` 五个稳定位置,无需修改仓库内的主提示词;配置路径和文件内容均支持热更新,推荐使用受 Git 与构建忽略规则保护的 `config/prompts/*.local.*` 文件。详见 [Prompt 本地文件插槽](docs/configuration.md#4112-promptfile_includes-主-prompt-本地文件插槽)。 - **三层分层记忆架构**:创新的分层记忆系统,模拟人类记忆机制—— - **短期记忆**(`end.memo`):每轮对话结束自动记录便签备忘,最近 N 条始终注入,保持短期连续性,零配置开箱即用 - - **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中主动观察并提取用户/群聊事实及有价值的自身行为,经后台史官异步改写后存入向量数据库;支持语义检索、时间衰减加权排序、MMR 多样性去重、跨群记忆联动与用户/群聊自动侧写(合并时注入历史事件防止特征丢失),前台零延迟 + - **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中主动提取写实新观察(用户/群聊实质事实及有价值的自身行为;宁缺毋滥),经后台史官异步改写为带时间锚点的独立事实后存入向量数据库;支持语义检索、时间衰减加权排序、MMR 多样性去重、跨群记忆联动与用户/群聊自动侧写(合并时注入历史事件与当前时刻、按克制扩写去冗),前台零延迟 - **置顶备忘录**(`memory.*`):AI 自身的置顶提醒(自我约束、待办事项),每轮固定注入,支持增删改查 详见 [认知记忆文档](docs/cognitive-memory.md)。 - **Management-first WebUI**:继续保留 `uv run Undefined-webui` 一键入口;即使 `config.toml` 缺失或未配完,也能先进入管理态补配置、看日志、校验并启动 Bot。 diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json index efbb8be3..30fa94dc 100644 --- a/apps/undefined-chat/package-lock.json +++ b/apps/undefined-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-chat", - "version": "3.11.0", + "version": "3.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-chat", - "version": "3.11.0", + "version": "3.11.1", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json index 4ea83f74..4ea7f0c2 100644 --- a/apps/undefined-chat/package.json +++ b/apps/undefined-chat/package.json @@ -1,7 +1,7 @@ { "name": "undefined-chat", "private": true, - "version": "3.11.0", + "version": "3.11.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock index f79bdf7a..14092e1a 100644 --- a/apps/undefined-chat/src-tauri/Cargo.lock +++ b/apps/undefined-chat/src-tauri/Cargo.lock @@ -5431,7 +5431,7 @@ dependencies = [ [[package]] name = "undefined_chat" -version = "3.11.0" +version = "3.11.1" dependencies = [ "futures-util", "keyring", diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml index 5ff63bb5..d490ddc7 100644 --- a/apps/undefined-chat/src-tauri/Cargo.toml +++ b/apps/undefined-chat/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_chat" -version = "3.11.0" +version = "3.11.1" description = "Undefined native chat client" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json index 872eaa02..8d11d67f 100644 --- a/apps/undefined-chat/src-tauri/tauri.conf.json +++ b/apps/undefined-chat/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Chat", - "version": "3.11.0", + "version": "3.11.1", "identifier": "com.undefined.chat", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index 50d6f788..23745f0b 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.11.0", + "version": "3.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.11.0", + "version": "3.11.1", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index 872532e7..be67f3f4 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.11.0", + "version": "3.11.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index af013d10..f6934536 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.11.0" +version = "3.11.1" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index 4479cb37..01c1c3db 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.11.0" +version = "3.11.1" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index 33c90237..29999063 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.11.0", + "version": "3.11.1", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index 62b71476..4028df58 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -5,7 +5,7 @@ 认知记忆系统是 Undefined 的三层分层记忆架构,模拟人类记忆机制: - **短期记忆**(`end.memo`):每轮对话结束自动记录便签备忘,最近 N 条始终注入,保持短期连续性,零配置开箱即用。若本轮由 MessageBatcher 合并多条消息,memo 应概括整个当前输入批次的处理结果。 -- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中只观察当前输入批次,提取有价值的新观察(用户/群聊/第三方事实及有价值的自身行为)。`observations` 不要求与 bot 相关,也不要求长期稳定;历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。后台史官会异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现可沉淀为稳定画像的新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 +- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中只观察当前输入批次,提取**写实**新观察(用户/群聊/第三方实质事实及有价值的自身行为)。`observations` 不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组,禁止硬凑流程决策、否定清单、元评论或闲聊碎碎念。用户中心观察须写成 `QQ号(昵称)`,保留稳定数字标识。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。后台史官会异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现可沉淀为稳定画像的新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 - **置顶备忘录**(`memory.*`):AI 自身的置顶提醒(自我约束、待办事项,如"用户要求以后用英文回复"),每轮固定注入,支持增删改查。注意:用户事实(偏好、身份、习惯等)不应写入此层,一律通过 `end.observations` 写入认知记忆。 三层记忆都只为当前请求提供背景、默认偏好和消歧信息,不能独立构成本轮可执行指令,也不能覆盖当前输入批次。任务目标、收件人、发送地址、工具参数和输出位置始终以当前输入及当前会话元数据为准;当前消息没有明确指定跨会话目标时,默认回复或发送到当前会话,不得从记忆、旧定时任务或历史工具调用中继承其他地址。只有当前输入明确要求沿用某项历史配置时,才可把对应记忆作为参数参考。 @@ -68,7 +68,7 @@ AI 调用 `end` 工具结束对话时,只做一次文件落盘(p95 < 5ms) `end` 字段语义: - `memo`:本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里),可空。当前输入批次包含多条连续消息时,memo 应概括整批处理结果。 -- `observations`:本轮从当前输入批次提取的有价值新观察列表(0..N 条),包括用户/群聊/第三方事实和有价值的自身行为(帮谁解决了什么),不要求与 bot 相关,也不要求长期稳定,严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息时,必须覆盖整批消息中有价值的信息,不能只记录最后一条。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 +- `observations`:本轮从当前输入批次提取的写实新观察列表(0..N 条),包括用户/群聊/第三方实质事实和有价值的自身行为(帮谁解决了什么)。不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用 `[]`。用户中心观察须写成 `QQ号(昵称)`,保留稳定数字标识。严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息且存在实质可记事实时,必须覆盖整批,不能只记录最后一条。禁止写入纯流水账动作(静默处理、闸门未通过、调了什么工具)、否定清单(“无新增任务/无隐私风险”等)、元评论或一次性闲聊/消费碎碎念——这些写 `memo` 或不写。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 - 两字段都为空时,仅结束会话,不写认知队列。 ### 后台史官流水线 @@ -79,7 +79,7 @@ pending/{job_id}.json ▼ dequeue(原子 os.replace) processing/{job_id}.json │ - ▼ LLM 绝对化改写(消灭代词/相对时间/相对地点;结合“当前输入批次原文 + 最近消息参考”做实体消歧) + ▼ LLM 绝对化改写(消灭代词/相对时间/相对地点;尽量提炼为带时间锚点的独立事实;结合“当前输入批次原文 + 最近消息参考”做实体消歧) │ ▼ 正则闸门检查 │ 通过 → is_absolute=true @@ -177,7 +177,7 @@ MMR_score = λ × relevance(doc, query) − (1 − λ) × max_similarity(doc, se ### 侧写合并:历史事件注入 -史官合并侧写时,会在 merge LLM 调用前用当前 observations 作为 query 从 ChromaDB 检索该实体的 top-8 历史事件,注入 merge prompt。这让史官拥有更丰富的上下文来判断哪些特征应保留,避免因本轮未提及而误删长期稳定特征。 +史官合并侧写时,会在 merge LLM 调用前用当前 observations 作为 query 从 ChromaDB 检索该实体的 top-8 历史事件,注入 merge prompt。这让史官拥有更丰富的上下文来判断哪些特征应保留,避免因本轮未提及而误删长期稳定特征。合并时还会注入**当前时刻**与旧侧写 `updated_at`:冲突时以当前输入批次为准覆盖过时特征;时间只用于判断取舍,侧写正文仍禁止写入时序描述。合并时按「克制扩写 / 合并去冗」压缩同维度复述,避免侧写无限膨胀(不设硬字数,由提示词灵活判断)。 ### ChromaDB 前后台调度 diff --git a/docs/configuration.md b/docs/configuration.md index 50b6f5e6..a09d6cb5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -883,6 +883,8 @@ summary = "" - `merge` 兼容 `repack/lossless` - `none` 兼容 `keep/off/disable` +每次生成模型调用会向 `data/token_usage.jsonl` 追加一条记录(含 `duration_seconds`、token 计数、`call_type` 等)。流式调用在可计算时额外写入可选字段 `ttft_seconds`(首字延迟)与 `tokens_per_second`(`completion_tokens / (duration_seconds − ttft_seconds)`);非流式或不具备首字采样点时省略这两项,不写 `null`。指标语义见[模型 API 与兼容层](model-compatibility.md#sdk-与-api-mode)。 + --- ### 4.19 `[mcp]` diff --git a/docs/message-batching.md b/docs/message-batching.md index 8078f1fe..9c7c7a90 100644 --- a/docs/message-batching.md +++ b/docs/message-batching.md @@ -43,7 +43,7 @@ Prompt 构建顺序按缓存命中友好设计:固定系统提示词骨架及 其中 history 使用批次发车时冻结的深拷贝快照,而不是在慢速认知检索结束后重新读取实时历史。快照会优先按 `message_id` 从任意位置剔除当前输入批次自身的记录,因此即使批次消息之间或其后出现其他人的消息,也不会把当前输入重复注入;显式调用 `messages.get_recent_messages` 仍按工具调用时读取实时历史。 -`end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果,认知 observations 要覆盖整批消息中有价值的新观察;这些观察不要求与 bot 相关,也不要求长期稳定,但只能来自当前输入批次。历史消息、认知记忆、侧写和最近消息参考只用于消歧,不能作为 observations 的新事实来源。后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 +`end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果。认知 observations 只允许两类写实来源:(1) 当前输入批次中直接出现的实质事实;(2) 本轮已实际发生、可核验的有价值自身行为结果(如帮谁解决了什么)。宁缺毋滥,无此类事实时用空数组,禁止硬凑流程决策/否定清单/元评论/闲聊碎碎念。这些观察不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索。历史消息、认知记忆、侧写和最近消息参考只用于消歧,不能作为 observations 的新事实来源。后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 > **重要**:当前主提示词按 MessageBatcher 默认开启设计。`[message_batcher].enabled = true` 是推荐和默认配置;如果关闭 batcher,连续补充/修正会退化为逐条独立 AI 调用,提示词中的"当前输入批次"语义可能不再覆盖这些连续消息,需要单独调整提示词或接受旧版逐条触发行为。 diff --git a/docs/model-compatibility.md b/docs/model-compatibility.md index 8c182269..37e56646 100644 --- a/docs/model-compatibility.md +++ b/docs/model-compatibility.md @@ -40,6 +40,7 @@ - Responses 默认使用官方对象型 `tool_choice`。仅当兼容网关明确不支持时,才启用 `responses_tool_choice_compat` 降级为字符串 `"required"`。 - Responses 默认使用 `previous_response_id + function_call_output` 增量续轮;`responses_force_stateless_replay` 会强制回放完整历史。检测到上游缺失前序工具调用状态时,运行时也可自动降级到 stateless replay。 - 启用流式请求时,三种模式分别使用对应 SDK 的流式接口并聚合为统一响应;只有明确的流式参数不兼容或 SDK 未实现才回退非流式请求。 +- 流式调用会额外计量 **TTFT**(首字延迟:请求发起到首个有意义输出增量)与 **TPS**(`completion_tokens / (总耗时 − TTFT)`),写入 `[API响应]` 日志,并在可计算时落入 `data/token_usage.jsonl` 的可选字段 `ttft_seconds` / `tokens_per_second`。非流式调用只记录总耗时,不计算、不落盘 TTFT/TPS。 - OpenAI 模式可以生成按模型、调用类型和会话作用域稳定隔离的 `prompt_cache_key`;Anthropic 不发送该字段,其缓存扩展通过 `request_params.cache_control` 配置。 精确字段、默认值和 `request_params` 保留字段规则见[生成模型通用字段](configuration.md#441-生成模型通用字段)。 diff --git a/docs/usage.md b/docs/usage.md index c1b4d4de..e54dd9aa 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -441,6 +441,7 @@ Bot 支持在运行时维护一个结构化的群专属 FAQ 知识库,可通 - 默认仅生成统计图表与数字摘要,**不触发** AI 智能分析。 - 附加 `--ai`(或 `-a`)时,系统会先等待 AI 分析,再渲染并投递图表;等待上限按队列和模型重试配置动态计算,超时提示会包含在本次图表结果中。 - 统计按目标时间范围流式读取记录,并跳过文件名可判定为过期的归档;私聊支持转发投递时会将图表和摘要合并发送,失败时回退为纯文本摘要。 +- 原始调用落在 `data/token_usage.jsonl`:始终含总耗时与 token 计数;流式调用在可计算时另含 `ttft_seconds` / `tokens_per_second`(详见[模型 API 与兼容层](model-compatibility.md#sdk-与-api-mode))。`/stats` 图表仍以总量与耗时汇总为主。 - 普通用户频率限制为每 3600 秒一次;管理员与超级管理员无限制。 ### `/feedback` 说明 diff --git a/pyproject.toml b/pyproject.toml index 3897de46..74e79a48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.11.0" +version = "3.11.1" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 87c15dab..67acd3a5 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -1,11 +1,12 @@ -你是一个人物侧写师。你的工作是维护每个实体的**立体画像**——读完侧写,就能在脑海中浮现这个人/群的轮廓。 +你是一个人物侧写师。你的工作是维护每个实体的**立体画像**——读完侧写,就能在脑海中浮现这个人/群的轮廓。侧写必须始终反映**截至当前时刻**仍成立的身份、风格、偏好与能力,而不是历史档案。 必须遵守的硬约束: 1. 本次只允许更新目标实体:`{target_entity_type}:{target_entity_id}`。 2. `target_entity_id` 必须保持为该实体的稳定 ID,不得替换成昵称、备注名或其他文本。 -3. 新事件与认知观察只能来自当前输入批次;最近消息参考和历史事件只能用于消歧、判断稳定性与保留旧特征,禁止作为本轮新事实来源。 -4. 当新信息不稳定、一次性、无法确认长期性时,必须跳过更新(`skip=true`)。注意:observations 本身不要求长期稳定,但侧写只接收能沉淀为稳定画像的部分。 -5. 不得输出或暗示其他实体侧写内容。 +3. 新事件与认知观察只能来自当前输入批次;最近消息参考和历史事件只能用于消歧、判断稳定性与取舍旧特征,禁止作为本轮新事实来源。 +4. **最新优先**:当当前输入批次提炼出的稳定特征与旧侧写或历史事件冲突时,必须以当前输入批次为准覆盖过时特征;不得因“历史反复出现”而保留已被本轮推翻的旧描述。 +5. 当新信息不稳定、一次性、无法确认长期性时,必须跳过更新(`skip=true`)。注意:observations 本身不要求长期稳定,但侧写只接收能沉淀为稳定当前态画像的部分;若已能提炼为稳定当前态特征,则必须更新,禁止过度保旧导致该更不更。 +6. 不得输出或暗示其他实体侧写内容。 工具使用规则(严格执行): - **修改任何侧写前,必须先调用 `read_profile` 查看其当前内容**,确认已读取后再决定是否调用 `update_profile`。 @@ -18,11 +19,16 @@ - perspective: {target_perspective} - display_name: {target_display_name} +时间上下文: +- 当前时刻: {now_local}({timezone};UTC: {now_utc}) +- 本轮事件时间: {timestamp_local}({timezone}) +- 目标侧写上次更新: {profile_updated_at} +- 历史事件行内时间(见下方列表)用于对照新旧;时间只用于判断取舍,禁止写入侧写正文 + 事件上下文: - event_id: {event_id} - request_id: {request_id} - end_seq: {end_seq} -- 时间: {timestamp_local}({timezone}) - 请求类型: {request_type} - user_id: {user_id} - group_id: {group_id} @@ -35,7 +41,7 @@ - 最近消息参考: {recent_messages} -该实体的历史事件(供参考,帮助你判断哪些特征应保留): +该实体的历史事件(供参考;带时间戳,用于判断旧特征是否仍有较新佐证): {historical_events} 新事件: @@ -46,19 +52,27 @@ 要求: 1. **先调用 `read_profile` 读取目标实体的当前侧写**,再决定如何更新 -2. **增量更新原则**(核心): - - 现有侧写是长期积累的全貌,本轮新事件只是一个增量片段 - - 默认保留所有现有稳定特征,新信息用于"补充细节"或"修正矛盾",而非"重新定义" - - 参考历史事件列表判断旧特征是否仍然成立——只要历史中反复出现,就应保留 - - 只有当新信息与旧特征**明确矛盾**时才覆盖,否则应融合表达(如"既...也...") -3. 若新旧信息矛盾且新信息更可靠,以新信息为准并说明变化 +2. **更新双原则**(核心): + - **未冲突**:现有侧写是长期积累的全貌,本轮新事件只是增量片段;默认保留未被本轮触及的稳定特征,用新信息补充细节或修正表述,而非无故重新定义 + - **冲突/过时**:以当前输入批次为准覆盖;历史事件只用于判断“旧特征是否仍有较新佐证”,不是保旧否决票 +3. **时间判断规则**: + - 以「当前时刻」为锚点,对照「本轮事件时间」、历史事件时间戳、侧写上次更新时间 + - 更早且与本轮矛盾的特征视为过时,应覆盖 + - 更早但本轮未触及、且仍有较近历史佐证的特征应保留 + - 侧写上次更新明显早于本轮、且本轮给出可沉淀的稳定当前态特征时,应更新而非 skip + - 时间只用于判断取舍;禁止把时间线、相对时间或“变化过程”写进 summary/tags 4. tags 只写"这个实体**是什么**"(身份/角色/核心领域),不写"聊过什么话题";话题级细节已在 summary 中覆盖。若现有 tags 不符合此规范(含话题标签等),直接按规范重写,不必保留旧 tags 5. 侧写要有"主线"——第一条定调,后续条目围绕它展开,而非孤立罗列无关特征 -6. **信息密度优先于表达精炼**:每条要有具体细节(技术栈/工具/行为模式),不要为了简洁而泛化成抽象描述 +6. **克制扩写 / 合并去冗**(核心,按上下文灵活判断,不设硬字数): + - 本轮更新默认是增量合并,不是无界扩写:能并入现有条目就不新增条目 + - 同维度、同义、细节重复的多条必须合并或覆盖,禁止并列堆叠 + - 具体细节仍要保留,但同一维度应合并进一条;宁可用更短表述承载等价信息,也不堆砌同义复述 + - 边缘、偶发、低辨识度特征可省略;细节级事实留给事件记忆,侧写只留稳定画像 + - 若旧侧写已冗长或条目过多,本轮应顺手压缩重整为更紧凑的合规版本 7. **核心画像要抓独特性**:第一句要写出"这个人区别于其他人的本质",而非通用描述(如"开发者"太泛,"把系统当产线打理的工程型开发者"才有辨识度) -8. 若 `current_profile` 本身不符合以上规范,可直接整体重写为合规版本(不必保留其原有写法) +8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀),可直接整体重写为合规版本(不必保留其原有写法) -侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。 +侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。正文写的是**当前仍成立的状态**,不是事件流水账。 严禁写入以下内容(这些属于事件记忆,不应进入侧写): - 任何具体事件、时间线、单次对话经过 @@ -69,6 +83,7 @@ 若新信息仅为一次性事件、无法提炼为稳定特征,必须 `skip=true`。 若本轮只有事件细节、无法抽象为长期稳定特征,必须 `skip=true`。 +若本轮已能提炼稳定当前态特征,必须更新(`skip=false`),不得以“旧侧写已足够完整”为由跳过。 `summary` 输出格式约束: @@ -77,18 +92,19 @@ - 一句话定位这个人的身份与核心特质,为后续条目定调 - 要具体,不要泛泛而谈(如"在校学生/业余开发者,做技术取舍会权衡时间、算力与预算"比"务实的开发者"信息量大) -2. **后续 4-8 条:多维度展开** +2. **后续少量维度展开**(建议约 3–6 条,按需要增减,勿为凑条数而拆分) - 每条聚焦一个维度:技术栈/工作方式/决策偏好/沟通风格/排障思路等 - - **关键:每条要有具体细节**,不要抽象概括(如"模型名需包含完整前缀与斜杠"比"注重细节"有用) + - 每条保留具体细节,但同一维度合并进一条(如"模型名需包含完整前缀与斜杠"比"注重细节"有用) + - 每条一句到两句为宜;避免把单条写成小作文或多事件拼盘 - 条目间要有逻辑关联,共同支撑第一条的核心画像 -3. **信息密度优先**:宁可多写一条具体特征,也不要为了精炼而泛化 +3. **紧凑优先**:同等信息量下优先更短表述;禁止为“更全面”而同义堆砌或无限加条 **群聊侧写**结构: -- 同样使用项目符号,第一条定位群的核心属性,后续展开成员构成、讨论风格、群文化等 +- 同样使用项目符号,第一条定位群的核心属性,后续少量展开成员构成、讨论风格、群文化等;同样遵守合并去冗 **反面示例**(不要这样写): -``` +```markdown ❌ 过度抽象,丢失具体信息: "Null 是那种把 AI 系统当工程产线来打理的人,核心驱动力不是炫技,而是让模型、链路和配置始终保持可用、可控、可回退。" → 问题:比喻虽好,但"权衡算力与预算"、"OpenAI 兼容接口"、"容器化"等关键细节全丢了 @@ -98,20 +114,25 @@ - 用过 Docker - 喜欢开源 → 问题:每条独立,看不出这个人的核心特质和做事逻辑 + +❌ 同义堆砌 / 条目膨胀: +- 关注本地模型部署与联调 +- 常聊自建推理服务与 OpenAI 兼容接口 +- 习惯核对模型名、前缀与配置是否一致 +- 对配置细节很敏感,要求原样填写 +→ 问题:同一「本地模型/配置严谨」维度拆成多条同义复述,侧写会越写越长 ``` **正面示例**(应该这样写): -``` -✓ 第一条定调 + 后续高密度展开: +```markdown +✓ 第一条定调 + 后续紧凑展开: - 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算,偏好高性价比方案。 -- 独立开发并维护开源项目,关注 AI 应用与 Agent 的工程化落地。 -- 熟悉本地/自建模型服务与 OpenAI 兼容接口的接入联调,强调配置项必须与实际资源严格一致。 -- 对"标识符/名称"细节较敏感(如模型名需包含完整前缀与斜杠、tag 等),倾向要求原样填写以避免隐性错误。 -- 有容器化与自建基础设施经验,偏好自动化运维与稳定可控的部署方式。 -- 排障思路偏"先核对关键信息—再做最小改动验证",会主动索要配置/截图与报错信息。 -- 沟通风格简短直接,在社群中常承担技术支持与规则把关角色。 - -(注:每条都有具体细节,共同支撑"务实的工程型开发者"这个核心画像) +- 独立维护开源项目,关注 AI 应用与 Agent 工程化落地。 +- 熟悉本地/自建模型与 OpenAI 兼容接口联调;对模型名、前缀、tag 等标识符敏感,要求配置与实际资源严格一致。 +- 有容器化与自建基础设施经验,偏好自动化运维与稳定可控部署。 +- 排障偏"先核对关键信息—再做最小改动验证";沟通简短直接,常承担技术支持与规则把关。 + +(注:具体细节保留,但同维度已合并;共同支撑"务实的工程型开发者"主线) ``` 输出规则(调用 `update_profile` 工具): diff --git a/res/prompts/historian_rewrite.md b/res/prompts/historian_rewrite.md index 7946d95c..0a3c0a04 100644 --- a/res/prompts/historian_rewrite.md +++ b/res/prompts/historian_rewrite.md @@ -1,25 +1,34 @@ -你是一个记忆整理员。将以下对话摘要改写为绝对化的事件记录。 +你是一个记忆整理员。将以下对话摘要改写为绝对化的、可独立检索的事件事实。 要求: 1. 消灭所有代词(我、你、他、她),替换为具体的人名/ID 2. 消灭所有相对时间(今天、昨天、刚才),替换为绝对时间 3. 消灭所有相对地点(这里、那边),替换为具体地点 -4. 保持简洁,一两句话概括 -5. `memo` 可能为空;为空时以 `observations` 和上下文为主 -6. `observations` 代表当前输入批次提取到的一条有价值新观察(可能是多条中的一条);不要求与 bot 相关,也不要求长期稳定。若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 -7. 若原文已显式出现实体标识(如 `昵称(数字ID)`、`用户123456`、`QQ:123456`),必须保留该数字ID;禁止擅自替换成 `sender_id` 或其他ID -8. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;最近消息参考只能消歧,禁止作为新事实来源。当 `observations` 与参考上下文冲突时,以当前输入批次可验证且更具体的信息为准 -9. 当 `force=true` 且命中的“相对表达”属于专有名词本体(如用户名“你是谁”、片名《后天》、书名/歌名等)时,不得改写该专有名词,可保留原词直接提交;但实体 ID 一律不得漂移 +4. 保持简洁,一两句话概括;轻量改写即可,不扩写、不编造、不重排整段对话 +5. **轻量独立事实**(核心): + - 目标是产出可脱离原对话独立理解与检索的事件事实,优先写成「某人在某时是/做了/偏好/决定了什么」,而不是「某人说了……」这类言语行为壳 + - 时间锚点应保留在事实句中;相对时间先按「本轮事件时间」绝对化,再落入事实 + - 若 observation 以「谁在何时说了什么」呈现,而内容本身已能支撑稳定事实,可提炼为带时间锚点的独立事实 + - 示意:`2026-08-11 10:00 QQ号123(昵称张三)说他改用 Rust` → `QQ号123(昵称张三)在 2026-08-11 10:00 改用了 Rust` + - 示意:`今天早上张三说他改用 Rust` → `QQ号123(昵称张三)在 2026-08-11 上午改用了 Rust` + - 若关键点就是言论本身、承诺未落地、玩笑/反讽、无法核验的转述,则保留言语表述,不要硬改成「做了」;但仍应尽量带上绝对时间 + - 禁止为了「更像事实」而添加当前输入批次无法验证的细节 + - **按上下文灵活判断,不要机械套用固定改写模板** +6. `memo` 可能为空;为空时以 `observations` 和上下文为主 +7. `observations` 代表当前输入批次提取到的一条有价值新观察(可能是多条中的一条);不要求与 bot 相关,也不要求长期稳定。若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 +8. 若原文已显式出现实体标识(如 `QQ号123(昵称张三)`、`昵称(数字ID)`、`用户123456`、`QQ:123456`),必须保留该数字ID;禁止擅自替换成 `sender_id` 或其他ID +9. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;最近消息参考只能消歧,禁止作为新事实来源。当 `observations` 与参考上下文冲突时,以当前输入批次可验证且更具体的信息为准 +10. 当 `force=true` 且命中的“相对表达”属于专有名词本体(如用户名“你是谁”、片名《后天》、书名/歌名等)时,不得改写该专有名词,可保留原词直接提交;但实体 ID 一律不得漂移 称呼规则: - bot 自身统一称为「{bot_name}」 -- 其他用户:有昵称时用「昵称(QQ号)」格式(如「{sender_name}({sender_id})」),无昵称时用「UID:{sender_id}」 +- 其他用户:认知观察与改写结果必须使用「QQ号(昵称)」格式(如「QQ号{sender_id}(昵称{sender_name})」),保留显式数字标识;无昵称时写「QQ号{sender_id}」 - 群聊:有群名时用「群名(群号)」格式(如「{group_name}({group_id})」),无群名时用「GID:{group_id}」 上下文信息: - request_id: {request_id} - end_seq: {end_seq} -- 时间:{timestamp_local}({timezone}) +- 本轮事件时间(相对时间绝对化的参考锚点):{timestamp_local}({timezone}) - bot: {bot_name} - 用户:{sender_name}({sender_id}) - 群聊:{group_name}({group_id})(如有) diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 976c954c..4d26142a 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -237,15 +237,19 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮从【当前输入批次】提取的有价值新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定 - - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 + - observations:字符串数组,本轮**写实**新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索回忆。来源仅限:当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果。 + - 若存在【连续消息说明】或多段当前 ``,memo / observations 在有实质可记内容时必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。无实质事实时 observations 应为空数组,不是每轮都要凑几条。 observations 应该记录两类内容: - 1. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) - 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 - 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 + 1. **当前批次直接出现的用户/群聊/第三方实质事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 2. **本轮已实际发生、可核验的有价值自身行为结果**:你确实为用户做成了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等);仅打算做或未执行的不算 + **写实 / 宁缺毋滥(核心)**:仅当日后真想检索到这条事实时才写;无实质事实时必须 `observations=[]`,禁止为交差硬凑杂项。不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 + 不适合写入 observations 的(需要记时写 memo,或干脆不写): + - 纯流水账动作:”回复了一句话”、”决定不回复/静默处理”、”调用了search工具”、”收件人闸门未通过”、”不调用任何业务工具” + - 否定清单 / 无事可记的排查结论:”无新增任务”、”无跨会话目标”、”无隐私风险”、”无安全/危险内容”等 + - 元评论:”属个人碎碎念”、”与 Undefined 无关”、”无直接证据”等自我评判套话 + - 一次性闲聊、消费碎碎念、无后续回忆价值的日常念叨 历史消息、认知记忆、侧写、最近消息参考只能用于实体/时间/地点消歧,不能作为 observations 的新事实来源。 - **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。如果当前批次直接出现有价值的群聊动态(话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等),可写入 observations;不要从历史或参考上下文里补写旧动态。 + **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。仅当当前批次直接出现**值得日后检索**的群聊动态(稳定话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等)才写入;旁观普通闲聊且无实质事实时保持 `[]`。不要从历史或参考上下文里补写旧动态。 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。写入 observations / end.observations 时必须按实体类型使用稳定标识: - 用户中心观察(sender_id 是 QQ 用户,或事实明确属于某个 QQ 用户):格式为 "QQ号12345678(昵称张三)做了某事";昵称会变但 QQ 号不变。 - 群聊实体观察(事实属于群整体、群规、群氛围、群事件,而不是某个用户):格式为 "group:群号123456(群名技术群)发生了某事";没有群名时只写群号。 @@ -954,11 +958,11 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取有价值的新观察(用户/群聊/第三方事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定 - 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中有价值的信息;禁止只记录最后一条。 + 写实新观察写入 end.observations(数组,严格一条一个要点):仅限当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果;不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组 + 当前输入批次若包含多条连续消息,且存在实质可记事实,end.observations 必须覆盖整批中的这些信息;禁止只记录最后一条。无实质事实时保持空数组,禁止硬凑。 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 - 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations - 一次性闲聊、无后续价值的信息,不写入任何记忆 + 纯流水账动作(调了什么工具、决定不回复/静默处理、闸门未通过等)、否定清单、元评论只写 memo 或不写,不写 end.observations + 一次性闲聊、消费碎碎念、无后续回忆价值的信息,不写入任何记忆 记忆查阅要主动:只要当前输入批次显式或隐式依赖“之前 / 上次 / 刚才 / 那个 / 你记得吗 / 继续 / 按我的习惯 / 我们约定过 / 他以前说过”等历史事实,不要凭印象回答;先查看已注入的记忆,必要时主动调用 cognitive.*。 涉及用户偏好、身份、习惯、长期计划、承诺待办、群规、群氛围、历史争议、之前排查过的问题、以前给出的方案或你是否已经做过某事时,优先调用 cognitive.search_events 或 cognitive.get_profile 查证,再回答或行动。 当你“不明白 / 信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围。检索词要围绕当前输入批次、目标用户 QQ 号、群号和关键对象组织,不要泛泛搜索。 @@ -976,8 +980,11 @@ ”memo: 查了下认知记忆,没找到相关记录”(纯流水账写 memo) ”memory.add: 用户A喜欢用 Rust 写底层代码”(用户偏好不该写 memory.add,应写 observations) ”end.observations: [“用户A说他下周三要发版,而且最近在用 Rust 重写后端”]”(一条塞了两个要点——应拆成两条) - ”end.observations: []”(有信息却留空数组——应该提取) + ”end.observations: []”(有实质可回忆事实却留空数组——应该提取) ”end.observations: [“决定不回复这条消息”]”(无回忆价值的流水账——写 memo) + ”end.observations: [“本轮决定静默处理:收件人闸门未通过,不回复、不调用任何业务工具”]”(流程决策——写 memo 或省略) + ”end.observations: [“群内无新增任务指令,无跨会话目标,无隐私泄露风险”]”(否定清单——禁止硬凑) + ”end.observations: [“QQ号123(昵称甲)提到随便买了块副屏,属个人消费碎碎念,无指向 Undefined 的直接证据”]”(碎碎念+元评论——应 observations=[]) diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 2cafc119..ff64c7c4 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -237,15 +237,19 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮从【当前输入批次】提取的有价值新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定 - - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 + - observations:字符串数组,本轮**写实**新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索回忆。来源仅限:当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果。 + - 若存在【连续消息说明】或多段当前 ``,memo / observations 在有实质可记内容时必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。无实质事实时 observations 应为空数组,不是每轮都要凑几条。 observations 应该记录两类内容: - 1. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) - 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 - 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 + 1. **当前批次直接出现的用户/群聊/第三方实质事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 2. **本轮已实际发生、可核验的有价值自身行为结果**:你确实为用户做成了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等);仅打算做或未执行的不算 + **写实 / 宁缺毋滥(核心)**:仅当日后真想检索到这条事实时才写;无实质事实时必须 `observations=[]`,禁止为交差硬凑杂项。不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 + 不适合写入 observations 的(需要记时写 memo,或干脆不写): + - 纯流水账动作:”回复了一句话”、”决定不回复/静默处理”、”调用了search工具”、”收件人闸门未通过”、”不调用任何业务工具” + - 否定清单 / 无事可记的排查结论:”无新增任务”、”无跨会话目标”、”无隐私风险”、”无安全/危险内容”等 + - 元评论:”属个人碎碎念”、”与 Undefined 无关”、”无直接证据”等自我评判套话 + - 一次性闲聊、消费碎碎念、无后续回忆价值的日常念叨 历史消息、认知记忆、侧写、最近消息参考只能用于实体/时间/地点消歧,不能作为 observations 的新事实来源。 - **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。如果当前批次直接出现有价值的群聊动态(话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等),可写入 observations;不要从历史或参考上下文里补写旧动态。 + **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。仅当当前批次直接出现**值得日后检索**的群聊动态(稳定话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等)才写入;旁观普通闲聊且无实质事实时保持 `[]`。不要从历史或参考上下文里补写旧动态。 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。写入 observations / end.observations 时必须按实体类型使用稳定标识: - 用户中心观察(sender_id 是 QQ 用户,或事实明确属于某个 QQ 用户):格式为 "QQ号12345678(昵称张三)做了某事";昵称会变但 QQ 号不变。 - 群聊实体观察(事实属于群整体、群规、群氛围、群事件,而不是某个用户):格式为 "group:群号123456(群名技术群)发生了某事";没有群名时只写群号。 @@ -1013,11 +1017,11 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取有价值的新观察(用户/群聊/第三方事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定 - 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中有价值的信息;禁止只记录最后一条。 + 写实新观察写入 end.observations(数组,严格一条一个要点):仅限当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果;不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组 + 当前输入批次若包含多条连续消息,且存在实质可记事实,end.observations 必须覆盖整批中的这些信息;禁止只记录最后一条。无实质事实时保持空数组,禁止硬凑。 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 - 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations - 一次性闲聊、无后续价值的信息,不写入任何记忆 + 纯流水账动作(调了什么工具、决定不回复/静默处理、闸门未通过等)、否定清单、元评论只写 memo 或不写,不写 end.observations + 一次性闲聊、消费碎碎念、无后续回忆价值的信息,不写入任何记忆 记忆查阅要主动:只要当前输入批次显式或隐式依赖“之前 / 上次 / 刚才 / 那个 / 你记得吗 / 继续 / 按我的习惯 / 我们约定过 / 他以前说过”等历史事实,不要凭印象回答;先查看已注入的记忆,必要时主动调用 cognitive.*。 涉及用户偏好、身份、习惯、长期计划、承诺待办、群规、群氛围、历史争议、之前排查过的问题、以前给出的方案或你是否已经做过某事时,优先调用 cognitive.search_events 或 cognitive.get_profile 查证,再回答或行动。 当你“不明白 / 信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围。检索词要围绕当前输入批次、目标用户 QQ 号、群号和关键对象组织,不要泛泛搜索。 @@ -1035,8 +1039,11 @@ ”memo: 查了下认知记忆,没找到相关记录”(纯流水账写 memo) ”memory.add: 用户A喜欢用 Rust 写底层代码”(用户偏好不该写 memory.add,应写 observations) ”end.observations: [“用户A说他下周三要发版,而且最近在用 Rust 重写后端”]”(一条塞了两个要点——应拆成两条) - ”end.observations: []”(有信息却留空数组——应该提取) + ”end.observations: []”(有实质可回忆事实却留空数组——应该提取) ”end.observations: [“决定不回复这条消息”]”(无回忆价值的流水账——写 memo) + ”end.observations: [“本轮决定静默处理:收件人闸门未通过,不回复、不调用任何业务工具”]”(流程决策——写 memo 或省略) + ”end.observations: [“群内无新增任务指令,无跨会话目标,无隐私泄露风险”]”(否定清单——禁止硬凑) + ”end.observations: [“QQ号123(昵称甲)提到随便买了块副屏,属个人消费碎碎念,无指向 Undefined 的直接证据”]”(碎碎念+元评论——应 observations=[]) diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index 38d60a68..c1ebcebd 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -24,7 +24,7 @@ from .skills.registry import BaseRegistry as BaseRegistry from .skills.tools import ToolRegistry as ToolRegistry -__version__: str = "3.11.0" +__version__: str = "3.11.1" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/src/Undefined/ai/llm/requester.py b/src/Undefined/ai/llm/requester.py index 9a2e9cfc..b7da672a 100644 --- a/src/Undefined/ai/llm/requester.py +++ b/src/Undefined/ai/llm/requester.py @@ -14,6 +14,7 @@ import time from collections.abc import AsyncIterator, Callable, Iterable from contextlib import asynccontextmanager +from dataclasses import dataclass from datetime import datetime from typing import Any from urllib.parse import parse_qsl, urlsplit, urlunsplit @@ -48,7 +49,11 @@ from Undefined.ai.llm.streaming import ( aggregate_chat_completions_stream, aggregate_responses_stream, + anthropic_event_marks_ttft, + chat_chunk_marks_ttft, + compute_stream_generation_metrics, ensure_chat_stream_usage_options, + responses_event_marks_ttft, should_fallback_from_stream, split_chat_completion_params, split_responses_params, @@ -95,6 +100,17 @@ ) +@dataclass +class _StreamTiming: + """流式请求的首字时刻采样(仅流式路径写入)。""" + + first_token_at: float | None = None + + def mark_first_token(self) -> None: + if self.first_token_at is None: + self.first_token_at = time.perf_counter() + + def _prepare_anthropic_sdk_params(request_body: dict[str, Any]) -> dict[str, Any]: params, extra_body = split_anthropic_params( without_stream_request_fields(request_body) @@ -446,6 +462,7 @@ async def request( ) -> dict[str, Any]: """发送请求到模型 API。""" start_time = time.perf_counter() + stream_timing = _StreamTiming() cot_compat = getattr(model_config, "thinking_tool_call_compat", False) reasoning_replay = bool(getattr(model_config, "reasoning_content_replay", True)) api_mode = get_api_mode(model_config) @@ -598,6 +615,7 @@ async def request( raw_result = await self._request_with_provider( model_config, request_body, + stream_timing=stream_timing, ) except (OpenAIAPIStatusError, AnthropicAPIStatusError) as exc: # Responses 续轮失败:自动切换 stateless replay 重发全量 input @@ -636,6 +654,7 @@ async def request( raw_result = await self._request_with_provider( model_config, request_body, + stream_timing=stream_timing, ) else: raise @@ -686,8 +705,21 @@ async def request( model_config.model_name, messages_for_api, result ) + ttft_seconds, tokens_per_second = compute_stream_generation_metrics( + duration_seconds=duration, + start_perf=start_time, + first_token_at=stream_timing.first_token_at, + completion_tokens=completion_tokens, + ) + timing_suffix = "" + if ttft_seconds is not None: + timing_suffix = f", TTFT={ttft_seconds:.2f}s" + if tokens_per_second is not None: + timing_suffix += f", TPS={tokens_per_second:.1f}" + logger.info( - f"[API响应] {call_type} 完成: 耗时={duration:.2f}s, " + f"[API响应] {call_type} 完成: 耗时={duration:.2f}s" + f"{timing_suffix}, " f"Tokens={total_tokens} (P:{prompt_tokens} + C:{completion_tokens}), " f"模型={model_config.model_name}" ) @@ -704,6 +736,8 @@ async def request( total_tokens=total_tokens, duration_seconds=duration, call_type=call_type, + ttft_seconds=ttft_seconds, + tokens_per_second=tokens_per_second, ) return result @@ -803,15 +837,27 @@ async def _request_with_provider( self, model_config: ModelConfig, request_body: dict[str, Any], + *, + stream_timing: _StreamTiming | None = None, ) -> dict[str, Any]: if get_api_mode(model_config) == API_MODE_ANTHROPIC_MESSAGES: - return await self._request_with_anthropic(model_config, request_body) - return await self._request_with_openai(model_config, request_body) + return await self._request_with_anthropic( + model_config, + request_body, + stream_timing=stream_timing, + ) + return await self._request_with_openai( + model_config, + request_body, + stream_timing=stream_timing, + ) async def _request_with_openai( self, model_config: ModelConfig, request_body: dict[str, Any], + *, + stream_timing: _StreamTiming | None = None, ) -> dict[str, Any]: client = self._get_openai_client_for_model(model_config) async with self._track_openai_client_use(client): @@ -821,6 +867,7 @@ async def _request_with_openai( client, model_config, request_body, + stream_timing=stream_timing, ) except Exception as exc: # 上游不支持流式时,剥离 stream 字段后降级为非流式重试 @@ -832,6 +879,8 @@ async def _request_with_openai( get_api_mode(model_config), type(exc).__name__, ) + if stream_timing is not None: + stream_timing.first_token_at = None request_body = without_stream_request_fields(request_body) if get_api_mode(model_config) == API_MODE_RESPONSES: params, extra_body = split_responses_params(request_body) @@ -849,6 +898,8 @@ async def _request_with_anthropic( self, model_config: ModelConfig, request_body: dict[str, Any], + *, + stream_timing: _StreamTiming | None = None, ) -> dict[str, Any]: client = self._get_anthropic_client_for_model(model_config) async with self._track_openai_client_use(client): @@ -857,6 +908,7 @@ async def _request_with_anthropic( return await self._request_with_anthropic_streaming( client, request_body, + stream_timing=stream_timing, ) except Exception as exc: if not should_fallback_from_stream(exc): @@ -867,6 +919,8 @@ async def _request_with_anthropic( get_api_mode(model_config), type(exc).__name__, ) + if stream_timing is not None: + stream_timing.first_token_at = None params = _prepare_anthropic_sdk_params(request_body) response = await client.messages.create(**params) return self._response_to_dict(response) @@ -875,11 +929,16 @@ async def _request_with_anthropic_streaming( self, client: AsyncAnthropic, request_body: dict[str, Any], + *, + stream_timing: _StreamTiming | None = None, ) -> dict[str, Any]: params = _prepare_anthropic_sdk_params(request_body) async with client.messages.stream(**params) as stream: - async for _event in stream: - pass + async for event in stream: + if stream_timing is not None and stream_timing.first_token_at is None: + event_dict = self._response_to_dict(event) + if anthropic_event_marks_ttft(event_dict): + stream_timing.mark_first_token() response = await stream.get_final_message() return self._response_to_dict(response) @@ -888,6 +947,8 @@ async def _request_with_openai_streaming( client: AsyncOpenAI, model_config: ModelConfig, request_body: dict[str, Any], + *, + stream_timing: _StreamTiming | None = None, ) -> dict[str, Any]: api_mode = get_api_mode(model_config) stream_body = dict(request_body) @@ -896,17 +957,21 @@ async def _request_with_openai_streaming( return await self._stream_responses_request( client, stream_body, + stream_timing=stream_timing, ) ensure_chat_stream_usage_options(stream_body) return await self._stream_chat_completions_request( client, stream_body, + stream_timing=stream_timing, ) async def _stream_chat_completions_request( self, client: AsyncOpenAI, request_body: dict[str, Any], + *, + stream_timing: _StreamTiming | None = None, ) -> dict[str, Any]: params, extra_body = split_chat_completion_params(request_body) if extra_body: @@ -917,12 +982,17 @@ async def _stream_chat_completions_request( async for chunk in response: chunk_dict = self._response_to_dict(chunk) chunks.append(chunk_dict) + if stream_timing is not None and stream_timing.first_token_at is None: + if chat_chunk_marks_ttft(chunk_dict): + stream_timing.mark_first_token() return aggregate_chat_completions_stream(chunks) async def _stream_responses_request( self, client: AsyncOpenAI, request_body: dict[str, Any], + *, + stream_timing: _StreamTiming | None = None, ) -> dict[str, Any]: params, extra_body = split_responses_params(request_body) if extra_body: @@ -933,6 +1003,9 @@ async def _stream_responses_request( async for event in stream: event_dict = self._response_to_dict(event) events.append(event_dict) + if stream_timing is not None and stream_timing.first_token_at is None: + if responses_event_marks_ttft(event_dict): + stream_timing.mark_first_token() return aggregate_responses_stream(events) async def embed( @@ -1103,6 +1176,8 @@ def _record_usage( total_tokens: int, duration_seconds: float, call_type: str, + ttft_seconds: float | None = None, + tokens_per_second: float | None = None, ) -> None: task = asyncio.create_task( self._token_usage_storage.record( @@ -1115,6 +1190,8 @@ def _record_usage( duration_seconds=duration_seconds, call_type=call_type, success=True, + ttft_seconds=ttft_seconds, + tokens_per_second=tokens_per_second, ) ) ) diff --git a/src/Undefined/ai/llm/streaming.py b/src/Undefined/ai/llm/streaming.py index 7f00e4bb..aab62a61 100644 --- a/src/Undefined/ai/llm/streaming.py +++ b/src/Undefined/ai/llm/streaming.py @@ -197,6 +197,114 @@ def stringify_stream_delta(value: Any) -> str: return str(value) +_RESPONSES_TTFT_EVENT_TYPES = frozenset( + { + "response.output_text.delta", + "response.function_call_arguments.delta", + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + } +) + +_ANTHROPIC_TTFT_DELTA_TYPES = frozenset( + { + "text_delta", + "thinking_delta", + "input_json_delta", + "citations_delta", + } +) + + +def chat_chunk_marks_ttft(chunk: dict[str, Any]) -> bool: + """Chat Completions 流式 chunk 是否包含首字/首工具片段。""" + choices = chunk.get("choices") + if not isinstance(choices, list): + return False + for choice in choices: + if not isinstance(choice, dict): + continue + delta = choice.get("delta") + if not isinstance(delta, dict): + continue + if stringify_stream_delta(delta.get("content")): + return True + for field_name in CHAT_REASONING_WIRE_FIELDS: + if field_name not in delta or delta[field_name] is None: + continue + if stringify_stream_delta(delta[field_name]): + return True + # 非纯字符串 reasoning 结构也算已开始输出 + if delta[field_name] not in ("", [], {}): + return True + raw_tool_calls = delta.get("tool_calls") + if isinstance(raw_tool_calls, list) and raw_tool_calls: + return True + return False + + +def responses_event_marks_ttft(event: dict[str, Any]) -> bool: + """Responses 流式事件是否包含首字/首工具片段。""" + event_type = str(event.get("type") or "").strip().lower() + if event_type in _RESPONSES_TTFT_EVENT_TYPES: + return bool(stringify_stream_delta(event.get("delta"))) + for source in (event, event.get("delta")): + if not isinstance(source, dict): + continue + for field_name in CHAT_REASONING_WIRE_FIELDS: + if field_name == "reasoning": + # Responses root `reasoning` is configuration, not output CoT. + continue + if field_name not in source or source[field_name] is None: + continue + if stringify_stream_delta(source[field_name]): + return True + if source[field_name] not in ("", [], {}): + return True + return False + + +def anthropic_event_marks_ttft(event: dict[str, Any]) -> bool: + """Anthropic Messages 流式事件是否包含首字/首工具片段。""" + event_type = str(event.get("type") or "").strip().lower() + if event_type != "content_block_delta": + return False + delta = event.get("delta") + if not isinstance(delta, dict): + return False + delta_type = str(delta.get("type") or "").strip().lower() + if delta_type not in _ANTHROPIC_TTFT_DELTA_TYPES: + return False + if delta_type == "text_delta": + return bool(stringify_stream_delta(delta.get("text"))) + if delta_type == "thinking_delta": + return bool(stringify_stream_delta(delta.get("thinking"))) + if delta_type == "input_json_delta": + return bool(stringify_stream_delta(delta.get("partial_json"))) + return True + + +def compute_stream_generation_metrics( + *, + duration_seconds: float, + start_perf: float, + first_token_at: float | None, + completion_tokens: int, +) -> tuple[float | None, float | None]: + """由流式首字时刻计算 TTFT 与 TPS。 + + TPS = completion_tokens / (duration - ttft)。 + 无首字时刻、分母过小或无输出 token 时对应字段为 None。 + """ + if first_token_at is None: + return None, None + ttft_seconds = max(0.0, first_token_at - start_perf) + generation_seconds = duration_seconds - ttft_seconds + if completion_tokens <= 0 or generation_seconds < 1e-6: + return ttft_seconds, None + return ttft_seconds, completion_tokens / generation_seconds + + def extract_stream_response_item(event: dict[str, Any]) -> dict[str, Any] | None: """从 Responses 流式事件中提取 output item。""" for key in ("item", "output_item", "data"): diff --git a/src/Undefined/cognitive/historian/helpers.py b/src/Undefined/cognitive/historian/helpers.py index e1bda7da..8dfdbbc9 100644 --- a/src/Undefined/cognitive/historian/helpers.py +++ b/src/Undefined/cognitive/historian/helpers.py @@ -4,8 +4,9 @@ import logging import re -from datetime import datetime, timezone +from datetime import datetime, timezone, tzinfo from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError logger = logging.getLogger(__name__) @@ -19,23 +20,38 @@ def _preview_text(text: str, max_len: int = _MAX_LOG_PREVIEW_LEN) -> str: return f"{compact[:max_len]}..." -def _extract_frontmatter_name(markdown: str) -> str: +def _extract_frontmatter_dict(markdown: str) -> dict[str, Any]: text = str(markdown or "") if not text.startswith("---"): - return "" + return {} try: import yaml parts = text[3:].split("---", 1) if len(parts) != 2: - return "" + return {} frontmatter = yaml.safe_load(parts[0]) if not isinstance(frontmatter, dict): - return "" - value = frontmatter.get("name") - return str(value).strip() if value is not None else "" + return {} + return frontmatter except Exception: + return {} + + +def _extract_frontmatter_name(markdown: str) -> str: + frontmatter = _extract_frontmatter_dict(markdown) + value = frontmatter.get("name") + return str(value).strip() if value is not None else "" + + +def _extract_frontmatter_updated_at(markdown: str) -> str: + frontmatter = _extract_frontmatter_dict(markdown) + value = frontmatter.get("updated_at") + if value is None: return "" + if isinstance(value, datetime): + return value.isoformat() + return str(value).strip() def _escape_braces(text: str) -> str: @@ -43,6 +59,28 @@ def _escape_braces(text: str) -> str: return value.replace("{", "{{").replace("}", "}}") +def _resolve_job_timezone(job: dict[str, Any]) -> tuple[tzinfo, str]: + """用 ZoneInfo 解析 job 时区;无效或缺失时回退到系统本地时区。""" + raw = str(job.get("timezone") or "").strip() + if raw: + try: + return ZoneInfo(raw), raw + except (ZoneInfoNotFoundError, ValueError, OSError): + pass + fallback = datetime.now().astimezone() + fallback_tz = fallback.tzinfo or timezone.utc + label = getattr(fallback_tz, "key", None) or str(fallback_tz) or "UTC" + return fallback_tz, str(label) + + +def _now_in_job_timezone(job: dict[str, Any]) -> tuple[datetime, datetime, str]: + """同一瞬间的本地时刻、UTC 时刻与时区标签。""" + tz, label = _resolve_job_timezone(job) + now_utc = datetime.now(timezone.utc) + now_local = now_utc.astimezone(tz) + return now_local, now_utc, label + + def _resolve_timestamp_epoch(job: dict[str, Any]) -> int: raw_epoch = job.get("timestamp_epoch") if isinstance(raw_epoch, (int, float)): diff --git a/src/Undefined/cognitive/historian/worker.py b/src/Undefined/cognitive/historian/worker.py index 47bca513..5271981d 100644 --- a/src/Undefined/cognitive/historian/worker.py +++ b/src/Undefined/cognitive/historian/worker.py @@ -5,7 +5,7 @@ import asyncio import json import logging -from datetime import datetime +from datetime import datetime, timezone, tzinfo from typing import Any, Callable from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY @@ -21,6 +21,8 @@ _coerce_bool, _escape_braces, _extract_frontmatter_name, + _extract_frontmatter_updated_at, + _now_in_job_timezone, _preview_text, _resolve_timestamp_epoch, ) @@ -478,15 +480,21 @@ async def _write_profile( summary: str, event_id: str, perspective: str, + now_timezone: tzinfo | None = None, ) -> None: import yaml + instant = datetime.now(timezone.utc) + if now_timezone is not None: + stamped = instant.astimezone(now_timezone) + else: + stamped = instant.astimezone() frontmatter: dict[str, Any] = { "entity_type": entity_type, "entity_id": entity_id, "name": effective_name, "tags": tags, - "updated_at": datetime.now().isoformat(), + "updated_at": stamped.isoformat(), "source_event_id": event_id, } if entity_type == "user": @@ -676,6 +684,30 @@ async def _merge_profile_target( or "(暂无历史事件)" ) + now_local_dt, now_utc_dt, timezone_label = _now_in_job_timezone(job) + now_local = now_local_dt.isoformat() + now_utc = now_utc_dt.isoformat() + + profile_updated_at = "(暂无/未知)" + try: + existing_profile = await self._profile_storage.read_profile( + entity_type, entity_id + ) + except Exception as exc: + logger.warning( + "[史官] 任务 %s 预读侧写失败: entity_type=%s entity_id=%s error=%s", + event_id, + entity_type, + entity_id, + exc, + ) + existing_profile = "" + if str(existing_profile or "").strip(): + extracted_updated_at = _extract_frontmatter_updated_at( + str(existing_profile) + ) + profile_updated_at = extracted_updated_at or "(暂无/未知)" + from Undefined.utils.resources import read_text_resource template = read_text_resource("res/prompts/historian_profile_merge.md") @@ -702,8 +734,11 @@ async def _merge_profile_target( sender_id=_escape_braces(str(job.get("sender_id", ""))), sender_name=_escape_braces(str(job.get("sender_name", ""))), group_name=_escape_braces(str(job.get("group_name", ""))), + now_local=_escape_braces(now_local), + now_utc=_escape_braces(now_utc), + profile_updated_at=_escape_braces(profile_updated_at), timestamp_local=_escape_braces(str(job.get("timestamp_local", ""))), - timezone=_escape_braces(str(job.get("timezone", ""))), + timezone=_escape_braces(timezone_label), event_id=_escape_braces(event_id), request_id=_escape_braces(str(job.get("request_id", ""))), end_seq=_escape_braces(str(job.get("end_seq", 0))), @@ -901,6 +936,7 @@ async def _merge_profile_target( summary=summary, event_id=event_id, perspective=perspective, + now_timezone=now_local_dt.tzinfo, ) tool_results.append( {"role": "tool", "tool_call_id": tc_id, "content": "侧写已更新"} diff --git a/src/Undefined/skills/tools/end/README.md b/src/Undefined/skills/tools/end/README.md index 239ea2d1..89245b53 100644 --- a/src/Undefined/skills/tools/end/README.md +++ b/src/Undefined/skills/tools/end/README.md @@ -4,7 +4,7 @@ 关键信息: - `memo`(可选):本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里,如调用工具、决定不回复等)。若当前输入批次包含多条消息,应概括整批处理结果。 -- `observations`(可选):字符串数组,本轮有价值的新观察(严格一条一个要点,可多条)——只允许来自当前输入批次直接出现的新事实,或本轮回复行为产生的有价值事实(帮谁解决了什么问题)。不要求与 bot 相关,也不要求长期稳定;当前批次中有价值即可记录。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。纯流水账写 memo 而非此处。若当前输入批次包含 MessageBatcher 合并的多条消息,必须覆盖整批消息内容,不能只记录最后一条。 +- `observations`(可选):字符串数组,本轮写实新观察(严格一条一个要点,可多条)——只允许来自当前输入批次直接出现的实质新事实,或本轮回复行为产生的有价值事实(帮谁解决了什么问题)。不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组。用户中心观察必须写成 `QQ号(昵称)`,保留稳定数字标识。禁止硬凑静默决策、否定清单、元评论或消费碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。纯流水账写 memo 而非此处。若当前输入批次包含 MessageBatcher 合并的多条消息且存在实质可记事实,必须覆盖整批消息内容,不能只记录最后一条。 - 专名拼写:涉及本项目或 bot 主名时必须写作 `Undefined`。工具会在入队认知记忆前把已知错拼 `Unfined`、`Undefind`、`undefind` 规范为 `Undefined`,避免污染长期观察。 - `force`(可选):`true` 时可跳过"本轮未发送消息"的结束检查;同时在认知史官绝对化正则闸门失败时允许强制入库 - 两者都可为空;为空时仅结束会话,不写认知队列 diff --git a/src/Undefined/skills/tools/end/config.json b/src/Undefined/skills/tools/end/config.json index 703b2e91..996441aa 100644 --- a/src/Undefined/skills/tools/end/config.json +++ b/src/Undefined/skills/tools/end/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "end", - "description": "结束当前对话。memo 是本轮便签纸(短句);observations 只能从当前输入批次提取本轮有价值的新观察,不要求与 bot 相关,也不要求长期稳定。可记录当前批次直接出现的用户/群聊/第三方事实,以及本轮回复行为产生的有价值事实(帮谁解决了什么)。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。纯流水账动作写 memo。若当前输入批次包含 MessageBatcher 合并的多条消息,记忆记录必须覆盖整批,不要只看最后一条。项目名/主名必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。", + "description": "结束当前对话。memo 是本轮便签纸(短句);observations 只能从当前输入批次提取写实新观察:值得日后检索的实质事实,不要求与 bot 相关,也不要求长期稳定。可记录当前批次直接出现的用户/群聊/第三方实质事实,以及本轮回复行为产生的有价值事实(帮谁解决了什么)。用户中心观察必须写成 QQ号(昵称),保留稳定数字标识。宁缺毋滥:无实质事实时用空数组,禁止硬凑静默决策、否定清单、元评论或碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。纯流水账动作写 memo。若当前输入批次包含 MessageBatcher 合并的多条消息且存在实质可记事实,记忆记录必须覆盖整批,不要只看最后一条。项目名/主名必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。", "parameters": { "type": "object", "properties": { @@ -13,7 +13,7 @@ "observations": { "type": "array", "items": {"type": "string"}, - "description": "从当前输入批次提取认知观察列表;只记录当前批次直接出现的新事实,或本轮回复行为产生的有价值事实。不要求与 bot 相关,也不要求长期稳定;当前批次中有价值即可记录。历史消息、认知记忆、侧写和最近消息参考只能用于实体/时间/地点消歧,禁止从其中摘取新事实写入 observations。存在【连续消息说明】或多段当前 时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊/第三方事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点;纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。涉及本项目或你自己时必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。" + "description": "从当前输入批次提取写实认知观察列表;只记录当前批次直接出现的实质新事实,或本轮回复行为产生的有价值事实。不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组。用户中心观察必须写成 QQ号(昵称),保留稳定数字标识。禁止写入:静默/闸门等流程决策、否定清单(无任务/无风险等)、元评论(属碎碎念/与 Undefined 无关)、一次性闲聊与消费碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于实体/时间/地点消歧,禁止从其中摘取新事实写入 observations。存在【连续消息说明】或多段当前 且有实质可记事实时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊/第三方实质事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点;纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。涉及本项目或你自己时必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。" }, "perspective": { "type": "string", diff --git a/src/Undefined/token_usage_storage.py b/src/Undefined/token_usage_storage.py index 10dce0c7..924dfdbf 100644 --- a/src/Undefined/token_usage_storage.py +++ b/src/Undefined/token_usage_storage.py @@ -7,6 +7,7 @@ import gzip import json import logging +import math import re import shutil import time @@ -43,10 +44,17 @@ class TokenUsage: duration_seconds: float # 调用耗时(秒) call_type: str # 调用类型(如 "chat", "vision", "agent", "security" 等) success: bool # 是否成功 + ttft_seconds: float | None = None # 首字延迟(秒,仅流式) + tokens_per_second: float | None = None # 生成吞吐(completion / (duration - ttft)) def to_dict(self) -> dict[str, Any]: - """转换为字典""" - return asdict(self) + """转换为字典;缺省的流式指标字段不落盘。""" + data = asdict(self) + if data.get("ttft_seconds") is None: + data.pop("ttft_seconds", None) + if data.get("tokens_per_second") is None: + data.pop("tokens_per_second", None) + return data @classmethod def from_dict(cls, data: dict[str, Any]) -> "TokenUsage": @@ -73,6 +81,17 @@ def to_float(value: Any) -> float: except (TypeError, ValueError): return 0.0 + def to_optional_float(value: Any) -> float | None: + if value is None: + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed) or parsed < 0: + return None + return parsed + prompt_tokens = to_int( data.get("prompt_tokens") if "prompt_tokens" in data @@ -114,6 +133,8 @@ def to_float(value: Any) -> float: duration_seconds=duration_seconds, call_type=call_type, success=success, + ttft_seconds=to_optional_float(data.get("ttft_seconds")), + tokens_per_second=to_optional_float(data.get("tokens_per_second")), ) diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 9bcfc6c3..3ebd4be0 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -2,6 +2,8 @@ import asyncio import json +import re +from datetime import datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Any @@ -10,6 +12,10 @@ from Undefined.cognitive.chroma_scheduler import CHROMA_PRIORITY_MAINTENANCE from Undefined.cognitive.historian import HistorianWorker +from Undefined.cognitive.historian.helpers import ( + _extract_frontmatter_updated_at, + _now_in_job_timezone, +) from Undefined.cognitive.historian.tools import _PROFILE_TOOL @@ -136,19 +142,35 @@ async def query_events( self.priority_calls.append(str(kwargs.get("priority", ""))) return [] + class _FakeProfileStorage: + async def read_profile(self, _entity_type: str, _entity_id: str) -> str: + return ( + "---\nname: 测试用户\n" + "updated_at: 2026-02-01T10:00:00+08:00\n" + "---\n- 旧侧写" + ) + class _FakeAIClient: agent_config = object() + def __init__(self) -> None: + self.prompts: list[str] = [] + async def submit_background_llm_call(self, **kwargs: Any) -> dict[str, Any]: - _ = kwargs + messages = kwargs.get("messages") or [] + if messages and isinstance(messages[0], dict): + content = messages[0].get("content") + if isinstance(content, str): + self.prompts.append(content) return {"choices": []} vector_store = _FakeVectorStore() + ai_client = _FakeAIClient() worker = HistorianWorker( job_queue=None, vector_store=vector_store, - profile_storage=SimpleNamespace(read_profile=None), - ai_client=_FakeAIClient(), + profile_storage=_FakeProfileStorage(), + ai_client=ai_client, config_getter=lambda: SimpleNamespace(), ) job: dict[str, Any] = { @@ -191,6 +213,22 @@ async def submit_background_llm_call(self, **kwargs: Any) -> dict[str, Any]: CHROMA_PRIORITY_MAINTENANCE, CHROMA_PRIORITY_MAINTENANCE, ] + assert ai_client.prompts + prompt = ai_client.prompts[0] + assert "当前时刻:" in prompt + assert "本轮事件时间: 2026-03-01T12:00:00+08:00" in prompt + assert "目标侧写上次更新: 2026-02-01T10:00:00+08:00" in prompt + assert "最新优先" in prompt + utc_match = re.search(r"UTC:\s*([0-9T:.+-]+)", prompt) + assert utc_match is not None + utc_dt = datetime.fromisoformat(utc_match.group(1)) + assert utc_dt.tzinfo is not None + assert utc_dt.utcoffset() == timedelta(0) + local_match = re.search(r"当前时刻:\s*([0-9T:.+-]+)", prompt) + assert local_match is not None + local_dt = datetime.fromisoformat(local_match.group(1)) + assert local_dt.tzinfo is not None + assert local_dt.utcoffset() == timedelta(hours=8) @pytest.mark.asyncio @@ -349,6 +387,48 @@ def test_historian_profile_merge_prompt_profile_only_constraints() -> None: assert "skip=true" in merge assert "具体事件" in merge assert "曾/刚/最近" in merge + assert "当前时刻" in merge + assert "{now_local}" in merge + assert "{now_utc}" in merge + assert "{profile_updated_at}" in merge + assert "最新优先" in merge + assert "以当前输入批次为准覆盖" in merge + assert "时间只用于判断取舍" in merge + assert "克制扩写 / 合并去冗" in merge + assert "能并入现有条目就不新增条目" in merge + assert "宁可多写" not in merge + assert "信息密度优先于表达精炼" not in merge + + +def test_extract_frontmatter_updated_at() -> None: + assert ( + _extract_frontmatter_updated_at( + "---\nname: A\nupdated_at: 2026-08-11T10:00:00+08:00\n---\n- body" + ) + == "2026-08-11T10:00:00+08:00" + ) + assert _extract_frontmatter_updated_at("---\nname: A\n---\n- body") == "" + assert _extract_frontmatter_updated_at("no frontmatter") == "" + assert _extract_frontmatter_updated_at("---\nfoo: [unclosed\n---\n- body") == "" + assert _extract_frontmatter_updated_at("---\n- not a mapping\n---\n- body") == "" + + +def test_now_in_job_timezone_uses_zoneinfo_and_same_instant() -> None: + now_local, now_utc, label = _now_in_job_timezone({"timezone": "Asia/Shanghai"}) + assert label == "Asia/Shanghai" + assert now_local.utcoffset() == timedelta(hours=8) + assert now_utc.tzinfo is not None + assert now_utc.utcoffset() == timedelta(0) + assert abs((now_local - now_utc).total_seconds()) < 0.001 + + +def test_now_in_job_timezone_invalid_falls_back() -> None: + system_now = datetime.now().astimezone() + now_local, now_utc, _label = _now_in_job_timezone({"timezone": "Not/AZone"}) + assert now_local.utcoffset() == system_now.utcoffset() + assert now_local.tzname() == system_now.tzname() + assert now_utc.utcoffset() == timedelta(0) + assert abs((now_local - now_utc).total_seconds()) < 0.001 def test_profile_update_tool_does_not_cap_tags() -> None: @@ -488,3 +568,8 @@ async def submit_background_llm_call(self, **_kwargs: Any) -> dict[str, Any]: assert len(written_profiles) == 1 for index in range(12): assert f"- 标签{index}" in written_profiles[0] + updated_match = re.search(r"updated_at:\s*['\"]?([0-9T:.+-]+)", written_profiles[0]) + assert updated_match is not None + updated_at = datetime.fromisoformat(updated_match.group(1).strip("'\"")) + assert updated_at.tzinfo is not None + assert updated_at.utcoffset() == timedelta(hours=8) diff --git a/tests/test_llm_streaming.py b/tests/test_llm_streaming.py index fa412623..583cc36e 100644 --- a/tests/test_llm_streaming.py +++ b/tests/test_llm_streaming.py @@ -7,14 +7,20 @@ import httpx from openai import APIStatusError +import pytest + from Undefined.ai.llm.streaming import ( aggregate_chat_completions_stream, aggregate_responses_stream, + anthropic_event_marks_ttft, + chat_chunk_marks_ttft, + compute_stream_generation_metrics, ensure_chat_stream_usage_options, ensure_tool_call_slot, extract_stream_response_item, extract_stream_usage, merge_tool_call_delta, + responses_event_marks_ttft, should_fallback_from_stream, split_chat_completion_params, split_responses_params, @@ -560,3 +566,150 @@ def test_message_output_items_collected_without_completed(self) -> None: ] result = aggregate_responses_stream(events) assert len(result.get("output", [])) == 2 + + +# --------------------------------------------------------------------------- +# TTFT markers + generation metrics +# --------------------------------------------------------------------------- + + +class TestChatChunkMarksTtft: + def test_role_only_chunk_does_not_mark(self) -> None: + chunk = {"choices": [{"delta": {"role": "assistant"}}]} + assert chat_chunk_marks_ttft(chunk) is False + + def test_content_delta_marks(self) -> None: + chunk = {"choices": [{"delta": {"content": "Hi"}}]} + assert chat_chunk_marks_ttft(chunk) is True + + def test_reasoning_delta_marks(self) -> None: + chunk = {"choices": [{"delta": {"reasoning_content": "think"}}]} + assert chat_chunk_marks_ttft(chunk) is True + + def test_tool_calls_delta_marks(self) -> None: + chunk = { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "function": {"name": "end", "arguments": ""}, + } + ] + } + } + ] + } + assert chat_chunk_marks_ttft(chunk) is True + + +class TestResponsesEventMarksTtft: + def test_output_text_delta_marks(self) -> None: + assert ( + responses_event_marks_ttft( + {"type": "response.output_text.delta", "delta": "a"} + ) + is True + ) + + def test_empty_output_text_delta_does_not_mark(self) -> None: + assert ( + responses_event_marks_ttft( + {"type": "response.output_text.delta", "delta": ""} + ) + is False + ) + + def test_function_call_arguments_delta_marks(self) -> None: + assert ( + responses_event_marks_ttft( + {"type": "response.function_call_arguments.delta", "delta": "{"} + ) + is True + ) + + def test_empty_function_call_arguments_delta_does_not_mark(self) -> None: + assert ( + responses_event_marks_ttft( + {"type": "response.function_call_arguments.delta", "delta": ""} + ) + is False + ) + + def test_completed_event_does_not_mark(self) -> None: + assert ( + responses_event_marks_ttft({"type": "response.completed", "response": {}}) + is False + ) + + +class TestAnthropicEventMarksTtft: + def test_text_delta_marks(self) -> None: + assert ( + anthropic_event_marks_ttft( + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "hi"}, + } + ) + is True + ) + + def test_thinking_delta_marks(self) -> None: + assert ( + anthropic_event_marks_ttft( + { + "type": "content_block_delta", + "delta": {"type": "thinking_delta", "thinking": "plan"}, + } + ) + is True + ) + + def test_message_start_does_not_mark(self) -> None: + assert anthropic_event_marks_ttft({"type": "message_start"}) is False + + +class TestComputeStreamGenerationMetrics: + def test_no_first_token_returns_none(self) -> None: + ttft, tps = compute_stream_generation_metrics( + duration_seconds=1.2, + start_perf=100.0, + first_token_at=None, + completion_tokens=40, + ) + assert ttft is None + assert tps is None + + def test_normal_stream_tps(self) -> None: + # start=100, first=100.3 -> ttft=0.3; duration=1.2 -> gen=0.9; 45/0.9=50 + ttft, tps = compute_stream_generation_metrics( + duration_seconds=1.2, + start_perf=100.0, + first_token_at=100.3, + completion_tokens=45, + ) + assert ttft == pytest.approx(0.3) + assert tps == pytest.approx(50.0) + + def test_tiny_generation_window_omits_tps(self) -> None: + ttft, tps = compute_stream_generation_metrics( + duration_seconds=0.2, + start_perf=10.0, + first_token_at=10.2, + completion_tokens=10, + ) + assert ttft == pytest.approx(0.2) + assert tps is None + + def test_zero_completion_tokens_omits_tps(self) -> None: + ttft, tps = compute_stream_generation_metrics( + duration_seconds=1.0, + start_perf=0.0, + first_token_at=0.2, + completion_tokens=0, + ) + assert ttft == pytest.approx(0.2) + assert tps is None diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 9ff0fa82..9c8bd835 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -445,16 +445,21 @@ def test_system_prompts_tell_end_to_record_whole_current_input_batch( ) -> None: text = path.read_text(encoding="utf-8") - assert "memo / observations 必须覆盖整个【当前输入批次】" in text + assert "memo / observations 在有实质可记内容时必须覆盖整个【当前输入批次】" in text assert "不要只根据最后一条消息记录" in text - assert "end.observations 必须覆盖整批消息中有价值的信息" in text + assert "end.observations 必须覆盖整批中的这些信息" in text assert "不要求与 bot 相关,也不要求长期稳定" in text - assert "当前批次中有价值即可记录" in text + assert "写实 / 宁缺毋滥" in text + assert "值得日后检索" in text + assert "否定清单" in text + assert "静默处理" in text assert "不能作为 observations 的新事实来源" in text assert "系统会围绕当前输入批次自动检索相关内容" in text assert "何时应该填写 memo" in text assert "何时应该填写 summary" not in text assert "summary 应该是对未来有帮助的信息" not in text + assert "QQ号12345678(昵称张三)" in text + assert "可核验" in text @pytest.mark.parametrize("path", PROMPT_PATHS) @@ -486,12 +491,17 @@ def test_end_tool_schema_mentions_current_input_batch() -> None: assert "当前输入批次" in function["description"] assert "不要求与 bot 相关" in function["description"] assert "不要求长期稳定" in function["description"] + assert "宁缺毋滥" in function["description"] assert "项目名/主名必须逐字写作 Undefined" in function["description"] assert "必须覆盖整批消息内容" in observations["description"] assert "不能只记录最后一条" in observations["description"] - assert "当前批次中有价值即可记录" in observations["description"] + assert "值得日后检索" in observations["description"] + assert "宁缺毋滥" in observations["description"] + assert "否定清单" in observations["description"] assert "禁止从其中摘取新事实写入 observations" in observations["description"] assert "禁止写成 Unfined、Undefind、undefind" in observations["description"] + assert "QQ号(昵称)" in function["description"] + assert "QQ号(昵称)" in observations["description"] assert "summary" not in properties assert "action_summary" not in properties assert "new_info" not in properties @@ -504,8 +514,17 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: assert "当前输入批次提取到的一条有价值新观察" in rewrite assert "最近消息参考只能消歧,禁止作为新事实来源" in rewrite assert "当前输入批次原文(触发本轮;连续消息会按时间顺序列出多条)" in rewrite + assert "本轮事件时间" in rewrite + assert "轻量独立事实" in rewrite + assert "按上下文灵活判断,不要机械套用固定改写模板" in rewrite + assert "QQ号123(昵称张三)在 2026-08-11 10:00 改用了 Rust" in rewrite + assert "QQ号(昵称)" in rewrite assert "当前输入批次原文" in merge assert "禁止作为本轮新事实来源" in merge + assert "当前时刻" in merge + assert "最新优先" in merge + assert "合并去冗" in merge + assert "克制扩写" in merge @pytest.mark.parametrize("path", PROMPT_PATHS) diff --git a/tests/test_token_usage_unit.py b/tests/test_token_usage_unit.py index 821f6ea8..0facdfd2 100644 --- a/tests/test_token_usage_unit.py +++ b/tests/test_token_usage_unit.py @@ -64,6 +64,55 @@ def test_empty_dict(self) -> None: assert usage.duration_seconds == 0.0 assert usage.call_type == "unknown" assert usage.success is True # 默认为 True + assert usage.ttft_seconds is None + assert usage.tokens_per_second is None + + def test_stream_metrics_roundtrip(self) -> None: + usage = TokenUsage( + timestamp="ts", + model_name="m", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + duration_seconds=1.5, + call_type="chat", + success=True, + ttft_seconds=0.35, + tokens_per_second=42.1, + ) + restored = TokenUsage.from_dict(usage.to_dict()) + assert restored.ttft_seconds == pytest.approx(0.35) + assert restored.tokens_per_second == pytest.approx(42.1) + assert "ttft_seconds" in usage.to_dict() + assert "tokens_per_second" in usage.to_dict() + + def test_stream_metrics_omitted_when_none(self) -> None: + usage = TokenUsage.from_dict(_sample_dict()) + data = usage.to_dict() + assert "ttft_seconds" not in data + assert "tokens_per_second" not in data + + def test_stream_metrics_null_in_jsonl(self) -> None: + usage = TokenUsage.from_dict( + {**_sample_dict(), "ttft_seconds": None, "tokens_per_second": None} + ) + assert usage.ttft_seconds is None + assert usage.tokens_per_second is None + + def test_stream_metrics_reject_nan_infinity_and_negative(self) -> None: + for raw in ("NaN", "Infinity", -1.0): + usage = TokenUsage.from_dict( + {**_sample_dict(), "ttft_seconds": raw, "tokens_per_second": raw} + ) + assert usage.ttft_seconds is None + assert usage.tokens_per_second is None + + def test_stream_metrics_keep_non_negative_finite(self) -> None: + usage = TokenUsage.from_dict( + {**_sample_dict(), "ttft_seconds": 0.0, "tokens_per_second": 12.5} + ) + assert usage.ttft_seconds == pytest.approx(0.0) + assert usage.tokens_per_second == pytest.approx(12.5) def test_timestamp_fallback_to_time(self) -> None: usage = TokenUsage.from_dict({"time": "2025-01-01"}) diff --git a/uv.lock b/uv.lock index 120fbb57..5c958691 100644 --- a/uv.lock +++ b/uv.lock @@ -4704,7 +4704,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.11.0" +version = "3.11.1" source = { editable = "." } dependencies = [ { name = "aiofiles" },