Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a4fd60f2 | ||
|
|
aae877b880 |
No files matched your search
@@ -4,15 +4,6 @@ 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
|
||||
|
||||
@@ -182,29 +182,9 @@ 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 与历史
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -432,14 +432,6 @@ 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
|
||||
@@ -758,15 +750,6 @@ 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
|
||||
@@ -1401,8 +1384,6 @@ 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,
|
||||
|
||||
@@ -15,9 +15,6 @@ 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 在
|
||||
@@ -41,14 +38,8 @@ from ...models.memory import MemoryLayer, MemoryStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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}")
|
||||
def _get_dedup_threshold() -> float:
|
||||
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
|
||||
try:
|
||||
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
|
||||
except (TypeError, ValueError):
|
||||
@@ -253,8 +244,6 @@ 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,判重并异步删复本。
|
||||
@@ -271,11 +260,6 @@ 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"}, ...]
|
||||
@@ -288,7 +272,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_enabled / dedup_threshold
|
||||
配置: fetch_limit / out_limit / dedup_threshold
|
||||
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
|
||||
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
|
||||
"""
|
||||
@@ -374,7 +358,7 @@ async def recall_intentions(
|
||||
_t = time.perf_counter()
|
||||
_backfilled = 0
|
||||
_missing_cnt = 0
|
||||
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
|
||||
if survivors:
|
||||
missing_ids = [
|
||||
h.get("node_id") for h in survivors
|
||||
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
|
||||
@@ -407,23 +391,18 @@ async def recall_intentions(
|
||||
)
|
||||
|
||||
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
|
||||
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
|
||||
_t = time.perf_counter()
|
||||
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:
|
||||
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}")
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1856,12 +1856,6 @@ 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,
|
||||
|
||||
@@ -635,7 +635,6 @@ class MemoryWriter(WritePipeline):
|
||||
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
|
||||
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
|
||||
回退到"不判重、全写入",绝不影响 upsert 主流程。
|
||||
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
|
||||
"""
|
||||
if not intentions:
|
||||
return []
|
||||
@@ -661,11 +660,7 @@ class MemoryWriter(WritePipeline):
|
||||
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
|
||||
keep_mask: List[bool] = [True] * len(intentions)
|
||||
try:
|
||||
dropped = (
|
||||
self._intention_batch_dedup(intentions, embeddings)
|
||||
if getattr(self.config.recall, "intention_dedup_enabled", True)
|
||||
else []
|
||||
)
|
||||
dropped = self._intention_batch_dedup(intentions, embeddings)
|
||||
for i in dropped:
|
||||
if 0 <= i < len(keep_mask):
|
||||
keep_mask[i] = False
|
||||
|
||||
@@ -30,8 +30,7 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
function languageState(uiLanguage: string | null, extra = {}) {
|
||||
const sdk =
|
||||
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
|
||||
const sdk = uiLanguage === null ? "auto" : uiLanguage;
|
||||
return {
|
||||
ui_language: uiLanguage,
|
||||
sdk_language: sdk,
|
||||
@@ -95,7 +94,7 @@ describe("memhub settings language", () => {
|
||||
});
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
const text = (await capturedRun(["settings", "language"])).stdout;
|
||||
expect(text).toContain("界面语言:跟随系统(未选择)");
|
||||
expect(text).toContain("界面语言:未设置(SDK 自动识别)");
|
||||
expect(text).toContain("SDK 提示语言:auto");
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* `memhub settings language [<code>|system]`.
|
||||
*
|
||||
* The display language the App reports to the Runtime (`ui_language`), and
|
||||
* 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
|
||||
* 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
|
||||
* finishes in the old language, the next ones use the new one. The App sends
|
||||
* its own choice again whenever it changes there.
|
||||
*/
|
||||
@@ -162,14 +163,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。简体中文和 system 不指定语言,其余语言(包括 en)会让 SDK 使用该语言。`,
|
||||
`可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,SDK 自动识别中文或英文。`,
|
||||
);
|
||||
}
|
||||
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
|
||||
|
||||
@@ -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 =
|
||||
#: never chosen or "follow the system".
|
||||
#: none set (the App sends the language it shows, even following 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 = never chosen or "follow the system".
|
||||
#: What the App reported; null = none set (CLI/API-only, or `system`).
|
||||
ui_language: UiLanguage | None
|
||||
#: What SDK clients are built with: `auto` (zh-Hans or no choice: the SDK
|
||||
#: What SDK clients are built with: `auto` (no language set: 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.
|
||||
|
||||
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
|
||||
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
|
||||
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 (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
|
||||
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
|
||||
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 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.
|
||||
#: 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.
|
||||
ui_language: UiLanguage | None = None
|
||||
|
||||
@field_validator("sources")
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
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 (never chosen, or "follow the system") means no explicit choice.
|
||||
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.
|
||||
|
||||
The owner's rule for the SDK:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
The language never names a collection; a change rebuilds the SDK handles the
|
||||
way a mode or feature change does (#218 use count).
|
||||
@@ -51,8 +56,6 @@ 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"
|
||||
|
||||
@@ -66,8 +69,9 @@ 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."""
|
||||
language = normalize_ui_language(ui_language)
|
||||
if language is None or language in AUTO_DETECTED:
|
||||
return None
|
||||
return language
|
||||
"""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)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""The App's display language, and the SDK prompt language it implies.
|
||||
|
||||
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
|
||||
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
|
||||
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 except zh-Hans is passed to the SDK.
|
||||
PASSED = ("en", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
|
||||
#: Every code is passed to the SDK, zh-Hans included.
|
||||
PASSED = ("en", "zh-Hans", "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 ("en", "zh-Hans", *PASSED[1:]) == UI_LANGUAGES
|
||||
assert PASSED == 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,19 +60,20 @@ def test_eleven_codes_and_the_legacy_chinese_code() -> None:
|
||||
assert normalize_ui_language(None) is None
|
||||
|
||||
|
||||
def test_only_zh_hans_and_no_choice_leave_the_sdk_to_detect() -> None:
|
||||
def test_only_an_unset_language_leaves_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_zh_hans_and_no_choice_build_the_config_of_before(tmp_path) -> None:
|
||||
before = repr(dataclasses.asdict(_config(tmp_path, None)))
|
||||
def test_no_language_builds_the_config_of_before(tmp_path) -> None:
|
||||
assert _config(tmp_path, None).language is None
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before
|
||||
assert _config(tmp_path, sdk_language("zh-Hans")).language == "zh-Hans"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", PASSED)
|
||||
@@ -282,17 +283,31 @@ 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: recorded, but the SDK still detects zh/en; nothing rebuilt.
|
||||
# zh-Hans is passed explicitly: Chinese prompts, handles rebuilt (#243).
|
||||
body = (
|
||||
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
|
||||
).json()
|
||||
assert body == {**current, "ui_language": "zh-Hans", "rebuilt": False}
|
||||
assert retired == [] and reads.current_language() is None
|
||||
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"]
|
||||
|
||||
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 == ["ja"]
|
||||
assert retired == ["zh-Hans", "ja"]
|
||||
|
||||
# An explicit English choice forces English prompts.
|
||||
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json()
|
||||
@@ -302,7 +317,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"
|
||||
|
||||
# Back to "follow the system": auto again.
|
||||
# Unset (CLI `settings language 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,9 +15,13 @@ 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).toMatch(
|
||||
/process\.env\.ONE_MEMORY_COMMIT\?\.trim\(\) \|\| readSourceCommit\(\)/,
|
||||
expect(config).toContain(
|
||||
"const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT)",
|
||||
);
|
||||
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 { LanguageSnapshot } from "@one-memory/i18n";
|
||||
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync";
|
||||
|
||||
@@ -21,11 +21,16 @@ function source(initial: LanguageSnapshot) {
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("Core language sync", () => {
|
||||
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("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("sends the stored choice once at launch, then every change", async () => {
|
||||
@@ -38,29 +43,33 @@ describe("Core language sync", () => {
|
||||
await settle();
|
||||
language.change({ preference: "system", locale: "en", revision: 2 });
|
||||
await settle();
|
||||
expect(send.mock.calls).toEqual([["es"], ["ja"], [null]]);
|
||||
expect(send.mock.calls).toEqual([["es"], ["ja"], ["en"]]);
|
||||
dispose();
|
||||
language.change({ preference: "de", locale: "de", revision: 3 });
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("an untouched install sends null, which leaves the SDK on auto", async () => {
|
||||
it("an untouched install sends the system language the GUI shows", async () => {
|
||||
const language = source({
|
||||
preference: "system",
|
||||
locale: "ja",
|
||||
locale: "zh-Hans",
|
||||
revision: 0,
|
||||
});
|
||||
const send = vi.fn(async () => {});
|
||||
installCoreLanguageSync({ language, send });
|
||||
await settle();
|
||||
expect(send.mock.calls).toEqual([[null]]);
|
||||
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);
|
||||
});
|
||||
|
||||
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 | null) => Promise<void>>()
|
||||
.fn<(value: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("core_unavailable"))
|
||||
.mockResolvedValue(undefined);
|
||||
const reportError = vi.fn();
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import type { GuiLocale, LanguagePreference } from "@one-memory/i18n";
|
||||
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
|
||||
import type { NativeLanguageSource } from "./native-language";
|
||||
|
||||
/** What Core is told: a code, or null for "follow the system". */
|
||||
export function coreUiLanguage(
|
||||
preference: LanguagePreference,
|
||||
): GuiLocale | null {
|
||||
return preference === "system" ? null : preference;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Core's `ui_language` setting on the display language a person chose.
|
||||
* Keep Core's `ui_language` setting on the display language the GUI shows.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function installCoreLanguageSync(options: {
|
||||
language: NativeLanguageSource;
|
||||
send(uiLanguage: GuiLocale | null): Promise<void>;
|
||||
send(uiLanguage: GuiLocale): Promise<void>;
|
||||
reportError?(error: unknown): void;
|
||||
}): () => void {
|
||||
let closed = false;
|
||||
let sent: GuiLocale | null | undefined;
|
||||
let sent: GuiLocale | undefined;
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
const push = () => {
|
||||
queue = queue.then(async () => {
|
||||
if (closed) return;
|
||||
const value = coreUiLanguage(options.language.read().preference);
|
||||
const value = coreUiLanguage(options.language.read());
|
||||
if (value === sent) return;
|
||||
try {
|
||||
await options.send(value);
|
||||
|
||||
@@ -526,7 +526,22 @@ describe("hourly isolated desktop launch", () => {
|
||||
expect(resolvePackagedBuildNumber("-1")).toBeUndefined();
|
||||
expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907);
|
||||
expect(resolvePackagedBuildNumber("3908", "3907")).toBe(3908);
|
||||
expect(resolvePackagedCommit(undefined, "388fd0e9")).toBe("388fd0e9");
|
||||
expect(resolvePackagedCommit("not-a-sha", "388fd0e9")).toBe("388fd0e9");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,12 @@ 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,
|
||||
@@ -150,7 +156,7 @@ export function resolvePackagedCommit(
|
||||
for (const value of [environmentValue, embeddedValue]) {
|
||||
if (typeof value !== "string") continue;
|
||||
const trimmed = value.trim();
|
||||
if (/^[0-9a-f]{7,40}$/i.test(trimmed)) return trimmed;
|
||||
if (/^[0-9a-f]{40}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,20 @@ const { resolveBuildNumber, resolveBuildVersion } =
|
||||
};
|
||||
const releaseVersion = resolveBuildVersion(process.env);
|
||||
const releaseBuildNumber = resolveBuildNumber(process.env);
|
||||
const releaseCommit =
|
||||
process.env.ONE_MEMORY_COMMIT?.trim() || readSourceCommit();
|
||||
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();
|
||||
}
|
||||
|
||||
function readSourceCommit(): string {
|
||||
try {
|
||||
|
||||
@@ -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 启动时也会同步一次已保存的选择)。全局设置,存于 `config.json` 的 `ui_language`;从未选择时不写入。
|
||||
- `zh-Hans` 和 `system` 不向 SDK 传语言,SDK 照旧自动识别中文或英文;其余语言(包括 `en`)作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言。
|
||||
- App 切换语言时会自动设置;Desktop 每次启动也会同步一次界面实际显示的语言(App 选“跟随系统”时,发送它按系统语言解析出的语言,例如 `zh-Hans`)。全局设置,存于 `config.json` 的 `ui_language`;从未设置时不写入。
|
||||
- 所有语言(包括 `zh-Hans` 和 `en`)都作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言;只有未设置(`system`,或只用 CLI/API、从未打开 App 的安装)时不向 SDK 传语言,SDK 照旧自动识别中文或英文。
|
||||
- 修改会像功能开关一样重建 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`。
|
||||
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码;`zh-Hans` 用户显示 `zh-Hans`)。API:`GET`/`PUT /api/v1/settings/ui-language`。
|
||||
|
||||
## 8. 召回(recall)
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
# -*- 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
|
||||
Reference in new issue
Block a user