spec: add BEHAVIOR_SPEC and fix B-01~B-10 (resolved/decay/scoring)
- Add BEHAVIOR_SPEC.md as full system behaviour reference - B-01: stop auto-archiving resolved buckets in update() - B-03: keep activation_count as float in calculate_score - B-04: initialise activation_count=0 on create - B-05: time score coefficient 0.1 -> 0.02 - B-06: w_time default 2.5 -> 1.5 - B-07: content_weight default 3.0 -> 1.0 - B-08: refresh local meta after auto_resolve - B-09: user-supplied valence/arousal takes priority over analyze() - B-10: allow empty domain for feel buckets - Refresh INTERNALS/README/dashboard accordingly
This commit is contained in:
595
BEHAVIOR_SPEC.md
Normal file
595
BEHAVIOR_SPEC.md
Normal file
@@ -0,0 +1,595 @@
|
||||
# Ombre Brain 用户全流程行为规格书
|
||||
|
||||
> 版本:基于 server.py / bucket_manager.py / decay_engine.py / dehydrator.py / embedding_engine.py / CLAUDE_PROMPT.md / config.example.yaml
|
||||
|
||||
---
|
||||
|
||||
## 一、系统角色说明
|
||||
|
||||
### 1.1 参与方总览
|
||||
|
||||
| 角色 | 实体 | 职责边界 |
|
||||
|------|------|---------|
|
||||
| **用户** | 人类 | 发起对话,提供原始内容;可直接访问 Dashboard Web UI |
|
||||
| **Claude(模型端)** | LLM(如 Claude 3.x)| 理解语义、决策何时调用工具、用自然语言回应用户;不直接操作文件 |
|
||||
| **OB 服务端** | `server.py` + 各模块 | 接收 MCP 工具调用,执行持久化、搜索、衰减;对 Claude 不透明 |
|
||||
|
||||
### 1.2 Claude 端职责边界
|
||||
- **必须做**:每次新对话第一步无参调用 `breath()`;对话内容有记忆价值时主动调用 `hold` / `grow`
|
||||
- **不做**:不直接读写 `.md` 文件;不执行衰减计算;不操作 SQLite
|
||||
- **决策权**:Claude 决定是否存、存哪些、何时 resolve;OB 决定如何存(合并/新建)
|
||||
|
||||
### 1.3 OB 服务端内部模块职责
|
||||
|
||||
| 模块 | 核心职责 |
|
||||
|------|---------|
|
||||
| `server.py` | 注册 MCP 工具(`breath/hold/grow/trace/pulse/dream`);路由 Dashboard HTTP 请求;`_merge_or_create()` 合并逻辑中枢 |
|
||||
| `bucket_manager.py` | 桶 CRUD;多维搜索(fuzzy + embedding 双通道);`touch()` 激活刷新;`_time_ripple()` 时间波纹 |
|
||||
| `dehydrator.py` | `analyze()` 自动打标;`merge()` 内容融合;`digest()` 日记拆分;`dehydrate()` 内容压缩 |
|
||||
| `embedding_engine.py` | `generate_and_store()` 生成向量并存 SQLite;`search_similar()` 余弦相似度检索 |
|
||||
| `decay_engine.py` | `calculate_score()` 衰减分计算;`run_decay_cycle()` 周期扫描归档;后台定时循环 |
|
||||
| `utils.py` | 配置加载;路径安全校验;ID 生成;token 估算 |
|
||||
|
||||
---
|
||||
|
||||
## 二、场景全流程
|
||||
|
||||
---
|
||||
|
||||
### 场景 1:新对话开始(冷启动,无历史记忆)
|
||||
|
||||
**用户操作**:打开新对话窗口,说第一句话
|
||||
|
||||
**Claude 行为**:在任何回复之前,先调用 `breath()`(无参)
|
||||
|
||||
**OB 工具调用**:
|
||||
```
|
||||
breath(query="", max_tokens=10000, domain="", valence=-1, arousal=-1, max_results=20, importance_min=-1)
|
||||
```
|
||||
|
||||
**系统内部发生什么**:
|
||||
1. `decay_engine.ensure_started()` — 懒加载启动后台衰减循环(若未运行)
|
||||
2. 进入"浮现模式"(`not query or not query.strip()`)
|
||||
3. `bucket_mgr.list_all(include_archive=False)` — 遍历 `permanent/` + `dynamic/` + `feel/` 目录,加载所有 `.md` 文件的 frontmatter + 正文
|
||||
4. 筛选钉选桶(`pinned=True` 或 `protected=True`)
|
||||
5. 筛选未解决桶(`resolved=False`,排除 `permanent/feel/pinned`)
|
||||
6. **冷启动检测**:找 `activation_count==0 && importance>=8` 的桶,最多取 2 个插入排序最前
|
||||
7. 按 `decay_engine.calculate_score(metadata)` 降序排列剩余未解决桶
|
||||
8. 对 top-20 以外随机洗牌(top-1 固定,2~20 随机)
|
||||
9. 截断到 `max_results` 条
|
||||
10. 对每个桶调用 `dehydrator.dehydrate(strip_wikilinks(content), clean_meta)` 压缩摘要
|
||||
11. 按 `max_tokens` 预算截断输出
|
||||
|
||||
**返回结果**:
|
||||
- 无记忆时:`"权重池平静,没有需要处理的记忆。"`
|
||||
- 有记忆时:`"=== 核心准则 ===\n📌 ...\n\n=== 浮现记忆 ===\n[权重:X.XX] [bucket_id:xxx] ..."`
|
||||
|
||||
**注意**:浮现模式**不调用** `touch()`,不重置衰减计时器
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:新对话开始(有历史记忆,breath 自动浮现)
|
||||
|
||||
(与场景 1 相同流程,区别在于桶文件已存在)
|
||||
|
||||
**Claude 行为(完整对话启动序列,来自 CLAUDE_PROMPT.md)**:
|
||||
```
|
||||
1. breath() — 浮现未解决记忆
|
||||
2. dream() — 消化最近记忆,有沉淀写 feel
|
||||
3. breath(domain="feel") — 读取之前的 feel
|
||||
4. 开始和用户说话
|
||||
```
|
||||
|
||||
**`breath(domain="feel")` 内部流程**:
|
||||
1. 检测到 `domain.strip().lower() == "feel"` → 进入 feel 专用通道
|
||||
2. `bucket_mgr.list_all()` 过滤 `type=="feel"` 的桶
|
||||
3. 按 `created` 降序排列
|
||||
4. 按 `max_tokens` 截断,不压缩(直接展示原文)
|
||||
5. 返回:`"=== 你留下的 feel ===\n[时间] [bucket_id:xxx]\n内容..."`
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:用户说了一件事,Claude 决定存入记忆(hold)
|
||||
|
||||
**用户操作**:例如"我刚刚拿到了实习 offer,有点激动"
|
||||
|
||||
**Claude 行为**:判断值得记忆,调用:
|
||||
```python
|
||||
hold(content="用户拿到实习 offer,情绪激动", importance=7)
|
||||
```
|
||||
|
||||
**OB 工具调用**:`hold(content, tags="", importance=7, pinned=False, feel=False, source_bucket="", valence=-1, arousal=-1)`
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `decay_engine.ensure_started()`
|
||||
2. 输入校验:`content.strip()` 非空
|
||||
3. `importance = max(1, min(10, 7))` = 7
|
||||
4. `extra_tags = []`(未传 tags)
|
||||
5. **自动打标**:`dehydrator.analyze(content)` → 调用 `_api_analyze()` → LLM 返回 JSON
|
||||
- 返回示例:`{"domain": ["成长", "求职"], "valence": 0.8, "arousal": 0.7, "tags": ["实习", "offer", "激动", ...], "suggested_name": "实习offer获得"}`
|
||||
- 失败时降级:`{"domain": ["未分类"], "valence": 0.5, "arousal": 0.3, "tags": [], "suggested_name": ""}`
|
||||
6. 合并 `auto_tags + extra_tags` 去重
|
||||
7. **合并检测**:`_merge_or_create(content, tags, importance=7, domain, valence, arousal, name)`
|
||||
- `bucket_mgr.search(content, limit=1, domain_filter=domain)` — 搜索最相似的桶
|
||||
- 若最高分 > `config["merge_threshold"]`(默认 75)且该桶非 pinned/protected:
|
||||
- `dehydrator.merge(old_content, new_content)` → `_api_merge()` → LLM 融合
|
||||
- `bucket_mgr.update(bucket_id, content=merged, tags=union, importance=max, domain=union, valence=avg, arousal=avg)`
|
||||
- `embedding_engine.generate_and_store(bucket_id, merged_content)` 更新向量
|
||||
- 返回 `(bucket_name, True)`
|
||||
- 否则:
|
||||
- `bucket_mgr.create(content, tags, importance=7, domain, valence, arousal, name)` → 写 `.md` 文件到 `dynamic/<主题域>/` 目录
|
||||
- `embedding_engine.generate_and_store(bucket_id, content)` 生成并存储向量
|
||||
- 返回 `(bucket_id, False)`
|
||||
|
||||
**返回结果**:
|
||||
- 新建:`"新建→实习offer获得 成长,求职"`
|
||||
- 合并:`"合并→求职经历 成长,求职"`
|
||||
|
||||
**bucket_mgr.create() 详情**:
|
||||
- `generate_bucket_id()` → `uuid4().hex[:12]`
|
||||
- `sanitize_name(name)` → 正则清洗,最长 80 字符
|
||||
- 写 YAML frontmatter + 正文到 `safe_path(domain_dir, f"{name}_{id}.md")`
|
||||
- frontmatter 字段:`id, name, tags, domain, valence, arousal, importance, type, created, last_active, activation_count=1`
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:用户说了一段长日记,Claude 整理存入(grow)
|
||||
|
||||
**用户操作**:发送一大段混合内容,如"今天去医院体检,结果还好;晚上和朋友吃饭聊了很多;最近有点焦虑..."
|
||||
|
||||
**Claude 行为**:
|
||||
```python
|
||||
grow(content="今天去医院体检,结果还好;晚上和朋友吃饭聊了很多;最近有点焦虑...")
|
||||
```
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `decay_engine.ensure_started()`
|
||||
2. 内容长度检查:`len(content.strip()) < 30` → 若短于 30 字符走**快速路径**(`dehydrator.analyze()` + `_merge_or_create()`,跳过 digest)
|
||||
3. **日记拆分**(正常路径):`dehydrator.digest(content)` → `_api_digest()` → LLM 调用 `DIGEST_PROMPT`
|
||||
- LLM 返回 JSON 数组,每项含:`name, content, domain, valence, arousal, tags, importance`
|
||||
- `_parse_digest()` 安全解析,校验 valence/arousal 范围
|
||||
4. 对每个 `item` 调用 `_merge_or_create(item["content"], item["tags"], item["importance"], item["domain"], item["valence"], item["arousal"], item["name"])`
|
||||
- 每项独立走合并或新建逻辑(同场景 3)
|
||||
- 单条失败不影响其他条(`try/except` 隔离)
|
||||
|
||||
**返回结果**:
|
||||
```
|
||||
3条|新2合1
|
||||
📝体检结果
|
||||
📌朋友聚餐
|
||||
📎近期焦虑情绪
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:用户想找某段记忆(breath 带 query 检索)
|
||||
|
||||
**用户操作**:例如"还记得我之前说过关于实习的事吗"
|
||||
|
||||
**Claude 行为**:
|
||||
```python
|
||||
breath(query="实习", domain="成长", valence=0.7, arousal=0.5)
|
||||
```
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `decay_engine.ensure_started()`
|
||||
2. 检测到 `query` 非空,进入**检索模式**
|
||||
3. 解析 `domain_filter = ["成长"]`,`q_valence=0.7`,`q_arousal=0.5`
|
||||
4. **关键词检索**:`bucket_mgr.search(query, limit=20, domain_filter, q_valence, q_arousal)`
|
||||
- **Layer 1**:domain 预筛 → 仅保留 domain 包含"成长"的桶;若为空则回退全量
|
||||
- **Layer 1.5**(embedding 已开启时):`embedding_engine.search_similar(query, top_k=50)` → 用 embedding 候选集替换/缩小精排范围
|
||||
- **Layer 2**:多维加权精排:
|
||||
- `_calc_topic_score()`: `fuzz.partial_ratio(query, name)×3 + domain×2.5 + tags×2 + body×1`,归一化 0~1
|
||||
- `_calc_emotion_score()`: `1 - √((v差²+a差²)/2)`,0~1
|
||||
- `_calc_time_score()`: `e^(-0.02×days_since_last_active)`,0~1
|
||||
- `importance_score`: `importance / 10`
|
||||
- `total = topic×4 + emotion×2 + time×1.5 + importance×1`,归一化到 0~100
|
||||
- `resolved` 桶降权 ×0.3
|
||||
- 过滤 `score >= fuzzy_threshold`(默认 50),返回最多 `limit` 条
|
||||
5. 排除 pinned/protected 桶(它们在浮现模式展示)
|
||||
6. **向量补充通道**(server.py 额外层):`embedding_engine.search_similar(query, top_k=20)` → 相似度 > 0.5 的桶补充到结果集(标记 `vector_match=True`)
|
||||
7. 对每个结果:
|
||||
- 记忆重构:若传了 `q_valence`,展示层 valence 做微调:`shift = (q_valence - 0.5) × 0.2`,最大 ±0.1
|
||||
- `dehydrator.dehydrate(strip_wikilinks(content), clean_meta)` 压缩摘要
|
||||
- `bucket_mgr.touch(bucket_id)` — 刷新 `last_active` + `activation_count += 1` + 触发 `_time_ripple()`(对 48h 内创建的邻近桶 activation_count + 0.3,最多 5 个桶)
|
||||
8. **随机漂流**:若检索结果 < 3 且 `random.random() < 0.4`,随机从 `decay_score < 2.0` 的旧桶里取 1~3 条,标注 `[surface_type: random]`
|
||||
|
||||
**返回结果**:
|
||||
```
|
||||
[bucket_id:abc123] [重要度:7] [主题:成长] 实习offer获得:...
|
||||
[语义关联] [bucket_id:def456] 求职经历...
|
||||
--- 忽然想起来 ---
|
||||
[surface_type: random] 某段旧记忆...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 6:用户想查看所有记忆状态(pulse)
|
||||
|
||||
**用户操作**:"帮我看看你现在都记得什么"
|
||||
|
||||
**Claude 行为**:
|
||||
```python
|
||||
pulse(include_archive=False)
|
||||
```
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `bucket_mgr.get_stats()` — 遍历三个目录,统计文件数量和 KB 大小
|
||||
2. `bucket_mgr.list_all(include_archive=False)` — 加载全部桶
|
||||
3. 对每个桶:`decay_engine.calculate_score(metadata)` 计算当前权重分
|
||||
4. 按类型/状态分配图标:📌钉选 / 📦permanent / 🫧feel / 🗄️archived / ✅resolved / 💭普通
|
||||
5. 拼接每桶摘要行:`名称 bucket_id 主题 情感坐标 重要度 权重 标签`
|
||||
|
||||
**返回结果**:
|
||||
```
|
||||
=== Ombre Brain 记忆系统 ===
|
||||
固化记忆桶: 2 个
|
||||
动态记忆桶: 15 个
|
||||
归档记忆桶: 3 个
|
||||
总存储大小: 48.3 KB
|
||||
衰减引擎: 运行中
|
||||
|
||||
=== 记忆列表 ===
|
||||
📌 [核心原则] bucket_id:abc123 主题:内心 情感:V0.8/A0.5 ...
|
||||
💭 [实习offer获得] bucket_id:def456 主题:成长 情感:V0.8/A0.7 ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 7:用户想修改/标记已解决/删除某条记忆(trace)
|
||||
|
||||
#### 7a 标记已解决
|
||||
|
||||
**Claude 行为**:
|
||||
```python
|
||||
trace(bucket_id="abc123", resolved=1)
|
||||
```
|
||||
|
||||
**系统内部**:
|
||||
1. `resolved in (0, 1)` → `updates["resolved"] = True`
|
||||
2. `bucket_mgr.update("abc123", resolved=True)` → 读取 `.md` 文件,更新 frontmatter,写回
|
||||
3. 后续 `breath()` 浮现时:该桶 `decay_engine.calculate_score()` 乘以 `resolved_factor=0.05`(若同时 `digested=True` 则 ×0.02)
|
||||
4. `bucket_mgr.search()` 中该桶得分乘以 0.3 降权,但仍可被关键词激活
|
||||
|
||||
**返回**:`"已修改记忆桶 abc123: resolved=True → 已沉底,只在关键词触发时重新浮现"`
|
||||
|
||||
#### 7b 修改元数据
|
||||
|
||||
```python
|
||||
trace(bucket_id="abc123", name="新名字", importance=8, tags="焦虑,成长")
|
||||
```
|
||||
|
||||
**系统内部**:收集非默认值字段 → `bucket_mgr.update()` 批量更新 frontmatter
|
||||
|
||||
#### 7c 删除
|
||||
|
||||
```python
|
||||
trace(bucket_id="abc123", delete=True)
|
||||
```
|
||||
|
||||
**系统内部**:
|
||||
1. `bucket_mgr.delete("abc123")` → `_find_bucket_file()` 定位文件 → `os.remove(file_path)`
|
||||
2. `embedding_engine.delete_embedding("abc123")` → SQLite `DELETE WHERE bucket_id=?`
|
||||
3. 返回:`"已遗忘记忆桶: abc123"`
|
||||
|
||||
---
|
||||
|
||||
### 场景 8:记忆长期未被激活,自动衰减归档(后台 decay)
|
||||
|
||||
**触发方式**:服务启动后,`decay_engine.start()` 创建后台 asyncio Task,每 `check_interval_hours`(默认 24h)执行一次 `run_decay_cycle()`
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `bucket_mgr.list_all(include_archive=False)` — 获取所有活跃桶
|
||||
2. 跳过 `type in ("permanent","feel")` 或 `pinned=True` 或 `protected=True` 的桶
|
||||
3. **自动 resolve**:若 `importance <= 4` 且距上次激活 > 30 天且 `resolved=False` → `bucket_mgr.update(bucket_id, resolved=True)`
|
||||
4. 对每桶调用 `calculate_score(metadata)`:
|
||||
|
||||
**短期(days_since ≤ 3)**:
|
||||
```
|
||||
time_weight = 1.0 + e^(-hours/36) (t=0→×2.0, t=36h→×1.5)
|
||||
emotion_weight = base(1.0) + arousal × arousal_boost(0.8)
|
||||
combined = time_weight×0.7 + emotion_weight×0.3
|
||||
base_score = importance × activation_count^0.3 × e^(-λ×days) × combined
|
||||
```
|
||||
**长期(days_since > 3)**:
|
||||
```
|
||||
combined = emotion_weight×0.7 + time_weight×0.3
|
||||
```
|
||||
**修正因子**:
|
||||
- `resolved=True` → ×0.05
|
||||
- `resolved=True && digested=True` → ×0.02
|
||||
- `arousal > 0.7 && resolved=False` → ×1.5(高唤醒紧迫加成)
|
||||
- `pinned/protected/permanent` → 返回 999.0(永不衰减)
|
||||
- `type=="feel"` → 返回 50.0(固定)
|
||||
|
||||
5. `score < threshold`(默认 0.3)→ `bucket_mgr.archive(bucket_id)` → `_move_bucket()` 将文件从 `dynamic/` 移动到 `archive/` 目录,更新 frontmatter `type="archived"`
|
||||
|
||||
**返回 stats**:`{"checked": N, "archived": N, "auto_resolved": N, "lowest_score": X}`
|
||||
|
||||
---
|
||||
|
||||
### 场景 9:用户使用 dream 工具进行记忆沉淀
|
||||
|
||||
**触发**:Claude 在对话启动时,`breath()` 之后调用 `dream()`
|
||||
|
||||
**OB 工具调用**:`dream()`(无参数)
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `bucket_mgr.list_all()` → 过滤非 `permanent/feel/pinned/protected` 桶
|
||||
2. 按 `created` 降序取前 10 条(最近新增的记忆)
|
||||
3. 对每条拼接:名称、resolved 状态、domain、V/A、创建时间、正文前 500 字符
|
||||
4. **连接提示**(embedding 已开启 && 桶数 >= 2):
|
||||
- 取每个最近桶的 embedding(`embedding_engine.get_embedding(bucket_id)`)
|
||||
- 两两计算 `_cosine_similarity()`,找相似度最高的对
|
||||
- 若 `best_sim > 0.5` → 输出提示:`"[名A] 和 [名B] 似乎有关联 (相似度:X.XX)"`
|
||||
5. **feel 结晶提示**(embedding 已开启 && feel 数 >= 3):
|
||||
- 对所有 feel 桶两两计算相似度
|
||||
- 若某 feel 与 >= 2 个其他 feel 相似度 > 0.7 → 提示升级为 pinned 桶
|
||||
6. 返回标准 header 说明(引导 Claude 自省)+ 记忆列表 + 连接提示 + 结晶提示
|
||||
|
||||
**Claude 后续行为**(根据 CLAUDE_PROMPT 引导):
|
||||
- `trace(bucket_id, resolved=1)` 放下可以放下的
|
||||
- `hold(content="...", feel=True, source_bucket="xxx", valence=0.6)` 写感受
|
||||
- 无沉淀则不操作
|
||||
|
||||
---
|
||||
|
||||
### 场景 10:用户使用 feel 工具记录 Claude 的感受
|
||||
|
||||
**触发**:Claude 在 dream 后决定记录某段记忆带来的感受
|
||||
|
||||
**OB 工具调用**:
|
||||
```python
|
||||
hold(content="她问起了警校的事,我感觉她在用问题保护自己,问是为了不去碰那个真实的恐惧。", feel=True, source_bucket="abc123", valence=0.45, arousal=0.4)
|
||||
```
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `feel=True` → 进入 feel 专用路径,跳过自动打标和合并检测
|
||||
2. `feel_valence = valence`(Claude 自身视角的情绪,非事件情绪)
|
||||
3. `bucket_mgr.create(content, tags=[], importance=5, domain=[], valence=feel_valence, arousal=feel_arousal, bucket_type="feel")` → 写入 `feel/` 目录
|
||||
4. `embedding_engine.generate_and_store(bucket_id, content)` — feel 桶同样有向量(供 dream 结晶检测使用)
|
||||
5. 若 `source_bucket` 非空:`bucket_mgr.update(source_bucket, digested=True, model_valence=feel_valence)` → 标记源记忆已消化
|
||||
- 此后该源桶 `calculate_score()` 中 `resolved_factor = 0.02`(accelerated fade)
|
||||
|
||||
**衰减特性**:feel 桶 `type=="feel"` → `calculate_score()` 固定返回 50.0,永不归档
|
||||
**检索特性**:不参与普通 `breath()` 浮现;只通过 `breath(domain="feel")` 读取
|
||||
|
||||
**返回**:`"🫧feel→<bucket_id>"`
|
||||
|
||||
---
|
||||
|
||||
### 场景 11:用户带 importance_min 参数批量拉取重要记忆
|
||||
|
||||
**Claude 行为**:
|
||||
```python
|
||||
breath(importance_min=8)
|
||||
```
|
||||
|
||||
**系统内部发生什么**:
|
||||
|
||||
1. `importance_min >= 1` → 进入**批量拉取模式**,完全跳过语义搜索
|
||||
2. `bucket_mgr.list_all(include_archive=False)` 全量加载
|
||||
3. 过滤 `importance >= 8` 且 `type != "feel"` 的桶
|
||||
4. 按 `importance` 降序排列,截断到最多 20 条
|
||||
5. 对每条调用 `dehydrator.dehydrate()` 压缩,按 `max_tokens`(默认 10000)预算截断
|
||||
|
||||
**返回**:
|
||||
```
|
||||
[importance:10] [bucket_id:xxx] ...(核心原则)
|
||||
---
|
||||
[importance:9] [bucket_id:yyy] ...
|
||||
---
|
||||
[importance:8] [bucket_id:zzz] ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 12:embedding 向量化检索场景(开启 embedding 时)
|
||||
|
||||
**前提**:`config.yaml` 中 `embedding.enabled: true` 且 `OMBRE_API_KEY` 已配置
|
||||
|
||||
**embedding 介入的两个层次**:
|
||||
|
||||
#### 层次 A:BucketManager.search() 内的 Layer 1.5 预筛
|
||||
- 调用点:`bucket_mgr.search()` → Layer 1.5
|
||||
- 函数:`embedding_engine.search_similar(query, top_k=50)` → 生成查询 embedding → SQLite 全量余弦计算 → 返回 `[(bucket_id, similarity)]` 按相似度降序
|
||||
- 作用:将精排候选集从所有桶缩小到向量最近邻的 50 个,加速后续多维精排
|
||||
|
||||
#### 层次 B:server.py breath 的额外向量通道
|
||||
- 调用点:`breath()` 检索模式中,keyword 搜索完成后
|
||||
- 函数:`embedding_engine.search_similar(query, top_k=20)` → 相似度 > 0.5 的桶补充到结果集
|
||||
- 标注:补充桶带 `[语义关联]` 前缀
|
||||
|
||||
**向量存储路径**:
|
||||
- 新建桶后:`embedding_engine.generate_and_store(bucket_id, content)` → `_generate_embedding(text[:2000])` → API 调用 → `_store_embedding()` → SQLite `INSERT OR REPLACE`
|
||||
- 合并更新后:同上,用 merged content 重新生成
|
||||
- 删除桶时:`embedding_engine.delete_embedding(bucket_id)` → `DELETE FROM embeddings`
|
||||
|
||||
**SQLite 结构**:
|
||||
```sql
|
||||
CREATE TABLE embeddings (
|
||||
bucket_id TEXT PRIMARY KEY,
|
||||
embedding TEXT NOT NULL, -- JSON 序列化的 float 数组
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
```
|
||||
|
||||
**相似度计算**:`_cosine_similarity(a, b)` = dot(a,b) / (|a| × |b|)
|
||||
|
||||
---
|
||||
|
||||
## 三、边界与降级行为
|
||||
|
||||
| 场景 | 异常情况 | 降级行为 |
|
||||
|------|---------|---------|
|
||||
| `breath()` 浮现 | 桶目录为空 | 返回 `"权重池平静,没有需要处理的记忆。"` |
|
||||
| `breath()` 浮现 | `list_all()` 异常 | 返回 `"记忆系统暂时无法访问。"` |
|
||||
| `breath()` 检索 | `bucket_mgr.search()` 异常 | 返回 `"检索过程出错,请稍后重试。"` |
|
||||
| `breath()` 检索 | embedding 不可用 / API 失败 | `logger.warning()` 记录,跳过向量通道,仅用 keyword 检索 |
|
||||
| `breath()` 检索 | 结果 < 3 条 | 40% 概率从低权重旧桶随机浮现 1~3 条,标注 `[surface_type: random]` |
|
||||
| `hold()` 自动打标 | `dehydrator.analyze()` 失败 | 降级到默认值:`domain=["未分类"], valence=0.5, arousal=0.3, tags=[], name=""` |
|
||||
| `hold()` 合并检测 | `bucket_mgr.search()` 失败 | `logger.warning()`,直接走新建路径 |
|
||||
| `hold()` 合并 | `dehydrator.merge()` 失败 | `logger.warning()`,跳过合并,直接新建 |
|
||||
| `hold()` embedding | API 失败 | `try/except` 吞掉,embedding 缺失但不影响存储 |
|
||||
| `grow()` 日记拆分 | `dehydrator.digest()` 失败 | 返回 `"日记整理失败: {e}"` |
|
||||
| `grow()` 单条处理失败 | 单个 item 异常 | `logger.warning()` + 标注 `⚠️条目名`,其他条目正常继续 |
|
||||
| `grow()` 内容 < 30 字 | — | 快速路径:`analyze()` + `_merge_or_create()`,跳过 `digest()`(节省 token) |
|
||||
| `trace()` | `bucket_mgr.get()` 返回 None | 返回 `"未找到记忆桶: {bucket_id}"` |
|
||||
| `trace()` | 未传任何可修改字段 | 返回 `"没有任何字段需要修改。"` |
|
||||
| `pulse()` | `get_stats()` 失败 | 返回 `"获取系统状态失败: {e}"` |
|
||||
| `dream()` | embedding 未开启 | 跳过连接提示和结晶提示,仅返回记忆列表 |
|
||||
| `dream()` | 桶列表为空 | 返回 `"没有需要消化的新记忆。"` |
|
||||
| `decay_cycle` | `list_all()` 失败 | 返回 `{"checked":0, "archived":0, ..., "error": str(e)}`,不终止后台循环 |
|
||||
| `decay_cycle` | 单桶 `calculate_score()` 失败 | `logger.warning()`,跳过该桶继续 |
|
||||
| 所有 feel 操作 | `source_bucket` 不存在 | `logger.warning()` 记录,feel 桶本身仍成功创建 |
|
||||
| `dehydrator.dehydrate()` | API 不可用(`api_available=False`)| ⚠️ **无本地 fallback**,直接返回原始内容或抛出异常 |
|
||||
| `embedding_engine.search_similar()` | `enabled=False` | 直接返回 `[]`,调用方 fallback 到 keyword 搜索 |
|
||||
|
||||
---
|
||||
|
||||
## 四、数据流图
|
||||
|
||||
### 4.1 一条记忆的完整生命周期
|
||||
|
||||
```
|
||||
用户输入内容
|
||||
│
|
||||
▼
|
||||
Claude 决策: hold / grow / 自动
|
||||
│
|
||||
├─[grow 长内容]──→ dehydrator.digest(content)
|
||||
│ DIGEST_PROMPT → LLM API
|
||||
│ 返回 [{name,content,domain,...}]
|
||||
│ ↓ 每条独立处理 ↓
|
||||
│
|
||||
└─[hold 单条]──→ dehydrator.analyze(content)
|
||||
ANALYZE_PROMPT → LLM API
|
||||
返回 {domain, valence, arousal, tags, suggested_name}
|
||||
│
|
||||
▼
|
||||
_merge_or_create()
|
||||
│
|
||||
bucket_mgr.search(content, limit=1, domain_filter)
|
||||
│
|
||||
┌─────┴─────────────────────────┐
|
||||
│ score > merge_threshold (75)? │
|
||||
│ │
|
||||
YES NO
|
||||
│ │
|
||||
▼ ▼
|
||||
dehydrator.merge( bucket_mgr.create(
|
||||
old_content, new) content, tags,
|
||||
MERGE_PROMPT → LLM importance, domain,
|
||||
│ valence, arousal,
|
||||
▼ bucket_type="dynamic"
|
||||
bucket_mgr.update(...) )
|
||||
│ │
|
||||
└──────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
embedding_engine.generate_and_store(
|
||||
bucket_id, content)
|
||||
→ _generate_embedding(text[:2000])
|
||||
→ API 调用 (gemini-embedding-001)
|
||||
→ _store_embedding() → SQLite
|
||||
│
|
||||
▼
|
||||
文件写入: {buckets_dir}/dynamic/{domain}/{name}_{id}.md
|
||||
YAML frontmatter:
|
||||
id, name, tags, domain, valence, arousal,
|
||||
importance, type="dynamic", created, last_active,
|
||||
activation_count=1
|
||||
│
|
||||
▼
|
||||
┌─────── 记忆桶存活期 ──────────────────────────────────────┐
|
||||
│ │
|
||||
│ 每次被 breath(query) 检索命中: │
|
||||
│ bucket_mgr.touch(bucket_id) │
|
||||
│ → last_active = now_iso() │
|
||||
│ → activation_count += 1 │
|
||||
│ → _time_ripple(source_id, now, hours=48) │
|
||||
│ 对 48h 内邻近桶 activation_count += 0.3 │
|
||||
│ │
|
||||
│ 被 dream() 消化: │
|
||||
│ hold(feel=True, source_bucket=id) → │
|
||||
│ bucket_mgr.update(id, digested=True) │
|
||||
│ │
|
||||
│ 被 trace(resolved=1) 标记: │
|
||||
│ resolved=True → decay score ×0.05 (或 ×0.02) │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
decay_engine 后台循环 (每 check_interval_hours=24h)
|
||||
run_decay_cycle()
|
||||
→ 列出所有动态桶
|
||||
→ calculate_score(metadata)
|
||||
importance × activation_count^0.3
|
||||
× e^(-λ×days)
|
||||
× combined_weight
|
||||
× resolved_factor
|
||||
× urgency_boost
|
||||
→ score < threshold (0.3)?
|
||||
│
|
||||
┌─────┴──────┐
|
||||
│ │
|
||||
YES NO
|
||||
│ │
|
||||
▼ ▼
|
||||
bucket_mgr.archive(id) 继续存活
|
||||
→ _move_bucket()
|
||||
→ 文件移动到 archive/
|
||||
→ frontmatter type="archived"
|
||||
│
|
||||
▼
|
||||
记忆桶归档(不再参与浮现/搜索)
|
||||
但文件仍存在,可通过 pulse(include_archive=True) 查看
|
||||
```
|
||||
|
||||
### 4.2 feel 桶的特殊路径
|
||||
|
||||
```
|
||||
hold(feel=True, source_bucket="xxx", valence=0.45)
|
||||
│
|
||||
▼
|
||||
bucket_mgr.create(bucket_type="feel")
|
||||
写入 feel/ 目录
|
||||
│
|
||||
├─→ embedding_engine.generate_and_store()(供 dream 结晶检测)
|
||||
│
|
||||
└─→ bucket_mgr.update(source_bucket, digested=True, model_valence=0.45)
|
||||
源桶 resolved_factor → 0.02
|
||||
加速衰减直到归档
|
||||
|
||||
feel 桶自身:
|
||||
- calculate_score() 返回固定 50.0
|
||||
- 不参与普通 breath 浮现
|
||||
- 不参与 dreaming 候选
|
||||
- 只通过 breath(domain="feel") 读取
|
||||
- 永不归档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、⚠️ 待实现项汇总
|
||||
|
||||
以下功能在现有代码中**未找到对应实现**,标注如下:
|
||||
|
||||
| 编号 | 描述 | 涉及位置 |
|
||||
|------|------|---------|
|
||||
| ⚠️-1 | `dehydrator.dehydrate()` **无本地降级 fallback**。当 `api_available=False` 时代码路径缺失(与注释"API 不可用时自动降级到本地关键词提取"不符)。`dehydrate()` 方法直接假设 API 可用。 | `dehydrator.py` `dehydrate()` + `_api_dehydrate()` |
|
||||
| ⚠️-2 | `decay_engine.run_decay_cycle()` 中 `auto_resolved` 逻辑的具体实现被摘要省略(Lines 211-215),无法确认 `days_since` 的计算和 `bucket_mgr.update(resolved=True)` 调用是否完整存在。 | `decay_engine.py` Lines 211-220 |
|
||||
| ⚠️-3 | `breath()` 浮现模式对**已归档桶**的处理逻辑:`list_all(include_archive=False)` 已正确排除归档桶,但 feel 桶的 `feel/` 子目录是否在 `list_all()` 中被遍历,需核实 `bucket_manager.py` 的 `list_all()` 实现(Lines 623-645 已摘要)。 | `bucket_manager.py` `list_all()` |
|
||||
| ⚠️-4 | `_time_ripple()` 的 `activation_count` 使用浮点数累加(+0.3),但 `calculate_score()` 中 `activation_count = max(1, int(...))` 会截断小数。浮点增量实际上对 score 无效果(任何 < 1 的浮点增量在 int() 后丢失)。 | `bucket_manager.py` `_time_ripple()` + `decay_engine.py` `calculate_score()` |
|
||||
| ⚠️-5 | Dashboard 路由(`/api/buckets`, `/api/search`, `/api/network` 等)未有认证保护说明——auth 中间件 `_require_auth()` 的调用是否覆盖全部 `/api/*` 路由,在摘要版代码中无法确认。 | `server.py` Lines 1211+ |
|
||||
|
||||
---
|
||||
|
||||
*本文档基于代码直接推导,每个步骤均可对照源文件函数名和行为验证。如代码更新,请同步修订此文档。*
|
||||
16
ENV_VARS.md
Normal file
16
ENV_VARS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 环境变量参考
|
||||
|
||||
| 变量名 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `OMBRE_API_KEY` | 是 | — | Gemini / OpenAI-compatible API Key,用于脱水(dehydration)和向量嵌入 |
|
||||
| `OMBRE_BASE_URL` | 否 | `https://generativelanguage.googleapis.com/v1beta/openai/` | API Base URL(可替换为代理或兼容接口) |
|
||||
| `OMBRE_TRANSPORT` | 否 | `stdio` | MCP 传输模式:`stdio` / `sse` / `streamable-http` |
|
||||
| `OMBRE_BUCKETS_DIR` | 否 | `./buckets` | 记忆桶文件存放目录(绑定 Docker Volume 时务必设置) |
|
||||
| `OMBRE_HOOK_URL` | 否 | — | Breath/Dream Webhook 回调地址,留空则不推送 |
|
||||
| `OMBRE_HOOK_SKIP` | 否 | `false` | 设为 `true` 跳过 Webhook 推送 |
|
||||
| `OMBRE_DASHBOARD_PASSWORD` | 否 | — | 预设 Dashboard 访问密码;设置后覆盖文件存储的密码,首次访问不弹设置向导 |
|
||||
|
||||
## 说明
|
||||
|
||||
- `OMBRE_API_KEY` 也可在 `config.yaml` 的 `dehydration.api_key` / `embedding.api_key` 中设置,但**强烈建议**通过环境变量传入,避免密钥写入文件。
|
||||
- `OMBRE_DASHBOARD_PASSWORD` 设置后,Dashboard 的"修改密码"功能将被禁用(显示提示,建议直接修改环境变量)。未设置则密码存储在 `{buckets_dir}/.dashboard_auth.json`(SHA-256 + salt)。
|
||||
51
INTERNALS.md
51
INTERNALS.md
@@ -76,7 +76,7 @@
|
||||
|
||||
| 工具 | 关键参数 | 功能 |
|
||||
|---|---|---|
|
||||
| `breath` | query, max_tokens, domain, valence, arousal, max_results | 检索/浮现记忆 |
|
||||
| `breath` | query, max_tokens, domain, valence, arousal, max_results, **importance_min** | 检索/浮现记忆 |
|
||||
| `hold` | content, tags, importance, pinned, feel, source_bucket, valence, arousal | 存储记忆 |
|
||||
| `grow` | content | 日记拆分归档 |
|
||||
| `trace` | bucket_id, name, domain, valence, arousal, importance, tags, resolved, pinned, digested, content, delete | 修改元数据/内容/删除 |
|
||||
@@ -85,10 +85,11 @@
|
||||
|
||||
**工具详细行为**
|
||||
|
||||
**`breath`** — 两种模式:
|
||||
- **浮现模式**(无 query):无参调用,按衰减引擎活跃度排序返回 top 记忆,permanent/pinned 始终浮现
|
||||
**`breath`** — 三种模式:
|
||||
- **浮现模式**(无 query):无参调用,按衰减引擎活跃度排序返回 top 记忆,钉选桶始终展示;冷启动检测(`activation_count==0 && importance>=8`)的桶最多 2 个插入最前,再 Top-1 固定 + Top-20 随机打乱
|
||||
- **检索模式**(有 query):关键词 + 向量双通道搜索,四维评分(topic×4 + emotion×2 + time×2.5 + importance×1),阈值过滤
|
||||
- **Feel 检索**(`domain="feel"`):特殊通道,按创建时间倒序返回所有 feel 类型桶,不走评分逻辑
|
||||
- **重要度批量模式**(`importance_min>=1`):跳过语义搜索,直接筛选 importance≥importance_min 的桶,按 importance 降序,最多 20 条
|
||||
- 若指定 valence,对匹配桶的 valence 微调 ±0.1(情感记忆重构)
|
||||
|
||||
**`hold`** — 两种模式:
|
||||
@@ -120,26 +121,41 @@
|
||||
| `/breath-hook` | GET | SessionStart 钩子 |
|
||||
| `/dream-hook` | GET | Dream 钩子 |
|
||||
| `/dashboard` | GET | Dashboard 页面 |
|
||||
| `/api/buckets` | GET | 桶列表 |
|
||||
| `/api/bucket/{id}` | GET | 桶详情 |
|
||||
| `/api/search?q=` | GET | 搜索 |
|
||||
| `/api/network` | GET | 向量相似网络 |
|
||||
| `/api/breath-debug` | GET | 评分调试 |
|
||||
| `/api/config` | GET | 配置查看(key 脱敏) |
|
||||
| `/api/config` | POST | 热更新配置 |
|
||||
| `/api/import/upload` | POST | 上传并启动历史对话导入 |
|
||||
| `/api/import/status` | GET | 导入进度查询 |
|
||||
| `/api/import/pause` | POST | 暂停/继续导入 |
|
||||
| `/api/import/patterns` | GET | 导入完成后词频规律检测 |
|
||||
| `/api/import/results` | GET | 已导入记忆桶列表 |
|
||||
| `/api/import/review` | POST | 批量审阅/批准导入结果 |
|
||||
| `/api/buckets` | GET | 桶列表 🔒 |
|
||||
| `/api/bucket/{id}` | GET | 桶详情 🔒 |
|
||||
| `/api/search?q=` | GET | 搜索 🔒 |
|
||||
| `/api/network` | GET | 向量相似网络 🔒 |
|
||||
| `/api/breath-debug` | GET | 评分调试 🔒 |
|
||||
| `/api/config` | GET | 配置查看(key 脱敏)🔒 |
|
||||
| `/api/config` | POST | 热更新配置 🔒 |
|
||||
| `/api/status` | GET | 系统状态(版本/桶数/引擎)🔒 |
|
||||
| `/api/import/upload` | POST | 上传并启动历史对话导入 🔒 |
|
||||
| `/api/import/status` | GET | 导入进度查询 🔒 |
|
||||
| `/api/import/pause` | POST | 暂停/继续导入 🔒 |
|
||||
| `/api/import/patterns` | GET | 导入完成后词频规律检测 🔒 |
|
||||
| `/api/import/results` | GET | 已导入记忆桶列表 🔒 |
|
||||
| `/api/import/review` | POST | 批量审阅/批准导入结果 🔒 |
|
||||
| `/auth/status` | GET | 认证状态(是否需要初始化密码)|
|
||||
| `/auth/setup` | POST | 首次设置密码 |
|
||||
| `/auth/login` | POST | 密码登录,颁发 session cookie |
|
||||
| `/auth/logout` | POST | 注销 session |
|
||||
| `/auth/change-password` | POST | 修改密码 🔒 |
|
||||
|
||||
**Dashboard(5 个 Tab)**
|
||||
> 🔒 = 需要 Dashboard 认证(未认证返回 401 JSON)
|
||||
|
||||
**Dashboard 认证**
|
||||
- 密码存储:SHA-256 + 随机 salt,保存于 `{buckets_dir}/.dashboard_auth.json`
|
||||
- 环境变量 `OMBRE_DASHBOARD_PASSWORD` 设置后,覆盖文件密码(只读,不可通过 Dashboard 修改)
|
||||
- Session:内存字典(服务重启失效),cookie `ombre_session`(HttpOnly, SameSite=Lax, 7天)
|
||||
- `/health`, `/breath-hook`, `/dream-hook`, `/mcp*` 路径不受保护(公开)
|
||||
|
||||
**Dashboard(6 个 Tab)**
|
||||
1. 记忆桶列表:6 种过滤器 + 主题域过滤 + 搜索 + 详情面板
|
||||
2. Breath 模拟:输入参数 → 可视化五步流程 → 四维条形图
|
||||
3. 记忆网络:Canvas 力导向图(节点=桶,边=相似度)
|
||||
4. 配置:热更新脱水/embedding/合并参数
|
||||
5. 导入:历史对话拖拽上传 → 分块处理进度条 → 词频规律分析 → 导入结果审阅
|
||||
6. 设置:服务状态监控、修改密码、退出登录
|
||||
|
||||
**部署选项**
|
||||
1. 本地 stdio(`python server.py`)
|
||||
@@ -172,6 +188,7 @@
|
||||
| `OMBRE_BUCKETS_DIR` | 记忆桶存储目录路径 | 否 | `""` → 回退到 config 或 `./buckets` |
|
||||
| `OMBRE_HOOK_URL` | SessionStart 钩子调用的服务器 URL | 否 | `"http://localhost:8000"` |
|
||||
| `OMBRE_HOOK_SKIP` | 设为 `"1"` 跳过 SessionStart 钩子 | 否 | 未设置(不跳过) |
|
||||
| `OMBRE_DASHBOARD_PASSWORD` | 预设 Dashboard 访问密码;设置后覆盖文件密码,首次访问不弹设置向导 | 否 | `""` |
|
||||
|
||||
环境变量优先级:`环境变量 > config.yaml > 硬编码默认值`。所有环境变量在 `utils.py` 中读取并注入 config dict。
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -435,6 +435,27 @@ Sensitive config via env vars:
|
||||
- `OMBRE_API_KEY` — LLM API 密钥
|
||||
- `OMBRE_TRANSPORT` — 覆盖传输方式
|
||||
- `OMBRE_BUCKETS_DIR` — 覆盖存储路径
|
||||
- `OMBRE_DASHBOARD_PASSWORD` — Dashboard 访问密码(可选,见下)
|
||||
|
||||
## Dashboard 认证 / Dashboard Auth
|
||||
|
||||
自 v1.3.0 起,Dashboard 和所有 `/api/*` 端点均受密码保护。
|
||||
Since v1.3.0, the Dashboard and all `/api/*` endpoints are password-protected.
|
||||
|
||||
**首次访问**:若未设置密码,浏览器会弹出设置向导,填写并确认密码后即可使用。
|
||||
**First visit**: If no password is set, a setup wizard will appear. Enter and confirm a password to get started.
|
||||
|
||||
**通过环境变量预设密码**:在 `docker-compose.user.yml` 中添加:
|
||||
**Pre-set via env var** in your `docker-compose.user.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- OMBRE_DASHBOARD_PASSWORD=your_password_here
|
||||
```
|
||||
设置后,Dashboard 的"修改密码"功能将被禁用,必须通过环境变量修改。
|
||||
When set, the in-Dashboard password change is disabled — modify the env var directly.
|
||||
|
||||
完整环境变量说明见 [ENV_VARS.md](ENV_VARS.md)。
|
||||
Full env var reference: [ENV_VARS.md](ENV_VARS.md).
|
||||
|
||||
## 衰减公式 / Decay Formula
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class BucketManager:
|
||||
天然兼容 Obsidian 直接浏览和编辑。
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
def __init__(self, config: dict, embedding_engine=None):
|
||||
# --- Read storage paths from config / 从配置中读取存储路径 ---
|
||||
self.base_dir = config["buckets_dir"]
|
||||
self.permanent_dir = os.path.join(self.base_dir, "permanent")
|
||||
@@ -92,6 +92,9 @@ class BucketManager:
|
||||
self.w_importance = scoring.get("importance", 1.0)
|
||||
self.content_weight = scoring.get("content_weight", 3.0) # Added to allow better content-based matching during merge
|
||||
|
||||
# --- Optional embedding engine for pre-filtering / 可选 embedding 引擎,用于预筛候选集 ---
|
||||
self.embedding_engine = embedding_engine
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Create a new bucket
|
||||
# 创建新桶
|
||||
@@ -473,6 +476,20 @@ class BucketManager:
|
||||
else:
|
||||
candidates = all_buckets
|
||||
|
||||
# --- Layer 1.5: embedding pre-filter (optional, reduces multi-dim ranking set) ---
|
||||
# --- 第1.5层:embedding 预筛(可选,缩小精排候选集)---
|
||||
if self.embedding_engine and self.embedding_engine.enabled:
|
||||
try:
|
||||
vector_results = await self.embedding_engine.search_similar(query, top_k=50)
|
||||
if vector_results:
|
||||
vector_ids = {bid for bid, _ in vector_results}
|
||||
emb_candidates = [b for b in candidates if b["id"] in vector_ids]
|
||||
if emb_candidates: # only replace if there's non-empty overlap
|
||||
candidates = emb_candidates
|
||||
# else: keep original candidates as fallback
|
||||
except Exception as e:
|
||||
logger.warning(f"Embedding pre-filter failed, using fuzzy only / embedding 预筛失败: {e}")
|
||||
|
||||
# --- Layer 2: weighted multi-dim ranking ---
|
||||
# --- 第二层:多维加权精排 ---
|
||||
scored = []
|
||||
|
||||
186
dashboard.html
186
dashboard.html
@@ -607,6 +607,7 @@
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="搜索记忆…" />
|
||||
</div>
|
||||
<button onclick="doLogout()" title="退出登录" style="margin-left:12px;background:none;border:1px solid var(--border);color:var(--text-dim);border-radius:20px;padding:6px 14px;font-size:12px;cursor:pointer;">退出</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -615,6 +616,7 @@
|
||||
<div class="tab" data-tab="network">记忆网络</div>
|
||||
<div class="tab" data-tab="config">配置</div>
|
||||
<div class="tab" data-tab="import">导入</div>
|
||||
<div class="tab" data-tab="settings">设置</div>
|
||||
</div>
|
||||
|
||||
<div class="content" id="list-view">
|
||||
@@ -778,7 +780,186 @@
|
||||
<div id="detail-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab View -->
|
||||
<div class="content" id="settings-view" style="display:none">
|
||||
<div style="max-width:580px;margin:0 auto;">
|
||||
|
||||
<div class="config-section">
|
||||
<h3>服务状态</h3>
|
||||
<div id="settings-status" style="font-size:13px;color:var(--text-dim);line-height:2;">加载中…</div>
|
||||
<button onclick="loadSettingsStatus()" style="margin-top:8px;font-size:12px;padding:4px 12px;">刷新状态</button>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3>修改密码</h3>
|
||||
<div id="settings-env-notice" style="display:none;font-size:12px;color:var(--warning);margin-bottom:10px;">
|
||||
⚠ 当前使用环境变量 OMBRE_DASHBOARD_PASSWORD,请直接修改环境变量。
|
||||
</div>
|
||||
<div id="settings-pwd-form">
|
||||
<div class="config-row">
|
||||
<label>当前密码</label>
|
||||
<input type="password" id="settings-current-pwd" placeholder="当前密码" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>新密码</label>
|
||||
<input type="password" id="settings-new-pwd" placeholder="新密码(至少6位)" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" id="settings-new-pwd2" placeholder="再次输入新密码" />
|
||||
</div>
|
||||
<button class="btn-primary" onclick="changePassword()" style="margin-top:4px;">修改密码</button>
|
||||
<div id="settings-pwd-msg" style="margin-top:10px;font-size:13px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3>账号操作</h3>
|
||||
<button onclick="doLogout()" style="color:var(--negative);border-color:var(--negative);">退出登录</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Overlay -->
|
||||
<div id="auth-overlay" style="position:fixed;inset:0;z-index:9999;background:var(--bg-gradient);background-attachment:fixed;display:flex;align-items:center;justify-content:center;">
|
||||
<div style="background:var(--surface);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:24px;padding:48px 40px;max-width:400px;width:90%;text-align:center;box-shadow:0 8px 40px rgba(0,0,0,0.12);">
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-size:28px;color:var(--accent);margin-bottom:8px;">◐ Ombre Brain</h2>
|
||||
<p style="color:var(--text-dim);font-size:13px;margin-bottom:28px;" id="auth-subtitle">验证身份</p>
|
||||
|
||||
<!-- Setup form -->
|
||||
<div id="auth-setup-form" style="display:none;">
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:16px;">首次使用,请设置访问密码</p>
|
||||
<input type="password" id="auth-setup-pwd" placeholder="设置密码(至少6位)" style="display:block;width:100%;padding:10px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface-solid);color:var(--text);font-size:14px;margin-bottom:10px;" />
|
||||
<input type="password" id="auth-setup-pwd2" placeholder="确认密码" style="display:block;width:100%;padding:10px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface-solid);color:var(--text);font-size:14px;margin-bottom:16px;" onkeydown="if(event.key==='Enter')doSetup()" />
|
||||
<button onclick="doSetup()" style="width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;cursor:pointer;">设置密码并进入</button>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<div id="auth-login-form" style="display:none;">
|
||||
<input type="password" id="auth-login-pwd" placeholder="输入访问密码" style="display:block;width:100%;padding:10px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface-solid);color:var(--text);font-size:14px;margin-bottom:16px;" onkeydown="if(event.key==='Enter')doLogin()" />
|
||||
<button onclick="doLogin()" style="width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;cursor:pointer;">登录</button>
|
||||
</div>
|
||||
|
||||
<div id="auth-error" style="color:var(--negative);font-size:13px;margin-top:12px;display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ========================================
|
||||
// Auth system / 认证系统
|
||||
// ========================================
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const resp = await fetch('/auth/status');
|
||||
const data = await resp.json();
|
||||
if (data.setup_needed) {
|
||||
document.getElementById('auth-subtitle').textContent = '首次设置';
|
||||
document.getElementById('auth-setup-form').style.display = 'block';
|
||||
} else if (data.authenticated) {
|
||||
document.getElementById('auth-overlay').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('auth-subtitle').textContent = '请输入访问密码';
|
||||
document.getElementById('auth-login-form').style.display = 'block';
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('auth-overlay').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthError(msg) {
|
||||
const el = document.getElementById('auth-error');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
async function doSetup() {
|
||||
const p1 = document.getElementById('auth-setup-pwd').value;
|
||||
const p2 = document.getElementById('auth-setup-pwd2').value;
|
||||
if (p1.length < 6) return showAuthError('密码至少6位');
|
||||
if (p1 !== p2) return showAuthError('两次密码不一致');
|
||||
const resp = await fetch('/auth/setup', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: p1}) });
|
||||
if (resp.ok) {
|
||||
document.getElementById('auth-overlay').style.display = 'none';
|
||||
} else {
|
||||
const d = await resp.json();
|
||||
showAuthError(d.detail || '设置失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const pwd = document.getElementById('auth-login-pwd').value;
|
||||
const resp = await fetch('/auth/login', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: pwd}) });
|
||||
if (resp.ok) {
|
||||
document.getElementById('auth-overlay').style.display = 'none';
|
||||
} else {
|
||||
const d = await resp.json();
|
||||
showAuthError(d.detail || '密码错误');
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await fetch('/auth/logout', { method: 'POST' });
|
||||
document.getElementById('auth-setup-form').style.display = 'none';
|
||||
document.getElementById('auth-login-form').style.display = 'none';
|
||||
document.getElementById('auth-login-form').style.display = 'block';
|
||||
document.getElementById('auth-subtitle').textContent = '请输入访问密码';
|
||||
document.getElementById('auth-error').style.display = 'none';
|
||||
document.getElementById('auth-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const currentPwd = document.getElementById('settings-current-pwd').value;
|
||||
const newPwd = document.getElementById('settings-new-pwd').value;
|
||||
const newPwd2 = document.getElementById('settings-new-pwd2').value;
|
||||
const msgEl = document.getElementById('settings-pwd-msg');
|
||||
if (newPwd.length < 6) { msgEl.style.color = 'var(--negative)'; msgEl.textContent = '新密码至少6位'; return; }
|
||||
if (newPwd !== newPwd2) { msgEl.style.color = 'var(--negative)'; msgEl.textContent = '两次密码不一致'; return; }
|
||||
const resp = await authFetch('/auth/change-password', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({current: currentPwd, new: newPwd}) });
|
||||
if (!resp) return;
|
||||
if (resp.ok) {
|
||||
msgEl.style.color = 'var(--accent)'; msgEl.textContent = '密码修改成功';
|
||||
document.getElementById('settings-current-pwd').value = '';
|
||||
document.getElementById('settings-new-pwd').value = '';
|
||||
document.getElementById('settings-new-pwd2').value = '';
|
||||
} else {
|
||||
const d = await resp.json();
|
||||
msgEl.style.color = 'var(--negative)'; msgEl.textContent = d.detail || '修改失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettingsStatus() {
|
||||
const el = document.getElementById('settings-status');
|
||||
try {
|
||||
const resp = await authFetch('/api/status');
|
||||
if (!resp) return;
|
||||
const d = await resp.json();
|
||||
const noticeEl = document.getElementById('settings-env-notice');
|
||||
if (d.using_env_password) noticeEl.style.display = 'block';
|
||||
else noticeEl.style.display = 'none';
|
||||
el.innerHTML = `
|
||||
<b>版本</b>:${d.version}<br>
|
||||
<b>Bucket 总数</b>:${(d.buckets?.total ?? 0)} (永久:${d.buckets?.permanent ?? 0} / 动态:${d.buckets?.dynamic ?? 0} / 归档:${d.buckets?.archive ?? 0})<br>
|
||||
<b>衰减引擎</b>:${d.decay_engine}<br>
|
||||
<b>向量搜索</b>:${d.embedding_enabled ? '已启用' : '未启用'}<br>
|
||||
`;
|
||||
} catch(e) {
|
||||
el.textContent = '加载失败: ' + e;
|
||||
}
|
||||
}
|
||||
|
||||
// authFetch: wraps fetch, shows auth overlay on 401
|
||||
async function authFetch(url, options) {
|
||||
const resp = await fetch(url, options);
|
||||
if (resp.status === 401) {
|
||||
doLogout();
|
||||
return null;
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
const BASE = location.origin;
|
||||
let allBuckets = [];
|
||||
let currentFilter = 'all';
|
||||
@@ -793,9 +974,11 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||
document.getElementById('network-view').style.display = target === 'network' ? '' : 'none';
|
||||
document.getElementById('config-view').style.display = target === 'config' ? '' : 'none';
|
||||
document.getElementById('import-view').style.display = target === 'import' ? '' : 'none';
|
||||
document.getElementById('settings-view').style.display = target === 'settings' ? '' : 'none';
|
||||
if (target === 'network') loadNetwork();
|
||||
if (target === 'config') loadConfig();
|
||||
if (target === 'import') { pollImportStatus(); loadImportResults(); }
|
||||
if (target === 'settings') loadSettingsStatus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1237,7 +1420,7 @@ async function saveConfig(persist) {
|
||||
}
|
||||
}
|
||||
|
||||
loadBuckets();
|
||||
checkAuth().then(() => loadBuckets());
|
||||
|
||||
// --- Import functions ---
|
||||
const uploadZone = document.getElementById('import-upload-zone');
|
||||
@@ -1300,6 +1483,7 @@ function updateImportUI(s) {
|
||||
document.getElementById('import-status-text').textContent = statusMap[s.status] || s.status;
|
||||
document.getElementById('import-pause-btn').style.display = s.status === 'running' ? '' : 'none';
|
||||
if (s.status !== 'running') clearInterval(importPollTimer);
|
||||
if (s.status === 'completed') loadImportResults();
|
||||
const errDiv = document.getElementById('import-errors');
|
||||
if (s.errors && s.errors.length) {
|
||||
errDiv.style.display = '';
|
||||
|
||||
@@ -39,6 +39,8 @@ def _parse_claude_json(data: dict | list) -> list[dict]:
|
||||
turns = []
|
||||
conversations = data if isinstance(data, list) else [data]
|
||||
for conv in conversations:
|
||||
if not isinstance(conv, dict):
|
||||
continue
|
||||
messages = conv.get("chat_messages", conv.get("messages", []))
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
@@ -61,18 +63,27 @@ def _parse_chatgpt_json(data: list | dict) -> list[dict]:
|
||||
turns = []
|
||||
conversations = data if isinstance(data, list) else [data]
|
||||
for conv in conversations:
|
||||
if not isinstance(conv, dict):
|
||||
continue
|
||||
mapping = conv.get("mapping", {})
|
||||
if mapping:
|
||||
# ChatGPT uses a tree structure with mapping
|
||||
sorted_nodes = sorted(
|
||||
mapping.values(),
|
||||
key=lambda n: n.get("message", {}).get("create_time", 0) or 0,
|
||||
)
|
||||
# Filter out None nodes before sorting
|
||||
valid_nodes = [n for n in mapping.values() if isinstance(n, dict)]
|
||||
|
||||
def _node_ts(n):
|
||||
msg = n.get("message")
|
||||
if not isinstance(msg, dict):
|
||||
return 0
|
||||
return msg.get("create_time") or 0
|
||||
|
||||
sorted_nodes = sorted(valid_nodes, key=_node_ts)
|
||||
for node in sorted_nodes:
|
||||
msg = node.get("message")
|
||||
if not msg or not isinstance(msg, dict):
|
||||
continue
|
||||
content_parts = msg.get("content", {}).get("parts", [])
|
||||
content_obj = msg.get("content", {})
|
||||
content_parts = content_obj.get("parts", []) if isinstance(content_obj, dict) else []
|
||||
content = " ".join(str(p) for p in content_parts if p)
|
||||
if not content.strip():
|
||||
continue
|
||||
@@ -168,7 +179,7 @@ def detect_and_parse(raw_content: str, filename: str = "") -> list[dict]:
|
||||
# Single conversation object with role/content messages
|
||||
if "role" in sample and "content" in sample:
|
||||
return _parse_claude_json(data)
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
except (json.JSONDecodeError, KeyError, IndexError, AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Fall back to markdown/text
|
||||
|
||||
305
server.py
305
server.py
@@ -35,6 +35,11 @@ import sys
|
||||
import random
|
||||
import logging
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
import json as _json_lib
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -57,10 +62,10 @@ setup_logging(config.get("log_level", "INFO"))
|
||||
logger = logging.getLogger("ombre_brain")
|
||||
|
||||
# --- Initialize core components / 初始化核心组件 ---
|
||||
bucket_mgr = BucketManager(config) # Bucket manager / 记忆桶管理器
|
||||
embedding_engine = EmbeddingEngine(config) # Embedding engine first (BucketManager depends on it)
|
||||
bucket_mgr = BucketManager(config, embedding_engine=embedding_engine) # Bucket manager / 记忆桶管理器
|
||||
dehydrator = Dehydrator(config) # Dehydrator / 脱水器
|
||||
decay_engine = DecayEngine(config, bucket_mgr) # Decay engine / 衰减引擎
|
||||
embedding_engine = EmbeddingEngine(config) # Embedding engine / 向量化引擎
|
||||
import_engine = ImportEngine(config, bucket_mgr, dehydrator, embedding_engine) # Import engine / 导入引擎
|
||||
|
||||
# --- Create MCP server instance / 创建 MCP 服务器实例 ---
|
||||
@@ -73,6 +78,183 @@ mcp = FastMCP(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Dashboard Auth — simple cookie-based session auth
|
||||
# Dashboard 认证 —— 基于 Cookie 的会话认证
|
||||
#
|
||||
# Env var OMBRE_DASHBOARD_PASSWORD overrides file-stored password.
|
||||
# First visit with no password set → forced setup wizard.
|
||||
# Sessions stored in memory (lost on restart, 7-day expiry).
|
||||
# =============================================================
|
||||
_sessions: dict[str, float] = {} # {token: expiry_timestamp}
|
||||
|
||||
|
||||
def _get_auth_file() -> str:
|
||||
return os.path.join(config["buckets_dir"], ".dashboard_auth.json")
|
||||
|
||||
|
||||
def _load_password_hash() -> str | None:
|
||||
try:
|
||||
auth_file = _get_auth_file()
|
||||
if os.path.exists(auth_file):
|
||||
with open(auth_file, "r", encoding="utf-8") as f:
|
||||
return _json_lib.load(f).get("password_hash")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_password_hash(password: str) -> None:
|
||||
salt = secrets.token_hex(16)
|
||||
h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
auth_file = _get_auth_file()
|
||||
os.makedirs(os.path.dirname(auth_file), exist_ok=True)
|
||||
with open(auth_file, "w", encoding="utf-8") as f:
|
||||
_json_lib.dump({"password_hash": f"{salt}:{h}"}, f)
|
||||
|
||||
|
||||
def _verify_password_hash(password: str, stored: str) -> bool:
|
||||
if ":" not in stored:
|
||||
return False
|
||||
salt, h = stored.split(":", 1)
|
||||
return hmac.compare_digest(
|
||||
h, hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
)
|
||||
|
||||
|
||||
def _is_setup_needed() -> bool:
|
||||
"""True if no password is configured (env var or file)."""
|
||||
if os.environ.get("OMBRE_DASHBOARD_PASSWORD", ""):
|
||||
return False
|
||||
return _load_password_hash() is None
|
||||
|
||||
|
||||
def _verify_any_password(password: str) -> bool:
|
||||
"""Check password against env var (first) or stored hash."""
|
||||
env_pwd = os.environ.get("OMBRE_DASHBOARD_PASSWORD", "")
|
||||
if env_pwd:
|
||||
return hmac.compare_digest(password, env_pwd)
|
||||
stored = _load_password_hash()
|
||||
if not stored:
|
||||
return False
|
||||
return _verify_password_hash(password, stored)
|
||||
|
||||
|
||||
def _create_session() -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
_sessions[token] = time.time() + 86400 * 7 # 7-day expiry
|
||||
return token
|
||||
|
||||
|
||||
def _is_authenticated(request) -> bool:
|
||||
token = request.cookies.get("ombre_session")
|
||||
if not token:
|
||||
return False
|
||||
expiry = _sessions.get(token)
|
||||
if expiry is None or time.time() > expiry:
|
||||
_sessions.pop(token, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _require_auth(request):
|
||||
"""Return JSONResponse(401) if not authenticated, else None."""
|
||||
from starlette.responses import JSONResponse
|
||||
if not _is_authenticated(request):
|
||||
return JSONResponse(
|
||||
{"error": "Unauthorized", "setup_needed": _is_setup_needed()},
|
||||
status_code=401,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# --- Auth endpoints ---
|
||||
@mcp.custom_route("/auth/status", methods=["GET"])
|
||||
async def auth_status(request):
|
||||
"""Return auth state (authenticated, setup_needed)."""
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse({
|
||||
"authenticated": _is_authenticated(request),
|
||||
"setup_needed": _is_setup_needed(),
|
||||
})
|
||||
|
||||
|
||||
@mcp.custom_route("/auth/setup", methods=["POST"])
|
||||
async def auth_setup_endpoint(request):
|
||||
"""Initial password setup (only when no password is configured)."""
|
||||
from starlette.responses import JSONResponse
|
||||
if not _is_setup_needed():
|
||||
return JSONResponse({"error": "Already configured"}, status_code=400)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return JSONResponse({"error": "密码不能少于6位"}, status_code=400)
|
||||
_save_password_hash(password)
|
||||
token = _create_session()
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.set_cookie("ombre_session", token, httponly=True, samesite="lax", max_age=86400 * 7)
|
||||
return resp
|
||||
|
||||
|
||||
@mcp.custom_route("/auth/login", methods=["POST"])
|
||||
async def auth_login(request):
|
||||
"""Login with password."""
|
||||
from starlette.responses import JSONResponse
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
password = body.get("password", "")
|
||||
if _verify_any_password(password):
|
||||
token = _create_session()
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.set_cookie("ombre_session", token, httponly=True, samesite="lax", max_age=86400 * 7)
|
||||
return resp
|
||||
return JSONResponse({"error": "密码错误"}, status_code=401)
|
||||
|
||||
|
||||
@mcp.custom_route("/auth/logout", methods=["POST"])
|
||||
async def auth_logout(request):
|
||||
"""Invalidate session."""
|
||||
from starlette.responses import JSONResponse
|
||||
token = request.cookies.get("ombre_session")
|
||||
if token:
|
||||
_sessions.pop(token, None)
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie("ombre_session")
|
||||
return resp
|
||||
|
||||
|
||||
@mcp.custom_route("/auth/change-password", methods=["POST"])
|
||||
async def auth_change_password(request):
|
||||
"""Change dashboard password (requires current password)."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
if os.environ.get("OMBRE_DASHBOARD_PASSWORD", ""):
|
||||
return JSONResponse({"error": "当前使用环境变量密码,请直接修改 OMBRE_DASHBOARD_PASSWORD"}, status_code=400)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
current = body.get("current", "")
|
||||
new_pwd = body.get("new", "").strip()
|
||||
if not _verify_any_password(current):
|
||||
return JSONResponse({"error": "当前密码错误"}, status_code=401)
|
||||
if len(new_pwd) < 6:
|
||||
return JSONResponse({"error": "新密码不能少于6位"}, status_code=400)
|
||||
_save_password_hash(new_pwd)
|
||||
_sessions.clear()
|
||||
token = _create_session()
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.set_cookie("ombre_session", token, httponly=True, samesite="lax", max_age=86400 * 7)
|
||||
return resp
|
||||
|
||||
|
||||
# =============================================================
|
||||
# /health endpoint: lightweight keepalive
|
||||
# 轻量保活接口
|
||||
@@ -274,12 +456,47 @@ async def breath(
|
||||
valence: float = -1,
|
||||
arousal: float = -1,
|
||||
max_results: int = 20,
|
||||
importance_min: int = -1,
|
||||
) -> str:
|
||||
"""检索/浮现记忆。不传query或传空=自动浮现,有query=关键词检索。max_tokens控制返回总token上限(默认10000)。domain逗号分隔,valence/arousal 0~1(-1忽略)。max_results控制返回数量上限(默认20,最大50)。"""
|
||||
"""检索/浮现记忆。不传query或传空=自动浮现,有query=关键词检索。max_tokens控制返回总token上限(默认10000)。domain逗号分隔,valence/arousal 0~1(-1忽略)。max_results控制返回数量上限(默认20,最大50)。importance_min>=1时按重要度批量拉取(不走语义搜索,按importance降序返回最多20条)。"""
|
||||
await decay_engine.ensure_started()
|
||||
max_results = min(max_results, 50)
|
||||
max_tokens = min(max_tokens, 20000)
|
||||
|
||||
# --- importance_min mode: bulk fetch by importance threshold ---
|
||||
# --- 重要度批量拉取模式:跳过语义搜索,按 importance 降序返回 ---
|
||||
if importance_min >= 1:
|
||||
try:
|
||||
all_buckets = await bucket_mgr.list_all(include_archive=False)
|
||||
except Exception as e:
|
||||
return f"记忆系统暂时无法访问: {e}"
|
||||
filtered = [
|
||||
b for b in all_buckets
|
||||
if int(b["metadata"].get("importance", 0)) >= importance_min
|
||||
and b["metadata"].get("type") not in ("feel",)
|
||||
]
|
||||
filtered.sort(key=lambda b: int(b["metadata"].get("importance", 0)), reverse=True)
|
||||
filtered = filtered[:20]
|
||||
if not filtered:
|
||||
return f"没有重要度 >= {importance_min} 的记忆。"
|
||||
results = []
|
||||
token_used = 0
|
||||
for b in filtered:
|
||||
if token_used >= max_tokens:
|
||||
break
|
||||
try:
|
||||
clean_meta = {k: v for k, v in b["metadata"].items() if k != "tags"}
|
||||
summary = await dehydrator.dehydrate(strip_wikilinks(b["content"]), clean_meta)
|
||||
t = count_tokens_approx(summary)
|
||||
if token_used + t > max_tokens:
|
||||
break
|
||||
imp = b["metadata"].get("importance", 0)
|
||||
results.append(f"[importance:{imp}] [bucket_id:{b['id']}] {summary}")
|
||||
token_used += t
|
||||
except Exception as e:
|
||||
logger.warning(f"importance_min dehydrate failed: {e}")
|
||||
return "\n---\n".join(results) if results else "没有可以展示的记忆。"
|
||||
|
||||
# --- No args or empty query: surfacing mode (weight pool active push) ---
|
||||
# --- 无参数或空query:浮现模式(权重池主动推送)---
|
||||
if not query or not query.strip():
|
||||
@@ -330,6 +547,18 @@ async def breath(
|
||||
top_scores = [(b["metadata"].get("name", b["id"]), decay_engine.calculate_score(b["metadata"])) for b in scored[:5]]
|
||||
logger.info(f"Top unresolved scores: {top_scores}")
|
||||
|
||||
# --- Cold-start detection: never-seen important buckets surface first ---
|
||||
# --- 冷启动检测:从未被访问过且重要度>=8的桶优先插入最前面(最多2个)---
|
||||
cold_start = [
|
||||
b for b in unresolved
|
||||
if int(b["metadata"].get("activation_count", 0)) == 0
|
||||
and int(b["metadata"].get("importance", 0)) >= 8
|
||||
][:2]
|
||||
cold_start_ids = {b["id"] for b in cold_start}
|
||||
# Merge: cold_start first, then scored (excluding duplicates)
|
||||
scored_deduped = [b for b in scored if b["id"] not in cold_start_ids]
|
||||
scored_with_cold = cold_start + scored_deduped
|
||||
|
||||
# --- Token-budgeted surfacing with diversity + hard cap ---
|
||||
# --- 按 token 预算浮现,带多样性 + 硬上限 ---
|
||||
# Top-1 always surfaces; rest sampled from top-20 for diversity
|
||||
@@ -337,13 +566,17 @@ async def breath(
|
||||
for r in pinned_results:
|
||||
token_budget -= count_tokens_approx(r)
|
||||
|
||||
candidates = list(scored)
|
||||
candidates = list(scored_with_cold)
|
||||
if len(candidates) > 1:
|
||||
# Ensure highest-score bucket is first, shuffle rest from top-20
|
||||
top1 = [candidates[0]]
|
||||
pool = candidates[1:min(20, len(candidates))]
|
||||
random.shuffle(pool)
|
||||
candidates = top1 + pool + candidates[min(20, len(candidates)):]
|
||||
# Cold-start buckets stay at front; shuffle rest from top-20
|
||||
n_cold = len(cold_start)
|
||||
non_cold = candidates[n_cold:]
|
||||
if len(non_cold) > 1:
|
||||
top1 = [non_cold[0]]
|
||||
pool = non_cold[1:min(20, len(non_cold))]
|
||||
random.shuffle(pool)
|
||||
non_cold = top1 + pool + non_cold[min(20, len(non_cold)):]
|
||||
candidates = cold_start + non_cold
|
||||
# Hard cap: never surface more than max_results buckets
|
||||
candidates = candidates[:max_results]
|
||||
|
||||
@@ -978,6 +1211,8 @@ async def dream() -> str:
|
||||
async def api_buckets(request):
|
||||
"""List all buckets with metadata (no content for efficiency)."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
try:
|
||||
all_buckets = await bucket_mgr.list_all(include_archive=True)
|
||||
result = []
|
||||
@@ -1012,6 +1247,8 @@ async def api_buckets(request):
|
||||
async def api_bucket_detail(request):
|
||||
"""Get full bucket content by ID."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
bucket_id = request.path_params["bucket_id"]
|
||||
bucket = await bucket_mgr.get(bucket_id)
|
||||
if not bucket:
|
||||
@@ -1029,6 +1266,8 @@ async def api_bucket_detail(request):
|
||||
async def api_search(request):
|
||||
"""Search buckets by query."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
query = request.query_params.get("q", "")
|
||||
if not query:
|
||||
return JSONResponse({"error": "missing q parameter"}, status_code=400)
|
||||
@@ -1055,6 +1294,8 @@ async def api_search(request):
|
||||
async def api_network(request):
|
||||
"""Get embedding similarity network for visualization."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
try:
|
||||
all_buckets = await bucket_mgr.list_all(include_archive=False)
|
||||
nodes = []
|
||||
@@ -1098,6 +1339,8 @@ async def api_network(request):
|
||||
async def api_breath_debug(request):
|
||||
"""Debug endpoint: simulate breath scoring and return per-bucket breakdown."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
query = request.query_params.get("q", "")
|
||||
q_valence = request.query_params.get("valence")
|
||||
q_arousal = request.query_params.get("arousal")
|
||||
@@ -1189,6 +1432,8 @@ async def dashboard(request):
|
||||
async def api_config_get(request):
|
||||
"""Get current runtime config (safe fields only, API key masked)."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
dehy = config.get("dehydration", {})
|
||||
emb = config.get("embedding", {})
|
||||
api_key = dehy.get("api_key", "")
|
||||
@@ -1216,6 +1461,8 @@ async def api_config_update(request):
|
||||
"""Hot-update runtime config. Optionally persist to config.yaml."""
|
||||
from starlette.responses import JSONResponse
|
||||
import yaml
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1306,6 +1553,8 @@ async def api_config_update(request):
|
||||
async def api_import_upload(request):
|
||||
"""Upload a conversation file and start import."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
|
||||
if import_engine.is_running:
|
||||
return JSONResponse({"error": "Import already running"}, status_code=409)
|
||||
@@ -1357,6 +1606,8 @@ async def api_import_upload(request):
|
||||
async def api_import_status(request):
|
||||
"""Get current import progress."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
return JSONResponse(import_engine.get_status())
|
||||
|
||||
|
||||
@@ -1364,6 +1615,8 @@ async def api_import_status(request):
|
||||
async def api_import_pause(request):
|
||||
"""Pause the running import."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
if not import_engine.is_running:
|
||||
return JSONResponse({"error": "No import running"}, status_code=400)
|
||||
import_engine.pause()
|
||||
@@ -1374,6 +1627,8 @@ async def api_import_pause(request):
|
||||
async def api_import_patterns(request):
|
||||
"""Detect high-frequency patterns after import."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
try:
|
||||
patterns = await import_engine.detect_patterns()
|
||||
return JSONResponse({"patterns": patterns})
|
||||
@@ -1385,6 +1640,8 @@ async def api_import_patterns(request):
|
||||
async def api_import_results(request):
|
||||
"""List recently imported/created buckets for review."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", "50"))
|
||||
all_buckets = await bucket_mgr.list_all(include_archive=False)
|
||||
@@ -1411,6 +1668,8 @@ async def api_import_results(request):
|
||||
async def api_import_review(request):
|
||||
"""Apply review decisions: mark buckets as important/noise/pinned."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1446,6 +1705,34 @@ async def api_import_review(request):
|
||||
return JSONResponse({"applied": applied, "errors": errors})
|
||||
|
||||
|
||||
# =============================================================
|
||||
# /api/status — system status for Dashboard settings tab
|
||||
# /api/status — Dashboard 设置页用系统状态
|
||||
# =============================================================
|
||||
@mcp.custom_route("/api/status", methods=["GET"])
|
||||
async def api_system_status(request):
|
||||
"""Return detailed system status for the settings panel."""
|
||||
from starlette.responses import JSONResponse
|
||||
err = _require_auth(request)
|
||||
if err: return err
|
||||
try:
|
||||
stats = await bucket_mgr.get_stats()
|
||||
return JSONResponse({
|
||||
"decay_engine": "running" if decay_engine.is_running else "stopped",
|
||||
"embedding_enabled": embedding_engine.enabled,
|
||||
"buckets": {
|
||||
"permanent": stats.get("permanent_count", 0),
|
||||
"dynamic": stats.get("dynamic_count", 0),
|
||||
"archive": stats.get("archive_count", 0),
|
||||
"total": stats.get("permanent_count", 0) + stats.get("dynamic_count", 0),
|
||||
},
|
||||
"using_env_password": bool(os.environ.get("OMBRE_DASHBOARD_PASSWORD", "")),
|
||||
"version": "1.3.0",
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
# --- Entry point / 启动入口 ---
|
||||
if __name__ == "__main__":
|
||||
transport = config.get("transport", "stdio")
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
import os
|
||||
import pytest
|
||||
import asyncio
|
||||
import pytest_asyncio
|
||||
|
||||
# Feel flow tests use direct BucketManager calls, no LLM needed.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest_asyncio.fixture
|
||||
async def isolated_tools(test_config, tmp_path, monkeypatch):
|
||||
"""
|
||||
Import server tools with config pointing to temp dir.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import pytest_asyncio
|
||||
# ============================================================
|
||||
# Test 1: Scoring Regression — pure local, no LLM needed
|
||||
# 测试 1:评分回归 —— 纯本地,不需要 LLM
|
||||
@@ -22,7 +23,7 @@ from tests.dataset import DATASET
|
||||
# ============================================================
|
||||
# Fixtures: populate temp buckets from dataset
|
||||
# ============================================================
|
||||
@pytest.fixture
|
||||
@pytest_asyncio.fixture
|
||||
async def populated_env(test_config, bucket_mgr, decay_eng):
|
||||
"""Create all dataset buckets in temp dir, return (bucket_mgr, decay_eng, bucket_ids)."""
|
||||
import frontmatter as fm
|
||||
|
||||
Reference in New Issue
Block a user