Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 ef5ff06590 fix(sdk): intention dedup switch also gates write-batch dedup
When intention_dedup_enabled is off, no intention dedup should run, but
the writer's batch dedup still dropped near-duplicate intentions from
one extraction. Gate it on the same switch. Its threshold stays on
MEMORY_DEDUP_THRESHOLD.

Also skip the embedding backfill when dedup is off, since only dedup
uses it, and fall back to the env default on an invalid configured
threshold so a bad value cannot fail the read.

Refs #241

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:33:56 +08:00
alvinfeiandClaude Opus 5.5 2624228f6f feat(sdk): configurable intention dedup (on by default, unchanged behaviour)
Add MemoryConfig.recall.intention_dedup_enabled (default true, env
MEMORY_INTENTION_DEDUP_ENABLED) and recall.intention_dedup_threshold
(env MEMORY_INTENTION_DEDUP_THRESHOLD, falling back to the shared
MEMORY_DEDUP_THRESHOLD, then 0.95). The reader passes both from the
client config to recall_intentions. When off, read-time intention dedup
is skipped entirely: nothing is hidden and nothing is deleted.

Refs #241

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:25:37 +08:00
21 changed files with 524 additions and 165 deletions

No files matched your search

+9
View File
@@ -4,6 +4,15 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- SDK: Intention dedup is now configurable per client
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
`MEMORY_INTENTION_DEDUP_THRESHOLD`. When on, behaviour is unchanged
(duplicates found at read time are still deleted); when off, no intention
dedup runs, at read time or within a write batch, and nothing is hidden,
dropped or deleted. The threshold applies to read-time dedup only. Refs #241.
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
+20
View File
@@ -182,9 +182,29 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
| `MEMORY_INTENTION_DEDUP_ENABLED` | `true` | 是否对 L7 Intention 判重(读侧和写入侧批内);见下文 |
| `MEMORY_INTENTION_DEDUP_THRESHOLD` | `MEMORY_DEDUP_THRESHOLD`,再缺省为 `0.95` | Intention 读侧判重的 cosine 阈值 |
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
Intention 判重:开启时(默认,与以往行为一致),Search 召回的 L7 Intention 中,
同 `user_id` + 同 `agent_id` + `memory_at` 同一天 + `valid_until` 相同、且 cosine ≥ 阈值的
条目视为复本,只返回最早写入的一条,其余在后台从向量库**删除**。判重不区分内容中的项目、
对象等语义差异,内容高度相似但对象不同的意图可能被误删;写入时同一批抽取结果也会做一次
批内判重。关闭后这两处都不判重,不隐藏、不丢弃、也不删除意图。两项对应
`MemoryConfig.recall.intention_dedup_enabled` /
`recall.intention_dedup_threshold`,按 client 生效,环境变量只提供默认值:
```python
client = HyMemoryClient.from_config({
"recall": {"intention_dedup_enabled": False},
})
```
`MEMORY_DEDUP_THRESHOLD` 同时被写入侧批内意图判重和 dedup 内核使用;
`MEMORY_INTENTION_DEDUP_THRESHOLD` 只影响读侧 Intention 判重,写入侧批内判重的阈值
仍取 `MEMORY_DEDUP_THRESHOLD`。
## 日志、Trace 与历史
| 变量 | 默认值 | 说明 |
+19
View File
@@ -432,6 +432,14 @@ class RecallConfig:
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
# ── Intention(L7)判重 ──
# 开启(默认):召回时同 user+agent+同天 memory_at+同 valid_until 且
# cosine≥阈值 的意图视为复本,只返回最早一条,其余异步从 VDB 删除(历史行为)。
# 关闭:读侧与写入侧批内都不判重——不隐藏、不丢弃、不删除任何意图。
intention_dedup_enabled: bool = None # env: MEMORY_INTENTION_DEDUP_ENABLED
# 读侧意图判重 cosine 阈值。env: MEMORY_INTENTION_DEDUP_THRESHOLD,
# 未设置时回退到共享的 MEMORY_DEDUP_THRESHOLD(兼容旧部署),默认 0.95。
intention_dedup_threshold: float = None
# 最小分数阈值
min_score_threshold: float = 0.0
@@ -750,6 +758,15 @@ class RecallConfig:
self.supplemental_propositions_enabled = _get_env_bool(
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
)
if self.intention_dedup_enabled is None:
self.intention_dedup_enabled = _get_env_bool(
"MEMORY_INTENTION_DEDUP_ENABLED", True
)
if self.intention_dedup_threshold is None:
self.intention_dedup_threshold = _get_env_float(
"MEMORY_INTENTION_DEDUP_THRESHOLD",
_get_env_float("MEMORY_DEDUP_THRESHOLD", 0.95),
)
@dataclass
@@ -1384,6 +1401,8 @@ class MemoryConfig:
"supplemental_propositions_enabled": (
self.recall.supplemental_propositions_enabled
),
"intention_dedup_enabled": self.recall.intention_dedup_enabled,
"intention_dedup_threshold": self.recall.intention_dedup_threshold,
},
"pipeline": {
"default_version": self.pipeline.default_version,
+31 -10
View File
@@ -15,6 +15,9 @@ HY Memory - Intention (L7) 召回 + 惰性过期转换 + 复本判定
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
- **开关**:判重受 `MemoryConfig.recall.intention_dedup_enabled`(默认开)与
`recall.intention_dedup_threshold` 控制,由 reader 按 client 配置传入。
关闭时完全跳过判重:不隐藏、不删除任何意图(写入侧批内判重同样跳过)。
Q: 为什么要看 memory_at 和 valid_until?
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
@@ -38,8 +41,14 @@ from ...models.memory import MemoryLayer, MemoryStatus
logger = logging.getLogger(__name__)
def _get_dedup_threshold() -> float:
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
def _get_dedup_threshold(configured: Optional[float] = None) -> float:
"""去重 cosine 阈值:优先用 client 配置;否则(或配置非法)沿用 dedup 内核的
MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
try:
if configured is not None:
return float(configured)
except (TypeError, ValueError):
logger.warning(f"[intention] invalid dedup threshold: {configured!r}")
try:
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
except (TypeError, ValueError):
@@ -244,6 +253,8 @@ async def recall_intentions(
stats_out: Optional[Dict[str, Any]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
dedup_enabled: bool = True,
dedup_threshold: Optional[float] = None,
) -> List[Dict[str, Any]]:
"""
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
@@ -260,6 +271,11 @@ async def recall_intentions(
now: 当前时间(默认 datetime.now(),测试可注入)
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
dedup_enabled: 是否执行读侧判重(及复本异步删库)。False 时跳过判重,
所有未过期意图原样返回,不删除任何节点。默认 True(历史行为)。
dedup_threshold: 判重 cosine 阈值。None 时回退到 env
MEMORY_DEDUP_THRESHOLD(默认 0.95),兼容直接调用本函数的旧代码;
reader 总是传入 `recall.intention_dedup_threshold`。
Returns:
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
@@ -272,7 +288,7 @@ async def recall_intentions(
埋点字段(回填进 stats_out):
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
数量: fetched / expired / survivors / dedup_dropped / returned
配置: fetch_limit / out_limit / dedup_threshold
配置: fetch_limit / out_limit / dedup_enabled / dedup_threshold
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
"""
@@ -358,7 +374,7 @@ async def recall_intentions(
_t = time.perf_counter()
_backfilled = 0
_missing_cnt = 0
if survivors:
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
missing_ids = [
h.get("node_id") for h in survivors
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
@@ -391,18 +407,23 @@ async def recall_intentions(
)
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
_t = time.perf_counter()
threshold = _get_dedup_threshold()
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
threshold = _get_dedup_threshold(dedup_threshold)
if dedup_enabled:
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
kept, delete_ids = survivors, []
else:
kept, delete_ids = survivors, []
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
_stat(
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
dedup_dropped=len(delete_ids),
dedup_enabled=bool(dedup_enabled),
dedup_threshold=threshold,
)
+6
View File
@@ -1856,6 +1856,12 @@ class BaseReadPipeline(ReadPipeline):
stats_out=_intent_stats,
include_sources=request.include_sources,
exclude_sources=request.exclude_sources,
dedup_enabled=bool(getattr(
self.config.recall, "intention_dedup_enabled", True
)),
dedup_threshold=getattr(
self.config.recall, "intention_dedup_threshold", None
),
)
await trace_log.log_intention(
stats=_intent_stats,
+6 -1
View File
@@ -635,6 +635,7 @@ class MemoryWriter(WritePipeline):
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
回退到"不判重、全写入",绝不影响 upsert 主流程。
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
"""
if not intentions:
return []
@@ -660,7 +661,11 @@ class MemoryWriter(WritePipeline):
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
keep_mask: List[bool] = [True] * len(intentions)
try:
dropped = self._intention_batch_dedup(intentions, embeddings)
dropped = (
self._intention_batch_dedup(intentions, embeddings)
if getattr(self.config.recall, "intention_dedup_enabled", True)
else []
)
for i in dropped:
if 0 <= i < len(keep_mask):
keep_mask[i] = False
@@ -30,7 +30,8 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
}));
function languageState(uiLanguage: string | null, extra = {}) {
const sdk = uiLanguage === null ? "auto" : uiLanguage;
const sdk =
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
return {
ui_language: uiLanguage,
sdk_language: sdk,
@@ -94,7 +95,7 @@ describe("memhub settings language", () => {
});
expect(transport.put).not.toHaveBeenCalled();
const text = (await capturedRun(["settings", "language"])).stdout;
expect(text).toContain("界面语言:未设置(SDK 自动识别)");
expect(text).toContain("界面语言:跟随系统(未选择)");
expect(text).toContain("SDK 提示语言:auto");
});
+6 -7
View File
@@ -2,11 +2,10 @@
* `memhub settings language [<code>|system]`.
*
* The display language the App reports to the Runtime (`ui_language`), and
* the SDK prompt language it implies. Every code, zh-Hans and en included,
* makes extraction and the other SDK prompts use that language; "system"
* (no language set) passes none, so the SDK keeps detecting Chinese or
* English as before. The App sends the language it shows, so it is only
* unset on an install without the App. A change applies at once: a write in flight
* the SDK prompt language it implies. zh-Hans and "system" (no explicit
* choice) pass no language, so the SDK keeps detecting Chinese or English as
* before; every other code, en included, makes extraction and the other SDK
* prompts use that language. A change applies at once: a write in flight
* finishes in the old language, the next ones use the new one. The App sends
* its own choice again whenever it changes there.
*/
@@ -163,14 +162,14 @@ export function renderLanguageText(document: LanguageDocument): string {
const lines = [
`界面语言:${
state.ui_language === null
? "未设置(SDK 自动识别)"
? "跟随系统(未选择)"
: `${state.ui_language}(${NAMES[state.ui_language]})`
}`,
`SDK 提示语言:${sdkText(state.sdk_language)}`,
];
if (document.state === "ok") {
lines.push(
`可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,SDK 自动识别中文或英文。`,
`可选:${UI_LANGUAGES.join("、")}、system。简体中文和 system 不指定语言,其余语言(包括 en)会让 SDK 使用该语言。`,
);
}
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
+6 -6
View File
@@ -766,7 +766,7 @@ class PipelineStatus(BaseModel):
#: default; empty while every feature is at its default.
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
#: The display language the App last reported (`ui_language`); null =
#: none set (the App sends the language it shows, even following the system).
#: never chosen or "follow the system".
ui_language: UiLanguage | None = None
#: The SDK prompt language: `auto` (the SDK detects zh/en) or a code.
sdk_language: str = SDK_LANGUAGE_AUTO
@@ -777,9 +777,9 @@ class UiLanguageState(BaseModel):
model_config = ConfigDict(extra="forbid")
#: What the App reported; null = none set (CLI/API-only, or `system`).
#: What the App reported; null = never chosen or "follow the system".
ui_language: UiLanguage | None
#: What SDK clients are built with: `auto` (no language set: the SDK
#: What SDK clients are built with: `auto` (zh-Hans or no choice: the SDK
#: detects zh/en, as before) or the code passed as `MemoryConfig.language`.
sdk_language: str
languages: tuple[UiLanguage, ...] = UI_LANGUAGES
@@ -4616,9 +4616,9 @@ def create_app(
async def put_ui_language(request: UiLanguageUpdate) -> UiLanguageChange:
"""Record the App's display language; one global setting, not per user.
Every code (zh-Hans and en included) is passed as
`MemoryConfig.language`; only null passes no language, so the SDK
keeps detecting zh/en as before. A change of that SDK language rebuilds the
zh-Hans and null pass no language to the SDK, which keeps detecting
zh/en as before; every other code (en included) is passed as
`MemoryConfig.language`. A change of that SDK language rebuilds the
SDK clients like a feature change: a write in flight finishes in the
old language. Nothing stored is migrated and no collection is renamed.
"""
@@ -367,8 +367,8 @@ def language_kwargs(language: str | None) -> LanguageKwargs:
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
None (no language set) sets nothing: the SDK detects zh/en as it always
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
None (zh-Hans, or no choice) sets nothing: the SDK detects zh/en as it
always did. Like a feature, the language never touches `vector_store`, so
the collection identity never depends on it.
"""
if language is not None:
@@ -74,9 +74,9 @@ class ConfigDocument(BaseModel):
#: SDK retrieval and extraction features a person set; an unset one keeps
#: the SDK default. See `one_memory.memory_features`.
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
#: The display language the App shows (its choice, or the system language
#: it follows); None = none set. See `one_memory.ui_language` for what it
#: does to the SDK.
#: The display language a person chose in the App; None = never chosen or
#: "follow the system". See `one_memory.ui_language` for what it does to
#: the SDK.
ui_language: UiLanguage | None = None
@field_validator("sources")
+12 -16
View File
@@ -3,19 +3,14 @@
The App owns the choice (its language selector) and tells Core whenever it
changes, so Core can build every SDK client with it. It is one global value in
`config.json` (`ui_language`), beside `memory_mode` and `memory_features`;
absent means no language was set (a CLI/API-only install, or
`settings language system`). An App that follows the system sends the
language it resolved and shows, not null.
absent (never chosen, or "follow the system") means no explicit choice.
The owner's rule for the SDK:
- Any supported code, zh-Hans and en included: pass it as
`MemoryConfig.language`, so extraction and the other prompts use that
language. Every display language is passed explicitly (#243).
- No language at all (never set, as on a CLI/API-only install): pass no
language. The SDK keeps detecting Chinese or English from the text, exactly
as before this setting existed. The App does not leave it unset: it sends
the language it shows, including when it follows the system.
- zh-Hans, or no explicit choice: pass no language. The SDK keeps detecting
Chinese or English from the text, exactly as before this setting existed.
- Any other code, including en: pass it as `MemoryConfig.language`, so
extraction and the other prompts use that language.
The language never names a collection; a change rebuilds the SDK handles the
way a mode or feature change does (#218 use count).
@@ -56,6 +51,8 @@ UI_LANGUAGES: tuple[UiLanguage, ...] = (
)
#: Codes earlier App builds stored, and the code each one is now.
LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"}
#: Display languages that leave the SDK's own zh/en detection in charge.
AUTO_DETECTED: frozenset[str] = frozenset({"zh-Hans"})
#: What status shows when no language is passed to the SDK.
SDK_LANGUAGE_AUTO = "auto"
@@ -69,9 +66,8 @@ def normalize_ui_language(value: object) -> UiLanguage | None:
def sdk_language(ui_language: str | None) -> str | None:
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect.
Every supported code is passed as is; only an unset (or unknown) value
leaves the SDK to detect zh/en.
"""
return normalize_ui_language(ui_language)
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect."""
language = normalize_ui_language(ui_language)
if language is None or language in AUTO_DETECTED:
return None
return language
+17 -32
View File
@@ -1,8 +1,8 @@
"""The App's display language, and the SDK prompt language it implies.
The owner's rule (#243): every display language, zh-Hans and en included, is
passed as `MemoryConfig.language` (hy-memory 2.0.11); only an unset language
passes none, so the SDK keeps detecting zh/en exactly as before. A change rebuilds the
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` (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.
"""
@@ -28,8 +28,8 @@ from one_memory.secrets import InMemorySecretStore
from one_memory.ui_language import UI_LANGUAGES, normalize_ui_language, sdk_language
TOKEN = "ui-language-test"
#: Every code is passed to the SDK, zh-Hans included.
PASSED = ("en", "zh-Hans", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
#: Every code except zh-Hans is passed to the SDK.
PASSED = ("en", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
@pytest.fixture(autouse=True)
@@ -51,7 +51,7 @@ def _config(tmp_path: Path, language: str | None):
def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert PASSED == UI_LANGUAGES
assert ("en", "zh-Hans", *PASSED[1:]) == UI_LANGUAGES
assert len(UI_LANGUAGES) == 11
# Earlier App builds stored zh-CN; it is zh-Hans now.
assert normalize_ui_language("zh-CN") == "zh-Hans"
@@ -60,20 +60,19 @@ def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert normalize_ui_language(None) is None
def test_only_an_unset_language_leaves_the_sdk_to_detect() -> None:
def test_only_zh_hans_and_no_choice_leave_the_sdk_to_detect() -> None:
assert sdk_language(None) is None
assert sdk_language("zh-Hans") is None
assert sdk_language("zh-CN") is None
assert sdk_language("system") is None
assert sdk_language("xx") is None
# zh-Hans is passed like every other code; the legacy zh-CN maps to it.
assert sdk_language("zh-Hans") == "zh-Hans"
assert sdk_language("zh-CN") == "zh-Hans"
for code in PASSED:
assert sdk_language(code) == code
def test_no_language_builds_the_config_of_before(tmp_path) -> 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 _config(tmp_path, sdk_language("zh-Hans")).language == "zh-Hans"
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before
@pytest.mark.parametrize("code", PASSED)
@@ -283,31 +282,17 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert current["languages"] == list(UI_LANGUAGES)
assert reads.current_language() is None and writes.sdk_language() is None
# zh-Hans is passed explicitly: Chinese prompts, handles rebuilt (#243).
# zh-Hans: recorded, but the SDK still detects zh/en; nothing rebuilt.
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body == {
**current,
"ui_language": "zh-Hans",
"sdk_language": "zh-Hans",
"rebuilt": True,
}
assert reads.current_language() == "zh-Hans" and writes.sdk_language() == "zh-Hans"
assert retired == ["zh-Hans"]
processing = (await client.get("/api/v1/memory-processing")).json()
assert processing["pipeline"]["sdk_language"] == "zh-Hans"
# The same value again changes nothing.
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body["rebuilt"] is False and retired == ["zh-Hans"]
assert body == {**current, "ui_language": "zh-Hans", "rebuilt": False}
assert retired == [] and reads.current_language() is None
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "ja"})).json()
assert body["sdk_language"] == "ja" and body["rebuilt"] is True
assert reads.current_language() == "ja" and writes.sdk_language() == "ja"
assert retired == ["zh-Hans", "ja"]
assert retired == ["ja"]
# An explicit English choice forces English prompts.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json()
@@ -317,7 +302,7 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert processing["pipeline"]["ui_language"] == "en"
assert processing["pipeline"]["sdk_language"] == "en"
# Unset (CLI `settings language system`): auto again.
# Back to "follow the system": auto again.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": None})).json()
assert body["sdk_language"] == "auto" and body["rebuilt"] is True
assert reads.current_language() is None
@@ -15,13 +15,9 @@ it("bakes fallback release identity when the packaging pipeline omits it", async
expect(config).toMatch(
/__ONE_MEMORY_BUILD_NUMBER__: JSON\.stringify\(\s*String\(releaseBuildNumber\)/,
);
expect(config).toContain(
"const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT)",
expect(config).toMatch(
/process\.env\.ONE_MEMORY_COMMIT\?\.trim\(\) \|\| readSourceCommit\(\)/,
);
expect(config).toMatch(/if \(!trimmed\) return readSourceCommit\(\);/);
// A supplied commit must be the full 40-character form the runtime accepts.
expect(config).toContain("/^[0-9a-f]{40}$/i.test(trimmed)");
expect(config).toContain("return trimmed.toLowerCase();");
expect(config).toMatch(
/execFileSync\("git", \["rev-parse", "HEAD"\],[\s\S]*?encoding: "utf8"/,
);
@@ -1,4 +1,4 @@
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import type { LanguageSnapshot } from "@one-memory/i18n";
import { describe, expect, it, vi } from "vitest";
import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync";
@@ -21,16 +21,11 @@ function source(initial: LanguageSnapshot) {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Core language sync", () => {
it("tells Core the language the GUI shows, system included", () => {
const shown = (
preference: LanguageSnapshot["preference"],
locale: GuiLocale,
) => coreUiLanguage({ preference, locale, revision: 0 });
expect(shown("system", "zh-Hans")).toBe("zh-Hans");
expect(shown("system", "en")).toBe("en");
expect(shown("zh-Hans", "zh-Hans")).toBe("zh-Hans");
expect(shown("en", "en")).toBe("en");
expect(shown("ja", "ja")).toBe("ja");
it("tells Core null for system and the code for an explicit choice", () => {
expect(coreUiLanguage("system")).toBeNull();
expect(coreUiLanguage("zh-Hans")).toBe("zh-Hans");
expect(coreUiLanguage("en")).toBe("en");
expect(coreUiLanguage("ja")).toBe("ja");
});
it("sends the stored choice once at launch, then every change", async () => {
@@ -43,33 +38,29 @@ describe("Core language sync", () => {
await settle();
language.change({ preference: "system", locale: "en", revision: 2 });
await settle();
expect(send.mock.calls).toEqual([["es"], ["ja"], ["en"]]);
expect(send.mock.calls).toEqual([["es"], ["ja"], [null]]);
dispose();
language.change({ preference: "de", locale: "de", revision: 3 });
await settle();
expect(send).toHaveBeenCalledTimes(3);
});
it("an untouched install sends the system language the GUI shows", async () => {
it("an untouched install sends null, which leaves the SDK on auto", async () => {
const language = source({
preference: "system",
locale: "zh-Hans",
locale: "ja",
revision: 0,
});
const send = vi.fn(async () => {});
installCoreLanguageSync({ language, send });
await settle();
expect(send.mock.calls).toEqual([["zh-Hans"]]);
// Choosing the language already shown changes nothing Core sees.
language.change({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
await settle();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls).toEqual([[null]]);
});
it("reports a failed send and retries it with the next change", async () => {
const language = source({ preference: "fr", locale: "fr", revision: 0 });
const send = vi
.fn<(value: string) => Promise<void>>()
.fn<(value: string | null) => Promise<void>>()
.mockRejectedValueOnce(new Error("core_unavailable"))
.mockResolvedValue(undefined);
const reportError = vi.fn();
+16 -18
View File
@@ -1,37 +1,35 @@
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import type { GuiLocale, LanguagePreference } from "@one-memory/i18n";
import type { NativeLanguageSource } from "./native-language";
/**
* What Core is told: the language the GUI shows. "Follow the system" sends
* the language it resolved to, so the memories match the interface a person
* sees; the App never leaves the SDK to detect zh/en (#243).
*/
export function coreUiLanguage(snapshot: LanguageSnapshot): GuiLocale {
return snapshot.locale;
/** What Core is told: a code, or null for "follow the system". */
export function coreUiLanguage(
preference: LanguagePreference,
): GuiLocale | null {
return preference === "system" ? null : preference;
}
/**
* Keep Core's `ui_language` setting on the display language the GUI shows.
* Keep Core's `ui_language` setting on the display language a person chose.
*
* Core builds every SDK client with it and passes it, zh-Hans included, as
* the SDK prompt language. Sent once when installed, so a choice made before
* this existed, or the system language followed at this launch, reaches
* Core; then on every committed change. A value Core already has is not sent
* again. Best effort: a failed send is reported and retried with the next
* change or launch, and never blocks the GUI.
* Core builds every SDK client with it: zh-Hans and "follow the system" pass
* no language (the SDK keeps detecting zh/en), any other choice sets the SDK
* prompt language. Sent once when installed, so a choice made before this
* existed reaches Core, then on every committed change; a value Core already
* has is not sent again. Best effort: a failed send is reported and retried
* with the next change or launch, and never blocks the GUI.
*/
export function installCoreLanguageSync(options: {
language: NativeLanguageSource;
send(uiLanguage: GuiLocale): Promise<void>;
send(uiLanguage: GuiLocale | null): Promise<void>;
reportError?(error: unknown): void;
}): () => void {
let closed = false;
let sent: GuiLocale | undefined;
let sent: GuiLocale | null | undefined;
let queue: Promise<void> = Promise.resolve();
const push = () => {
queue = queue.then(async () => {
if (closed) return;
const value = coreUiLanguage(options.language.read());
const value = coreUiLanguage(options.language.read().preference);
if (value === sent) return;
try {
await options.send(value);
@@ -526,22 +526,7 @@ describe("hourly isolated desktop launch", () => {
expect(resolvePackagedBuildNumber("-1")).toBeUndefined();
expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907);
expect(resolvePackagedBuildNumber("3908", "3907")).toBe(3908);
});
it("resolves only a full 40-character commit and lowercases it", () => {
// Every downstream check (packaged-runtime install, local install, release
// download, release scripts) requires 40 lowercase hex characters.
const embedded = "388fd0e9b3c41a2d5e6f7a8b9c0d1e2f3a4b5c6d";
expect(resolvePackagedCommit(undefined, embedded)).toBe(embedded);
expect(resolvePackagedCommit(embedded.toUpperCase())).toBe(embedded);
expect(
resolvePackagedCommit(` ${embedded.toUpperCase()} `, undefined),
).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9", embedded)).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9")).toBeUndefined();
expect(resolvePackagedCommit("not-a-sha", embedded)).toBe(embedded);
expect(resolvePackagedCommit(`${embedded}0`, embedded)).toBe(embedded);
expect(resolvePackagedCommit("g".repeat(40), embedded)).toBe(embedded);
expect(resolvePackagedCommit(undefined, "unknown")).toBeUndefined();
expect(resolvePackagedCommit(undefined, "388fd0e9")).toBe("388fd0e9");
expect(resolvePackagedCommit("not-a-sha", "388fd0e9")).toBe("388fd0e9");
});
});
+1 -7
View File
@@ -143,12 +143,6 @@ export function resolvePackagedBuildNumber(
return undefined;
}
/**
* The packaged-runtime install, local install, release download and release
* scripts all require a full 40-character lowercase commit. Normalise here so
* that one format crosses this boundary: an uppercase full SHA is lowercased,
* and a short or malformed value is skipped so it cannot shadow the next source.
*/
export function resolvePackagedCommit(
environmentValue: unknown,
embeddedValue?: unknown,
@@ -156,7 +150,7 @@ export function resolvePackagedCommit(
for (const value of [environmentValue, embeddedValue]) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (/^[0-9a-f]{40}$/i.test(trimmed)) return trimmed.toLowerCase();
if (/^[0-9a-f]{7,40}$/i.test(trimmed)) return trimmed;
}
return undefined;
}
+2 -14
View File
@@ -11,20 +11,8 @@ const { resolveBuildNumber, resolveBuildVersion } =
};
const releaseVersion = resolveBuildVersion(process.env);
const releaseBuildNumber = resolveBuildNumber(process.env);
const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT);
// The runtime accepts only a full 40-character commit, so an explicitly
// supplied malformed value fails the build instead of being baked and skipped.
function resolveReleaseCommit(explicit: string | undefined): string {
const trimmed = explicit?.trim();
if (!trimmed) return readSourceCommit();
if (!/^[0-9a-f]{40}$/i.test(trimmed)) {
throw new Error(
`ONE_MEMORY_COMMIT must be a full 40-character hex commit, got ${JSON.stringify(trimmed)}`,
);
}
return trimmed.toLowerCase();
}
const releaseCommit =
process.env.ONE_MEMORY_COMMIT?.trim() || readSourceCommit();
function readSourceCommit(): string {
try {
+4 -4
View File
@@ -189,14 +189,14 @@ node apps/cli/dist/one-memory.cjs settings features reset
```bash
node apps/cli/dist/one-memory.cjs settings language # 界面语言和 SDK 提示语言
node apps/cli/dist/one-memory.cjs settings language ja # 设为日语,立即生效
node apps/cli/dist/one-memory.cjs settings language system # 清除设置(不指定 SDK 语言)
node apps/cli/dist/one-memory.cjs settings language system # 跟随系统(不指定 SDK 语言)
```
- 可选 11 种:`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、`ko`、`ru`、`id`,以及 `system`;旧代码 `zh-CN` 按 `zh-Hans` 处理。
- App 切换语言时会自动设置;Desktop 每次启动也会同步一次界面实际显示的语言(App 选“跟随系统”时,发送它按系统语言解析出的语言,例如 `zh-Hans`)。全局设置,存于 `config.json` 的 `ui_language`;从未设置时不写入。
- 所有语言(包括 `zh-Hans` 和 `en`)都作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言;只有未设置(`system`,或只用 CLI/API、从未打开 App 的安装)时不向 SDK 传语言,SDK 照旧自动识别中文或英文。
- App 切换语言时会自动设置(Desktop 启动时也会同步一次已保存的选择)。全局设置,存于 `config.json` 的 `ui_language`;从未选择时不写入。
- `zh-Hans` 和 `system` 不向 SDK 传语言,SDK 照旧自动识别中文或英文;其余语言(包括 `en`)作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言。
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码;`zh-Hans` 用户显示 `zh-Hans`)。API:`GET`/`PUT /api/v1/settings/ui-language`。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码)。API:`GET`/`PUT /api/v1/settings/ui-language`。
## 8. 召回(recall)
+346
View File
@@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
"""
Intention(L7)读侧判重开关(issue #241)。
覆盖:
1. 默认开启:行为不变,复本只返回最早一条,其余异步删库;
2. 关闭:不隐藏、不删除,两条意图都返回;
3. 阈值生效:cosine 低于阈值不判重,高于阈值判重;
4. reader 按 client 的 MemoryConfig.recall 读取开关/阈值,而非调用时的 env;
5. MemoryConfig 默认值与 env 回退;
6. 写入侧批内判重同样受开关控制;阈值不影响写入侧;
7. 关闭时不为判重补拉 embedding;配置阈值非法时不影响召回。
"""
from __future__ import annotations
import uuid
from datetime import datetime
import pytest
from hy_memory.config import MemoryConfig
from hy_memory.pipelines.base import ReadRequest
from hy_memory.pipelines._retrieval.intention import recall_intentions
from tests.test_intention_dedup import (
_FakeVectorStore,
_make_intention_node,
_run,
)
_MA = datetime(2025, 8, 6)
_VU = datetime(2025, 8, 31, 23, 59)
_NOW = datetime(2025, 8, 6, 12, 0)
def _unit(vec):
n = sum(x * x for x in vec) ** 0.5 or 1.0
return [x / n for x in vec]
def _seed_project_pair(vs, *, emb_a, emb_b):
"""A / B 两个项目的近似意图:同 user/agent/同天/同截止日。A 更早写入。"""
a = _make_intention_node(
"A 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 1), embedding=emb_a,
)
b = _make_intention_node(
"B 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 2), embedding=emb_b,
)
_run(vs.upsert(a))
_run(vs.upsert(b))
return a, b
# ============================================================================
# recall_intentions 参数
# ============================================================================
def test_default_on_keeps_earliest_and_deletes_duplicate():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_enabled"] is True
assert stats["dedup_dropped"] == 1
def test_off_returns_both_and_deletes_nothing():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_enabled=False,
))
assert {h["node_id"] for h in result} == {a.node_id, b.node_id}
assert vs.delete_calls == []
assert stats["dedup_enabled"] is False
assert stats["dedup_dropped"] == 0
def test_threshold_respected():
# cosine(a, b) = 0.9
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
# 默认 0.95 → 0.9 不算复本
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
# 调低到 0.85 → 判重,删较晚的 B
vs = _FakeVectorStore()
a, b = _seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
stats = {}
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW,
dedup_threshold=0.85, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_threshold"] == 0.85
def test_explicit_threshold_wins_over_env(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.5")
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
def test_off_skips_embedding_backfill():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
_seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
backfill_calls = []
async def _get_embeddings(ids):
backfill_calls.append(ids)
return {}
vs.get_embeddings = _get_embeddings
for n in vs._nodes.values():
n.embedding = None
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, dedup_enabled=False,
))
assert len(result) == 2
assert backfill_calls == []
def test_invalid_threshold_degrades_to_env_default(monkeypatch):
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_threshold="not-a-number",
))
assert [h["node_id"] for h in result] == [a.node_id]
assert stats["dedup_threshold"] == 0.95
# ============================================================================
# 写入侧批内判重
# ============================================================================
class _SameVectorEmbed:
"""所有文本同一向量:cosine=1,开启判重时必判为复本。"""
async def embed_batch(self, texts):
return [_unit([1.0, 0.0]) for _ in texts]
async def embed_queued(self, text):
return _unit([1.0, 0.0])
def _store_batch(config_recall):
from hy_memory.pipelines.base import WriteRequest
from hy_memory.pipelines.writer import MemoryWriter
cfg = MemoryConfig.from_dict({"recall": config_recall})
vs = _FakeVectorStore()
writer = MemoryWriter(cfg, embed_service=_SameVectorEmbed(), vector_store=vs)
req = WriteRequest(
content="", messages=[], user_id="u1", agent_id="a1",
session_id="s1", request_id=uuid.uuid4().hex,
)
intentions = [
{"content": "A 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
{"content": "B 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
]
return _run(writer._store_intentions(intentions, req, vs, req.request_id)), vs
def test_write_batch_dedup_default_on_unchanged():
ids, vs = _store_batch({})
assert len(ids) == 1
assert [n.content for n in vs._nodes.values()] == [
"A 项目每周一触发发布前校验流程",
]
def test_write_batch_dedup_off_stores_both():
ids, vs = _store_batch({"intention_dedup_enabled": False})
assert len(ids) == 2
assert len(vs._nodes) == 2
def test_read_threshold_does_not_change_write_batch_dedup(monkeypatch):
"""intention_dedup_threshold 只管读侧;写入侧仍取 MEMORY_DEDUP_THRESHOLD。"""
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
ids, _ = _store_batch({"intention_dedup_threshold": 1.5})
assert len(ids) == 1
# ============================================================================
# MemoryConfig
# ============================================================================
def test_config_defaults(monkeypatch):
for k in (
"MEMORY_INTENTION_DEDUP_ENABLED",
"MEMORY_INTENTION_DEDUP_THRESHOLD",
"MEMORY_DEDUP_THRESHOLD",
):
monkeypatch.delenv(k, raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is True
assert cfg.recall.intention_dedup_threshold == 0.95
def test_config_env_fallbacks(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "false")
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.9")
monkeypatch.delenv("MEMORY_INTENTION_DEDUP_THRESHOLD", raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is False
# 未设专用阈值 → 回退共享 MEMORY_DEDUP_THRESHOLD(兼容旧部署)
assert cfg.recall.intention_dedup_threshold == 0.9
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_THRESHOLD", "0.8")
assert MemoryConfig().recall.intention_dedup_threshold == 0.8
def test_config_from_dict_overrides_env(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "true")
cfg = MemoryConfig.from_dict({"recall": {
"intention_dedup_enabled": False,
"intention_dedup_threshold": 0.99,
}})
assert cfg.recall.intention_dedup_enabled is False
assert cfg.recall.intention_dedup_threshold == 0.99
assert cfg.to_dict()["recall"]["intention_dedup_enabled"] is False
# ============================================================================
# reader:按 client 配置,而不是调用时的 env
# ============================================================================
def _reader_for(enabled: bool, vs):
from hy_memory.pipelines.reader_hybrid import HybridReadPipeline
cfg = MemoryConfig.from_dict({
"vector_store": {
"provider": "chroma",
"collection_name": f"t_intent_sw_{uuid.uuid4().hex[:8]}",
"embedding_dims": 2,
},
"recall": {"intention_dedup_enabled": enabled},
})
reader = HybridReadPipeline(cfg, vector_store=vs)
reader._vector_store = vs
reader._vector_store_initialized = True
return reader
async def _no_keyword_hits(**kw):
return []
def _read_project_pair(enabled: bool):
"""经 reader 完整读路径召回 A/B 两条近似意图;返回 (返回的意图 id, 删库调用, a, b)。"""
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
# 读路径用真实 now:让两条都未过期且同一天
today = datetime.now()
for n in (a, b):
n.memory_at = today
n.valid_until = None
reader = _reader_for(enabled, vs)
_run(reader.initialize())
resp = _run(reader.read(ReadRequest(
query="发布前校验", query_embedding=emb,
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
ids = {
m["memory_id"] for m in resp.memories
if m.get("layer") == "l7_intention"
}
return ids, list(vs.delete_calls), a, b
@pytest.mark.parametrize("env_value", ["true", "false"])
def test_reader_uses_per_client_config_not_env(monkeypatch, env_value):
"""调用时的 env 与 client 配置无关:两个 client 一开一关,各自生效。"""
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", env_value)
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.0")
ids_on, deleted_on, a, b = _read_project_pair(True)
assert ids_on == {a.node_id}
assert deleted_on == [b.node_id]
ids_off, deleted_off, a, b = _read_project_pair(False)
assert ids_off == {a.node_id, b.node_id}
assert deleted_off == []
def test_reader_passes_client_threshold(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.1")
seen = []
async def _spy(*args, **kwargs):
seen.append(kwargs)
return []
monkeypatch.setattr("hy_memory.pipelines.reader_base.recall_intentions", _spy)
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
reader = _reader_for(True, vs)
reader.config.recall.intention_dedup_threshold = 0.97
_run(reader.initialize())
_run(reader.read(ReadRequest(
query="发布前校验", query_embedding=_unit([1.0, 0.0]),
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
assert seen and seen[0]["dedup_enabled"] is True
assert seen[0]["dedup_threshold"] == 0.97