From b318e557b01617bd267fb6b541456579117d8be7 Mon Sep 17 00:00:00 2001 From: P0luz Date: Tue, 21 Apr 2026 19:05:08 +0800 Subject: [PATCH] fix: complete B-03/B-08/B-09 and add OMBRE_*_MODEL env vars - decay_engine: keep activation_count as float (B-03); refresh local meta after auto_resolve so resolved_factor applies in the same cycle (B-08) - server.hold(): user-supplied valence/arousal now takes priority over analyze() output (B-09) - utils.load_config: support OMBRE_DEHYDRATION_MODEL (with OMBRE_MODEL alias) and OMBRE_EMBEDDING_MODEL - ENV_VARS.md: document new model env vars - tests/conftest.py: align fixture with spec-correct weights (time_proximity=1.5, content_weight=1.0) and feel subdir layout --- ENV_VARS.md | 3 + decay_engine.py | 3 +- server.py | 17 ++++-- tests/conftest.py | 137 ++++++++++++++++++++++++++++++++++++++++++++-- utils.py | 10 ++++ 5 files changed, 158 insertions(+), 12 deletions(-) diff --git a/ENV_VARS.md b/ENV_VARS.md index 88472c9..5245458 100644 --- a/ENV_VARS.md +++ b/ENV_VARS.md @@ -9,6 +9,9 @@ | `OMBRE_HOOK_URL` | 否 | — | Breath/Dream Webhook 回调地址,留空则不推送 | | `OMBRE_HOOK_SKIP` | 否 | `false` | 设为 `true` 跳过 Webhook 推送 | | `OMBRE_DASHBOARD_PASSWORD` | 否 | — | 预设 Dashboard 访问密码;设置后覆盖文件存储的密码,首次访问不弹设置向导 | +| `OMBRE_DEHYDRATION_MODEL` | 否 | `deepseek-chat` | 脱水/打标/合并/拆分用的 LLM 模型名(覆盖 `dehydration.model`) | +| `OMBRE_MODEL` | 否 | — | `OMBRE_DEHYDRATION_MODEL` 的别名(前者优先) | +| `OMBRE_EMBEDDING_MODEL` | 否 | `gemini-embedding-001` | 向量嵌入模型名(覆盖 `embedding.model`) | ## 说明 diff --git a/decay_engine.py b/decay_engine.py index 2213deb..afc9531 100644 --- a/decay_engine.py +++ b/decay_engine.py @@ -112,7 +112,7 @@ class DecayEngine: return 50.0 importance = max(1, min(10, int(metadata.get("importance", 5)))) - activation_count = max(1, int(metadata.get("activation_count", 1))) + activation_count = max(1.0, float(metadata.get("activation_count", 1))) # --- Days since last activation --- last_active_str = metadata.get("last_active", metadata.get("created", "")) @@ -215,6 +215,7 @@ class DecayEngine: if imp <= 4 and days_since > 30: try: await self.bucket_mgr.update(bucket["id"], resolved=True) + meta["resolved"] = True # refresh local meta so resolved_factor applies this cycle auto_resolved += 1 logger.info( f"Auto-resolved / 自动结案: " diff --git a/server.py b/server.py index baedcd0..723a9d4 100644 --- a/server.py +++ b/server.py @@ -790,11 +790,16 @@ async def hold( } domain = analysis["domain"] - valence = analysis["valence"] - arousal = analysis["arousal"] + auto_valence = analysis["valence"] + auto_arousal = analysis["arousal"] auto_tags = analysis["tags"] suggested_name = analysis.get("suggested_name", "") + # --- User-supplied valence/arousal takes priority over analyze() result --- + # --- 用户显式传入的 valence/arousal 优先,analyze() 结果作为 fallback --- + final_valence = valence if 0 <= valence <= 1 else auto_valence + final_arousal = arousal if 0 <= arousal <= 1 else auto_arousal + all_tags = list(dict.fromkeys(auto_tags + extra_tags)) # --- Pinned buckets bypass merge and are created directly in permanent dir --- @@ -805,8 +810,8 @@ async def hold( tags=all_tags, importance=10, domain=domain, - valence=valence, - arousal=arousal, + valence=final_valence, + arousal=final_arousal, name=suggested_name or None, bucket_type="permanent", pinned=True, @@ -823,8 +828,8 @@ async def hold( tags=all_tags, importance=importance, domain=domain, - valence=valence, - arousal=arousal, + valence=final_valence, + arousal=final_arousal, name=suggested_name, ) diff --git a/tests/conftest.py b/tests/conftest.py index 3b2d157..6057121 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ import pytest import asyncio from datetime import datetime, timedelta from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch # Ensure project root importable sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -21,23 +22,28 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @pytest.fixture def test_config(tmp_path): - """Minimal config pointing to a temp directory.""" + """ + Minimal config pointing to a temp directory. + Uses spec-correct scoring weights (after B-05, B-06, B-07 fixes). + """ buckets_dir = str(tmp_path / "buckets") os.makedirs(os.path.join(buckets_dir, "permanent"), exist_ok=True) os.makedirs(os.path.join(buckets_dir, "dynamic"), exist_ok=True) os.makedirs(os.path.join(buckets_dir, "archive"), exist_ok=True) - os.makedirs(os.path.join(buckets_dir, "dynamic", "feel"), exist_ok=True) + os.makedirs(os.path.join(buckets_dir, "feel"), exist_ok=True) return { "buckets_dir": buckets_dir, + "merge_threshold": 75, "matching": {"fuzzy_threshold": 50, "max_results": 10}, "wikilink": {"enabled": False}, + # Spec-correct weights (post B-05/B-06/B-07 fix) "scoring_weights": { "topic_relevance": 4.0, "emotion_resonance": 2.0, - "time_proximity": 2.5, + "time_proximity": 1.5, # spec: 1.5 (was 2.5 in buggy code) "importance": 1.0, - "content_weight": 3.0, + "content_weight": 1.0, # spec: 1.0 (was 3.0 in buggy code) }, "decay": { "lambda": 0.05, @@ -46,7 +52,7 @@ def test_config(tmp_path): "emotion_weights": {"base": 1.0, "arousal_boost": 0.8}, }, "dehydration": { - "api_key": os.environ.get("OMBRE_API_KEY", ""), + "api_key": os.environ.get("OMBRE_API_KEY", "test-key"), "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "model": "gemini-2.5-flash-lite", }, @@ -54,10 +60,49 @@ def test_config(tmp_path): "api_key": os.environ.get("OMBRE_API_KEY", ""), "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "model": "gemini-embedding-001", + "enabled": False, }, } +@pytest.fixture +def buggy_config(tmp_path): + """ + Config using the PRE-FIX (buggy) scoring weights. + Used in regression tests to document the old broken behaviour. + """ + buckets_dir = str(tmp_path / "buckets") + for d in ["permanent", "dynamic", "archive", "feel"]: + os.makedirs(os.path.join(buckets_dir, d), exist_ok=True) + + return { + "buckets_dir": buckets_dir, + "merge_threshold": 75, + "matching": {"fuzzy_threshold": 50, "max_results": 10}, + "wikilink": {"enabled": False}, + # Buggy weights (before B-05/B-06/B-07 fixes) + "scoring_weights": { + "topic_relevance": 4.0, + "emotion_resonance": 2.0, + "time_proximity": 2.5, # B-06: was too high + "importance": 1.0, + "content_weight": 3.0, # B-07: was too high + }, + "decay": { + "lambda": 0.05, + "threshold": 0.3, + "check_interval_hours": 24, + "emotion_weights": {"base": 1.0, "arousal_boost": 0.8}, + }, + "dehydration": { + "api_key": "", + "base_url": "https://example.com", + "model": "test-model", + }, + "embedding": {"enabled": False, "api_key": ""}, + } + + @pytest.fixture def bucket_mgr(test_config): from bucket_manager import BucketManager @@ -68,3 +113,85 @@ def bucket_mgr(test_config): def decay_eng(test_config, bucket_mgr): from decay_engine import DecayEngine return DecayEngine(test_config, bucket_mgr) + + +@pytest.fixture +def mock_dehydrator(): + """ + Mock Dehydrator that returns deterministic results without any API calls. + Suitable for integration tests that do not test LLM behaviour. + """ + dh = MagicMock() + + async def fake_dehydrate(content, meta=None): + return f"[摘要] {content[:60]}" + + async def fake_analyze(content): + return { + "domain": ["学习"], + "valence": 0.7, + "arousal": 0.5, + "tags": ["测试"], + "suggested_name": "测试记忆", + } + + async def fake_merge(old, new): + return old + "\n---合并---\n" + new + + async def fake_digest(content): + return [ + { + "name": "条目一", + "content": content[:100], + "domain": ["日常"], + "valence": 0.6, + "arousal": 0.4, + "tags": ["测试"], + "importance": 5, + } + ] + + dh.dehydrate = AsyncMock(side_effect=fake_dehydrate) + dh.analyze = AsyncMock(side_effect=fake_analyze) + dh.merge = AsyncMock(side_effect=fake_merge) + dh.digest = AsyncMock(side_effect=fake_digest) + dh.api_available = True + return dh + + +@pytest.fixture +def mock_embedding_engine(): + """Mock EmbeddingEngine that returns empty results — no network calls.""" + ee = MagicMock() + ee.enabled = False + ee.generate_and_store = AsyncMock(return_value=None) + ee.search_similar = AsyncMock(return_value=[]) + ee.delete_embedding = AsyncMock(return_value=True) + ee.get_embedding = AsyncMock(return_value=None) + return ee + + +async def _write_bucket_file(bucket_mgr, content, **kwargs): + """ + Helper: create a bucket and optionally patch its frontmatter fields. + Accepts extra kwargs like created/last_active/resolved/digested/pinned. + Returns bucket_id. + """ + import frontmatter as fm + + direct_fields = { + k: kwargs.pop(k) for k in list(kwargs.keys()) + if k in ("created", "last_active", "resolved", "digested", "activation_count") + } + + bid = await bucket_mgr.create(content=content, **kwargs) + + if direct_fields: + fpath = bucket_mgr._find_bucket_file(bid) + post = fm.load(fpath) + for k, v in direct_fields.items(): + post[k] = v + with open(fpath, "w", encoding="utf-8") as f: + f.write(fm.dumps(post)) + + return bid diff --git a/utils.py b/utils.py index 2f1dc43..d9d608e 100644 --- a/utils.py +++ b/utils.py @@ -98,6 +98,16 @@ def load_config(config_path: str = None) -> dict: if env_buckets_dir: config["buckets_dir"] = env_buckets_dir + # OMBRE_DEHYDRATION_MODEL (with OMBRE_MODEL alias) overrides dehydration.model + env_dehy_model = os.environ.get("OMBRE_DEHYDRATION_MODEL", "") or os.environ.get("OMBRE_MODEL", "") + if env_dehy_model: + config.setdefault("dehydration", {})["model"] = env_dehy_model + + # OMBRE_EMBEDDING_MODEL overrides embedding.model + env_embed_model = os.environ.get("OMBRE_EMBEDDING_MODEL", "") + if env_embed_model: + config.setdefault("embedding", {})["model"] = env_embed_model + # --- Ensure bucket storage directories exist --- # --- 确保记忆桶存储目录存在 --- buckets_dir = config["buckets_dir"]