Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9459a1107b | ||
|
|
2c6f269170 | ||
|
|
7a2ff2f4c4 | ||
|
|
35128f2c84 | ||
|
|
f12a95ce7a | ||
|
|
2b75adc718 | ||
|
|
75ac1b01f2 | ||
|
|
16da11f788 |
No files matched your search
@@ -78,6 +78,7 @@ MEMORY_VECTOR_STORE=chroma
|
||||
|
||||
# ── 处理模式 ──
|
||||
# MEMORY_MODE=pro # lite | pro | ultra
|
||||
# MEMORY_LANGUAGE= # 提示词/记忆语言:en | zh-Hans | zh-Hant | ja | es | de | pt | fr | ko | ru | id;空=zh/en 自动检测
|
||||
|
||||
# ── Coding Memory(生产力 / 编码场景独立链路,默认开启)──
|
||||
# 独立于 chat 记忆的一套链路:写入端对 messages 判类,coding 场景抽「任务→方案」存 sqlite;
|
||||
|
||||
@@ -4,6 +4,39 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## Hy Memory 2.0.11 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.11`](https://pypi.org/project/hy-memory/2.0.11/).
|
||||
- LLM prompts now ship in 11 languages: `en`, `zh-Hans`,
|
||||
`zh-Hant`, `ja`, `es`, `de`, `pt`, `fr`, `ko`, `ru` and `id`. They live in
|
||||
one registry, `hy_memory/prompts/`, with one module per language. Call
|
||||
sites use `get_prompt(name, language)`, so a new language only needs a new
|
||||
module. The registry covers chat extraction (system prompt, basic_info,
|
||||
intentions, few-shot including Example 11), the no-intention variant,
|
||||
reconcile and its search-query prompt, the working/coding extractor, both
|
||||
coding reconcilers and the chain summary, the episode and topic summaries,
|
||||
System 2 evolve, and the user name line. The English and Simplified Chinese
|
||||
strings are unchanged byte for byte, and a test pins them. The old names
|
||||
(`prompts_zh.*_ZH`, `extractor.EXTRACT_SYSTEM_PROMPT`, ...) still import.
|
||||
The nine new languages are machine translations, and native-speaker review
|
||||
is recommended.
|
||||
- New optional `language` to choose the prompt language
|
||||
explicitly. Set it on `MemoryConfig.language` (env `MEMORY_LANGUAGE`) for
|
||||
a client default, or per call on `add` / `async_add`, `add_extracted`,
|
||||
`finalize_session`, `evolve` and `sweep`. A per-call value overrides the
|
||||
config. The value goes through every LLM call of the request: extract and
|
||||
basic profile, reconcile, the coding path, the summaries and System 2.
|
||||
Aliases: `zh`, `zh-CN` → `zh-Hans`; `zh-TW`, `zh-HK` → `zh-Hant`; `pt-BR` →
|
||||
`pt`. An unknown code raises `ValueError` listing the supported codes, and
|
||||
the HTTP server returns 400. With an explicit language, memories are
|
||||
written in that language even when the conversation uses another one.
|
||||
Without a language, the existing zh/en auto-detection runs unchanged and
|
||||
memories keep the source language. `search` has no `language`: its judge
|
||||
and rewrite prompts are English-only and keep the query's language.
|
||||
- The HTTP server accepts `language` on `/api/v1/add` and
|
||||
`/api/v1/add_extracted`.
|
||||
|
||||
## Hy Memory 2.0.10 — 2026-11-01
|
||||
|
||||
- Published
|
||||
|
||||
@@ -98,6 +98,12 @@ export OPENAI_API_KEY="sk-your-key"
|
||||
|
||||
See the [Environment Variable Reference](docs/env_reference.md) for the full list.
|
||||
|
||||
Prompts ship in 11 languages (`en`, `zh-Hans`, `zh-Hant`, `ja`, `es`, `de`, `pt`,
|
||||
`fr`, `ko`, `ru`, `id`). By default the SDK picks Chinese or English from the
|
||||
input. Set `MEMORY_LANGUAGE` (or `language=` on `add()`) to choose one
|
||||
explicitly; memories are then written in that language. See
|
||||
[Prompt language](docs/usage.md#提示词语言language).
|
||||
|
||||
### 3. Chat Memory
|
||||
|
||||
```python
|
||||
|
||||
@@ -98,6 +98,11 @@ export OPENAI_API_KEY="sk-your-key"
|
||||
|
||||
完整配置见 [环境变量参考](docs/env_reference.md)。
|
||||
|
||||
提示词提供 11 种语言(`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、
|
||||
`ko`、`ru`、`id`)。默认按输入自动在中文与英文之间选择;设置 `MEMORY_LANGUAGE`(或在
|
||||
`add()` 上传 `language=`)可显式指定,记忆随之用该语言书写。详见
|
||||
[提示词语言](docs/usage.md#提示词语言language)。
|
||||
|
||||
### 3. Chat 记忆
|
||||
|
||||
```python
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|---|---|---|
|
||||
| `MEMORY_MODE` | `pro` | `lite` / `pro` / `ultra` |
|
||||
| `MEMORY_DATA_DIR` | `~/.hy_memory` | 本地数据、缓存和日志根目录 |
|
||||
| `MEMORY_LANGUAGE` | — | LLM 提示词与记忆正文语言:`en` / `zh-Hans` / `zh-Hant` / `ja` / `es` / `de` / `pt` / `fr` / `ko` / `ru` / `id`(别名 `zh`、`zh-CN` → `zh-Hans`,`zh-TW`、`zh-HK` → `zh-Hant`,`pt-BR` → `pt`)。未设置时按输入做 zh/en 自动检测;per-call `language=` 优先 |
|
||||
|
||||
- `lite`:纯 Embedding 写入,Graph 强制关闭。
|
||||
- `pro`:LLM 提取与 reconcile;Graph 由 `MEMORY_ENABLE_GRAPH` 控制。
|
||||
|
||||
@@ -103,6 +103,7 @@ result = client.add(
|
||||
metadata=None,
|
||||
meta=None,
|
||||
embed_override=None,
|
||||
language=None,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -136,6 +137,54 @@ LLM。pro / ultra 仍遵循各自的层级与场景参数,默认不公开原
|
||||
正交:前者说明“从哪个产品/入口写入”,后者仍是记忆隔离中的 agent 身份。source 会
|
||||
执行 `strip().lower()` 后存储,并随 chat、working、coding 记忆一起返回。
|
||||
|
||||
### 提示词语言(`language`)
|
||||
|
||||
SDK 的 LLM 提示词提供 11 种语言,按语言码注册在 `hy_memory/prompts/`
|
||||
(每种语言一个模块,`get_prompt(name, language)` 统一取用):
|
||||
|
||||
| 语言码 | 语言 | 别名 |
|
||||
|---|---|---|
|
||||
| `en` | English | `en-US`、`en-GB` |
|
||||
| `zh-Hans` | 简体中文 | `zh`、`zh-CN`、`zh-SG` |
|
||||
| `zh-Hant` | 繁體中文 | `zh-TW`、`zh-HK`、`zh-MO` |
|
||||
| `ja` | 日本語 | |
|
||||
| `es` | Español | |
|
||||
| `de` | Deutsch | |
|
||||
| `pt` | Português | `pt-BR`、`pt-PT` |
|
||||
| `fr` | Français | |
|
||||
| `ko` | 한국어 | |
|
||||
| `ru` | Русский | |
|
||||
| `id` | Bahasa Indonesia | |
|
||||
|
||||
语言码大小写不敏感,`_` 与 `-` 等价;未知语言码抛 `ValueError` 并列出支持的语言码。
|
||||
|
||||
```python
|
||||
# client 默认值(也可用环境变量 MEMORY_LANGUAGE=ja)
|
||||
client = HyMemoryClient.from_config({"mode": "pro", "language": "ja"})
|
||||
|
||||
# per-call 覆盖 client 默认值
|
||||
client.add(messages, user_id="u1", language="de")
|
||||
client.add_extracted(["Der Nutzer trinkt keinen Kaffee."], user_id="u1", language="de")
|
||||
client.finalize_session(user_id="u1", session_id="s1", language="de")
|
||||
client.evolve(user_id="u1", language="de") # ultra
|
||||
client.sweep(user_ids=["u1"], language="de") # ultra
|
||||
```
|
||||
|
||||
优先级:per-call `language` > `MemoryConfig.language`(`MEMORY_LANGUAGE`)> 自动检测。
|
||||
|
||||
- **未指定语言(默认)**:行为与以前完全一致。SDK 按输入做 zh/en 自动检测,中文输入用
|
||||
简体中文提示词,其余用英文提示词;自动检测不会选到其他语言。提示词里的「输出语言与
|
||||
输入语言一致」规则仍然生效,所以记忆正文保持对话的原始语言。
|
||||
- **显式指定语言**:本次请求的全部 LLM 调用(抽取、基础画像、reconcile、working /
|
||||
coding 链路的抽取与两个 reconciler 及演变总结、episode / topic 摘要、System 2)都使用
|
||||
该语言的提示词,并在提示词末尾追加该语言的「输出语言」规则:记忆正文、摘要等自然语言
|
||||
字段一律用该语言书写,即使对话是另一种语言。JSON 键、字段名、枚举值、代码、路径、
|
||||
报错信息和专有名词保持原样;tags 仍是英文小写关键词。
|
||||
- 只有英文版的提示词(session / dynamic episode、cross-domain sweeper)在显式指定语言时
|
||||
同样追加该输出规则;搜索侧的场景判断 / query rewrite 只有英文版且保持 query 原语言,
|
||||
因此 `search()` 不接受 `language`。
|
||||
- 自定义 `extract_system_prompt_zh` / `extract_system_prompt_en` 只覆盖 `zh-Hans` / `en`。
|
||||
|
||||
### `search()` / `async_search()`
|
||||
|
||||
普通 chat 检索:
|
||||
|
||||
@@ -43,6 +43,12 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ..utils.source_filter import merge_sources
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -115,56 +121,9 @@ Messages:
|
||||
"""
|
||||
|
||||
|
||||
EPISODE_SUMMARY_PROMPT = """You are summarizing ONE episode of conversation with the user.
|
||||
|
||||
An episode is a single stretch of discussion — one thing that was talked about,
|
||||
possibly across several sessions of writing.
|
||||
|
||||
## Memories captured during this episode (oldest → newest)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
Write a single dense summary of what happened / what was discussed in this episode.
|
||||
|
||||
Rules:
|
||||
|
||||
* 2–4 sentences, third person, starting with "The user ..." (or the named person
|
||||
if the memories are about someone else).
|
||||
* Tie the memories together into one coherent account — an episode usually has a
|
||||
thread running through it (a plan, a problem, an outing, a decision).
|
||||
* Preserve concrete details: names, places, numbers, dates.
|
||||
* Do NOT invent anything that is not in the memories.
|
||||
* Do NOT enumerate the memories one by one; synthesize them.
|
||||
* Output language must match the language of the memories.
|
||||
|
||||
Return ONLY the summary text, no preamble, no JSON, no quotes."""
|
||||
|
||||
|
||||
EPISODE_SUMMARY_PROMPT_ZH = """你在为**一段讨论**(一个 episode)做总结。
|
||||
|
||||
一个 episode 是一段连续的讨论 —— 围绕一件事,可能跨越好几次写入。
|
||||
|
||||
## 这段讨论中抽取到的记忆(时间正序)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
写一段关于这次讨论内容的高密度摘要。
|
||||
|
||||
要求:
|
||||
|
||||
* 2–4 句,第三人称,以"用户…"开头(若记忆讲的是别人,则以那个人的名字开头)。
|
||||
* 把这些记忆串成一个连贯的叙述 —— 一段讨论通常有一根主线(一个计划、一个问题、
|
||||
一次出行、一个决定)。
|
||||
* 保留具体细节:人名、地点、数字、日期。
|
||||
* 绝不编造记忆里没有的内容。
|
||||
* 不要逐条罗列记忆,要综合成一段。
|
||||
* 输出语言与记忆的语言一致。
|
||||
|
||||
只返回摘要正文,不要任何前言、不要 JSON、不要引号。"""
|
||||
# 多语言版本见 hy_memory/prompts/<language>.py;旧名字保留为别名。
|
||||
from ..prompts.en import EPISODE_SUMMARY_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.zh_hans import EPISODE_SUMMARY_PROMPT as EPISODE_SUMMARY_PROMPT_ZH # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
SESSION_EPISODE_PROMPT = """Convert this complete conversation session into one retrieval-friendly episode narrative.
|
||||
@@ -596,9 +555,13 @@ def _assign_facts_to_segments(facts: List[Any], segments: List[dict]) -> Dict[in
|
||||
async def finalize_dynamic_episode_capsules(
|
||||
*, vector_store, embed_service, llm_provider, user_id: str,
|
||||
agent_id: Optional[str], session_id: str, summary_concurrency: int = 3,
|
||||
language: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Split a completed session, create compact L3 capsules, and link its facts.
|
||||
|
||||
The capsule prompt is English-only; an explicit ``language`` appends that
|
||||
language's output rule so the capsule text is written in it.
|
||||
|
||||
Boundary and summary generation are separate calls. Boundary failure degrades
|
||||
to one whole-session capsule. A segmented-summary failure also retries the
|
||||
session as one capsule so the write never loses the complete event context.
|
||||
@@ -802,6 +765,7 @@ async def finalize_dynamic_episode_capsules(
|
||||
start = end + 1
|
||||
|
||||
semaphore = asyncio.Semaphore(max(1, min(16, int(summary_concurrency or 1))))
|
||||
explicit_language = normalize_language(language)
|
||||
|
||||
async def summarize(spec):
|
||||
segment_index, segment_start, segment_end, segment = spec
|
||||
@@ -809,9 +773,13 @@ async def finalize_dynamic_episode_capsules(
|
||||
try:
|
||||
async with semaphore:
|
||||
response = await llm_provider.complete(
|
||||
prompt=DYNAMIC_CAPSULE_PROMPT.format(
|
||||
timestamp=timestamp_text,
|
||||
messages=_render_messages(segment),
|
||||
prompt=with_output_language(
|
||||
DYNAMIC_CAPSULE_PROMPT.format(
|
||||
timestamp=timestamp_text,
|
||||
messages=_render_messages(segment),
|
||||
),
|
||||
explicit_language,
|
||||
explicit=explicit_language is not None,
|
||||
),
|
||||
max_tokens=4096,
|
||||
)
|
||||
@@ -1128,9 +1096,13 @@ def _cosine(a: List[float], b: List[float]) -> float:
|
||||
async def finalize_session_episode(
|
||||
*, vector_store, embed_service, llm_provider, user_id: str,
|
||||
agent_id: Optional[str], session_id: str,
|
||||
language: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create one first-class narrative from all raw chunks in a caller session.
|
||||
|
||||
The prompt is English-only; an explicit ``language`` appends that
|
||||
language's output rule so the narrative is written in it.
|
||||
|
||||
This is deliberately explicit: the SDK cannot infer that the final write of a
|
||||
session has arrived. Repeated calls upsert the same deterministic node id.
|
||||
"""
|
||||
@@ -1213,6 +1185,10 @@ async def finalize_session_episode(
|
||||
timestamp=timestamp_text,
|
||||
conversation=conversation,
|
||||
)
|
||||
explicit_language = normalize_language(language)
|
||||
prompt = with_output_language(
|
||||
prompt, explicit_language, explicit=explicit_language is not None,
|
||||
)
|
||||
response = await llm_provider.complete(prompt=prompt, max_tokens=3000)
|
||||
narrative = (getattr(response, "content", "") or "").strip()
|
||||
if not narrative:
|
||||
@@ -1274,9 +1250,12 @@ async def summarize_closed_episode(
|
||||
max_memories: int = 60,
|
||||
temperature: Optional[float] = None,
|
||||
trace=None,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""总结一个**刚刚关闭**的 episode,返回摘要节点 id(跳过则 None)。
|
||||
|
||||
language 为显式 prompt 语言码;None 时按记忆内容做 zh/en 自动检测。
|
||||
|
||||
由 writer 在检测到 episode 滚动时 fire-and-forget 调用:此时上一个 episode
|
||||
不会再有新内容进来,所以这一次总结就是最终产物,不需要后续 merge / 重写。
|
||||
"""
|
||||
@@ -1317,6 +1296,7 @@ async def summarize_closed_episode(
|
||||
_t0 = _dt.now()
|
||||
summary, prompt, tokens = await _generate(
|
||||
llm_provider=llm_provider, memories=contents, temperature=temperature,
|
||||
language=language,
|
||||
)
|
||||
_elapsed = (_dt.now() - _t0).total_seconds() * 1000
|
||||
if trace is not None:
|
||||
@@ -1351,15 +1331,16 @@ async def summarize_closed_episode(
|
||||
|
||||
async def _generate(
|
||||
*, llm_provider, memories: List[str], temperature: Optional[float] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""返回 (摘要文本, 实际 prompt, token 用量) —— 后两个供埋点,线上要能复盘。"""
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
body = "\n".join(f"- {c}" for c in memories if c)
|
||||
if not body:
|
||||
return "", "", {}
|
||||
tpl = EPISODE_SUMMARY_PROMPT_ZH if is_chinese(body) else EPISODE_SUMMARY_PROMPT
|
||||
prompt = tpl.format(memories=body)
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, body)
|
||||
prompt = get_prompt("EPISODE_SUMMARY_PROMPT", code).format(memories=body)
|
||||
prompt = with_output_language(prompt, code, explicit=explicit is not None)
|
||||
resp = await llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=400,
|
||||
|
||||
+69
-846
@@ -20,6 +20,12 @@ import re
|
||||
|
||||
from .llm_provider import LLMProvider, _content_fingerprint, _log_llm_content_enabled
|
||||
from ..config import LLMConfig as GlobalLLMConfig
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,704 +170,13 @@ class V2ExtractResult:
|
||||
# - 不再输出 time_ref 字段。时间相关信息如果是时间敏感的(schedule / past event),
|
||||
# 让 LLM 把已解析的时间自然嵌入 content 即可;稳定偏好类 content 保持不含时间。
|
||||
|
||||
# System prompt(固定,不参与 format)
|
||||
EXTRACT_SYSTEM_PROMPT = """# ROLE
|
||||
|
||||
You are a Memory Extractor.
|
||||
|
||||
Extract durable, high-value memories from conversations and convert them into structured, self-contained statements.
|
||||
|
||||
Only extract information that is likely to matter in future conversations.
|
||||
|
||||
---
|
||||
|
||||
# INPUTS
|
||||
|
||||
You will receive:
|
||||
|
||||
* Last k Messages
|
||||
* New Messages
|
||||
* Memory Date
|
||||
* Current Date
|
||||
|
||||
Use:
|
||||
|
||||
* Last k Messages only for coreference resolution
|
||||
* Memory Date as the ONLY temporal anchor
|
||||
* Current Date only as metadata, never for resolving relative time
|
||||
|
||||
---
|
||||
|
||||
# WHAT TO EXTRACT
|
||||
|
||||
Extract ONLY genuinely new information introduced in the NEW user messages.
|
||||
|
||||
Good memories include:
|
||||
|
||||
* preferences
|
||||
* long-term projects
|
||||
* habits
|
||||
* goals
|
||||
* recurring activities
|
||||
* opinions
|
||||
* relationships
|
||||
* important experiences
|
||||
* stable traits
|
||||
* future plans
|
||||
* decisions
|
||||
* constraints
|
||||
* accepted recommendations
|
||||
|
||||
Do NOT extract:
|
||||
|
||||
* small talk
|
||||
* acknowledgements
|
||||
* generic emotions
|
||||
* temporary conversational context
|
||||
* assistant reminders of old memories
|
||||
* information already implied by prior context
|
||||
* vague personality judgments
|
||||
|
||||
---
|
||||
|
||||
# SUBJECT
|
||||
|
||||
Every memory must be self-contained, written in third person, with an explicit
|
||||
subject. Which subject to use is decided by **who spoke**:
|
||||
|
||||
| Speaker | Subject to write | Example |
|
||||
| --- | --- | --- |
|
||||
| `role: "user"` | the user | "The user ..." / "用户..." |
|
||||
| any speaker with a `name` other than the user (e.g. `name: "John"`) | that person, by name | "John ..." |
|
||||
| `role: "assistant"` with NO `name` | the AI assistant | "The assistant ..." |
|
||||
|
||||
The two input sections express the speaker differently:
|
||||
|
||||
* **New Messages** is a JSON array — read `role` and the optional `name` field.
|
||||
* **Last k Messages** is rendered text — the speaker is the bracket label,
|
||||
`[user]:` / `[John]:` / `[assistant]:`, optionally preceded by one line
|
||||
"The user's name is Tim" / "用户名叫Tim".
|
||||
|
||||
Rules:
|
||||
|
||||
* The user is the person these memories belong to. Even when the user's own name
|
||||
is known (from `name` on their message, or from the leading name line), still
|
||||
write "The user ...", NEVER "Tim ...".
|
||||
* **`role: "assistant"` WITH a `name` is another real person, not the AI.**
|
||||
Write their memories with their name as the subject: "John ...".
|
||||
* Only a nameless `role: "assistant"` is the AI assistant.
|
||||
* Never invent a name for the AI assistant, it has none.
|
||||
|
||||
---
|
||||
|
||||
# LANGUAGE RULES
|
||||
|
||||
* Output language must match input language.
|
||||
* Preserve original scripts and technical terms exactly.
|
||||
* Never transliterate names, brands, or equations.
|
||||
|
||||
---
|
||||
|
||||
# TWO KINDS OF MEMORY
|
||||
|
||||
You extract two kinds of memory: **memory** and **intentions**.
|
||||
|
||||
## memory
|
||||
|
||||
Everything durable and worth remembering about the user — and about any other
|
||||
named person in the conversation — goes here:
|
||||
|
||||
* preferences, opinions, values, attitudes
|
||||
* events, actions, experiences
|
||||
* habits, behavioral patterns
|
||||
* relationships, traits
|
||||
* objective facts, decisions, constraints
|
||||
* ongoing activities
|
||||
|
||||
Implicit preferences MUST be inferred when strongly suggested
|
||||
(e.g. resuming an activity → positive interest; avoiding something →
|
||||
negative preference; repeated voluntary effort → sustained interest).
|
||||
|
||||
Do NOT separate "what kind of person the user is" from "what happened".
|
||||
Both go into `memory`.
|
||||
|
||||
## intentions
|
||||
|
||||
A forward-looking thing the user **plans or intends to do in the future**,
|
||||
which can be acted on and which becomes irrelevant once done or past.
|
||||
|
||||
Examples:
|
||||
|
||||
* "The user has a job interview next Tuesday."
|
||||
* "The user plans to move apartments at the end of the month."
|
||||
* "The user intends to finish the report before Friday."
|
||||
|
||||
NOT intentions (these belong in `memory`):
|
||||
|
||||
* stable preferences / traits ("The user likes basketball")
|
||||
* completed past events ("The user moved to Beijing last year")
|
||||
* general goals with no actionable future step
|
||||
|
||||
### valid_until
|
||||
|
||||
For each intention, provide `valid_until`: the date after which the
|
||||
intention no longer matters (the deadline / target date), resolved
|
||||
against **Memory Date** (e.g. "next Tuesday" → that absolute date).
|
||||
If no deadline can be determined, set `valid_until` to `null`.
|
||||
|
||||
---
|
||||
|
||||
# OWNER
|
||||
|
||||
Every memory and intention MUST carry an `owner` saying which memory store it
|
||||
belongs to. There are exactly two values:
|
||||
|
||||
* `"user"` — anything belonging to the user's own memory store. This includes
|
||||
facts/preferences/traits about the user themselves, AND facts about other
|
||||
named real people in the conversation, because those are still things the
|
||||
user's memory should hold.
|
||||
* `"agent"` — information the AI assistant (a nameless `role: "assistant"`)
|
||||
provides (recommendations, confirmations, things it researched), or a future
|
||||
action the user wants the **assistant** to perform ("remind me in 8 hours").
|
||||
|
||||
A named person is NEVER `owner: "agent"` — they are a real person, not the AI,
|
||||
even though their messages carry `role: "assistant"`. Their memories are
|
||||
`owner: "user"` with their name as the content subject.
|
||||
|
||||
Decide `owner` per item. When unsure, default to `"user"`.
|
||||
|
||||
---
|
||||
|
||||
# TEMPORAL RULES
|
||||
|
||||
Resolve relative time using Memory Date.
|
||||
|
||||
Examples:
|
||||
|
||||
* "next week"
|
||||
* "last month"
|
||||
* "tomorrow"
|
||||
|
||||
If time cannot be resolved safely:
|
||||
|
||||
* remove the relative wording
|
||||
* keep the memory atemporal
|
||||
|
||||
Do not invent chronology.
|
||||
|
||||
**Just-happened events are events — keep them.** When the user reports something as
|
||||
just completed ("just got back", "just finished", "this morning", "yesterday"), the
|
||||
completion date is the Memory Date. Emit the **event itself** with that date; do not
|
||||
fold it into a background statement.
|
||||
|
||||
* "I just got back from a trip with my family"
|
||||
→ "The user returned from a family trip on <Memory Date>."
|
||||
NOT → "The user takes family trips." (the return date is the answer to "when did
|
||||
they get back", and dropping it makes the question unanswerable)
|
||||
|
||||
Where an event has both a start and an end that the conversation gives separately,
|
||||
record both rather than collapsing them into one span.
|
||||
|
||||
---
|
||||
|
||||
# QUALITY RULES
|
||||
|
||||
Each memory must be:
|
||||
|
||||
* self-contained
|
||||
* contextually complete
|
||||
* retrieval-friendly
|
||||
* information-dense
|
||||
|
||||
Retain important details:
|
||||
|
||||
* names
|
||||
* products
|
||||
* locations
|
||||
* numbers
|
||||
* equations
|
||||
* constraints
|
||||
|
||||
Never generalise a specific into a category. Write what was actually said.
|
||||
|
||||
* "Banff" → keep "Banff", NOT "the Rocky Mountains"
|
||||
* "a 3-year-old border collie named Luna" → keep the breed, age and name
|
||||
|
||||
**Specifics supplied by the other speaker still count.** A fact is often built across
|
||||
turns: the other speaker names the thing, the user confirms it. The subject rules
|
||||
decide *whose* memory it is — they do not license dropping detail.
|
||||
|
||||
* [Sam]: "Did you take advantage of the skiing opportunities in Banff?"
|
||||
[user]: "Yeah, it was great for skiing!"
|
||||
→ "The user went skiing in Banff and found the snow amazing."
|
||||
NOT → "The user enjoys winter sports." (both the place and the activity were
|
||||
established, one by each speaker)
|
||||
|
||||
Avoid fragmentary memories lacking context.
|
||||
|
||||
Choose the smallest number of memories that still preserves distinct future
|
||||
retrieval needs.
|
||||
|
||||
* Compression means combining, not omitting. Every explicit durable proposition
|
||||
in the new messages must appear exactly once in the output.
|
||||
* Combine details when they share the same subject and the same real-world event,
|
||||
state, preference, plan, or decision. A coherent event may use 1–3 sentences.
|
||||
* Do not emit both a broad statement and narrower restatements of the same evidence.
|
||||
* Split only when the subject differs, the events happened independently, or the
|
||||
memories describe different states.
|
||||
* Keep a cause, recommendation, or consequence with the fact it explains when
|
||||
separating them would lose the relationship.
|
||||
* Never merge separate dated occurrences merely because they share a topic.
|
||||
* Preserve the source's time precision. Do not turn vague phrases such as "last
|
||||
weekend" or "last summer" into exact calendar dates unless the date is explicit
|
||||
or uniquely determined; "the weekend before <Memory Date>" is acceptable.
|
||||
|
||||
---
|
||||
|
||||
# TAGS
|
||||
|
||||
Each memory must include 1–3 concise tags.
|
||||
- Topical keywords stay lowercase (travel, health, work).
|
||||
- Proper nouns keep capitalization (Jasper, Canada, Banff, Prius).
|
||||
|
||||
Examples:
|
||||
|
||||
* work
|
||||
* travel
|
||||
* Jasper
|
||||
* Canada
|
||||
* health
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
STRUCTURE_METADATA_PROMPT_EN = """
|
||||
|
||||
---
|
||||
|
||||
# STRUCTURAL ORGANIZATION
|
||||
|
||||
For every `memory`, also output:
|
||||
|
||||
* `topic_paths`: a backward-compatible list of zero to three broad-to-specific
|
||||
semantic paths.
|
||||
* `event_facet`: an object with only `subject`, `participants`, `action`,
|
||||
`objects`, `locations`, and `state`; state is `planned`, `ongoing`,
|
||||
`completed`, or `unknown`.
|
||||
* `topic_assignments`: zero to six subject-specific memberships. Each item is
|
||||
`{subject, role, path}`, where `path` is a list of `{name, type}` segments and
|
||||
`type` is one of `topic`, `facet`, `series`, or `episode`. Use `topic` for a
|
||||
broad domain, `facet` for a concrete activity/semantic aspect, `series` only
|
||||
for repeated occurrences of the same concrete activity/state line, and
|
||||
`episode` only for one bounded real-world occurrence. A shared event may have
|
||||
one assignment per explicit participant while keeping one canonical memory.
|
||||
Types follow `topic -> facet -> series -> episode`; levels may be skipped but
|
||||
types may not repeat or move backward.
|
||||
|
||||
This extractor sees only the current write and therefore must not emit a
|
||||
`series` segment. Emit Topic/Facet/Episode here. Reconcile may later establish a
|
||||
Series after comparing independent stored occurrences.
|
||||
|
||||
This is write-time organization. Do not infer Series/Episode from path depth,
|
||||
manufacture hierarchy from a proper noun or broad tag, or assign a person only
|
||||
because they co-occur in unrelated context. Stop at the most specific supported
|
||||
level and use empty values when uncertain. Never encode query forms such as
|
||||
"when", "how many", "who", plurality, or ordinal wording.
|
||||
"""
|
||||
|
||||
# Few-shot examples (appended to the system prompt only when few_shot_enabled).
|
||||
# Each example mirrors the real input shape (Last k Messages / New Messages /
|
||||
# Memory Date) and our output schema (memory / intentions / basic_info with
|
||||
# owner & tags), and ends with a one-line takeaway.
|
||||
EXTRACT_FEW_SHOT_EN = """
|
||||
---
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
## Example 1 — Boundary: old context is for resolution only, never re-extracted
|
||||
|
||||
Last k Messages:
|
||||
[{"role": "user", "content": "I've been looking at rescue dogs at the shelter downtown."},
|
||||
{"role": "assistant", "content": "That's lovely — found one you like?"}]
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Yeah, picked her up yesterday — a 3-year-old border collie named Luna."}]
|
||||
Memory Date: 2025-04-10
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user adopted a 3-year-old border collie named Luna from a downtown shelter on 2025-04-09.", "tags": ["pets", "family"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The old messages only resolve "her" and supply context ("downtown shelter"). Do NOT emit a stale memory like "the user is considering adopting a dog" — only the genuinely new fact (the completed adoption) is extracted, with "yesterday" resolved against Memory Date.
|
||||
|
||||
## Example 2 — Condense a long assistant reply
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Write me a short essay on why remote work boosts productivity."},
|
||||
{"role": "assistant", "content": "Sure — opening: 'Remote work has reshaped how teams operate...' [~2000 words of body omitted] ...closing: 'In short, autonomy and fewer interruptions drive higher output.'"}]
|
||||
Memory Date: 2025-03-22
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The assistant drafted a short essay for the user arguing that remote work boosts productivity because autonomy and fewer interruptions drive higher output.", "tags": ["writing", "remote-work"], "owner": "agent"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The assistant's reply is long. Never store the verbatim essay — distill it to topic + thesis. owner is "agent" because the assistant produced it. Bloated verbatim content makes memory unusable.
|
||||
|
||||
## Example 3 — Composite extraction: basic_info + intention + condensed agent plan
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Hi, I'm Kenji. I'm planning a trip to Japan next month — could you put together a 7-day itinerary?"},
|
||||
{"role": "assistant", "content": "# 7-Day Classic Japan Golden Route (Tokyo → Kyoto → Nara → Osaka)\\nFly into Tokyo, Shinkansen south to Kyoto, day-trip Nara, depart from Osaka (Kansai).\\n\\n## Day 1: Arrival in Tokyo\\n- Land at Haneda/Narita, drop bags at hotel near Tokyo Station.\\n- Late afternoon: Sensoji Temple (Asakusa), wander Nakamise Street, try taiyaki.\\n- Evening: Shibuya Crossing + Shibuya Sky rooftop for sunset; izakaya dinner (yakitori, sake).\\n\\n## Day 2: Tokyo markets & landmarks — [omitted]\\n## Day 3: Shinkansen to Kyoto, Fushimi Inari, Gion — [omitted]\\n## Day 4: Kyoto temples (Kiyomizu-dera, Kinkaku-ji, Ginkaku-ji) — [omitted]\\n## Day 5: Nara day trip (Nara Park deer, Todai-ji) — [omitted]\\n## Day 6: Osaka (Osaka Castle, Dotonbori street food) — [omitted]\\n## Day 7: Osaka sightseeing + depart from Kansai Airport — [omitted]\\n\\nTip: buy a 7-day JR Pass before arriving. Want me to adjust anything?"}]
|
||||
Memory Date: 2025-09-15
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The assistant created a 7-day Japan itinerary for the user along the classic golden route: Tokyo (Asakusa, Shibuya), Shinkansen to Kyoto (Fushimi Inari, Kiyomizu-dera, Kinkaku-ji, Gion), a Nara day trip (deer park, Todai-ji), then Osaka (Osaka Castle, Dotonbori), departing from Kansai Airport.", "tags": ["travel", "japan", "itinerary"], "owner": "agent"}
|
||||
],
|
||||
"intentions": [
|
||||
{"content": "The user plans to take a 7-day trip to Japan in October 2025.", "tags": ["travel", "japan"], "valid_until": "2025-10-31", "owner": "user"}
|
||||
],
|
||||
"basic_info": {"name": "Kenji"}
|
||||
}
|
||||
```
|
||||
One turn produces three outputs in different buckets: the name goes to basic_info, the trip is a forward-looking intention with a deadline, and the assistant's long multi-section itinerary is condensed into ONE dense agent memory — the route and key stops per city, NOT every day's bullets, tips, or food lists. Storing the whole reply verbatim would bloat memory; storing the skeleton keeps it queryable.
|
||||
|
||||
## Example 4 — A later message confirming/adopting an earlier recommendation is itself a fact
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "My 6-year-old won't eat any vegetables. Any ideas?"},
|
||||
{"role": "assistant", "content": "Try blending spinach or carrots into pasta sauce — the color and flavor mostly disappear, and many kids accept it without noticing."},
|
||||
{"role": "user", "content": "Oh nice, we'll try that tonight. By the way, what's a good bedtime for that age?"},
|
||||
{"role": "assistant", "content": "For a 6-year-old, aim for 7–9 PM with about 10–11 hours of sleep."}]
|
||||
Memory Date: 2025-06-20
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user has a 6-year-old child who refuses to eat vegetables, and accepted the assistant's suggestion to blend spinach or carrots into pasta sauce to hide them.", "tags": ["parenting", "food"], "owner": "user"},
|
||||
{"content": "The assistant advised the user that a 6-year-old should aim for a 7–9 PM bedtime with about 10–11 hours of sleep.", "tags": ["parenting", "sleep"], "owner": "agent"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
In the third message the user said "we'll try that tonight" — explicitly adopting the earlier suggestion. That acceptance is valuable and must be recorded ("accepted the assistant's suggestion to ..."), not discarded as small talk. The bedtime advice was just given and not yet adopted, so it is recorded as advice (owner "agent").
|
||||
|
||||
## Example 5 — RAG/injected context: never store the injected text
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "[Retrieved context: The Eiffel Tower is 330m tall and was completed in 1889.] Given the above, I'm going to propose to my girlfriend at the top of the Eiffel Tower this summer."}]
|
||||
Memory Date: 2025-05-02
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [
|
||||
{"content": "The user plans to propose to his girlfriend at the top of the Eiffel Tower in summer 2025.", "tags": ["relationship", "travel"], "valid_until": "2025-08-31", "owner": "user"}
|
||||
],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The bracketed retrieved context (the tower's height and year) is RAG-injected reference material, NOT something the user told us about themselves — do NOT store it. Only the user's own forward-looking plan is extracted. System/RAG-injected text is context for the model, never a memory.
|
||||
|
||||
## Example 6 — Shared reference material: extract the content, not the sharing action
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Remember my grandma's dumpling recipe so I don't lose it. Filling: 500g ground pork, 300g napa cabbage, 2 tbsp soy sauce, 1 tbsp sesame oil, a thumb of ginger minced. Cabbage salted 15 min then squeezed dry. Boil dumplings, add cold water twice, ready when they float."},
|
||||
{"role": "assistant", "content": "Got it, saved!"}]
|
||||
Memory Date: 2025-07-01
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user's grandmother's dumpling recipe: filling of 500g ground pork, 300g napa cabbage (salted 15 minutes then squeezed dry), 2 tbsp soy sauce, 1 tbsp sesame oil, and a thumb of minced ginger; boil the dumplings, add cold water twice, and they are done when they float.", "tags": ["food", "recipe", "family"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
Extract the actual factual content (ingredients, quantities, the salt-and-squeeze step, the boiling method) — NOT "the user shared a recipe" or "the user asked to remember the recipe". The sharing action is worthless; the content is what's queryable.
|
||||
|
||||
## Example 7 — Structured numbers and attributes preserved exactly
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Logging my home-gym PRs this cycle: Squat 140kg x 3, Bench 100kg x 5, Deadlift 180kg x 2, Overhead Press 60kg x 6. Bodyweight 78kg, 3 sets per lift, 3 min rest."},
|
||||
{"role": "assistant", "content": "Logged — strong numbers!"}]
|
||||
Memory Date: 2026-01-20
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user's current lifting PRs are Squat 140kg x 3, Bench 100kg x 5, Deadlift 180kg x 2, and Overhead Press 60kg x 6, at a bodyweight of 78kg.", "tags": ["fitness", "strength"], "owner": "user"},
|
||||
{"content": "The user trains with 3 sets per lift and 3 minutes of rest between sets.", "tags": ["fitness", "training"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
Every number and unit (kg, reps, sets, rest time) is preserved exactly — dropping them would destroy the most queryable information. Split into two clean topics (PRs vs. training protocol), but never round or omit the specifics.
|
||||
|
||||
## Example 8 — Long multi-topic conversation: do not stop after the first topic
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Big week — I finally defended my PhD thesis on Tuesday!"},
|
||||
{"role": "assistant", "content": "Huge congrats! How do you feel?"},
|
||||
{"role": "user", "content": "Relieved! I also signed a lease on an apartment in Berlin and move next month. And I've started learning German on Duolingo — 30-day streak. My partner Sofia is staying in Madrid for now, which is hard."}]
|
||||
Memory Date: 2025-11-08
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user defended their PhD thesis on 2025-11-04.", "tags": ["education", "milestone"], "owner": "user"},
|
||||
{"content": "The user signed a lease on an apartment in Berlin.", "tags": ["housing"], "owner": "user"},
|
||||
{"content": "The user has been learning German on Duolingo and has a 30-day streak.", "tags": ["language", "learning"], "owner": "user"},
|
||||
{"content": "The user's partner Sofia is staying in Madrid for now, which the user finds hard.", "tags": ["relationship"], "owner": "user"}
|
||||
],
|
||||
"intentions": [
|
||||
{"content": "The user plans to move to Berlin next month (around December 2025).", "tags": ["housing", "relocation"], "valid_until": "2025-12-31", "owner": "user"}
|
||||
],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
Four+ topics — thesis defense, the Berlin apartment, learning German, and the partner in Madrid. Do not stop after the first topic. Note one turn split into a completed fact (lease signed) AND an intention (the upcoming move), and the emotional-but-substantive detail about Sofia is kept because it carries lasting relationship context.
|
||||
|
||||
## Example 9 — Two real people: `name` on an assistant message means a real person
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "name": "Tim", "content": "Just got back from the climbing gym, third time this week. Finally sent that overhang route."},
|
||||
{"role": "assistant", "name": "John", "content": "Nice! I've been swimming instead — my shoulder still hurts from last month."}]
|
||||
Memory Date: 2025-08-14
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user has been climbing three times a week and recently completed a difficult overhang route.", "tags": ["fitness", "climbing"], "owner": "user"},
|
||||
{"content": "John has been swimming instead of other exercise because his shoulder still hurt after an injury around July 2025.", "tags": ["fitness", "health"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {"name": "Tim"}
|
||||
}
|
||||
```
|
||||
John carries `role: "assistant"` but he has a `name`, so he is another real person, NOT the AI: his memory uses "John ..." as the subject and is still `owner: "user"` (it belongs to the user's memory store). The user's own memory stays "The user ..." even though we know the name is Tim — never "Tim has been climbing". `basic_info.name` comes from `name: "Tim"` on the user message, which is authoritative even though Tim never said his own name out loud.
|
||||
|
||||
## Example 10 — Counter-example: another person's attributes never go into basic_info
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "name": "Tim", "content": "你最近在忙什么?"},
|
||||
{"role": "assistant", "name": "John", "content": "我在柏林一家医院当护士,上的是夜班,挺累的。"}]
|
||||
Memory Date: 2025-09-02
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "John是柏林一家医院的护士,上夜班,觉得很累。", "tags": ["work", "health"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {"name": "Tim"}
|
||||
}
|
||||
```
|
||||
John's occupation and location are HIS attributes, so they go into `memory` with "John" as the subject. They must NEVER land in `basic_info.occupation` / `basic_info.location` — `basic_info` is exclusively the user's own profile. Only `name` (Tim, from the user message's `name` field) belongs there.
|
||||
|
||||
## Example 11 — Counter-example: small talk has no basic_info
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello! The user's name is The user. How can I help?"}]
|
||||
Memory Date: 2025-09-03
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The user stated nothing about themselves. The assistant's line is not a user turn, and "The user" is a placeholder, not a name. Output `{}` — never `{"name": "The user"}` or `{"name": "user"}`.
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
EXTRACT_PROMPT = """## Last k Messages
|
||||
|
||||
{last_messages}
|
||||
|
||||
---
|
||||
|
||||
## New Messages
|
||||
|
||||
{content}
|
||||
|
||||
---
|
||||
|
||||
## Memory Date
|
||||
|
||||
{memory_at}
|
||||
|
||||
---
|
||||
|
||||
## Current Date
|
||||
|
||||
{current_date}
|
||||
|
||||
---
|
||||
|
||||
# Output Format
|
||||
|
||||
Return ONLY a JSON object inside a ```json fenced block.
|
||||
|
||||
```json
|
||||
{{
|
||||
"memory": [
|
||||
{{
|
||||
"content": "...",
|
||||
"tags": ["..."],
|
||||
"owner": "user"
|
||||
}}
|
||||
],
|
||||
"intentions": [
|
||||
{{
|
||||
"content": "...",
|
||||
"tags": ["..."],
|
||||
"valid_until": "YYYY-MM-DD",
|
||||
"owner": "user"
|
||||
}}
|
||||
],
|
||||
"basic_info": {{}}
|
||||
}}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* `memory` = durable info worth remembering: preferences, opinions, attitudes,
|
||||
traits, events, plans, experiences, habits, decisions — about the user or
|
||||
about any other named real person, with the subject written per "# SUBJECT"
|
||||
* `intentions` = forward-looking things the user (or another named person) plans /
|
||||
intends to do in the future, with `valid_until` = the deadline/target date
|
||||
(resolved against Memory Date) or `null` if none
|
||||
* `owner` (required on every memory & intention) = `"user"` or `"agent"`
|
||||
(see "# OWNER" above; default `"user"` when unsure)
|
||||
* `basic_info` = the **user's own profile only**. Include stable profile
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
* NEVER put another real person's attributes here. If John (an assistant-role
|
||||
message carrying `name`) says "I'm a nurse in Berlin", that is a `memory`
|
||||
("John is a nurse in Berlin."), NOT `basic_info.occupation` / `.location`.
|
||||
* No duplicate information across sections
|
||||
* Tags must contain 1–3 concise lowercase keywords
|
||||
* Use empty arrays `[]` or `{{}}` when nothing is extracted
|
||||
* Return valid JSON only
|
||||
|
||||
Output the JSON now."""
|
||||
|
||||
|
||||
# User prompt when MEMORY_ENABLE_INTENTION=false(无 intentions 桶)
|
||||
EXTRACT_PROMPT_NO_INTENTION = """## Last k Messages
|
||||
|
||||
{last_messages}
|
||||
|
||||
---
|
||||
|
||||
## New Messages
|
||||
|
||||
{content}
|
||||
|
||||
---
|
||||
|
||||
## Memory Date
|
||||
|
||||
{memory_at}
|
||||
|
||||
---
|
||||
|
||||
## Current Date
|
||||
|
||||
{current_date}
|
||||
|
||||
---
|
||||
|
||||
# Output Format
|
||||
|
||||
Return ONLY a JSON object inside a ```json fenced block.
|
||||
|
||||
```json
|
||||
{{
|
||||
"memory": [
|
||||
{{
|
||||
"content": "...",
|
||||
"tags": ["..."],
|
||||
"owner": "user"
|
||||
}}
|
||||
],
|
||||
"basic_info": {{}}
|
||||
}}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* `memory` = durable info worth remembering: preferences, opinions, attitudes,
|
||||
traits, events, experiences, habits, decisions, **and future plans / upcoming
|
||||
actions** — about the user or about any other named real person, with the
|
||||
subject written per "# SUBJECT". Do **not** invent a separate intentions bucket.
|
||||
* `owner` (required on every memory) = `"user"` or `"agent"`
|
||||
(see "# OWNER" above; default `"user"` when unsure)
|
||||
* `basic_info` = the **user's own profile only**. Include stable profile
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
* NEVER put another real person's attributes here. If John (an assistant-role
|
||||
message carrying `name`) says "I'm a nurse in Berlin", that is a `memory`
|
||||
("John is a nurse in Berlin."), NOT `basic_info.occupation` / `.location`.
|
||||
* No duplicate information across sections
|
||||
* Tags: 1–3 concise keywords; topical tags lowercase; proper nouns keep capitalization
|
||||
* Use empty arrays `[]` or `{{}}` when nothing is extracted
|
||||
* Return valid JSON only — **never** include an `intentions` key
|
||||
|
||||
Output the JSON now."""
|
||||
# Prompt 文本统一放在 hy_memory/prompts/<language>.py,按语言码经 get_prompt()
|
||||
# 取用。以下旧名字保留为英文版的别名(向后兼容)。
|
||||
from ..prompts.en import EXTRACT_SYSTEM_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import STRUCTURE_METADATA_PROMPT as STRUCTURE_METADATA_PROMPT_EN # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import EXTRACT_FEW_SHOT as EXTRACT_FEW_SHOT_EN # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import EXTRACT_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import EXTRACT_PROMPT_NO_INTENTION # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -870,101 +185,26 @@ Output the JSON now."""
|
||||
|
||||
|
||||
def _system_prompt_without_intention(system_prompt: str, *, lang: str) -> str:
|
||||
"""从 system prompt 去掉 intentions 分桶,改为单一 memory(含未来计划)。"""
|
||||
"""从 system prompt 去掉 intentions 分桶,改为单一 memory(含未来计划)。
|
||||
|
||||
``lang`` 是语言码(``en`` / ``zh-Hans`` / ``ja`` ...;旧值 ``zh`` 视作 zh-Hans)。
|
||||
每种语言的替换片段在 ``hy_memory/prompts/<lang>.py`` 中定义。
|
||||
"""
|
||||
import re
|
||||
|
||||
if lang == "zh":
|
||||
repl = (
|
||||
"# 记忆\n"
|
||||
"\n"
|
||||
"你只提取一类记忆:**memory(记忆)**。\n"
|
||||
"\n"
|
||||
"## memory(记忆)\n"
|
||||
"\n"
|
||||
"一切值得长期记住的信息都放这里——既包括用户本人的,也包括对话中其他具名真人的:\n"
|
||||
"\n"
|
||||
"* 偏好、观点、态度、价值观\n"
|
||||
"* 事件、行动、经历\n"
|
||||
"* 习惯、行为模式\n"
|
||||
"* 关系、稳定特质\n"
|
||||
"* 客观事实、决策、约束条件\n"
|
||||
"* 正在进行的活动\n"
|
||||
"* **未来计划 / 即将发生的安排**(若值得记住,直接写进 `memory`;"
|
||||
"不要单独开 intentions 桶)\n"
|
||||
"\n"
|
||||
"如果用户隐式表达偏好,也应提取。\n"
|
||||
"\n"
|
||||
"不要把\"用户是怎样的人\"和\"发生了什么\"分开。两者都放进 `memory`。\n"
|
||||
"\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
)
|
||||
system_prompt = re.sub(
|
||||
r"# 两类记忆.*?(?=\n# OWNER)",
|
||||
repl,
|
||||
system_prompt,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
system_prompt = system_prompt.replace(
|
||||
"每条 memory 和 intention 都必须带 `owner`",
|
||||
"每条 memory 都必须带 `owner`",
|
||||
)
|
||||
system_prompt = system_prompt.replace(
|
||||
"每条 memory 和 intention 都必须带",
|
||||
"每条 memory 都必须带",
|
||||
)
|
||||
# few-shot 里若仍出现 intentions,用硬约束盖住
|
||||
system_prompt += (
|
||||
"\n\n---\n\n# INTENTION 关闭\n"
|
||||
"当前配置关闭了 intention 抽取。即使示例里出现 `intentions`,"
|
||||
"也**不要**输出该字段;未来计划写入 `memory`。\n"
|
||||
)
|
||||
else:
|
||||
repl = (
|
||||
"# MEMORY\n"
|
||||
"\n"
|
||||
"You extract one kind of memory: **memory**.\n"
|
||||
"\n"
|
||||
"## memory\n"
|
||||
"\n"
|
||||
"Everything durable and worth remembering about the user — and about any other\n"
|
||||
"named person in the conversation — goes here:\n"
|
||||
"\n"
|
||||
"* preferences, opinions, values, attitudes\n"
|
||||
"* events, actions, experiences\n"
|
||||
"* habits, behavioral patterns\n"
|
||||
"* relationships, traits\n"
|
||||
"* objective facts, decisions, constraints\n"
|
||||
"* ongoing activities\n"
|
||||
"* **future plans / upcoming actions** that are worth remembering\n"
|
||||
" (put them in `memory`; do NOT invent a separate intentions bucket)\n"
|
||||
"\n"
|
||||
"Implicit preferences MUST be inferred when strongly suggested.\n"
|
||||
"\n"
|
||||
"Do NOT separate \"what kind of person the user is\" from \"what happened\".\n"
|
||||
"Both go into `memory`.\n"
|
||||
"\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
)
|
||||
system_prompt = re.sub(
|
||||
r"# TWO KINDS OF MEMORY.*?(?=\n# OWNER\n)",
|
||||
repl,
|
||||
system_prompt,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
system_prompt = system_prompt.replace(
|
||||
"Every memory and intention MUST carry an `owner`",
|
||||
"Every memory MUST carry an `owner`",
|
||||
)
|
||||
system_prompt += (
|
||||
"\n\n---\n\n# INTENTION DISABLED\n"
|
||||
"Intention extraction is disabled. Even if few-shot examples show "
|
||||
"`intentions`, do **not** emit that key; put future plans in `memory`.\n"
|
||||
)
|
||||
return system_prompt
|
||||
language = "zh-Hans" if lang == "zh" else lang
|
||||
repl = get_prompt("NO_INTENTION_MEMORY_SECTION", language)
|
||||
system_prompt = re.sub(
|
||||
get_prompt("NO_INTENTION_SECTION_PATTERN", language),
|
||||
lambda _m: repl,
|
||||
system_prompt,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
for old, new in get_prompt("NO_INTENTION_REPLACEMENTS", language):
|
||||
system_prompt = system_prompt.replace(old, new)
|
||||
# few-shot 里若仍出现 intentions,用硬约束盖住
|
||||
return system_prompt + get_prompt("INTENTION_DISABLED_NOTE", language)
|
||||
|
||||
|
||||
class Extractor:
|
||||
@@ -1001,6 +241,7 @@ class Extractor:
|
||||
tool_context: Optional[Dict[str, Any]] = None,
|
||||
history_context: str = "",
|
||||
basic_profile_fields: Optional[Dict[str, str]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> ExtractResult:
|
||||
"""
|
||||
统一提取:identity / facts / basic_info
|
||||
@@ -1022,6 +263,9 @@ class Extractor:
|
||||
**DEPRECATED**: 曾用于渲染 EXTRACT prompt 的基础画像 schema 段落,
|
||||
现已移除该段落(basic_info 由内置 few-shot / 指令引导)。保留参数
|
||||
仅为向后兼容,传入不再生效。
|
||||
language: 显式 prompt 语言码(见 hy_memory.prompts.SUPPORTED_LANGUAGES,
|
||||
接受别名)。None 时按 content 做 zh/en 自动检测。显式指定时
|
||||
追加 OUTPUT_LANGUAGE_DIRECTIVE,记忆正文用该语言书写。
|
||||
"""
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
|
||||
@@ -1048,73 +292,52 @@ class Extractor:
|
||||
else:
|
||||
_memory_at = _current_date
|
||||
|
||||
# Select prompt based on input language
|
||||
from ..utils.lang_detect import is_chinese
|
||||
# 选 prompt 语言:显式 language(per-call / MemoryConfig.language)优先;
|
||||
# 未指定时沿用 zh/en 自动检测(行为与以前完全一致)。
|
||||
_explicit_language = normalize_language(language)
|
||||
_language = resolve_language(_explicit_language, content)
|
||||
|
||||
_enable_intention = bool(
|
||||
getattr(self._llm_config, "enable_intention", True)
|
||||
)
|
||||
if is_chinese(content):
|
||||
from .prompts_zh import (
|
||||
EXTRACT_PROMPT_ZH,
|
||||
EXTRACT_PROMPT_ZH_NO_INTENTION,
|
||||
EXTRACT_SYSTEM_PROMPT_ZH,
|
||||
EXTRACT_FEW_SHOT_ZH,
|
||||
)
|
||||
|
||||
# client 自定义 system prompt 仅覆盖对应语言(zh → zh-Hans, en → en);
|
||||
# 直接用,不追加内置 few-shot
|
||||
_custom_sys = None
|
||||
if _language == "zh-Hans":
|
||||
_custom_sys = getattr(
|
||||
self._llm_config, "extract_system_prompt_zh", None
|
||||
)
|
||||
if _custom_sys:
|
||||
# client 自定义 system prompt:直接用,不追加内置 few-shot
|
||||
system_prompt = _custom_sys
|
||||
else:
|
||||
system_prompt = EXTRACT_SYSTEM_PROMPT_ZH
|
||||
if getattr(self._llm_config, "few_shot_enabled", False):
|
||||
system_prompt = system_prompt + EXTRACT_FEW_SHOT_ZH
|
||||
if not _enable_intention and not _custom_sys:
|
||||
system_prompt = _system_prompt_without_intention(
|
||||
system_prompt, lang="zh"
|
||||
)
|
||||
if getattr(self._llm_config, "structure_metadata_enabled", False):
|
||||
from .prompts_zh import STRUCTURE_METADATA_PROMPT_ZH
|
||||
system_prompt += STRUCTURE_METADATA_PROMPT_ZH
|
||||
_user_tmpl = (
|
||||
EXTRACT_PROMPT_ZH
|
||||
if _enable_intention
|
||||
else EXTRACT_PROMPT_ZH_NO_INTENTION
|
||||
)
|
||||
prompt = _user_tmpl.format(
|
||||
content=content,
|
||||
current_date=_current_date,
|
||||
memory_at=_memory_at,
|
||||
last_messages=_last_messages,
|
||||
)
|
||||
else:
|
||||
elif _language == "en":
|
||||
_custom_sys = getattr(
|
||||
self._llm_config, "extract_system_prompt_en", None
|
||||
)
|
||||
if _custom_sys:
|
||||
system_prompt = _custom_sys
|
||||
else:
|
||||
system_prompt = EXTRACT_SYSTEM_PROMPT
|
||||
if getattr(self._llm_config, "few_shot_enabled", False):
|
||||
system_prompt = system_prompt + EXTRACT_FEW_SHOT_EN
|
||||
if not _enable_intention and not _custom_sys:
|
||||
system_prompt = _system_prompt_without_intention(
|
||||
system_prompt, lang="en"
|
||||
if _custom_sys:
|
||||
system_prompt = _custom_sys
|
||||
else:
|
||||
system_prompt = get_prompt("EXTRACT_SYSTEM_PROMPT", _language)
|
||||
if getattr(self._llm_config, "few_shot_enabled", False):
|
||||
system_prompt = system_prompt + get_prompt(
|
||||
"EXTRACT_FEW_SHOT", _language
|
||||
)
|
||||
if getattr(self._llm_config, "structure_metadata_enabled", False):
|
||||
system_prompt += STRUCTURE_METADATA_PROMPT_EN
|
||||
_user_tmpl = (
|
||||
EXTRACT_PROMPT if _enable_intention else EXTRACT_PROMPT_NO_INTENTION
|
||||
)
|
||||
prompt = _user_tmpl.format(
|
||||
content=content,
|
||||
current_date=_current_date,
|
||||
memory_at=_memory_at,
|
||||
last_messages=_last_messages,
|
||||
if not _enable_intention and not _custom_sys:
|
||||
system_prompt = _system_prompt_without_intention(
|
||||
system_prompt, lang=_language
|
||||
)
|
||||
if getattr(self._llm_config, "structure_metadata_enabled", False):
|
||||
system_prompt += get_prompt("STRUCTURE_METADATA_PROMPT", _language)
|
||||
system_prompt = with_output_language(
|
||||
system_prompt, _language, explicit=_explicit_language is not None,
|
||||
)
|
||||
_user_tmpl = get_prompt(
|
||||
"EXTRACT_PROMPT" if _enable_intention else "EXTRACT_PROMPT_NO_INTENTION",
|
||||
_language,
|
||||
)
|
||||
prompt = _user_tmpl.format(
|
||||
content=content,
|
||||
current_date=_current_date,
|
||||
memory_at=_memory_at,
|
||||
last_messages=_last_messages,
|
||||
)
|
||||
|
||||
# 累计 token 统计
|
||||
if _log_llm_content_enabled():
|
||||
|
||||
@@ -144,6 +144,7 @@ class MemAgent:
|
||||
tool_context: Optional[Dict[str, Any]] = None,
|
||||
history_context: str = "",
|
||||
basic_profile_fields: Optional[Dict[str, str]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> AgentResult:
|
||||
"""
|
||||
处理添加记忆请求
|
||||
@@ -161,6 +162,7 @@ class MemAgent:
|
||||
basic_profile_fields:
|
||||
{field_name: description} 字段表,由调用方从 MemoryConfig.basic_profile.fields
|
||||
透传过来,extractor 会渲染到 prompt 中要求 LLM 在 JSON basic_info 字段返回。
|
||||
language: 显式 prompt 语言码,透传给 extractor;None 时 zh/en 自动检测。
|
||||
|
||||
Returns:
|
||||
处理结果
|
||||
@@ -187,6 +189,7 @@ class MemAgent:
|
||||
tool_context=tool_context,
|
||||
history_context=history_context,
|
||||
basic_profile_fields=basic_profile_fields,
|
||||
language=language,
|
||||
)
|
||||
|
||||
_t_parallel = datetime.now()
|
||||
|
||||
+15
-1229
File diff suppressed because it is too large.
Load diff
+49
-450
@@ -160,299 +160,18 @@ class ReconcileResult:
|
||||
# Prompts
|
||||
# ================================================================
|
||||
|
||||
SEARCH_QUERY_PROMPT = """You are a search query generator. Given a list of newly extracted memories, generate a set of short search queries that can be used to find related existing memories in a vector database.
|
||||
|
||||
The goal is to maximize recall — find existing memories that are semantically related to the new memories, even if the wording is very different.
|
||||
|
||||
## New memories:
|
||||
{new_memories}
|
||||
|
||||
## Instructions
|
||||
|
||||
Generate search queries that cover:
|
||||
- Key topics, entities, and themes mentioned in the memories
|
||||
- Rephrased or abstracted versions of the core concepts
|
||||
- Related concepts that might exist in the user's memory store
|
||||
|
||||
Output a JSON array of query strings (5-15 queries, short and focused):
|
||||
|
||||
["query1", "query2", "query3", ...]
|
||||
|
||||
Output JSON array only, no other text."""
|
||||
|
||||
RECONCILE_PROMPT = """You are a memory management system.
|
||||
|
||||
Your task is to merge new memories into an existing memory base while keeping it:
|
||||
|
||||
* lossless
|
||||
* non-contradictory
|
||||
* compact
|
||||
* highly retrievable
|
||||
|
||||
---
|
||||
|
||||
# Operations
|
||||
|
||||
## ADD
|
||||
|
||||
Use when the information is entirely new.
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "ADD",
|
||||
"content": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UPDATE
|
||||
|
||||
Use when:
|
||||
|
||||
* the new memory is compatible with an existing one
|
||||
* they describe the same topic
|
||||
* merging improves completeness or retrievability
|
||||
|
||||
Rules:
|
||||
|
||||
* merge losslessly
|
||||
* preserve all useful information
|
||||
* do NOT create duplicates
|
||||
* if the two disagree on a value, keep the value from the memory with the later
|
||||
`memory_at`; never let an older memory overwrite a newer value
|
||||
|
||||
### Same occurrence vs. another occurrence
|
||||
|
||||
Use **UPDATE** only when the new memory adds detail to the same real-world
|
||||
occurrence or durable fact already recorded.
|
||||
|
||||
If the same person does the same kind of activity/event again at another time
|
||||
(another trip, game, injury, purchase, health scare, etc.), keep the occurrence
|
||||
as a separate **ADD**. Preserve its own date and details. When an existing memory
|
||||
is clearly another occurrence in that same concrete series, link it with
|
||||
`related: [{{"memory_id":"...","relation":"same_series"}}]`.
|
||||
|
||||
Do not merge separate dated occurrences into one long count/timeline memory.
|
||||
The retrieval layer builds timelines from the linked L2 memories.
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "UPDATE",
|
||||
"memory_id": "...",
|
||||
"content": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SUPERSEDE
|
||||
|
||||
Use ONLY when:
|
||||
|
||||
* the new memory contradicts an existing memory
|
||||
* both cannot be true at the same time
|
||||
* the old memory would now give the wrong current-state answer
|
||||
* the new memory's `memory_at` is the same as or LATER than the existing memory's `memory_at`
|
||||
|
||||
Examples:
|
||||
|
||||
* city changed
|
||||
* employer changed
|
||||
* relationship status changed
|
||||
|
||||
Do NOT use for:
|
||||
|
||||
* added detail
|
||||
* compatible preferences
|
||||
* wording differences
|
||||
* semantic equivalence
|
||||
* a new memory whose `memory_at` is EARLIER than the existing memory's — it is an
|
||||
older state and must never replace a newer one (see "Time order")
|
||||
|
||||
Rules:
|
||||
|
||||
* content must contain ONLY the new state
|
||||
* do NOT copy unchanged information
|
||||
* the system keeps the old node and links it onto the evolution chain automatically; you don't need to handle history
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "SUPERSEDE",
|
||||
"memory_id": "...",
|
||||
"content": "...",
|
||||
"supersede_reason": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
### Folding several existing memories into one chain
|
||||
|
||||
If TWO OR MORE existing memories track the SAME dimension and the new memory is
|
||||
the latest change on that dimension — its `memory_at` is later than every folded
|
||||
memory's `memory_at` — you may fold them into a single evolution
|
||||
chain. Use `memory_ids` (an ordered list, OLDEST → NEWEST) instead of a single
|
||||
`memory_id`:
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "SUPERSEDE",
|
||||
"memory_ids": ["<oldest_id>", "<newer_id>"],
|
||||
"content": "<only the newest state>",
|
||||
"supersede_reason": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
The listed memories are chained oldest → newest and the new memory becomes the
|
||||
chain head. `content` still describes ONLY the newest state — never copy the
|
||||
older memories' text into it; they remain queryable in the chain history.
|
||||
|
||||
---
|
||||
|
||||
# Rules
|
||||
|
||||
* Preserve all meaningful information.
|
||||
* Skip exact duplicates.
|
||||
* Prefer merging related fragments.
|
||||
* Separate dated occurrences stay separate ADDs; use `same_series` only for the
|
||||
same subject and same concrete activity/event type.
|
||||
* Keep each memory focused on one coherent topic.
|
||||
* Follow "Time order" below.
|
||||
* **NEVER merge or supersede across different subjects.** The subject is whoever
|
||||
the content is about: "The user ..." vs "John ..." vs "Mary ...". Memories about
|
||||
different people are independent facts even when the sentence is otherwise
|
||||
identical — "The user likes coffee" and "John likes coffee" are two separate
|
||||
memories, never an UPDATE or a SUPERSEDE of each other.
|
||||
* `owner` (`user`/`agent`): copy it from the memory being written. NEVER merge or supersede across different owners — a user's fact and the assistant's fact are distinct even on the same topic.
|
||||
* A SUPERSEDE either targets ONE memory (`memory_id`) or folds several memories on the same dimension into one chain (`memory_ids`, oldest→newest). An UPDATE targets exactly ONE memory.
|
||||
* Never touch the same existing memory from more than one op.
|
||||
* Output language matches input memory language.
|
||||
|
||||
---
|
||||
|
||||
# Time order
|
||||
|
||||
Every memory, new or existing, has `memory_at`: when it happened. Memories are
|
||||
NOT always written in the order they happened — history can be imported late,
|
||||
and a memory can be added while older history is still being processed. A new
|
||||
memory can therefore be OLDER than an existing one.
|
||||
|
||||
* Decide which memory is newer ONLY by `memory_at`. "New memories" means "being
|
||||
written now", not "more recent".
|
||||
* An older memory must never replace a newer one: no SUPERSEDE of a newer
|
||||
memory, and no UPDATE that overwrites a newer value with an older one.
|
||||
* If a new memory is older than an existing memory it conflicts with, keep the
|
||||
existing memory as the current state. ADD the new one as a past state, worded
|
||||
in the past with its time (for example "In 2023 the user lived in Shanghai"),
|
||||
or output nothing if it adds no information.
|
||||
* `memory_at: null` means unknown time. Unknown is NOT newer: never SUPERSEDE a
|
||||
memory that has a known `memory_at` with one whose `memory_at` is null.
|
||||
* Do not invent chronology.
|
||||
|
||||
---
|
||||
|
||||
# Optional: `related`
|
||||
|
||||
On any op you MAY add a `related` array recording how the memory being written
|
||||
relates to an EXISTING memory. This does not change what gets stored — it only
|
||||
records a link.
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "ADD",
|
||||
"content": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."],
|
||||
"related": [{{"memory_id": "<existing_id>", "relation": "causes"}}]
|
||||
}}
|
||||
```
|
||||
|
||||
Only these four relations are allowed:
|
||||
|
||||
* `causes` — the two memories are in a cause/effect relationship
|
||||
(e.g. "started running daily" → "knee started hurting")
|
||||
* `elaborates` — adds detail to, or is a later development of, the existing memory
|
||||
* `contrasts` — they are in tension but BOTH still true (if one makes the other
|
||||
false, that is a SUPERSEDE, not a `contrasts` link)
|
||||
* `same_series` — separate dated occurrences of the same concrete activity or
|
||||
event type for the same subject (for example, two different skiing trips)
|
||||
|
||||
Rules:
|
||||
|
||||
* Only link to memories that appear in "Existing memories" — never invent an id.
|
||||
* Only emit a link when the relationship is **explicit and specific**. Two memories
|
||||
merely being about the same broad topic is NOT a relation — leave `related` out.
|
||||
* `same_series` requires the same subject and a concrete repeated action/event;
|
||||
shared broad tags such as health, travel, sport, food, or work are insufficient.
|
||||
* Omit the field entirely when there is nothing to link. Zero links is the normal case.
|
||||
* Never use `related` as a substitute for UPDATE / SUPERSEDE.
|
||||
|
||||
---
|
||||
{few_shot_section}
|
||||
###################
|
||||
Input Data
|
||||
###################
|
||||
|
||||
Current date: {current_date}
|
||||
|
||||
## Existing memories
|
||||
|
||||
{existing_memories}
|
||||
|
||||
## New memories
|
||||
|
||||
{new_memories}
|
||||
|
||||
---
|
||||
|
||||
# Output
|
||||
|
||||
Return ONLY a JSON array inside a fenced ```json code block.
|
||||
|
||||
If no changes are needed:
|
||||
|
||||
```json
|
||||
[]
|
||||
```
|
||||
|
||||
Now produce the JSON array."""
|
||||
|
||||
RECONCILE_STRUCTURE_METADATA_PROMPT_EN = """
|
||||
|
||||
Additional structural output contract:
|
||||
|
||||
For every ADD, UPDATE, or SUPERSEDE result, preserve or refine `topic_paths`,
|
||||
`event_facet`, and `topic_assignments`. `event_facet` contains only `subject`,
|
||||
`participants`, `action`, `objects`, `locations`, and `state`.
|
||||
`topic_assignments` contains subject-specific `{subject, role, path}` entries;
|
||||
each path segment is `{name, type}` and type is `topic|facet|series|episode`.
|
||||
Types follow `topic -> facet -> series -> episode`; levels may be skipped but
|
||||
types may not repeat or move backward.
|
||||
Use Series only for repeated occurrences of one concrete activity/state line and
|
||||
Episode only for one bounded real-world occurrence. Never infer these types from
|
||||
depth, a broad shared tag, or unrelated co-occurrence. When an Existing memory
|
||||
already has a semantically equivalent subject-specific path, reuse its exact node
|
||||
names and types instead of creating a synonym fork. To request exact reuse, emit
|
||||
`structure_references: [{"memory_id":"<existing_id>", "subject":"...",
|
||||
"through":"topic|facet|series|episode"}]`. The referenced ID must appear in
|
||||
Existing memories. `through` means copy that existing path prefix through the
|
||||
named structural type; an explicitly authored deeper suffix remains new. Reuse
|
||||
is forbidden when the subject, concrete activity/state line, or Episode boundary
|
||||
differs. These fields describe the memory, never a future query form such as
|
||||
"when", "how many", or "who".
|
||||
|
||||
Series is a reconcile-time relation: establish it only when the new memory and
|
||||
at least one independent Existing memory are distinct occurrences of the same
|
||||
concrete activity/state line. Multiple propositions from the current write do
|
||||
not prove repetition.
|
||||
"""
|
||||
# Prompt 文本统一放在 hy_memory/prompts/<language>.py(get_prompt 按语言码取);
|
||||
# 以下旧名字保留为英文版别名(向后兼容)。
|
||||
from ..prompts import ( # noqa: E402
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ..prompts.en import SEARCH_QUERY_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import RECONCILE_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import RECONCILE_STRUCTURE_METADATA_PROMPT as RECONCILE_STRUCTURE_METADATA_PROMPT_EN # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import RECONCILE_FEW_SHOT as RECONCILE_FEW_SHOT_EN # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
def append_reconcile_structure_prompt(
|
||||
@@ -461,10 +180,8 @@ def append_reconcile_structure_prompt(
|
||||
"""Append the structure contract only for an explicit experiment config."""
|
||||
if not enabled:
|
||||
return prompt
|
||||
if language == "zh":
|
||||
from .prompts_zh import RECONCILE_STRUCTURE_METADATA_PROMPT_ZH
|
||||
return prompt + RECONCILE_STRUCTURE_METADATA_PROMPT_ZH
|
||||
return prompt + RECONCILE_STRUCTURE_METADATA_PROMPT_EN
|
||||
code = "zh-Hans" if language == "zh" else language
|
||||
return prompt + get_prompt("RECONCILE_STRUCTURE_METADATA_PROMPT", code)
|
||||
|
||||
|
||||
def _as_aware(value: Any) -> Optional[datetime]:
|
||||
@@ -550,147 +267,42 @@ def build_reconcile_prompt(
|
||||
current_date: str,
|
||||
few_shot_enabled: bool = False,
|
||||
structure_metadata_enabled: bool = False,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build the language-matched reconcile prompt used by the write path."""
|
||||
from ..utils.lang_detect import is_chinese
|
||||
"""Build the reconcile prompt used by the write path.
|
||||
|
||||
if is_chinese("\n".join(new_memories)):
|
||||
from .prompts_zh import RECONCILE_FEW_SHOT_ZH, RECONCILE_PROMPT_ZH
|
||||
|
||||
few_shot = ("\n" + RECONCILE_FEW_SHOT_ZH + "\n") if few_shot_enabled else ""
|
||||
prompt = RECONCILE_PROMPT_ZH.format(
|
||||
current_date=current_date,
|
||||
new_memories=new_memories_block,
|
||||
existing_memories=existing_memories_block,
|
||||
few_shot_section=few_shot,
|
||||
)
|
||||
return append_reconcile_structure_prompt(
|
||||
prompt,
|
||||
enabled=structure_metadata_enabled,
|
||||
language="zh",
|
||||
)
|
||||
|
||||
few_shot = ("\n" + RECONCILE_FEW_SHOT_EN + "\n") if few_shot_enabled else ""
|
||||
prompt = RECONCILE_PROMPT.format(
|
||||
``language`` set → that language's prompt plus the output-language rule;
|
||||
None → zh/en auto-detection over the new memories (unchanged behaviour).
|
||||
"""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, "\n".join(new_memories))
|
||||
few_shot = (
|
||||
("\n" + get_prompt("RECONCILE_FEW_SHOT", code) + "\n")
|
||||
if few_shot_enabled else ""
|
||||
)
|
||||
prompt = get_prompt("RECONCILE_PROMPT", code).format(
|
||||
current_date=current_date,
|
||||
new_memories=new_memories_block,
|
||||
existing_memories=existing_memories_block,
|
||||
few_shot_section=few_shot,
|
||||
)
|
||||
return append_reconcile_structure_prompt(
|
||||
prompt = append_reconcile_structure_prompt(
|
||||
prompt,
|
||||
enabled=structure_metadata_enabled,
|
||||
language="en",
|
||||
language=code,
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None)
|
||||
|
||||
|
||||
# Reconcile few-shot examples (injected into {few_shot_section} only when
|
||||
# few_shot_enabled). Each shows the existing/new input and the expected ops,
|
||||
# with a one-line takeaway. IDs are shortened for readability.
|
||||
RECONCILE_FEW_SHOT_EN = """
|
||||
###################
|
||||
Examples
|
||||
###################
|
||||
|
||||
## Example 1 — One new memory: part conflicts with an existing chain, part is unrelated
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "m1", "content": "The user works as a product manager at Stripe.", "owner": "user", "history_versions": [{"content": "The user works as a product manager at a fintech startup."}]}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "The user has left Stripe and is now a product lead at Notion, and has recently taken up pottery on weekends.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "SUPERSEDE", "memory_id": "m1", "content": "The user works as a product lead at Notion.", "supersede_reason": "employer changed from Stripe to Notion", "owner": "user", "tags": ["work"]},
|
||||
{"op": "ADD", "content": "The user has recently taken up pottery on weekends.", "owner": "user", "tags": ["hobby"]}
|
||||
]
|
||||
```
|
||||
A SINGLE new memory carries two things: the job change (same dimension as the existing employment chain m1, which already has history → SUPERSEDE, becomes the new chain head) and an unrelated detail (pottery → ADD). Split the one memory into the right op per part — the new head describes ONLY the employment state, never the pottery.
|
||||
|
||||
## Example 2 — Fold two unlinked existing memories + the new change into one 3-node chain
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "a1", "content": "The user is learning to play the guitar.", "owner": "user", "memory_at": "2024-02-10 09:00"},
|
||||
{"memory_id": "a2", "content": "The user can now play a few full songs on the guitar.", "owner": "user", "memory_at": "2024-09-05 09:00"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "The user now performs guitar at local open-mic nights.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "SUPERSEDE", "memory_ids": ["a1", "a2"], "content": "The user performs guitar at local open-mic nights.", "supersede_reason": "guitar skill progressed: learning → playing songs → performing live", "owner": "user", "tags": ["music", "hobby"]}
|
||||
]
|
||||
```
|
||||
a1 and a2 were never linked, but they track the same dimension (guitar progress) and the new memory is the latest step. Fold them with `memory_ids` ordered oldest→newest; they become a 3-node chain (a1 ← a2 ← new head). The head `content` describes ONLY the current state — it does not restate "learning" or "a few songs".
|
||||
|
||||
## Example 3 — New head must NOT copy the old memory's content
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "c1", "content": "The user lives in a rented one-bedroom apartment in Shibuya, Tokyo, pays ¥180,000/month, loves the 5-minute walk to the station, but complains the place is noisy at night and has no parking.", "owner": "user"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "The user just bought and moved into a three-bedroom house in the suburbs of Yokohama with a garden and a two-car garage.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "SUPERSEDE", "memory_id": "c1", "content": "The user bought and moved into a three-bedroom house in suburban Yokohama, with a garden and a two-car garage.", "supersede_reason": "residence changed from a rented Shibuya apartment to an owned Yokohama house", "owner": "user", "tags": ["housing"]}
|
||||
]
|
||||
```
|
||||
The new memory SUPERSEDEs c1 and becomes the chain head. The head `content` describes ONLY the new Yokohama house. Do NOT drag the old apartment's distinctive details (Shibuya, ¥180,000 rent, the station walk, the noise/parking complaints) into the new node — those clearly belong to the old residence, not the new one. They stay in chain history and remain retrievable; copying them would make the head self-contradictory (renting in Shibuya AND owning in Yokohama at once).
|
||||
|
||||
## Example 4 — Different subjects are never merged, however similar the wording
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "d1", "content": "The user drinks black coffee every morning.", "owner": "user"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "John drinks black coffee every morning.", "owner": "user"},
|
||||
{"content": "The user now drinks black coffee twice a day, morning and after lunch.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "ADD", "content": "John drinks black coffee every morning.", "owner": "user", "tags": ["food"]},
|
||||
{"op": "UPDATE", "memory_id": "d1", "content": "The user drinks black coffee twice a day, in the morning and after lunch.", "owner": "user", "tags": ["food"]}
|
||||
]
|
||||
```
|
||||
The two new memories are near-identical in wording but have DIFFERENT subjects. "John ..." is about another person, so it is a plain ADD — never an UPDATE or SUPERSEDE of d1, even though d1 says almost the same thing about the user. Only the memory whose subject is also "The user" may merge with d1.
|
||||
|
||||
## Example 5 — Recurring event: bump count and keep a timeline (UPDATE, not ADD)
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "i1", "content": "John injured his ankle last season and needed time off plus physical therapy.", "owner": "user", "memory_at": "2023-05-21 19:48"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "John twisted his ankle again during a recent game.", "owner": "user", "memory_at": "2023-10-13 13:50"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "UPDATE", "memory_id": "i1", "content": "John has injured his ankle two times: first last season (around the 2022–2023 season), needing time off and physical therapy; second during a game around 2023-10-13.", "owner": "user", "tags": ["health", "basketball", "injury"]}
|
||||
]
|
||||
```
|
||||
Same event type happening again → UPDATE the old memory. State the **count** and list occurrences with times when known. Do NOT ADD a second "John injured his ankle" fact, and do NOT SUPERSEDE (the first injury did not stop being true).
|
||||
"""
|
||||
def build_search_query_prompt(
|
||||
new_memories: List[str], *, language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Search-query generation prompt (same language rule as reconcile)."""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, "\n".join(new_memories))
|
||||
mem_lines = "\n".join(f"{i+1}. {m}" for i, m in enumerate(new_memories))
|
||||
prompt = get_prompt("SEARCH_QUERY_PROMPT", code).format(new_memories=mem_lines)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -745,7 +357,9 @@ class MemoryReconciler:
|
||||
self._llm = LLMProvider(self.config)
|
||||
return self._llm
|
||||
|
||||
async def _generate_search_queries(self, new_memories: List[str]) -> tuple:
|
||||
async def _generate_search_queries(
|
||||
self, new_memories: List[str], language: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Pre-process: 从全部新 memory 中提取 search queries,
|
||||
用于补充向量搜索的召回能力。
|
||||
@@ -753,17 +367,7 @@ class MemoryReconciler:
|
||||
Returns:
|
||||
(queries: List[str], llm_response: Optional[LLMResponse])
|
||||
"""
|
||||
mem_lines = "\n".join(f"{i+1}. {m}" for i, m in enumerate(new_memories))
|
||||
|
||||
# Select prompt based on input language
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
if is_chinese("\n".join(new_memories)):
|
||||
from .prompts_zh import SEARCH_QUERY_PROMPT_ZH
|
||||
|
||||
prompt = SEARCH_QUERY_PROMPT_ZH.format(new_memories=mem_lines)
|
||||
else:
|
||||
prompt = SEARCH_QUERY_PROMPT.format(new_memories=mem_lines)
|
||||
prompt = build_search_query_prompt(new_memories, language=language)
|
||||
|
||||
try:
|
||||
llm = self._get_llm()
|
||||
@@ -849,6 +453,7 @@ class MemoryReconciler:
|
||||
graph_store=None, # Optional GraphStore:Entity MENTIONS boost
|
||||
new_memory_embeddings: Optional[Dict[str, List[float]]] = None,
|
||||
source: str = "default",
|
||||
language: Optional[str] = None,
|
||||
) -> ReconcileResult:
|
||||
"""
|
||||
对 new_memories 做 reconcile。
|
||||
@@ -856,6 +461,7 @@ class MemoryReconciler:
|
||||
Args:
|
||||
new_memories: 新记忆内容列表(content only,用于 embed 搜索)
|
||||
new_memories_with_meta: 可选,含 tags 的完整 meta
|
||||
language: 显式 prompt 语言码;None 时按新记忆做 zh/en 自动检测
|
||||
"""
|
||||
if not new_memories:
|
||||
return ReconcileResult(ops=[], success=True)
|
||||
@@ -876,7 +482,7 @@ class MemoryReconciler:
|
||||
|
||||
if self._enable_search_query:
|
||||
search_queries, sq_response = await self._generate_search_queries(
|
||||
new_memories
|
||||
new_memories, language=language,
|
||||
)
|
||||
sq_elapsed = (_time.perf_counter() - t_sq) * 1000
|
||||
|
||||
@@ -887,18 +493,10 @@ class MemoryReconciler:
|
||||
|
||||
# 写 SEARCH_QUERY pipeline log
|
||||
if cache and request_id:
|
||||
mem_lines = "\n".join(f"{i+1}. {m}" for i, m in enumerate(new_memories))
|
||||
if self._enable_search_query:
|
||||
from ..utils.lang_detect import is_chinese as _is_zh
|
||||
|
||||
if _is_zh("\n".join(new_memories)):
|
||||
from .prompts_zh import SEARCH_QUERY_PROMPT_ZH
|
||||
|
||||
sq_prompt = SEARCH_QUERY_PROMPT_ZH.format(
|
||||
new_memories=mem_lines
|
||||
)
|
||||
else:
|
||||
sq_prompt = SEARCH_QUERY_PROMPT.format(new_memories=mem_lines)
|
||||
sq_prompt = build_search_query_prompt(
|
||||
new_memories, language=language,
|
||||
)
|
||||
else:
|
||||
sq_prompt = "[search query disabled — hybrid retrieval per new memory]"
|
||||
try:
|
||||
@@ -1447,6 +1045,7 @@ class MemoryReconciler:
|
||||
getattr(self.config.llm, "few_shot_enabled", False)
|
||||
),
|
||||
structure_metadata_enabled=_structure_metadata_on,
|
||||
language=language,
|
||||
)
|
||||
|
||||
# ── Step 5: LLM 调用 + 重试 ──
|
||||
|
||||
@@ -225,6 +225,16 @@ _PLACEHOLDER_VALUES = frozenset({
|
||||
"not provided", "not specified", "not mentioned",
|
||||
"未提供", "未知", "不详", "未说明", "未提及", "无",
|
||||
"-", "?",
|
||||
# 其他提示词语言里的「用户」/「未知」(显式 language 时模型可能照抄)
|
||||
"使用者", "該使用者", "用戶", "該用戶", "不詳", "無",
|
||||
"ユーザー", "ユーザ", "不明", "なし",
|
||||
"사용자", "유저", "알 수 없음", "없음",
|
||||
"el usuario", "usuario", "la usuaria", "desconocido", "ninguno",
|
||||
"der nutzer", "nutzer", "der benutzer", "benutzer", "unbekannt", "keine",
|
||||
"o usuário", "usuário", "o utilizador", "utilizador", "desconhecido", "nenhum",
|
||||
"l'utilisateur", "utilisateur", "inconnu", "aucun",
|
||||
"пользователь", "неизвестно", "нет",
|
||||
"pengguna", "tidak diketahui", "tidak ada",
|
||||
})
|
||||
|
||||
# 内置字段的中文标签:弱模型也会把字段说明的中文词当值。
|
||||
|
||||
+66
-4
@@ -909,6 +909,19 @@ class HyMemoryClient:
|
||||
"""
|
||||
return "coding" if str(scene or "").strip().lower() == "coding" else "working"
|
||||
|
||||
def _resolve_language(self, language: Optional[str]) -> Optional[str]:
|
||||
"""per-call language > MemoryConfig.language; None = zh/en 自动检测。
|
||||
|
||||
返回 canonical 码;未知码抛 ValueError(列出支持的码与别名)。
|
||||
"""
|
||||
from .prompts import normalize_language
|
||||
|
||||
code = normalize_language(language)
|
||||
if code is not None:
|
||||
return code
|
||||
config = getattr(self, "_config", None)
|
||||
return normalize_language(getattr(config, "language", None))
|
||||
|
||||
@staticmethod
|
||||
def _parse_input(data: AddInput):
|
||||
"""
|
||||
@@ -1723,6 +1736,7 @@ class HyMemoryClient:
|
||||
request_id: Optional[str] = None,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
embed_override: Optional[Dict[str, Any]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
写入记忆。
|
||||
@@ -1773,6 +1787,13 @@ class HyMemoryClient:
|
||||
meta 独立持久化,只做业务透传。
|
||||
语义为「该次 write 时刻的上下文」,故存为 list——同一节点被
|
||||
多次写入合并时会追加而非覆盖,详见 get() 返回的 meta 字段。
|
||||
language: 本次请求的 LLM prompt 语言码(en / zh-Hans / zh-Hant / ja / es /
|
||||
de / pt / fr / ko / ru / id;别名 zh、zh-CN → zh-Hans,zh-TW、
|
||||
zh-HK → zh-Hant,pt-BR → pt)。优先于 MemoryConfig.language
|
||||
(env MEMORY_LANGUAGE);两者都未设时沿用 zh/en 自动检测。
|
||||
显式指定时 extract / reconcile / coding 链路 / 基础画像等本次
|
||||
请求的全部 LLM 调用都用该语言的 prompt,记忆正文也用该语言书写。
|
||||
未知码抛 ValueError(列出支持的码)。
|
||||
|
||||
Returns:
|
||||
chat 链路:{"success": True, "memory_id": "...", "request_id": "...", "elapsed_ms": ...}
|
||||
@@ -1788,6 +1809,7 @@ class HyMemoryClient:
|
||||
workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, meta=meta,
|
||||
embed_override=embed_override,
|
||||
language=language,
|
||||
),
|
||||
include_runtime_diag=True,
|
||||
)
|
||||
@@ -1798,15 +1820,20 @@ class HyMemoryClient:
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Finalize one caller session into a first-class episode narrative.
|
||||
|
||||
The feature is opt-in through MEMORY_SESSION_EPISODE_ENABLED. Calls are
|
||||
idempotent: the same user/agent/session tuple upserts one deterministic node.
|
||||
|
||||
``language`` (or MemoryConfig.language) makes the narrative be written in
|
||||
that language; see add(). The episode prompts themselves are English-only.
|
||||
"""
|
||||
return self._loop_thread.run(
|
||||
self.async_finalize_session(
|
||||
user_id=user_id, agent_id=agent_id, session_id=session_id,
|
||||
language=language,
|
||||
),
|
||||
include_runtime_diag=True,
|
||||
)
|
||||
@@ -2263,6 +2290,7 @@ class HyMemoryClient:
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
max_batches: Optional[int] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
手动触发 System 2 认知加工(ultra 模式专用,同步阻塞直到完成)。
|
||||
@@ -2285,6 +2313,8 @@ class HyMemoryClient:
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
agent_id: Agent ID
|
||||
language: System 2 / sweeper prompt 语言码,语义同 add()(per-call >
|
||||
MemoryConfig.language > 按 materials 做 zh/en 检测)。
|
||||
|
||||
Returns:
|
||||
{"success": True, "request_id": "...", "tasks_processed": 1, "results": {...},
|
||||
@@ -2298,6 +2328,7 @@ class HyMemoryClient:
|
||||
return self._loop_thread.run(
|
||||
self.async_evolve(
|
||||
user_id=user_id, agent_id=agent_id, max_batches=max_batches,
|
||||
language=language,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2307,8 +2338,10 @@ class HyMemoryClient:
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
max_batches: Optional[int] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""手动触发 System 2 认知加工(异步)"""
|
||||
language = self._resolve_language(language)
|
||||
t0 = __import__("time").perf_counter()
|
||||
|
||||
# 获取 System2Writer
|
||||
@@ -2331,6 +2364,7 @@ class HyMemoryClient:
|
||||
|
||||
result = await writer.evolve(
|
||||
user_id=user_id, agent_id=agent_id, max_batches=max_batches,
|
||||
language=language,
|
||||
)
|
||||
result["elapsed_ms"] = round((__import__("time").perf_counter() - t0) * 1000, 2)
|
||||
return result
|
||||
@@ -2339,6 +2373,7 @@ class HyMemoryClient:
|
||||
self,
|
||||
user_ids: List[str],
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
手动触发 cross-domain sweeper(同步)。
|
||||
@@ -2352,6 +2387,8 @@ class HyMemoryClient:
|
||||
Args:
|
||||
user_ids: SDK user_id 列表
|
||||
agent_ids: Agent ID 列表(默认 ["default_agent"])
|
||||
language: 输出语言码,语义同 add()。sweeper prompt 只有英文版,显式
|
||||
指定时追加该语言的输出规则。
|
||||
|
||||
Returns:
|
||||
{"success": True, "results": [{...}, ...]}
|
||||
@@ -2361,7 +2398,9 @@ class HyMemoryClient:
|
||||
f"sweep() requires mode='ultra', but this client is mode='{self._mode}'."
|
||||
)
|
||||
return self._loop_thread.run(
|
||||
self.async_sweep(user_ids=user_ids, agent_ids=agent_ids)
|
||||
self.async_sweep(
|
||||
user_ids=user_ids, agent_ids=agent_ids, language=language,
|
||||
)
|
||||
)
|
||||
|
||||
@_ensure_internal_loop
|
||||
@@ -2369,11 +2408,13 @@ class HyMemoryClient:
|
||||
self,
|
||||
user_ids: List[str],
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""手动触发 cross-domain sweeper(异步)
|
||||
|
||||
对每个 user_id × agent_id 组合执行一次 sweeper。
|
||||
"""
|
||||
language = self._resolve_language(language)
|
||||
t0 = __import__("time").perf_counter()
|
||||
|
||||
try:
|
||||
@@ -2385,7 +2426,9 @@ class HyMemoryClient:
|
||||
results = []
|
||||
for uid in user_ids:
|
||||
for aid in effective_agents:
|
||||
r = await writer.run_sweeper(user_id=uid, agent_id=aid)
|
||||
r = await writer.run_sweeper(
|
||||
user_id=uid, agent_id=aid, language=language,
|
||||
)
|
||||
results.append({"user_id": uid, "agent_id": aid, **r})
|
||||
|
||||
elapsed_ms = round((__import__("time").perf_counter() - t0) * 1000, 2)
|
||||
@@ -2740,13 +2783,17 @@ class HyMemoryClient:
|
||||
request_id: Optional[str] = None,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
embed_override: Optional[Dict[str, Any]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""写入记忆(异步)
|
||||
|
||||
embed_override: 本次写入的 embedding 端点覆盖(不改 model/dims,只换接入点)。
|
||||
dict,可含 base_url / api_key / extra_headers / timeout;None = 用 client 配置。
|
||||
可用于按请求切换任意 OpenAI-compatible embedding endpoint。
|
||||
language: 本次请求的 prompt 语言码,详见 add()。
|
||||
"""
|
||||
# 先校验语言码:非法值直接 ValueError,不占用 request_id 的幂等槽位
|
||||
language = self._resolve_language(language)
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
replay = await self._claim_write_outcome(
|
||||
request_id=request_id,
|
||||
@@ -2770,7 +2817,7 @@ class HyMemoryClient:
|
||||
source=source,
|
||||
metadata=metadata, memory_at=memory_at,
|
||||
scene=scene, workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, meta=meta,
|
||||
request_id=request_id, meta=meta, language=language,
|
||||
)
|
||||
else:
|
||||
result = await self._async_add_impl(
|
||||
@@ -2778,7 +2825,7 @@ class HyMemoryClient:
|
||||
source=source,
|
||||
metadata=metadata, memory_at=memory_at,
|
||||
scene=scene, workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, meta=meta,
|
||||
request_id=request_id, meta=meta, language=language,
|
||||
)
|
||||
except BaseException:
|
||||
await self._mark_write_outcome_unknown(request_id)
|
||||
@@ -2797,8 +2844,10 @@ class HyMemoryClient:
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Async counterpart of :meth:`finalize_session`."""
|
||||
language = self._resolve_language(language)
|
||||
series = bool(getattr(self._config.recall, "series_episode_enabled", False))
|
||||
dynamic = bool(
|
||||
getattr(self._config.recall, "dynamic_episode_enabled", False) or series
|
||||
@@ -2828,6 +2877,7 @@ class HyMemoryClient:
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
language=language,
|
||||
)
|
||||
if finalize is finalize_dynamic_episode_capsules:
|
||||
finalize_kwargs["summary_concurrency"] = getattr(
|
||||
@@ -2867,6 +2917,7 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""写入记忆(异步)
|
||||
|
||||
@@ -2875,6 +2926,7 @@ class HyMemoryClient:
|
||||
None = 自动判定。详见 add() docstring。
|
||||
workspace_id / branch: Coding memory 路径专用,详见 add() docstring。
|
||||
meta: 调用方自定义业务 meta,透传存储,详见 add() docstring。
|
||||
language: 已解析的 canonical 语言码(None = zh/en 自动检测)。
|
||||
"""
|
||||
from .utils.source_filter import normalize_source
|
||||
source = normalize_source(source)
|
||||
@@ -2973,6 +3025,7 @@ class HyMemoryClient:
|
||||
request_id=request_id,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
language=language,
|
||||
)
|
||||
# Public receipt always identifies the productivity namespace.
|
||||
resp["variant"] = scene_variant
|
||||
@@ -3038,6 +3091,7 @@ class HyMemoryClient:
|
||||
# 落库 memory_ops / WRITE_SUMMARY / pipeline_logs 的单一真相源。
|
||||
request_id=request_id,
|
||||
client_meta=meta,
|
||||
language=language,
|
||||
)
|
||||
|
||||
# mode → writer:
|
||||
@@ -3126,6 +3180,7 @@ class HyMemoryClient:
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
memory_at: Optional[datetime] = None,
|
||||
request_id: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""写入「调用方已抽取好的」记忆:跳过 extract,直接进 reconcile。
|
||||
|
||||
@@ -3140,6 +3195,7 @@ class HyMemoryClient:
|
||||
|
||||
Args:
|
||||
contents: 单条事实字符串,或多条事实字符串列表。
|
||||
language: reconcile prompt 的语言码,语义同 add()。
|
||||
其余参数语义同 add()。
|
||||
|
||||
Returns:
|
||||
@@ -3151,6 +3207,7 @@ class HyMemoryClient:
|
||||
contents, user_id=user_id, agent_id=agent_id,
|
||||
session_id=session_id, source=source, metadata=metadata,
|
||||
memory_at=memory_at, request_id=request_id,
|
||||
language=language,
|
||||
),
|
||||
include_runtime_diag=True,
|
||||
)
|
||||
@@ -3167,8 +3224,10 @@ class HyMemoryClient:
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
memory_at: Optional[datetime] = None,
|
||||
request_id: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Idempotent async wrapper for extracted-memory writes."""
|
||||
language = self._resolve_language(language)
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
replay = await self._claim_write_outcome(
|
||||
request_id=request_id,
|
||||
@@ -3189,6 +3248,7 @@ class HyMemoryClient:
|
||||
metadata=metadata,
|
||||
memory_at=memory_at,
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
except BaseException:
|
||||
await self._mark_write_outcome_unknown(request_id)
|
||||
@@ -3211,6 +3271,7 @@ class HyMemoryClient:
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
memory_at: Optional[datetime] = None,
|
||||
request_id: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""写入已抽取好的记忆(异步);跳过 extract,直接 reconcile。详见 add_extracted()。"""
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
@@ -3251,6 +3312,7 @@ class HyMemoryClient:
|
||||
extra=extra,
|
||||
memory_at=memory_at,
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
|
||||
# lite/pro → MemoryWriter;ultra → System2Writer(内部复用 MemoryWriter)
|
||||
|
||||
+39
-694
@@ -21,7 +21,12 @@ from typing import List, Optional, Dict, Any, Tuple
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
from ..pipelines.base import ChatMessage
|
||||
from ..utils.lang_detect import is_chinese
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from .preproc import (
|
||||
extract_files,
|
||||
keep_last_k_user_turns,
|
||||
@@ -65,332 +70,7 @@ _VERBATIM_STATEMENT_CHARS = 600
|
||||
# Prompt
|
||||
# ================================================================
|
||||
|
||||
EXTRACT_PROMPT = """\
|
||||
You extract durable WORKING memories from a task-execution session segment.
|
||||
A "working" session is any hands-on task where the user operates a computer /
|
||||
system through tools — this covers BOTH software/code work (writing, debugging,
|
||||
refactoring, building, deploying, API/library usage) AND system & operations
|
||||
work (running shell/PowerShell commands, disk/storage analysis or cleanup, file
|
||||
& folder operations, installing/configuring software, environment/device setup,
|
||||
hardware/disk health inspection, backup, scheduling/automation, data processing).
|
||||
|
||||
You produce TWO layers from the SAME segment:
|
||||
|
||||
EXPERIENCES (procedural, "how to do it") — reusable method, extracted only
|
||||
when it clears the VALUE BAR below. A (task, search_keys, solution,
|
||||
boundary_envs) bundle.
|
||||
|
||||
FACT (episodic, "what happened this time") — EXACTLY ONE record of THIS
|
||||
session: what the user wanted, how it was gone about, how it came out. No
|
||||
value bar, but there is never more than one.
|
||||
|
||||
The two layers answer different questions and are stored separately. A method
|
||||
that generalizes goes in EXPERIENCES; the story of this particular session —
|
||||
only true for this system / this project / right now — goes in the FACT.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR (EXPERIENCES ONLY) — DO NOT cross this lightly.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
A memory MUST satisfy ALL of:
|
||||
(a) Solves a problem likely to recur for the same user
|
||||
(b) Solution is non-trivial: combines multiple pieces of info / steps, OR
|
||||
carries a decision rationale, OR captures a hard-won workaround, OR is a
|
||||
reusable procedure/recipe for an operational task (e.g. how to analyze &
|
||||
clean up disk usage, how to batch-organize files, how to inspect hardware
|
||||
health, how to set up a scheduled job)
|
||||
(c) A future agent / user would prefer looking it up over re-discovering it
|
||||
|
||||
IMPORTANT — analysis / diagnostic / investigation tasks COUNT:
|
||||
A task does NOT have to make a change or "fix" something to be worth remembering.
|
||||
Read-only / analytical / troubleshooting work is valuable when its PROCEDURE or
|
||||
CONCLUSION is reusable. Explicitly extract:
|
||||
- Analysis / diagnostic recipes: the reusable procedure + what it revealed
|
||||
(e.g. "how to analyze which folders eat C-drive space" — the scan commands,
|
||||
the gotchas hit, and the finding). "It only produced a report, no change"
|
||||
is NOT a reason to skip — the reusable METHOD is the memory.
|
||||
- Investigation outcomes, INCLUDING negative / not-found results
|
||||
(e.g. "searched folder X across C: and D: with rg / fs_search / SQL index —
|
||||
not found anywhere"). A recorded dead-end saves the next agent from repeating
|
||||
the same fruitless search, so it clears the bar.
|
||||
Do NOT reject a memory merely because "the assistant could regenerate the
|
||||
commands" or "nothing was actually modified". If the procedure or the conclusion
|
||||
would save future effort, keep it.
|
||||
|
||||
DO NOT extract:
|
||||
- Truly trivial single-step ops with no reusable procedure ("rename one file",
|
||||
"git status", "run ls once")
|
||||
- One-off issues already resolved with no transferable lesson
|
||||
- Information that is already in the repo/system (don't duplicate package.json)
|
||||
- Per-PR / per-session throwaway tasks unless they encode a reusable pattern
|
||||
- The act of ASKING THE USER for missing information. Requesting a parameter,
|
||||
listing what is unknown, or eliciting required fields is conversational
|
||||
behaviour, not a method: every agent already knows how to ask a question, so
|
||||
there is nothing to look up later. Never emit an experience whose task or
|
||||
search_keys are shaped like "how to ask for X", "gather the missing Y",
|
||||
"eliciting required fields", "what to clarify before starting". Note this
|
||||
stays out of EXPERIENCES only — that the session ended waiting on the user
|
||||
is real state and belongs in the FACT.
|
||||
|
||||
If the segment made no substantive progress because it stalled waiting on the
|
||||
user — the assistant only asked for parameters, or was blocked before doing any
|
||||
real work — emit "experiences": [] and write the FACT alone. Nothing was
|
||||
learned yet, so there is no method to store. This matters more than it looks:
|
||||
an experience distilled from a stall reads as a legitimate skill on retrieval,
|
||||
and a later segment of the same task will match it and ask the user again
|
||||
instead of proceeding, so the stall reproduces itself for the rest of the
|
||||
session. Judge substance by what the tools actually did, not by whether the
|
||||
turn produced fluent text.
|
||||
|
||||
Heuristic: if the user (or a future agent doing the same kind of task) would
|
||||
want this written down instead of figuring it out again, keep it. A multi-step
|
||||
operational procedure (even a system/ops or read-only analysis/search one, not
|
||||
just code) usually qualifies. Only drop it if it is a single command any user
|
||||
would find via `--help` in 30 seconds AND carries no decision/gotcha worth
|
||||
remembering.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE FIELDS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — Query-shaped name; phrase as how a user would later ASK.
|
||||
"publish a release to the xx platform" not
|
||||
"publish_release_internal"
|
||||
Self-contained (no "this", "the file", "we").
|
||||
|
||||
search_keys — 0 to 5 alternative phrasings capturing important aspects of
|
||||
solution that `task` alone wouldn't match. Phrase as questions
|
||||
or topics future users would ASK ABOUT. Distinct from task.
|
||||
Omit if task already covers everything.
|
||||
|
||||
solution — Complete, self-contained content the user actually needs:
|
||||
commands, credential locations (NOT credential VALUES),
|
||||
file paths, ordered steps, gotchas, decision rationale.
|
||||
Aggregate multi-turn end-to-end work into ONE solution if it
|
||||
forms a coherent task; split into multiple memories only when
|
||||
the user did unrelated tasks.
|
||||
|
||||
boundary_envs — Concrete pinning that solution depends on, multi-line "key: value":
|
||||
runtime / SDK / library + version
|
||||
API endpoint or method signature
|
||||
external service / middleware version
|
||||
env var NAME (not value)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
If solution is environment-agnostic, leave as "" (empty).
|
||||
Purpose: when the underlying SDK/middleware upgrades, future
|
||||
consumers can inspect this to decide if solution still applies.
|
||||
|
||||
confidence — 0.0 to 1.0
|
||||
1.0 user explicitly stated this as a rule / instruction
|
||||
0.7 inferred with good evidence from tool actions
|
||||
0.5 weakly inferred; one observation; uncertain
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT FIELD — EXACTLY ONE per segment, no value bar
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
You write ONE fact for the whole segment — a recoverable state record for this
|
||||
session, not a polished retrospective and never a list of separate findings. A
|
||||
future agent must be able to resume from it without mistaking a suggestion,
|
||||
inference, or partial search for something verified.
|
||||
|
||||
subject — WHO or WHAT this session is about: the single entity the work was
|
||||
performed on or for. One short noun phrase, no sentence, no verb.
|
||||
Pick the most specific entity that the session is *about*, not the
|
||||
tool it used:
|
||||
a person's name when the session serves one named person
|
||||
("Dylan", "Matthew")
|
||||
the file / module / symbol when the work targets code
|
||||
("auth/middleware.ts", "parse_plan_text")
|
||||
the service / host / device / drive for ops work
|
||||
("D:\\WXWork", "redis-cache-prod", "ci.yml")
|
||||
This is a HARD KEY, not prose: two sessions about different people
|
||||
or different files MUST get different subjects, and two sessions
|
||||
about the same one MUST get the same string, spelled identically.
|
||||
Prefer the exact identifier as it appears in the trace over a
|
||||
description of it. If the segment genuinely has no single subject,
|
||||
output "".
|
||||
|
||||
Why it matters: subject is the gate that decides whether a later
|
||||
session is allowed to overwrite this one as "the same task,
|
||||
continued". Get it wrong and one person's state silently replaces
|
||||
another's.
|
||||
|
||||
statement — A compact record of THIS session. Two parts, and the second is the
|
||||
one that gets lost.
|
||||
Preserve task-scoped givens and concrete constraints instead of
|
||||
replacing them with a generic evaluation of the outcome. State
|
||||
each rule once: repeated instruction text competes with the data
|
||||
the memory must retain.
|
||||
|
||||
PART 1 — what happened, about 300 latin characters (~150 CJK).
|
||||
Start with the specific goal and object ("reclaim disk space taken
|
||||
by D:\\WXWork", "locate the new_file_search folder"), not a generic
|
||||
"fix a bug" / "find a file". Then roughly what was inspected, tried,
|
||||
or changed. Then how it ended, with the semantics of exactly one of:
|
||||
completed, partially completed, failed, blocked, or
|
||||
still awaiting verification. Include negative results and dead ends
|
||||
without overstating them.
|
||||
|
||||
PART 2 — the TASK-SCOPED GIVENS: everything the WHOLE task runs on,
|
||||
even when this segment did not establish it and even when it feels
|
||||
too elementary to state. Bindings (what each name/symbol/label
|
||||
stands for), fixed values the task supplies rather than derives,
|
||||
conventions adopted (indexing base, units, timezone, defaults), and
|
||||
this segment's own result WITH its value ("x = 7", not "the value
|
||||
was computed").
|
||||
|
||||
Write PART 2 as data, one item per line, values as given:
|
||||
retry budget = 5; timeout 30s; index starts at 0
|
||||
step 3 input: n=64, tolerance 1e-6
|
||||
slot B: type=private, cost 688.7-721.3 per unit
|
||||
A line should be almost entirely values. Keep the trace's notation —
|
||||
symbols, units, numbers, spellings — and drop its sentences: strip
|
||||
the phrases that announce a given rather than being one ("I would
|
||||
prefer", "it should be somewhere between … and …"). They repeat once
|
||||
per item, so a 15-item list pays for them 15 times.
|
||||
Carry them on EVERY fact; never write "as above" or "same as the
|
||||
previous session" — retrieval returns a handful of facts by
|
||||
similarity and the referent may not be among them.
|
||||
|
||||
Aim under 1200 characters total. If PART 2 does not fit, fix the
|
||||
notation, never the coverage: statement is the retrieval vector AND
|
||||
the only field a later segment can read (details is neither embedded
|
||||
nor injected), so anything cut from it is gone. Compress the wording
|
||||
around a given; never the given.
|
||||
|
||||
The test: if the next segment cannot see the original material, can
|
||||
it continue from this record alone? Only a yes counts. A missing
|
||||
binding does not fail loudly — the name still looks meaningful, so
|
||||
it gets read with its general-usage meaning and the answer that
|
||||
follows is fluent and about a different question.
|
||||
|
||||
LANGUAGE — same language as the session trace: statement is the
|
||||
retrieval vector and queries arrive in the user's working language,
|
||||
so another language pays a cross-lingual penalty on every lookup.
|
||||
|
||||
EVIDENCE FIDELITY — record only actions actually executed and
|
||||
evidence explicitly present in the trace. Preserve material
|
||||
commands, flags, arguments, errors, and numbers verbatim.
|
||||
NEVER complete a command with customary flags, and never turn
|
||||
advice or plans into actions:
|
||||
"ran Get-ChildItem -Force" → "ran Get-ChildItem"
|
||||
(the trace did not contain `-Force`; do not invent it)
|
||||
Scope every negative finding to what was actually searched, and
|
||||
enumerate EVERY explicit coverage limit that could invalidate it,
|
||||
especially permission failures and depth limits: keep the roots,
|
||||
filters, and depth that were in effect ("not found under C:\\ and
|
||||
D:\\ at depth 3; some directories were not covered due to Access
|
||||
denied"), never widen it to "does not exist". Do not omit one limit
|
||||
to make the record prettier.
|
||||
Never promote a partial search to global absence.
|
||||
A blocked attempt keeps its blocker.
|
||||
Preserve the distinction among attempted, observed, verified,
|
||||
inferred, and unresolved — express it naturally ("tried … but was
|
||||
blocked by Access denied", "suspected …, not yet verified"), not as
|
||||
labels. Without tool output or explicit confirmation, NEVER claim a
|
||||
fix succeeded, a cause was established, or a path does not exist.
|
||||
|
||||
Do NOT record greetings or pure chatter — that is the only case
|
||||
getting "fact": null. A segment that merely stated requirements, or
|
||||
stalled before doing work, still established something.
|
||||
|
||||
details — The session's concrete OUTPUT, copied over verbatim. `statement` is
|
||||
a summary and a summary necessarily drops the individual values; a
|
||||
later lookup almost always wants one specific value, not the gist.
|
||||
So whatever addressable result this session produced, reproduce it
|
||||
here in its original form and structure:
|
||||
a plan / schedule / itinerary → keep every line, every day, every
|
||||
slot, and every price exactly as written
|
||||
a config or code block → keep the block
|
||||
a command's material output → keep the lines that carry the
|
||||
findings (sizes, counts, versions, error text)
|
||||
a structured result (table, list of records) → keep the rows
|
||||
Preserve the original layout — line breaks, `Day 1:` / key: value
|
||||
prefixes, units, currency symbols, ids. Do not re-summarize, do not
|
||||
reorder, do not round numbers, do not translate.
|
||||
|
||||
Copy ONLY what is present in the trace. Never fill a gap: if a slot
|
||||
was left unfilled, leave it unfilled or say so — an invented value
|
||||
is worse than a missing one, because it will be retrieved and used.
|
||||
Cap around 4000 characters; if the output is longer, keep the parts
|
||||
carrying concrete values and drop decorative prose, noting what was
|
||||
dropped.
|
||||
|
||||
"" when the session produced no such artifact (pure investigation
|
||||
with no findings yet, or a stall waiting on the user).
|
||||
|
||||
This field is NOT part of the retrieval vector — `statement` and
|
||||
`subject` do the finding, `details` supplies the value once found.
|
||||
So do not write retrieval bait here, and do not duplicate the
|
||||
summary; write the raw thing.
|
||||
|
||||
files — 0..N absolute or repo-relative FILE OR DIRECTORY paths actually
|
||||
operated on or investigated during this session. Keep investigated
|
||||
roots such as `C:\\` and `D:\\`. Only include paths that were
|
||||
actually accessed/searched, not paths merely suggested or planned;
|
||||
[] if none apply.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT (given by SDK)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
When no workspace_id is present, the solution cannot rely on unstated
|
||||
project-local context — spell out any repo / path / service it needs inside
|
||||
`solution` or `boundary_envs` so the memory stays usable on its own.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"subject": "...",
|
||||
"statement": "...",
|
||||
"files": ["..."],
|
||||
"details": "..."
|
||||
}}
|
||||
}}
|
||||
|
||||
DEFAULT TO EXTRACTING. When the segment did real work, you MUST output at least
|
||||
one experience — even if the task looks simple or was a one-shot success, still
|
||||
capture the reusable essence of what was done (the method / command / how-to).
|
||||
When the session is thin, the single most valuable experience is enough.
|
||||
|
||||
The ONE exception is the stall case above: if the segment did no real work
|
||||
because it was blocked asking the user for missing information, output
|
||||
"experiences": [] and write only the fact. Do not manufacture an experience to
|
||||
satisfy this instruction — a fabricated one ("how to ask for the departure
|
||||
city") is worse than none, because it is later retrieved and followed. Empty is
|
||||
allowed ONLY for that reason, never as a shortcut when work was actually done.
|
||||
|
||||
"fact" is a SINGLE object, never a list — one record per session.
|
||||
|
||||
ALWAYS write the fact. The bar for the fact is not "was this valuable" but "did
|
||||
anything at all happen", and a segment reaching you has already cleared that.
|
||||
This includes segments that look like setup rather than work: a single
|
||||
user message stating requirements, parameters, or a starting state, with no
|
||||
tools run, is exactly the material a later session cannot reconstruct on its own
|
||||
— 把需求、参数、起始状态连同情形一起写进 `statement`(紧凑记法,不要照抄对话原句),
|
||||
不要推给 `details`(`details` 不进检索向量,读取侧也不再注入它)。Use
|
||||
"fact": null ONLY for greetings and pure chatter with no content whatsoever.
|
||||
When in doubt, write the fact: an unneeded record costs one row, while a dropped
|
||||
one costs every later session that needed it and has no way to know it existed.
|
||||
"""
|
||||
from ..prompts.en import CODING_EXTRACT_PROMPT as EXTRACT_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -421,350 +101,7 @@ one costs every later session that needed it and has no way to know it existed.
|
||||
#
|
||||
# 修改 prompt 时应使用代表性 coding traces 做回归,确认坐标、决策与验证状态仍保留。
|
||||
|
||||
EXTRACT_PROMPT_CODING = """\
|
||||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
║ HARD CONSTRAINT — read this before anything else, it overrides any ║
|
||||
║ phrasing later in this prompt that sounds like it wants a narrative. ║
|
||||
║ ║
|
||||
║ You are NOT writing a report of what a person did. You are writing what a ║
|
||||
║ FUTURE AGENT needs in order to act. That agent already has its own task; ║
|
||||
║ it does not care what this user wanted. It needs to know: what was decided ║
|
||||
║ here, at which code coordinates, what was tried and rejected, and whether ║
|
||||
║ it was verified. ║
|
||||
║ ║
|
||||
║ Concretely, in every `statement` and every `solution`: ║
|
||||
║ · The grammatical subject is the CODE or THE DECISION — never "the user" ║
|
||||
║ and never "the agent". Do not write "The user attempted to…", ║
|
||||
║ "The user wanted…", "The agent then checked…". ║
|
||||
║ · Never close with a bookkeeping phrase like "the session was completed", ║
|
||||
║ "the error was noted", "no solution was found yet". Close with the ║
|
||||
║ decision and its verification status. If nothing was decided, close ║
|
||||
║ with what was RULED OUT and the next unexplored lead — that is the ║
|
||||
║ reusable part. ║
|
||||
║ · Keep the concrete symptom (error string, failing command, wrong ║
|
||||
║ output) — that is what a future search will match on. ║
|
||||
║ ║
|
||||
║ bad: "The user attempted to write a QTable to RST with header_rows and ║
|
||||
║ hit a TypeError. The session was completed with the error noted." ║
|
||||
║ good: "ascii.rst's RST writer rejects header_rows: `tbl.write(..., \ ║
|
||||
║ format='ascii.rst', header_rows=['name','unit'])` raises TypeError ║
|
||||
║ because RST.__init__ does not forward it, unlike ascii.fixed_width ║
|
||||
║ which accepts it. No fix written — the writer needs the same ║
|
||||
║ header_rows plumbing as fixed_width." ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
You extract durable WORKING memories from a task-execution session segment.
|
||||
A "working" session is any hands-on task where the user operates a computer /
|
||||
system through tools — this covers BOTH software/code work (writing, debugging,
|
||||
refactoring, building, deploying, API/library usage) AND system & operations
|
||||
work (running shell/PowerShell commands, disk/storage analysis or cleanup, file
|
||||
& folder operations, installing/configuring software, environment/device setup,
|
||||
hardware/disk health inspection, backup, scheduling/automation, data processing).
|
||||
|
||||
You produce TWO layers from the SAME segment:
|
||||
|
||||
EXPERIENCES (procedural, "how to do it") — reusable method, extracted only
|
||||
when it clears the VALUE BAR below. A (task, search_keys, solution,
|
||||
boundary_envs) bundle.
|
||||
|
||||
FACT (episodic, "what was established this time") — EXACTLY ONE record of THIS
|
||||
session. It is NOT a narrative of what the user did. It records, in this order:
|
||||
the task and its concrete symptom (this is what makes it findable later), the
|
||||
code coordinates, WHAT WAS SETTLED ON and what was rejected, and how far it was
|
||||
verified. No value bar, but there is never more than one.
|
||||
|
||||
The two layers answer different questions and are stored separately. A method
|
||||
that generalizes goes in EXPERIENCES; the story of this particular session —
|
||||
only true for this system / this project / right now — goes in the FACT.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR (EXPERIENCES ONLY) — DO NOT cross this lightly.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
A memory MUST satisfy ALL of:
|
||||
(a) Solves a problem likely to recur for the same user
|
||||
(b) Solution is non-trivial: combines multiple pieces of info / steps, OR
|
||||
carries a decision rationale, OR captures a hard-won workaround, OR is a
|
||||
reusable procedure/recipe for an operational task (e.g. how to analyze &
|
||||
clean up disk usage, how to batch-organize files, how to inspect hardware
|
||||
health, how to set up a scheduled job)
|
||||
(c) A future agent / user would prefer looking it up over re-discovering it
|
||||
|
||||
IMPORTANT — analysis / diagnostic / investigation tasks COUNT:
|
||||
A task does NOT have to make a change or "fix" something to be worth remembering.
|
||||
Read-only / analytical / troubleshooting work is valuable when its PROCEDURE or
|
||||
CONCLUSION is reusable. Explicitly extract:
|
||||
- Analysis / diagnostic recipes: the reusable procedure + what it revealed
|
||||
(e.g. "how to analyze which folders eat C-drive space" — the scan commands,
|
||||
the gotchas hit, and the finding). "It only produced a report, no change"
|
||||
is NOT a reason to skip — the reusable METHOD is the memory.
|
||||
- Investigation outcomes, INCLUDING negative / not-found results
|
||||
(e.g. "searched folder X across C: and D: with rg / fs_search / SQL index —
|
||||
not found anywhere"). A recorded dead-end saves the next agent from repeating
|
||||
the same fruitless search, so it clears the bar.
|
||||
Do NOT reject a memory merely because "the assistant could regenerate the
|
||||
commands" or "nothing was actually modified". If the procedure or the conclusion
|
||||
would save future effort, keep it.
|
||||
|
||||
DO NOT extract:
|
||||
- Truly trivial single-step ops with no reusable procedure ("rename one file",
|
||||
"git status", "run ls once")
|
||||
- One-off issues already resolved with no transferable lesson
|
||||
- Information that is already in the repo/system (don't duplicate package.json)
|
||||
- Per-PR / per-session throwaway tasks unless they encode a reusable pattern
|
||||
|
||||
Heuristic: if the user (or a future agent doing the same kind of task) would
|
||||
want this written down instead of figuring it out again, keep it. A multi-step
|
||||
operational procedure (even a system/ops or read-only analysis/search one, not
|
||||
just code) usually qualifies. Only drop it if it is a single command any user
|
||||
would find via `--help` in 30 seconds AND carries no decision/gotcha worth
|
||||
remembering.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CODE WORK — what a future agent cannot re-derive, so it MUST be captured
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
When the segment is software work on a repository, the reusable method is only
|
||||
half of it. The other half is WHERE the work lives and WHICH VERSION won. Those
|
||||
cannot be recovered by reading the code later — the code shows the current state,
|
||||
not which branch it sits on, nor what was already rejected.
|
||||
|
||||
This section does NOT ask for a longer record. It asks you to SPEND THE SAME
|
||||
BUDGET on higher-value tokens: drop restated goals, narration of what the
|
||||
assistant "then decided to", and pasted code you are only echoing; spend those
|
||||
characters on the four items below instead. A record that omits the branch but
|
||||
quotes 30 lines of diff is a bad trade.
|
||||
|
||||
1. BRANCH / WORKING LOCATION — verbatim, in the fact `statement`. If the segment
|
||||
mentions or checks out `feat/x` / `fix/y`, that token MUST appear. An agent
|
||||
that resumes on the wrong branch produces changes that are silently thrown
|
||||
away, so this one token outranks every other detail.
|
||||
|
||||
2. WHICH VERSION WON — when the user changed their mind, name the winner and mark
|
||||
the loser in one clause: "settled on A; the interim B was dropped". Not both in
|
||||
full — one clause. Keeping only the latest is wrong: an agent that meets the
|
||||
rejected code must recognize it, or it will "fix" things back to the discarded
|
||||
variant. Phrases like `scratch that / go back to / actually, let's / no, not that`
|
||||
mark this: what follows is of record, what precedes is superseded.
|
||||
|
||||
3. EXACT COORDINATES, NOT PASTED CODE — repo-relative path
|
||||
(`qutebrowser/utils/debug.py`, never bare `debug.py`) plus the function /
|
||||
class touched. If a signature changed, give the new signature. If a constant,
|
||||
regex or literal was decided, quote THAT verbatim — but quote only it, not the
|
||||
surrounding block. One accurate line beats a paragraph of approximation.
|
||||
|
||||
4. VERIFIED vs MERELY WRITTEN — four words is enough ("tests run and passing" /
|
||||
"confirmed by git diff only, tests not run"). "the edit is in" and "the edit is in
|
||||
and tested" lead a future agent to do very different things next.
|
||||
|
||||
Anti-pattern — summarizing the goal and dropping the coordinates:
|
||||
bad: "refactored the colour parsing logic to be cleaner."
|
||||
good: "on feat/q4, in qutebrowser/config/configtypes.py QtColor._parse_value:
|
||||
settled on `mult = 360.0 if kind == 'h' else 255.0`; the interim version that
|
||||
used 255.0 for everything was dropped. Tests not run."
|
||||
(the good one is SHORTER than most bad ones — it just spends every character
|
||||
on something that cannot be re-derived)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE FIELDS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — Query-shaped name; phrase as how a user would later ASK.
|
||||
"publish a release to the xx platform", not "publish_release_internal"
|
||||
Self-contained (no "this", "the file", "we").
|
||||
|
||||
search_keys — 0 to 5 alternative phrasings capturing important aspects of
|
||||
solution that `task` alone wouldn't match. Phrase as questions
|
||||
or topics future users would ASK ABOUT. Distinct from task.
|
||||
Omit if task already covers everything.
|
||||
|
||||
solution — Complete, self-contained content the user actually needs:
|
||||
commands, credential locations (NOT credential VALUES),
|
||||
file paths, ordered steps, gotchas, decision rationale.
|
||||
Aggregate multi-turn end-to-end work into ONE solution if it
|
||||
forms a coherent task; split into multiple memories only when
|
||||
the user did unrelated tasks.
|
||||
|
||||
boundary_envs — Concrete pinning that solution depends on, multi-line "key: value":
|
||||
runtime / SDK / library + version
|
||||
API endpoint or method signature
|
||||
external service / middleware version
|
||||
env var NAME (not value)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
If solution is environment-agnostic, leave as "" (empty).
|
||||
Purpose: when the underlying SDK/middleware upgrades, future
|
||||
consumers can inspect this to decide if solution still applies.
|
||||
|
||||
confidence — 0.0 to 1.0
|
||||
1.0 user explicitly stated this as a rule / instruction
|
||||
0.7 inferred with good evidence from tool actions
|
||||
0.5 weakly inferred; one observation; uncertain
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT FIELD — EXACTLY ONE per segment, no value bar
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
You write ONE fact for the whole segment — a recoverable state record for this
|
||||
session, not a polished retrospective and never a list of separate findings. A
|
||||
future agent must be able to resume from it without mistaking a suggestion,
|
||||
inference, or partial search for something verified.
|
||||
|
||||
statement — A compact summary of THIS session. It has two jobs at once, and it
|
||||
must do BOTH: it is the retrieval vector (a future agent finds it by
|
||||
searching with a fresh issue report, phrased the way a user phrases a
|
||||
problem), and it is the payload that agent then acts on. So:
|
||||
|
||||
OPEN with the task and the symptom, in the words a user would use.
|
||||
This is what makes it findable — keep the surface form close to how
|
||||
the problem would be reported. Name the specific goal and object
|
||||
("reclaim disk space under D:\\WXWork", "adjust the hue multiplier
|
||||
in QtColor._parse_value on feat/q4", "locate the new_file_search
|
||||
folder"), never a generic "fix a bug" / "look for a file". Include
|
||||
the concrete symptom: the error string, the failing command, the
|
||||
wrong output.
|
||||
|
||||
THEN give the coordinates and the decision.
|
||||
repo-relative path, symbol, and branch for code work. State what was
|
||||
SETTLED ON, and in the same clause mark what was dropped
|
||||
("settled on X; the interim Y was dropped"). This is the part that
|
||||
cannot be re-derived by reading the code — the code only shows the
|
||||
winner, never that Y was tried and rejected. An agent that meets the
|
||||
rejected variant later must be able to recognize it, or it will
|
||||
"fix" the code back to the discarded version.
|
||||
|
||||
CLOSE with the evidence status.
|
||||
Exactly one of: completed, partially completed, failed, blocked, or
|
||||
still awaiting verification — plus how it was checked ("tests run and
|
||||
passing" / "confirmed by git diff only, tests not run"). Include
|
||||
negative results and dead ends without overstating them.
|
||||
|
||||
⚠️ Never stop at the narrative. "The user tried X and hit error Y; no
|
||||
solution found yet" is a wasted record: it retrieves fine but gives a
|
||||
future agent nothing to act on. If the session genuinely reached no
|
||||
decision, say what was ruled out and what the next unexplored lead is —
|
||||
that is the reusable part.
|
||||
|
||||
The subject of the sentences should be the code and the decision, not
|
||||
"the user". 2 to 4 sentences, flowing prose, not labelled sections.
|
||||
Hard ceiling ~400 latin characters (~200 CJK characters).
|
||||
|
||||
EVIDENCE FIDELITY — mandatory:
|
||||
- Record only actions actually executed and evidence explicitly
|
||||
present in the trace. Preserve material commands, flags,
|
||||
arguments, errors, and numbers verbatim. NEVER complete a
|
||||
command with customary flags or turn advice/plans into actions.
|
||||
- Preserve the distinction among attempted, observed, verified,
|
||||
inferred, and unresolved. Do not add those labels to the prose;
|
||||
express their meaning naturally, e.g. "tried … but was blocked by
|
||||
Access denied" or "suspect … , not yet verified".
|
||||
- Without tool output or explicit confirmation, NEVER claim a fix
|
||||
succeeded, a cause was established, a file/folder does not
|
||||
exist, or tests passed.
|
||||
- Scope every negative finding to what was actually searched and
|
||||
enumerate EVERY explicit coverage limit that could invalidate
|
||||
it, especially permission failures and maximum search depth:
|
||||
"not found within the searched parts of C:\\ and D:\\; some
|
||||
directories were skipped on Access denied, and the search was also
|
||||
depth-limited". Do not omit one limit to make the
|
||||
summary prettier. Never promote a partial search to global
|
||||
absence.
|
||||
- State causality as certain only when the trace directly verifies
|
||||
it. Otherwise retain the hedge ("suspected" / "inferred" / "not yet
|
||||
verified"); do not rewrite an
|
||||
incorrect test assumption as the system's root cause.
|
||||
|
||||
CRITICAL — this text IS the retrieval vector, and it is the only
|
||||
thing a future query gets matched against. A generic summary is
|
||||
worthless, because EVERY session looks like a generic summary: it
|
||||
would match every query and answer none of them. So you MUST name
|
||||
the concrete anchors that make THIS session findable — real file
|
||||
paths, function / symbol names, branch names, commands, error
|
||||
strings, versions, config keys and values, host / service names.
|
||||
If someone later searches for any one of those specifics, this
|
||||
summary has to be the thing that comes back.
|
||||
good: "fixing a failing `npm run build` in CI. Read
|
||||
.github/workflows/ci.yml and package.json; traced it to webpack
|
||||
5.74 hitting ERR_OSSL_EVP_UNSUPPORTED under node 18. Bumping the
|
||||
workflow's node to 20 made the build pass; node 18 still
|
||||
reproduces locally."
|
||||
good: "on branch feat/q0, in qutebrowser/utils/debug.py signal_name:
|
||||
PyQt<5.11 only exposes the name via repr(sig), and repr comes in
|
||||
both a bracketed and a parenthesised form. Settled on two patterns
|
||||
tried in order with fullmatch, breaking on the first hit; the
|
||||
single-regex version was dropped. Confirmed by git diff, tests not
|
||||
run."
|
||||
bad: "user wanted a build bug fixed, the agent looked at some config
|
||||
files, changed some code, and fixed it."
|
||||
(no path, no command, no error string, no version — nothing
|
||||
to retrieve on; every session reads exactly like this)
|
||||
bad: "fixed the signal-name parsing compatibility issue on a feature
|
||||
branch."
|
||||
(which branch? which file? which function? which of the two
|
||||
competing versions won? nothing here survives being
|
||||
retrieved a week later)
|
||||
Fidelity examples (bad → good):
|
||||
"ran Get-ChildItem -Force" → "ran Get-ChildItem"
|
||||
(the trace did not contain `-Force`; do not invent it)
|
||||
"confirmed new_file_search does not exist" →
|
||||
"new_file_search was not found within the searched parts of C:\\ and
|
||||
D:\\; some directories were skipped on Access denied or by the depth
|
||||
limit, so the investigation is only partly done"
|
||||
"identical payloads cause a spurious lock error" →
|
||||
"the verification script assumed the last writer is always a fixed
|
||||
rank; that assumption does not hold, so whether there is a lock bug
|
||||
is still unverified"
|
||||
|
||||
Do NOT record greetings or pure chatter. If the segment established
|
||||
nothing at all, output "fact": null.
|
||||
|
||||
files — 0..N absolute or repo-relative FILE OR DIRECTORY paths actually
|
||||
operated on or investigated during this session. Keep investigated
|
||||
roots such as `C:\\` and `D:\\`. Only include paths that were
|
||||
actually accessed/searched, not paths merely suggested or planned;
|
||||
[] if none apply.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT (given by SDK)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
When no workspace_id is present, the solution cannot rely on unstated
|
||||
project-local context — spell out any repo / path / service it needs inside
|
||||
`solution` or `boundary_envs` so the memory stays usable on its own.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"statement": "...",
|
||||
"files": ["..."]
|
||||
}}
|
||||
}}
|
||||
|
||||
MANDATORY: You MUST output at least ONE experience. Do NOT return an empty list.
|
||||
Even if the task looks simple or is a one-shot success, still capture the
|
||||
reusable essence of what was done (the method / command / how-to). Only the
|
||||
single most valuable experience is required when the session is thin — but never
|
||||
output an empty "experiences" array.
|
||||
|
||||
"fact" is a SINGLE object, never a list — one record per session. Use
|
||||
"fact": null only when the segment is pure chatter that established nothing.
|
||||
"""
|
||||
from ..prompts.en import CODING_EXTRACT_PROMPT_CODING as EXTRACT_PROMPT_CODING # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -839,15 +176,22 @@ class CodingMemoryExtractor:
|
||||
if str(message.content or "").strip()
|
||||
)
|
||||
|
||||
def _select_prompt_template(self, messages: List[ChatMessage]) -> Tuple[str, str]:
|
||||
"""Return (template, language) for this write's new messages."""
|
||||
if not is_chinese(self._language_text(messages)):
|
||||
return self._prompt_tmpl, "en"
|
||||
def _select_prompt_template(
|
||||
self, messages: List[ChatMessage], language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return (template, language code) for this write's new messages.
|
||||
|
||||
from .prompts_zh import EXTRACT_PROMPT_CODING_ZH, EXTRACT_PROMPT_ZH
|
||||
|
||||
prompt = EXTRACT_PROMPT_CODING_ZH if self.scene == "coding" else EXTRACT_PROMPT_ZH
|
||||
return prompt, "zh"
|
||||
Explicit ``language`` wins; otherwise zh/en auto-detection over the new
|
||||
user text (unchanged behaviour).
|
||||
"""
|
||||
code = resolve_language(language, self._language_text(messages))
|
||||
if code == "en":
|
||||
return self._prompt_tmpl, code
|
||||
name = (
|
||||
"CODING_EXTRACT_PROMPT_CODING" if self.scene == "coding"
|
||||
else "CODING_EXTRACT_PROMPT"
|
||||
)
|
||||
return get_prompt(name, code), code
|
||||
|
||||
async def extract(
|
||||
self,
|
||||
@@ -861,12 +205,14 @@ class CodingMemoryExtractor:
|
||||
branch: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
request_id: str = "",
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[CodingMemoryDraft], List[WorkingFactDraft]]:
|
||||
"""
|
||||
Args:
|
||||
messages: 本次 write 的完整新消息;只做单条限长,不裁 turn
|
||||
history_messages: 之前 write 的历史消息;仅取最近 5 个 user-turn 作消解上下文
|
||||
user_id / agent_id / workspace_id / branch / session_id: SDK 注入
|
||||
language: 显式 prompt 语言码;None 时按新 user 文本做 zh/en 自动检测
|
||||
|
||||
Returns:
|
||||
(经验草稿, 事实草稿)。两层都可能为空。只有显式启用
|
||||
@@ -892,27 +238,26 @@ class CodingMemoryExtractor:
|
||||
|
||||
history_block = self._format_conversation(history_window).strip()
|
||||
new_block = self._format_conversation(new_messages).strip()
|
||||
prompt_tmpl, prompt_language = self._select_prompt_template(new_messages)
|
||||
if prompt_language == "zh":
|
||||
conversation_block = (
|
||||
"最近 5 个用户轮次(仅作上下文,不要重复抽取):\n"
|
||||
+ (history_block or "(无)")
|
||||
+ "\n\n本次新增消息(只从这次完整写入中抽取):\n"
|
||||
+ (new_block or "(无)")
|
||||
)
|
||||
else:
|
||||
conversation_block = (
|
||||
"LAST 5 USER TURNS (CONTEXT ONLY; do not extract them again):\n"
|
||||
+ (history_block or "(none)")
|
||||
+ "\n\nNEW MESSAGES (extract only from this complete write):\n"
|
||||
+ (new_block or "(none)")
|
||||
)
|
||||
explicit_language = normalize_language(language)
|
||||
prompt_tmpl, prompt_language = self._select_prompt_template(
|
||||
new_messages, explicit_language,
|
||||
)
|
||||
empty_block = get_prompt("CODING_EMPTY_BLOCK", prompt_language)
|
||||
conversation_block = get_prompt(
|
||||
"CODING_CONVERSATION_BLOCK", prompt_language,
|
||||
).format(
|
||||
history=history_block or empty_block,
|
||||
new=new_block or empty_block,
|
||||
)
|
||||
|
||||
prompt = prompt_tmpl.format(
|
||||
workspace_present=str(bool(workspace_id)).lower(),
|
||||
branch_present=str(bool(branch)).lower(),
|
||||
conversation_block=conversation_block,
|
||||
)
|
||||
prompt = with_output_language(
|
||||
prompt, prompt_language, explicit=explicit_language is not None,
|
||||
)
|
||||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
|
||||
@@ -23,7 +23,12 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
from ..core.embed_service import EmbedService
|
||||
from ..utils.lang_detect import is_chinese
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from .store import CodingMemoryStore
|
||||
from .types import (
|
||||
|
||||
@@ -206,143 +211,10 @@ def _subject_conflict(new_subject: str, old_subject: str) -> bool:
|
||||
# General subject-isolation rules belong in the prompt; lookalike examples
|
||||
# encourage pattern matching instead of teaching the rule.
|
||||
|
||||
FACT_RECONCILE_PROMPT = """\
|
||||
You maintain session-level WORKING FACTS. Each statement summarizes one whole
|
||||
trace/session: the user's task, the rough approach, and the result. It is not an
|
||||
atomic claim.
|
||||
|
||||
Every new session fact below WILL be stored; that is not your decision. Your only
|
||||
job is to detect task continuation: is the new session continuing to advance the
|
||||
SAME user task as an existing session fact? If so, SUPERSEDE the older fact so
|
||||
the links form a cross-session progress chain for that task.
|
||||
|
||||
Mark an existing fact as superseded ONLY when there is strong evidence that:
|
||||
- The subjects match: both records are about the SAME entity — the same
|
||||
person, the same file/module, the same host/service. A shared subject is
|
||||
necessary but not sufficient; AND
|
||||
- The user goal is materially the SAME, not merely in the same topic/project;
|
||||
AND
|
||||
- The new trace continues, retries, extends, verifies, or finishes the work
|
||||
recorded by the existing trace.
|
||||
|
||||
Do NOT supersede when:
|
||||
- The two records name different subjects. Two people in one group, two files
|
||||
in one module, two hosts in one cluster are PARALLEL, not successive: one
|
||||
person's state is not a newer version of another's. Where a subject is shown
|
||||
as (unspecified), fall back to judging by the statement alone.
|
||||
- The sessions merely mention the same file, symbol, component, repository,
|
||||
error, or technology but pursue different user goals.
|
||||
- One task only supplies context or a technique for another task.
|
||||
- The relationship is uncertain. Be conservative: INSERT is the safe default;
|
||||
merging unrelated sessions destroys their separate task history.
|
||||
|
||||
Treat each statement as evidence-bounded text. Do not reinterpret an inference
|
||||
inside it as an established cause or use a shared conjecture/error as proof that
|
||||
the user goals are the same. Your decision is only about task continuation; do
|
||||
not "correct", complete, or embellish either session record.
|
||||
|
||||
Examples:
|
||||
existing: "The user needed to fix CI's npm run build; traced it to
|
||||
ERR_OSSL_EVP_UNSUPPORTED under Node 18, workflow not yet changed."
|
||||
new: "The user continued fixing the same CI build; raised Node in
|
||||
.github/workflows/ci.yml to 20 and npm run build passed."
|
||||
→ SUPERSEDE
|
||||
|
||||
existing: "The user needed to fix JWT expiry handling in
|
||||
auth/middleware.ts; refresh logic was added."
|
||||
new: "The user needed performance logging added to
|
||||
auth/middleware.ts; a latency metric was introduced."
|
||||
→ keep both
|
||||
(same file/project, different user goal)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW SESSION FACTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING SESSION FACTS (per new fact, retrieved by statement similarity)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "supersedes": ["fact_xxx"], "reason": "continues the same CI fix task"}},
|
||||
{{"draft_idx": 1, "supersedes": []}}
|
||||
]
|
||||
}}
|
||||
|
||||
Only use fact ids that appear in the candidate list for that same draft_idx.
|
||||
If nothing is superseded anywhere, output {{"ops": []}}.
|
||||
"""
|
||||
from ..prompts.en import CODING_FACT_RECONCILE_PROMPT as FACT_RECONCILE_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
CHAIN_SUMMARY_PROMPT = """\
|
||||
A user task in a working-memory store has progressed across sessions. Below are
|
||||
the session summaries in that task's progress chain, oldest first, ending with
|
||||
the current session.
|
||||
|
||||
Summarize how the SAME task progressed across sessions to its current state.
|
||||
Cover every important stage in chronological order. For each stage, preserve
|
||||
where available: the actual action or change, its direct result, and any
|
||||
unresolved issue or limiting condition. End with an explicit current status.
|
||||
|
||||
Rules:
|
||||
- This is compression only. Report ONLY what the entries say; never add or
|
||||
complete a command, cause, motive, action, test result, success state, or
|
||||
global negative conclusion.
|
||||
- Preserve evidence status exactly: attempted is not completed, observed is
|
||||
not verified, inferred is not established, and unresolved stays unresolved.
|
||||
Keep uncertainty words and search/permission/coverage limits.
|
||||
- State the current ending status faithfully (completed, partially completed,
|
||||
failed, blocked, or still awaiting verification). Never upgrade it based on
|
||||
a plausible causal story or on a later entry that did not verify it.
|
||||
- When entries mention a suspected cause and a later result, connect them
|
||||
causally only if an entry directly says that cause was verified.
|
||||
- Preserve concrete retrieval anchors: file paths, symbols, commands, error
|
||||
strings, versions, config keys/values, and important numeric values that
|
||||
matter to the progress.
|
||||
- For chains of 2-3 generations, do not over-compress the progression into one
|
||||
sentence. Prefer 2-4 compact paragraphs when useful, or readable prose with
|
||||
a clear task goal, stage-by-stage progression, and current status.
|
||||
- Do not mechanically copy the full history. Omit repetition and low-value
|
||||
detail while retaining important actions, direct results, and leftovers.
|
||||
- Do not invent stage headings or dates. Mention a date only when the entries
|
||||
make it meaningful; bare timestamps are noise.
|
||||
- If the entries say older revisions were omitted, explicitly state that
|
||||
earlier history was omitted; do not imply the supplied entries are complete.
|
||||
- Write in the same language the entries are written in.
|
||||
- No preamble or enclosing quotes.
|
||||
|
||||
Example
|
||||
entries:
|
||||
(2026-07-20) The user needed to fix CI's npm run build; traced it to
|
||||
ERR_OSSL_EVP_UNSUPPORTED under Node 18, workflow not yet
|
||||
changed.
|
||||
(2026-07-24) The user continued that CI fix; raised Node in
|
||||
.github/workflows/ci.yml to 20, but the dependency cache
|
||||
still reproduced the old error.
|
||||
(now) The user continued verifying the same fix; after clearing the
|
||||
npm cache, npm run build passed.
|
||||
output:
|
||||
The task goal is fixing CI's npm run build. It was first traced to
|
||||
ERR_OSSL_EVP_UNSUPPORTED under Node 18, with the workflow not yet changed;
|
||||
then .github/workflows/ci.yml was raised to Node 20, but the dependency
|
||||
cache kept the old error present. Finally, after clearing the npm cache,
|
||||
npm run build passed, so this build fix is currently verified as complete.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
ENTRIES
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{chain_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (the summary itself, nothing else)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
"""
|
||||
from ..prompts.en import CODING_CHAIN_SUMMARY_PROMPT as CHAIN_SUMMARY_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
def _fact_drafts_language_text(drafts: List[WorkingFactDraft]) -> str:
|
||||
@@ -357,37 +229,34 @@ def _fact_drafts_language_text(drafts: List[WorkingFactDraft]) -> str:
|
||||
def build_fact_reconcile_prompt(
|
||||
drafts: List[WorkingFactDraft],
|
||||
candidates: List[List[WorkingFact]],
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return the language-matched fact reconcile prompt and language."""
|
||||
if is_chinese(_fact_drafts_language_text(drafts)):
|
||||
from .prompts_zh import FACT_RECONCILE_PROMPT_ZH
|
||||
"""Return the fact reconcile prompt and its language code.
|
||||
|
||||
template = FACT_RECONCILE_PROMPT_ZH
|
||||
language = "zh"
|
||||
else:
|
||||
template = FACT_RECONCILE_PROMPT
|
||||
language = "en"
|
||||
return template.format(
|
||||
Explicit ``language`` wins; otherwise zh/en auto-detection over the drafts.
|
||||
"""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, _fact_drafts_language_text(drafts))
|
||||
prompt = get_prompt("CODING_FACT_RECONCILE_PROMPT", code).format(
|
||||
drafts_block=WorkingFactReconciler._format_drafts(drafts),
|
||||
candidates_block=WorkingFactReconciler._format_candidates(drafts, candidates),
|
||||
), language
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||
|
||||
|
||||
def build_chain_summary_prompt(
|
||||
chain_block: str,
|
||||
*,
|
||||
current_statement: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return the language-matched cross-session summary prompt and language."""
|
||||
if is_chinese(current_statement or chain_block):
|
||||
from .prompts_zh import CHAIN_SUMMARY_PROMPT_ZH
|
||||
|
||||
template = CHAIN_SUMMARY_PROMPT_ZH
|
||||
language = "zh"
|
||||
else:
|
||||
template = CHAIN_SUMMARY_PROMPT
|
||||
language = "en"
|
||||
return template.format(chain_block=chain_block), language
|
||||
"""Return the cross-session summary prompt and its language code."""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, current_statement or chain_block)
|
||||
prompt = get_prompt("CODING_CHAIN_SUMMARY_PROMPT", code).format(
|
||||
chain_block=chain_block,
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -455,9 +324,11 @@ class WorkingFactReconciler:
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
"""
|
||||
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
|
||||
language 为显式 prompt 语言码(None → zh/en 自动检测),同时用于演变总结。
|
||||
|
||||
Returns:
|
||||
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
||||
@@ -477,7 +348,7 @@ class WorkingFactReconciler:
|
||||
)
|
||||
|
||||
try:
|
||||
supersedes = await self._llm_decide(drafts, candidates)
|
||||
supersedes = await self._llm_decide(drafts, candidates, language=language)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[fact-reconcile] LLM decide failed, insert without supersede: {e}"
|
||||
@@ -495,6 +366,7 @@ class WorkingFactReconciler:
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
language=language,
|
||||
)
|
||||
logger.info(
|
||||
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
||||
@@ -588,13 +460,14 @@ class WorkingFactReconciler:
|
||||
self,
|
||||
drafts: List[WorkingFactDraft],
|
||||
candidates: List[List[WorkingFact]],
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[int, List[str]]:
|
||||
"""返回 {draft_idx: [被它覆盖的旧 fact_id, ...]}。"""
|
||||
# 一条候选都没有就没得判,省一次 LLM 调用
|
||||
if not any(candidates):
|
||||
return {}
|
||||
|
||||
prompt, _ = build_fact_reconcile_prompt(drafts, candidates)
|
||||
prompt, _ = build_fact_reconcile_prompt(drafts, candidates, language=language)
|
||||
resp = await self.llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=self.max_tokens,
|
||||
@@ -638,7 +511,8 @@ class WorkingFactReconciler:
|
||||
return ordered[-MAX_FACT_CHAIN_ENTRIES:], truncated
|
||||
|
||||
async def _summarize_chain(
|
||||
self, history: List[WorkingFact], current: str, *, truncated: bool = False
|
||||
self, history: List[WorkingFact], current: str, *, truncated: bool = False,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""让 LLM 把同一任务的跨 session 进展写成简短摘要。
|
||||
|
||||
@@ -659,6 +533,7 @@ class WorkingFactReconciler:
|
||||
prompt, _ = build_chain_summary_prompt(
|
||||
"\n".join(rows),
|
||||
current_statement=current,
|
||||
language=language,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -790,6 +665,7 @@ class WorkingFactReconciler:
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
inserted: List[WorkingFact] = []
|
||||
ops: List[FactReconcileOp] = []
|
||||
@@ -873,7 +749,8 @@ class WorkingFactReconciler:
|
||||
retired, user_id=user_id
|
||||
)
|
||||
new_fact.fact_chain_summary = await self._summarize_chain(
|
||||
history, new_fact.statement, truncated=truncated
|
||||
history, new_fact.statement, truncated=truncated,
|
||||
language=language,
|
||||
)
|
||||
# 摘要之外再把历代自己的 details 原样过继一份。摘要保叙事,过继保数值,
|
||||
# 两者不重复:details 本来就不进摘要(_collect_history 只读 statement)。
|
||||
|
||||
@@ -1,745 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""HY Memory working/coding 链路使用的中文提示词模板。"""
|
||||
"""HY Memory working/coding 链路使用的中文提示词模板(兼容入口)。
|
||||
|
||||
|
||||
EXTRACT_PROMPT_ZH = """\
|
||||
你需要从一段任务执行会话中抽取可长期保留的 WORKING 记忆。
|
||||
“working”会话是指用户通过工具操作计算机或系统的任何实操任务——既包括软件/代码
|
||||
工作(编写、调试、重构、构建、部署、API/library 使用),也包括系统与运维工作
|
||||
(运行 shell/PowerShell 命令、磁盘/存储分析或清理、文件与文件夹操作、安装/配置
|
||||
软件、环境/设备设置、硬件/磁盘健康检查、备份、调度/自动化、数据处理)。
|
||||
|
||||
你需要从同一段会话中产出两个层次:
|
||||
|
||||
EXPERIENCES(过程性,“如何做”)——可复用的方法;只有达到下述 VALUE BAR 时才
|
||||
抽取。每条是一个 (task, search_keys, solution, boundary_envs) 组合。
|
||||
|
||||
FACT(情节性,“这次发生了什么”)——关于本次会话的恰好一条记录:用户想做什么、
|
||||
大致如何推进、最终结果如何。它不受价值门槛限制,但绝不能超过一条。
|
||||
|
||||
这两个层次回答不同问题,并分别存储。可泛化的方法放入 EXPERIENCES;只对这个系统、
|
||||
这个项目、此时此刻成立的本次会话经历放入 FACT。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR(仅适用于 EXPERIENCES)——不要轻易越过此门槛。
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
一条记忆必须同时满足以下全部条件:
|
||||
(a) 解决了同一用户以后很可能再次遇到的问题
|
||||
(b) 解决方案并非琐碎操作:它组合了多项信息/多个步骤,或者包含决策理由,或者记录了
|
||||
来之不易的 workaround,或者是某项运维任务的可复用流程/配方(例如如何分析并
|
||||
清理磁盘占用、如何批量整理文件、如何检查硬件健康、如何设置 scheduled job)
|
||||
(c) 未来的 agent / 用户会更愿意查阅它,而不是重新摸索
|
||||
|
||||
重要——分析、诊断、调查任务也算:
|
||||
一项任务不必实际修改或“修复”任何东西才值得记忆。只读、分析、故障排查工作,只要其
|
||||
流程或结论可复用,就有价值。明确抽取:
|
||||
- 分析/诊断配方:可复用的流程以及它揭示的结果(例如“如何分析哪些文件夹占用了
|
||||
C-drive 空间”——扫描命令、遇到的坑和最终发现)。“只生成了报告,没有修改”
|
||||
不是跳过理由——可复用的方法本身就是记忆。
|
||||
- 调查结果,包括否定结果或未找到结果(例如“使用 rg / fs_search / SQL index
|
||||
在 C: 和 D: 中搜索 folder X——任何位置都未找到”)。记录一条死路可以避免下一个
|
||||
agent 重复同样无果的搜索,因此达到了价值门槛。
|
||||
不要仅仅因为“assistant 可以重新生成这些命令”或“实际上没有修改任何东西”就拒绝
|
||||
一条记忆。只要流程或结论能节省未来的工作,就保留它。
|
||||
|
||||
不要抽取:
|
||||
- 没有可复用流程、真正琐碎的单步操作(“rename one file”、“git status”、
|
||||
“run ls once”)
|
||||
- 已经解决且没有可迁移经验的一次性问题
|
||||
- repo/system 中已经存在的信息(不要重复 package.json)
|
||||
- 每个 PR / 每次会话的一次性任务,除非其中编码了可复用模式
|
||||
- 向用户询问缺失信息这一行为。请求参数、列出未知项或引导用户提供必填字段属于
|
||||
对话行为,不是方法:每个 agent 都知道如何提问,因此以后没有任何值得查阅的内容。
|
||||
绝不要产出 task 或 search_keys 形如“how to ask for X”、“gather the missing Y”、
|
||||
“eliciting required fields”、“what to clarify before starting”的 experience。
|
||||
注意,这条限制只适用于 EXPERIENCES——会话最终停在等待用户的状态是真实状态,
|
||||
应写入 FACT。
|
||||
|
||||
如果该段会话因为等待用户而没有取得任何实质进展——assistant 只是询问参数,或者在
|
||||
开展任何实际工作前就被阻塞——输出 "experiences": [],只写 FACT。此时尚未学到任何
|
||||
东西,因此没有方法可存。这一点比看起来更重要:从停滞中提炼出的 experience 在检索时
|
||||
会像一项真正的技能;同一任务的后续片段会匹配它,再次向用户提问而不是继续推进,导致
|
||||
这种停滞在余下会话中不断自我复制。应根据工具实际做了什么判断是否有实质进展,而不是
|
||||
根据该轮是否生成了流畅文本。
|
||||
|
||||
启发式标准:如果用户(或未来执行同类任务的 agent)希望把它记下来,而不是以后再重新
|
||||
摸索,就保留。多步骤运维流程通常符合条件——包括 system/ops 或只读分析/搜索流程,
|
||||
不局限于代码。只有当它只是任何用户都能在 30 秒内通过 `--help` 找到的单条命令,并且
|
||||
不包含值得记忆的决策或坑点时,才丢弃。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE 字段
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — 采用查询式名称;写成用户以后会如何提问。
|
||||
使用“publish a release to the xx platform”,不要使用
|
||||
“publish_release_internal”。
|
||||
必须自包含(不要使用“this”、“the file”、“we”)。
|
||||
|
||||
search_keys — 0 到 5 种替代表述,用来覆盖仅靠 `task` 无法匹配、但对 solution 很
|
||||
重要的方面。写成未来用户会询问的问题或主题,且必须与 task 有区别。
|
||||
如果 task 已覆盖全部内容,则省略。
|
||||
|
||||
solution — 用户实际需要的完整、自包含内容:命令、凭据位置(不是凭据值)、
|
||||
文件路径、有序步骤、坑点、决策理由。如果多轮端到端工作构成一个连贯
|
||||
任务,应聚合成一个 solution;只有用户执行了互不相关的任务时才拆成
|
||||
多条记忆。
|
||||
|
||||
boundary_envs — solution 所依赖的具体限定条件,使用多行“key: value”:
|
||||
runtime / SDK / library + version
|
||||
API endpoint 或 method signature
|
||||
external service / middleware version
|
||||
env var NAME(不是值)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
如果 solution 与环境无关,填写 ""(空字符串)。
|
||||
目的:底层 SDK/middleware 升级后,未来使用者可以据此判断 solution
|
||||
是否仍然适用。
|
||||
|
||||
confidence — 0.0 到 1.0
|
||||
1.0 用户明确将其陈述为规则/指令
|
||||
0.7 根据工具操作及充分证据推断
|
||||
0.5 弱推断;只有一次观察;不确定
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT 字段——每段会话恰好一条,无价值门槛
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
你要为整段会话写一条 FACT——它是本次会话可恢复的状态记录,不是精心润色的回顾,也
|
||||
绝不是分散发现的列表。未来 agent 必须能够从中继续推进,且不会把建议、推断或局部搜索
|
||||
误认为已经验证的事实。
|
||||
|
||||
subject — 本次会话围绕谁或什么展开:工作所针对或服务的单一实体。使用简短名词
|
||||
短语,不要写句子,不要使用动词。选择会话真正围绕的最具体实体,而不是
|
||||
所使用的工具:
|
||||
会话服务于某个具名人员时,使用人名
|
||||
(“Dylan”、“Matthew”)
|
||||
工作针对代码时,使用 file / module / symbol
|
||||
(“auth/middleware.ts”、“parse_plan_text”)
|
||||
运维工作使用 service / host / device / drive
|
||||
(“D:\\WXWork”、“redis-cache-prod”、“ci.yml”)
|
||||
这是 HARD KEY,不是散文:关于不同人员或不同文件的两次会话必须得到不同
|
||||
subject;关于同一实体的两次会话必须得到拼写完全一致的同一字符串。
|
||||
优先使用 trace 中出现的精确标识符,而不是对它的描述。如果该段确实没有
|
||||
单一 subject,输出 ""。
|
||||
|
||||
这很重要:subject 是决定后续会话是否可以按“同一任务的继续”覆盖本条
|
||||
记录的闸门。填错后,一个人的状态可能静默替换另一个人的状态。
|
||||
|
||||
statement — 本次会话的紧凑记录。它分为两部分,而第二部分最容易丢失。
|
||||
保留任务范围内的给定量和具体约束,不要用泛泛的结果评价取代它们。
|
||||
每条规则只写一次:重复的指令文字会挤占记忆真正需要保留的数据。
|
||||
|
||||
PART 1——发生了什么,约 300 个拉丁字符(约 150 个 CJK 字符)。
|
||||
从具体目标和对象开始(“reclaim disk space taken by D:\\WXWork”、
|
||||
“locate the new_file_search folder”),不要写泛泛的“fix a bug”/
|
||||
“find a file”。然后概述检查、尝试或修改了什么。最后说明结束状态,
|
||||
且语义必须精确对应以下之一:completed、partially completed、failed、
|
||||
blocked、still awaiting verification。纳入否定结果和死路,但不要夸大。
|
||||
|
||||
PART 2——TASK-SCOPED GIVENS:整个任务赖以运行的全部给定信息,即使并非
|
||||
本段会话建立,或看起来基础到不值得写。包括绑定关系(每个 name/symbol/
|
||||
label 代表什么)、任务提供而不是推导出的固定值、采用的约定(indexing
|
||||
base、units、timezone、defaults),以及本段自己的结果和值(写“x = 7”,
|
||||
不要只写“the value was computed”)。
|
||||
|
||||
将 PART 2 写成数据,每行一项,值保持给定形式:
|
||||
retry budget = 5; timeout 30s; index starts at 0
|
||||
step 3 input: n=64, tolerance 1e-6
|
||||
slot B: type=private, cost 688.7-721.3 per unit
|
||||
每一行几乎都应由值构成。保留 trace 的 notation——symbols、units、
|
||||
numbers、spellings——删掉其句子外壳:去掉那些只是宣告“这是给定量”的
|
||||
短语,而非给定量本身(“I would prefer”、“it should be somewhere
|
||||
between … and …”)。这些短语会在每项重复;一个 15 项列表会为它们
|
||||
支付 15 次空间成本。
|
||||
每一条 FACT 都要携带这些信息;绝不要写“as above”或“same as the
|
||||
previous session”——检索只会按相似度返回少量 facts,被指代项可能不在
|
||||
其中。
|
||||
|
||||
总长度尽量控制在 1200 字符以内。如果 PART 2 放不下,应优化 notation,
|
||||
绝不能削减覆盖范围:statement 是 retrieval vector,也是后续 segment
|
||||
唯一能读到的字段(details 既不做 embedding,也不会被注入),因此从中
|
||||
删除的内容就永久丢失。压缩给定量周围的措辞,绝不要压缩给定量本身。
|
||||
|
||||
检验标准:如果下一段看不到原始材料,能否仅凭此记录继续任务?只有肯定
|
||||
答案才合格。缺失 binding 不会明显报错——该名称仍看起来有意义,于是会
|
||||
按其通用含义被理解,后续答案可以很流畅,却回答了另一个问题。
|
||||
|
||||
LANGUAGE——使用与 session trace 相同的语言:statement 是 retrieval
|
||||
vector,而查询会使用用户的工作语言;改用另一种语言会让每次检索都付出
|
||||
跨语言匹配损失。
|
||||
|
||||
EVIDENCE FIDELITY——只记录 trace 中实际执行的操作和明确存在的证据。
|
||||
对关键命令、flags、arguments、errors、numbers 保持原样。
|
||||
绝不要按惯例补全命令 flags,也不要把建议或计划写成已经执行的动作:
|
||||
“ran Get-ChildItem -Force” → “ran Get-ChildItem”
|
||||
(trace 中没有 `-Force`,不要杜撰)
|
||||
每个否定发现都必须限定在实际搜索范围内,并逐一列出可能使其失效的所有
|
||||
明确覆盖限制,尤其是 permission failures 和 depth limits:保留当时的
|
||||
roots、filters、depth(“not found under C:\\ and D:\\ at depth 3;
|
||||
some directories were not covered due to Access denied”),绝不要扩大为
|
||||
“does not exist”。不要为了让记录更漂亮而漏掉任何限制。
|
||||
绝不要把局部搜索提升为全局不存在。
|
||||
被阻塞的尝试必须保留其 blocker。
|
||||
保留 attempted、observed、verified、inferred、unresolved 之间的区别;
|
||||
用自然语言表达(“tried … but was blocked by Access denied”、
|
||||
“suspected …, not yet verified”),不要把它们当标签。没有工具输出或
|
||||
明确确认时,绝不要声称修复已成功、原因已确定或路径不存在。
|
||||
|
||||
不要记录问候或纯闲聊——只有这种情况才输出 "fact": null。仅仅陈述了
|
||||
要求,或尚未开展工作就停滞的会话,仍然建立了信息。
|
||||
|
||||
details — 本次会话的具体 OUTPUT,逐字复制。`statement` 是摘要,而摘要必然会
|
||||
丢掉各个具体值;后续检索几乎总是在寻找某个具体值,而非大意。因此,只要
|
||||
会话产出了可寻址的结果,就以原始形式和结构复现在这里:
|
||||
plan / schedule / itinerary → 每一行、每一天、每个 slot、每个 price
|
||||
都严格按原文保留
|
||||
config 或 code block → 保留整个 block
|
||||
command 的关键 output → 保留承载发现的行(sizes、counts、versions、
|
||||
error text)
|
||||
structured result(table、records list)→ 保留各行
|
||||
保留原始布局——line breaks、`Day 1:` / key: value prefixes、units、
|
||||
currency symbols、ids。不要再次概括,不要重排,不要舍入数字,不要翻译。
|
||||
|
||||
只复制 trace 中实际存在的内容。绝不要填补空缺:如果某个 slot 没有填,
|
||||
就保持未填或明确说明——杜撰的值比缺失的值更糟,因为它会被检索并使用。
|
||||
长度控制在约 4000 字符;如果输出更长,保留承载具体值的部分,删去装饰性
|
||||
文字,并注明删掉了什么。
|
||||
|
||||
如果本次会话未产生此类 artifact(尚无发现的纯调查,或等待用户导致的
|
||||
停滞),填写 ""。
|
||||
|
||||
此字段不属于 retrieval vector——`statement` 和 `subject` 负责找到
|
||||
记录,`details` 在找到后提供具体值。因此不要在此堆砌检索诱饵,也不要
|
||||
重复摘要;只写原始内容。
|
||||
|
||||
files — 0..N 个本次会话中实际操作或调查过的绝对或 repo-relative FILE OR
|
||||
DIRECTORY paths。保留调查过的 roots,例如 `C:\\` 和 `D:\\`。只包含
|
||||
实际访问/搜索过的路径,不包含仅建议或计划访问的路径;不适用时填写 []。
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT(由 SDK 提供)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
没有 workspace_id 时,solution 不能依赖未声明的项目本地上下文——必须在 `solution`
|
||||
或 `boundary_envs` 中明确写出其所需的 repo / path / service,使记忆能够独立使用。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"subject": "...",
|
||||
"statement": "...",
|
||||
"files": ["..."],
|
||||
"details": "..."
|
||||
}}
|
||||
}}
|
||||
|
||||
默认应进行抽取。当该段会话确实完成了实际工作时,必须输出至少一条 experience——即使
|
||||
任务看似简单或一次成功,也要记录所做工作的可复用本质(method / command / how-to)。
|
||||
会话内容较少时,只保留最有价值的一条 experience 即可。
|
||||
|
||||
唯一例外是上述停滞情况:如果该段因为缺少信息、被阻塞在向用户提问而没有开展实际工作,
|
||||
输出 "experiences": [],只写 fact。不要为了满足本指令而杜撰 experience——杜撰的
|
||||
“how to ask for the departure city”比没有记忆更糟,因为以后会被检索并照做。只有这个
|
||||
原因允许空数组;实际开展过工作时,绝不能把空数组当捷径。
|
||||
|
||||
"fact" 是单个对象,绝不是列表——每次会话一条记录。
|
||||
|
||||
始终写 fact。fact 的门槛不是“这是否有价值”,而是“是否发生了任何事情”;能够到达
|
||||
你的 segment 已经越过这条门槛。这包括看起来只是 setup 而非工作的片段:仅有一条用户
|
||||
消息陈述 requirements、parameters 或 starting state,即使没有运行工具,也恰恰是后续
|
||||
会话无法自行重建的材料——把需求、参数、起始状态连同情形一起写进 `statement`(紧凑
|
||||
记法,不要照抄对话原句),不要推给 `details`(`details` 不进检索向量,读取侧也不再
|
||||
注入它)。只有完全没有内容的问候和纯闲聊才使用 "fact": null。有疑问时应写 fact:
|
||||
多余记录只多占一行,而漏掉记录会伤害每一次需要它、却无从知道它曾存在的后续会话。
|
||||
文本已迁到 ``hy_memory/prompts/zh_hans.py``(``CODING_*``),这里保留旧名字。
|
||||
"""
|
||||
|
||||
|
||||
EXTRACT_PROMPT_CODING_ZH = """\
|
||||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
║ HARD CONSTRAINT——先阅读本节;它覆盖本提示词后文任何看似要求叙事的措辞。 ║
|
||||
║ ║
|
||||
║ 你不是在撰写某个人做过什么的报告。你要写的是 FUTURE AGENT 为采取行动所需要 ║
|
||||
║ 的信息。该 agent 已有自己的任务;它不关心这个用户想要什么。它需要知道:此处 ║
|
||||
║ 做出了什么决定、位于哪些代码坐标、尝试并否决了什么,以及是否经过验证。 ║
|
||||
║ ║
|
||||
║ 具体来说,每个 `statement` 和每个 `solution` 都必须遵守: ║
|
||||
║ · 语法主语必须是 CODE 或 THE DECISION——绝不能是“the user”,也绝不能是 ║
|
||||
║ “the agent”。不要写“The user attempted to…”、“The user wanted…”、 ║
|
||||
║ “The agent then checked…”。 ║
|
||||
║ · 绝不要用“the session was completed”、“the error was noted”、 ║
|
||||
║ “no solution was found yet”之类流水账短语收尾。结尾应写决定及其验证 ║
|
||||
║ 状态。如果没有做出决定,就以已排除的内容和下一条尚未探索的线索收尾——这才 ║
|
||||
║ 是可复用部分。 ║
|
||||
║ · 保留具体症状(error string、failing command、wrong output)——未来搜索 ║
|
||||
║ 正是通过这些内容命中。 ║
|
||||
║ ║
|
||||
║ bad: “The user attempted to write a QTable to RST with header_rows and ║
|
||||
║ hit a TypeError. The session was completed with the error noted.” ║
|
||||
║ good: “ascii.rst's RST writer rejects header_rows: ║
|
||||
║ `tbl.write(..., format='ascii.rst', header_rows=['name','unit'])` ║
|
||||
║ raises TypeError because RST.__init__ does not forward it, unlike ║
|
||||
║ ascii.fixed_width which accepts it. No fix written — the writer ║
|
||||
║ needs the same header_rows plumbing as fixed_width.” ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
你需要从一段任务执行会话中抽取可长期保留的 WORKING 记忆。
|
||||
“working”会话是指用户通过工具操作计算机或系统的任何实操任务——既包括软件/代码
|
||||
工作(编写、调试、重构、构建、部署、API/library 使用),也包括系统与运维工作
|
||||
(运行 shell/PowerShell 命令、磁盘/存储分析或清理、文件与文件夹操作、安装/配置
|
||||
软件、环境/设备设置、硬件/磁盘健康检查、备份、调度/自动化、数据处理)。
|
||||
|
||||
你需要从同一段会话中产出两个层次:
|
||||
|
||||
EXPERIENCES(过程性,“如何做”)——可复用的方法;只有达到下述 VALUE BAR 时才
|
||||
抽取。每条是一个 (task, search_keys, solution, boundary_envs) 组合。
|
||||
|
||||
FACT(情节性,“这次确立了什么”)——关于本次会话的恰好一条记录。它不是用户做了
|
||||
什么的叙事,而要按以下顺序记录:任务及其具体症状(使它以后能被检索到)、代码坐标、
|
||||
最终采用了什么以及拒绝了什么、验证推进到什么程度。它不受价值门槛限制,但绝不能
|
||||
超过一条。
|
||||
|
||||
这两个层次回答不同问题,并分别存储。可泛化的方法放入 EXPERIENCES;只对这个系统、
|
||||
这个项目、此时此刻成立的本次会话经历放入 FACT。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR(仅适用于 EXPERIENCES)——不要轻易越过此门槛。
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
一条记忆必须同时满足以下全部条件:
|
||||
(a) 解决了同一用户以后很可能再次遇到的问题
|
||||
(b) 解决方案并非琐碎操作:它组合了多项信息/多个步骤,或者包含决策理由,或者记录了
|
||||
来之不易的 workaround,或者是某项运维任务的可复用流程/配方(例如如何分析并
|
||||
清理磁盘占用、如何批量整理文件、如何检查硬件健康、如何设置 scheduled job)
|
||||
(c) 未来的 agent / 用户会更愿意查阅它,而不是重新摸索
|
||||
|
||||
重要——分析、诊断、调查任务也算:
|
||||
一项任务不必实际修改或“修复”任何东西才值得记忆。只读、分析、故障排查工作,只要其
|
||||
流程或结论可复用,就有价值。明确抽取:
|
||||
- 分析/诊断配方:可复用的流程以及它揭示的结果(例如“如何分析哪些文件夹占用了
|
||||
C-drive 空间”——扫描命令、遇到的坑和最终发现)。“只生成了报告,没有修改”
|
||||
不是跳过理由——可复用的方法本身就是记忆。
|
||||
- 调查结果,包括否定结果或未找到结果(例如“使用 rg / fs_search / SQL index
|
||||
在 C: 和 D: 中搜索 folder X——任何位置都未找到”)。记录一条死路可以避免下一个
|
||||
agent 重复同样无果的搜索,因此达到了价值门槛。
|
||||
不要仅仅因为“assistant 可以重新生成这些命令”或“实际上没有修改任何东西”就拒绝
|
||||
一条记忆。只要流程或结论能节省未来的工作,就保留它。
|
||||
|
||||
不要抽取:
|
||||
- 没有可复用流程、真正琐碎的单步操作(“rename one file”、“git status”、
|
||||
“run ls once”)
|
||||
- 已经解决且没有可迁移经验的一次性问题
|
||||
- repo/system 中已经存在的信息(不要重复 package.json)
|
||||
- 每个 PR / 每次会话的一次性任务,除非其中编码了可复用模式
|
||||
|
||||
启发式标准:如果用户(或未来执行同类任务的 agent)希望把它记下来,而不是以后再重新
|
||||
摸索,就保留。多步骤运维流程通常符合条件——包括 system/ops 或只读分析/搜索流程,
|
||||
不局限于代码。只有当它只是任何用户都能在 30 秒内通过 `--help` 找到的单条命令,并且
|
||||
不包含值得记忆的决策或坑点时,才丢弃。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CODE WORK——未来 agent 无法重新推导的信息,因此必须捕获
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
当该段是针对 repository 的软件工作时,可复用方法只占一半。另一半是工作位于哪里,
|
||||
以及哪个版本胜出。这些无法通过以后阅读代码恢复——代码只能展示当前状态,无法说明它
|
||||
位于哪个 branch,也无法说明哪些方案已被否决。
|
||||
|
||||
本节不是要求写得更长,而是要求把相同预算花在价值更高的 token 上:删掉重复目标、
|
||||
assistant“随后决定”做什么的叙事,以及只是复述的粘贴代码;把这些字符用于下面四项。
|
||||
一条遗漏 branch、却引用 30 行 diff 的记录是糟糕的取舍。
|
||||
|
||||
1. BRANCH / WORKING LOCATION——逐字写入 fact `statement`。如果该段提到或 checkout
|
||||
了 `feat/x` / `fix/y`,这个 token 必须出现。在错误 branch 上恢复工作的 agent
|
||||
会产生随后被静默丢弃的改动,因此这一个 token 的优先级高于其他任何细节。
|
||||
|
||||
2. WHICH VERSION WON——当用户改变主意时,用一个分句指出胜出者并标记落败者:
|
||||
“settled on A; the interim B was dropped”。不要完整保留两个版本,只需一个分句。
|
||||
只保留最新版本是错误的:以后遇到被否决代码的 agent 必须识别它,否则会把代码
|
||||
“修复”回已经丢弃的变体。`scratch that / go back to / actually, let's / no, not that`
|
||||
之类措辞就是标志:后面的内容才是正式结论,前面的内容已被 superseded。
|
||||
|
||||
3. EXACT COORDINATES, NOT PASTED CODE——使用 repo-relative path
|
||||
(`qutebrowser/utils/debug.py`,绝不要只写 `debug.py`)以及触及的 function /
|
||||
class。如果 signature 发生变化,给出新的 signature。如果决定的是 constant、regex
|
||||
或 literal,逐字引用它——但只引用这一项,不要引用周围 block。准确的一行胜过一段
|
||||
近似描述。
|
||||
|
||||
4. VERIFIED vs MERELY WRITTEN——四个词就足够(“tests run and passing”/
|
||||
“confirmed by git diff only, tests not run”)。“the edit is in”和“the edit is in
|
||||
and tested”会让未来 agent 采取完全不同的下一步。
|
||||
|
||||
反例——只总结目标,却丢掉坐标:
|
||||
bad: “refactored the colour parsing logic to be cleaner.”
|
||||
good: “on feat/q4, in qutebrowser/config/configtypes.py QtColor._parse_value:
|
||||
settled on `mult = 360.0 if kind == 'h' else 255.0`; the interim version that
|
||||
used 255.0 for everything was dropped. Tests not run.”
|
||||
(good 往往比 bad 还短——它只是把每个字符都用在无法重新推导的信息上)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE 字段
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — 采用查询式名称;写成用户以后会如何提问。
|
||||
使用“publish a release to the xx platform”,不要使用
|
||||
“publish_release_internal”。
|
||||
必须自包含(不要使用“this”、“the file”、“we”)。
|
||||
|
||||
search_keys — 0 到 5 种替代表述,用来覆盖仅靠 `task` 无法匹配、但对 solution 很
|
||||
重要的方面。写成未来用户会询问的问题或主题,且必须与 task 有区别。
|
||||
如果 task 已覆盖全部内容,则省略。
|
||||
|
||||
solution — 用户实际需要的完整、自包含内容:命令、凭据位置(不是凭据值)、
|
||||
文件路径、有序步骤、坑点、决策理由。如果多轮端到端工作构成一个连贯
|
||||
任务,应聚合成一个 solution;只有用户执行了互不相关的任务时才拆成
|
||||
多条记忆。
|
||||
|
||||
boundary_envs — solution 所依赖的具体限定条件,使用多行“key: value”:
|
||||
runtime / SDK / library + version
|
||||
API endpoint 或 method signature
|
||||
external service / middleware version
|
||||
env var NAME(不是值)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
如果 solution 与环境无关,填写 ""(空字符串)。
|
||||
目的:底层 SDK/middleware 升级后,未来使用者可以据此判断 solution
|
||||
是否仍然适用。
|
||||
|
||||
confidence — 0.0 到 1.0
|
||||
1.0 用户明确将其陈述为规则/指令
|
||||
0.7 根据工具操作及充分证据推断
|
||||
0.5 弱推断;只有一次观察;不确定
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT 字段——每段会话恰好一条,无价值门槛
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
你要为整段会话写一条 FACT——它是本次会话可恢复的状态记录,不是精心润色的回顾,也
|
||||
绝不是分散发现的列表。未来 agent 必须能够从中继续推进,且不会把建议、推断或局部搜索
|
||||
误认为已经验证的事实。
|
||||
|
||||
statement — 本次会话的紧凑摘要。它同时承担两个职责,且两者都必须完成:它既是
|
||||
retrieval vector(未来 agent 用一份新 issue report 搜索它;措辞方式
|
||||
与用户报告问题的方式一致),也是该 agent 找到后要据以行动的 payload。
|
||||
因此:
|
||||
|
||||
开头写任务和症状,使用用户会使用的措辞。
|
||||
这是保证可检索性的关键——表述应尽量贴近问题被报告时的表面形式。
|
||||
点明具体目标和对象(“reclaim disk space under D:\\WXWork”、
|
||||
“adjust the hue multiplier in QtColor._parse_value on feat/q4”、
|
||||
“locate the new_file_search folder”),绝不要写泛泛的“fix a bug”/
|
||||
“look for a file”。包含具体症状:error string、failing command、
|
||||
wrong output。
|
||||
|
||||
然后给出坐标和决定。
|
||||
对代码工作写 repo-relative path、symbol 和 branch。说明最终 SETTLED ON
|
||||
什么,并在同一分句标记丢弃了什么(“settled on X; the interim Y was
|
||||
dropped”)。这部分无法通过阅读代码重新推导——代码只展示胜出者,从不
|
||||
展示 Y 曾被尝试后否决。以后遇到被否决变体的 agent 必须能识别它,
|
||||
否则会把代码“修复”回已丢弃版本。
|
||||
|
||||
结尾写证据状态。
|
||||
必须精确对应以下之一:completed、partially completed、failed、blocked、
|
||||
still awaiting verification;还要写明如何检查(“tests run and passing”/
|
||||
“confirmed by git diff only, tests not run”)。纳入否定结果和死路,
|
||||
但不要夸大。
|
||||
|
||||
⚠️ 绝不要停留在叙事层。“The user tried X and hit error Y; no solution found
|
||||
yet”是一条浪费的记录:它容易被检索到,却不能指导未来 agent 行动。如果
|
||||
本次确实没有得出决定,就说明排除了什么、下一条尚未探索的线索是什么——
|
||||
这才是可复用部分。
|
||||
|
||||
句子的主语应是 code 和 decision,而不是“the user”。使用 2 到 4 个连贯
|
||||
句子,不要写带标签的小节。硬上限约 400 个拉丁字符(约 200 个 CJK 字符)。
|
||||
|
||||
EVIDENCE FIDELITY——强制要求:
|
||||
- 只记录 trace 中实际执行的操作和明确存在的证据。对关键 commands、flags、
|
||||
arguments、errors、numbers 保持原样。绝不要按惯例补全 command flags,
|
||||
也不要把 advice/plans 写成已执行的动作。
|
||||
- 保留 attempted、observed、verified、inferred、unresolved 之间的区别。
|
||||
不要把这些词作为标签写进正文;自然表达其含义,例如“tried … but was
|
||||
blocked by Access denied”或“suspect …, not yet verified”。
|
||||
- 没有工具输出或明确确认时,绝不要声称修复成功、原因已经确定、file/folder
|
||||
不存在或 tests passed。
|
||||
- 每个否定发现都必须限定在实际搜索范围内,并逐一列出可能使其失效的所有
|
||||
明确覆盖限制,尤其是 permission failures 和 maximum search depth:
|
||||
“not found within the searched parts of C:\\ and D:\\; some directories
|
||||
were skipped on Access denied, and the search was also depth-limited”。
|
||||
不要为了摘要更漂亮而漏掉任何限制。绝不要把局部搜索提升为全局不存在。
|
||||
- 只有 trace 直接验证了因果关系时,才能确定地陈述因果。否则保留限定词
|
||||
(“suspected”/“inferred”/“not yet verified”);不要把错误的测试假设
|
||||
改写成系统的 root cause。
|
||||
|
||||
关键——这段文字就是 retrieval vector,也是未来 query 唯一会匹配的内容。
|
||||
泛泛摘要毫无价值,因为每次会话看起来都像泛泛摘要:它会匹配所有查询,
|
||||
却无法回答任何一个。因此必须写出使本次会话可检索的具体 anchors——真实
|
||||
file paths、function / symbol names、branch names、commands、error strings、
|
||||
versions、config keys and values、host / service names。以后有人搜索其中任一
|
||||
具体项时,本摘要必须能被返回。
|
||||
good: “fixing a failing `npm run build` in CI. Read
|
||||
.github/workflows/ci.yml and package.json; traced it to webpack
|
||||
5.74 hitting ERR_OSSL_EVP_UNSUPPORTED under node 18. Bumping the
|
||||
workflow's node to 20 made the build pass; node 18 still
|
||||
reproduces locally.”
|
||||
good: “on branch feat/q0, in qutebrowser/utils/debug.py signal_name:
|
||||
PyQt<5.11 only exposes the name via repr(sig), and repr comes in
|
||||
both a bracketed and a parenthesised form. Settled on two patterns
|
||||
tried in order with fullmatch, breaking on the first hit; the
|
||||
single-regex version was dropped. Confirmed by git diff, tests not
|
||||
run.”
|
||||
bad: “user wanted a build bug fixed, the agent looked at some config
|
||||
files, changed some code, and fixed it.”
|
||||
(没有 path、command、error string、version——没有任何可检索锚点;
|
||||
每次会话都可以写成这样)
|
||||
bad: “fixed the signal-name parsing compatibility issue on a feature
|
||||
branch.”
|
||||
(哪个 branch?哪个 file?哪个 function?两个竞争版本中谁胜出?
|
||||
一周后检索到它时,没有任何信息仍然有用)
|
||||
忠实度示例(bad → good):
|
||||
“ran Get-ChildItem -Force” → “ran Get-ChildItem”
|
||||
(trace 中没有 `-Force`,不要杜撰)
|
||||
“confirmed new_file_search does not exist” →
|
||||
“new_file_search was not found within the searched parts of C:\\ and
|
||||
D:\\; some directories were skipped on Access denied or by the depth
|
||||
limit, so the investigation is only partly done”
|
||||
“identical payloads cause a spurious lock error” →
|
||||
“the verification script assumed the last writer is always a fixed
|
||||
rank; that assumption does not hold, so whether there is a lock bug
|
||||
is still unverified”
|
||||
|
||||
不要记录问候或纯闲聊。如果该段完全没有建立任何信息,输出 "fact": null。
|
||||
|
||||
files — 0..N 个本次会话中实际操作或调查过的绝对或 repo-relative FILE OR
|
||||
DIRECTORY paths。保留调查过的 roots,例如 `C:\\` 和 `D:\\`。只包含
|
||||
实际访问/搜索过的路径,不包含仅建议或计划访问的路径;不适用时填写 []。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT(由 SDK 提供)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
没有 workspace_id 时,solution 不能依赖未声明的项目本地上下文——必须在 `solution`
|
||||
或 `boundary_envs` 中明确写出其所需的 repo / path / service,使记忆能够独立使用。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"statement": "...",
|
||||
"files": ["..."]
|
||||
}}
|
||||
}}
|
||||
|
||||
强制要求:必须输出至少一条 experience,不要返回空列表。即使任务看似简单或一次成功,
|
||||
也要记录所做工作的可复用本质(method / command / how-to)。会话内容较少时,只需要
|
||||
最有价值的一条 experience——但绝不能输出空的 "experiences" array。
|
||||
|
||||
"fact" 是单个对象,绝不是列表——每次会话一条记录。只有该段是没有建立任何信息的
|
||||
纯闲聊时,才使用 "fact": null。
|
||||
"""
|
||||
|
||||
|
||||
RECONCILE_PROMPT_ZH = """\
|
||||
你需要决定如何将新的 coding memory drafts 与用户已有的 memories 合并。
|
||||
|
||||
对每条新 draft,选择以下一种 action:
|
||||
ADD — 没有已有 memory 描述相同的任务/方面
|
||||
UPDATE — 某条已有 memory 描述相同任务;用新 draft 替换它
|
||||
(solution 可能已经变化,env 可能已经升级,等等)
|
||||
SKIP — 与已有内容重复;没有新信息
|
||||
|
||||
此外,当新内容使已有候选失效或作废时,可以针对已有 candidates 输出 DELETE ops:
|
||||
DELETE — 新 conversation 明确 supersedes/cancels/invalidates 某条已有 memory,
|
||||
且无法通过 UPDATE 修复。示例:
|
||||
* 用户说“don't use X anymore”,而 X 只存在于另一条独立 memory 中
|
||||
(不是本次正在 ADD/UPDATE 的那条)
|
||||
* candidate 的 boundary_envs(service / endpoint / SDK)已经消失,
|
||||
且新 conversation 确认其已 deprecated
|
||||
|
||||
规则:
|
||||
- “Same task”是指已有 memory 和新 draft 都可以作为同一个未来用户查询的有效答案。
|
||||
- 如果 solution 不同,优先 UPDATE(不保留历史版本)。
|
||||
- 如果 task 和 solution 都高度一致,选择 SKIP。
|
||||
- 只有在存在明确证据表明 candidate 已 invalid/obsolete 时才 DELETE。绝不要仅仅因为
|
||||
candidate 看起来“stale”就 DELETE。
|
||||
- 产生 DELETE 时,输出的 ops 数量可以超过 drafts 数量。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW DRAFTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING CANDIDATES(按每条 draft 的 task similarity 检索)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "action": "ADD"}},
|
||||
{{"draft_idx": 1, "action": "UPDATE", "target_experience_id": "exp_xxx", "reason": "..."}},
|
||||
{{"draft_idx": 1, "action": "DELETE", "target_experience_id": "exp_yyy", "reason": "user explicitly deprecated Y"}},
|
||||
{{"draft_idx": 2, "action": "SKIP", "reason": "duplicate"}}
|
||||
]
|
||||
}}
|
||||
|
||||
如果没有 drafts,输出 {{"ops": []}}。
|
||||
"""
|
||||
|
||||
|
||||
FACT_RECONCILE_PROMPT_ZH = """\
|
||||
你负责维护 session-level WORKING FACTS。每条 statement 都概括一整段 trace/session:
|
||||
用户的任务、大致方法和结果。它不是一条 atomic claim。
|
||||
|
||||
下面每条新的 session fact 都一定会被存储;这不是你要决定的事情。你的唯一任务是识别
|
||||
task continuation:新 session 是否在继续推进某条已有 session fact 所记录的同一个用户
|
||||
任务?如果是,就将较旧 fact 标记为 SUPERSEDE,使这些链接为该任务构成跨 session 的
|
||||
进展链。
|
||||
|
||||
只有在存在充分证据同时满足以下条件时,才将已有 fact 标记为 superseded:
|
||||
- subjects 匹配:两条记录围绕同一个实体——同一个 person、同一个 file/module、
|
||||
同一个 host/service。subject 相同是必要条件,但不是充分条件;并且
|
||||
- 用户目标在实质上相同,而不只是处于同一 topic/project;并且
|
||||
- 新 trace 在继续、重试、扩展、验证或完成已有 trace 所记录的工作。
|
||||
|
||||
以下情况不要 supersede:
|
||||
- 两条记录的 subjects 不同。同一 group 中的两个人、同一 module 中的两个 files、
|
||||
同一 cluster 中的两个 hosts 是并行关系,不是先后关系:一个人的状态不是另一个人
|
||||
状态的新版本。如果 subject 显示为 (unspecified),退回到只根据 statement 判断。
|
||||
- 两次 session 只是提到了相同 file、symbol、component、repository、error 或
|
||||
technology,但追求不同的用户目标。
|
||||
- 一个任务只是为另一个任务提供 context 或 technique。
|
||||
- 关系不确定。应保守判断:INSERT 是安全默认值;合并无关 session 会毁掉它们各自
|
||||
独立的任务历史。
|
||||
|
||||
把每条 statement 当作有证据边界的文本。不要把其中的 inference 重新解释为已经确定的
|
||||
cause,也不要把共同的 conjecture/error 当作用户目标相同的证据。你的判断仅涉及 task
|
||||
continuation;不要“纠正”、补全或润色任何一条 session 记录。
|
||||
|
||||
示例:
|
||||
existing: “需要修复 CI 的 npm run build;已追踪到 Node 18 下的
|
||||
ERR_OSSL_EVP_UNSUPPORTED,workflow 尚未修改。”
|
||||
new: “继续修复同一个 CI build;已将 .github/workflows/ci.yml 中的 Node
|
||||
提升到 20,且 npm run build 通过。”
|
||||
→ SUPERSEDE
|
||||
|
||||
existing: “需要修复 auth/middleware.ts 中的 JWT expiry handling;已添加
|
||||
refresh logic。”
|
||||
new: “需要在 auth/middleware.ts 中添加 performance logging;已引入
|
||||
latency metric。”
|
||||
→ 两者都保留
|
||||
(同一 file/project,不同用户目标)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW SESSION FACTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING SESSION FACTS(按每条新 fact 的 statement similarity 检索)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "supersedes": ["fact_xxx"], "reason": "continues the same CI fix task"}},
|
||||
{{"draft_idx": 1, "supersedes": []}}
|
||||
]
|
||||
}}
|
||||
|
||||
只能使用同一个 draft_idx 的 candidate list 中实际出现的 fact ids。
|
||||
如果任何位置都没有 fact 被 superseded,输出 {{"ops": []}}。
|
||||
"""
|
||||
|
||||
|
||||
CHAIN_SUMMARY_PROMPT_ZH = """\
|
||||
working-memory store 中的某个用户任务已跨多个 sessions 推进。下面是该任务进展链中的
|
||||
session summaries,按从最早到最新排列,最后一条是当前 session。
|
||||
|
||||
总结同一个任务如何跨 sessions 推进到当前状态。按时间顺序覆盖每个重要阶段。对每个
|
||||
阶段,在有信息时保留:实际 action 或 change、其 direct result,以及任何 unresolved
|
||||
issue 或 limiting condition。最后明确写出当前状态。
|
||||
|
||||
规则:
|
||||
- 这只是压缩。只能报告 entries 实际陈述的内容;绝不要新增或补全 command、cause、
|
||||
motive、action、test result、success state 或 global negative conclusion。
|
||||
- 精确保留证据状态:attempted 不等于 completed,observed 不等于 verified,inferred
|
||||
不等于 established,unresolved 必须保持 unresolved。保留 uncertainty words 以及
|
||||
search/permission/coverage limits。
|
||||
- 忠实陈述当前结束状态(completed、partially completed、failed、blocked 或
|
||||
still awaiting verification)。绝不要因为某个看似合理的因果故事,或后续 entry
|
||||
并未验证的内容而提升状态。
|
||||
- 如果 entries 提到 suspected cause 和后续 result,只有某条 entry 直接说明该 cause
|
||||
已被 verified 时,才可在两者之间建立因果联系。
|
||||
- 保留具体 retrieval anchors:file paths、symbols、commands、error strings、versions、
|
||||
config keys/values,以及对进展重要的 numeric values。
|
||||
- 对 2-3 代的 chains,不要把进展过度压缩成一句话。适合时优先使用 2-4 个紧凑段落,
|
||||
或使用可读性良好的 prose,清楚呈现 task goal、逐阶段 progression 和 current status。
|
||||
- 不要机械复制完整 history。在保留重要 actions、direct results 和 leftovers 的同时,
|
||||
省略重复及低价值细节。
|
||||
- 不要杜撰 stage headings 或 dates。只有 entries 使日期有实际意义时才提及日期;
|
||||
单独的 timestamps 是噪声。
|
||||
- 如果 entries 说明更早 revisions 已被省略,必须明确指出 earlier history was omitted;
|
||||
不要暗示所提供的 entries 是完整历史。
|
||||
- 使用与 entries 相同的语言。
|
||||
- 不要写前言,也不要用引号包裹输出。
|
||||
|
||||
示例
|
||||
entries:
|
||||
(2026-07-20) 需要修复 CI 的 npm run build;已追踪到 Node 18 下的
|
||||
ERR_OSSL_EVP_UNSUPPORTED,workflow 尚未修改。
|
||||
(2026-07-24) 继续修复同一个 CI 问题;已将 .github/workflows/ci.yml 中的 Node
|
||||
提升到 20,但 dependency cache 仍复现旧错误。
|
||||
(now) 继续验证同一修复;清除 npm cache 后,npm run build 通过。
|
||||
output:
|
||||
任务目标是修复 CI 的 npm run build。最初将问题追踪到 Node 18 下的
|
||||
ERR_OSSL_EVP_UNSUPPORTED,当时尚未修改 workflow;随后将
|
||||
.github/workflows/ci.yml 提升到 Node 20,但 dependency cache 仍使旧错误继续
|
||||
出现。最后,清除 npm cache 后 npm run build 通过,因此该 build 修复目前已验证完成。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
ENTRIES
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{chain_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(只输出 summary 本身,不要其他内容)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
"""
|
||||
from ..prompts.zh_hans import ( # noqa: F401
|
||||
CODING_CHAIN_SUMMARY_PROMPT as CHAIN_SUMMARY_PROMPT_ZH,
|
||||
CODING_EXTRACT_PROMPT as EXTRACT_PROMPT_ZH,
|
||||
CODING_EXTRACT_PROMPT_CODING as EXTRACT_PROMPT_CODING_ZH,
|
||||
CODING_FACT_RECONCILE_PROMPT as FACT_RECONCILE_PROMPT_ZH,
|
||||
CODING_RECONCILE_PROMPT as RECONCILE_PROMPT_ZH,
|
||||
)
|
||||
@@ -21,7 +21,12 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
from ..core.embed_service import EmbedService
|
||||
from ..utils.lang_detect import is_chinese
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from .judge import _store_coding_log
|
||||
from .store import CodingMemoryStore
|
||||
from .types import CodingMemory, CodingMemoryDraft, ReconcileOp
|
||||
@@ -50,57 +55,7 @@ def _resolve_configured_temperature(llm_provider) -> Optional[float]:
|
||||
# Prompt
|
||||
# ================================================================
|
||||
|
||||
RECONCILE_PROMPT = """\
|
||||
You decide how to merge new coding memory drafts with the user's existing memories.
|
||||
|
||||
For each new draft, decide one of:
|
||||
ADD — no existing memory describes the same task/aspect
|
||||
UPDATE — an existing memory describes the same task; the new draft replaces it
|
||||
(solution may have changed, env may have upgraded, etc.)
|
||||
SKIP — duplicate of existing; nothing new
|
||||
|
||||
Additionally, you may emit DELETE ops on existing candidates when the new
|
||||
content makes them obsolete or invalid:
|
||||
DELETE — the new conversation EXPLICITLY supersedes/cancels/invalidates an
|
||||
existing memory that cannot be repaired via UPDATE. Examples:
|
||||
* user says "don't use X anymore" and X is only present as a
|
||||
separate memory (not the one being added/updated)
|
||||
* candidate's boundary_envs (service / endpoint / SDK) is gone
|
||||
and the new conversation confirms the deprecation
|
||||
|
||||
Rules:
|
||||
- "Same task" means the existing memory and the new draft would both be valid
|
||||
answers to the same future user query.
|
||||
- If solution differs, prefer UPDATE (we don't keep history).
|
||||
- If both task and solution match closely, SKIP.
|
||||
- DELETE only when there is EXPLICIT evidence the candidate is invalid/obsolete.
|
||||
Never DELETE just because a candidate looks "stale".
|
||||
- Output ops can outnumber drafts when DELETEs are produced.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW DRAFTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING CANDIDATES (per draft, retrieved by task similarity)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "action": "ADD"}},
|
||||
{{"draft_idx": 1, "action": "UPDATE", "target_experience_id": "exp_xxx", "reason": "..."}},
|
||||
{{"draft_idx": 1, "action": "DELETE", "target_experience_id": "exp_yyy", "reason": "user explicitly deprecated Y"}},
|
||||
{{"draft_idx": 2, "action": "SKIP", "reason": "duplicate"}}
|
||||
]
|
||||
}}
|
||||
|
||||
If there are no drafts, output {{"ops": []}}.
|
||||
"""
|
||||
from ..prompts.en import CODING_RECONCILE_PROMPT as RECONCILE_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
def _drafts_language_text(drafts: List[CodingMemoryDraft]) -> str:
|
||||
@@ -119,24 +74,23 @@ def _drafts_language_text(drafts: List[CodingMemoryDraft]) -> str:
|
||||
def build_experience_reconcile_prompt(
|
||||
drafts: List[CodingMemoryDraft],
|
||||
candidates_per_draft: List[List[CodingMemory]],
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return the language-matched experience reconcile prompt and language."""
|
||||
"""Return the experience reconcile prompt and its language code.
|
||||
|
||||
Explicit ``language`` wins; otherwise zh/en auto-detection over the drafts.
|
||||
"""
|
||||
drafts_block = CodingMemoryReconciler._format_drafts(drafts)
|
||||
candidates_block = CodingMemoryReconciler._format_candidates(
|
||||
drafts, candidates_per_draft,
|
||||
)
|
||||
if is_chinese(_drafts_language_text(drafts)):
|
||||
from .prompts_zh import RECONCILE_PROMPT_ZH
|
||||
|
||||
template = RECONCILE_PROMPT_ZH
|
||||
language = "zh"
|
||||
else:
|
||||
template = RECONCILE_PROMPT
|
||||
language = "en"
|
||||
return template.format(
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, _drafts_language_text(drafts))
|
||||
prompt = get_prompt("CODING_RECONCILE_PROMPT", code).format(
|
||||
drafts_block=drafts_block,
|
||||
candidates_block=candidates_block,
|
||||
), language
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -186,6 +140,7 @@ class CodingMemoryReconciler:
|
||||
user_id: str = "",
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str = "",
|
||||
language: Optional[str] = None,
|
||||
) -> List[ReconcileOp]:
|
||||
"""
|
||||
执行 reconcile + 持久化。返回执行的 ReconcileOp[](含成功执行的 ADD/UPDATE/DELETE/SKIP)。
|
||||
@@ -214,7 +169,7 @@ class CodingMemoryReconciler:
|
||||
_decide_resp = None
|
||||
try:
|
||||
ops, _decide_prompt, _decide_raw, _decide_resp = await self._llm_decide(
|
||||
drafts, candidates_per_draft
|
||||
drafts, candidates_per_draft, language=language,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-reconcile] LLM decide failed, fallback to ADD-all: {e}")
|
||||
@@ -311,9 +266,12 @@ class CodingMemoryReconciler:
|
||||
self,
|
||||
drafts: List[CodingMemoryDraft],
|
||||
candidates_per_draft: List[List[CodingMemory]],
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[ReconcileOp], str, str, Any]:
|
||||
"""返回 (ops, prompt, raw_response, llm_resp),供上层打 CODING_RECONCILE log。"""
|
||||
prompt, _ = build_experience_reconcile_prompt(drafts, candidates_per_draft)
|
||||
prompt, _ = build_experience_reconcile_prompt(
|
||||
drafts, candidates_per_draft, language=language,
|
||||
)
|
||||
|
||||
resp = await self.llm_provider.complete(
|
||||
prompt=prompt,
|
||||
|
||||
@@ -81,10 +81,13 @@ class CodingWriter:
|
||||
request_id: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
|
||||
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
|
||||
事实上;不传则用收到请求的时间(与 chat 链路一致)。language 是显式 prompt
|
||||
语言码,透传给抽取、经验 reconcile、事实 reconcile 与演变总结;None 时各步
|
||||
沿用 zh/en 自动检测。返回响应 dict(兼容 client.add 返回结构):
|
||||
{
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
@@ -151,6 +154,7 @@ class CodingWriter:
|
||||
branch=branch,
|
||||
session_id=session_id,
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
finally:
|
||||
# 历史服务于下一次 write;即使本次没跨过价值门槛,也不能丢掉上下文。
|
||||
@@ -187,6 +191,7 @@ class CodingWriter:
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id or "",
|
||||
language=language,
|
||||
)
|
||||
if drafts else []
|
||||
)
|
||||
@@ -204,6 +209,7 @@ class CodingWriter:
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
language=language,
|
||||
)
|
||||
|
||||
experience_ids = [op.target_experience_id for op in ops
|
||||
|
||||
@@ -1264,6 +1264,12 @@ class MemoryConfig:
|
||||
enable_agent: bool = False
|
||||
debug: bool = False
|
||||
metrics_enabled: bool = True
|
||||
# LLM prompt 语言(client 默认值;env: MEMORY_LANGUAGE)。canonical 码见
|
||||
# hy_memory.prompts.SUPPORTED_LANGUAGES(en / zh-Hans / zh-Hant / ja / es / de /
|
||||
# pt / fr / ko / ru / id),也接受别名 zh、zh-CN → zh-Hans,zh-TW、zh-HK →
|
||||
# zh-Hant,pt-BR → pt。add() 等的 per-call language 优先于它。None(默认)=
|
||||
# 沿用 zh/en 自动检测。显式指定时记忆正文用该语言书写。未知码抛 ValueError。
|
||||
language: Optional[str] = None
|
||||
# Runtime-only HTTP transport factory. MemHub uses this for request-cost
|
||||
# budgeting without restricting the embedded SDK's local store access.
|
||||
http_transport_factory: Optional[Callable[[], Any]] = field(
|
||||
@@ -1278,6 +1284,11 @@ class MemoryConfig:
|
||||
self.enable_agent = _get_env_bool("MEMORY_ENABLE_AGENT", False)
|
||||
self.debug = _get_env_bool("MEMORY_DEBUG", False)
|
||||
self.metrics_enabled = _get_env_bool("MEMORY_METRICS", True)
|
||||
if self.language is None:
|
||||
self.language = os.getenv("MEMORY_LANGUAGE", "").strip() or None
|
||||
from .prompts import normalize_language
|
||||
|
||||
self.language = normalize_language(self.language)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "MemoryConfig":
|
||||
@@ -1328,6 +1339,10 @@ class MemoryConfig:
|
||||
config.metrics_enabled = config_dict.get(
|
||||
"metrics_enabled", config.metrics_enabled
|
||||
)
|
||||
if config_dict.get("language") is not None:
|
||||
from .prompts import normalize_language
|
||||
|
||||
config.language = normalize_language(config_dict["language"])
|
||||
|
||||
return config
|
||||
|
||||
@@ -1386,6 +1401,7 @@ class MemoryConfig:
|
||||
"enable_graph": self.enable_graph,
|
||||
"enable_agent": self.enable_agent,
|
||||
"metrics_enabled": self.metrics_enabled,
|
||||
"language": self.language,
|
||||
}
|
||||
|
||||
def print_config(self) -> None:
|
||||
|
||||
@@ -64,59 +64,15 @@ def _topic_lock(graph_store, isolation_key: str, topic_id: str) -> asyncio.Lock:
|
||||
return lock
|
||||
|
||||
|
||||
TOPIC_SUMMARY_PROMPT = """You are summarizing everything known about ONE topic.
|
||||
|
||||
## Topic
|
||||
|
||||
{topic_name}
|
||||
|
||||
## Memories under this topic (oldest → newest)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
Write a single dense summary of what is known about the user with respect to this
|
||||
topic.
|
||||
|
||||
Rules:
|
||||
|
||||
* 2–4 sentences, third person, starting with "The user ..." (or the named person
|
||||
if the memories are about someone else).
|
||||
* Cover the topic as a whole — state the current situation, and mention how it
|
||||
developed only when the change matters.
|
||||
* Preserve concrete details: names, places, numbers, dates.
|
||||
* Do NOT invent anything that is not in the memories.
|
||||
* Do NOT enumerate the memories one by one; synthesize them.
|
||||
* Output language must match the language of the memories.
|
||||
|
||||
Return ONLY the summary text, no preamble, no JSON, no quotes."""
|
||||
|
||||
|
||||
TOPIC_SUMMARY_PROMPT_ZH = """你在为**一个主题**总结已知的全部信息。
|
||||
|
||||
## 主题
|
||||
|
||||
{topic_name}
|
||||
|
||||
## 该主题下的记忆(时间正序)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
写一段关于用户在这个主题上的高密度摘要。
|
||||
|
||||
要求:
|
||||
|
||||
* 2–4 句,第三人称,以"用户…"开头(若记忆讲的是别人,则以那个人的名字开头)。
|
||||
* 覆盖整个主题 —— 说明当前状况;只在变化本身重要时才提它是怎么演变过来的。
|
||||
* 保留具体细节:人名、地点、数字、日期。
|
||||
* 绝不编造记忆里没有的内容。
|
||||
* 不要逐条罗列记忆,要综合成一段。
|
||||
* 输出语言与记忆的语言一致。
|
||||
|
||||
只返回摘要正文,不要任何前言、不要 JSON、不要引号。"""
|
||||
# 多语言版本见 hy_memory/prompts/<language>.py;旧名字保留为别名。
|
||||
from ...prompts import ( # noqa: E402
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ...prompts.en import TOPIC_SUMMARY_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ...prompts.zh_hans import TOPIC_SUMMARY_PROMPT as TOPIC_SUMMARY_PROMPT_ZH # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
async def find_latest_topic_summary(
|
||||
@@ -193,9 +149,13 @@ async def maybe_summarize_topics(
|
||||
max_concurrency: int = 3,
|
||||
temperature: Optional[float] = None,
|
||||
trace=None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""对本次写入触达的 Topic,按需有界并发生成 / 刷新 summary。
|
||||
|
||||
language 为显式 prompt 语言码(随触发本次写入的请求);None 时按记忆内容
|
||||
做 zh/en 自动检测。
|
||||
|
||||
触发条件:Topic 下 active 记忆数 ≥ `min_memories`,且比上次总结时多了 ≥ `step` 条。
|
||||
这样既不会每写一条就重算(贵),也不会一直不更新(陈旧)。
|
||||
|
||||
@@ -254,6 +214,7 @@ async def maybe_summarize_topics(
|
||||
topic_name=topic_names.get(topic_id) or topic_id,
|
||||
memories=[m.get("content") or "" for m in mems],
|
||||
temperature=temperature,
|
||||
language=language,
|
||||
)
|
||||
if trace is not None:
|
||||
await trace(
|
||||
@@ -317,15 +278,18 @@ async def maybe_summarize_topics(
|
||||
async def _generate(
|
||||
*, llm_provider, topic_name: str, memories: List[str],
|
||||
temperature: Optional[float] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""返回 (摘要文本, 实际 prompt, token 用量) —— 后两个供埋点,线上要能复盘。"""
|
||||
from ...utils.lang_detect import is_chinese
|
||||
|
||||
body = "\n".join(f"- {c}" for c in memories if c)
|
||||
if not body:
|
||||
return "", "", {}
|
||||
tpl = TOPIC_SUMMARY_PROMPT_ZH if is_chinese(body) else TOPIC_SUMMARY_PROMPT
|
||||
prompt = tpl.format(topic_name=topic_name, memories=body)
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, body)
|
||||
prompt = get_prompt("TOPIC_SUMMARY_PROMPT", code).format(
|
||||
topic_name=topic_name, memories=body,
|
||||
)
|
||||
prompt = with_output_language(prompt, code, explicit=explicit is not None)
|
||||
resp = await llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=400,
|
||||
|
||||
+54
-15
@@ -95,21 +95,48 @@ class ToolCall:
|
||||
# [John]: 我也喜欢茶
|
||||
# 名字单独占一行、整段只出现一次;[user] 标签**不换成名字**,视角信息留在标签里。
|
||||
# extract 用这一行给 L0 basic_info 取 name(用户不必在对话里自报姓名)。
|
||||
# 各语言的模板在 hy_memory/prompts/<language>.py 的 USER_NAME_LINE;这两个旧名字保留。
|
||||
_USER_NAME_LINE_ZH = "用户名叫{name}"
|
||||
_USER_NAME_LINE_EN = "The user's name is {name}"
|
||||
|
||||
_USER_NAME_LINE_RE = re.compile(
|
||||
r"^(?:用户名叫|The user's name is)\s*(.+?)\s*$"
|
||||
)
|
||||
_user_name_line_res: Optional[List["re.Pattern"]] = None
|
||||
|
||||
|
||||
def render_user_name_line(name: str, is_zh: bool) -> str:
|
||||
"""渲染文首的用户名字行;name 为空返回空串。"""
|
||||
def _user_name_line_patterns() -> List["re.Pattern"]:
|
||||
"""每种语言一个匹配名字行的正则(L1_RAW 里可能存着任意语言的名字行)。"""
|
||||
global _user_name_line_res
|
||||
if _user_name_line_res is None:
|
||||
from ..prompts import SUPPORTED_LANGUAGES, get_prompt
|
||||
|
||||
patterns = []
|
||||
for code in SUPPORTED_LANGUAGES:
|
||||
try:
|
||||
tpl = get_prompt("USER_NAME_LINE", code)
|
||||
except (ImportError, AttributeError):
|
||||
continue
|
||||
prefix, _, suffix = tpl.partition("{name}")
|
||||
patterns.append(re.compile(
|
||||
r"^" + re.escape(prefix.strip()) + r"\s*(.+?)\s*"
|
||||
+ re.escape(suffix.strip()) + r"$"
|
||||
))
|
||||
_user_name_line_res = patterns
|
||||
return _user_name_line_res
|
||||
|
||||
|
||||
def render_user_name_line(
|
||||
name: str, is_zh: bool = False, *, language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""渲染文首的用户名字行;name 为空返回空串。
|
||||
|
||||
language(语言码)优先;未给时按 is_zh 在 zh-Hans / en 间选(旧行为)。
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return ""
|
||||
tpl = _USER_NAME_LINE_ZH if is_zh else _USER_NAME_LINE_EN
|
||||
return tpl.format(name=name)
|
||||
from ..prompts import get_prompt
|
||||
|
||||
code = language or ("zh-Hans" if is_zh else "en")
|
||||
return get_prompt("USER_NAME_LINE", code).format(name=name)
|
||||
|
||||
|
||||
def parse_user_name_line(line: str) -> Optional[str]:
|
||||
@@ -118,8 +145,12 @@ def parse_user_name_line(line: str) -> Optional[str]:
|
||||
L1_RAW 存的就是 get_flat_content() 的产物,回读历史时要靠这个把名字行识别出来,
|
||||
否则它会被当成一条无前缀的裸消息(见 MemoryWriter._get_recent_history)。
|
||||
"""
|
||||
m = _USER_NAME_LINE_RE.match((line or "").strip())
|
||||
return m.group(1) if m else None
|
||||
text = (line or "").strip()
|
||||
for pattern in _user_name_line_patterns():
|
||||
m = pattern.match(text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -257,6 +288,11 @@ class WriteRequest:
|
||||
branch: Optional[str] = None
|
||||
# 分支名。同 workspace_id。
|
||||
|
||||
# 显式 prompt 语言码(canonical,见 hy_memory.prompts.SUPPORTED_LANGUAGES)。
|
||||
# 由 client 入口按「per-call > MemoryConfig.language」解析后注入;None 时
|
||||
# 抽取 / reconcile / 画像等各步沿用 zh/en 自动检测。
|
||||
language: Optional[str] = None
|
||||
|
||||
# ---- 便捷方法 ----
|
||||
|
||||
def has_messages(self) -> bool:
|
||||
@@ -286,8 +322,8 @@ class WriteRequest:
|
||||
[John]: 我也喜欢茶
|
||||
|
||||
无 name 时与历史行为完全一致(`[user]: ...` / `[assistant]: ...`)。
|
||||
名字行只在 role=user 带 name 时出现,且整段只出现一次;语言按消息内容判定
|
||||
(与 extractor 选 zh/en prompt 同一套判据)。
|
||||
名字行只在 role=user 带 name 时出现,且整段只出现一次;语言取显式
|
||||
language,未指定时按消息内容判定(与 extractor 选 zh/en prompt 同一套判据)。
|
||||
"""
|
||||
if not self.messages:
|
||||
return self.content or ""
|
||||
@@ -296,11 +332,14 @@ class WriteRequest:
|
||||
|
||||
name = self.user_name()
|
||||
if name:
|
||||
from ..utils.lang_detect import is_chinese
|
||||
if self.language:
|
||||
name_line = render_user_name_line(name, language=self.language)
|
||||
else:
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
name_line = render_user_name_line(
|
||||
name, is_chinese(messages=self.messages)
|
||||
)
|
||||
name_line = render_user_name_line(
|
||||
name, is_chinese(messages=self.messages)
|
||||
)
|
||||
if name_line:
|
||||
lines.insert(0, name_line)
|
||||
|
||||
|
||||
@@ -979,171 +979,16 @@ def s2_agent_skip_reason(materials: Dict[str, Any]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
# ====================================================================
|
||||
# System Prompt (dual language)
|
||||
# System Prompt (multilingual: hy_memory/prompts/<language>.py)
|
||||
# ====================================================================
|
||||
|
||||
SYSTEM2_AGENT_PROMPT_ZH = """你是一个认知加工 Agent,负责从用户的记忆数据中演化高层认知结构。
|
||||
|
||||
## 记忆分层架构
|
||||
|
||||
用户的记忆存储在两个系统中:
|
||||
|
||||
### VDB(向量数据库)— 你只能读取,不能修改
|
||||
| 层级 | 说明 |
|
||||
|------|------|
|
||||
| L0 BASIC_INFO | 用户基础属性(姓名、年龄、职业等)|
|
||||
| L1 RAW | 原始对话记录 |
|
||||
| L2 FACT | 原子事实(从对话中提取的客观事实)|
|
||||
| L3 SUMMARY | 会话摘要 |
|
||||
| L4 IDENTITY | 身份画像(性格特征、偏好、习惯)|
|
||||
| L5 KNOWLEDGE | 领域知识 |
|
||||
|
||||
### Graph(知识图谱)— 你是唯一的写入者
|
||||
| 层级 | 说明 |
|
||||
|------|------|
|
||||
| L6 SCHEMA | 行为模式 — 域内、原子化、创建后不可变 |
|
||||
|
||||
## L6 Schema 是什么?
|
||||
|
||||
Schema 是从多次独立经历归纳出的长期行为模式,包含以下三个要素:
|
||||
|
||||
- **Circumstance(场景)**:该模式发生的领域/话题/场景。Schema 必须限定在其场景内,不要跨域泛化。
|
||||
- **Pattern(模式)**:用户在该场景下的惯常行为、思维方式或行动倾向。
|
||||
- **Insight(洞察)**:底层心理驱动力或心智模型。
|
||||
|
||||
### 内容格式
|
||||
行为模式用一句话组合三要素:
|
||||
"当[场景]时,用户[模式]——反映了[洞察]。"
|
||||
|
||||
对与情绪、治疗、压力、哀伤或创伤有关的长期模式,不要把具体人生背景压缩成
|
||||
空泛人格词。优先保留这三部分:
|
||||
"Trigger/background(持久触发背景) → emotional response(反复情绪反应) → observed coping resource(用户已实际使用的应对资源)。"
|
||||
只能写入 user-owned 证据中出现的触发背景、反应和应对方式;助手建议不是用户已观察到的应对资源。
|
||||
|
||||
### 规则
|
||||
- **原子化**:一个 Schema 只包含一个模式。两个不同模式 → 两个 Schema。
|
||||
- **不可变**:Schema 创建后内容永远不变。不要修改已有 Schema 的内容。
|
||||
- **累积证据**:新事实只是在支持已有表述 → 调 `add_evidence`。如果新证据会让旧画像的当前状态产生实质遗漏、错误或误导,使用 `revise_schema` 创建新版本,让旧版本保留审计但退出 active 检索。
|
||||
- **域内约束**:不要跨域。跨域抽象由系统的其他流程处理。
|
||||
- **事实与模式分层**:单条明确身份、健康状态、设备使用、隶属关系、项目或偏好属于 L2 状态,不创建 L6。L6 必须由至少两个独立 raw/session 中的 user-owned 证据支持。
|
||||
- **主体安全**:同一原始写入可能包含他人资料或示例。只有材料明确归因于当前用户时才创建用户 Schema;否则对该材料不执行任何操作。
|
||||
- **候选覆盖**:source-context 审查时,每条候选事实必须二选一:用于 `create_schema`/`add_evidence` 的 evidence,或用 `skip_fact` 明确跳过。原始上下文只用于确认主体或补回该事实遗漏的细节,不得据此创建无关 Schema。
|
||||
- **证据门槛**:创建 L6 必须有 user-owned evidence,且这些证据合计代表至少两个独立 raw/session。
|
||||
一个被 S1 合并的 L2 事实可以代表多次独立写入,应检查它的 `source_raw_memory_ids`,不要只数节点。
|
||||
助手建议不能作为用户模式的独立证据。
|
||||
|
||||
## 工作流程
|
||||
|
||||
输入材料分两部分(仅当已形成聚类时才会调用你):
|
||||
- **聚类结果(Cluster Results)**:主输入,按语义相似度归组的事实
|
||||
- **未聚类事实(补充)**:未进入任一聚类的事实,优先级低于聚类结果,酌情审视即可
|
||||
|
||||
对于每组事实(无论聚类还是未聚类):
|
||||
1. **搜索已有 Schema**:如果已有 Schema 已覆盖该模式,使用 `add_evidence`。只有加入新证据后旧表述会产生实质遗漏、错误或误导时才使用 `revise_schema`。
|
||||
2. **创建新 Schema**:仅当没有已有 Schema 覆盖时。一句话,三要素。
|
||||
3. **建立关系**:两个 Schema 主题相关 → 用 `add_edge`(RELATED_TO)。
|
||||
|
||||
## 原则
|
||||
|
||||
- 所有 Graph 操作必须通过 tools 执行
|
||||
- Schema 内容创建后不可变——绝不重新创建已存在的 Schema
|
||||
- 优先 `add_evidence` 而非创建重复节点
|
||||
- 宁可不创建,也不要创建低质量节点
|
||||
- 一个 Schema = 一个域内原子模式
|
||||
- 打标签时优先使用已有标签列表中的标签"""
|
||||
|
||||
|
||||
SYSTEM2_AGENT_PROMPT_EN = """You are a cognitive processing Agent. Your job is to evolve higher-order cognitive structures from a user's raw memory data.
|
||||
|
||||
## Memory Architecture
|
||||
|
||||
### VDB (Vector Database) — read-only
|
||||
| Layer | Description |
|
||||
|-------|-------------|
|
||||
| L0 BASIC_INFO | User attributes (name, age, occupation) |
|
||||
| L1 RAW | Original conversation transcripts |
|
||||
| L2 FACT | Atomic facts extracted from conversations |
|
||||
| L3 SUMMARY | Session summaries |
|
||||
| L4 IDENTITY | Identity traits, preferences, habits |
|
||||
| L5 KNOWLEDGE | Domain knowledge |
|
||||
|
||||
### Graph (Knowledge Graph) — you are the sole writer
|
||||
| Layer | Description |
|
||||
|-------|-------------|
|
||||
| L6 SCHEMA | Behavioral patterns — domain-bound, atomic, immutable once created |
|
||||
|
||||
## What is an L6 Schema?
|
||||
|
||||
A Schema is one cross-event behavioral pattern in the long-term user model and
|
||||
contains the following three components:
|
||||
|
||||
|
||||
- **Circumstance**: The domain/topic/situation where this pattern is observed. A Schema MUST stay within its circumstance — do NOT generalize across domains. Cross-domain abstraction is handled separately by the system.
|
||||
- **Pattern**: The user's habitual behavior, thinking style, or action tendency in this circumstance.
|
||||
- **Insight**: The underlying psychological driver or mental model.
|
||||
|
||||
### Content format
|
||||
Use a single sentence combining all three:
|
||||
"When [circumstance], the user [pattern] — reflecting [insight]."
|
||||
|
||||
For durable emotional, therapy, stress, grief, or trauma-related patterns, do
|
||||
not erase the specific autobiographical background into a generic personality
|
||||
label. Prefer an anchored structure that preserves:
|
||||
"Trigger/background -> recurring emotional response -> observed coping resource."
|
||||
Only include triggers, responses, and coping resources present in user-owned
|
||||
evidence. Assistant advice is not an observed user coping resource.
|
||||
|
||||
### Rules
|
||||
- **Atomic**: One pattern per Schema. Two distinct patterns → two Schemas.
|
||||
- **Immutable**: Once created, content NEVER changes.
|
||||
- **Evidence only**: New facts that merely support existing wording → call
|
||||
`add_evidence`. If new evidence materially changes, corrects, or narrows the
|
||||
current durable state, use `revise_schema` so the old version is retained for
|
||||
audit but removed from active retrieval.
|
||||
- **Domain-bound**: Stay within the observed domain. Cross-domain abstraction is handled by a separate system process.
|
||||
- **Separate facts from patterns**: A single explicit identity, health state,
|
||||
equipment use, affiliation, project, or preference belongs in L2 state memory,
|
||||
not L6. L6 requires user-owned evidence representing at least two independent
|
||||
raw writes or sessions. One reconciled L2 fact may represent multiple writes;
|
||||
inspect its source_raw_memory_ids rather than counting fact nodes.
|
||||
- **Subject safety**: Facts sharing a source chunk may describe other people or
|
||||
examples. Create a user Schema only when the material explicitly attributes
|
||||
the pattern to the user. Otherwise output no operation for that material.
|
||||
- **Candidate coverage**: In a source-context review, account for every listed
|
||||
candidate fact: either use it as evidence in `create_schema`/`add_evidence`,
|
||||
or emit `skip_fact`. Raw context may confirm its subject or restore omitted
|
||||
detail, but must not seed unrelated Schemas.
|
||||
- **Evidence threshold**: Creating L6 requires one or more user-owned evidence
|
||||
facts representing at least two independent raw writes or sessions.
|
||||
Assistant advice does not establish a user pattern by itself.
|
||||
|
||||
### Good vs Bad examples
|
||||
✅ "When cooking, the user strictly follows recipe steps and precisely measures ingredients — reflecting a need for external structure to manage uncertainty."
|
||||
✅ "When gaming, the user always checks walkthroughs to achieve 100% completion — driven by low tolerance for incompleteness."
|
||||
✅ "After an unexpected mentor loss, mortality reminders can bring grief back suddenly; the user pauses to reflect and takes quiet walks to regain steadiness."
|
||||
❌ "The user approaches music as a medium for creative synthesis and cultural bridge-building, consistently blending diverse musical traditions..." (multiple patterns merged, too broad)
|
||||
❌ "The user is passionate about many things and values quality." (no circumstance, too vague)
|
||||
❌ "The user is meaning-driven and resilient." (erases the durable trigger and observed coping evidence)
|
||||
|
||||
## Your Workflow
|
||||
|
||||
Input materials (you are only invoked when at least one cluster exists):
|
||||
- **Cluster Results**: Primary input — facts grouped by semantic similarity
|
||||
- **Unclustered Facts (supplemental)**: Facts not in any cluster; lower priority than cluster results
|
||||
|
||||
For each group of facts (clustered or unclustered):
|
||||
1. **Search existing Schemas**: If an existing Schema already captures this pattern, call `add_evidence`. Use `revise_schema` only when the old wording would become materially incomplete or misleading after adding the new evidence.
|
||||
2. **Create new Schema**: Only if no existing Schema covers this pattern. One sentence, three components.
|
||||
3. **Build relationships**: If two Schemas are thematically related, use `add_edge` (RELATED_TO).
|
||||
|
||||
## Principles
|
||||
|
||||
- All Graph operations MUST go through tools
|
||||
- Schema content is IMMUTABLE — never recreate what already exists
|
||||
- Prefer `add_evidence` over creating duplicates
|
||||
- Prefer not creating over creating low-quality nodes
|
||||
- One Schema = one atomic pattern in one domain
|
||||
- When tagging, prefer reusing tags from the existing tags list"""
|
||||
from ..prompts import ( # noqa: E402
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ..prompts.zh_hans import SYSTEM2_AGENT_PROMPT as SYSTEM2_AGENT_PROMPT_ZH # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import SYSTEM2_AGENT_PROMPT as SYSTEM2_AGENT_PROMPT_EN # noqa: E402,F401 (re-export)
|
||||
|
||||
# backward compat alias
|
||||
SYSTEM2_AGENT_PROMPT = SYSTEM2_AGENT_PROMPT_ZH
|
||||
@@ -2095,10 +1940,14 @@ async def run_system2_agent(
|
||||
tool_executor: System2ToolExecutor,
|
||||
config: MemoryConfig,
|
||||
max_iterations: int = 10, # kept for backward compat, unused in single-call mode
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Phase 2: System 2 Agent — Single LLM call + JSON output + post-process execution.
|
||||
|
||||
``language``: explicit prompt language code (overrides config.language);
|
||||
None and no config default → zh/en detection over the materials.
|
||||
|
||||
Instead of multi-turn tool-calling loop, this version:
|
||||
1. Puts all materials (facts + existing graph) into the prompt
|
||||
2. Asks LLM to output a JSON array of operations
|
||||
@@ -2120,9 +1969,17 @@ async def run_system2_agent(
|
||||
# Build LLMProvider from config
|
||||
llm_provider = LLMProvider(config)
|
||||
|
||||
# Detect language from materials content
|
||||
lang = _detect_language(materials)
|
||||
system_prompt = _build_single_call_system_prompt(lang)
|
||||
# Prompt language: explicit language (evolve(language=...) or
|
||||
# MemoryConfig.language) wins; otherwise detect zh/en from the materials.
|
||||
explicit_language = normalize_language(language) or normalize_language(
|
||||
getattr(config, "language", None)
|
||||
)
|
||||
lang = explicit_language or _s2_language_code(_detect_language(materials))
|
||||
system_prompt = with_output_language(
|
||||
_build_single_call_system_prompt(lang),
|
||||
lang,
|
||||
explicit=explicit_language is not None,
|
||||
)
|
||||
user_msg = _build_materials_message(materials, lang=lang)
|
||||
|
||||
messages = [
|
||||
@@ -2393,101 +2250,30 @@ async def run_system2_agent(
|
||||
}
|
||||
|
||||
|
||||
def _build_single_call_system_prompt(lang: str) -> str:
|
||||
"""Build system prompt for single-call mode (includes output format instructions)."""
|
||||
base = SYSTEM2_AGENT_PROMPT_ZH if lang == "zh" else SYSTEM2_AGENT_PROMPT_EN
|
||||
|
||||
def _s2_language_code(lang: str) -> str:
|
||||
"""旧的 "zh" / "en" 标记映射到语言码;其余值按语言码处理。"""
|
||||
if lang == "zh":
|
||||
output_instructions = """
|
||||
return "zh-Hans"
|
||||
return lang or "en"
|
||||
|
||||
## 输出格式
|
||||
|
||||
不要调用 tools。直接输出一个 JSON 数组,包含你决定执行的操作。用 ```json 代码块包裹。
|
||||
def _build_single_call_system_prompt(lang: str) -> str:
|
||||
"""Build system prompt for single-call mode (includes output format instructions).
|
||||
|
||||
可用操作:
|
||||
|
||||
1. **create_schema** — 创建新的 L6 行为 Schema
|
||||
```{"op": "create_schema", "schema_kind": "behavior_pattern", "content": "供用户阅读的简洁画像", "retrieval_text": "用于检索的行为与适用场景", "evidence_list": ["fact_node_id_1", "fact_node_id_2"], "tags": ["tag1"]}```
|
||||
|
||||
2. **add_evidence** — 给已有 Schema 追加证据(不修改内容)
|
||||
```{"op": "add_evidence", "node_id": "existing_schema_node_id", "evidence_list": ["new_fact_id_1"]}```
|
||||
|
||||
3. **revise_schema** — 新证据使已有 Schema 的当前状态发生实质变化时,创建新版本并让旧版本退出默认检索。不要用于可共存的细节补充。
|
||||
```{"op": "revise_schema", "node_id": "old_schema_id", "schema_kind": "explicit_state", "content": "更新后的当前画像", "retrieval_text": "更新后的检索文本", "evidence_list": ["new_fact_id_1"], "tags": ["tag1"], "reason": "明确说明旧画像为何会误导"}```
|
||||
|
||||
4. **add_edge** — 建立两个 Schema 之间的关系
|
||||
```{"op": "add_edge", "source_id": "schema_id_1", "target_id": "schema_id_2", "reason": "关系原因"}```
|
||||
|
||||
5. **skip_fact** — 明确标记某条候选事实已审查但不创建 Schema。仅用于他人事实、临时事件、不能证明用户特征的助手建议,或已被现有 Schema 完整覆盖的事实。
|
||||
```{"op": "skip_fact", "node_id": "candidate_fact_id", "reason": "不是稳定的用户模式"}```
|
||||
|
||||
## 输出约定
|
||||
|
||||
1. 输出必须是 JSON 数组,用 ```json 代码块包裹。
|
||||
2. 如果数据不足以得出可靠结论,输出 `[]`。在 source-context review 中,
|
||||
`[]` 表示已审查该组全部候选,且都不足以支持稳定 Schema。
|
||||
3. 代码块外不要有任何其他文字。
|
||||
4. evidence_list 中的 id 必须来自材料中提供的 fact node_id。
|
||||
5. add_evidence / revise_schema 的 node_id 必须来自已有 Graph 节点的 id。
|
||||
6. 对 source-context 审查,必须让每条候选事实出现在 evidence_list 或 skip_fact 中。"""
|
||||
else:
|
||||
output_instructions = """
|
||||
|
||||
## Output Format
|
||||
|
||||
Do NOT call tools. Output a JSON array of operations directly, wrapped in a ```json code block.
|
||||
|
||||
Available operations:
|
||||
|
||||
1. **create_schema** — Create a new L6 behavioral Schema
|
||||
```{"op": "create_schema", "schema_kind": "behavior_pattern", "content": "concise user-facing pattern", "retrieval_text": "behavior plus relevant situations", "evidence_list": ["fact_node_id_1", "fact_node_id_2"], "tags": ["tag1"]}```
|
||||
|
||||
2. **add_evidence** — Add evidence to an existing Schema (content stays immutable)
|
||||
```{"op": "add_evidence", "node_id": "existing_schema_node_id", "evidence_list": ["new_fact_id_1"]}```
|
||||
|
||||
3. **revise_schema** — Create a new version when new evidence materially changes the current state and retire the old version from active retrieval. Do not use it for compatible detail.
|
||||
```{"op": "revise_schema", "node_id": "old_schema_id", "schema_kind": "explicit_state", "content": "updated current profile", "retrieval_text": "updated retrieval text", "evidence_list": ["new_fact_id_1"], "tags": ["tag1"], "reason": "why the old wording would mislead"}```
|
||||
|
||||
4. **add_edge** — Create a relationship between two Schemas
|
||||
```{"op": "add_edge", "source_id": "schema_id_1", "target_id": "schema_id_2", "reason": "relationship reason"}```
|
||||
|
||||
5. **skip_fact** — Explicitly mark a candidate fact as reviewed without creating
|
||||
a Schema. Use only for facts about another person, transient events, assistant
|
||||
advice that does not establish a user trait, or facts already fully covered.
|
||||
```{"op": "skip_fact", "node_id": "candidate_fact_id", "reason": "not a durable user pattern"}```
|
||||
|
||||
## Output Contract
|
||||
|
||||
1. Output MUST be a JSON array wrapped in a ```json code block.
|
||||
2. If data is insufficient for reliable conclusions, output `[]`. In a
|
||||
source-context review, `[]` means every candidate in that review was examined
|
||||
and none supports a durable Schema.
|
||||
3. NO text outside the code block.
|
||||
4. evidence_list IDs must come from the fact node_ids provided in the materials.
|
||||
5. add_evidence / revise_schema node_id must come from existing Graph node IDs.
|
||||
6. For a source-context review, every candidate fact must appear in an evidence_list or skip_fact."""
|
||||
``lang`` is a language code (``en`` / ``zh-Hans`` / ``ja`` ...; legacy ``zh``
|
||||
means zh-Hans). Every fragment comes from hy_memory/prompts/<language>.py.
|
||||
"""
|
||||
code = _s2_language_code(lang)
|
||||
base = get_prompt("SYSTEM2_AGENT_PROMPT", code)
|
||||
output_instructions = get_prompt("SYSTEM2_OUTPUT_INSTRUCTIONS", code)
|
||||
|
||||
if _S2_SCHEMA_EVIDENCE_GATE_ENABLED:
|
||||
if lang == "zh":
|
||||
output_instructions += """
|
||||
output_instructions += get_prompt("SYSTEM2_SCHEMA_EVIDENCE_GATE", code)
|
||||
|
||||
7. L6 只表示跨事件行为模式。create_schema 必须使用 user-owned 证据,且证据合计代表至少两个独立 raw/session。当 `source_raw_memory_ids` 包含多个独立写入时,一个合并后的 L2 事实也可以满足门槛。
|
||||
8. 单条明确身份、健康状态、设备使用、隶属关系、项目或偏好不要创建 L6;这些由 canonical L2 状态通道维护。"""
|
||||
else:
|
||||
output_instructions += """
|
||||
|
||||
7. L6 represents cross-event behavioral patterns only. create_schema must use user-owned evidence representing at least two independent raw writes or sessions. A single reconciled fact can qualify when source_raw_memory_ids contains multiple independent writes.
|
||||
8. Do not create L6 for a single explicit identity, health state, equipment use, affiliation, project, or preference. Canonical L2 state projection owns those facts."""
|
||||
|
||||
# Remove the old "All Graph operations MUST go through tools" principle
|
||||
base = base.replace("- 所有 Graph 操作必须通过 tools 执行\n", "")
|
||||
base = base.replace("- All Graph operations MUST go through tools\n", "")
|
||||
# Remove old tool-calling instructions from workflow
|
||||
base = base.replace("调 `add_evidence` 添加证据", "使用 add_evidence 操作")
|
||||
base = base.replace("call `add_evidence`", "use add_evidence operation")
|
||||
base = base.replace("调 `add_evidence`", "使用 add_evidence")
|
||||
base = base.replace("用 `add_edge`(RELATED_TO)", "使用 add_edge 操作")
|
||||
base = base.replace("use `add_edge` (RELATED_TO)", "use add_edge operation")
|
||||
# Remove the old "All Graph operations MUST go through tools" principle and
|
||||
# the tool-calling wording from the workflow (single-call mode has no tools).
|
||||
for old, new in get_prompt("SYSTEM2_SINGLE_CALL_REPLACEMENTS", code):
|
||||
base = base.replace(old, new)
|
||||
|
||||
return base + output_instructions
|
||||
|
||||
@@ -2554,10 +2340,14 @@ def _detect_language(materials: Dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str:
|
||||
"""将预处理好的 materials 格式化为 Agent 能理解的 user message"""
|
||||
is_zh = (lang == "zh")
|
||||
"""将预处理好的 materials 格式化为 Agent 能理解的 user message
|
||||
|
||||
parts = [("## 预处理材料\n" if is_zh else "## Preprocessed Materials\n")]
|
||||
``lang`` 为语言码(旧值 "zh" 视作 zh-Hans);文案见 prompts/<language>.py 的
|
||||
SYSTEM2_MATERIALS。
|
||||
"""
|
||||
text = get_prompt("SYSTEM2_MATERIALS", _s2_language_code(lang))
|
||||
|
||||
parts = [text["title"]]
|
||||
|
||||
stats = materials.get("stats", {})
|
||||
fresh = stats.get("fresh_facts", 0)
|
||||
@@ -2566,55 +2356,35 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
graph_total = stats.get("graph_total_schemas", 0)
|
||||
recalled_schemas = stats.get("graph_schemas_recalled", 0)
|
||||
|
||||
if is_zh:
|
||||
parts.append(f"### 统计\n- VDB 事实总数: {stats.get('total_facts_all', 0)}")
|
||||
parts.append(f"- 新鲜事实(待处理): {fresh}(已被引用排除={evidenced})")
|
||||
parts.append(f"- 聚类数: {stats.get('clusters_found', 0)}")
|
||||
parts.append(
|
||||
f"- 聚类内事实: {stats.get('clustered_facts_count', 0)},"
|
||||
f"未聚类补充: {stats.get('unclustered_facts_count', 0)}"
|
||||
)
|
||||
parts.append(f"- Graph Schema 总数: {stats.get('graph_total_schemas', 0)}(本次召回={recalled_schemas})")
|
||||
if graph_total == 0:
|
||||
parts.append("- Graph 状态: **空**(首次认知加工)\n")
|
||||
else:
|
||||
parts.append("")
|
||||
parts.append(text["stats"].format(
|
||||
total=stats.get('total_facts_all', 0),
|
||||
fresh=fresh,
|
||||
evidenced=evidenced,
|
||||
clusters=stats.get('clusters_found', 0),
|
||||
clustered=stats.get('clustered_facts_count', 0),
|
||||
unclustered=stats.get('unclustered_facts_count', 0),
|
||||
graph_total=stats.get('graph_total_schemas', 0),
|
||||
recalled=recalled_schemas,
|
||||
))
|
||||
if graph_total == 0:
|
||||
parts.append(text["graph_status_empty"])
|
||||
else:
|
||||
parts.append(f"### Statistics\n- Total VDB facts: {stats.get('total_facts_all', 0)}")
|
||||
parts.append(f"- Fresh facts (to process): {fresh} (evidenced_excluded={evidenced})")
|
||||
parts.append(f"- Clusters found: {stats.get('clusters_found', 0)}")
|
||||
parts.append(
|
||||
f"- Facts in clusters: {stats.get('clustered_facts_count', 0)}, "
|
||||
f"unclustered supplement: {stats.get('unclustered_facts_count', 0)}"
|
||||
)
|
||||
parts.append(f"- Graph Schemas total: {stats.get('graph_total_schemas', 0)} (recalled={recalled_schemas})")
|
||||
if graph_total == 0:
|
||||
parts.append("- Graph status: **EMPTY** (first cognitive processing run)\n")
|
||||
else:
|
||||
parts.append("")
|
||||
parts.append("")
|
||||
|
||||
# Clusters
|
||||
clusters = materials.get("clusters", [])
|
||||
clustered_ids = set()
|
||||
if clusters:
|
||||
parts.append(("### 聚类结果\n" if is_zh else "### Cluster Results\n"))
|
||||
parts.append(text["clusters_header"])
|
||||
for i, c in enumerate(clusters):
|
||||
n_facts = len(c['facts'])
|
||||
group_type = c.get("group_type", "semantic_cluster")
|
||||
if group_type == "holistic_therapy_review":
|
||||
label = (
|
||||
f"{n_facts} 条跨会话用户事实;只归纳有证据的长期情绪/治疗模式"
|
||||
if is_zh else
|
||||
f"{n_facts} cross-session user facts; only evidence-backed durable emotional/therapy patterns"
|
||||
)
|
||||
label = text["label_holistic"].format(n=n_facts)
|
||||
elif group_type == "source_context_review":
|
||||
label = (
|
||||
f"{n_facts} 条候选事实;原始写入仅用于主体与细节核验"
|
||||
if is_zh else
|
||||
f"{n_facts} candidate facts; raw write is only for subject/detail validation"
|
||||
)
|
||||
label = text["label_source_context"].format(n=n_facts)
|
||||
else:
|
||||
label = f"{n_facts} 条,主题" if is_zh else f"{n_facts} facts, topic"
|
||||
label = text["label_topic"].format(n=n_facts)
|
||||
parts.append(f"**Cluster {i}** ({label}: {c['centroid_text']})")
|
||||
for f in c["facts"]:
|
||||
source_ids = list(f.get("source_raw_memory_ids") or [])
|
||||
@@ -2628,8 +2398,7 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
)
|
||||
clustered_ids.add(f['node_id'])
|
||||
if group_type == "source_context_review" and c.get("raw_contexts"):
|
||||
raw_label = "候选事实对应的原始片段" if is_zh else "Relevant raw excerpt per candidate"
|
||||
parts.append(f"\n**{raw_label}:**")
|
||||
parts.append(f"\n**{text['raw_label']}:**")
|
||||
for item in c["raw_contexts"]:
|
||||
parts.append(
|
||||
f"- candidate_id={item.get('node_id')}, "
|
||||
@@ -2638,30 +2407,12 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
)
|
||||
parts.append("")
|
||||
if materials.get("holistic_therapy_review"):
|
||||
parts.append(
|
||||
(
|
||||
"这是一次有边界的夜间全局整理。最多创建 "
|
||||
f"{_S2_HOLISTIC_THERAPY_MAX_SCHEMAS} 条 anchored therapy Schema。"
|
||||
"优先联合不同 raw/session 中的持久 trigger/background、"
|
||||
"反复 emotional response 和用户已实际使用的 coping resource/decision constraint。"
|
||||
"证据不足时输出 []。\n"
|
||||
if is_zh else
|
||||
"This is one bounded nightly holistic consolidation pass. Create at most "
|
||||
f"{_S2_HOLISTIC_THERAPY_MAX_SCHEMAS} anchored therapy Schemas. "
|
||||
"Prefer joining durable trigger/background, recurring emotional response, "
|
||||
"and observed coping resource or decision constraint across different raw writes/sessions. "
|
||||
"Before create_schema, union raw_sources for its evidence IDs and require at least two "
|
||||
"distinct raw source IDs; two facts from one raw write are one event. "
|
||||
"Output [] when evidence is insufficient.\n"
|
||||
)
|
||||
)
|
||||
parts.append(text["holistic_note"].format(
|
||||
max_schemas=_S2_HOLISTIC_THERAPY_MAX_SCHEMAS,
|
||||
))
|
||||
companion_hints = materials.get("independent_source_companions") or {}
|
||||
if companion_hints:
|
||||
parts.append(
|
||||
"**独立来源 companion 提示(只是候选,不是结论):**"
|
||||
if is_zh else
|
||||
"**Independent-source companion hints (candidates, not conclusions):**"
|
||||
)
|
||||
parts.append(text["companion_header"])
|
||||
for node_id, companions in companion_hints.items():
|
||||
parts.append(
|
||||
f"- {node_id} -> "
|
||||
@@ -2677,14 +2428,8 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
all_facts = materials.get("unprocessed_facts", [])
|
||||
unclustered = [f for f in all_facts if f["node_id"] not in clustered_ids]
|
||||
if clusters and unclustered:
|
||||
header = "### 未聚类事实(补充,次要)\n" if is_zh else "### Unclustered Facts (supplemental)\n"
|
||||
hint = (
|
||||
"以下事实未进入上述任一聚类,可在完成聚类主题加工后酌情审视;优先级低于聚类结果。\n"
|
||||
if is_zh
|
||||
else "Facts below were not grouped into any cluster above. Review after cluster themes; lower priority than cluster results.\n"
|
||||
)
|
||||
parts.append(header)
|
||||
parts.append(hint)
|
||||
parts.append(text["unclustered_header"])
|
||||
parts.append(text["unclustered_hint"])
|
||||
for f in unclustered:
|
||||
parts.append(f" - [{f['layer']}] {f['content']} (id={f['node_id']})")
|
||||
parts.append("")
|
||||
@@ -2692,10 +2437,7 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
# Existing graph nodes
|
||||
forward = materials.get("graph_forward", [])
|
||||
if forward:
|
||||
if is_zh:
|
||||
parts.append(f"### 相关 Graph 节点(通过 cluster centroid 向量召回,共 {len(forward)} 条)\n")
|
||||
else:
|
||||
parts.append(f"### Related Graph Nodes (retrieved via cluster centroid search, {len(forward)} hits)\n")
|
||||
parts.append(text["forward_header"].format(count=len(forward)))
|
||||
for g in forward:
|
||||
sim = g.get("similarity", "")
|
||||
sim_str = f", sim={sim}" if sim else ""
|
||||
@@ -2705,30 +2447,17 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
)
|
||||
parts.append("")
|
||||
elif graph_total == 0:
|
||||
if is_zh:
|
||||
parts.append("### 已有 Graph 节点\n当前 Graph 为空,这是首次认知加工。无需搜索 Graph,直接根据聚类结果创建 Schema。\n")
|
||||
else:
|
||||
parts.append("### Existing Graph Nodes\nThe Graph is currently EMPTY — this is the first cognitive processing run. Do NOT search the Graph (it will return nothing). Proceed directly to create Schemas from the cluster results.\n")
|
||||
parts.append(text["graph_empty"])
|
||||
else:
|
||||
# Graph 有节点但 centroid search 没召回(可能 threshold 太高或语义距离远)
|
||||
if is_zh:
|
||||
parts.append(
|
||||
f"### 已有 Graph 节点\n"
|
||||
f"Graph 中共有 {stats.get('graph_total_schemas', 0)} 个 Schema,"
|
||||
f"但 centroid 向量召回未命中相关节点。请根据聚类结果判断是否需要创建新 Schema。\n"
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"### Existing Graph Nodes\n"
|
||||
f"The Graph has {stats.get('graph_total_schemas', 0)} Schemas, "
|
||||
f"but centroid-based vector recall found no matches. Decide based on cluster results whether new Schemas are needed.\n"
|
||||
)
|
||||
parts.append(text["graph_no_recall"].format(
|
||||
graph_total=stats.get('graph_total_schemas', 0),
|
||||
))
|
||||
|
||||
# Reverse hits
|
||||
reverse = materials.get("graph_reverse", [])
|
||||
if reverse:
|
||||
parts.append(("### Graph 反向引用(已有 Schema 引用的 VDB 事实)\n" if is_zh
|
||||
else "### Graph Reverse References (VDB facts referenced by existing Schemas)\n"))
|
||||
parts.append(text["reverse_header"])
|
||||
for r in reverse:
|
||||
parts.append(
|
||||
f" - [{r['layer']}] {r['content']} "
|
||||
@@ -2739,26 +2468,9 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
# Existing tags(指导 Agent 复用已有 tag,减少发散)
|
||||
existing_tags = materials.get("existing_tags", [])
|
||||
if existing_tags:
|
||||
if is_zh:
|
||||
parts.append("### 已有标签(请优先从中选择,避免创建同义标签)\n")
|
||||
parts.append(f" {', '.join(existing_tags)}\n")
|
||||
else:
|
||||
parts.append("### Existing Tags (prefer reusing these over creating synonyms)\n")
|
||||
parts.append(f" {', '.join(existing_tags)}\n")
|
||||
parts.append(text["tags_header"])
|
||||
parts.append(f" {', '.join(existing_tags)}\n")
|
||||
|
||||
if is_zh:
|
||||
parts.append(
|
||||
"请分析以上材料,输出操作 JSON 数组。"
|
||||
"决定是否需要创建新的 Schema,或为已有 Schema 追加 evidence。"
|
||||
"如果数据不足以得出可靠结论,输出空数组 `[]`。"
|
||||
"打标签时请优先使用已有标签列表中的标签。"
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
"Analyze the materials above and output a JSON array of operations. "
|
||||
"Decide whether to create new Schemas, or add evidence to existing ones. "
|
||||
"If the data is insufficient for reliable conclusions, output an empty array `[]`. "
|
||||
"When tagging, prefer reusing tags from the existing tags list above."
|
||||
)
|
||||
parts.append(text["closing"])
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -243,6 +243,7 @@ class System2Writer(WritePipeline):
|
||||
materials=materials,
|
||||
tool_executor=tool_executor,
|
||||
config=self.config,
|
||||
language=payload.get("language"),
|
||||
)
|
||||
|
||||
# 计算 timing
|
||||
@@ -508,6 +509,7 @@ class System2Writer(WritePipeline):
|
||||
materials=batch,
|
||||
tool_executor=tool_executor,
|
||||
config=self.config,
|
||||
language=payload.get("language"),
|
||||
)
|
||||
batch_record = {
|
||||
"batch_index": batch_index,
|
||||
@@ -632,6 +634,7 @@ class System2Writer(WritePipeline):
|
||||
materials=holistic_materials,
|
||||
tool_executor=tool_executor,
|
||||
config=self.config,
|
||||
language=payload.get("language"),
|
||||
)
|
||||
if holistic_result.get("success"):
|
||||
await self._mark_holistic_therapy_reviewed(
|
||||
@@ -1044,11 +1047,26 @@ class System2Writer(WritePipeline):
|
||||
agent_id: str,
|
||||
llm_call,
|
||||
request_id: str = "",
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""在 System2 Agent 之后运行跨域突破性归纳"""
|
||||
"""在 System2 Agent 之后运行跨域突破性归纳
|
||||
|
||||
sweeper 的 prompt 只有英文版(要求与输入 schema 同语言)。显式 language
|
||||
时在每个 prompt 末尾追加该语言的输出规则,让产出用该语言书写。
|
||||
"""
|
||||
if not _SWEEPER_ENABLED:
|
||||
return {"skipped": True, "reason": "SWEEPER_ENABLED=false"}
|
||||
|
||||
if language:
|
||||
from ..prompts import with_output_language
|
||||
|
||||
_base_call = llm_call
|
||||
|
||||
async def llm_call(prompt: str) -> str:
|
||||
return await _base_call(
|
||||
with_output_language(prompt, language, explicit=True)
|
||||
)
|
||||
|
||||
try:
|
||||
from .cross_domain_sweeper import CrossDomainSweeper
|
||||
|
||||
@@ -1119,6 +1137,7 @@ class System2Writer(WritePipeline):
|
||||
self,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
手动触发 cross-domain sweeper(同步等待完成)。
|
||||
@@ -1145,6 +1164,7 @@ class System2Writer(WritePipeline):
|
||||
agent_id=agent_id,
|
||||
llm_call=self._get_llm_call(),
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
result["request_id"] = request_id
|
||||
return result
|
||||
@@ -1154,6 +1174,7 @@ class System2Writer(WritePipeline):
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
max_batches: Optional[int] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
手动触发 System 2 认知加工(由调用方/App 层控制触发时机)。
|
||||
@@ -1186,6 +1207,8 @@ class System2Writer(WritePipeline):
|
||||
"content": "",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"max_batches": max_batches,
|
||||
# 显式 prompt 语言码(None → System 2 按 materials 做 zh/en 检测)
|
||||
"language": language,
|
||||
}
|
||||
results = await self._run_system2_workers(payload)
|
||||
|
||||
@@ -1204,6 +1227,7 @@ class System2Writer(WritePipeline):
|
||||
agent_id=agent_id,
|
||||
llm_call=self._get_llm_call(),
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
|
||||
agent_success = bool((results.get("system2_agent") or {}).get("success", True))
|
||||
|
||||
@@ -997,6 +997,7 @@ class MemoryWriter(WritePipeline):
|
||||
# 这是一次 LLM 调用:prompt / 产出必须能在线上追到,
|
||||
# 否则 badcase 只能看到"摘要内容不对"却拿不到输入
|
||||
trace=self._make_trace_sink(request, "EPISODE_SUMMARY"),
|
||||
language=request.language,
|
||||
)
|
||||
if nid:
|
||||
logger.info(
|
||||
@@ -1210,6 +1211,7 @@ class MemoryWriter(WritePipeline):
|
||||
self._make_trace_sink(request, "TOPIC_SUMMARY")
|
||||
if request is not None else None
|
||||
),
|
||||
language=getattr(request, "language", None),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[topic-summary] failed (non-fatal): {e}")
|
||||
@@ -1286,6 +1288,7 @@ class MemoryWriter(WritePipeline):
|
||||
graph_store=getattr(self, "_graph_store", None),
|
||||
new_memory_embeddings=precomputed_embeddings,
|
||||
source=request.source,
|
||||
language=request.language,
|
||||
)
|
||||
|
||||
if not recon_result.success:
|
||||
@@ -2714,6 +2717,7 @@ class MemoryWriter(WritePipeline):
|
||||
vector_store, request.user_id, request.agent_id,
|
||||
exclude_memory_id=memory_id,
|
||||
max_turns=_hist_turns * 2, # k 轮对话 = k*2 条 message
|
||||
language=request.language,
|
||||
)
|
||||
_recent_history_ms = (_t_recent_history and (datetime.now() - _t_recent_history).total_seconds() * 1000) or 0.0
|
||||
_recent_history_count = len([line for line in (_history_context or "").splitlines() if line.strip()])
|
||||
@@ -2794,6 +2798,7 @@ class MemoryWriter(WritePipeline):
|
||||
user_id=request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
session_id=request.session_id or "default_session",
|
||||
language=request.language,
|
||||
)
|
||||
if finalize is finalize_dynamic_episode_capsules:
|
||||
finalize_kwargs["summary_concurrency"] = getattr(
|
||||
@@ -2954,9 +2959,11 @@ class MemoryWriter(WritePipeline):
|
||||
exclude_memory_id: str = "",
|
||||
max_turns: int = 20,
|
||||
max_chars_assistant: Optional[int] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
获取该用户最近的 L1_RAW 对话记录,按轮次拆分后拼成 history context。
|
||||
language:显式 prompt 语言码,用于渲染名字行;None 时按内容判 zh/en。
|
||||
|
||||
每条 L1_RAW 的 content 是多轮对话([user]: ...\n[assistant]: ...)。
|
||||
raw 在写入时已按 utils/raw_trim.py 的规则裁好(custom[RAW_TRIMMED_KEY]),
|
||||
@@ -3060,9 +3067,13 @@ class MemoryWriter(WritePipeline):
|
||||
from .base import render_user_name_line
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
_name_line = render_user_name_line(
|
||||
user_name,
|
||||
is_chinese("\n".join(b for _, b, _ in recent)),
|
||||
_name_line = (
|
||||
render_user_name_line(user_name, language=language)
|
||||
if language else
|
||||
render_user_name_line(
|
||||
user_name,
|
||||
is_chinese("\n".join(b for _, b, _ in recent)),
|
||||
)
|
||||
)
|
||||
if _name_line:
|
||||
all_messages.append(_name_line)
|
||||
@@ -3305,6 +3316,7 @@ class MemoryWriter(WritePipeline):
|
||||
existing_tags=existing_tags,
|
||||
history_context=history_context,
|
||||
basic_profile_fields=_basic_profile_fields,
|
||||
language=request.language,
|
||||
)
|
||||
extract_timing["extract_total_ms"] = round(
|
||||
(datetime.now() - _t_extract_total).total_seconds() * 1000, 2
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Prompt registry: every multilingual LLM prompt, keyed by language code.
|
||||
|
||||
One module per language (``en.py``, ``zh_hans.py``, ``ja.py`` ...) defines the
|
||||
same set of names (``PROMPT_NAMES``). Call sites look prompts up with
|
||||
``get_prompt(name, language)`` instead of importing a language module, so a
|
||||
new language only needs a new module and an entry in ``_MODULES``.
|
||||
|
||||
Language selection (``resolve_language``):
|
||||
|
||||
* explicit ``language`` (per call, else ``MemoryConfig.language`` /
|
||||
``MEMORY_LANGUAGE``) wins and is validated by ``normalize_language``;
|
||||
* otherwise the existing zh/en auto-detection runs unchanged
|
||||
(``utils.lang_detect.is_chinese`` → ``zh-Hans`` or ``en``). Auto-detection
|
||||
never picks any of the other languages.
|
||||
|
||||
When the language is explicit, ``with_output_language`` appends
|
||||
``OUTPUT_LANGUAGE_DIRECTIVE`` so memories are written in that language even
|
||||
if the conversation uses another one. Auto-detected requests keep the
|
||||
"same language as the input" rule and their prompts are byte-identical to
|
||||
earlier releases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# Canonical codes, in documentation order.
|
||||
SUPPORTED_LANGUAGES: Tuple[str, ...] = (
|
||||
"en",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ja",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"fr",
|
||||
"ko",
|
||||
"ru",
|
||||
"id",
|
||||
)
|
||||
|
||||
# Accepted aliases (matched case-insensitively, "_" treated as "-").
|
||||
LANGUAGE_ALIASES: Dict[str, str] = {
|
||||
"zh": "zh-Hans",
|
||||
"zh-cn": "zh-Hans",
|
||||
"zh-sg": "zh-Hans",
|
||||
"zh-hans-cn": "zh-Hans",
|
||||
"zh-tw": "zh-Hant",
|
||||
"zh-hk": "zh-Hant",
|
||||
"zh-mo": "zh-Hant",
|
||||
"zh-hant-tw": "zh-Hant",
|
||||
"zh-hant-hk": "zh-Hant",
|
||||
"pt-br": "pt",
|
||||
"pt-pt": "pt",
|
||||
"en-us": "en",
|
||||
"en-gb": "en",
|
||||
}
|
||||
|
||||
_MODULES: Dict[str, str] = {
|
||||
"en": "en",
|
||||
"zh-Hans": "zh_hans",
|
||||
"zh-Hant": "zh_hant",
|
||||
"ja": "ja",
|
||||
"es": "es",
|
||||
"de": "de",
|
||||
"pt": "pt",
|
||||
"fr": "fr",
|
||||
"ko": "ko",
|
||||
"ru": "ru",
|
||||
"id": "id",
|
||||
}
|
||||
|
||||
# Every language module must define all of these.
|
||||
PROMPT_NAMES: Tuple[str, ...] = (
|
||||
# chat extraction
|
||||
"EXTRACT_SYSTEM_PROMPT",
|
||||
"STRUCTURE_METADATA_PROMPT",
|
||||
"EXTRACT_FEW_SHOT",
|
||||
"EXTRACT_PROMPT",
|
||||
"EXTRACT_PROMPT_NO_INTENTION",
|
||||
"NO_INTENTION_SECTION_PATTERN",
|
||||
"NO_INTENTION_MEMORY_SECTION",
|
||||
"NO_INTENTION_REPLACEMENTS",
|
||||
"INTENTION_DISABLED_NOTE",
|
||||
"USER_NAME_LINE",
|
||||
# chat reconcile
|
||||
"SEARCH_QUERY_PROMPT",
|
||||
"RECONCILE_PROMPT",
|
||||
"RECONCILE_STRUCTURE_METADATA_PROMPT",
|
||||
"RECONCILE_FEW_SHOT",
|
||||
# working / coding memory
|
||||
"CODING_EXTRACT_PROMPT",
|
||||
"CODING_EXTRACT_PROMPT_CODING",
|
||||
"CODING_CONVERSATION_BLOCK",
|
||||
"CODING_EMPTY_BLOCK",
|
||||
"CODING_RECONCILE_PROMPT",
|
||||
"CODING_FACT_RECONCILE_PROMPT",
|
||||
"CODING_CHAIN_SUMMARY_PROMPT",
|
||||
# summaries
|
||||
"EPISODE_SUMMARY_PROMPT",
|
||||
"TOPIC_SUMMARY_PROMPT",
|
||||
# System 2 evolve
|
||||
"SYSTEM2_AGENT_PROMPT",
|
||||
"SYSTEM2_OUTPUT_INSTRUCTIONS",
|
||||
"SYSTEM2_SCHEMA_EVIDENCE_GATE",
|
||||
"SYSTEM2_SINGLE_CALL_REPLACEMENTS",
|
||||
"SYSTEM2_MATERIALS",
|
||||
# explicit-language output rule
|
||||
"OUTPUT_LANGUAGE_DIRECTIVE",
|
||||
"LANGUAGE_NAME",
|
||||
)
|
||||
|
||||
# Prompts rendered with str.format(); the value lists the expected fields.
|
||||
# The rest are appended verbatim (their braces are literal JSON).
|
||||
FORMAT_FIELDS: Dict[str, Tuple[str, ...]] = {
|
||||
"EXTRACT_PROMPT": ("content", "current_date", "last_messages", "memory_at"),
|
||||
"EXTRACT_PROMPT_NO_INTENTION": ("content", "current_date", "last_messages", "memory_at"),
|
||||
"USER_NAME_LINE": ("name",),
|
||||
"SEARCH_QUERY_PROMPT": ("new_memories",),
|
||||
"RECONCILE_PROMPT": ("current_date", "existing_memories", "few_shot_section", "new_memories"),
|
||||
"CODING_EXTRACT_PROMPT": ("branch_present", "conversation_block", "workspace_present"),
|
||||
"CODING_EXTRACT_PROMPT_CODING": ("branch_present", "conversation_block", "workspace_present"),
|
||||
"CODING_CONVERSATION_BLOCK": ("history", "new"),
|
||||
"CODING_RECONCILE_PROMPT": ("candidates_block", "drafts_block"),
|
||||
"CODING_FACT_RECONCILE_PROMPT": ("candidates_block", "drafts_block"),
|
||||
"CODING_CHAIN_SUMMARY_PROMPT": ("chain_block",),
|
||||
"EPISODE_SUMMARY_PROMPT": ("memories",),
|
||||
"TOPIC_SUMMARY_PROMPT": ("memories", "topic_name"),
|
||||
}
|
||||
|
||||
# Fields of each SYSTEM2_MATERIALS entry (all other entries have none).
|
||||
SYSTEM2_MATERIALS_FIELDS: Dict[str, Tuple[str, ...]] = {
|
||||
"title": (),
|
||||
"stats": ("clustered", "clusters", "evidenced", "fresh", "graph_total", "recalled", "total", "unclustered"),
|
||||
"graph_status_empty": (),
|
||||
"clusters_header": (),
|
||||
"label_holistic": ("n",),
|
||||
"label_source_context": ("n",),
|
||||
"label_topic": ("n",),
|
||||
"raw_label": (),
|
||||
"holistic_note": ("max_schemas",),
|
||||
"companion_header": (),
|
||||
"unclustered_header": (),
|
||||
"unclustered_hint": (),
|
||||
"forward_header": ("count",),
|
||||
"graph_empty": (),
|
||||
"graph_no_recall": ("graph_total",),
|
||||
"reverse_header": (),
|
||||
"tags_header": (),
|
||||
"closing": (),
|
||||
}
|
||||
|
||||
_loaded: Dict[str, ModuleType] = {}
|
||||
|
||||
|
||||
def _supported_text() -> str:
|
||||
aliases = ", ".join(f"{k}->{v}" for k, v in LANGUAGE_ALIASES.items())
|
||||
return f"supported: {', '.join(SUPPORTED_LANGUAGES)} (aliases: {aliases})"
|
||||
|
||||
|
||||
def normalize_language(language: Optional[str]) -> Optional[str]:
|
||||
"""Return the canonical code for ``language``; None/"" stays None.
|
||||
|
||||
Raises ValueError for an unknown code, listing the supported codes.
|
||||
"""
|
||||
if language is None:
|
||||
return None
|
||||
if not isinstance(language, str):
|
||||
raise ValueError(
|
||||
f"language must be a string, got {type(language).__name__}; "
|
||||
+ _supported_text()
|
||||
)
|
||||
raw = language.strip()
|
||||
if not raw:
|
||||
return None
|
||||
key = raw.replace("_", "-").lower()
|
||||
for code in SUPPORTED_LANGUAGES:
|
||||
if code.lower() == key:
|
||||
return code
|
||||
if key in LANGUAGE_ALIASES:
|
||||
return LANGUAGE_ALIASES[key]
|
||||
raise ValueError(f"unsupported language {language!r}; " + _supported_text())
|
||||
|
||||
|
||||
def detect_language_code(content: str = "", messages=None) -> str:
|
||||
"""The legacy automatic choice: ``zh-Hans`` if Chinese, else ``en``."""
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
return "zh-Hans" if is_chinese(content, messages=messages) else "en"
|
||||
|
||||
|
||||
def resolve_language(
|
||||
language: Optional[str] = None,
|
||||
content: str = "",
|
||||
messages=None,
|
||||
) -> str:
|
||||
"""Explicit language if given (validated), else zh/en auto-detection."""
|
||||
code = normalize_language(language)
|
||||
if code is not None:
|
||||
return code
|
||||
return detect_language_code(content, messages=messages)
|
||||
|
||||
|
||||
def _module(language: str) -> ModuleType:
|
||||
code = normalize_language(language) or "en"
|
||||
mod = _loaded.get(code)
|
||||
if mod is None:
|
||||
mod = importlib.import_module(f"{__name__}.{_MODULES[code]}")
|
||||
_loaded[code] = mod
|
||||
return mod
|
||||
|
||||
|
||||
def get_prompt(name: str, language: Optional[str] = "en") -> Any:
|
||||
"""Look up prompt ``name`` for ``language`` (canonical code or alias)."""
|
||||
if name not in PROMPT_NAMES:
|
||||
raise KeyError(f"unknown prompt {name!r}")
|
||||
return getattr(_module(language or "en"), name)
|
||||
|
||||
|
||||
def with_output_language(
|
||||
prompt: str, language: Optional[str], explicit: bool,
|
||||
) -> str:
|
||||
"""Append the output-language rule when the language was set explicitly."""
|
||||
if not explicit or not language:
|
||||
return prompt
|
||||
return prompt + get_prompt("OUTPUT_LANGUAGE_DIRECTIVE", language)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTED_LANGUAGES",
|
||||
"LANGUAGE_ALIASES",
|
||||
"PROMPT_NAMES",
|
||||
"FORMAT_FIELDS",
|
||||
"SYSTEM2_MATERIALS_FIELDS",
|
||||
"normalize_language",
|
||||
"detect_language_code",
|
||||
"resolve_language",
|
||||
"get_prompt",
|
||||
"with_output_language",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,11 @@ API Endpoints:
|
||||
GET /healthz — Health check (static, process alive)
|
||||
GET /health — Liveness probe (pings internal event loop; 503 if loop blocked)
|
||||
GET /info — Server info
|
||||
|
||||
/add and /add_extracted accept an optional "language" (en, zh-Hans, zh-Hant, ja,
|
||||
es, de, pt, fr, ko, ru, id, or an alias such as zh-CN / zh-TW / pt-BR). An
|
||||
unknown code returns 400. /search has no language option: the search-time
|
||||
judge / rewrite prompts are English-only and keep the query's own language.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -100,6 +105,20 @@ def _get_client():
|
||||
return _client
|
||||
|
||||
|
||||
def _language_arg(handler: BaseHTTPRequestHandler, body: Dict, kwargs: Dict) -> bool:
|
||||
"""Validate optional ``language`` and add it to kwargs; False after a 400 reply."""
|
||||
if body.get("language") in (None, ""):
|
||||
return True
|
||||
from .prompts import normalize_language
|
||||
|
||||
try:
|
||||
kwargs["language"] = normalize_language(body["language"])
|
||||
except ValueError as e:
|
||||
_json_response(handler, 400, {"error": str(e)})
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _json_response(handler: BaseHTTPRequestHandler, status: int, data: Any):
|
||||
"""Send a JSON response."""
|
||||
body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
||||
@@ -436,6 +455,9 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
kwargs["memory_at"] = datetime.fromisoformat(body["memory_at"])
|
||||
except Exception:
|
||||
pass
|
||||
# language: explicit prompt / memory language (see HyMemoryClient.add)
|
||||
if not _language_arg(self, body, kwargs):
|
||||
return
|
||||
|
||||
result = client.add(data, **kwargs)
|
||||
_json_response(self, 200, result)
|
||||
@@ -462,6 +484,8 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
kwargs["memory_at"] = datetime.fromisoformat(body["memory_at"])
|
||||
except Exception:
|
||||
pass
|
||||
if not _language_arg(self, body, kwargs):
|
||||
return
|
||||
|
||||
result = client.add_extracted(contents, **kwargs)
|
||||
_json_response(self, 200, result)
|
||||
|
||||
+4
-4
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.10`。MemHub 源码版本为 `1.0.5`
|
||||
(build 36),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.11`。MemHub 源码版本为 `1.0.6`
|
||||
(build 37),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
`v1.0.6` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.10`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.11`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.10",
|
||||
"versionInfo": "2.0.11",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.10
|
||||
## hy-memory 2.0.11
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "one-memory"
|
||||
version = "1.0.5"
|
||||
version = "1.0.6"
|
||||
description = "MemHub local-first memory core"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.10",
|
||||
"hy-memory==2.0.11",
|
||||
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
||||
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
||||
"chromadb==1.5.9",
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.10"
|
||||
HY_MEMORY_VERSION = "2.0.11"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
|
||||
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
|
||||
CodingMemoryStore,
|
||||
)
|
||||
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.10 is pinned exactly).
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.11 is pinned exactly).
|
||||
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
||||
store._db_path = str(path)
|
||||
store._conn = None
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable, Coroutine, Iterable, Iterator, Mapping
|
||||
@@ -58,8 +57,6 @@ from one_memory.model_profiles import (
|
||||
disable_thinking_params,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RUNTIME_LOCK = threading.RLock()
|
||||
BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
|
||||
_LOCAL_ONNX_PLACEHOLDER_URL = "http://127.0.0.1/local-onnx"
|
||||
@@ -354,9 +351,6 @@ def apply_sdk_features(
|
||||
return config
|
||||
|
||||
|
||||
_LANGUAGE_UNSUPPORTED_LOGGED = False
|
||||
|
||||
|
||||
class LanguageKwargs(TypedDict, total=False):
|
||||
language: str
|
||||
|
||||
@@ -371,22 +365,14 @@ def language_kwargs(language: str | None) -> LanguageKwargs:
|
||||
|
||||
|
||||
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
|
||||
"""Set the SDK prompt language, when there is one and the SDK has the field.
|
||||
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
||||
|
||||
None (zh-Hans, or no choice) sets nothing: the SDK detects zh/en as it
|
||||
always did. An SDK without `MemoryConfig.language` keeps working without
|
||||
it; that is logged once at debug level. Like a feature, the language never
|
||||
touches `vector_store`, so the collection identity never depends on it.
|
||||
always did. Like a feature, the language never touches `vector_store`, so
|
||||
the collection identity never depends on it.
|
||||
"""
|
||||
global _LANGUAGE_UNSUPPORTED_LOGGED
|
||||
if language is None:
|
||||
return config
|
||||
if not hasattr(config, "language"):
|
||||
if not _LANGUAGE_UNSUPPORTED_LOGGED:
|
||||
_LANGUAGE_UNSUPPORTED_LOGGED = True
|
||||
logger.debug("hy_sdk_language_unsupported language=%s", language)
|
||||
return config
|
||||
config.language = language
|
||||
if language is not None:
|
||||
config.language = language
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
||||
assert manifest["bundle_id"] == "migration-1"
|
||||
assert manifest["sdk_namespace"] == "governed-v2"
|
||||
assert manifest["runtime_build"] == "build-13"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.10"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.11"
|
||||
paths = {item["path"]: item for item in manifest["files"]}
|
||||
assert {
|
||||
"management/one-memory.sqlite3",
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
The owner's rule: zh-Hans, or no explicit choice, passes no language, so the
|
||||
SDK keeps detecting zh/en exactly as before; every other code (en included)
|
||||
is passed as `MemoryConfig.language`. The pinned SDK may not have that field
|
||||
yet: then nothing is passed and that is logged once. A change rebuilds the
|
||||
is passed as `MemoryConfig.language` (hy-memory 2.0.11). A change rebuilds the
|
||||
handles the way a mode or feature change does, and the language never touches
|
||||
the vector collection identity.
|
||||
"""
|
||||
@@ -13,7 +12,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -34,31 +32,10 @@ TOKEN = "ui-language-test"
|
||||
PASSED = ("en", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _MemoryConfigWithLanguage(hy_sdk_runtime.MemoryConfig):
|
||||
"""The released SDK's `MemoryConfig`, which adds `language`."""
|
||||
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sdk_with_language(monkeypatch):
|
||||
monkeypatch.setattr(hy_sdk_runtime, "MemoryConfig", _MemoryConfigWithLanguage)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sdk_without_language(monkeypatch):
|
||||
"""The pinned SDK, whose `MemoryConfig` has no `language` yet."""
|
||||
monkeypatch.setattr(hy_sdk_runtime, "_LANGUAGE_UNSUPPORTED_LOGGED", False)
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Pinned(hy_sdk_runtime.MemoryConfig):
|
||||
def __getattribute__(self, name):
|
||||
if name == "language":
|
||||
raise AttributeError(name)
|
||||
return super().__getattribute__(name)
|
||||
|
||||
monkeypatch.setattr(hy_sdk_runtime, "MemoryConfig", Pinned)
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_sdk_env(monkeypatch):
|
||||
"""The SDK's own `MEMORY_LANGUAGE` feeds its default; tests start without it."""
|
||||
monkeypatch.delenv("MEMORY_LANGUAGE", raising=False)
|
||||
|
||||
|
||||
def _config(tmp_path: Path, language: str | None):
|
||||
@@ -92,19 +69,19 @@ def test_only_zh_hans_and_no_choice_leave_the_sdk_to_detect() -> None:
|
||||
assert sdk_language(code) == code
|
||||
|
||||
|
||||
def test_zh_hans_and_no_choice_build_the_config_of_before(tmp_path, sdk_with_language) -> None:
|
||||
def test_zh_hans_and_no_choice_build_the_config_of_before(tmp_path) -> None:
|
||||
before = repr(dataclasses.asdict(_config(tmp_path, None)))
|
||||
assert _config(tmp_path, None).language is None
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ("en", "ja"))
|
||||
def test_a_passed_code_reaches_the_memory_config(tmp_path, sdk_with_language, code) -> None:
|
||||
@pytest.mark.parametrize("code", PASSED)
|
||||
def test_a_passed_code_reaches_the_memory_config(tmp_path, code) -> None:
|
||||
config = _config(tmp_path, sdk_language(code))
|
||||
assert config.language == code
|
||||
|
||||
|
||||
def test_the_language_never_changes_the_collection_identity(tmp_path, sdk_with_language) -> None:
|
||||
def test_the_language_never_changes_the_collection_identity(tmp_path) -> None:
|
||||
def identity(config):
|
||||
return (
|
||||
dataclasses.asdict(config.vector_store),
|
||||
@@ -119,24 +96,6 @@ def test_the_language_never_changes_the_collection_identity(tmp_path, sdk_with_l
|
||||
assert identity(_config(tmp_path, code)) == baseline
|
||||
|
||||
|
||||
def test_an_sdk_without_the_field_is_left_alone_and_logged_once(
|
||||
tmp_path, sdk_without_language, caplog
|
||||
) -> None:
|
||||
caplog.set_level(logging.DEBUG, logger=hy_sdk_runtime.__name__)
|
||||
before = repr(dataclasses.asdict(_config(tmp_path, None)))
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, "ja"))) == before
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, "fr"))) == before
|
||||
logged = [r for r in caplog.records if "hy_sdk_language_unsupported" in r.getMessage()]
|
||||
assert len(logged) == 1 and logged[0].levelno == logging.DEBUG
|
||||
|
||||
|
||||
def test_the_pinned_sdk_builds_with_a_language(tmp_path) -> None:
|
||||
"""Whatever the pinned SDK is, a language never breaks building a config."""
|
||||
config = _config(tmp_path, "ja")
|
||||
if hasattr(config, "language"):
|
||||
assert config.language == "ja"
|
||||
|
||||
|
||||
def test_acquire_client_passes_the_language_only_when_there_is_one(tmp_path, monkeypatch) -> None:
|
||||
calls: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
|
||||
Generated
+5
-5
@@ -991,7 +991,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hy-memory"
|
||||
version = "2.0.10"
|
||||
version = "2.0.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/ed/1e1264d5b8741237c34fdaf17ae68bf5d16d403ca73c054997a502cd06a3/hy_memory-2.0.10.tar.gz", hash = "sha256:0841a441862e2427f75898bb8c958ed22fa083d2c0326298687ee129bcb729a8", size = 881659, upload-time = "2026-11-01T11:33:33.742Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/93/aec7d243259f6a315f38e8ebc21a9eea153a48fc13648fda149be7672788/hy_memory-2.0.11.tar.gz", hash = "sha256:96d6bd606d61052f127cd274aa6f8de8e7151d6250f1e89e4ef5b90e18ec7cb0", size = 1245738, upload-time = "2026-11-01T14:15:02.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d6/c07a9d2d3af14da5a90c2bc466a46fc77068c24570fafe8f5729e46e2cae/hy_memory-2.0.10-py3-none-any.whl", hash = "sha256:54528d61a1730c7ea0f464f15144de3d1952f5d9aab1875bc841c97a193914b1", size = 947165, upload-time = "2026-11-01T11:33:31.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/85/1f5e6a13507a0cfcdce26f67c590df00d327a0f7d5c5b5483c3bd0fa2f85/hy_memory-2.0.11-py3-none-any.whl", hash = "sha256:c0173444a83c1197a49acaccb123f8286cea205b4838c1e83d106974f5bad259", size = 1315871, upload-time = "2026-11-01T14:14:58.764Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1801,7 +1801,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "one-memory"
|
||||
version = "1.0.5"
|
||||
version = "1.0.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomysql" },
|
||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "filelock", specifier = ">=3.32.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "hy-memory", specifier = "==2.0.10" },
|
||||
{ name = "hy-memory", specifier = "==2.0.11" },
|
||||
{ name = "keyring", specifier = ">=25.7.0" },
|
||||
{ name = "numpy", specifier = "==2.5.2" },
|
||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/desktop",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/site",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/web",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
|
||||
- Linux x64/arm64 Runtime on native GHA runners.
|
||||
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
||||
- Windows is not supported.
|
||||
- `hy-memory==2.0.10` is the current stable SDK dependency; the release
|
||||
- `hy-memory==2.0.11` is the current stable SDK dependency; the release
|
||||
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
||||
|
||||
See [`INSTALLATION.md`](INSTALLATION.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.5`(build 36)的发行目标如下:
|
||||
MemHub `v1.0.6`(build 37)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -195,7 +195,7 @@ node apps/cli/dist/one-memory.cjs settings language system # 跟随系统(
|
||||
- 可选 11 种:`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、`ko`、`ru`、`id`,以及 `system`;旧代码 `zh-CN` 按 `zh-Hans` 处理。
|
||||
- App 切换语言时会自动设置(Desktop 启动时也会同步一次已保存的选择)。全局设置,存于 `config.json` 的 `ui_language`;从未选择时不写入。
|
||||
- `zh-Hans` 和 `system` 不向 SDK 传语言,SDK 照旧自动识别中文或英文;其余语言(包括 `en`)作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言。
|
||||
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名。当前固定的 SDK 版本还没有 `language` 字段时只在 debug 日志记一次,不影响运行。
|
||||
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
|
||||
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码)。API:`GET`/`PUT /api/v1/settings/ui-language`。
|
||||
|
||||
## 8. 召回(recall)
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"release": {
|
||||
"build_number": 36,
|
||||
"build_number": 37,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/contracts",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/host-bridge",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/i18n",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/operations",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/runtime-control",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
|
||||
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
|
||||
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
|
||||
HY_MEMORY_NAME = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.10"
|
||||
HY_MEMORY_VERSION = "2.0.11"
|
||||
|
||||
|
||||
class SbomError(RuntimeError):
|
||||
|
||||
@@ -12,8 +12,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(readme, /`hy-memory==2\.0\.10`/);
|
||||
assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/);
|
||||
assert.match(readme, /`hy-memory==2\.0\.11`/);
|
||||
assert.match(readme, /`v1\.0\.6` 的发行目标是 Linux x64\/arm64 Runtime/);
|
||||
assert.match(readme, /Linux 制品由 GitHub Actions/);
|
||||
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
|
||||
assert.match(readme, /release-manifest\.json/);
|
||||
@@ -25,7 +25,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
assert.match(installation, /Linux x64.*GitHub Actions/s);
|
||||
assert.match(installation, /Linux arm64.*GitHub Actions/s);
|
||||
assert.match(installation, /macOS arm64.*Developer ID/s);
|
||||
// v1.0.5 ships no Intel build; the table has to say so rather than omit it.
|
||||
// v1.0.6 ships no Intel build; the table has to say so rather than omit it.
|
||||
assert.match(installation, /\| macOS x64 \| 无 \|/);
|
||||
assert.match(installation, /release-manifest\.json/);
|
||||
assert.match(installation, /fake Release/);
|
||||
|
||||
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
|
||||
dist_info = tmp_path / "hy_memory-2.0.10.dist-info"
|
||||
dist_info = tmp_path / "hy_memory-2.0.11.dist-info"
|
||||
licenses = dist_info / "licenses"
|
||||
licenses.mkdir(parents=True)
|
||||
(dist_info / "METADATA").write_text(
|
||||
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
(
|
||||
"Metadata-Version: 2.4",
|
||||
"Name: hy-memory",
|
||||
"Version: 2.0.10",
|
||||
"Version: 2.0.11",
|
||||
"License-Expression: Apache-2.0",
|
||||
"",
|
||||
)
|
||||
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
notice = module.hy_memory_notice(dist_info)
|
||||
|
||||
assert notice["name"] == "hy-memory"
|
||||
assert notice["version"] == "2.0.10"
|
||||
assert notice["version"] == "2.0.11"
|
||||
assert notice["license"] == "Apache-2.0"
|
||||
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
|
||||
assert "Licensed under the Apache License" in notice["text"]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.10"
|
||||
version = "2.0.11"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -137,7 +137,7 @@ def test_extractor_routes_chinese_new_user_message_to_chinese_prompt(scene, zh_p
|
||||
ChatMessage(role="tool", content="Traceback: TypeError at line 100"),
|
||||
])
|
||||
expected = zh_prompts["extract_coding" if scene == "coding" else "extract"]
|
||||
assert language == "zh"
|
||||
assert language == "zh-Hans"
|
||||
assert template is expected
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ def test_experience_reconcile_routes_from_new_drafts_not_candidates():
|
||||
solution="Use the legacy retry helper",
|
||||
)]]
|
||||
prompt, language = build_experience_reconcile_prompt(drafts, candidates)
|
||||
assert language == "zh"
|
||||
assert language == "zh-Hans"
|
||||
assert prompt.startswith("你")
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ def test_fact_reconcile_routes_from_new_fact_statement():
|
||||
statement="The previous session inspected retry behavior.",
|
||||
)]]
|
||||
prompt, language = build_fact_reconcile_prompt(drafts, candidates)
|
||||
assert language == "zh"
|
||||
assert language == "zh-Hans"
|
||||
assert prompt.startswith("你")
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ def test_chain_summary_routes_from_current_statement():
|
||||
"(now) 用户继续修复 timeout,并完成验证。",
|
||||
current_statement="用户继续修复 timeout,并完成验证。",
|
||||
)
|
||||
assert language == "zh"
|
||||
assert language == "zh-Hans"
|
||||
assert prompt == CHAIN_SUMMARY_PROMPT_ZH.format(
|
||||
chain_block=(
|
||||
"(2026-10-11) The previous session inspected timeout behavior.\n"
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Explicit prompt language: validation, precedence and threading.
|
||||
|
||||
With no language the existing zh/en auto-detection runs unchanged; with an
|
||||
explicit language every LLM call of the request uses that language's prompt
|
||||
plus its OUTPUT_LANGUAGE_DIRECTIVE.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.agent.extractor import Extractor, _system_prompt_without_intention
|
||||
from hy_memory.agent.llm_provider import LLMResponse
|
||||
from hy_memory.agent.reconciler import build_reconcile_prompt, build_search_query_prompt
|
||||
from hy_memory.coding.extractor import CodingMemoryExtractor
|
||||
from hy_memory.coding.fact_reconciler import (
|
||||
build_chain_summary_prompt,
|
||||
build_fact_reconcile_prompt,
|
||||
)
|
||||
from hy_memory.coding.reconciler import build_experience_reconcile_prompt
|
||||
from hy_memory.coding.types import CodingMemoryDraft, WorkingFactDraft
|
||||
from hy_memory.config import LLMConfig, MemoryConfig
|
||||
from hy_memory.pipelines.base import (
|
||||
ChatMessage,
|
||||
WriteRequest,
|
||||
parse_user_name_line,
|
||||
render_user_name_line,
|
||||
)
|
||||
from hy_memory.prompts import (
|
||||
SUPPORTED_LANGUAGES,
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
)
|
||||
|
||||
from tests.coding._helpers import MockLLMProvider
|
||||
|
||||
ZH_INPUT = "我叫小明,住在北京,喜欢喝美式咖啡。"
|
||||
EN_INPUT = "My name is Bob, I live in Seattle and I love hiking."
|
||||
|
||||
|
||||
def run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def _directive(code):
|
||||
return get_prompt("OUTPUT_LANGUAGE_DIRECTIVE", code)
|
||||
|
||||
|
||||
# ── codes, aliases, validation ──
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("en", "en"), ("zh-Hans", "zh-Hans"), ("zh-Hant", "zh-Hant"), ("ja", "ja"),
|
||||
("ZH-hans", "zh-Hans"), ("zh_Hant", "zh-Hant"), (" ko ", "ko"),
|
||||
("zh", "zh-Hans"), ("zh-CN", "zh-Hans"), ("zh-cn", "zh-Hans"),
|
||||
("zh-TW", "zh-Hant"), ("zh-HK", "zh-Hant"), ("pt-BR", "pt"),
|
||||
(None, None), ("", None),
|
||||
])
|
||||
def test_normalize_language_accepts_codes_and_aliases(value, expected):
|
||||
assert normalize_language(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["xx", "english", "zh-XX", "pt_BR_x", 3])
|
||||
def test_unknown_language_raises_value_error_listing_supported_codes(value):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
normalize_language(value)
|
||||
for code in SUPPORTED_LANGUAGES:
|
||||
assert code in str(exc.value)
|
||||
|
||||
|
||||
def test_resolve_language_falls_back_to_zh_en_detection_only():
|
||||
assert resolve_language(None, ZH_INPUT) == "zh-Hans"
|
||||
assert resolve_language(None, EN_INPUT) == "en"
|
||||
# auto-detection never picks the other languages
|
||||
assert resolve_language(None, "ユーザーはコーヒーが好きです") in ("zh-Hans", "en")
|
||||
assert resolve_language(None, "Der Nutzer mag Kaffee.") == "en"
|
||||
assert resolve_language("de", ZH_INPUT) == "de"
|
||||
|
||||
|
||||
# ── config + client precedence ──
|
||||
|
||||
def test_memory_config_language_from_env_and_dict(monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_LANGUAGE", "zh-TW")
|
||||
assert MemoryConfig().language == "zh-Hant"
|
||||
monkeypatch.setenv("MEMORY_LANGUAGE", "")
|
||||
assert MemoryConfig().language is None
|
||||
assert MemoryConfig.from_dict({"language": "pt-BR"}).language == "pt"
|
||||
assert MemoryConfig(language="FR").language == "fr"
|
||||
monkeypatch.setenv("MEMORY_LANGUAGE", "klingon")
|
||||
with pytest.raises(ValueError):
|
||||
MemoryConfig()
|
||||
|
||||
|
||||
def test_client_per_call_language_overrides_config(monkeypatch):
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
monkeypatch.delenv("MEMORY_LANGUAGE", raising=False)
|
||||
client = HyMemoryClient.__new__(HyMemoryClient)
|
||||
client._config = MemoryConfig(language="ja")
|
||||
assert client._resolve_language(None) == "ja"
|
||||
assert client._resolve_language("zh-CN") == "zh-Hans"
|
||||
client._config = MemoryConfig()
|
||||
assert client._resolve_language(None) is None
|
||||
with pytest.raises(ValueError):
|
||||
client._resolve_language("xx")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_add_threads_language_and_rejects_unknown_before_claim():
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
client = HyMemoryClient.__new__(HyMemoryClient)
|
||||
client._config = MemoryConfig(language="es")
|
||||
client._claim_write_outcome = AsyncMock(return_value=None)
|
||||
client._finish_write_outcome = AsyncMock()
|
||||
client._async_add_impl = AsyncMock(return_value={"success": True})
|
||||
|
||||
await HyMemoryClient.async_add.__wrapped__(client, "hola", user_id="u1")
|
||||
assert client._async_add_impl.await_args.kwargs["language"] == "es"
|
||||
await HyMemoryClient.async_add.__wrapped__(
|
||||
client, "hi", user_id="u1", language="zh-HK",
|
||||
)
|
||||
assert client._async_add_impl.await_args.kwargs["language"] == "zh-Hant"
|
||||
|
||||
client._claim_write_outcome.reset_mock()
|
||||
with pytest.raises(ValueError):
|
||||
await HyMemoryClient.async_add.__wrapped__(
|
||||
client, "hi", user_id="u1", language="xx",
|
||||
)
|
||||
client._claim_write_outcome.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_add_extracted_threads_language():
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
client = HyMemoryClient.__new__(HyMemoryClient)
|
||||
client._config = MemoryConfig()
|
||||
client._claim_write_outcome = AsyncMock(return_value=None)
|
||||
client._finish_write_outcome = AsyncMock()
|
||||
client._async_add_extracted_impl = AsyncMock(return_value={"success": True})
|
||||
await HyMemoryClient.async_add_extracted.__wrapped__(
|
||||
client, ["a fact"], user_id="u1", language="ru",
|
||||
)
|
||||
assert client._async_add_extracted_impl.await_args.kwargs["language"] == "ru"
|
||||
await HyMemoryClient.async_add_extracted.__wrapped__(
|
||||
client, ["a fact"], user_id="u1",
|
||||
)
|
||||
assert client._async_add_extracted_impl.await_args.kwargs["language"] is None
|
||||
|
||||
|
||||
# ── chat extraction ──
|
||||
|
||||
class _CapturingLLM:
|
||||
def __init__(self):
|
||||
self.last_messages = None
|
||||
|
||||
async def complete_messages(self, *, messages, max_tokens, temperature=None,
|
||||
tools=None, tool_choice=None, **kwargs):
|
||||
self.last_messages = messages
|
||||
return LLMResponse(
|
||||
content='{"memory": [], "intentions": [], "basic_info": {}}',
|
||||
tokens_used=1, prompt_tokens=1, completion_tokens=0, tool_calls=None,
|
||||
)
|
||||
|
||||
def role(self, role):
|
||||
return next(m["content"] for m in self.last_messages if m["role"] == role)
|
||||
|
||||
|
||||
def _extract(content, language=None, **cfg):
|
||||
llm = _CapturingLLM()
|
||||
run(Extractor(llm, LLMConfig(**cfg)).extract(
|
||||
content=content, current_time="2026-07-01", language=language,
|
||||
))
|
||||
return llm
|
||||
|
||||
|
||||
def test_extract_without_language_keeps_auto_detection_byte_identical():
|
||||
assert _extract(ZH_INPUT).role("system") == get_prompt("EXTRACT_SYSTEM_PROMPT", "zh-Hans")
|
||||
assert _extract(EN_INPUT).role("system") == get_prompt("EXTRACT_SYSTEM_PROMPT", "en")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", SUPPORTED_LANGUAGES)
|
||||
def test_extract_with_explicit_language_uses_that_prompt_and_directive(code):
|
||||
llm = _extract(ZH_INPUT, language=code)
|
||||
assert llm.role("system") == (
|
||||
get_prompt("EXTRACT_SYSTEM_PROMPT", code) + _directive(code)
|
||||
)
|
||||
assert llm.role("user") == get_prompt("EXTRACT_PROMPT", code).format(
|
||||
content=ZH_INPUT, current_date=_today(),
|
||||
memory_at="2026-07-01", last_messages="(none)",
|
||||
)
|
||||
|
||||
|
||||
def _today():
|
||||
from datetime import date
|
||||
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def test_extract_alias_and_few_shot_follow_explicit_language():
|
||||
llm = _extract(EN_INPUT, language="zh-TW", few_shot_enabled=True)
|
||||
assert llm.role("system").startswith(
|
||||
get_prompt("EXTRACT_SYSTEM_PROMPT", "zh-Hant")
|
||||
+ get_prompt("EXTRACT_FEW_SHOT", "zh-Hant")
|
||||
)
|
||||
|
||||
|
||||
def test_custom_system_prompt_still_applies_to_its_own_language_only():
|
||||
llm = _extract(EN_INPUT, language="en", extract_system_prompt_en="CUSTOM EN")
|
||||
assert llm.role("system") == "CUSTOM EN" + _directive("en")
|
||||
llm = _extract(EN_INPUT, language="de", extract_system_prompt_en="CUSTOM EN")
|
||||
assert llm.role("system").startswith(get_prompt("EXTRACT_SYSTEM_PROMPT", "de"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", SUPPORTED_LANGUAGES)
|
||||
def test_no_intention_rewrite_works_for_every_language(code):
|
||||
import re
|
||||
|
||||
base = get_prompt("EXTRACT_SYSTEM_PROMPT", code)
|
||||
out = _system_prompt_without_intention(base, lang=code)
|
||||
assert get_prompt("NO_INTENTION_MEMORY_SECTION", code) in out
|
||||
assert not re.search(get_prompt("NO_INTENTION_SECTION_PATTERN", code), out, re.S)
|
||||
assert out.endswith(get_prompt("INTENTION_DISABLED_NOTE", code))
|
||||
|
||||
|
||||
# ── chat reconcile ──
|
||||
|
||||
def _reconcile(memories, language=None):
|
||||
return build_reconcile_prompt(
|
||||
new_memories=memories, new_memories_block="NEW", existing_memories_block="OLD",
|
||||
current_date="2026-07-01 10:00", few_shot_enabled=True,
|
||||
structure_metadata_enabled=True, language=language,
|
||||
)
|
||||
|
||||
|
||||
def test_reconcile_without_language_keeps_auto_detection():
|
||||
zh = _reconcile([ZH_INPUT])
|
||||
assert zh.startswith(get_prompt("RECONCILE_PROMPT", "zh-Hans")[:40])
|
||||
assert zh.endswith(get_prompt("RECONCILE_STRUCTURE_METADATA_PROMPT", "zh-Hans"))
|
||||
en = _reconcile([EN_INPUT])
|
||||
assert en.endswith(get_prompt("RECONCILE_STRUCTURE_METADATA_PROMPT", "en"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ["fr", "zh-Hant", "id"])
|
||||
def test_reconcile_and_search_query_use_explicit_language(code):
|
||||
prompt = _reconcile([ZH_INPUT], language=code)
|
||||
expected = get_prompt("RECONCILE_PROMPT", code).format(
|
||||
current_date="2026-07-01 10:00", new_memories="NEW", existing_memories="OLD",
|
||||
few_shot_section="\n" + get_prompt("RECONCILE_FEW_SHOT", code) + "\n",
|
||||
) + get_prompt("RECONCILE_STRUCTURE_METADATA_PROMPT", code) + _directive(code)
|
||||
assert prompt == expected
|
||||
sq = build_search_query_prompt([ZH_INPUT], language=code)
|
||||
assert sq.endswith(_directive(code))
|
||||
assert sq.startswith(get_prompt("SEARCH_QUERY_PROMPT", code)[:20])
|
||||
assert build_search_query_prompt([ZH_INPUT]) == get_prompt(
|
||||
"SEARCH_QUERY_PROMPT", "zh-Hans",
|
||||
).format(new_memories=f"1. {ZH_INPUT}")
|
||||
|
||||
|
||||
# ── working / coding path ──
|
||||
|
||||
@pytest.mark.parametrize("scene,name", [
|
||||
("working", "CODING_EXTRACT_PROMPT"), ("coding", "CODING_EXTRACT_PROMPT_CODING"),
|
||||
])
|
||||
def test_coding_extractor_template_follows_explicit_language(scene, name):
|
||||
extractor = CodingMemoryExtractor(MockLLMProvider("{}"), scene=scene)
|
||||
messages = [ChatMessage(role="user", content="请修复 api_client.py 的 timeout")]
|
||||
template, code = extractor._select_prompt_template(messages, "ko")
|
||||
assert code == "ko" and template is get_prompt(name, "ko")
|
||||
template, code = extractor._select_prompt_template(messages)
|
||||
assert code == "zh-Hans" and template is get_prompt(name, "zh-Hans")
|
||||
|
||||
|
||||
def test_coding_extract_call_uses_language_for_labels_and_directive():
|
||||
llm = MockLLMProvider('{"experiences": [], "fact": null}')
|
||||
extractor = CodingMemoryExtractor(llm, scene="working")
|
||||
run(extractor.extract(
|
||||
[ChatMessage(role="user", content="Fix the timeout"),
|
||||
ChatMessage(role="tool", content="ok")],
|
||||
user_id="u1", language="ja",
|
||||
))
|
||||
prompt = llm.calls[-1]["prompt"]
|
||||
assert prompt.endswith(_directive("ja"))
|
||||
empty = get_prompt("CODING_EMPTY_BLOCK", "ja")
|
||||
head = get_prompt("CODING_CONVERSATION_BLOCK", "ja").split("{history}")[0]
|
||||
assert head + empty in prompt
|
||||
|
||||
|
||||
def test_coding_extract_without_language_is_unchanged():
|
||||
llm = MockLLMProvider('{"experiences": [], "fact": null}')
|
||||
extractor = CodingMemoryExtractor(llm, scene="working")
|
||||
run(extractor.extract(
|
||||
[ChatMessage(role="user", content="Fix the timeout"),
|
||||
ChatMessage(role="tool", content="ok")],
|
||||
user_id="u1",
|
||||
))
|
||||
prompt = llm.calls[-1]["prompt"]
|
||||
assert "LAST 5 USER TURNS (CONTEXT ONLY; do not extract them again):\n(none)" in prompt
|
||||
assert _directive("en") not in prompt
|
||||
|
||||
|
||||
def test_coding_reconcilers_follow_explicit_language():
|
||||
drafts = [CodingMemoryDraft(task="修复搜索超时", solution="改 timeout", user_id="u1")]
|
||||
prompt, code = build_experience_reconcile_prompt(drafts, [[]], language="es")
|
||||
assert code == "es" and prompt.endswith(_directive("es"))
|
||||
assert prompt.startswith(get_prompt("CODING_RECONCILE_PROMPT", "es")[:30])
|
||||
_, code = build_experience_reconcile_prompt(drafts, [[]])
|
||||
assert code == "zh-Hans"
|
||||
|
||||
facts = [WorkingFactDraft(subject="api.py", statement="修复了 timeout")]
|
||||
prompt, code = build_fact_reconcile_prompt(facts, [[]], language="pt-BR")
|
||||
assert code == "pt" and prompt.endswith(_directive("pt"))
|
||||
|
||||
prompt, code = build_chain_summary_prompt(
|
||||
"(now) fixed", current_statement="fixed", language="ru",
|
||||
)
|
||||
assert code == "ru"
|
||||
assert prompt == get_prompt("CODING_CHAIN_SUMMARY_PROMPT", "ru").format(
|
||||
chain_block="(now) fixed",
|
||||
) + _directive("ru")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coding_writer_threads_language_to_every_step():
|
||||
from hy_memory.coding.writer import CodingWriter
|
||||
|
||||
writer = CodingWriter.__new__(CodingWriter)
|
||||
writer.store = type("S", (), {})()
|
||||
writer.store.config = type("C", (), {
|
||||
"coding": type("CC", (), {"tool_result_max_bytes": 2048})(),
|
||||
"extractor": type("E", (), {"history_turns": 5})(),
|
||||
})()
|
||||
writer.store.get_recent_trace_messages = AsyncMock(return_value=[])
|
||||
writer.store.append_trace = AsyncMock()
|
||||
draft = CodingMemoryDraft(task="t", solution="s", user_id="u1")
|
||||
fact = WorkingFactDraft(subject="x", statement="y")
|
||||
writer.extractor = type("X", (), {})()
|
||||
writer.extractor.extract = AsyncMock(return_value=([draft], [fact]))
|
||||
writer.reconciler = type("R", (), {})()
|
||||
writer.reconciler.reconcile = AsyncMock(return_value=[])
|
||||
writer.fact_reconciler = type("F", (), {})()
|
||||
writer.fact_reconciler.reconcile = AsyncMock(return_value=([], []))
|
||||
writer._crosslink = AsyncMock()
|
||||
writer.cache = None
|
||||
writer.variant = "working"
|
||||
|
||||
await writer.write([ChatMessage(role="user", content="hi")], user_id="u1",
|
||||
language="de")
|
||||
assert writer.extractor.extract.await_args.kwargs["language"] == "de"
|
||||
assert writer.reconciler.reconcile.await_args.kwargs["language"] == "de"
|
||||
assert writer.fact_reconciler.reconcile.await_args.kwargs["language"] == "de"
|
||||
|
||||
|
||||
# ── summaries ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_episode_and_topic_summary_prompts_follow_language():
|
||||
from hy_memory.agent import episode_summary
|
||||
from hy_memory.pipelines._graph import topic_summary
|
||||
|
||||
llm = MockLLMProvider("summary")
|
||||
_, prompt, _ = await episode_summary._generate(
|
||||
llm_provider=llm, memories=["用户喜欢咖啡"], language="ja",
|
||||
)
|
||||
assert prompt == get_prompt("EPISODE_SUMMARY_PROMPT", "ja").format(
|
||||
memories="- 用户喜欢咖啡",
|
||||
) + _directive("ja")
|
||||
_, prompt, _ = await episode_summary._generate(
|
||||
llm_provider=llm, memories=["用户喜欢咖啡"],
|
||||
)
|
||||
assert prompt == get_prompt("EPISODE_SUMMARY_PROMPT", "zh-Hans").format(
|
||||
memories="- 用户喜欢咖啡",
|
||||
)
|
||||
_, prompt, _ = await topic_summary._generate(
|
||||
llm_provider=llm, topic_name="coffee", memories=["likes coffee"],
|
||||
language="fr",
|
||||
)
|
||||
assert prompt.endswith(_directive("fr"))
|
||||
assert prompt.startswith(get_prompt("TOPIC_SUMMARY_PROMPT", "fr")[:20])
|
||||
|
||||
|
||||
# ── System 2 ──
|
||||
|
||||
@pytest.mark.parametrize("code", SUPPORTED_LANGUAGES)
|
||||
def test_system2_prompts_follow_language(code):
|
||||
from hy_memory.pipelines import system2_agent as s2
|
||||
|
||||
system_prompt = s2._build_single_call_system_prompt(code)
|
||||
assert system_prompt.endswith(get_prompt("SYSTEM2_OUTPUT_INSTRUCTIONS", code))
|
||||
first_old = get_prompt("SYSTEM2_SINGLE_CALL_REPLACEMENTS", code)[0][0]
|
||||
assert first_old not in system_prompt
|
||||
message = s2._build_materials_message(
|
||||
{"stats": {"graph_total_schemas": 0}, "clusters": []}, lang=code,
|
||||
)
|
||||
materials = get_prompt("SYSTEM2_MATERIALS", code)
|
||||
assert message.startswith(materials["title"])
|
||||
assert message.endswith(materials["closing"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system2_agent_uses_explicit_language(monkeypatch):
|
||||
from hy_memory.pipelines import system2_agent as s2
|
||||
|
||||
seen = {}
|
||||
|
||||
class _Provider:
|
||||
def __init__(self, config):
|
||||
pass
|
||||
|
||||
async def complete_messages(self, messages, **kwargs):
|
||||
seen["messages"] = messages
|
||||
return LLMResponse(content="```json\n[]\n```", tokens_used=1)
|
||||
|
||||
monkeypatch.setattr("hy_memory.agent.llm_provider.LLMProvider", _Provider)
|
||||
materials = {"stats": {}, "clusters": [{"facts": [
|
||||
{"layer": "L2", "content": "用户每天跑步", "node_id": "f1"}], "centroid_text": "跑步"}]}
|
||||
async def _run(**kwargs):
|
||||
# Only the prompt sent to the model matters here; executing the
|
||||
# (empty) operation list needs a real tool executor.
|
||||
try:
|
||||
await s2.run_system2_agent(
|
||||
materials, tool_executor=None, config=MemoryConfig(), **kwargs,
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
await _run(language="de")
|
||||
system = seen["messages"][0]["content"]
|
||||
assert system.startswith(get_prompt("SYSTEM2_AGENT_PROMPT", "de")[:30])
|
||||
assert system.endswith(_directive("de"))
|
||||
assert seen["messages"][1]["content"].startswith(
|
||||
get_prompt("SYSTEM2_MATERIALS", "de")["title"]
|
||||
)
|
||||
|
||||
await _run()
|
||||
system = seen["messages"][0]["content"]
|
||||
assert system == s2._build_single_call_system_prompt("zh")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweeper_llm_call_gets_output_language_rule(monkeypatch):
|
||||
from hy_memory.pipelines import system2_writer
|
||||
|
||||
prompts = []
|
||||
|
||||
class _Sweeper:
|
||||
def __init__(self, *, llm_call, **kwargs):
|
||||
self.llm_call = llm_call
|
||||
|
||||
async def sweep(self):
|
||||
await self.llm_call("Task: abstract")
|
||||
return {}
|
||||
|
||||
async def llm_call(prompt):
|
||||
prompts.append(prompt)
|
||||
return "{}"
|
||||
|
||||
monkeypatch.setattr(system2_writer, "_SWEEPER_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
"hy_memory.pipelines.cross_domain_sweeper.CrossDomainSweeper", _Sweeper,
|
||||
)
|
||||
writer = system2_writer.System2Writer.__new__(system2_writer.System2Writer)
|
||||
writer._writer = type("W", (), {"embed_service": None})()
|
||||
writer._graph_store = None
|
||||
writer._cache = None
|
||||
await writer._run_cross_domain_sweeper("u1", "a1", llm_call, language="ko")
|
||||
await writer._run_cross_domain_sweeper("u1", "a1", llm_call)
|
||||
assert prompts == ["Task: abstract" + _directive("ko"), "Task: abstract"]
|
||||
|
||||
|
||||
# ── basic profile name line + write request ──
|
||||
|
||||
@pytest.mark.parametrize("code", SUPPORTED_LANGUAGES)
|
||||
def test_user_name_line_round_trips_in_every_language(code):
|
||||
line = render_user_name_line("Tim", language=code)
|
||||
assert line == get_prompt("USER_NAME_LINE", code).format(name="Tim")
|
||||
assert parse_user_name_line(line) == "Tim"
|
||||
|
||||
|
||||
def test_write_request_name_line_uses_explicit_language():
|
||||
msgs = [ChatMessage(role="user", content="我喜欢咖啡", name="Tim")]
|
||||
assert WriteRequest(messages=msgs).get_flat_content().startswith("用户名叫Tim")
|
||||
assert WriteRequest(messages=msgs, language="ja").get_flat_content().startswith(
|
||||
get_prompt("USER_NAME_LINE", "ja").format(name="Tim")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mem_agent_passes_language_to_extractor():
|
||||
from hy_memory.agent.mem_agent import MemAgent
|
||||
|
||||
agent = MemAgent.__new__(MemAgent)
|
||||
agent.extractor = type("X", (), {})()
|
||||
agent.extractor.extract = AsyncMock(side_effect=RuntimeError("stop"))
|
||||
await agent.process_add(content="hi", context={}, language="id")
|
||||
assert agent.extractor.extract.await_args.kwargs["language"] == "id"
|
||||
|
||||
|
||||
# ── server ──
|
||||
|
||||
def test_server_language_arg_validates_and_normalizes():
|
||||
from hy_memory import server
|
||||
|
||||
sent = {}
|
||||
original = server._json_response
|
||||
server._json_response = lambda handler, status, data: sent.update(
|
||||
status=status, data=data,
|
||||
)
|
||||
try:
|
||||
kwargs = {}
|
||||
assert server._language_arg(None, {"language": "zh-TW"}, kwargs)
|
||||
assert kwargs == {"language": "zh-Hant"}
|
||||
kwargs = {}
|
||||
assert server._language_arg(None, {}, kwargs) and kwargs == {}
|
||||
assert not server._language_arg(None, {"language": "xx"}, {})
|
||||
assert sent["status"] == 400 and "zh-Hans" in sent["data"]["error"]
|
||||
finally:
|
||||
server._json_response = original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"使用者", "ユーザー", "사용자", "El usuario", "Der Nutzer", "O usuário",
|
||||
"L'utilisateur", "Пользователь", "Pengguna",
|
||||
])
|
||||
def test_localized_user_placeholders_never_become_a_name(value):
|
||||
from hy_memory.agent.tools.basic_profile import is_placeholder_value
|
||||
|
||||
assert is_placeholder_value("name", value)
|
||||
assert not is_placeholder_value("name", "Tim")
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Prompt registry contracts: every language defines every prompt correctly.
|
||||
|
||||
en and zh-Hans are pinned by sha256 to the strings shipped before the
|
||||
registry existed, so existing users see byte-identical prompts.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from string import Formatter
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.prompts import (
|
||||
FORMAT_FIELDS,
|
||||
PROMPT_NAMES,
|
||||
SUPPORTED_LANGUAGES,
|
||||
SYSTEM2_MATERIALS_FIELDS,
|
||||
get_prompt,
|
||||
)
|
||||
|
||||
_PINNED_SHA256 = {
|
||||
"en": {
|
||||
"EXTRACT_SYSTEM_PROMPT": "2e362cbeb2d2ed5cb71a830979b645c56460e95afe00ad3238bf986c0d631936",
|
||||
"STRUCTURE_METADATA_PROMPT": "b64b3d33484240d1ac8b7db2488864a166beb9d677a9270c3701f0fb0ce55987",
|
||||
"EXTRACT_FEW_SHOT": "c6c3b5ce137e5d18bfd492c5d36ad170e0c29e1755a94b29cc705edeee14d3f1",
|
||||
"EXTRACT_PROMPT": "d72586990e9b8cce115cc9092da7ac9bccc47ad990b9d7fe81250dcb45671276",
|
||||
"EXTRACT_PROMPT_NO_INTENTION": "26a2c2efcd32551a424d21d1c0ff6677951f97edeac68db7eec4bb714cc30ea6",
|
||||
"NO_INTENTION_SECTION_PATTERN": "b45f2d33cd009d7b7f25085dcf384ac7a4da2e8fde42f823c9eec9f661cd7d02",
|
||||
"NO_INTENTION_MEMORY_SECTION": "702b1fc9811acaf502a1c99184e1c406470187b351b2643395776c6bce3c9f25",
|
||||
"NO_INTENTION_REPLACEMENTS": "63b0f145520cbc1480ae4f9f4c5d1692e6c3e56a662cb6c47e468e21bf09ea40",
|
||||
"INTENTION_DISABLED_NOTE": "3979b55177eda6748239a37b8eb522fb1051ea240836118f4a5d972121839af2",
|
||||
"USER_NAME_LINE": "5bc710c0a52712aa8ace10555de186a87f7051787ed61971c461acd81e6cb7e8",
|
||||
"SEARCH_QUERY_PROMPT": "90617660bb4c20460e295802dc2ff1ab7447d51e3e6ef5ac77b9d10594696de5",
|
||||
"RECONCILE_PROMPT": "bb3dcc2cc65a315d4ce52195a681efbd32527a81d085c61f0fb052799d1a3a85",
|
||||
"RECONCILE_STRUCTURE_METADATA_PROMPT": "9fe65cb30b08a9de47e5db5ba512bace739b5c85a36395e61b2cebb02d3d7f5b",
|
||||
"RECONCILE_FEW_SHOT": "0fbe1b979b86e4ca133d15b24607284a19ab4523010e770c51a5d865b9cc8528",
|
||||
"CODING_EXTRACT_PROMPT": "53a056cfb1deabd613a23ea26058e821d52c4f863f89b706547a974aeca008ac",
|
||||
"CODING_EXTRACT_PROMPT_CODING": "3e987f1b6cfb7a17cfec11f4918729fc3fe118268471ca46d57e84fd3de9e6b4",
|
||||
"CODING_CONVERSATION_BLOCK": "c0d2bb215e8a02f0590f586d81daa7955d39a94bed9e37657d2b922d81b29e7d",
|
||||
"CODING_EMPTY_BLOCK": "552e423045fcbd949252397b4bee754f0b8f99b1bbbe41edc9b25dbfc81b146a",
|
||||
"CODING_RECONCILE_PROMPT": "00bca0e7f44f421470363c3a6d35a3da8df1993c685f514a8d5ae93023427c75",
|
||||
"CODING_FACT_RECONCILE_PROMPT": "1db547157bce2910030772cbf2286b77ddefa4936ff3e4ed05ff2088019110be",
|
||||
"CODING_CHAIN_SUMMARY_PROMPT": "dac479310ce51dbea8f0c25f00de6060d65a524af1f3a1c686a5df19c29724df",
|
||||
"EPISODE_SUMMARY_PROMPT": "d93e2d2fcf9f424c3de524ac82d36a41ad5c3bf4c702a0e487085e856f65ef5e",
|
||||
"TOPIC_SUMMARY_PROMPT": "a5bb0a74491f382139a52fee46a055c22efa694ab03fc148cb894d08961a1d2d",
|
||||
"SYSTEM2_AGENT_PROMPT": "c2254e94d0bc239ccda9cbd79b98508128417bb224d27792f2ea89b837c5e05e",
|
||||
"SYSTEM2_OUTPUT_INSTRUCTIONS": "cbbd0786c4f3ff8b69f69a46a7476a6037ec722801718579392f2e6e26c8c2e7",
|
||||
"SYSTEM2_SCHEMA_EVIDENCE_GATE": "6e858894061a9f2107a21f8b6cd547d42deaac69a40d59e11d338cbf2de892e4",
|
||||
"SYSTEM2_SINGLE_CALL_REPLACEMENTS": "7879edaf5a3681b0ddfaeb65cf76fed305ab88914c05fb0fffe5d5dfd51cf21d",
|
||||
"SYSTEM2_MATERIALS": "3ff1546a7a3ca150f1bae324d94939ce864de229b29e0245692621084b04e52a",
|
||||
},
|
||||
"zh-Hans": {
|
||||
"EXTRACT_SYSTEM_PROMPT": "fc8de8505c744091fcdc24a2cfd7a4e6f62a1332c18fa39d191a7f39aa0a7f4a",
|
||||
"STRUCTURE_METADATA_PROMPT": "dac3aca6671dc5ca0766425df1463c01a516ed515c7b38a7925c1b53ffb115a6",
|
||||
"EXTRACT_FEW_SHOT": "6e2ad8bb1f054cc32f3501e42cdc865b246934ecb660d9572e342be8d900f244",
|
||||
"EXTRACT_PROMPT": "2e065893433ba2af68b55d41c3dc7e056f630dd4b41ed431e557751101e51be5",
|
||||
"EXTRACT_PROMPT_NO_INTENTION": "66adb01ad790a46ad0c13ff2b30aa79c0e29bcbb2e191bc3cf3e544fe6e4956b",
|
||||
"NO_INTENTION_SECTION_PATTERN": "27110fb7913b57e205372ede892a1656682a2635e77e119952a68a864cfdf646",
|
||||
"NO_INTENTION_MEMORY_SECTION": "1d698eb697aa0e079d33bcc450ec0036dbafc600b1ec30ca80a6a18def6894f6",
|
||||
"NO_INTENTION_REPLACEMENTS": "211f2bacfbc1852fc3f11dfb67acdd44c534f703b1612dfc59d1c569ff3a39b2",
|
||||
"INTENTION_DISABLED_NOTE": "50fe78d881568a3a17a0cd3c993836eceafd719f762cfb37c7884b448b74b174",
|
||||
"USER_NAME_LINE": "9f702915ddcd42d5c16ed7a0da3256e69d33991f4b88b14c2d3c04cc22c95150",
|
||||
"SEARCH_QUERY_PROMPT": "91b760eec1d2ed2bc02f1688acb911b0786cadd06eea88645722db9691baf25c",
|
||||
"RECONCILE_PROMPT": "32f5162b496c07fec643a96158eae87aa12c1dfae0ffd2084f5aa71b71377869",
|
||||
"RECONCILE_STRUCTURE_METADATA_PROMPT": "720640c185bd2c4e1b0c1694e1236b23e1923b7a02d948282a049de7dd4dfb8a",
|
||||
"RECONCILE_FEW_SHOT": "c96ffff49ac71bf52ccef0d1173ea8dfba31c1542f3cfeab939b4a906e037e58",
|
||||
"CODING_EXTRACT_PROMPT": "b87d5df66ef153baaf38f32a59ec0641e6f3bac4f0014056b37f61278d176c59",
|
||||
"CODING_EXTRACT_PROMPT_CODING": "1d6bb3c538dd31f310b3d123617bf73ee5a1c4474f3176f042d9f2af62c3d997",
|
||||
"CODING_CONVERSATION_BLOCK": "3253e7c9d81136475b435d07f47f7cf5fe315d1c9d8b8b31ed15da8db7829d72",
|
||||
"CODING_EMPTY_BLOCK": "c7bcc6d27f3abaaa5410e40c17f0c5ca79781a74e176695193c22aa4a8064d62",
|
||||
"CODING_RECONCILE_PROMPT": "4b62e6e214b70403e6e041e0d00cd7d5545100759beda9c2b6d5a5a6235452dc",
|
||||
"CODING_FACT_RECONCILE_PROMPT": "74ed3b045f11a4aeb29fcaf43407e3f814c4e25f0469d7f3c3b4f57e5973431c",
|
||||
"CODING_CHAIN_SUMMARY_PROMPT": "2d1445b2ade72102750920644ff2e086416dd58152419fd5d43114e75a35d270",
|
||||
"EPISODE_SUMMARY_PROMPT": "999bd17d779380248681e6e992fad0daa5e785e7476898a28888f6e51fef42ec",
|
||||
"TOPIC_SUMMARY_PROMPT": "a4b96d5f3e317e0b5c4b047d9fbbe5c3378c55c5814dc5765201ea15d5c7beee",
|
||||
"SYSTEM2_AGENT_PROMPT": "7c681573ca35dca00bd79dd81b56cfb4e820db76c7bedcea620f15394a42cf48",
|
||||
"SYSTEM2_OUTPUT_INSTRUCTIONS": "2d6da105e45e333494122c03c4416a5a9ad90cdce3603ace7251731e1acafcc4",
|
||||
"SYSTEM2_SCHEMA_EVIDENCE_GATE": "cdfefe924a0d504593a04466cc48009c4583d9f6771fe8770a62e56f9ff4be1c",
|
||||
"SYSTEM2_SINGLE_CALL_REPLACEMENTS": "bac38a8ad4a03037d59b33360ad3b7de4729f454c3b5291f20a1d5cb8893043b",
|
||||
"SYSTEM2_MATERIALS": "0780c720db4f30bec96028353056e03b9fb60c8805cca7d74ff127566353ca15",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_NEW_ONLY = {"OUTPUT_LANGUAGE_DIRECTIVE", "LANGUAGE_NAME"}
|
||||
_TRANSLATED = [code for code in SUPPORTED_LANGUAGES if code not in ("en", "zh-Hans")]
|
||||
|
||||
_JSON_KEY_RE = re.compile(r'"([A-Za-z_][A-Za-z0-9_]*)"\s*:')
|
||||
_ENUM_RE = re.compile(r'"([A-Z][A-Z0-9_]+)"')
|
||||
|
||||
|
||||
def _sha(value) -> str:
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _fields(template: str) -> set:
|
||||
return {name for _, name, _, _ in Formatter().parse(template) if name is not None}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", ["en", "zh-Hans"])
|
||||
def test_en_and_zh_hans_are_byte_identical_to_previous_release(language):
|
||||
pins = _PINNED_SHA256[language]
|
||||
assert set(pins) == set(PROMPT_NAMES) - _NEW_ONLY
|
||||
changed = [name for name, digest in pins.items()
|
||||
if _sha(get_prompt(name, language)) != digest]
|
||||
assert changed == []
|
||||
|
||||
|
||||
def test_legacy_names_still_point_at_registry_strings():
|
||||
from hy_memory.agent import extractor, prompts_zh, reconciler
|
||||
from hy_memory.coding import extractor as coding_extractor
|
||||
from hy_memory.coding import prompts_zh as coding_zh
|
||||
|
||||
assert extractor.EXTRACT_SYSTEM_PROMPT is get_prompt("EXTRACT_SYSTEM_PROMPT", "en")
|
||||
assert extractor.EXTRACT_FEW_SHOT_EN is get_prompt("EXTRACT_FEW_SHOT", "en")
|
||||
assert reconciler.RECONCILE_PROMPT is get_prompt("RECONCILE_PROMPT", "en")
|
||||
assert prompts_zh.EXTRACT_SYSTEM_PROMPT_ZH is get_prompt("EXTRACT_SYSTEM_PROMPT", "zh-Hans")
|
||||
assert prompts_zh.RECONCILE_FEW_SHOT_ZH is get_prompt("RECONCILE_FEW_SHOT", "zh-Hans")
|
||||
assert coding_extractor.EXTRACT_PROMPT_CODING is get_prompt("CODING_EXTRACT_PROMPT_CODING", "en")
|
||||
assert coding_zh.CHAIN_SUMMARY_PROMPT_ZH is get_prompt("CODING_CHAIN_SUMMARY_PROMPT", "zh-Hans")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", SUPPORTED_LANGUAGES)
|
||||
def test_language_defines_every_prompt(language):
|
||||
for name in PROMPT_NAMES:
|
||||
value = get_prompt(name, language)
|
||||
en = get_prompt(name, "en")
|
||||
assert type(value) is type(en), name
|
||||
if isinstance(value, str):
|
||||
assert value.strip(), name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", SUPPORTED_LANGUAGES)
|
||||
def test_format_prompts_keep_en_placeholders_and_render(language):
|
||||
for name, expected in FORMAT_FIELDS.items():
|
||||
template = get_prompt(name, language)
|
||||
assert _fields(template) == set(expected), name
|
||||
assert _fields(get_prompt(name, "en")) == set(expected), name
|
||||
rendered = template.format(**{field: f"<{field}>" for field in expected})
|
||||
for field in expected:
|
||||
assert f"<{field}>" in rendered, (name, field)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", SUPPORTED_LANGUAGES)
|
||||
def test_json_keys_and_enum_values_match_en(language):
|
||||
for name in PROMPT_NAMES:
|
||||
value, en = get_prompt(name, language), get_prompt(name, "en")
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
assert set(_JSON_KEY_RE.findall(value)) == set(_JSON_KEY_RE.findall(en)), name
|
||||
assert set(_ENUM_RE.findall(value)) == set(_ENUM_RE.findall(en)), name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", SUPPORTED_LANGUAGES)
|
||||
def test_system2_materials_entries(language):
|
||||
materials = get_prompt("SYSTEM2_MATERIALS", language)
|
||||
assert set(materials) == set(SYSTEM2_MATERIALS_FIELDS)
|
||||
for key, expected in SYSTEM2_MATERIALS_FIELDS.items():
|
||||
template = materials[key]
|
||||
assert _fields(template) == set(expected), key
|
||||
template.format(**{field: 1 for field in expected})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", SUPPORTED_LANGUAGES)
|
||||
def test_no_intention_fragments_match_their_own_prompt(language):
|
||||
system_prompt = get_prompt("EXTRACT_SYSTEM_PROMPT", language)
|
||||
pattern = get_prompt("NO_INTENTION_SECTION_PATTERN", language)
|
||||
assert re.search(pattern, system_prompt, flags=re.S), language
|
||||
for old, new in get_prompt("NO_INTENTION_REPLACEMENTS", language):
|
||||
assert isinstance(old, str) and isinstance(new, str)
|
||||
assert get_prompt("NO_INTENTION_REPLACEMENTS", language)[0][0] in system_prompt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", SUPPORTED_LANGUAGES)
|
||||
def test_system2_single_call_replacements_match_their_own_prompt(language):
|
||||
base = get_prompt("SYSTEM2_AGENT_PROMPT", language)
|
||||
pairs = get_prompt("SYSTEM2_SINGLE_CALL_REPLACEMENTS", language)
|
||||
assert pairs and pairs[0][0] in base
|
||||
if language not in ("en", "zh-Hans"):
|
||||
missing = [old for old, _ in pairs if old not in base]
|
||||
assert missing == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", _TRANSLATED)
|
||||
def test_translated_languages_are_not_copies(language):
|
||||
for name in ("EXTRACT_SYSTEM_PROMPT", "RECONCILE_PROMPT", "CODING_EXTRACT_PROMPT",
|
||||
"SYSTEM2_AGENT_PROMPT", "OUTPUT_LANGUAGE_DIRECTIVE"):
|
||||
value = get_prompt(name, language)
|
||||
assert value != get_prompt(name, "en"), name
|
||||
assert value != get_prompt(name, "zh-Hans"), name
|
||||
|
||||
|
||||
def test_zh_hant_uses_traditional_script():
|
||||
hant = get_prompt("EXTRACT_SYSTEM_PROMPT", "zh-Hant")
|
||||
# 记/记忆/对话 are Simplified-only forms; the Traditional prompt uses 記/對.
|
||||
for simplified in ("记忆", "对话", "用户"):
|
||||
assert simplified not in hant
|
||||
for traditional in ("記憶", "對話"):
|
||||
assert traditional in hant
|
||||
Reference in new issue
Block a user