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
This commit is contained in:
@@ -9,6 +9,9 @@
|
|||||||
| `OMBRE_HOOK_URL` | 否 | — | Breath/Dream Webhook 回调地址,留空则不推送 |
|
| `OMBRE_HOOK_URL` | 否 | — | Breath/Dream Webhook 回调地址,留空则不推送 |
|
||||||
| `OMBRE_HOOK_SKIP` | 否 | `false` | 设为 `true` 跳过 Webhook 推送 |
|
| `OMBRE_HOOK_SKIP` | 否 | `false` | 设为 `true` 跳过 Webhook 推送 |
|
||||||
| `OMBRE_DASHBOARD_PASSWORD` | 否 | — | 预设 Dashboard 访问密码;设置后覆盖文件存储的密码,首次访问不弹设置向导 |
|
| `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`) |
|
||||||
|
|
||||||
## 说明
|
## 说明
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class DecayEngine:
|
|||||||
return 50.0
|
return 50.0
|
||||||
|
|
||||||
importance = max(1, min(10, int(metadata.get("importance", 5))))
|
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 ---
|
# --- Days since last activation ---
|
||||||
last_active_str = metadata.get("last_active", metadata.get("created", ""))
|
last_active_str = metadata.get("last_active", metadata.get("created", ""))
|
||||||
@@ -215,6 +215,7 @@ class DecayEngine:
|
|||||||
if imp <= 4 and days_since > 30:
|
if imp <= 4 and days_since > 30:
|
||||||
try:
|
try:
|
||||||
await self.bucket_mgr.update(bucket["id"], resolved=True)
|
await self.bucket_mgr.update(bucket["id"], resolved=True)
|
||||||
|
meta["resolved"] = True # refresh local meta so resolved_factor applies this cycle
|
||||||
auto_resolved += 1
|
auto_resolved += 1
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto-resolved / 自动结案: "
|
f"Auto-resolved / 自动结案: "
|
||||||
|
|||||||
17
server.py
17
server.py
@@ -790,11 +790,16 @@ async def hold(
|
|||||||
}
|
}
|
||||||
|
|
||||||
domain = analysis["domain"]
|
domain = analysis["domain"]
|
||||||
valence = analysis["valence"]
|
auto_valence = analysis["valence"]
|
||||||
arousal = analysis["arousal"]
|
auto_arousal = analysis["arousal"]
|
||||||
auto_tags = analysis["tags"]
|
auto_tags = analysis["tags"]
|
||||||
suggested_name = analysis.get("suggested_name", "")
|
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))
|
all_tags = list(dict.fromkeys(auto_tags + extra_tags))
|
||||||
|
|
||||||
# --- Pinned buckets bypass merge and are created directly in permanent dir ---
|
# --- Pinned buckets bypass merge and are created directly in permanent dir ---
|
||||||
@@ -805,8 +810,8 @@ async def hold(
|
|||||||
tags=all_tags,
|
tags=all_tags,
|
||||||
importance=10,
|
importance=10,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
valence=valence,
|
valence=final_valence,
|
||||||
arousal=arousal,
|
arousal=final_arousal,
|
||||||
name=suggested_name or None,
|
name=suggested_name or None,
|
||||||
bucket_type="permanent",
|
bucket_type="permanent",
|
||||||
pinned=True,
|
pinned=True,
|
||||||
@@ -823,8 +828,8 @@ async def hold(
|
|||||||
tags=all_tags,
|
tags=all_tags,
|
||||||
importance=importance,
|
importance=importance,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
valence=valence,
|
valence=final_valence,
|
||||||
arousal=arousal,
|
arousal=final_arousal,
|
||||||
name=suggested_name,
|
name=suggested_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import pytest
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
# Ensure project root importable
|
# Ensure project root importable
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
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
|
@pytest.fixture
|
||||||
def test_config(tmp_path):
|
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")
|
buckets_dir = str(tmp_path / "buckets")
|
||||||
os.makedirs(os.path.join(buckets_dir, "permanent"), exist_ok=True)
|
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, "dynamic"), exist_ok=True)
|
||||||
os.makedirs(os.path.join(buckets_dir, "archive"), 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 {
|
return {
|
||||||
"buckets_dir": buckets_dir,
|
"buckets_dir": buckets_dir,
|
||||||
|
"merge_threshold": 75,
|
||||||
"matching": {"fuzzy_threshold": 50, "max_results": 10},
|
"matching": {"fuzzy_threshold": 50, "max_results": 10},
|
||||||
"wikilink": {"enabled": False},
|
"wikilink": {"enabled": False},
|
||||||
|
# Spec-correct weights (post B-05/B-06/B-07 fix)
|
||||||
"scoring_weights": {
|
"scoring_weights": {
|
||||||
"topic_relevance": 4.0,
|
"topic_relevance": 4.0,
|
||||||
"emotion_resonance": 2.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,
|
"importance": 1.0,
|
||||||
"content_weight": 3.0,
|
"content_weight": 1.0, # spec: 1.0 (was 3.0 in buggy code)
|
||||||
},
|
},
|
||||||
"decay": {
|
"decay": {
|
||||||
"lambda": 0.05,
|
"lambda": 0.05,
|
||||||
@@ -46,7 +52,7 @@ def test_config(tmp_path):
|
|||||||
"emotion_weights": {"base": 1.0, "arousal_boost": 0.8},
|
"emotion_weights": {"base": 1.0, "arousal_boost": 0.8},
|
||||||
},
|
},
|
||||||
"dehydration": {
|
"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",
|
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
"model": "gemini-2.5-flash-lite",
|
"model": "gemini-2.5-flash-lite",
|
||||||
},
|
},
|
||||||
@@ -54,10 +60,49 @@ def test_config(tmp_path):
|
|||||||
"api_key": os.environ.get("OMBRE_API_KEY", ""),
|
"api_key": os.environ.get("OMBRE_API_KEY", ""),
|
||||||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
"model": "gemini-embedding-001",
|
"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
|
@pytest.fixture
|
||||||
def bucket_mgr(test_config):
|
def bucket_mgr(test_config):
|
||||||
from bucket_manager import BucketManager
|
from bucket_manager import BucketManager
|
||||||
@@ -68,3 +113,85 @@ def bucket_mgr(test_config):
|
|||||||
def decay_eng(test_config, bucket_mgr):
|
def decay_eng(test_config, bucket_mgr):
|
||||||
from decay_engine import DecayEngine
|
from decay_engine import DecayEngine
|
||||||
return DecayEngine(test_config, bucket_mgr)
|
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
|
||||||
|
|||||||
10
utils.py
10
utils.py
@@ -98,6 +98,16 @@ def load_config(config_path: str = None) -> dict:
|
|||||||
if env_buckets_dir:
|
if env_buckets_dir:
|
||||||
config["buckets_dir"] = 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 ---
|
# --- Ensure bucket storage directories exist ---
|
||||||
# --- 确保记忆桶存储目录存在 ---
|
# --- 确保记忆桶存储目录存在 ---
|
||||||
buckets_dir = config["buckets_dir"]
|
buckets_dir = config["buckets_dir"]
|
||||||
|
|||||||
Reference in New Issue
Block a user