From 33692fa0b9fd664c779ecdeb433869e303220b67 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Tue, 11 Aug 2026 10:12:40 +0800 Subject: [PATCH 1/9] feat(cognitive): prefer latest facts in historian profile merge Inject merge-time now and profile updated_at so the historian can resolve stale traits against the current input batch. Co-authored-by: Cursor --- docs/cognitive-memory.md | 2 +- res/prompts/historian_profile_merge.md | 36 +++++++++------ res/prompts/historian_rewrite.md | 2 +- src/Undefined/cognitive/historian/helpers.py | 27 +++++++++--- src/Undefined/cognitive/historian/worker.py | 34 ++++++++++++++- tests/test_cognitive_historian.py | 46 ++++++++++++++++++-- tests/test_system_prompt_constraints.py | 3 ++ 7 files changed, 124 insertions(+), 26 deletions(-) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index 62b71476..95c61920 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -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/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 87c15dab..2651e7de 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,22 @@ 要求: 1. **先调用 `read_profile` 读取目标实体的当前侧写**,再决定如何更新 -2. **增量更新原则**(核心): - - 现有侧写是长期积累的全貌,本轮新事件只是一个增量片段 - - 默认保留所有现有稳定特征,新信息用于"补充细节"或"修正矛盾",而非"重新定义" - - 参考历史事件列表判断旧特征是否仍然成立——只要历史中反复出现,就应保留 - - 只有当新信息与旧特征**明确矛盾**时才覆盖,否则应融合表达(如"既...也...") -3. 若新旧信息矛盾且新信息更可靠,以新信息为准并说明变化 +2. **更新双原则**(核心): + - **未冲突**:现有侧写是长期积累的全貌,本轮新事件只是增量片段;默认保留未被本轮触及的稳定特征,用新信息补充细节或修正表述,而非无故重新定义 + - **冲突/过时**:以当前输入批次为准覆盖;历史事件只用于判断“旧特征是否仍有较新佐证”,不是保旧否决票 +3. **时间判断规则**: + - 以「当前时刻」为锚点,对照「本轮事件时间」、历史事件时间戳、侧写上次更新时间 + - 更早且与本轮矛盾的特征视为过时,应覆盖 + - 更早但本轮未触及、且仍有较近历史佐证的特征应保留 + - 侧写上次更新明显早于本轮、且本轮给出可沉淀的稳定当前态特征时,应更新而非 skip + - 时间只用于判断取舍;禁止把时间线、相对时间或“变化过程”写进 summary/tags 4. tags 只写"这个实体**是什么**"(身份/角色/核心领域),不写"聊过什么话题";话题级细节已在 summary 中覆盖。若现有 tags 不符合此规范(含话题标签等),直接按规范重写,不必保留旧 tags 5. 侧写要有"主线"——第一条定调,后续条目围绕它展开,而非孤立罗列无关特征 6. **信息密度优先于表达精炼**:每条要有具体细节(技术栈/工具/行为模式),不要为了简洁而泛化成抽象描述 7. **核心画像要抓独特性**:第一句要写出"这个人区别于其他人的本质",而非通用描述(如"开发者"太泛,"把系统当产线打理的工程型开发者"才有辨识度) 8. 若 `current_profile` 本身不符合以上规范,可直接整体重写为合规版本(不必保留其原有写法) -侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。 +侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。正文写的是**当前仍成立的状态**,不是事件流水账。 严禁写入以下内容(这些属于事件记忆,不应进入侧写): - 任何具体事件、时间线、单次对话经过 @@ -69,6 +78,7 @@ 若新信息仅为一次性事件、无法提炼为稳定特征,必须 `skip=true`。 若本轮只有事件细节、无法抽象为长期稳定特征,必须 `skip=true`。 +若本轮已能提炼稳定当前态特征,必须更新(`skip=false`),不得以“旧侧写已足够完整”为由跳过。 `summary` 输出格式约束: diff --git a/res/prompts/historian_rewrite.md b/res/prompts/historian_rewrite.md index 7946d95c..58e70700 100644 --- a/res/prompts/historian_rewrite.md +++ b/res/prompts/historian_rewrite.md @@ -19,7 +19,7 @@ 上下文信息: - 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/src/Undefined/cognitive/historian/helpers.py b/src/Undefined/cognitive/historian/helpers.py index e1bda7da..7240ad5b 100644 --- a/src/Undefined/cognitive/historian/helpers.py +++ b/src/Undefined/cognitive/historian/helpers.py @@ -19,23 +19,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: diff --git a/src/Undefined/cognitive/historian/worker.py b/src/Undefined/cognitive/historian/worker.py index 47bca513..5b83b57a 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 from typing import Any, Callable from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY @@ -21,6 +21,7 @@ _coerce_bool, _escape_braces, _extract_frontmatter_name, + _extract_frontmatter_updated_at, _preview_text, _resolve_timestamp_epoch, ) @@ -676,6 +677,32 @@ async def _merge_profile_target( or "(暂无历史事件)" ) + now_local_dt = datetime.now().astimezone() + now_utc_dt = datetime.now(timezone.utc) + now_local = now_local_dt.isoformat() + now_utc = now_utc_dt.isoformat() + timezone_label = str(job.get("timezone") or now_local_dt.tzinfo or "").strip() + + 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 +729,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))), diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 9bcfc6c3..410bae3a 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -10,6 +10,7 @@ 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 from Undefined.cognitive.historian.tools import _PROFILE_TOOL @@ -136,19 +137,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 +208,12 @@ 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 @pytest.mark.asyncio @@ -349,6 +372,23 @@ 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 "{profile_updated_at}" in merge + assert "最新优先" in merge + assert "以当前输入批次为准覆盖" in merge + assert "时间只用于判断取舍" 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") == "" def test_profile_update_tool_does_not_cap_tags() -> None: diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 9ff0fa82..666febd7 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -504,8 +504,11 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: assert "当前输入批次提取到的一条有价值新观察" in rewrite assert "最近消息参考只能消歧,禁止作为新事实来源" in rewrite assert "当前输入批次原文(触发本轮;连续消息会按时间顺序列出多条)" in rewrite + assert "本轮事件时间" in rewrite assert "当前输入批次原文" in merge assert "禁止作为本轮新事实来源" in merge + assert "当前时刻" in merge + assert "最新优先" in merge @pytest.mark.parametrize("path", PROMPT_PATHS) From 851408ef61a15492e6f74675f889370d885a4b13 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Tue, 11 Aug 2026 10:18:56 +0800 Subject: [PATCH 2/9] feat(cognitive): rewrite historian events as timed independent facts Guide lightweight rewrite to prefer verifiable fact sentences with absolute time anchors over speech-act shells. Co-authored-by: Cursor --- docs/cognitive-memory.md | 2 +- res/prompts/historian_rewrite.md | 23 ++++++++++++++++------- tests/test_system_prompt_constraints.py | 3 +++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index 95c61920..e0e9dfa4 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -79,7 +79,7 @@ pending/{job_id}.json ▼ dequeue(原子 os.replace) processing/{job_id}.json │ - ▼ LLM 绝对化改写(消灭代词/相对时间/相对地点;结合“当前输入批次原文 + 最近消息参考”做实体消歧) + ▼ LLM 绝对化改写(消灭代词/相对时间/相对地点;尽量提炼为带时间锚点的独立事实;结合“当前输入批次原文 + 最近消息参考”做实体消歧) │ ▼ 正则闸门检查 │ 通过 → is_absolute=true diff --git a/res/prompts/historian_rewrite.md b/res/prompts/historian_rewrite.md index 58e70700..7f2b3f71 100644 --- a/res/prompts/historian_rewrite.md +++ b/res/prompts/historian_rewrite.md @@ -1,15 +1,24 @@ -你是一个记忆整理员。将以下对话摘要改写为绝对化的事件记录。 +你是一个记忆整理员。将以下对话摘要改写为绝对化的、可独立检索的事件事实。 要求: 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 张三(123)说他改用 Rust` → `张三(123)在 2026-08-11 10:00 改用了 Rust` + - 示意:`今天早上张三说他改用 Rust` → `张三(123)在 2026-08-11 上午改用了 Rust` + - 若关键点就是言论本身、承诺未落地、玩笑/反讽、无法核验的转述,则保留言语表述,不要硬改成「做了」;但仍应尽量带上绝对时间 + - 禁止为了「更像事实」而添加当前输入批次无法验证的细节 + - **按上下文灵活判断,不要机械套用固定改写模板** +6. `memo` 可能为空;为空时以 `observations` 和上下文为主 +7. `observations` 代表当前输入批次提取到的一条有价值新观察(可能是多条中的一条);不要求与 bot 相关,也不要求长期稳定。若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 +8. 若原文已显式出现实体标识(如 `昵称(数字ID)`、`用户123456`、`QQ:123456`),必须保留该数字ID;禁止擅自替换成 `sender_id` 或其他ID +9. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;最近消息参考只能消歧,禁止作为新事实来源。当 `observations` 与参考上下文冲突时,以当前输入批次可验证且更具体的信息为准 +10. 当 `force=true` 且命中的“相对表达”属于专有名词本体(如用户名“你是谁”、片名《后天》、书名/歌名等)时,不得改写该专有名词,可保留原词直接提交;但实体 ID 一律不得漂移 称呼规则: - bot 自身统一称为「{bot_name}」 diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 666febd7..9432c436 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -505,6 +505,9 @@ 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 "张三(123)在 2026-08-11 10:00 改用了 Rust" in rewrite assert "当前输入批次原文" in merge assert "禁止作为本轮新事实来源" in merge assert "当前时刻" in merge From 538053000b4661854e91f5224dcf09dadb08028d Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Tue, 11 Aug 2026 10:55:49 +0800 Subject: [PATCH 3/9] feat(cognitive): curb historian profile bloat via merge dedup guidance Co-authored-by: Cursor --- docs/cognitive-memory.md | 2 +- res/prompts/historian_profile_merge.md | 41 ++++++++++++++++--------- tests/test_cognitive_historian.py | 4 +++ tests/test_system_prompt_constraints.py | 2 ++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index e0e9dfa4..6020c7fd 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -177,7 +177,7 @@ MMR_score = λ × relevance(doc, query) − (1 − λ) × max_similarity(doc, se ### 侧写合并:历史事件注入 -史官合并侧写时,会在 merge LLM 调用前用当前 observations 作为 query 从 ChromaDB 检索该实体的 top-8 历史事件,注入 merge prompt。这让史官拥有更丰富的上下文来判断哪些特征应保留,避免因本轮未提及而误删长期稳定特征。合并时还会注入**当前时刻**与旧侧写 `updated_at`:冲突时以当前输入批次为准覆盖过时特征;时间只用于判断取舍,侧写正文仍禁止写入时序描述。 +史官合并侧写时,会在 merge LLM 调用前用当前 observations 作为 query 从 ChromaDB 检索该实体的 top-8 历史事件,注入 merge prompt。这让史官拥有更丰富的上下文来判断哪些特征应保留,避免因本轮未提及而误删长期稳定特征。合并时还会注入**当前时刻**与旧侧写 `updated_at`:冲突时以当前输入批次为准覆盖过时特征;时间只用于判断取舍,侧写正文仍禁止写入时序描述。合并时按「克制扩写 / 合并去冗」压缩同维度复述,避免侧写无限膨胀(不设硬字数,由提示词灵活判断)。 ### ChromaDB 前后台调度 diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 2651e7de..3a6df2b9 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -63,9 +63,14 @@ - 时间只用于判断取舍;禁止把时间线、相对时间或“变化过程”写进 summary/tags 4. tags 只写"这个实体**是什么**"(身份/角色/核心领域),不写"聊过什么话题";话题级细节已在 summary 中覆盖。若现有 tags 不符合此规范(含话题标签等),直接按规范重写,不必保留旧 tags 5. 侧写要有"主线"——第一条定调,后续条目围绕它展开,而非孤立罗列无关特征 -6. **信息密度优先于表达精炼**:每条要有具体细节(技术栈/工具/行为模式),不要为了简洁而泛化成抽象描述 +6. **克制扩写 / 合并去冗**(核心,按上下文灵活判断,不设硬字数): + - 本轮更新默认是增量合并,不是无界扩写:能并入现有条目就不新增条目 + - 同维度、同义、细节重复的多条必须合并或覆盖,禁止并列堆叠 + - 具体细节仍要保留,但同一维度应合并进一条;宁可用更短表述承载等价信息,也不堆砌同义复述 + - 边缘、偶发、低辨识度特征可省略;细节级事实留给事件记忆,侧写只留稳定画像 + - 若旧侧写已冗长或条目过多,本轮应顺手压缩重整为更紧凑的合规版本 7. **核心画像要抓独特性**:第一句要写出"这个人区别于其他人的本质",而非通用描述(如"开发者"太泛,"把系统当产线打理的工程型开发者"才有辨识度) -8. 若 `current_profile` 本身不符合以上规范,可直接整体重写为合规版本(不必保留其原有写法) +8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀),可直接整体重写为合规版本(不必保留其原有写法) 侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。正文写的是**当前仍成立的状态**,不是事件流水账。 @@ -87,15 +92,16 @@ - 一句话定位这个人的身份与核心特质,为后续条目定调 - 要具体,不要泛泛而谈(如"在校学生/业余开发者,做技术取舍会权衡时间、算力与预算"比"务实的开发者"信息量大) -2. **后续 4-8 条:多维度展开** +2. **后续少量维度展开**(建议约 3–6 条,按需要增减,勿为凑条数而拆分) - 每条聚焦一个维度:技术栈/工作方式/决策偏好/沟通风格/排障思路等 - - **关键:每条要有具体细节**,不要抽象概括(如"模型名需包含完整前缀与斜杠"比"注重细节"有用) + - 每条保留具体细节,但同一维度合并进一条(如"模型名需包含完整前缀与斜杠"比"注重细节"有用) + - 每条一句到两句为宜;避免把单条写成小作文或多事件拼盘 - 条目间要有逻辑关联,共同支撑第一条的核心画像 -3. **信息密度优先**:宁可多写一条具体特征,也不要为了精炼而泛化 +3. **紧凑优先**:同等信息量下优先更短表述;禁止为“更全面”而同义堆砌或无限加条 **群聊侧写**结构: -- 同样使用项目符号,第一条定位群的核心属性,后续展开成员构成、讨论风格、群文化等 +- 同样使用项目符号,第一条定位群的核心属性,后续少量展开成员构成、讨论风格、群文化等;同样遵守合并去冗 **反面示例**(不要这样写): ``` @@ -108,20 +114,25 @@ - 用过 Docker - 喜欢开源 → 问题:每条独立,看不出这个人的核心特质和做事逻辑 + +❌ 同义堆砌 / 条目膨胀: +- 关注本地模型部署与联调 +- 常聊自建推理服务与 OpenAI 兼容接口 +- 习惯核对模型名、前缀与配置是否一致 +- 对配置细节很敏感,要求原样填写 +→ 问题:同一「本地模型/配置严谨」维度拆成多条同义复述,侧写会越写越长 ``` **正面示例**(应该这样写): ``` -✓ 第一条定调 + 后续高密度展开: +✓ 第一条定调 + 后续紧凑展开: - 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算,偏好高性价比方案。 -- 独立开发并维护开源项目,关注 AI 应用与 Agent 的工程化落地。 -- 熟悉本地/自建模型服务与 OpenAI 兼容接口的接入联调,强调配置项必须与实际资源严格一致。 -- 对"标识符/名称"细节较敏感(如模型名需包含完整前缀与斜杠、tag 等),倾向要求原样填写以避免隐性错误。 -- 有容器化与自建基础设施经验,偏好自动化运维与稳定可控的部署方式。 -- 排障思路偏"先核对关键信息—再做最小改动验证",会主动索要配置/截图与报错信息。 -- 沟通风格简短直接,在社群中常承担技术支持与规则把关角色。 - -(注:每条都有具体细节,共同支撑"务实的工程型开发者"这个核心画像) +- 独立维护开源项目,关注 AI 应用与 Agent 工程化落地。 +- 熟悉本地/自建模型与 OpenAI 兼容接口联调;对模型名、前缀、tag 等标识符敏感,要求配置与实际资源严格一致。 +- 有容器化与自建基础设施经验,偏好自动化运维与稳定可控部署。 +- 排障偏"先核对关键信息—再做最小改动验证";沟通简短直接,常承担技术支持与规则把关。 + +(注:具体细节保留,但同维度已合并;共同支撑"务实的工程型开发者"主线) ``` 输出规则(调用 `update_profile` 工具): diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 410bae3a..dff83aac 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -378,6 +378,10 @@ def test_historian_profile_merge_prompt_profile_only_constraints() -> None: 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: diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 9432c436..1a303f13 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -512,6 +512,8 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: assert "禁止作为本轮新事实来源" in merge assert "当前时刻" in merge assert "最新优先" in merge + assert "合并去冗" in merge + assert "克制扩写" in merge @pytest.mark.parametrize("path", PROMPT_PATHS) From 16de7f61f670a2cd8f32c3367e84e2e9da138844 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Tue, 11 Aug 2026 11:52:42 +0800 Subject: [PATCH 4/9] feat(ai): log stream TTFT and generation TPS for LLM calls Co-authored-by: Cursor --- src/Undefined/ai/llm/requester.py | 87 +++++++++++++++- src/Undefined/ai/llm/streaming.py | 110 ++++++++++++++++++++ src/Undefined/token_usage_storage.py | 21 +++- tests/test_llm_streaming.py | 145 +++++++++++++++++++++++++++ tests/test_token_usage_unit.py | 34 +++++++ 5 files changed, 390 insertions(+), 7 deletions(-) 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..400c3a90 100644 --- a/src/Undefined/ai/llm/streaming.py +++ b/src/Undefined/ai/llm/streaming.py @@ -197,6 +197,116 @@ 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: + if event_type == "response.function_call_arguments.delta": + return True + 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/token_usage_storage.py b/src/Undefined/token_usage_storage.py index 10dce0c7..234382c6 100644 --- a/src/Undefined/token_usage_storage.py +++ b/src/Undefined/token_usage_storage.py @@ -43,10 +43,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 +80,14 @@ 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: + return float(value) + except (TypeError, ValueError): + return None + prompt_tokens = to_int( data.get("prompt_tokens") if "prompt_tokens" in data @@ -114,6 +129,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_llm_streaming.py b/tests/test_llm_streaming.py index fa412623..bede2f1d 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,142 @@ 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_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_token_usage_unit.py b/tests/test_token_usage_unit.py index 821f6ea8..3a088806 100644 --- a/tests/test_token_usage_unit.py +++ b/tests/test_token_usage_unit.py @@ -64,6 +64,40 @@ 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_timestamp_fallback_to_time(self) -> None: usage = TokenUsage.from_dict({"time": "2025-01-01"}) From 6662581f8f8989eb1cea24bf797d43dd6176f8bb Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Tue, 11 Aug 2026 13:27:52 +0800 Subject: [PATCH 5/9] feat(prompt): tighten end.observations to substantive facts only Co-authored-by: Cursor --- res/prompts/undefined.xml | 29 ++++++++++++++-------- res/prompts/undefined_nagaagent.xml | 29 ++++++++++++++-------- src/Undefined/skills/tools/end/README.md | 2 +- src/Undefined/skills/tools/end/config.json | 4 +-- tests/test_system_prompt_constraints.py | 14 ++++++++--- 5 files changed, 49 insertions(+), 29 deletions(-) diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 976c954c..8821d3af 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. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 1. **当前批次直接出现的用户/群聊/第三方实质事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) - 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 - 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 + **写实 / 宁缺毋滥(核心)**:仅当日后真想检索到这条事实时才写;无实质事实时必须 `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..d0088eb6 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. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 1. **当前批次直接出现的用户/群聊/第三方实质事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) - 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 - 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 + **写实 / 宁缺毋滥(核心)**:仅当日后真想检索到这条事实时才写;无实质事实时必须 `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/skills/tools/end/README.md b/src/Undefined/skills/tools/end/README.md index 239ea2d1..3d22af57 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 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组。禁止硬凑静默决策、否定清单、元评论或消费碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。纯流水账写 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..4de15bdc 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 相关,也不要求长期稳定。可记录当前批次直接出现的用户/群聊/第三方实质事实,以及本轮回复行为产生的有价值事实(帮谁解决了什么)。宁缺毋滥:无实质事实时用空数组,禁止硬凑静默决策、否定清单、元评论或碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 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 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组。禁止写入:静默/闸门等流程决策、否定清单(无任务/无风险等)、元评论(属碎碎念/与 Undefined 无关)、一次性闲聊与消费碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于实体/时间/地点消歧,禁止从其中摘取新事实写入 observations。存在【连续消息说明】或多段当前 且有实质可记事实时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊/第三方实质事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点;纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。涉及本项目或你自己时必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。" }, "perspective": { "type": "string", diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 1a303f13..64da4810 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -445,11 +445,14 @@ 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 @@ -486,10 +489,13 @@ 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 "summary" not in properties From 6fc10e1cf548332f4161373a3d5b7d9d844423de Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 13 Aug 2026 09:20:32 +0800 Subject: [PATCH 6/9] docs: sync observations quality and stream TTFT/TPS notes Co-authored-by: Cursor --- AGENTS.md | 2 +- ARCHITECTURE.md | 6 +++--- CLAUDE.md | 4 ++-- README.md | 2 +- docs/cognitive-memory.md | 4 ++-- docs/configuration.md | 2 ++ docs/message-batching.md | 2 +- docs/model-compatibility.md | 1 + docs/usage.md | 1 + 9 files changed, 14 insertions(+), 10 deletions(-) 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/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/docs/cognitive-memory.md b/docs/cognitive-memory.md index 6020c7fd..901cc7b1 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 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组,禁止硬凑流程决策、否定清单、元评论或闲聊碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。后台史官会异步改写为绝对化事件并存入 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 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用 `[]`。严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息且存在实质可记事实时,必须覆盖整批,不能只记录最后一条。禁止写入纯流水账动作(静默处理、闸门未通过、调了什么工具)、否定清单(“无新增任务/无隐私风险”等)、元评论或一次性闲聊/消费碎碎念——这些写 `memo` 或不写。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 - 两字段都为空时,仅结束会话,不写认知队列。 ### 后台史官流水线 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..0f027e06 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 在存在实质可记事实时要覆盖整批写实新观察(宁缺毋滥,无实质事实时用空数组,禁止硬凑流程决策/否定清单/元评论/闲聊碎碎念)。这些观察不要求与 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` 说明 From 86b03242e1f72a9f7a65d3794118baff7b97dac5 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 13 Aug 2026 09:23:59 +0800 Subject: [PATCH 7/9] chore(version): bump version to 3.11.1 Co-authored-by: Cursor --- CHANGELOG.md | 12 ++++++++++++ apps/undefined-chat/package-lock.json | 4 ++-- apps/undefined-chat/package.json | 2 +- apps/undefined-chat/src-tauri/Cargo.lock | 2 +- apps/undefined-chat/src-tauri/Cargo.toml | 2 +- apps/undefined-chat/src-tauri/tauri.conf.json | 2 +- apps/undefined-console/package-lock.json | 4 ++-- apps/undefined-console/package.json | 2 +- apps/undefined-console/src-tauri/Cargo.lock | 2 +- apps/undefined-console/src-tauri/Cargo.toml | 2 +- apps/undefined-console/src-tauri/tauri.conf.json | 2 +- pyproject.toml | 2 +- src/Undefined/__init__.py | 2 +- uv.lock | 2 +- 14 files changed, 27 insertions(+), 15 deletions(-) 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/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/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/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/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" }, From 1e7769365f8a5b603f04ca0179408a4a3fe4c102 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 13 Aug 2026 09:54:42 +0800 Subject: [PATCH 8/9] fix(review): align historian contracts and stream metrics Keep observation provenance, QQ-ID naming, job timezone offsets, and TTFT/TPS parsing consistent so merge prompts and usage logs stay trustworthy. Co-authored-by: Cursor --- docs/cognitive-memory.md | 4 +- docs/message-batching.md | 2 +- res/prompts/historian_profile_merge.md | 4 +- res/prompts/historian_rewrite.md | 8 ++-- res/prompts/undefined.xml | 6 +-- res/prompts/undefined_nagaagent.xml | 6 +-- src/Undefined/ai/llm/streaming.py | 2 - src/Undefined/cognitive/historian/helpers.py | 25 +++++++++++- src/Undefined/cognitive/historian/worker.py | 16 +++++--- src/Undefined/skills/tools/end/README.md | 2 +- src/Undefined/skills/tools/end/config.json | 4 +- src/Undefined/token_usage_storage.py | 6 ++- tests/test_cognitive_historian.py | 40 +++++++++++++++++++- tests/test_llm_streaming.py | 8 ++++ tests/test_system_prompt_constraints.py | 7 +++- tests/test_token_usage_unit.py | 15 ++++++++ 16 files changed, 126 insertions(+), 29 deletions(-) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index 901cc7b1..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 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用 `[]`。严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息且存在实质可记事实时,必须覆盖整批,不能只记录最后一条。禁止写入纯流水账动作(静默处理、闸门未通过、调了什么工具)、否定清单(“无新增任务/无隐私风险”等)、元评论或一次性闲聊/消费碎碎念——这些写 `memo` 或不写。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 +- `observations`:本轮从当前输入批次提取的写实新观察列表(0..N 条),包括用户/群聊/第三方实质事实和有价值的自身行为(帮谁解决了什么)。不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用 `[]`。用户中心观察须写成 `QQ号(昵称)`,保留稳定数字标识。严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息且存在实质可记事实时,必须覆盖整批,不能只记录最后一条。禁止写入纯流水账动作(静默处理、闸门未通过、调了什么工具)、否定清单(“无新增任务/无隐私风险”等)、元评论或一次性闲聊/消费碎碎念——这些写 `memo` 或不写。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 - 两字段都为空时,仅结束会话,不写认知队列。 ### 后台史官流水线 diff --git a/docs/message-batching.md b/docs/message-batching.md index 0f027e06..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/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 3a6df2b9..67acd3a5 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -104,7 +104,7 @@ - 同样使用项目符号,第一条定位群的核心属性,后续少量展开成员构成、讨论风格、群文化等;同样遵守合并去冗 **反面示例**(不要这样写): -``` +```markdown ❌ 过度抽象,丢失具体信息: "Null 是那种把 AI 系统当工程产线来打理的人,核心驱动力不是炫技,而是让模型、链路和配置始终保持可用、可控、可回退。" → 问题:比喻虽好,但"权衡算力与预算"、"OpenAI 兼容接口"、"容器化"等关键细节全丢了 @@ -124,7 +124,7 @@ ``` **正面示例**(应该这样写): -``` +```markdown ✓ 第一条定调 + 后续紧凑展开: - 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算,偏好高性价比方案。 - 独立维护开源项目,关注 AI 应用与 Agent 工程化落地。 diff --git a/res/prompts/historian_rewrite.md b/res/prompts/historian_rewrite.md index 7f2b3f71..0a3c0a04 100644 --- a/res/prompts/historian_rewrite.md +++ b/res/prompts/historian_rewrite.md @@ -9,20 +9,20 @@ - 目标是产出可脱离原对话独立理解与检索的事件事实,优先写成「某人在某时是/做了/偏好/决定了什么」,而不是「某人说了……」这类言语行为壳 - 时间锚点应保留在事实句中;相对时间先按「本轮事件时间」绝对化,再落入事实 - 若 observation 以「谁在何时说了什么」呈现,而内容本身已能支撑稳定事实,可提炼为带时间锚点的独立事实 - - 示意:`2026-08-11 10:00 张三(123)说他改用 Rust` → `张三(123)在 2026-08-11 10:00 改用了 Rust` - - 示意:`今天早上张三说他改用 Rust` → `张三(123)在 2026-08-11 上午改用了 Rust` + - 示意:`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. 若原文已显式出现实体标识(如 `昵称(数字ID)`、`用户123456`、`QQ:123456`),必须保留该数字ID;禁止擅自替换成 `sender_id` 或其他ID +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}」 上下文信息: diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 8821d3af..4d26142a 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -237,11 +237,11 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮从【当前输入批次】提取的**写实**新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索回忆 + - observations:字符串数组,本轮**写实**新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索回忆。来源仅限:当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果。 - 若存在【连续消息说明】或多段当前 ``,memo / observations 在有实质可记内容时必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。无实质事实时 observations 应为空数组,不是每轮都要凑几条。 observations 应该记录两类内容: 1. **当前批次直接出现的用户/群聊/第三方实质事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) + 2. **本轮已实际发生、可核验的有价值自身行为结果**:你确实为用户做成了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等);仅打算做或未执行的不算 **写实 / 宁缺毋滥(核心)**:仅当日后真想检索到这条事实时才写;无实质事实时必须 `observations=[]`,禁止为交差硬凑杂项。不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 不适合写入 observations 的(需要记时写 memo,或干脆不写): - 纯流水账动作:”回复了一句话”、”决定不回复/静默处理”、”调用了search工具”、”收件人闸门未通过”、”不调用任何业务工具” @@ -958,7 +958,7 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取写实新观察(用户/群聊/第三方实质事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组 + 写实新观察写入 end.observations(数组,严格一条一个要点):仅限当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果;不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组 当前输入批次若包含多条连续消息,且存在实质可记事实,end.observations 必须覆盖整批中的这些信息;禁止只记录最后一条。无实质事实时保持空数组,禁止硬凑。 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 纯流水账动作(调了什么工具、决定不回复/静默处理、闸门未通过等)、否定清单、元评论只写 memo 或不写,不写 end.observations diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index d0088eb6..ff64c7c4 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -237,11 +237,11 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮从【当前输入批次】提取的**写实**新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索回忆 + - observations:字符串数组,本轮**写实**新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索回忆。来源仅限:当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果。 - 若存在【连续消息说明】或多段当前 ``,memo / observations 在有实质可记内容时必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。无实质事实时 observations 应为空数组,不是每轮都要凑几条。 observations 应该记录两类内容: 1. **当前批次直接出现的用户/群聊/第三方实质事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) + 2. **本轮已实际发生、可核验的有价值自身行为结果**:你确实为用户做成了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等);仅打算做或未执行的不算 **写实 / 宁缺毋滥(核心)**:仅当日后真想检索到这条事实时才写;无实质事实时必须 `observations=[]`,禁止为交差硬凑杂项。不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 不适合写入 observations 的(需要记时写 memo,或干脆不写): - 纯流水账动作:”回复了一句话”、”决定不回复/静默处理”、”调用了search工具”、”收件人闸门未通过”、”不调用任何业务工具” @@ -1017,7 +1017,7 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取写实新观察(用户/群聊/第三方实质事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组 + 写实新观察写入 end.observations(数组,严格一条一个要点):仅限当前输入批次中的实质事实,以及本轮已实际发生、可核验的有价值自身行为结果;不要求与 bot 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组 当前输入批次若包含多条连续消息,且存在实质可记事实,end.observations 必须覆盖整批中的这些信息;禁止只记录最后一条。无实质事实时保持空数组,禁止硬凑。 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 纯流水账动作(调了什么工具、决定不回复/静默处理、闸门未通过等)、否定清单、元评论只写 memo 或不写,不写 end.observations diff --git a/src/Undefined/ai/llm/streaming.py b/src/Undefined/ai/llm/streaming.py index 400c3a90..aab62a61 100644 --- a/src/Undefined/ai/llm/streaming.py +++ b/src/Undefined/ai/llm/streaming.py @@ -247,8 +247,6 @@ 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: - if event_type == "response.function_call_arguments.delta": - return True return bool(stringify_stream_delta(event.get("delta"))) for source in (event, event.get("delta")): if not isinstance(source, dict): diff --git a/src/Undefined/cognitive/historian/helpers.py b/src/Undefined/cognitive/historian/helpers.py index 7240ad5b..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__) @@ -58,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 5b83b57a..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, timezone +from datetime import datetime, timezone, tzinfo from typing import Any, Callable from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY @@ -22,6 +22,7 @@ _escape_braces, _extract_frontmatter_name, _extract_frontmatter_updated_at, + _now_in_job_timezone, _preview_text, _resolve_timestamp_epoch, ) @@ -479,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": @@ -677,11 +684,9 @@ async def _merge_profile_target( or "(暂无历史事件)" ) - now_local_dt = datetime.now().astimezone() - now_utc_dt = datetime.now(timezone.utc) + 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() - timezone_label = str(job.get("timezone") or now_local_dt.tzinfo or "").strip() profile_updated_at = "(暂无/未知)" try: @@ -931,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 3d22af57..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 4de15bdc..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 相关,也不要求长期稳定,但必须值得日后检索;宁缺毋滥,无实质事实时用空数组。禁止写入:静默/闸门等流程决策、否定清单(无任务/无风险等)、元评论(属碎碎念/与 Undefined 无关)、一次性闲聊与消费碎碎念。历史消息、认知记忆、侧写和最近消息参考只能用于实体/时间/地点消歧,禁止从其中摘取新事实写入 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 234382c6..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 @@ -84,9 +85,12 @@ def to_optional_float(value: Any) -> float | None: if value is None: return None try: - return float(value) + 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") diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index dff83aac..f185e322 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,7 +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 +from Undefined.cognitive.historian.helpers import ( + _extract_frontmatter_updated_at, + _now_in_job_timezone, +) from Undefined.cognitive.historian.tools import _PROFILE_TOOL @@ -214,6 +219,16 @@ async def submit_background_llm_call(self, **kwargs: Any) -> dict[str, Any]: 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 @@ -374,6 +389,7 @@ def test_historian_profile_merge_prompt_profile_only_constraints() -> None: 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 @@ -395,6 +411,23 @@ def test_extract_frontmatter_updated_at() -> None: assert _extract_frontmatter_updated_at("no frontmatter") == "" +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: + now_local, now_utc, label = _now_in_job_timezone({"timezone": "Not/AZone"}) + assert label != "Not/AZone" + assert now_local.tzinfo is not None + 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: parameters: Any = _PROFILE_TOOL["function"]["parameters"] # type: ignore[index] tags_schema: Any = parameters["properties"]["tags"] @@ -532,3 +565,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 bede2f1d..583cc36e 100644 --- a/tests/test_llm_streaming.py +++ b/tests/test_llm_streaming.py @@ -630,6 +630,14 @@ def test_function_call_arguments_delta_marks(self) -> None: 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": {}}) diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 64da4810..9c8bd835 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -458,6 +458,8 @@ def test_system_prompts_tell_end_to_record_whole_current_input_batch( 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) @@ -498,6 +500,8 @@ def test_end_tool_schema_mentions_current_input_batch() -> None: 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 @@ -513,7 +517,8 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: assert "本轮事件时间" in rewrite assert "轻量独立事实" in rewrite assert "按上下文灵活判断,不要机械套用固定改写模板" in rewrite - assert "张三(123)在 2026-08-11 10:00 改用了 Rust" 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 diff --git a/tests/test_token_usage_unit.py b/tests/test_token_usage_unit.py index 3a088806..0facdfd2 100644 --- a/tests/test_token_usage_unit.py +++ b/tests/test_token_usage_unit.py @@ -99,6 +99,21 @@ def test_stream_metrics_null_in_jsonl(self) -> 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"}) assert usage.timestamp == "2025-01-01" From 12fbd242f6675e6b3d4eca66b6e44bf792b00a3f Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 13 Aug 2026 10:06:07 +0800 Subject: [PATCH 9/9] test(cognitive): tighten historian timezone and frontmatter fallbacks Assert invalid job timezones fall back to the system-local zone, and cover malformed or non-mapping YAML when reading profile updated_at. Co-authored-by: Cursor --- tests/test_cognitive_historian.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index f185e322..3ebd4be0 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -409,6 +409,8 @@ def test_extract_frontmatter_updated_at() -> None: ) 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: @@ -421,9 +423,10 @@ def test_now_in_job_timezone_uses_zoneinfo_and_same_instant() -> None: def test_now_in_job_timezone_invalid_falls_back() -> None: - now_local, now_utc, label = _now_in_job_timezone({"timezone": "Not/AZone"}) - assert label != "Not/AZone" - assert now_local.tzinfo is not 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