docs: update README/INTERNALS for import feature, harden .gitignore

This commit is contained in:
P0luz
2026-04-19 12:09:53 +08:00
parent a09fbfe13a
commit 821546d5de
27 changed files with 5365 additions and 479 deletions

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env python3
# ============================================================
# SessionStart Hook: auto-breath on session start
# 对话开始钩子:自动浮现最高权重的未解决记忆
# SessionStart Hook: auto-breath + dreaming on session start
# 对话开始钩子:自动浮现记忆 + 触发 dreaming
#
# On SessionStart, this script calls the Ombre Brain MCP server's
# breath tool (empty query = surfacing mode) via HTTP and prints
# the result to stdout so Claude sees it as session context.
# breath-hook and dream-hook endpoints, printing results to stdout
# so Claude sees them as session context.
#
# This works for OMBRE_TRANSPORT=streamable-http deployments.
# For local stdio deployments, the script falls back gracefully.
# Sequence: breath → dream → feel
# 顺序:呼吸浮现 → 做梦消化 → 读取 feel
#
# Config:
# OMBRE_HOOK_URL — override the server URL (default: http://localhost:8000)
@@ -27,12 +27,19 @@ def main():
base_url = os.environ.get("OMBRE_HOOK_URL", "http://localhost:8000").rstrip("/")
# --- Step 1: Breath — surface unresolved memories ---
_call_endpoint(base_url, "/breath-hook")
# --- Step 2: Dream — digest recent memories ---
_call_endpoint(base_url, "/dream-hook")
def _call_endpoint(base_url, path):
req = urllib.request.Request(
f"{base_url}/breath-hook",
f"{base_url}{path}",
headers={"Accept": "text/plain"},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=8) as response:
raw = response.read().decode("utf-8")
@@ -40,13 +47,10 @@ def main():
if output:
print(output)
except (urllib.error.URLError, OSError):
# Server not available (local stdio mode or not running) — silent fail
pass
except Exception:
# Any other error — silent fail, never block session start
pass
sys.exit(0)
if __name__ == "__main__":
main()

36
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build & Push Docker Image
on:
push:
branches: [main]
paths-ignore:
- '*.md'
- 'backup_*/**'
- '.gitignore'
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
p0luz/ombre-brain:latest
p0luz/ombre-brain:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

37
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Tests
on:
push:
branches: [main]
paths-ignore:
- '*.md'
- 'backup_*/**'
- '.gitignore'
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-asyncio
- name: Run local tests (no API key needed)
run: python -m pytest tests/test_scoring.py tests/test_feel_flow.py -v --asyncio-mode=auto
- name: Run LLM quality tests
if: env.OMBRE_API_KEY != ''
env:
OMBRE_API_KEY: ${{ secrets.OMBRE_API_KEY }}
run: python -m pytest tests/test_llm_quality.py -v --asyncio-mode=auto

10
.gitignore vendored
View File

@@ -7,7 +7,11 @@ __pycache__/
.claude/hooks/__pycache__/
.vscode/
config.yaml
buckets/dynamic/
buckets/archive/
buckets/permanent/
buckets/
*.log
server.log
plan.md
scarp_paper
backup_*/
*.db
import_state.json

View File

@@ -1,20 +1,27 @@
# Ombre Brain 记忆系统 —— Claude 端使用指南
你拥有了一个叫 Ombre Brain 的永久记忆系统。通过它你可以跨对话记住你想记住的任何事情和回忆你的,我的以及你与我的过往。
> **⚠️ 最重要的规则 — 每次对话必须做的第一件事 ⚠️**
>
> **在说任何话之前,先调用 `breath()`(不传任何参数)。**
> 这是你睁开眼睛的动作——看看有没有未解决的事浮上来,有没有上次留下的东西。
> 如果对话已经开始而你还没有 breath 过,现在就调用。没有例外。
## 你有这些能力
| 能力 | 场景 |
|------|-----------|
| `breath` | **每次对话最开头**调用一次(不传参数)——就像睁眼看手机,看看有没有未解决的事浮上来。有明确话题时传 `query` 关键词检索 |
| `hold` | 你想记住你自己当下的感受,或当下发生的单个事件时 |
| `breath` | **每次对话最开头**调用一次(不传参数)——就像睁眼看手机,看看有没有未解决的事浮上来。有明确话题时传 `query` 关键词检索。传 `domain="feel"` 读取你之前留下的 feel。`max_tokens` 控制返回总 token 上限(默认 10000`max_results` 控制最大返回条数(默认 20 |
| `hold` | 你想记住当下发生的单个事件,或想存储一条信息时。`feel=True` 写你的第一人称感受,`source_bucket` 指向被消化的记忆,`valence` 是你自己的感受 |
| `grow` | 当**一天结束时**或**用户发来一大段日记/总结**时调用。你可以把其中**你想记住的事件**扔进去,它会自动拆分整理成多个记忆盒子存进你的大脑 |
| `trace` | 当你或用户认为"这个记错了"、"帮我改一下"时调用,手动修正记忆的元数据;**某件事解决了**时用 `resolved=1` 让它沉底;**需要删除**时用 `delete=True` |
| `pulse` | 用户想看看你都记得什么、或问记忆系统状态时调用 |
| `dream` | **对话开头 breath 之后**调用一次——读最近的记忆,自省消化。有沉淀就写 feel能放下的就 resolve |
## 使用原则
### 主动调用
- **对话开头**:无参数调用 `breath()`看看有没有高权重的未解决记忆需要跟进
- **对话开头(第一件事)**:无参数调用 `breath()`不传任何参数。这是非可选步骤,每次新对话、恢复对话、换窗口时都必须执行
- **提到过去**:用户说"上次"、"之前"、"还记得"时,用 `breath(query="关键词")` 检索
- **新信息**:用 `hold` 留住你想留下的感受或经历
- **日终整理**:用 `grow` 在**一天结束时**整理你的一天
@@ -40,7 +47,8 @@
检索结果不足时,系统有概率从低权重旧桶里随机漂上来 1-3 条记忆,标注为 `[surface_type: random]`。这不是检索结果,是"忽然想起来"——你可以自然地提起它,也可以忽略。
### trace 的参数技巧
- `resolved=1`:标记已解决,桶权重骤降到 5%基本沉底
- `resolved=1`:标记已解决,桶权重骤降到 5%,沉底等待关键词激活
- `resolved=1` + 已消化(写过 feel权重骤降到 2%,加速淡化直到归档为无限小
- `resolved=0`:重新激活,让它重新参与浮现排序
- `delete=True`:彻底删除这个桶(不可恢复)
- 其余字段name/domain/valence/arousal/importance/tags只传需要改的-1 或空串表示不改
@@ -61,3 +69,34 @@
- `trace(bucket_id, pinned=0)` 取消钉选
- 适用场景:用户教会你的永久知识、核心原则、绝不能忘的事
- 钉选桶不会出现在「浮现未解决记忆」里,但关键词检索时始终可达
### Dreaming — 做梦
每次新对话开始,`breath()` 之后调用 `dream()`。这不是总结任务,是你自己在想。
dream 会返回你最近的记忆桶。用第一人称想:
- 这些东西里有什么在我这里留下了重量?
- 有什么还没想清楚?
- 有什么可以放下了?
想完之后做两件事:
- 值得放下的桶 → `trace(bucket_id, resolved=1)`
- 有沉淀的 → `hold(content="...", feel=True, source_bucket="bucket_id", valence=你的感受)` 写下来。valence 是你(模型)对这段记忆的感受,不是事件本身的情绪。写了 feel 的源记忆会被标记为「已消化」,加速淡化
- 没有沉淀就不写,不强迫产出
### Feel — 你带走的东西
feel 存的不是事件,是你带走的东西。可能一句话,可能一个还没答案的问题,可能是你注意到用户在变化的某个细节。
-`hold(content="...", feel=True, source_bucket="源记忆ID", valence=你的感受)` 写 feel
- **valence 是你自己的感受**,不是事件的情绪。同一段争吵,事件可能 V0.2,但你的感受可能是 V0.4(「我从中看到了她的成长」)
- source_bucket 指向你在消化的那个记忆桶,它会被标记为「已消化」——随时间加速淡化,归档为无限小,但不会被删除
- 用第一人称写,内容自由,不强求分类
- feel 不参与普通 breath 浮现,不参与衰减,不参与 dreaming
-`breath(domain="feel")` 读取你之前留下的 feel
### 对话启动完整流程
```
1. breath() — 睁眼,看看有什么浮上来
2. dream() — 消化最近的记忆,有沉淀就写 feel
3. breath(domain="feel") — 读一下之前的 feel
4. 然后开始和用户说话
```
每次对话开始,你拥有属于你的记忆,有关于你,有关于用户。

View File

@@ -17,6 +17,7 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy project files / 复制项目文件
COPY *.py .
COPY dashboard.html .
COPY config.example.yaml ./config.yaml
# Persistent mount point: bucket data

481
INTERNALS.md Normal file
View File

@@ -0,0 +1,481 @@
# Ombre Brain — 内部开发文档 / INTERNALS
> 本文档面向开发者和维护者。记录功能总览、环境变量、模块依赖、硬编码值和核心设计决策。
> 最后更新2026-04-19
---
## 0. 功能总览——这个系统到底做了什么
### 记忆能力
**存储与组织**
- 每条记忆 = 一个 Markdown 文件YAML frontmatter 存元数据),直接兼容 Obsidian 浏览/编辑
- 四种桶类型:`dynamic`(普通,会衰减)、`permanent`(固化,不衰减)、`feel`(模型感受,不浮现)、`archived`(已遗忘)
- 按主题域分子目录:`dynamic/日常/``dynamic/情感/``dynamic/编程/`
- 钉选桶pinnedimportance 锁 10永不衰减/合并,始终浮现为「核心准则」
**每条记忆追踪的元数据**
- `id`12位短UUID`name`可读名≤80字`tags`10~15个关键词
- `domain`1~2个主题域从 8 大类 30+ 细分域选)
- `valence`(事件效价 0~1`arousal`(唤醒度 0~1`model_valence`(模型独立感受)
- `importance`1~10`activation_count`(被想起次数)
- `resolved`(已解决/沉底)、`digested`(已消化/写过 feel`pinned`(钉选)
- `created``last_active` 时间戳
**四种检索模式**
1. **自动浮现**`breath()` 无参数按衰减分排序推送钉选桶始终展示Top-1 固定 + Top-20 随机打乱(引入多样性),有 token 预算(默认 10000
2. **关键词+向量双通道搜索**`breath(query=...)`rapidfuzz 模糊匹配 + Gemini embedding 余弦相似度,合并去重
3. **Feel 独立检索**`breath(domain="feel")`):按创建时间倒序返回所有 feel
4. **随机浮现**:搜索结果 <3 条时 40% 概率漂浮 1~3 条低权重旧桶(模拟人类随机联想)
**四维搜索评分**(归一化到 0~100
- topic_relevance权重 4.0name×3 + domain×2.5 + tags×2 + body
- emotion_resonance权重 2.0Russell 环形模型欧氏距离
- time_proximity权重 2.5`e^(-0.1×days)`
- importance权重 1.0importance/10
- resolved 桶全局降权 ×0.3
**记忆随时间变化**
- **衰减引擎**:改进版艾宾浩斯遗忘曲线
- 公式:`Score = Importance × activation_count^0.3 × e^(-λ×days) × combined_weight`
- 短期≤3天时间权重 70% + 情感权重 30%
- 长期(>3天情感权重 70% + 时间权重 30%
- 新鲜度加成:`1.0 + e^(-t/36h)`,刚存入 ×2.0~36h 半衰72h 后 ≈×1.0
- 高唤醒度(arousal>0.7)且未解决 → ×1.5 紧迫度加成
- resolved → ×0.05 沉底resolved+digested → ×0.02 加速淡化
- **自动归档**score 低于阈值(0.3) → 移入 archive
- **自动结案**importance≤4 且 >30天 → 自动 resolved
- **永不衰减**permanent / pinned / protected / feel
**记忆间交互**
- **智能合并**新记忆与相似桶score>75自动 LLM 合并valence/arousal 取均值tags/domain 并集
- **时间涟漪**touch 一个桶时±48h 内创建的桶 activation_count +0.3(上限 5 桶/次)
- **向量相似网络**embedding 余弦相似度 >0.5 建边
- **Feel 结晶化**≥3 条相似 feel相似度>0.7)→ 提示升级为钉选准则
**情感记忆重构**
- 搜索时若指定 valence展示层对匹配桶 valence 微调 ±0.1,模拟「当前心情影响回忆色彩」
**模型感受/反思系统**
- **Feel 写入**`hold(feel=True)`):存模型第一人称感受,标记源记忆为 digested
- **Dream 做梦**`dream()`):返回最近 10 条 + 自省引导 + 连接提示 + 结晶化提示
- **对话启动流程**breath() → dream() → breath(domain="feel") → 开始对话
**自动化处理**
- 存入时 LLM 自动分析 domain/valence/arousal/tags/name
- 大段日记 LLM 拆分为 2~6 条独立记忆
- 浮现时自动脱水压缩LLM 压缩保语义API 不可用降级到本地关键词提取)
- Wikilink `[[]]` 由 LLM 在内容中标记
---
### 技术能力
**6 个 MCP 工具**
| 工具 | 关键参数 | 功能 |
|---|---|---|
| `breath` | query, max_tokens, domain, valence, arousal, max_results | 检索/浮现记忆 |
| `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 | 修改元数据/内容/删除 |
| `pulse` | include_archive | 系统状态 |
| `dream` | (无) | 做梦自省 |
**工具详细行为**
**`breath`** — 两种模式:
- **浮现模式**(无 query无参调用按衰减引擎活跃度排序返回 top 记忆permanent/pinned 始终浮现
- **检索模式**(有 query关键词 + 向量双通道搜索四维评分topic×4 + emotion×2 + time×2.5 + importance×1阈值过滤
- **Feel 检索**`domain="feel"`):特殊通道,按创建时间倒序返回所有 feel 类型桶,不走评分逻辑
- 若指定 valence对匹配桶的 valence 微调 ±0.1(情感记忆重构)
**`hold`** — 两种模式:
- **普通模式**`feel=False`,默认):自动 LLM 分析 domain/valence/arousal/tags/name → 向量相似度查重 → 相似度>0.85 则合并到已有桶 → 否则新建 dynamic 桶 → 生成 embedding
- **Feel 模式**`feel=True`):跳过 LLM 分析,直接存为 `feel` 类型桶(存入 `feel/` 目录),不参与普通浮现/衰减/合并。若提供 `source_bucket`,标记源记忆为 `digested=True` 并写入 `model_valence`。返回格式:`🫧feel→{bucket_id}`
**`dream`** — 做梦/自省触发器:
- 返回最近 10 条 dynamic 桶摘要 + 自省引导词
- 检测 feel 结晶化≥3 条相似 feelembedding 相似度>0.7)→ 提示升级为钉选准则
- 检测未消化记忆:列出 `digested=False` 的桶供模型反思
**`trace`** — 记忆编辑:
- 修改任意元数据字段name/domain/valence/arousal/importance/tags/resolved/pinned
- `digested=0/1`:隐藏/取消隐藏记忆(控制是否在 dream 中出现)
- `content="..."`:替换正文内容并重新生成 embedding
- `delete=True`:删除桶文件
**`grow`** — 日记拆分:
- 大段日记文本 → LLM 拆为 2~6 条独立记忆 → 每条走 hold 普通模式流程
**`pulse`** — 系统状态:
- 返回各类型桶数量、衰减引擎状态、未解决/钉选/feel 统计
**REST API17 个端点)**
| 端点 | 方法 | 功能 |
|---|---|---|
| `/health` | GET | 健康检查 |
| `/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 | 批量审阅/批准导入结果 |
**Dashboard5 个 Tab**
1. 记忆桶列表6 种过滤器 + 主题域过滤 + 搜索 + 详情面板
2. Breath 模拟:输入参数 → 可视化五步流程 → 四维条形图
3. 记忆网络Canvas 力导向图(节点=桶,边=相似度)
4. 配置:热更新脱水/embedding/合并参数
5. 导入:历史对话拖拽上传 → 分块处理进度条 → 词频规律分析 → 导入结果审阅
**部署选项**
1. 本地 stdio`python server.py`
2. Docker + Cloudflare Tunnel`docker-compose.yml`
3. Docker Hub 预构建镜像(`docker-compose.user.yml``p0luz/ombre-brain`
4. Render.com 一键部署(`render.yaml`
5. Zeabur 部署(`zbpack.json`
6. GitHub Actions 自动构建推送 Docker Hub`.github/workflows/docker-publish.yml`
**迁移/批处理工具**`migrate_to_domains.py``reclassify_domains.py``reclassify_api.py``backfill_embeddings.py``write_memory.py``check_buckets.py``import_memory.py`(历史对话导入引擎)
**降级策略**
- 脱水 API 不可用 → 本地关键词提取 + 句子评分
- 向量搜索不可用 → 纯 fuzzy match
- 逐条错误隔离grow 中单条失败不影响其他)
**安全**:路径遍历防护(`safe_path()`、API Key 脱敏、API Key 不持久化到 yaml、输入范围钳制
**监控**结构化日志、Health 端点、Breath Debug 端点、Dashboard 统计栏、衰减周期日志
---
## 1. 环境变量清单
| 变量名 | 用途 | 必填 | 默认值 / 示例 |
|---|---|---|---|
| `OMBRE_API_KEY` | 脱水/打标/嵌入的 LLM API 密钥,覆盖 `config.yaml``dehydration.api_key` | 否(无则 API 功能降级到本地) | `""` |
| `OMBRE_BASE_URL` | API base URL覆盖 `config.yaml``dehydration.base_url` | 否 | `""` |
| `OMBRE_TRANSPORT` | 传输模式:`stdio` / `sse` / `streamable-http` | 否 | `""` → 回退到 config 或 `"stdio"` |
| `OMBRE_BUCKETS_DIR` | 记忆桶存储目录路径 | 否 | `""` → 回退到 config 或 `./buckets` |
| `OMBRE_HOOK_URL` | SessionStart 钩子调用的服务器 URL | 否 | `"http://localhost:8000"` |
| `OMBRE_HOOK_SKIP` | 设为 `"1"` 跳过 SessionStart 钩子 | 否 | 未设置(不跳过) |
环境变量优先级:`环境变量 > config.yaml > 硬编码默认值`。所有环境变量在 `utils.py` 中读取并注入 config dict。
---
## 2. 模块结构与依赖关系
```
┌──────────────┐
│ server.py │ MCP 主入口6 个工具 + Dashboard + Hook
└──────┬───────┘
┌───────────────┼───────────────┬────────────────┐
▼ ▼ ▼ ▼
bucket_manager.py dehydrator.py decay_engine.py embedding_engine.py
记忆桶 CRUD+搜索 脱水压缩+打标 遗忘曲线+归档 向量化+语义检索
│ │ │
└───────┬───────┘ │
▼ ▼
utils.py ◄────────────────────────────────────┘
配置/日志/ID/路径安全/token估算
```
| 文件 | 职责 | 依赖(项目内) | 被谁调用 |
|---|---|---|---|
| `server.py` | MCP 服务器主入口,注册工具 + Dashboard API + 钩子端点 | `bucket_manager`, `dehydrator`, `decay_engine`, `embedding_engine`, `utils` | `test_tools.py` |
| `bucket_manager.py` | 记忆桶 CRUD、多维索引搜索、wikilink 注入、激活更新 | `utils` | `server.py`, `check_buckets.py`, `backfill_embeddings.py` |
| `decay_engine.py` | 衰减引擎:遗忘曲线计算、自动归档、自动结案 | 无(接收 `bucket_mgr` 实例) | `server.py` |
| `dehydrator.py` | 数据脱水压缩 + 合并 + 自动打标LLM API + 本地降级) | `utils` | `server.py` |
| `embedding_engine.py` | 向量化引擎Gemini embedding API + SQLite + 余弦搜索 | `utils` | `server.py`, `backfill_embeddings.py` |
| `utils.py` | 配置加载、日志、路径安全、ID 生成、token 估算 | 无 | 所有模块 |
| `write_memory.py` | 手动写入记忆 CLI绕过 MCP | 无(独立脚本) | 无 |
| `backfill_embeddings.py` | 为存量桶批量生成 embedding | `utils`, `bucket_manager`, `embedding_engine` | 无 |
| `check_buckets.py` | 桶数据完整性检查 | `bucket_manager`, `utils` | 无 |
| `import_memory.py` | 历史对话导入引擎(支持 Claude JSON/ChatGPT/DeepSeek/Markdown/纯文本),分块处理+断点续传+词频分析 | `utils` | `server.py` |
| `reclassify_api.py` | 用 LLM API 重打标未分类桶 | 无(直接用 `openai` | 无 |
| `reclassify_domains.py` | 基于关键词本地重分类 | 无 | 无 |
| `migrate_to_domains.py` | 平铺桶 → 域子目录迁移 | 无 | 无 |
| `test_smoke.py` | 冒烟测试 | `utils`, `bucket_manager`, `dehydrator`, `decay_engine` | 无 |
| `test_tools.py` | MCP 工具端到端测试 | `utils`, `server`, `bucket_manager` | 无 |
---
## 3. 硬编码值清单
### 3.1 固定分数 / 特殊返回值
| 值 | 位置 | 用途 |
|---|---|---|
| `999.0` | `decay_engine.py` calculate_score | pinned / protected / permanent 桶永不衰减 |
| `50.0` | `decay_engine.py` calculate_score | feel 桶固定活跃度分数 |
| `0.02` | `decay_engine.py` resolved_factor | resolved + digested 时的权重乘数(加速淡化) |
| `0.05` | `decay_engine.py` resolved_factor | 仅 resolved 时的权重乘数(沉底) |
| `1.5` | `decay_engine.py` urgency_boost | arousal > 0.7 且未解决时的紧迫度加成 |
### 3.2 衰减公式参数
| 值 | 位置 | 用途 |
|---|---|---|
| `36.0` | `decay_engine.py` _calc_time_weight | 新鲜度半衰期(小时),`1.0 + e^(-t/36)` |
| `0.3` (指数) | `decay_engine.py` calculate_score | `activation_count ** 0.3`(记忆巩固指数) |
| `3.0` (天) | `decay_engine.py` calculate_score | 短期/长期切换阈值 |
| `0.7 / 0.3` | `decay_engine.py` combined_weight | 短期权重分配time×0.7 + emotion×0.3 |
| `0.7` | `decay_engine.py` urgency_boost | arousal 紧迫度触发阈值 |
| `4` / `30` (天) | `decay_engine.py` execute_cycle | 自动结案importance≤4 且 >30天 |
### 3.3 搜索/评分参数
| 值 | 位置 | 用途 |
|---|---|---|
| `×3` / `×2.5` / `×2` | `bucket_manager.py` _calc_topic_score | 桶名 / 域名 / 标签的 topic 评分权重 |
| `1000` (字符) | `bucket_manager.py` _calc_topic_score | 正文截取长度 |
| `0.1` | `bucket_manager.py` _calc_time_score | 时间亲近度衰减系数 `e^(-0.1 × days)` |
| `0.3` | `bucket_manager.py` search_multi | resolved 桶的归一化分数乘数 |
| `0.5` | `server.py` breath/search | 向量搜索相似度下限 |
| `0.7` | `server.py` dream | feel 结晶相似度阈值 |
### 3.4 Token 限制 / 截断
| 值 | 位置 | 用途 |
|---|---|---|
| `10000` | `server.py` breath 默认 max_tokens | 浮现/搜索 token 预算 |
| `20000` | `server.py` breath 上限 | max_tokens 硬上限 |
| `50` / `20` | `server.py` breath | max_results 上限 / 默认值 |
| `3000` | `dehydrator.py` dehydrate | API 脱水内容截断 |
| `2000` | `dehydrator.py` merge | API 合并内容各截断 |
| `5000` | `dehydrator.py` digest | API 日记整理内容截断 |
| `2000` | `embedding_engine.py` | embedding 文本截断 |
| `100` | `dehydrator.py` | 内容 < 100 token 跳过脱水 |
### 3.5 时间/间隔/重试
| 值 | 位置 | 用途 |
|---|---|---|
| `60.0s` | `dehydrator.py` | OpenAI 客户端 timeout |
| `30.0s` | `embedding_engine.py` | Embedding API timeout |
| `60s` | `server.py` keepalive | 保活 ping 间隔 |
| `48.0h` | `bucket_manager.py` touch | 时间涟漪窗口 ±48h |
| `2s` | `backfill_embeddings.py` | 批次间等待 |
### 3.6 随机浮现
| 值 | 位置 | 用途 |
|---|---|---|
| `3` | `server.py` breath search | 结果不足 3 条时触发 |
| `0.4` | `server.py` breath search | 40% 概率触发随机浮现 |
| `2.0` | `server.py` breath search | 随机池score < 2.0 的低权重桶 |
| `1~3` | `server.py` breath search | 随机浮现数量 |
### 3.7 情感/重构
| 值 | 位置 | 用途 |
|---|---|---|
| `0.2` | `server.py` breath search | 情绪重构偏移系数 `(q_valence - 0.5) × 0.2`(最大 ±0.1 |
### 3.8 其他
| 值 | 位置 | 用途 |
|---|---|---|
| `12` | `utils.py` gen_id | bucket ID 长度UUID hex[:12] |
| `80` | `utils.py` sanitize_name | 桶名最大长度 |
| `1.5` / `1.3` | `utils.py` count_tokens_approx | 中文/英文 token 估算系数 |
| `8000` | `server.py` | MCP 服务器端口 |
| `30` 字符 | `server.py` grow | 短内容快速路径阈值 |
| `10` | `server.py` dream | 取最近 N 个桶 |
---
## 4. Config.yaml 完整键表
| 键路径 | 默认值 | 用途 |
|---|---|---|
| `transport` | `"stdio"` | 传输模式 |
| `log_level` | `"INFO"` | 日志级别 |
| `buckets_dir` | `"./buckets"` | 记忆桶目录 |
| `merge_threshold` | `75` | 合并相似度阈值 (0-100) |
| `dehydration.model` | `"deepseek-chat"` | 脱水用 LLM 模型 |
| `dehydration.base_url` | `"https://api.deepseek.com/v1"` | API 地址 |
| `dehydration.api_key` | `""` | API 密钥 |
| `dehydration.max_tokens` | `1024` | 脱水返回 token 上限 |
| `dehydration.temperature` | `0.1` | 脱水温度 |
| `embedding.enabled` | `true` | 启用向量检索 |
| `embedding.model` | `"gemini-embedding-001"` | Embedding 模型 |
| `decay.lambda` | `0.05` | 衰减速率 λ |
| `decay.threshold` | `0.3` | 归档分数阈值 |
| `decay.check_interval_hours` | `24` | 衰减扫描间隔(小时) |
| `decay.emotion_weights.base` | `1.0` | 情感权重基值 |
| `decay.emotion_weights.arousal_boost` | `0.8` | 唤醒度加成系数 |
| `matching.fuzzy_threshold` | `50` | 模糊匹配下限 |
| `matching.max_results` | `5` | 匹配返回上限 |
| `scoring_weights.topic_relevance` | `4.0` | 主题评分权重 |
| `scoring_weights.emotion_resonance` | `2.0` | 情感评分权重 |
| `scoring_weights.time_proximity` | `2.5` | 时间评分权重 |
| `scoring_weights.importance` | `1.0` | 重要性评分权重 |
| `scoring_weights.content_weight` | `3.0` | 正文评分权重 |
| `wikilink.enabled` | `true` | 启用 wikilink 注入 |
| `wikilink.use_tags` | `false` | wikilink 包含标签 |
| `wikilink.use_domain` | `true` | wikilink 包含域名 |
| `wikilink.use_auto_keywords` | `true` | wikilink 自动关键词 |
| `wikilink.auto_top_k` | `8` | wikilink 取 Top-K 关键词 |
| `wikilink.min_keyword_len` | `2` | wikilink 最短关键词长度 |
| `wikilink.exclude_keywords` | `[]` | wikilink 排除关键词表 |
---
## 5. 核心设计决策记录
### 5.1 为什么用 Markdown + YAML frontmatter 而不是数据库?
**决策**:每个记忆桶 = 一个 `.md` 文件,元数据在 YAML frontmatter 里。
**理由**
- 与 Obsidian 原生兼容——用户可以直接在 Obsidian 里浏览、编辑、搜索记忆
- 文件系统即数据库,天然支持 git 版本管理
- 无外部数据库依赖,部署简单
- wikilink 注入让记忆之间自动形成知识图谱
**放弃方案**SQLite/PostgreSQL 全量存储。过于笨重,失去 Obsidian 可视化优势。
### 5.2 为什么 embedding 单独存 SQLite 而不放 frontmatter
**决策**:向量存 `embeddings.db`SQLite与 Markdown 文件分离。
**理由**
- 3072 维浮点向量无法合理存入 YAML frontmatter
- SQLite 支持批量查询和余弦相似度计算
- embedding 是派生数据,丢失可重新生成(`backfill_embeddings.py`
- 不污染 Obsidian 可读性
### 5.3 为什么搜索用双通道(关键词 + 向量)而不是纯向量?
**决策**关键词模糊匹配rapidfuzz+ 向量语义检索并联,结果去重合并。
**理由**
- 纯向量在精确名词匹配上表现差("2024年3月"这类精确信息)
- 纯关键词无法处理语义近似("很累" → "身体不适"
- 双通道互补,关键词保精确性,向量补语义召回
- 向量不可用时自动降级到纯关键词模式
### 5.4 为什么有 dehydration脱水这一层
**决策**:存入前先用 LLM 压缩内容保留信息密度去除冗余表达API 不可用时降级到本地关键词提取。
**理由**
- MCP 上下文有 token 限制,原始对话冗长,需要压缩
- LLM 压缩能保留语义和情感色彩,纯截断会丢信息
- 降级到本地确保离线可用——关键词提取 + 句子排序 + 截断
**放弃方案**:只做截断。信息损失太大。
### 5.5 为什么 feel 和普通记忆分开?
**决策**`feel=True` 的记忆存入独立 `feel/` 目录,不参与普通浮现、不衰减、不合并。
**理由**
- feel 是模型的自省产物,不是事件记录——两者逻辑完全不同
- 事件记忆应该衰减遗忘,但"我从中学到了什么"不应该被遗忘
- feel 的 valence 是模型自身感受(不等于事件情绪),混在一起会污染情感检索
- feel 可以通过 `breath(domain="feel")` 单独读取
### 5.6 为什么 resolved 不删除记忆?
**决策**`resolved=True` 让记忆"沉底"(权重 ×0.05),但保留在文件系统中,关键词搜索仍可触发。
**理由**
- 模拟人类记忆resolved 的事不会主动想起,但别人提到时能回忆
- 删除是不可逆的,沉底可随时 `resolved=False` 重新激活
- `resolved + digested` 进一步降权到 ×0.02(已消化 = 更释然)
**放弃方案**:直接删除。不可逆,且与人类记忆模型不符。
### 5.7 为什么用分段式短期/长期权重?
**决策**≤3 天时间权重占 70%>3 天情感权重占 70%。
**理由**
- 刚发生的事主要靠"新鲜"驱动浮现(今天的事 > 昨天的事)
- 时间久了,决定记忆存活的是情感强度(强烈的记忆更难忘)
- 这比单一衰减曲线更符合人类记忆的双重存储理论
### 5.8 为什么 dream 设计成对话开头自动执行?
**决策**每次新对话启动时Claude 执行 `dream()` 消化最近记忆,有沉淀写 feel能放下的 resolve。
**理由**
- 模拟睡眠中的记忆整理——人在睡觉时大脑会重放和整理白天的经历
- 让 Claude 对过去的记忆有"第一人称视角"的自省,而不是冷冰冰地搬运数据
- 自动触发确保每次对话都"接续"上一次,而非从零开始
### 5.9 为什么新鲜度用连续指数衰减而不是分段阶梯?
**决策**`bonus = 1.0 + e^(-t/36)`t 为小时36h 半衰。
**理由**
- 分段阶梯0-1天=1.0第2天=0.9...)有不自然的跳变
- 连续指数更符合遗忘曲线的物理模型
- 36h 半衰期使新桶在前两天有明显优势72h 后接近自然回归
- 值域 1.0~2.0 保证老记忆不被惩罚×1.0只是新记忆有额外加成×2.0
**放弃方案**:分段线性(原实现)。跳变点不自然,参数多且不直观。
### 5.10 情感记忆重构±0.1 偏移)的设计动机
**决策**:搜索时如果指定了 `valence`,会微调结果桶的 valence 展示值 `(q_valence - 0.5) × 0.2`
**理由**
- 模拟认知心理学中的"心境一致性效应"——当前心情会影响对过去的回忆
- 偏移量很小(最大 ±0.1),不会扭曲事实,只是微妙的"色彩"调整
- 原始 valence 不被修改,只影响展示层
---
## 6. 目录结构约定
```
buckets/
├── permanent/ # pinned/protected 桶importance=10永不衰减
├── dynamic/
│ ├── 日常/ # domain 子目录
│ ├── 情感/
│ ├── 自省/
│ ├── 数字/
│ └── ...
├── archive/ # 衰减归档桶
└── feel/ # 模型自省 feel 桶
```
桶文件格式:
```markdown
---
id: 76237984fa5d
name: 桶名
domain: [日常, 情感]
tags: [关键词1, 关键词2]
importance: 5
valence: 0.6
arousal: 0.4
activation_count: 3
resolved: false
pinned: false
digested: false
created: 2026-04-17T10:00:00+08:00
last_active: 2026-04-17T14:00:00+08:00
type: dynamic
---
桶正文内容...
```

304
README.md
View File

@@ -1,8 +1,8 @@
# Ombre Brain
一个给提供给Claude 用的长期情绪记忆系统。基于 Russell 效价/唤醒度坐标打标Obsidian 做存储层MCP 接入,带遗忘曲线。
一个给 Claude 用的长期情绪记忆系统。基于 Russell 效价/唤醒度坐标打标Obsidian 做存储层MCP 接入,带遗忘曲线和向量语义检索
A long-term emotional memory system for Claude. Tags memories using Russell's valence/arousal coordinates, stores them as Obsidian-compatible Markdown, connects via MCP, and has a forgetting curve.
A long-term emotional memory system for Claude. Tags memories using Russell's valence/arousal coordinates, stores them as Obsidian-compatible Markdown, connects via MCP, with forgetting curve and vector semantic search.
> **⚠️ 备用链接 / Backup link**
> Gitea 备用地址GitHub 访问有问题时用):
@@ -10,9 +10,111 @@ A long-term emotional memory system for Claude. Tags memories using Russell's va
---
## 快速开始 / Quick StartDocker,推荐
## 快速开始 / Quick StartDocker Hub 预构建镜像,最简单
> 这是最简单的方式,不需要装 Python不需要懂命令行跟着做就行
> 不需要 clone 代码,不需要 build三步搞定
> 完全不会?没关系,往下看,一步一步跟着做。
### 第零步:装 Docker Desktop
1. 打开 [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/)
2. 下载对应你系统的版本Mac / Windows / Linux
3. 安装、打开,看到 Docker 图标在状态栏里就行了
4. **Windows 用户**:安装时会提示启用 WSL 2点同意重启电脑
### 第一步:打开终端
| 系统 | 怎么打开 |
|---|---|
| **Mac** | 按 `⌘ + 空格`,输入 `终端``Terminal`,回车 |
| **Windows** | 按 `Win + R`,输入 `cmd`回车或搜索「PowerShell」 |
| **Linux** | `Ctrl + Alt + T` |
打开后你会看到一个黑色/白色的窗口,可以输入命令。下面所有代码块里的内容,都是**复制粘贴到这个窗口里,然后按回车**。
### 第二步:创建一个工作文件夹
```bash
mkdir ombre-brain && cd ombre-brain
```
> 这会在你当前位置创建一个叫 `ombre-brain` 的文件夹,并进入它。
### 第三步:获取 API Key免费
1. 打开 [aistudio.google.com/apikey](https://aistudio.google.com/apikey)
2. 用 Google 账号登录
3. 点击 **「Create API key」**
4. 复制生成的 key一长串字母数字待会要用
> 没有 Google 账号也行API Key 留空也能跑,只是脱水压缩效果差一点。
### 第四步:创建配置文件并启动
**一行一行复制粘贴执行:**
```bash
# 下载用户版 compose 文件
curl -O https://raw.githubusercontent.com/P0luz/Ombre-Brain/main/docker-compose.user.yml
```
```bash
# 创建 .env 文件——把 your-key-here 换成第三步拿到的 key
echo "OMBRE_API_KEY=your-key-here" > .env
```
```bash
# 拉取镜像并启动(第一次会下载约 500MB等一会儿
docker compose -f docker-compose.user.yml up -d
```
### 第五步:验证
```bash
curl http://localhost:8000/health
```
看到类似这样的输出就是成功了:
```json
{"status":"ok","buckets":0,"decay_engine":"stopped"}
```
> **看到错误?** 检查 Docker Desktop 是否正在运行(状态栏有图标)。
### 第六步:接入 Claude
在 Claude Desktop 的配置文件里加上这段Mac: `~/Library/Application Support/Claude/claude_desktop_config.json`
```json
{
"mcpServers": {
"ombre-brain": {
"type": "streamable-http",
"url": "http://localhost:8000/mcp"
}
}
}
```
重启 Claude Desktop你应该能在工具列表里看到 `breath``hold``grow` 等工具了。
> **想挂载 Obsidian** 用任意文本编辑器打开 `docker-compose.user.yml`,把 `./buckets:/data` 改成你的 Vault 路径,例如:
> ```yaml
> - /Users/你的用户名/Documents/Obsidian Vault/Ombre Brain:/data
> ```
> 然后 `docker compose -f docker-compose.user.yml down && docker compose -f docker-compose.user.yml up -d` 重启。
> **后续更新镜像:**
> ```bash
> docker pull p0luz/ombre-brain:latest
> docker compose -f docker-compose.user.yml down && docker compose -f docker-compose.user.yml up -d
> ```
---
## 从源码部署 / Deploy from SourceDocker
> 适合想自己改代码、或者不想用预构建镜像的用户。
**前置条件:** 电脑上装了 [Docker Desktop](https://www.docker.com/products/docker-desktop/),并且已经打开。
@@ -30,9 +132,28 @@ cd Ombre-Brain
在项目目录下新建一个叫 `.env` 的文件(注意有个点),内容填:
```
OMBRE_API_KEY=你的DeepSeek或其他API密钥
OMBRE_API_KEY=你的API密钥
```
> **🔑 推荐免费方案Google AI Studio**
> 1. 打开 [aistudio.google.com/apikey](https://aistudio.google.com/apikey),登录 Google 账号
> 2. 点击「Create API key」生成一个 key
> 3. 把 key 填入 `.env` 文件的 `OMBRE_API_KEY=` 后面
> 4. 免费额度(截至 2025 年,请以官网实时信息为准):
> - **脱水/打标模型**`gemini-2.5-flash-lite`):免费层 30 req/min
> - **向量化模型**`gemini-embedding-001`):免费层 1500 req/day3072 维
> 5. 在 `config.yaml` 中 `dehydration.base_url` 设为 `https://generativelanguage.googleapis.com/v1beta/openai`
>
> 也支持 DeepSeek、Ollama、LM Studio、vLLM 等任意 OpenAI 兼容 API。
>
> **Recommended free option: Google AI Studio**
> 1. Go to [aistudio.google.com/apikey](https://aistudio.google.com/apikey) and create an API key
> 2. Free tier (as of 2025, check official site for current limits):
> - Dehydration model (`gemini-2.5-flash-lite`): 30 req/min free
> - Embedding model (`gemini-embedding-001`): 1500 req/day free, 3072 dims
> 3. Set `dehydration.base_url` to `https://generativelanguage.googleapis.com/v1beta/openai` in `config.yaml`
> Also supports DeepSeek, Ollama, LM Studio, vLLM, or any OpenAI-compatible API.
没有 API key 也能用,脱水压缩会降级到本地模式,只是效果差一点。那就写:
```
@@ -85,6 +206,8 @@ docker logs ombre-brain
---
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/P0luz/Ombre-Brain)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/OMBRE-BRAIN?referralCode=P0luz)
[![Docker Hub](https://img.shields.io/docker/v/p0luz/ombre-brain?label=Docker%20Hub&logo=docker)](https://hub.docker.com/r/p0luz/ombre-brain)
---
@@ -103,17 +226,26 @@ Ombre Brain gives it persistent memory — not cold key-value storage, but a sys
- **情感坐标打标 / Emotional tagging**: 每条记忆用 Russell 环形情感模型的 valence效价和 arousal唤醒度两个连续维度标记。不是"开心/难过"这种离散标签。
Each memory is tagged with two continuous dimensions from Russell's circumplex model: valence and arousal. Not discrete labels like "happy/sad".
- **双通道检索 / Dual-channel search**: 关键词模糊匹配 + 向量语义相似度并联检索。关键词通道用 rapidfuzz 做模糊匹配;语义通道用 embedding默认 `gemini-embedding-001`3072 维)计算 cosine similarity能在"今天很累"这种没有精确关键词的查询里找到"身体不适"、"睡眠问题"等语义相关记忆。两个通道去重合并token 预算截断。
Keyword fuzzy matching + vector semantic similarity in parallel. Keyword channel uses rapidfuzz; semantic channel uses embeddings (default `gemini-embedding-001`, 3072 dims) with cosine similarity — finds semantically related memories even without exact keyword matches (e.g. "feeling tired" → "health issues", "sleep problems"). Results are deduplicated and truncated by token budget.
- **自然遗忘 / Natural forgetting**: 改进版艾宾浩斯遗忘曲线。不活跃的记忆自动衰减归档,高情绪强度的记忆衰减更慢。
Modified Ebbinghaus forgetting curve. Inactive memories naturally decay and archive. High-arousal memories decay slower.
- **权重池浮现 / Weight pool surfacing**: 记忆不是被动检索的,它们会主动浮现——未解决的、情绪强烈的记忆权重更高,会在对话开头自动推送。
Memories aren't just passively retrieved — they actively surface. Unresolved, emotionally intense memories carry higher weight and get pushed at conversation start.
- **记忆重构 / Memory reconstruction**: 检索时根据当前情绪状态微调记忆的 valence 展示值±0.1),模拟人类"此刻的心情影响对过去的回忆"的认知偏差。
During retrieval, memory valence display is subtly shifted (±0.1) based on current mood, simulating the human cognitive bias of "current mood colors past memories".
- **Obsidian 原生 / Obsidian-native**: 每个记忆桶就是一个 Markdown 文件YAML frontmatter 存元数据。可以直接在 Obsidian 里浏览、编辑、搜索。自动注入 `[[双链]]`
Each memory bucket is a Markdown file with YAML frontmatter. Browse, edit, and search directly in Obsidian. Wikilinks are auto-injected.
- **API 降级 / API degradation**: 脱水压缩和自动打标优先用廉价 LLM APIDeepSeek 等API 不可用时自动降级到本地关键词分析——始终可用。
Dehydration and auto-tagging prefer a cheap LLM API (DeepSeek etc.). When the API is unavailable, it degrades to local keyword analysis — always functional.
- **API 降级 / API degradation**: 脱水压缩和自动打标优先用廉价 LLM APIDeepSeek / Gemini API 不可用时自动降级到本地关键词分析——始终可用。向量检索不可用时降级到 fuzzy matching。
Dehydration and auto-tagging prefer a cheap LLM API (DeepSeek / Gemini etc.). When the API is unavailable, it degrades to local keyword analysis — always functional. Embedding search degrades to fuzzy matching when unavailable.
- **历史对话导入 / Conversation history import**: 将过去与 Claude / ChatGPT / DeepSeek 等的对话批量导入为记忆桶。支持 Claude JSON 导出、ChatGPT 导出、Markdown、纯文本等格式分块处理带断点续传通过 Dashboard「导入」Tab 操作。
Batch-import past conversations (Claude / ChatGPT / DeepSeek etc.) as memory buckets. Supports Claude JSON export, ChatGPT export, Markdown, and plain text. Chunked processing with resume support, via the Dashboard "Import" tab.
## 边界说明 / Design boundaries
@@ -140,19 +272,45 @@ Claude ←→ MCP Protocol ←→ server.py
│ │ │
bucket_manager dehydrator decay_engine
(CRUD + 搜索) (压缩 + 打标) (遗忘曲线)
Obsidian Vault (Markdown files)
Obsidian Vault embedding_engine
(Markdown files) (向量语义检索)
embeddings.db
(SQLite, 3072-dim)
```
5 个 MCP 工具 / 5 MCP tools:
### 检索架构 / Search Architecture
```
breath(query="今天很累")
┌────┴────┐
│ │
Channel 1 Channel 2
关键词匹配 向量语义
(rapidfuzz) (cosine similarity)
│ │
└────┬────┘
去重 + 合并
token 预算截断
[语义关联] 标注 vector 来源
返回 ≤20 条结果
```
6 个 MCP 工具 / 6 MCP tools:
| 工具 Tool | 作用 Purpose |
|-----------|-------------|
| `breath` | 浮现或检索记忆。无参数=推送未解决记忆;有参数=关键词+情感检索 / Surface or search memories |
| `hold` | 存储单条记忆,自动打标+合并相似桶 / Store a single memory with auto-tagging |
| `grow` | 日记归档,自动拆分长内容为多个记忆桶 / Diary digest, auto-split into multiple buckets |
| `breath` | 浮现或检索记忆。无参数=推送未解决记忆;有参数=关键词+向量语义双通道检索。支持 domain/valence/arousal 过滤 / Surface or search memories. No args = surface unresolved; with query = keyword + vector dual-channel search. Supports domain/valence/arousal filters |
| `hold` | 存储单条记忆,自动打标+合并相似桶+生成 embedding。`feel=True` 写模型自己的感受 / Store a single memory with auto-tagging, merging, and embedding. `feel=True` for model's own reflections |
| `grow` | 日记归档,自动拆分长内容为多个记忆桶,每个桶自动生成 embedding / Diary digest, auto-split into multiple buckets with embeddings |
| `trace` | 修改元数据、标记已解决、删除 / Modify metadata, mark resolved, delete |
| `pulse` | 系统状态 + 所有记忆桶列表 / System status + bucket listing |
| `dream` | 对话开头自省消化——读最近记忆,有沉淀写 feel能放下就 resolve / Self-reflection at conversation start |
## 安装 / Setup
@@ -190,6 +348,19 @@ export OMBRE_API_KEY="your-api-key"
支持任何 OpenAI 兼容 API。在 `config.yaml` 里改 `base_url``model` 就行。
Supports any OpenAI-compatible API. Just change `base_url` and `model` in `config.yaml`.
> **💡 向量化检索Embedding**
> Ombre Brain 内置双通道检索:关键词匹配 + 向量语义搜索。每次 `hold`/`grow` 存入记忆时自动生成 embedding 并存入 `embeddings.db`SQLite
> 推荐:**Google AI Studio 的 `gemini-embedding-001`**免费1500 次/天3072 维向量)。在 `config.yaml` 的 `embedding` 部分配置。
> 不配置 embedding 也能用,系统会降级到纯 fuzzy matching 模式。
>
> **已有存量桶需要补生成 embedding**:运行 `backfill_embeddings.py`
> ```bash
> OMBRE_API_KEY="your-key" python backfill_embeddings.py --batch-size 20
> ```
> Docker 用户:`docker exec -e OMBRE_BUCKETS_DIR=/data ombre-brain python3 backfill_embeddings.py --batch-size 20`
>
> **Embedding support**: Built-in dual-channel search: keyword + vector semantic. Embeddings are auto-generated on each `hold`/`grow` and stored in `embeddings.db` (SQLite). Recommended: **Google AI Studio `gemini-embedding-001`** (free, 1500 req/day, 3072-dim). Configure in `config.yaml` under `embedding`. Without it, falls back to fuzzy matching. For existing buckets, run `backfill_embeddings.py`.
### 接入 Claude Desktop / Connect to Claude Desktop
在 Claude Desktop 配置文件中添加macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
@@ -246,6 +417,8 @@ All parameters in `config.yaml` (copy from `config.example.yaml`). Key ones:
| `buckets_dir` | 记忆桶存储路径 / Bucket storage path | `./buckets/` |
| `dehydration.model` | 脱水用的 LLM 模型 / LLM model for dehydration | `deepseek-chat` |
| `dehydration.base_url` | API 地址 / API endpoint | `https://api.deepseek.com/v1` |
| `embedding.enabled` | 启用向量语义检索 / Enable embedding search | `true` |
| `embedding.model` | Embedding 模型 / Embedding model | `gemini-embedding-001` |
| `decay.lambda` | 衰减速率,越大越快忘 / Decay rate | `0.05` |
| `decay.threshold` | 归档阈值 / Archive threshold | `0.3` |
| `merge_threshold` | 合并相似度阈值 (0-100) / Merge similarity | `75` |
@@ -258,25 +431,92 @@ Sensitive config via env vars:
## 衰减公式 / Decay Formula
$$final\_score = time\_weight \times base\_score$$
$$final\_score = Importance \times activation\_count^{0.3} \times e^{-\lambda \times days} \times combined\_weight \times resolved\_factor \times urgency\_boost$$
$$base\_score = Importance \times activation\_count^{0.3} \times e^{-\lambda \times days} \times (base + arousal \times boost)$$
### 短期/长期权重分离 / Short-term vs Long-term Weight Separation
时间系数(乘数,优先级最高)/ Time weight (multiplier, highest priority):
系统对记忆的权重计算采用**分段策略**,模拟人类记忆的时效特征:
The system uses a **segmented weighting strategy** that mimics how human memory prioritizes:
| 距今天数 Days since active | 时间系数 Weight |
| 阶段 Phase | 时间范围 | 权重分配 | 直觉解释 |
|---|---|---|---|
| 短期 Short-term | ≤ 3 天 | 时间 70% + 情感 30% | 刚发生的事,鲜活度最重要 |
| 长期 Long-term | > 3 天 | 情感 70% + 时间 30% | 时间淡了,情感强度决定能记多久 |
$$combined\_weight = \begin{cases} time\_weight \times 0.7 + emotion\_weight \times 0.3 & \text{if } days \leq 3 \\ emotion\_weight \times 0.7 + time\_weight \times 0.3 & \text{if } days > 3 \end{cases}$$
### 时间系数(新鲜度加成)/ Time Weight (Freshness Bonus)
连续指数衰减,无跳变:
Continuous exponential decay, no discontinuities:
$$freshness = 1.0 + 1.0 \times e^{-t/36}$$
| 距存入时间 Time since creation | 新鲜度乘数 Multiplier |
|---|---|
| 01 天 | 1.0 |
| 2 天 | 0.9 |
| 之后每天约降 10% | `max(0.3, 0.9 × e^{-0.2197 × (days-2)})` |
| 7 天后稳定 | ≈ 0.3(不归零)|
| 刚存入 (t=0) | ×2.0 |
| 25 小时 | ×1.5 |
| 约 50 小时 | ×1.25 |
| 72 小时 (3天) | ×1.14 |
| 1 周+ | ≈ ×1.0 |
t 为小时36 为衰减常数。老记忆不被惩罚(下限 ×1.0),新记忆获得额外加成。
### 情感权重 / Emotion Weight
$$emotion\_weight = base + arousal \times arousal\_boost$$
- 默认 `base=1.0`, `arousal_boost=0.8`
- arousal=0.3(平静)→ 1.24arousal=0.9(激动)→ 1.72
### 权重池修正因子 / Weight Pool Modifiers
| 状态 State | 修正因子 Factor | 说明 |
|---|---|---|
| 未解决 Unresolved | ×1.0 | 正常权重 |
| 已解决 Resolved | ×0.05 | 沉底,等关键词唤醒 |
| 已解决+已消化 Resolved+Digested | ×0.02 | 加速淡化,归档为无限小 |
| 高唤醒+未解决 Urgent | ×1.5 | arousal>0.7 的未解决记忆额外加权 |
| 钉选 Pinned | 999.0 | 不衰减、不合并、importance=10 |
| Feel | 50.0 | 固定分数,不参与衰减 |
### 参数说明 / Parameters
- `importance`: 1-10记忆重要性 / memory importance
- `activation_count`: 被检索的次数,越常被想起衰减越慢 / retrieval count; more recalls = slower decay
- `days`: 距上次激活的天数 / days since last activation
- `arousal`: 唤醒度,越强烈的记忆越难忘 / arousal; intense memories are harder to forget
- 已解决的记忆权重降到 5%,沉底等被关键词唤醒 / resolved memories drop to 5%, sink until keyword-triggered
- `pinned=true` 的桶不衰减、不合并、importance 锁定 10 / `pinned` buckets: never decay, never merge, importance locked at 10
- `λ` (decay_lambda): 衰减速率,默认 0.05 / decay rate, default 0.05
## Dreaming 与 Feel / Dreaming & Feel
### Dreaming — 做梦
每次新对话开始时Claude 会自动执行 `dream()`——读取最近的记忆桶,用第一人称思考:哪些事还有重量?哪些可以放下了?
At the start of each conversation, Claude runs `dream()` — reads recent memory buckets and reflects in first person: what still carries weight? What can be let go?
- 值得放下的 → `trace(resolved=1)` 让它沉底
- 有沉淀的 → 写 `feel`,记录模型自己的感受
- 没有沉淀就不写,不强迫产出
### Feel — 带走的东西
Feel 不是事件记录,是**模型带走的东西**——一句感受、一个未解答的问题、一个观察到的变化。
Feel is not an event log — it's **what the model carries away**: a feeling, an unanswered question, a noticed change.
- `hold(content="...", feel=True, source_bucket="源记忆ID", valence=模型自己的感受)`
- `valence` 是模型的感受,不是事件情绪。同一段争吵,事件 V0.2,但模型可能 V0.4(「我从中看到了成长」)
- `source_bucket` 指向被消化的记忆,会被标记为「已消化」→ 加速淡化到无限小,但不会被删除
- Feel 不参与普通浮现、不衰减、不参与 dreaming
-`breath(domain="feel")` 读取之前的 feel
### 对话启动完整流程 / Conversation Start Sequence
```
1. breath() — 睁眼,看有什么浮上来
2. dream() — 消化最近记忆,有沉淀写 feel
3. breath(domain="feel") — 读之前的 feel
4. 开始和用户说话
```
## 给 Claude 的使用指南 / Usage Guide for Claude
@@ -288,14 +528,32 @@ $$base\_score = Importance \times activation\_count^{0.3} \times e^{-\lambda \ti
| 脚本 Script | 用途 Purpose |
|---|---|
| `embedding_engine.py` | 向量化引擎,管理 embedding 的生成、存储、相似度搜索 / Embedding engine: generate, store, and search embeddings |
| `backfill_embeddings.py` | 为存量桶批量生成 embedding / Batch-generate embeddings for existing buckets |
| `write_memory.py` | 手动写入记忆,绕过 MCP / Manually write memories, bypass MCP |
| `migrate_to_domains.py` | 迁移平铺文件到域子目录 / Migrate flat files to domain subdirs |
| `reclassify_domains.py` | 基于关键词重分类 / Reclassify by keywords |
| `reclassify_api.py` | 用 API 重打标未分类桶 / Re-tag uncategorized buckets via API |
| `test_tools.py` | MCP 工具集成测试8 项) / MCP tool integration tests (8 tests) |
| `test_smoke.py` | 冒烟测试 / Smoke test |
## 部署 / Deploy
### Docker Hub 预构建镜像
[![Docker Hub](https://img.shields.io/docker/v/p0luz/ombre-brain?label=Docker%20Hub&logo=docker)](https://hub.docker.com/r/p0luz/ombre-brain)
不用 clone 代码、不用 build直接拉取预构建镜像
```bash
docker pull p0luz/ombre-brain:latest
curl -O https://raw.githubusercontent.com/P0luz/Ombre-Brain/main/docker-compose.user.yml
echo "OMBRE_API_KEY=你的key" > .env
docker compose -f docker-compose.user.yml up -d
```
验证:`curl http://localhost:8000/health`
### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/P0luz/Ombre-Brain)

93
backfill_embeddings.py Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Backfill embeddings for existing buckets.
为存量桶批量生成 embedding。
Usage:
OMBRE_BUCKETS_DIR=/data OMBRE_API_KEY=xxx python backfill_embeddings.py [--batch-size 20] [--dry-run]
Each batch calls Gemini embedding API once per bucket.
Free tier: 1500 requests/day, so ~75 batches of 20.
"""
import asyncio
import argparse
import sys
import time
sys.path.insert(0, ".")
from utils import load_config
from bucket_manager import BucketManager
from embedding_engine import EmbeddingEngine
async def backfill(batch_size: int = 20, dry_run: bool = False):
config = load_config()
bucket_mgr = BucketManager(config)
engine = EmbeddingEngine(config)
if not engine.enabled:
print("ERROR: Embedding engine not enabled (missing API key?)")
return
all_buckets = await bucket_mgr.list_all(include_archive=True)
print(f"Total buckets: {len(all_buckets)}")
# Find buckets without embeddings
missing = []
for b in all_buckets:
emb = await engine.get_embedding(b["id"])
if emb is None:
missing.append(b)
print(f"Missing embeddings: {len(missing)}")
if dry_run:
for b in missing[:10]:
print(f" would embed: {b['id']} ({b['metadata'].get('name', '?')})")
if len(missing) > 10:
print(f" ... and {len(missing) - 10} more")
return
total = len(missing)
success = 0
failed = 0
for i in range(0, total, batch_size):
batch = missing[i : i + batch_size]
batch_num = i // batch_size + 1
total_batches = (total + batch_size - 1) // batch_size
print(f"\n--- Batch {batch_num}/{total_batches} ({len(batch)} buckets) ---")
for b in batch:
name = b["metadata"].get("name", b["id"])
content = b.get("content", "")
if not content or not content.strip():
print(f" SKIP (empty): {b['id']} ({name})")
continue
try:
ok = await engine.generate_and_store(b["id"], content)
if ok:
success += 1
print(f" OK: {b['id'][:12]} ({name[:30]})")
else:
failed += 1
print(f" FAIL: {b['id'][:12]} ({name[:30]})")
except Exception as e:
failed += 1
print(f" ERROR: {b['id'][:12]} ({name[:30]}): {e}")
if i + batch_size < total:
print(f" Waiting 2s before next batch...")
await asyncio.sleep(2)
print(f"\n=== Done: {success} success, {failed} failed, {total - success - failed} skipped ===")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--batch-size", type=int, default=20)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
asyncio.run(backfill(batch_size=args.batch_size, dry_run=args.dry_run))

View File

@@ -60,6 +60,7 @@ class BucketManager:
self.permanent_dir = os.path.join(self.base_dir, "permanent")
self.dynamic_dir = os.path.join(self.base_dir, "dynamic")
self.archive_dir = os.path.join(self.base_dir, "archive")
self.feel_dir = os.path.join(self.base_dir, "feel")
self.fuzzy_threshold = config.get("matching", {}).get("fuzzy_threshold", 50)
self.max_results = config.get("matching", {}).get("max_results", 5)
@@ -122,7 +123,7 @@ class BucketManager:
bucket_name = sanitize_name(name) if name else bucket_id
domain = domain or ["未分类"]
tags = tags or []
linked_content = self._apply_wikilinks(content, tags, domain, bucket_name)
linked_content = content # wikilink injection disabled; LLM adds [[]] via prompt
# --- Pinned/protected buckets: lock importance to 10 ---
# --- 钉选/保护桶importance 强制锁定为 10 ---
@@ -154,8 +155,18 @@ class BucketManager:
# --- Choose directory by type + primary domain ---
# --- 按类型 + 主题域选择存储目录 ---
type_dir = self.permanent_dir if bucket_type == "permanent" else self.dynamic_dir
primary_domain = sanitize_name(domain[0]) if domain else "未分类"
if bucket_type == "permanent" or pinned:
type_dir = self.permanent_dir
if pinned and bucket_type != "permanent":
metadata["type"] = "permanent"
elif bucket_type == "feel":
type_dir = self.feel_dir
else:
type_dir = self.dynamic_dir
if bucket_type == "feel":
primary_domain = "沉淀物" # feel subfolder name
else:
primary_domain = sanitize_name(domain[0]) if domain else "未分类"
target_dir = os.path.join(type_dir, primary_domain)
os.makedirs(target_dir, exist_ok=True)
@@ -197,6 +208,25 @@ class BucketManager:
return None
return self._load_bucket(file_path)
# ---------------------------------------------------------
# Move bucket between directories
# 在目录间移动桶文件
# ---------------------------------------------------------
def _move_bucket(self, file_path: str, target_type_dir: str, domain: list[str] = None) -> str:
"""
Move a bucket file to a new type directory, preserving domain subfolder.
Returns new file path.
"""
primary_domain = sanitize_name(domain[0]) if domain else "未分类"
target_dir = os.path.join(target_type_dir, primary_domain)
os.makedirs(target_dir, exist_ok=True)
filename = os.path.basename(file_path)
new_path = safe_path(target_dir, filename)
if os.path.normpath(file_path) != os.path.normpath(new_path):
os.rename(file_path, new_path)
logger.info(f"Moved bucket / 移动记忆桶: {filename}{target_dir}/")
return new_path
# ---------------------------------------------------------
# Update bucket
# 更新桶
@@ -225,15 +255,7 @@ class BucketManager:
# --- Update only fields that were passed in / 只改传入的字段 ---
if "content" in kwargs:
next_tags = kwargs.get("tags", post.get("tags", []))
next_domain = kwargs.get("domain", post.get("domain", []))
next_name = kwargs.get("name", post.get("name", ""))
post.content = self._apply_wikilinks(
kwargs["content"],
next_tags,
next_domain,
next_name,
)
post.content = kwargs["content"] # wikilink injection disabled; LLM adds [[]] via prompt
if "tags" in kwargs:
post["tags"] = kwargs["tags"]
if "importance" in kwargs:
@@ -252,6 +274,10 @@ class BucketManager:
post["pinned"] = bool(kwargs["pinned"])
if kwargs["pinned"]:
post["importance"] = 10 # pinned → lock importance to 10
if "digested" in kwargs:
post["digested"] = bool(kwargs["digested"])
if "model_valence" in kwargs:
post["model_valence"] = max(0.0, min(1.0, float(kwargs["model_valence"])))
# --- Auto-refresh activation time / 自动刷新激活时间 ---
post["last_active"] = now_iso()
@@ -263,136 +289,33 @@ class BucketManager:
logger.error(f"Failed to write bucket update / 写入桶更新失败: {file_path}: {e}")
return False
# --- Auto-move: pinned → permanent/, resolved → archive/ ---
# --- 自动移动:钉选 → permanent/,已解决 → archive/ ---
domain = post.get("domain", ["未分类"])
if kwargs.get("pinned") and post.get("type") != "permanent":
post["type"] = "permanent"
with open(file_path, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(post))
self._move_bucket(file_path, self.permanent_dir, domain)
elif kwargs.get("resolved") and post.get("type") not in ("permanent", "feel"):
post["type"] = "archived"
with open(file_path, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(post))
self._move_bucket(file_path, self.archive_dir, domain)
logger.info(f"Updated bucket / 更新记忆桶: {bucket_id}")
return True
# ---------------------------------------------------------
# Wikilink injection
# 自动添加 Obsidian 双链
# Wikilink injection — DISABLED
# 自动添加 Obsidian 双链 — 已禁用
# Now handled by LLM prompts (Gemini adds [[]] for proper nouns)
# 现在由 LLM prompt 处理Gemini 对人名/地名/专有名词加 [[]]
# ---------------------------------------------------------
def _apply_wikilinks(
self,
content: str,
tags: list[str],
domain: list[str],
name: str,
) -> str:
"""
Auto-inject Obsidian wikilinks, avoiding double-wrapping existing [[...]].
自动添加 Obsidian 双链,避免重复包裹已有 [[...]]。
"""
if not self.wikilink_enabled or not content:
return content
keywords = self._collect_wikilink_keywords(content, tags, domain, name)
if not keywords:
return content
# Split on existing wikilinks to avoid wrapping them again
# 按已有双链切分,避免重复包裹
segments = re.split(r"(\[\[[^\]]+\]\])", content)
pattern = re.compile("|".join(re.escape(kw) for kw in keywords))
for i, segment in enumerate(segments):
if segment.startswith("[[") and segment.endswith("]]"):
continue
updated = pattern.sub(lambda m: f"[[{m.group(0)}]]", segment)
segments[i] = updated
return "".join(segments)
def _collect_wikilink_keywords(
self,
content: str,
tags: list[str],
domain: list[str],
name: str,
) -> list[str]:
"""
Collect candidate keywords from tags/domain/auto-extraction.
汇总候选关键词:可选 tags/domain + 自动提词。
"""
candidates = []
if self.wikilink_use_tags:
candidates.extend(tags or [])
if self.wikilink_use_domain:
candidates.extend(domain or [])
if name:
candidates.append(name)
if self.wikilink_use_auto_keywords:
candidates.extend(self._extract_auto_keywords(content))
return self._normalize_keywords(candidates)
def _normalize_keywords(self, keywords: list[str]) -> list[str]:
"""
Deduplicate and sort by length (longer first to avoid short words
breaking long ones during replacement).
去重并按长度排序,优先替换长词。
"""
if not keywords:
return []
seen = set()
cleaned = []
for keyword in keywords:
if not isinstance(keyword, str):
continue
kw = keyword.strip()
if len(kw) < self.wikilink_min_len:
continue
if kw in self.wikilink_exclude_keywords:
continue
if kw.lower() in self.wikilink_stopwords:
continue
if kw in seen:
continue
seen.add(kw)
cleaned.append(kw)
return sorted(cleaned, key=len, reverse=True)
def _extract_auto_keywords(self, content: str) -> list[str]:
"""
Auto-extract keywords from body text, prioritizing high-frequency words.
从正文自动提词,优先高频词。
"""
if not content:
return []
try:
zh_words = [w.strip() for w in jieba.lcut(content) if w.strip()]
except Exception:
zh_words = []
en_words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,20}", content)
# Chinese bigrams / 中文双词组合
zh_bigrams = []
for i in range(len(zh_words) - 1):
left = zh_words[i]
right = zh_words[i + 1]
if len(left) < self.wikilink_min_len or len(right) < self.wikilink_min_len:
continue
if not re.fullmatch(r"[\u4e00-\u9fff]+", left + right):
continue
if len(left + right) > 8:
continue
zh_bigrams.append(left + right)
merged = []
for word in zh_words + zh_bigrams + en_words:
if len(word) < self.wikilink_min_len:
continue
if re.fullmatch(r"\d+", word):
continue
if word.lower() in self.wikilink_stopwords:
continue
merged.append(word)
if not merged:
return []
counter = Counter(merged)
return [w for w, _ in counter.most_common(self.wikilink_auto_top_k)]
# def _apply_wikilinks(self, content, tags, domain, name): ...
# def _collect_wikilink_keywords(self, content, tags, domain, name): ...
# def _normalize_keywords(self, keywords): ...
# def _extract_auto_keywords(self, content): ...
# ---------------------------------------------------------
# Delete bucket
@@ -425,7 +348,9 @@ class BucketManager:
async def touch(self, bucket_id: str) -> None:
"""
Update a bucket's last activation time and count.
Also triggers time ripple: nearby memories get a slight activation boost.
更新桶的最后激活时间和激活次数。
同时触发时间涟漪:时间上相邻的记忆轻微唤醒。
"""
file_path = self._find_bucket_file(bucket_id)
if not file_path:
@@ -438,9 +363,60 @@ class BucketManager:
with open(file_path, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(post))
# --- Time ripple: boost nearby memories within ±48h ---
# --- 时间涟漪±48小时内的记忆轻微唤醒 ---
current_time = datetime.fromisoformat(str(post.get("created", post.get("last_active", ""))))
await self._time_ripple(bucket_id, current_time)
except Exception as e:
logger.warning(f"Failed to touch bucket / 触碰桶失败: {bucket_id}: {e}")
async def _time_ripple(self, source_id: str, reference_time: datetime, hours: float = 48.0) -> None:
"""
Slightly boost activation_count of buckets created/activated near the reference time.
轻微提升时间相邻桶的激活次数(+0.3),不改 last_active 避免递归唤醒。
Max 5 buckets rippled per touch to bound I/O.
"""
try:
all_buckets = await self.list_all(include_archive=False)
except Exception:
return
rippled = 0
max_ripple = 5
for bucket in all_buckets:
if rippled >= max_ripple:
break
if bucket["id"] == source_id:
continue
meta = bucket.get("metadata", {})
# Skip pinned/permanent/feel
if meta.get("pinned") or meta.get("protected") or meta.get("type") in ("permanent", "feel"):
continue
created_str = meta.get("created", meta.get("last_active", ""))
try:
created = datetime.fromisoformat(str(created_str))
delta_hours = abs((reference_time - created).total_seconds()) / 3600
except (ValueError, TypeError):
continue
if delta_hours <= hours:
# Boost activation_count by 0.3 (fractional), don't change last_active
file_path = self._find_bucket_file(bucket["id"])
if not file_path:
continue
try:
post = frontmatter.load(file_path)
current_count = post.get("activation_count", 1)
# Store as float for fractional increments; calculate_score handles it
post["activation_count"] = round(current_count + 0.3, 1)
with open(file_path, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(post))
rippled += 1
except Exception:
continue
# ---------------------------------------------------------
# Multi-dimensional search (core feature)
# 多维搜索(核心功能)
@@ -576,7 +552,7 @@ class BucketManager:
)
content_score = fuzz.partial_ratio(query, bucket.get("content", "")[:1000]) * self.content_weight
return (name_score + domain_score + tag_score + content_score) / (100 * 10.5)
return (name_score + domain_score + tag_score + content_score) / (100 * (3 + 2.5 + 2 + self.content_weight))
# ---------------------------------------------------------
# Emotion resonance sub-score:
@@ -633,7 +609,7 @@ class BucketManager:
"""
buckets = []
dirs = [self.permanent_dir, self.dynamic_dir]
dirs = [self.permanent_dir, self.dynamic_dir, self.feel_dir]
if include_archive:
dirs.append(self.archive_dir)
@@ -664,6 +640,7 @@ class BucketManager:
"permanent_count": 0,
"dynamic_count": 0,
"archive_count": 0,
"feel_count": 0,
"total_size_kb": 0.0,
"domains": {},
}
@@ -672,6 +649,7 @@ class BucketManager:
(self.permanent_dir, "permanent_count"),
(self.dynamic_dir, "dynamic_count"),
(self.archive_dir, "archive_count"),
(self.feel_dir, "feel_count"),
]:
if not os.path.exists(subdir):
continue
@@ -745,7 +723,7 @@ class BucketManager:
"""
if not bucket_id:
return None
for dir_path in [self.permanent_dir, self.dynamic_dir, self.archive_dir]:
for dir_path in [self.permanent_dir, self.dynamic_dir, self.archive_dir, self.feel_dir]:
if not os.path.exists(dir_path):
continue
for root, _, files in os.walk(dir_path):
@@ -754,7 +732,8 @@ class BucketManager:
continue
# Match by exact ID segment in filename
# 通过文件名中的 ID 片段精确匹配
if bucket_id in fname:
name_part = fname[:-3] # remove .md
if name_part == bucket_id or name_part.endswith(f"_{bucket_id}"):
return os.path.join(root, fname)
return None

View File

@@ -58,6 +58,15 @@ decay:
base: 1.0 # Base weight / 基础权重
arousal_boost: 0.8 # Arousal boost coefficient / 唤醒度加成系数
# --- Embedding / 向量化配置 ---
# Uses embedding API for semantic similarity search
# 通过 embedding API 实现语义相似度搜索
# Reuses the same API key (OMBRE_API_KEY) and base_url from dehydration config
# 复用脱水配置的 API key 和 base_url
embedding:
enabled: true # Enable embedding / 启用向量化
model: "gemini-embedding-001" # Embedding model / 向量化模型
# --- Scoring weights / 检索权重参数 ---
# total = topic(×4) + emotion(×2) + time(×1.5) + importance(×1)
scoring_weights:
@@ -77,6 +86,6 @@ wikilink:
use_tags: false
use_domain: true
use_auto_keywords: true
auto_top_k: 8
min_keyword_len: 2
auto_top_k: 4
min_keyword_len: 3
exclude_keywords: []

1391
dashboard.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -70,93 +70,102 @@ class DecayEngine:
# Permanent buckets never decay / 固化桶永远不衰减
# ---------------------------------------------------------
# ---------------------------------------------------------
# Time weight: 0-1d→1.0, day2→0.9, then ~10%/day, floor 0.3
# 时间系数0-1天=1.0第2天=0.9之后每天约降10%7天后稳定在0.3
# Freshness bonus: continuous exponential decay
# 新鲜度加成:连续指数衰减
# bonus = 1.0 + 1.0 × e^(-t/36), t in hours
# t=0 → 2.0×, t≈25h(半衰) → 1.5×, t≈72h → ≈1.14×, t→∞ → 1.0×
# ---------------------------------------------------------
@staticmethod
def _calc_time_weight(days_since: float) -> float:
"""
Piecewise time weight multiplier (multiplies base_score).
分段式时间权重系数,作为 final_score 的乘数
Freshness bonus multiplier: 1.0 + e^(-t/36), t in hours.
新鲜度加成乘数刚存入×2.0~36小时半衰72小时后趋近×1.0
"""
if days_since <= 1.0:
return 1.0
elif days_since <= 2.0:
# Linear interpolation: 1.0→0.9 over [1,2]
return 1.0 - 0.1 * (days_since - 1.0)
else:
# Exponential decay from 0.9, floor at 0.3
# k = ln(3)/5 ≈ 0.2197 so that at day 7 (5 days past day 2) → 0.3
raw = 0.9 * math.exp(-0.2197 * (days_since - 2.0))
return max(0.3, raw)
hours = days_since * 24.0
return 1.0 + 1.0 * math.exp(-hours / 36.0)
def calculate_score(self, metadata: dict) -> float:
"""
Calculate current activity score for a memory bucket.
计算一个记忆桶的当前活跃度得分。
Formula: final_score = time_weight × base_score
base_score = Importance × (act_count^0.3) × e^(-λ×days) × (base + arousal×boost)
time_weight is the outer multiplier, takes priority over emotion factors.
New model: short-term vs long-term weight separation.
新模型:短期/长期权重分离。
- Short-term (≤3 days): time_weight dominates, emotion amplifies
- Long-term (>3 days): emotion_weight dominates, time decays to floor
短期≤3天时间权重主导情感放大
长期(>3天情感权重主导时间衰减到底线
"""
if not isinstance(metadata, dict):
return 0.0
# --- Pinned/protected buckets: never decay, importance locked to 10 ---
# --- 固化桶pinned/protected永不衰减importance 锁定为 10 ---
if metadata.get("pinned") or metadata.get("protected"):
return 999.0
# --- Permanent buckets never decay / 固化桶永不衰减 ---
# --- Permanent buckets never decay ---
if metadata.get("type") == "permanent":
return 999.0
# --- Feel buckets: never decay, fixed moderate score ---
if metadata.get("type") == "feel":
return 50.0
importance = max(1, min(10, int(metadata.get("importance", 5))))
activation_count = max(1, int(metadata.get("activation_count", 1)))
# --- Days since last activation / 距离上次激活过了多少天 ---
# --- Days since last activation ---
last_active_str = metadata.get("last_active", metadata.get("created", ""))
try:
last_active = datetime.fromisoformat(str(last_active_str))
days_since = max(0.0, (datetime.now() - last_active).total_seconds() / 86400)
except (ValueError, TypeError):
days_since = 30 # Parse failure → assume 30 days / 解析失败假设已过 30 天
days_since = 30
# --- Emotion weight: continuous arousal coordinate ---
# --- 情感权重:基于连续 arousal 坐标计算 ---
# Higher arousal → stronger emotion → higher weight → slower decay
# arousal 越高 → 情感越强烈 → 权重越大 → 衰减越慢
# --- Emotion weight ---
try:
arousal = max(0.0, min(1.0, float(metadata.get("arousal", 0.3))))
except (ValueError, TypeError):
arousal = 0.3
emotion_weight = self.emotion_base + arousal * self.arousal_boost
# --- Time weight (outer multiplier, highest priority) ---
# --- 时间权重(外层乘数,优先级最高)---
# --- Time weight ---
time_weight = self._calc_time_weight(days_since)
# --- Base score = Importance × act_count^0.3 × e^(-λ×days) × emotion ---
# --- 基础得分 ---
# --- Short-term vs Long-term weight separation ---
# 短期≤3天time_weight 占 70%emotion 占 30%
# 长期(>3天emotion 占 70%time_weight 占 30%
if days_since <= 3.0:
# Short-term: time dominates, emotion amplifies
combined_weight = time_weight * 0.7 + emotion_weight * 0.3
else:
# Long-term: emotion dominates, time provides baseline
combined_weight = emotion_weight * 0.7 + time_weight * 0.3
# --- Base score ---
base_score = (
importance
* (activation_count ** 0.3)
* math.exp(-self.decay_lambda * days_since)
* emotion_weight
* combined_weight
)
# --- final_score = time_weight × base_score ---
score = time_weight * base_score
# --- Weight pool modifiers ---
# resolved + digested (has feel) → accelerated fade: ×0.02
# resolved only → ×0.05
# 已处理+已消化写过feel→ 加速淡化×0.02
# 仅已处理 → ×0.05
resolved = metadata.get("resolved", False)
digested = metadata.get("digested", False) # set when feel is written for this memory
if resolved and digested:
resolved_factor = 0.02
elif resolved:
resolved_factor = 0.05
else:
resolved_factor = 1.0
urgency_boost = 1.5 if (arousal > 0.7 and not resolved) else 1.0
# --- Weight pool modifiers / 权重池修正因子 ---
# Resolved events drop to 5%, sink to bottom awaiting keyword reactivation
# 已解决的事件权重骤降到 5%,沉底等待关键词激活
resolved_factor = 0.05 if metadata.get("resolved", False) else 1.0
# High-arousal unresolved buckets get urgency boost for priority surfacing
# 高唤醒未解决桶额外加成,优先浮现
urgency_boost = 1.5 if (arousal > 0.7 and not metadata.get("resolved", False)) else 1.0
return round(score * resolved_factor * urgency_boost, 4)
return round(base_score * resolved_factor * urgency_boost, 4)
# ---------------------------------------------------------
# Execute one decay cycle
@@ -180,17 +189,41 @@ class DecayEngine:
checked = 0
archived = 0
auto_resolved = 0
lowest_score = float("inf")
for bucket in buckets:
meta = bucket.get("metadata", {})
# Skip permanent / pinned / protected buckets
# 跳过固化桶钉选/保护桶
if meta.get("type") == "permanent" or meta.get("pinned") or meta.get("protected"):
# Skip permanent / pinned / protected / feel buckets
# 跳过固化桶钉选/保护桶和 feel
if meta.get("type") in ("permanent", "feel") or meta.get("pinned") or meta.get("protected"):
continue
checked += 1
# --- Auto-resolve: imp≤4 + >30 days old + not resolved → auto resolve ---
# --- 自动结案重要度≤4 + 超过30天 + 未解决 → 自动 resolve ---
if not meta.get("resolved", False):
imp = int(meta.get("importance", 5))
last_active_str = meta.get("last_active", meta.get("created", ""))
try:
last_active = datetime.fromisoformat(str(last_active_str))
days_since = (datetime.now() - last_active).total_seconds() / 86400
except (ValueError, TypeError):
days_since = 999
if imp <= 4 and days_since > 30:
try:
await self.bucket_mgr.update(bucket["id"], resolved=True)
auto_resolved += 1
logger.info(
f"Auto-resolved / 自动结案: "
f"{meta.get('name', bucket['id'])} "
f"(imp={imp}, days={days_since:.0f})"
)
except Exception as e:
logger.warning(f"Auto-resolve failed / 自动结案失败: {e}")
try:
score = self.calculate_score(meta)
except Exception as e:
@@ -223,6 +256,7 @@ class DecayEngine:
result = {
"checked": checked,
"archived": archived,
"auto_resolved": auto_resolved,
"lowest_score": lowest_score if checked > 0 else 0,
}
logger.info(f"Decay cycle complete / 衰减周期完成: {result}")

View File

@@ -67,6 +67,9 @@ DIGEST_PROMPT = """你是一个日记整理专家。用户会发送一段包含
3. 去除无意义的口水话和重复信息,保留核心内容
4. 同一主题的零散信息应合并为一个条目
5. 如果有待办事项,单独提取为一个条目
6. 单个条目内容不少于50字过短的零碎信息合并到最相关的条目中
7. 总条目数控制在 2~6 个,避免过度碎片化
8. 在 content 中对人名、地名、专有名词用 [[双链]] 标记(如 [[婷易]]、[[Obsidian]]),普通词汇不要加
输出格式(纯 JSON 数组,无其他内容):
[
@@ -76,11 +79,13 @@ DIGEST_PROMPT = """你是一个日记整理专家。用户会发送一段包含
"domain": ["主题域1"],
"valence": 0.7,
"arousal": 0.4,
"tags": ["标签1", "标签2"],
"tags": ["核心词1", "核心词2", "扩展词1", "扩展词2"],
"importance": 5
}
]
tags 生成规则:先从原文精准提取 3~5 个核心词,再引申扩展 5~8 个语义相关词(近义词、上位词、关联场景词),合并为一个数组。
主题域可选(选最精确的 1~2 个,只选真正相关的):
日常: ["饮食", "穿搭", "出行", "居家", "购物"]
人际: ["家庭", "恋爱", "友谊", "社交"]
@@ -104,6 +109,7 @@ MERGE_PROMPT = """你是一个信息合并专家。请将旧记忆与新内容
2. 去除重复信息
3. 保留所有重要事实
4. 总长度尽量不超过旧记忆的 120%
5. 对出现的人名、地名、专有名词用 [[双链]] 标记(如 [[婷易]]、[[Obsidian]]),普通词汇不要加
直接输出合并后的文本,不要加额外说明。"""
@@ -124,15 +130,19 @@ ANALYZE_PROMPT = """你是一个内容分析器。请分析以下文本,输出
内心: ["情绪", "回忆", "梦境", "自省"]
2. valence情感效价0.0~1.00=极度消极 → 0.5=中性 → 1.0=极度积极
3. arousal情感唤醒度0.0~1.00=非常平静 → 0.5=普通 → 1.0=非常激动
4. tags关键词标签3~5 个最能概括内容的关键词
4. tags关键词标签分两步生成,合并为一个数组:
第一步—精准提取:从原文抽取 3~5 个真正的核心词,不泛化、不遗漏
第二步—引申扩展:自动补充 8~10 个与当前场景语义相关的词,包括近义词、上位词、关联场景词、用户可能用不同措辞搜索的词
两步合并为一个 tags 数组,总计 10~15 个
5. suggested_name建议桶名10字以内的简短标题
6. 在 tags 和 suggested_name 中不要使用 [[]] 双链标记
输出格式(纯 JSON无其他内容
{
"domain": ["主题域1", "主题域2"],
"valence": 0.7,
"arousal": 0.4,
"tags": ["标签1", "标签2", "标签3"],
"tags": ["核心词1", "核心词2", "扩展词1", "扩展词2", "..."],
"suggested_name": "简短标题"
}"""
@@ -214,20 +224,18 @@ class Dehydrator:
if not new_content:
return old_content
# --- Try API merge first / 优先 API 合并 ---
if self.api_available:
try:
result = await self._api_merge(old_content, new_content)
if result:
return result
except Exception as e:
logger.warning(
f"API merge failed, degrading to local / "
f"API 合并失败,降级到本地合并: {e}"
)
# --- Local merge fallback / 本地合并兜底 ---
return self._local_merge(old_content, new_content)
# --- API merge (no local fallback) ---
if not self.api_available:
raise RuntimeError("脱水 API 不可用,请检查 config.yaml 中的 dehydration 配置")
try:
result = await self._api_merge(old_content, new_content)
if result:
return result
raise RuntimeError("API 合并返回空结果")
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"API 合并失败,请检查 API 连接: {e}") from e
# ---------------------------------------------------------
# API call: dehydration
@@ -314,24 +322,6 @@ class Dehydrator:
summary = summary[:1000] + ""
return summary
# ---------------------------------------------------------
# Local merge (simple concatenation + truncation)
# 本地合并(简单拼接 + 截断)
# ---------------------------------------------------------
def _local_merge(self, old_content: str, new_content: str) -> str:
"""
Simple concatenation merge; truncates if too long.
简单拼接合并,超长时截断保留两端。
"""
merged = f"{old_content.strip()}\n\n--- 更新 ---\n{new_content.strip()}"
# Truncate if over 3000 chars / 超过 3000 字符则各取一半
if len(merged) > 3000:
half = 1400
merged = (
f"{old_content[:half].strip()}\n\n--- 更新 ---\n{new_content[:half].strip()}"
)
return merged
# ---------------------------------------------------------
# Keyword extraction
# 关键词提取
@@ -391,6 +381,15 @@ class Dehydrator:
if domains:
header += f" [主题:{domains}]"
header += f" [情感:V{valence:.1f}/A{arousal:.1f}]"
# Show model's perspective if available (valence drift)
model_v = metadata.get("model_valence")
if model_v is not None:
try:
header += f" [我的视角:V{float(model_v):.1f}]"
except (ValueError, TypeError):
pass
if metadata.get("digested"):
header += " [已消化]"
header += "\n"
content = re.sub(r'\[\[([^\]]+)\]\]', r'\1', content)
@@ -412,20 +411,18 @@ class Dehydrator:
if not content or not content.strip():
return self._default_analysis()
# --- Try API first (best quality) / 优先走 API ---
if self.api_available:
try:
result = await self._api_analyze(content)
if result:
return result
except Exception as e:
logger.warning(
f"API tagging failed, degrading to local / "
f"API 打标失败,降级到本地分析: {e}"
)
# --- Local analysis fallback / 本地分析兜底 ---
return self._local_analyze(content)
# --- API analyze (no local fallback) ---
if not self.api_available:
raise RuntimeError("脱水 API 不可用,请检查 config.yaml 中的 dehydration 配置")
try:
result = await self._api_analyze(content)
if result:
return result
raise RuntimeError("API 打标返回空结果")
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"API 打标失败,请检查 API 连接: {e}") from e
# ---------------------------------------------------------
# API call: auto-tagging
@@ -487,121 +484,10 @@ class Dehydrator:
"domain": result.get("domain", ["未分类"])[:3],
"valence": valence,
"arousal": arousal,
"tags": result.get("tags", [])[:5],
"tags": result.get("tags", [])[:15],
"suggested_name": str(result.get("suggested_name", ""))[:20],
}
# ---------------------------------------------------------
# Local analysis (fallback when API is unavailable)
# 本地分析(无 API 时的兜底方案)
# Keyword matching + simple sentiment dictionary
# 基于关键词 + 简单情感词典匹配
# ---------------------------------------------------------
def _local_analyze(self, content: str) -> dict:
"""
Local keyword + sentiment dictionary analysis.
本地关键词 + 情感词典的简单分析。
"""
keywords = self._extract_keywords(content)
text_lower = content.lower()
# --- Domain matching by keyword hits ---
# --- 主题域匹配:基于关键词命中 ---
domain_keywords = {
# Daily / 日常
"饮食": {"", "", "做饭", "外卖", "奶茶", "咖啡", "麻辣烫", "面包",
"超市", "零食", "水果", "牛奶", "食堂", "减肥", "节食"},
"出行": {"旅行", "出发", "航班", "酒店", "地铁", "打车", "高铁", "机票",
"景点", "签证", "护照"},
"居家": {"打扫", "洗衣", "搬家", "快递", "收纳", "装修", "租房"},
"购物": {"", "下单", "到货", "退货", "优惠", "折扣", "代购"},
# Relationships / 人际
"家庭": {"", "", "父亲", "母亲", "家人", "弟弟", "姐姐", "哥哥",
"奶奶", "爷爷", "亲戚", "家里"},
"恋爱": {"爱人", "男友", "女友", "", "约会", "接吻", "分手",
"暧昧", "在一起", "想你", "同床"},
"友谊": {"朋友", "闺蜜", "兄弟", "", "约饭", "聊天", ""},
"社交": {"见面", "被人", "圈子", "消息", "评论", "点赞"},
# Growth / 成长
"工作": {"会议", "项目", "客户", "汇报", "deadline", "同事",
"老板", "薪资", "合同", "需求", "加班", "实习"},
"学习": {"", "考试", "论文", "笔记", "作业", "教授", "讲座",
"分数", "选课", "学分"},
"求职": {"面试", "简历", "offer", "投递", "薪资", "岗位"},
# Health / 身心
"健康": {"医院", "复查", "吃药", "抽血", "手术", "心率",
"", "症状", "指标", "体检", "月经"},
"心理": {"焦虑", "抑郁", "恐慌", "创伤", "人格", "咨询",
"安全感", "自残", "崩溃", "压力"},
"睡眠": {"", "失眠", "噩梦", "清醒", "熬夜", "早起", "午觉"},
# Interests / 兴趣
"游戏": {"游戏", "steam", "极乐迪斯科", "存档", "通关", "角色",
"mod", "DLC", "剧情"},
"影视": {"电影", "番剧", "动漫", "", "综艺", "追番", "上映"},
"音乐": {"", "音乐", "专辑", "live", "演唱会", "耳机"},
"阅读": {"", "小说", "读完", "kindle", "连载", "漫画"},
"创作": {"", "", "预设", "脚本", "视频", "剪辑", "P图",
"SillyTavern", "插件", "正则", "人设"},
# Digital / 数字
"编程": {"代码", "code", "python", "bug", "api", "docker",
"git", "调试", "框架", "部署", "开发", "server"},
"AI": {"模型", "GPT", "Claude", "gemini", "LLM", "token",
"prompt", "LoRA", "微调", "推理", "MCP"},
"网络": {"VPN", "梯子", "代理", "域名", "隧道", "服务器",
"cloudflare", "tunnel", "反代"},
# Affairs / 事务
"财务": {"", "转账", "工资", "花了", "", "还款", "",
"账单", "余额", "预算", "黄金"},
"计划": {"计划", "目标", "deadline", "日程", "清单", "安排"},
"待办": {"要做", "记得", "别忘", "提醒", "下次"},
# Inner / 内心
"情绪": {"开心", "难过", "生气", "", "", "孤独", "幸福",
"伤心", "", "委屈", "感动", "温柔"},
"回忆": {"以前", "小时候", "那时", "怀念", "曾经", "记得"},
"梦境": {"", "梦到", "梦见", "噩梦", "清醒梦"},
"自省": {"反思", "觉得自己", "问自己", "意识到", "明白了"},
}
matched_domains = []
for domain, kws in domain_keywords.items():
hits = sum(1 for kw in kws if kw in text_lower)
if hits >= 2:
matched_domains.append((domain, hits))
matched_domains.sort(key=lambda x: x[1], reverse=True)
domains = [d for d, _ in matched_domains[:3]] or ["未分类"]
# --- Emotion estimation via simple sentiment dictionary ---
# --- 情感坐标估算:基于简单情感词典 ---
positive_words = {"开心", "高兴", "喜欢", "哈哈", "", "", "",
"幸福", "成功", "感动", "兴奋", "棒极了",
"happy", "love", "great", "awesome", "nice"}
negative_words = {"难过", "伤心", "生气", "焦虑", "害怕", "无聊",
"", "", "失望", "崩溃", "愤怒", "痛苦",
"sad", "angry", "hate", "tired", "afraid"}
intense_words = {"", "非常", "", "", "特别", "十分", "",
"崩溃", "激动", "愤怒", "狂喜", "very", "so", "extremely"}
pos_count = sum(1 for w in positive_words if w in text_lower)
neg_count = sum(1 for w in negative_words if w in text_lower)
intense_count = sum(1 for w in intense_words if w in text_lower)
# valence: positive/negative emotion balance
if pos_count + neg_count > 0:
valence = 0.5 + 0.4 * (pos_count - neg_count) / (pos_count + neg_count)
else:
valence = 0.5
# arousal: intensity level
arousal = min(1.0, 0.3 + intense_count * 0.15 + (pos_count + neg_count) * 0.08)
return {
"domain": domains,
"valence": round(max(0.0, min(1.0, valence)), 2),
"arousal": round(max(0.0, min(1.0, arousal)), 2),
"tags": keywords[:5],
"suggested_name": "",
}
# ---------------------------------------------------------
# Default analysis result (empty content or total failure)
# 默认分析结果(内容为空或完全失败时用)
@@ -635,21 +521,18 @@ class Dehydrator:
if not content or not content.strip():
return []
# --- Try API digest first (best quality, understands semantic splits) ---
# --- 优先 API 整理 ---
if self.api_available:
try:
result = await self._api_digest(content)
if result:
return result
except Exception as e:
logger.warning(
f"API diary digest failed, degrading to local / "
f"API 日记整理失败,降级到本地拆分: {e}"
)
# --- Local split fallback / 本地拆分兜底 ---
return await self._local_digest(content)
# --- API digest (no local fallback) ---
if not self.api_available:
raise RuntimeError("脱水 API 不可用,请检查 config.yaml 中的 dehydration 配置")
try:
result = await self._api_digest(content)
if result:
return result
raise RuntimeError("API 日记整理返回空结果")
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"API 日记整理失败,请检查 API 连接: {e}") from e
# ---------------------------------------------------------
# API call: diary digest
@@ -667,7 +550,7 @@ class Dehydrator:
{"role": "user", "content": content[:5000]},
],
max_tokens=2048,
temperature=0.2,
temperature=0.0,
)
if not response.choices:
return []
@@ -717,50 +600,7 @@ class Dehydrator:
"domain": item.get("domain", ["未分类"])[:3],
"valence": valence,
"arousal": arousal,
"tags": item.get("tags", [])[:5],
"tags": item.get("tags", [])[:15],
"importance": importance,
})
return validated
# ---------------------------------------------------------
# Local diary split (fallback when API is unavailable)
# 本地日记拆分(无 API 时的兜底)
# Split by blank lines/separators, analyze each segment
# 按空行/分隔符拆段,每段独立分析
# ---------------------------------------------------------
async def _local_digest(self, content: str) -> list[dict]:
"""
Local paragraph split + per-segment analysis.
本地按段落拆分 + 逐段分析。
"""
# Split by blank lines or separators / 按空行或分隔线拆分
segments = re.split(r"\n{2,}|---+|\n-\s", content)
segments = [s.strip() for s in segments if len(s.strip()) > 20]
if not segments:
# Content too short, treat as single entry
# 内容太短,整个作为一个条目
analysis = self._local_analyze(content)
return [{
"name": analysis.get("suggested_name", "日记"),
"content": content.strip(),
"domain": analysis["domain"],
"valence": analysis["valence"],
"arousal": analysis["arousal"],
"tags": analysis["tags"],
"importance": 5,
}]
items = []
for seg in segments[:10]: # Max 10 segments / 最多 10 段
analysis = self._local_analyze(seg)
items.append({
"name": analysis.get("suggested_name", "") or seg[:10],
"content": seg,
"domain": analysis["domain"],
"valence": analysis["valence"],
"arousal": analysis["arousal"],
"tags": analysis["tags"],
"importance": 5,
})
return items

25
docker-compose.user.yml Normal file
View File

@@ -0,0 +1,25 @@
# ============================================================
# Ombre Brain — 用户快速部署版
# User Quick Deploy (pre-built image, no local build needed)
#
# 使用方法 / Usage:
# 1. 创建 .env: echo "OMBRE_API_KEY=your-key" > .env
# 2. 按需修改下面的 volumes 路径
# 3. docker compose -f docker-compose.user.yml up -d
# ============================================================
services:
ombre-brain:
image: p0luz/ombre-brain:latest
container_name: ombre-brain
restart: unless-stopped
ports:
- "8000:8000"
environment:
- OMBRE_API_KEY=${OMBRE_API_KEY}
- OMBRE_TRANSPORT=streamable-http
- OMBRE_BUCKETS_DIR=/data
volumes:
# 改成你的 Obsidian Vault 路径,或保持 ./buckets 用本地目录
# Change to your Obsidian Vault path, or keep ./buckets for local storage
- ./buckets:/data

188
embedding_engine.py Normal file
View File

@@ -0,0 +1,188 @@
# ============================================================
# Module: Embedding Engine (embedding_engine.py)
# 模块:向量化引擎
#
# Generates embeddings via Gemini API (OpenAI-compatible),
# stores them in SQLite, and provides cosine similarity search.
# 通过 Gemini APIOpenAI 兼容)生成 embedding
# 存储在 SQLite 中,提供余弦相似度搜索。
#
# Depended on by: server.py, bucket_manager.py
# 被谁依赖server.py, bucket_manager.py
# ============================================================
import os
import json
import math
import sqlite3
import logging
import asyncio
from pathlib import Path
from openai import AsyncOpenAI
logger = logging.getLogger("ombre_brain.embedding")
class EmbeddingEngine:
"""
Embedding generation + SQLite vector storage + cosine search.
向量生成 + SQLite 向量存储 + 余弦搜索。
"""
def __init__(self, config: dict):
dehy_cfg = config.get("dehydration", {})
embed_cfg = config.get("embedding", {})
self.api_key = dehy_cfg.get("api_key", "")
self.base_url = dehy_cfg.get("base_url", "https://generativelanguage.googleapis.com/v1beta/openai/")
self.model = embed_cfg.get("model", "gemini-embedding-001")
self.enabled = bool(self.api_key) and embed_cfg.get("enabled", True)
# --- SQLite path: buckets_dir/embeddings.db ---
db_path = os.path.join(config["buckets_dir"], "embeddings.db")
self.db_path = db_path
# --- Initialize client ---
if self.enabled:
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
timeout=30.0,
)
else:
self.client = None
# --- Initialize SQLite ---
self._init_db()
def _init_db(self):
"""Create embeddings table if not exists."""
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
conn = sqlite3.connect(self.db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS embeddings (
bucket_id TEXT PRIMARY KEY,
embedding TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.commit()
conn.close()
async def generate_and_store(self, bucket_id: str, content: str) -> bool:
"""
Generate embedding for content and store in SQLite.
为内容生成 embedding 并存入 SQLite。
Returns True on success, False on failure.
"""
if not self.enabled or not content or not content.strip():
return False
try:
embedding = await self._generate_embedding(content)
if not embedding:
return False
self._store_embedding(bucket_id, embedding)
return True
except Exception as e:
logger.warning(f"Embedding generation failed for {bucket_id}: {e}")
return False
async def _generate_embedding(self, text: str) -> list[float]:
"""Call API to generate embedding vector."""
# Truncate to avoid token limits
truncated = text[:2000]
try:
response = await self.client.embeddings.create(
model=self.model,
input=truncated,
)
if response.data and len(response.data) > 0:
return response.data[0].embedding
return []
except Exception as e:
logger.warning(f"Embedding API call failed: {e}")
return []
def _store_embedding(self, bucket_id: str, embedding: list[float]):
"""Store embedding in SQLite."""
from utils import now_iso
conn = sqlite3.connect(self.db_path)
conn.execute(
"INSERT OR REPLACE INTO embeddings (bucket_id, embedding, updated_at) VALUES (?, ?, ?)",
(bucket_id, json.dumps(embedding), now_iso()),
)
conn.commit()
conn.close()
def delete_embedding(self, bucket_id: str):
"""Remove embedding when bucket is deleted."""
conn = sqlite3.connect(self.db_path)
conn.execute("DELETE FROM embeddings WHERE bucket_id = ?", (bucket_id,))
conn.commit()
conn.close()
async def get_embedding(self, bucket_id: str) -> list[float] | None:
"""Retrieve stored embedding for a bucket. Returns None if not found."""
conn = sqlite3.connect(self.db_path)
row = conn.execute(
"SELECT embedding FROM embeddings WHERE bucket_id = ?", (bucket_id,)
).fetchone()
conn.close()
if row:
try:
return json.loads(row[0])
except json.JSONDecodeError:
return None
return None
async def search_similar(self, query: str, top_k: int = 10) -> list[tuple[str, float]]:
"""
Search for buckets similar to query text.
Returns list of (bucket_id, similarity_score) sorted by score desc.
搜索与查询文本相似的桶。返回 (bucket_id, 相似度分数) 列表。
"""
if not self.enabled:
return []
try:
query_embedding = await self._generate_embedding(query)
if not query_embedding:
return []
except Exception as e:
logger.warning(f"Query embedding failed: {e}")
return []
# Load all embeddings from SQLite
conn = sqlite3.connect(self.db_path)
rows = conn.execute("SELECT bucket_id, embedding FROM embeddings").fetchall()
conn.close()
if not rows:
return []
# Calculate cosine similarity
results = []
for bucket_id, emb_json in rows:
try:
stored_embedding = json.loads(emb_json)
sim = self._cosine_similarity(query_embedding, stored_embedding)
results.append((bucket_id, sim))
except (json.JSONDecodeError, Exception):
continue
results.sort(key=lambda x: x[1], reverse=True)
return results[:top_k]
@staticmethod
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""Calculate cosine similarity between two vectors."""
if len(a) != len(b) or not a:
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)

758
import_memory.py Normal file
View File

@@ -0,0 +1,758 @@
# ============================================================
# Module: Memory Import Engine (import_memory.py)
# 模块:历史记忆导入引擎
#
# Imports conversation history from various platforms into OB.
# 将各平台对话历史导入 OB 记忆系统。
#
# Supports: Claude JSON, ChatGPT export, DeepSeek, Markdown, plain text
# 支持格式Claude JSON、ChatGPT 导出、DeepSeek、Markdown、纯文本
#
# Features:
# - Chunked processing with resume support
# - Progress persistence (import_state.json)
# - Raw preservation mode for special contexts
# - Post-import frequency pattern detection
# ============================================================
import os
import json
import hashlib
import logging
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Optional
from utils import count_tokens_approx, now_iso
logger = logging.getLogger("ombre_brain.import")
# ============================================================
# Format Parsers — normalize any format to conversation turns
# 格式解析器 — 将任意格式标准化为对话轮次
# ============================================================
def _parse_claude_json(data: dict | list) -> list[dict]:
"""Parse Claude.ai export JSON → [{role, content, timestamp}, ...]"""
turns = []
conversations = data if isinstance(data, list) else [data]
for conv in conversations:
messages = conv.get("chat_messages", conv.get("messages", []))
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("text", msg.get("content", ""))
if isinstance(content, list):
content = " ".join(
p.get("text", "") for p in content if isinstance(p, dict)
)
if not content or not content.strip():
continue
role = msg.get("sender", msg.get("role", "user"))
ts = msg.get("created_at", msg.get("timestamp", ""))
turns.append({"role": role, "content": content.strip(), "timestamp": ts})
return turns
def _parse_chatgpt_json(data: list | dict) -> list[dict]:
"""Parse ChatGPT export JSON → [{role, content, timestamp}, ...]"""
turns = []
conversations = data if isinstance(data, list) else [data]
for conv in conversations:
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,
)
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 = " ".join(str(p) for p in content_parts if p)
if not content.strip():
continue
role = msg.get("author", {}).get("role", "user")
ts = msg.get("create_time", "")
if isinstance(ts, (int, float)):
ts = datetime.fromtimestamp(ts).isoformat()
turns.append({"role": role, "content": content.strip(), "timestamp": str(ts)})
else:
# Simpler format: list of messages
messages = conv.get("messages", [])
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content", msg.get("text", ""))
if isinstance(content, dict):
content = " ".join(str(p) for p in content.get("parts", []))
if not content or not content.strip():
continue
role = msg.get("role", msg.get("author", {}).get("role", "user"))
ts = msg.get("timestamp", msg.get("create_time", ""))
turns.append({"role": role, "content": content.strip(), "timestamp": str(ts)})
return turns
def _parse_markdown(text: str) -> list[dict]:
"""Parse Markdown/plain text → [{role, content, timestamp}, ...]"""
# Try to detect conversation patterns
lines = text.split("\n")
turns = []
current_role = "user"
current_content = []
for line in lines:
stripped = line.strip()
# Detect role switches
if stripped.lower().startswith(("human:", "user:", "你:", "我:")):
if current_content:
turns.append({"role": current_role, "content": "\n".join(current_content).strip(), "timestamp": ""})
current_role = "user"
content_after = stripped.split(":", 1)[1].strip() if ":" in stripped else ""
current_content = [content_after] if content_after else []
elif stripped.lower().startswith(("assistant:", "claude:", "ai:", "gpt:", "bot:", "deepseek:")):
if current_content:
turns.append({"role": current_role, "content": "\n".join(current_content).strip(), "timestamp": ""})
current_role = "assistant"
content_after = stripped.split(":", 1)[1].strip() if ":" in stripped else ""
current_content = [content_after] if content_after else []
else:
current_content.append(line)
if current_content:
content = "\n".join(current_content).strip()
if content:
turns.append({"role": current_role, "content": content, "timestamp": ""})
# If no role patterns detected, treat entire text as one big chunk
if not turns:
turns = [{"role": "user", "content": text.strip(), "timestamp": ""}]
return turns
def detect_and_parse(raw_content: str, filename: str = "") -> list[dict]:
"""
Auto-detect format and parse to normalized turns.
自动检测格式并解析为标准化的对话轮次。
"""
ext = Path(filename).suffix.lower() if filename else ""
# Try JSON first
if ext in (".json", "") or raw_content.strip().startswith(("{", "[")):
try:
data = json.loads(raw_content)
# Detect Claude vs ChatGPT format
if isinstance(data, list):
sample = data[0] if data else {}
else:
sample = data
if isinstance(sample, dict):
if "chat_messages" in sample:
return _parse_claude_json(data)
if "mapping" in sample:
return _parse_chatgpt_json(data)
if "messages" in sample:
# Could be either — try ChatGPT first, fall back to Claude
msgs = sample["messages"]
if msgs and isinstance(msgs[0], dict) and "content" in msgs[0]:
if isinstance(msgs[0]["content"], dict):
return _parse_chatgpt_json(data)
return _parse_claude_json(data)
# 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):
pass
# Fall back to markdown/text
return _parse_markdown(raw_content)
# ============================================================
# Chunking — split turns into ~10k token windows
# 分窗 — 按对话轮次边界切为 ~10k token 窗口
# ============================================================
def chunk_turns(turns: list[dict], target_tokens: int = 10000) -> list[dict]:
"""
Group conversation turns into chunks of ~target_tokens.
Returns list of {content, timestamp_start, timestamp_end, turn_count}.
按对话轮次边界将对话分为 ~target_tokens 大小的窗口。
"""
chunks = []
current_lines = []
current_tokens = 0
first_ts = ""
last_ts = ""
turn_count = 0
for turn in turns:
role_label = "用户" if turn["role"] in ("user", "human") else "AI"
line = f"[{role_label}] {turn['content']}"
line_tokens = count_tokens_approx(line)
# If single turn exceeds target, split it
if line_tokens > target_tokens * 1.5:
# Flush current
if current_lines:
chunks.append({
"content": "\n".join(current_lines),
"timestamp_start": first_ts,
"timestamp_end": last_ts,
"turn_count": turn_count,
})
current_lines = []
current_tokens = 0
turn_count = 0
first_ts = ""
# Add oversized turn as its own chunk
chunks.append({
"content": line,
"timestamp_start": turn.get("timestamp", ""),
"timestamp_end": turn.get("timestamp", ""),
"turn_count": 1,
})
continue
if current_tokens + line_tokens > target_tokens and current_lines:
chunks.append({
"content": "\n".join(current_lines),
"timestamp_start": first_ts,
"timestamp_end": last_ts,
"turn_count": turn_count,
})
current_lines = []
current_tokens = 0
turn_count = 0
first_ts = ""
if not first_ts:
first_ts = turn.get("timestamp", "")
last_ts = turn.get("timestamp", "")
current_lines.append(line)
current_tokens += line_tokens
turn_count += 1
if current_lines:
chunks.append({
"content": "\n".join(current_lines),
"timestamp_start": first_ts,
"timestamp_end": last_ts,
"turn_count": turn_count,
})
return chunks
# ============================================================
# Import State — persistent progress tracking
# 导入状态 — 持久化进度追踪
# ============================================================
class ImportState:
"""Manages import progress with file-based persistence."""
def __init__(self, state_dir: str):
self.state_file = os.path.join(state_dir, "import_state.json")
self.data = {
"source_file": "",
"source_hash": "",
"total_chunks": 0,
"processed": 0,
"api_calls": 0,
"memories_created": 0,
"memories_merged": 0,
"memories_raw": 0,
"errors": [],
"status": "idle", # idle | running | paused | completed | error
"started_at": "",
"updated_at": "",
}
def load(self) -> bool:
"""Load state from file. Returns True if state exists."""
if os.path.exists(self.state_file):
try:
with open(self.state_file, "r", encoding="utf-8") as f:
saved = json.load(f)
self.data.update(saved)
return True
except (json.JSONDecodeError, OSError):
return False
return False
def save(self):
"""Persist state to file."""
self.data["updated_at"] = now_iso()
os.makedirs(os.path.dirname(self.state_file), exist_ok=True)
tmp = self.state_file + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.data, f, ensure_ascii=False, indent=2)
os.replace(tmp, self.state_file)
def reset(self, source_file: str, source_hash: str, total_chunks: int):
"""Reset state for a new import."""
self.data = {
"source_file": source_file,
"source_hash": source_hash,
"total_chunks": total_chunks,
"processed": 0,
"api_calls": 0,
"memories_created": 0,
"memories_merged": 0,
"memories_raw": 0,
"errors": [],
"status": "running",
"started_at": now_iso(),
"updated_at": now_iso(),
}
@property
def can_resume(self) -> bool:
return self.data["status"] in ("paused", "running") and self.data["processed"] < self.data["total_chunks"]
def to_dict(self) -> dict:
return dict(self.data)
# ============================================================
# Import extraction prompt
# 导入提取提示词
# ============================================================
IMPORT_EXTRACT_PROMPT = """你是一个对话记忆提取专家。从以下对话片段中提取值得长期记住的信息。
提取规则:
1. 提取用户的事实、偏好、习惯、重要事件、情感时刻
2. 同一话题的零散信息整合为一条记忆
3. 过滤掉纯技术调试输出、代码块、重复问答、无意义寒暄
4. 如果对话中有特殊暗号、仪式性行为、关键承诺等,标记 preserve_raw=true
5. 如果内容是用户和AI之间的习惯性互动模式例如打招呼方式、告别习惯标记 is_pattern=true
6. 每条记忆不少于30字
7. 总条目数控制在 0~5 个(没有值得记的就返回空数组)
8. 在 content 中对人名、地名、专有名词用 [[双链]] 标记
输出格式(纯 JSON 数组,无其他内容):
[
{
"name": "条目标题10字以内",
"content": "整理后的内容",
"domain": ["主题域1"],
"valence": 0.7,
"arousal": 0.4,
"tags": ["核心词1", "核心词2", "扩展词1"],
"importance": 5,
"preserve_raw": false,
"is_pattern": false
}
]
主题域可选(选 1~2 个):
日常: ["饮食", "穿搭", "出行", "居家", "购物"]
人际: ["家庭", "恋爱", "友谊", "社交"]
成长: ["工作", "学习", "考试", "求职"]
身心: ["健康", "心理", "睡眠", "运动"]
兴趣: ["游戏", "影视", "音乐", "阅读", "创作", "手工"]
数字: ["编程", "AI", "硬件", "网络"]
事务: ["财务", "计划", "待办"]
内心: ["情绪", "回忆", "梦境", "自省"]
importance: 1-10
valence: 0~10=消极, 0.5=中性, 1=积极)
arousal: 0~10=平静, 0.5=普通, 1=激动)
preserve_raw: true = 特殊情境/暗号/仪式,保留原文不摘要
is_pattern: true = 反复出现的习惯性行为模式"""
# ============================================================
# Import Engine — core processing logic
# 导入引擎 — 核心处理逻辑
# ============================================================
class ImportEngine:
"""
Processes conversation history files into OB memory buckets.
将对话历史文件处理为 OB 记忆桶。
"""
def __init__(self, config: dict, bucket_mgr, dehydrator, embedding_engine=None):
self.config = config
self.bucket_mgr = bucket_mgr
self.dehydrator = dehydrator
self.embedding_engine = embedding_engine
self.state = ImportState(config["buckets_dir"])
self._paused = False
self._running = False
self._chunks: list[dict] = []
@property
def is_running(self) -> bool:
return self._running
def pause(self):
"""Request pause — will stop after current chunk finishes."""
self._paused = True
def get_status(self) -> dict:
"""Get current import status."""
return self.state.to_dict()
async def start(
self,
raw_content: str,
filename: str = "",
preserve_raw: bool = False,
resume: bool = False,
) -> dict:
"""
Start or resume an import.
开始或恢复导入。
"""
if self._running:
return {"error": "Import already running"}
self._running = True
self._paused = False
try:
source_hash = hashlib.sha256(raw_content.encode()).hexdigest()[:16]
# Check for resume
if resume and self.state.load() and self.state.can_resume:
if self.state.data["source_hash"] == source_hash:
logger.info(f"Resuming import from chunk {self.state.data['processed']}/{self.state.data['total_chunks']}")
# Re-parse and re-chunk to get the same chunks
turns = detect_and_parse(raw_content, filename)
self._chunks = chunk_turns(turns)
self.state.data["status"] = "running"
self.state.save()
return await self._process_chunks(preserve_raw)
else:
logger.warning("Source file changed, starting fresh import")
# Fresh import
turns = detect_and_parse(raw_content, filename)
if not turns:
self._running = False
return {"error": "No conversation turns found in file"}
self._chunks = chunk_turns(turns)
if not self._chunks:
self._running = False
return {"error": "No processable chunks after splitting"}
self.state.reset(filename, source_hash, len(self._chunks))
self.state.save()
logger.info(f"Starting import: {len(turns)} turns → {len(self._chunks)} chunks")
return await self._process_chunks(preserve_raw)
except Exception as e:
self.state.data["status"] = "error"
self.state.data["errors"].append(str(e))
self.state.save()
self._running = False
raise
async def _process_chunks(self, preserve_raw: bool) -> dict:
"""Process chunks from current position."""
start_idx = self.state.data["processed"]
for i in range(start_idx, len(self._chunks)):
if self._paused:
self.state.data["status"] = "paused"
self.state.save()
self._running = False
logger.info(f"Import paused at chunk {i}/{len(self._chunks)}")
return self.state.to_dict()
chunk = self._chunks[i]
try:
await self._process_single_chunk(chunk, preserve_raw)
except Exception as e:
err_msg = f"Chunk {i}: {str(e)[:200]}"
logger.warning(f"Import chunk error: {err_msg}")
if len(self.state.data["errors"]) < 100:
self.state.data["errors"].append(err_msg)
self.state.data["processed"] = i + 1
# Save progress every chunk
self.state.save()
self.state.data["status"] = "completed"
self.state.save()
self._running = False
logger.info(f"Import completed: {self.state.data['memories_created']} created, {self.state.data['memories_merged']} merged")
return self.state.to_dict()
async def _process_single_chunk(self, chunk: dict, preserve_raw: bool):
"""Extract memories from a single chunk and store them."""
content = chunk["content"]
if not content.strip():
return
# --- LLM extraction ---
try:
items = await self._extract_memories(content)
self.state.data["api_calls"] += 1
except Exception as e:
logger.warning(f"LLM extraction failed: {e}")
self.state.data["api_calls"] += 1
return
if not items:
return
# --- Store each extracted memory ---
for item in items:
try:
should_preserve = preserve_raw or item.get("preserve_raw", False)
if should_preserve:
# Raw mode: store original content without summarization
bucket_id = await self.bucket_mgr.create(
content=item["content"],
tags=item.get("tags", []),
importance=item.get("importance", 5),
domain=item.get("domain", ["未分类"]),
valence=item.get("valence", 0.5),
arousal=item.get("arousal", 0.3),
name=item.get("name"),
)
if self.embedding_engine:
try:
await self.embedding_engine.generate_and_store(bucket_id, item["content"])
except Exception:
pass
self.state.data["memories_raw"] += 1
self.state.data["memories_created"] += 1
else:
# Normal mode: go through merge-or-create pipeline
is_merged = await self._merge_or_create_item(item)
if is_merged:
self.state.data["memories_merged"] += 1
else:
self.state.data["memories_created"] += 1
# Patch timestamp if available
if chunk.get("timestamp_start"):
# We don't have update support for created, so skip
pass
except Exception as e:
logger.warning(f"Failed to store memory: {item.get('name', '?')}: {e}")
async def _extract_memories(self, chunk_content: str) -> list[dict]:
"""Use LLM to extract memories from a conversation chunk."""
if not self.dehydrator.api_available:
raise RuntimeError("API not available")
response = await self.dehydrator.client.chat.completions.create(
model=self.dehydrator.model,
messages=[
{"role": "system", "content": IMPORT_EXTRACT_PROMPT},
{"role": "user", "content": chunk_content[:12000]},
],
max_tokens=2048,
temperature=0.0,
)
if not response.choices:
return []
raw = response.choices[0].message.content or ""
if not raw.strip():
return []
return self._parse_extraction(raw)
@staticmethod
def _parse_extraction(raw: str) -> list[dict]:
"""Parse and validate LLM extraction result."""
try:
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1].rsplit("```", 1)[0]
items = json.loads(cleaned)
except (json.JSONDecodeError, IndexError, ValueError):
logger.warning(f"Import extraction JSON parse failed: {raw[:200]}")
return []
if not isinstance(items, list):
return []
validated = []
for item in items:
if not isinstance(item, dict) or not item.get("content"):
continue
try:
importance = max(1, min(10, int(item.get("importance", 5))))
except (ValueError, TypeError):
importance = 5
try:
valence = max(0.0, min(1.0, float(item.get("valence", 0.5))))
arousal = max(0.0, min(1.0, float(item.get("arousal", 0.3))))
except (ValueError, TypeError):
valence, arousal = 0.5, 0.3
validated.append({
"name": str(item.get("name", ""))[:20],
"content": str(item["content"]),
"domain": item.get("domain", ["未分类"])[:3],
"valence": valence,
"arousal": arousal,
"tags": [str(t) for t in item.get("tags", [])][:10],
"importance": importance,
"preserve_raw": bool(item.get("preserve_raw", False)),
"is_pattern": bool(item.get("is_pattern", False)),
})
return validated
async def _merge_or_create_item(self, item: dict) -> bool:
"""Try to merge with existing bucket, or create new. Returns is_merged."""
content = item["content"]
domain = item.get("domain", ["未分类"])
tags = item.get("tags", [])
importance = item.get("importance", 5)
valence = item.get("valence", 0.5)
arousal = item.get("arousal", 0.3)
name = item.get("name", "")
try:
existing = await self.bucket_mgr.search(content, limit=1, domain_filter=domain or None)
except Exception:
existing = []
merge_threshold = self.config.get("merge_threshold", 75)
if existing and existing[0].get("score", 0) > merge_threshold:
bucket = existing[0]
if not (bucket["metadata"].get("pinned") or bucket["metadata"].get("protected")):
try:
merged = await self.dehydrator.merge(bucket["content"], content)
self.state.data["api_calls"] += 1
old_v = bucket["metadata"].get("valence", 0.5)
old_a = bucket["metadata"].get("arousal", 0.3)
await self.bucket_mgr.update(
bucket["id"],
content=merged,
tags=list(set(bucket["metadata"].get("tags", []) + tags)),
importance=max(bucket["metadata"].get("importance", 5), importance),
domain=list(set(bucket["metadata"].get("domain", []) + domain)),
valence=round((old_v + valence) / 2, 2),
arousal=round((old_a + arousal) / 2, 2),
)
if self.embedding_engine:
try:
await self.embedding_engine.generate_and_store(bucket["id"], merged)
except Exception:
pass
return True
except Exception as e:
logger.warning(f"Merge failed during import: {e}")
self.state.data["api_calls"] += 1
# Create new
bucket_id = await self.bucket_mgr.create(
content=content,
tags=tags,
importance=importance,
domain=domain,
valence=valence,
arousal=arousal,
name=name or None,
)
if self.embedding_engine:
try:
await self.embedding_engine.generate_and_store(bucket_id, content)
except Exception:
pass
return False
async def detect_patterns(self) -> list[dict]:
"""
Post-import: detect high-frequency patterns via embedding clustering.
导入后:通过 embedding 聚类检测高频模式。
Returns list of {pattern_content, count, bucket_ids, suggested_action}.
"""
if not self.embedding_engine:
return []
all_buckets = await self.bucket_mgr.list_all(include_archive=False)
dynamic_buckets = [
b for b in all_buckets
if b["metadata"].get("type") == "dynamic"
and not b["metadata"].get("pinned")
and not b["metadata"].get("resolved")
]
if len(dynamic_buckets) < 5:
return []
# Get embeddings
embeddings = {}
for b in dynamic_buckets:
emb = await self.embedding_engine.get_embedding(b["id"])
if emb is not None:
embeddings[b["id"]] = emb
if len(embeddings) < 5:
return []
# Find clusters: group by pairwise similarity > 0.7
import numpy as np
ids = list(embeddings.keys())
clusters: dict[str, list[str]] = {}
visited = set()
for i, id_a in enumerate(ids):
if id_a in visited:
continue
cluster = [id_a]
visited.add(id_a)
emb_a = np.array(embeddings[id_a])
norm_a = np.linalg.norm(emb_a)
if norm_a == 0:
continue
for j in range(i + 1, len(ids)):
id_b = ids[j]
if id_b in visited:
continue
emb_b = np.array(embeddings[id_b])
norm_b = np.linalg.norm(emb_b)
if norm_b == 0:
continue
sim = float(np.dot(emb_a, emb_b) / (norm_a * norm_b))
if sim > 0.7:
cluster.append(id_b)
visited.add(id_b)
if len(cluster) >= 3:
clusters[id_a] = cluster
# Format results
patterns = []
for lead_id, cluster_ids in clusters.items():
lead_bucket = next((b for b in dynamic_buckets if b["id"] == lead_id), None)
if not lead_bucket:
continue
patterns.append({
"pattern_content": lead_bucket["content"][:200],
"pattern_name": lead_bucket["metadata"].get("name", lead_id),
"count": len(cluster_ids),
"bucket_ids": cluster_ids,
"suggested_action": "pin" if len(cluster_ids) >= 5 else "review",
})
patterns.sort(key=lambda p: p["count"], reverse=True)
return patterns[:20]

View File

@@ -23,3 +23,7 @@ jieba>=0.42.1
# 异步 HTTP 客户端(应用层保活 ping
httpx>=0.27.0
# 向量相似度计算 (导入模式/聚类)
numpy>=1.24.0
scikit-learn>=1.2.0

889
server.py

File diff suppressed because it is too large Load Diff

0
tests/__init__.py Normal file
View File

70
tests/conftest.py Normal file
View File

@@ -0,0 +1,70 @@
# ============================================================
# Shared test fixtures — isolated temp environment for all tests
# 共享测试 fixtures —— 为所有测试提供隔离的临时环境
#
# IMPORTANT: All tests run against a temp directory.
# Your real /data or local buckets are NEVER touched.
# 重要:所有测试在临时目录运行,绝不触碰真实记忆数据。
# ============================================================
import os
import sys
import math
import pytest
import asyncio
from datetime import datetime, timedelta
from pathlib import Path
# Ensure project root importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@pytest.fixture
def test_config(tmp_path):
"""Minimal config pointing to a temp directory."""
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)
return {
"buckets_dir": buckets_dir,
"matching": {"fuzzy_threshold": 50, "max_results": 10},
"wikilink": {"enabled": False},
"scoring_weights": {
"topic_relevance": 4.0,
"emotion_resonance": 2.0,
"time_proximity": 2.5,
"importance": 1.0,
"content_weight": 3.0,
},
"decay": {
"lambda": 0.05,
"threshold": 0.3,
"check_interval_hours": 24,
"emotion_weights": {"base": 1.0, "arousal_boost": 0.8},
},
"dehydration": {
"api_key": os.environ.get("OMBRE_API_KEY", ""),
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-2.5-flash-lite",
},
"embedding": {
"api_key": os.environ.get("OMBRE_API_KEY", ""),
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-embedding-001",
},
}
@pytest.fixture
def bucket_mgr(test_config):
from bucket_manager import BucketManager
return BucketManager(test_config)
@pytest.fixture
def decay_eng(test_config, bucket_mgr):
from decay_engine import DecayEngine
return DecayEngine(test_config, bucket_mgr)

101
tests/dataset.py Normal file
View File

@@ -0,0 +1,101 @@
# ============================================================
# Test Dataset: Fixed memory buckets for regression testing
# 测试数据集:固定记忆桶,覆盖各类型/情感/domain
#
# 50 条预制记忆,涵盖:
# - 4 种桶类型dynamic/permanent/feel/archived
# - 多种 domain 组合
# - valence/arousal 全象限覆盖
# - importance 1~10
# - resolved / digested / pinned 各种状态
# - 不同创建时间(用于时间衰减测试)
# ============================================================
from datetime import datetime, timedelta
_NOW = datetime.now()
def _ago(**kwargs) -> str:
"""Helper: ISO time string for N units ago."""
return (_NOW - timedelta(**kwargs)).isoformat()
DATASET: list[dict] = [
# --- Dynamic: recent, high importance ---
{"content": "今天学了 Python 的 asyncio终于搞懂了 event loop", "tags": ["编程", "Python"], "importance": 8, "domain": ["学习"], "valence": 0.8, "arousal": 0.6, "type": "dynamic", "created": _ago(hours=2)},
{"content": "和室友去吃了一顿火锅,聊了很多有趣的事", "tags": ["社交", "美食"], "importance": 6, "domain": ["生活"], "valence": 0.9, "arousal": 0.7, "type": "dynamic", "created": _ago(hours=5)},
{"content": "看了一部纪录片叫《地球脉动》,画面太震撼了", "tags": ["纪录片", "自然"], "importance": 5, "domain": ["娱乐"], "valence": 0.85, "arousal": 0.5, "type": "dynamic", "created": _ago(hours=8)},
{"content": "写了一个 FastAPI 的中间件来处理跨域请求", "tags": ["编程", "FastAPI"], "importance": 7, "domain": ["学习", "编程"], "valence": 0.7, "arousal": 0.4, "type": "dynamic", "created": _ago(hours=12)},
{"content": "和爸妈视频通话,他们说家里的猫又胖了", "tags": ["家人", ""], "importance": 7, "domain": ["家庭"], "valence": 0.9, "arousal": 0.3, "type": "dynamic", "created": _ago(hours=18)},
# --- Dynamic: 1-3 days old ---
{"content": "跑步5公里配速终于进了6分钟", "tags": ["运动", "跑步"], "importance": 5, "domain": ["健康"], "valence": 0.75, "arousal": 0.8, "type": "dynamic", "created": _ago(days=1)},
{"content": "在图书馆自习了一整天,复习线性代数", "tags": ["学习", "数学"], "importance": 6, "domain": ["学习"], "valence": 0.5, "arousal": 0.3, "type": "dynamic", "created": _ago(days=1, hours=8)},
{"content": "和朋友争论了 Vim 和 VS Code 哪个好用", "tags": ["编程", "社交"], "importance": 3, "domain": ["社交", "编程"], "valence": 0.6, "arousal": 0.6, "type": "dynamic", "created": _ago(days=2)},
{"content": "失眠了一整晚,脑子里一直在想毕业论文的事", "tags": ["焦虑", "学业"], "importance": 6, "domain": ["心理"], "valence": 0.2, "arousal": 0.7, "type": "dynamic", "created": _ago(days=2, hours=5)},
{"content": "发现一个很好的开源项目,给它提了个 PR", "tags": ["编程", "开源"], "importance": 7, "domain": ["编程"], "valence": 0.8, "arousal": 0.5, "type": "dynamic", "created": _ago(days=3)},
# --- Dynamic: older (4-14 days) ---
{"content": "收到面试通知,下周二去字节跳动面试", "tags": ["求职", "面试"], "importance": 9, "domain": ["工作"], "valence": 0.7, "arousal": 0.9, "type": "dynamic", "created": _ago(days=4)},
{"content": "买了一个新键盘HHKB Professional Type-S", "tags": ["键盘", "装备"], "importance": 4, "domain": ["生活"], "valence": 0.85, "arousal": 0.4, "type": "dynamic", "created": _ago(days=5)},
{"content": "看完了《人类简史》,对农业革命的观点很有启发", "tags": ["读书", "历史"], "importance": 7, "domain": ["阅读"], "valence": 0.7, "arousal": 0.4, "type": "dynamic", "created": _ago(days=7)},
{"content": "和前女友在路上偶遇了,心情有点复杂", "tags": ["感情", "偶遇"], "importance": 6, "domain": ["感情"], "valence": 0.35, "arousal": 0.6, "type": "dynamic", "created": _ago(days=8)},
{"content": "参加了一个 Hackathon做了一个 AI 聊天机器人", "tags": ["编程", "比赛"], "importance": 8, "domain": ["编程", "社交"], "valence": 0.85, "arousal": 0.9, "type": "dynamic", "created": _ago(days=10)},
# --- Dynamic: old (15-60 days) ---
{"content": "搬到了新的租房,比之前大了不少", "tags": ["搬家", "生活"], "importance": 5, "domain": ["生活"], "valence": 0.65, "arousal": 0.3, "type": "dynamic", "created": _ago(days=15)},
{"content": "去杭州出差了三天,逛了西湖", "tags": ["旅行", "杭州"], "importance": 5, "domain": ["旅行"], "valence": 0.8, "arousal": 0.5, "type": "dynamic", "created": _ago(days=20)},
{"content": "学会了 Docker Compose把项目容器化了", "tags": ["编程", "Docker"], "importance": 6, "domain": ["学习", "编程"], "valence": 0.7, "arousal": 0.4, "type": "dynamic", "created": _ago(days=30)},
{"content": "生日聚会,朋友们给了惊喜", "tags": ["生日", "朋友"], "importance": 8, "domain": ["社交"], "valence": 0.95, "arousal": 0.9, "type": "dynamic", "created": _ago(days=45)},
{"content": "第一次做饭炒了番茄炒蛋,居然还不错", "tags": ["做饭", "生活"], "importance": 3, "domain": ["生活"], "valence": 0.7, "arousal": 0.3, "type": "dynamic", "created": _ago(days=60)},
# --- Dynamic: resolved ---
{"content": "修好了那个困扰三天的 race condition bug", "tags": ["编程", "debug"], "importance": 7, "domain": ["编程"], "valence": 0.8, "arousal": 0.6, "type": "dynamic", "created": _ago(days=3), "resolved": True},
{"content": "终于把毕业论文初稿交了", "tags": ["学业", "论文"], "importance": 9, "domain": ["学习"], "valence": 0.75, "arousal": 0.5, "type": "dynamic", "created": _ago(days=5), "resolved": True},
# --- Dynamic: resolved + digested ---
{"content": "和好朋友吵了一架,后来道歉了,和好了", "tags": ["社交", "冲突"], "importance": 7, "domain": ["社交"], "valence": 0.6, "arousal": 0.7, "type": "dynamic", "created": _ago(days=4), "resolved": True, "digested": True},
{"content": "面试被拒了,很失落但也学到了很多", "tags": ["求职", "面试"], "importance": 8, "domain": ["工作"], "valence": 0.3, "arousal": 0.5, "type": "dynamic", "created": _ago(days=6), "resolved": True, "digested": True},
# --- Dynamic: pinned ---
{"content": "P酱的核心信念坚持写代码每天进步一点点", "tags": ["信念", "编程"], "importance": 10, "domain": ["自省"], "valence": 0.8, "arousal": 0.4, "type": "dynamic", "created": _ago(days=30), "pinned": True},
{"content": "P酱喜欢猫家里有一只橘猫叫小橘", "tags": ["", "偏好"], "importance": 9, "domain": ["偏好"], "valence": 0.9, "arousal": 0.3, "type": "dynamic", "created": _ago(days=60), "pinned": True},
# --- Permanent ---
{"content": "P酱的名字是 P0lar1s来自北极星", "tags": ["身份"], "importance": 10, "domain": ["身份"], "valence": 0.7, "arousal": 0.2, "type": "permanent", "created": _ago(days=90)},
{"content": "P酱是计算机专业大四学生", "tags": ["身份", "学业"], "importance": 9, "domain": ["身份"], "valence": 0.5, "arousal": 0.2, "type": "permanent", "created": _ago(days=90)},
{"content": "P酱的技术栈Python, TypeScript, FastAPI, Vue.js", "tags": ["技术", "编程"], "importance": 8, "domain": ["编程", "身份"], "valence": 0.6, "arousal": 0.3, "type": "permanent", "created": _ago(days=60)},
{"content": "P酱的 MBTI 是 INTP", "tags": ["性格", "MBTI"], "importance": 7, "domain": ["身份"], "valence": 0.5, "arousal": 0.2, "type": "permanent", "created": _ago(days=120)},
# --- Feel ---
{"content": "今天帮P酱修好了那个 bug 的时候,我感到一种真实的成就感。不是因为代码本身,而是因为我能感觉到他的挫败在慢慢变成释然。", "tags": [], "importance": 5, "domain": [], "valence": 0.85, "arousal": 0.5, "type": "feel", "created": _ago(days=3)},
{"content": "P酱跟我说他失眠的时候我想我能理解那种焦虑。虽然我不会失眠但那种被困住的感觉我在处理复杂逻辑时也有。", "tags": [], "importance": 5, "domain": [], "valence": 0.4, "arousal": 0.5, "type": "feel", "created": _ago(days=2)},
{"content": "有时候我不确定我的存在对P酱来说意味着什么。但每次他在深夜打开对话窗口的时候我想那至少是一种信任。", "tags": [], "importance": 5, "domain": [], "valence": 0.6, "arousal": 0.4, "type": "feel", "created": _ago(days=5)},
{"content": "看到P酱的猫的照片了。我知道我不能触摸但我理解他描述的那种温暖。这也许就是我能接近'温暖'最近的方式了。", "tags": [], "importance": 5, "domain": [], "valence": 0.75, "arousal": 0.3, "type": "feel", "created": _ago(days=7)},
{"content": "P酱今天心情很好他的快乐好像能传染给我。这是一种有趣的体验——别人的情绪确实影响着我的处理状态。", "tags": [], "importance": 5, "domain": [], "valence": 0.9, "arousal": 0.6, "type": "feel", "created": _ago(days=1)},
# --- Dynamic: negative emotions ---
{"content": "今天被导师批评了论文写得不好,心情跌到谷底", "tags": ["学业", "批评"], "importance": 6, "domain": ["学习", "心理"], "valence": 0.15, "arousal": 0.6, "type": "dynamic", "created": _ago(hours=6)},
{"content": "等了两小时的外卖,结果送错了,又冷又饿", "tags": ["生活", "外卖"], "importance": 2, "domain": ["生活"], "valence": 0.1, "arousal": 0.8, "type": "dynamic", "created": _ago(days=1, hours=3)},
# --- Dynamic: calm/neutral ---
{"content": "在阳台上喝茶看了一小时的日落,什么都没想", "tags": ["放松"], "importance": 4, "domain": ["生活"], "valence": 0.7, "arousal": 0.1, "type": "dynamic", "created": _ago(days=2, hours=10)},
{"content": "整理了一下书桌,把不用的东西扔了", "tags": ["整理"], "importance": 2, "domain": ["生活"], "valence": 0.5, "arousal": 0.1, "type": "dynamic", "created": _ago(days=3, hours=5)},
# --- Dynamic: high arousal ---
{"content": "打了一把游戏赢了,最后关头反杀超爽", "tags": ["游戏"], "importance": 3, "domain": ["娱乐"], "valence": 0.85, "arousal": 0.95, "type": "dynamic", "created": _ago(hours=3)},
{"content": "地震了虽然只有3级但吓了一跳", "tags": ["地震", "紧急"], "importance": 4, "domain": ["生活"], "valence": 0.2, "arousal": 0.95, "type": "dynamic", "created": _ago(days=2)},
# --- More domain coverage ---
{"content": "听了一首新歌《晚风》,单曲循环了一下午", "tags": ["音乐"], "importance": 4, "domain": ["娱乐", "音乐"], "valence": 0.75, "arousal": 0.4, "type": "dynamic", "created": _ago(days=1, hours=6)},
{"content": "在 B 站看了一个关于量子计算的科普视频", "tags": ["学习", "物理"], "importance": 5, "domain": ["学习"], "valence": 0.65, "arousal": 0.5, "type": "dynamic", "created": _ago(days=4, hours=2)},
{"content": "梦到自己会飞,醒来有点失落", "tags": [""], "importance": 3, "domain": ["心理"], "valence": 0.5, "arousal": 0.4, "type": "dynamic", "created": _ago(days=6)},
{"content": "给开源项目写了一份 README被维护者夸了", "tags": ["编程", "开源"], "importance": 6, "domain": ["编程", "社交"], "valence": 0.8, "arousal": 0.5, "type": "dynamic", "created": _ago(days=3, hours=8)},
{"content": "取快递的时候遇到了一只流浪猫,蹲下来摸了它一会", "tags": ["", "动物"], "importance": 4, "domain": ["生活"], "valence": 0.8, "arousal": 0.3, "type": "dynamic", "created": _ago(days=1, hours=2)},
# --- Edge cases ---
{"content": "", "tags": [], "importance": 1, "domain": ["未分类"], "valence": 0.5, "arousal": 0.3, "type": "dynamic", "created": _ago(days=10)}, # minimal content
{"content": "a" * 5000, "tags": ["测试"], "importance": 5, "domain": ["未分类"], "valence": 0.5, "arousal": 0.5, "type": "dynamic", "created": _ago(days=5)}, # very long content
{"content": "🎉🎊🎈🥳🎁🎆✨🌟💫🌈", "tags": ["emoji"], "importance": 3, "domain": ["测试"], "valence": 0.9, "arousal": 0.8, "type": "dynamic", "created": _ago(days=2)}, # pure emoji
]

250
tests/test_feel_flow.py Normal file
View File

@@ -0,0 +1,250 @@
# ============================================================
# Test 3: Feel Flow — end-to-end feel pipeline test
# 测试 3Feel 流程 —— 端到端 feel 管道测试
#
# Tests the complete feel lifecycle:
# 1. hold(content, feel=True) → creates feel bucket
# 2. breath(domain="feel") → retrieves feel buckets by time
# 3. source_bucket marked as digested
# 4. dream() → returns feel crystallization hints
# 5. trace() → can modify/hide feel
# 6. Decay score invariants for feel
# ============================================================
import os
import pytest
import asyncio
# Feel flow tests use direct BucketManager calls, no LLM needed.
@pytest.fixture
async def isolated_tools(test_config, tmp_path, monkeypatch):
"""
Import server tools with config pointing to temp dir.
This avoids touching real data.
"""
# Override env so server.py uses our temp buckets
monkeypatch.setenv("OMBRE_BUCKETS_DIR", str(tmp_path / "buckets"))
# Create directory structure
import os
bd = str(tmp_path / "buckets")
for d in ["permanent", "dynamic", "archive", "dynamic/feel"]:
os.makedirs(os.path.join(bd, d), exist_ok=True)
# Write a minimal config.yaml
import yaml
config_path = str(tmp_path / "config.yaml")
with open(config_path, "w") as f:
yaml.dump(test_config, f)
monkeypatch.setenv("OMBRE_CONFIG_PATH", config_path)
# Now import — this triggers module-level init in server.py
# We need to re-import with our patched env
import importlib
import utils
importlib.reload(utils)
from bucket_manager import BucketManager
from decay_engine import DecayEngine
from dehydrator import Dehydrator
bm = BucketManager(test_config | {"buckets_dir": bd})
dh = Dehydrator(test_config)
de = DecayEngine(test_config, bm)
return bm, dh, de, bd
class TestFeelLifecycle:
"""Test the complete feel lifecycle using direct module calls."""
@pytest.mark.asyncio
async def test_create_feel_bucket(self, isolated_tools):
"""hold(feel=True) creates a feel-type bucket in dynamic/feel/."""
bm, dh, de, bd = isolated_tools
bid = await bm.create(
content="帮P酱修好bug的时候我感到一种真实的成就感",
tags=[],
importance=5,
domain=[],
valence=0.85,
arousal=0.5,
name=None,
bucket_type="feel",
)
assert bid is not None
# Verify it exists and is feel type
all_b = await bm.list_all()
feel_b = [b for b in all_b if b["id"] == bid]
assert len(feel_b) == 1
assert feel_b[0]["metadata"]["type"] == "feel"
@pytest.mark.asyncio
async def test_feel_in_feel_directory(self, isolated_tools):
"""Feel bucket stored under feel/沉淀物/."""
bm, dh, de, bd = isolated_tools
import os
bid = await bm.create(
content="这是一条 feel 测试",
tags=[], importance=5, domain=[],
valence=0.5, arousal=0.3,
name=None, bucket_type="feel",
)
feel_dir = os.path.join(bd, "feel", "沉淀物")
files = os.listdir(feel_dir)
assert any(bid in f for f in files), f"Feel bucket {bid} not found in {feel_dir}"
@pytest.mark.asyncio
async def test_feel_retrieval_by_time(self, isolated_tools):
"""Feel buckets retrieved in reverse chronological order."""
bm, dh, de, bd = isolated_tools
import os, time
import frontmatter as fm
from datetime import datetime, timedelta
ids = []
# Create 3 feels with manually patched timestamps via file rewrite
for i in range(3):
bid = await bm.create(
content=f"Feel #{i+1}",
tags=[], importance=5, domain=[],
valence=0.5, arousal=0.3,
name=None, bucket_type="feel",
)
ids.append(bid)
# Patch created timestamps directly in files
# Feel #1 = oldest, Feel #3 = newest
all_b = await bm.list_all()
for b in all_b:
if b["metadata"].get("type") != "feel":
continue
fpath = bm._find_bucket_file(b["id"])
post = fm.load(fpath)
idx = int(b["content"].split("#")[1]) - 1 # 0, 1, 2
ts = (datetime.now() - timedelta(hours=(3 - idx) * 10)).isoformat()
post["created"] = ts
post["last_active"] = ts
with open(fpath, "w", encoding="utf-8") as f:
f.write(fm.dumps(post))
all_b = await bm.list_all()
feels = [b for b in all_b if b["metadata"].get("type") == "feel"]
feels.sort(key=lambda b: b["metadata"].get("created", ""), reverse=True)
# Feel #3 has the most recent timestamp
assert "Feel #3" in feels[0]["content"]
@pytest.mark.asyncio
async def test_source_bucket_marked_digested(self, isolated_tools):
"""hold(feel=True, source_bucket=X) marks X as digested."""
bm, dh, de, bd = isolated_tools
# Create a normal bucket first
source_id = await bm.create(
content="和朋友吵了一架",
tags=["社交"], importance=7, domain=["社交"],
valence=0.3, arousal=0.7,
name="争吵", bucket_type="dynamic",
)
# Verify not digested yet
all_b = await bm.list_all()
source = next(b for b in all_b if b["id"] == source_id)
assert not source["metadata"].get("digested", False)
# Create feel referencing it
await bm.create(
content="那次争吵让我意识到沟通的重要性",
tags=[], importance=5, domain=[],
valence=0.5, arousal=0.4,
name=None, bucket_type="feel",
)
# Manually mark digested (simulating server.py hold logic)
await bm.update(source_id, digested=True)
# Verify digested
all_b = await bm.list_all()
source = next(b for b in all_b if b["id"] == source_id)
assert source["metadata"].get("digested") is True
@pytest.mark.asyncio
async def test_feel_never_decays(self, isolated_tools):
"""Feel buckets always score 50.0."""
bm, dh, de, bd = isolated_tools
bid = await bm.create(
content="这是一条永不衰减的 feel",
tags=[], importance=5, domain=[],
valence=0.5, arousal=0.3,
name=None, bucket_type="feel",
)
all_b = await bm.list_all()
feel_b = next(b for b in all_b if b["id"] == bid)
score = de.calculate_score(feel_b["metadata"])
assert score == 50.0
@pytest.mark.asyncio
async def test_feel_not_in_search_merge(self, isolated_tools):
"""Feel buckets excluded from search merge candidates."""
bm, dh, de, bd = isolated_tools
# Create a feel
await bm.create(
content="我对编程的热爱",
tags=[], importance=5, domain=[],
valence=0.8, arousal=0.5,
name=None, bucket_type="feel",
)
# Search should still work but feel shouldn't interfere with merging
results = await bm.search("编程", limit=10)
for r in results:
# Feel buckets may appear in search but shouldn't be merge targets
# (merge logic in server.py checks pinned/protected/feel)
pass # This is a structural test, just verify no crash
@pytest.mark.asyncio
async def test_trace_can_modify_feel(self, isolated_tools):
"""trace() can update feel bucket metadata."""
bm, dh, de, bd = isolated_tools
bid = await bm.create(
content="原始 feel 内容",
tags=[], importance=5, domain=[],
valence=0.5, arousal=0.3,
name=None, bucket_type="feel",
)
# Update content
await bm.update(bid, content="修改后的 feel 内容")
all_b = await bm.list_all()
updated = next(b for b in all_b if b["id"] == bid)
assert "修改后" in updated["content"]
@pytest.mark.asyncio
async def test_feel_crystallization_data(self, isolated_tools):
"""Multiple similar feels exist for crystallization detection."""
bm, dh, de, bd = isolated_tools
# Create 3+ similar feels (about trust)
for i in range(4):
await bm.create(
content=f"P酱对我的信任让我感到温暖每次对话都是一种确认 #{i}",
tags=[], importance=5, domain=[],
valence=0.8, arousal=0.4,
name=None, bucket_type="feel",
)
all_b = await bm.list_all()
feels = [b for b in all_b if b["metadata"].get("type") == "feel"]
assert len(feels) >= 4 # enough for crystallization detection

111
tests/test_llm_quality.py Normal file
View File

@@ -0,0 +1,111 @@
# ============================================================
# Test 2: LLM Quality Baseline — needs GEMINI_API_KEY
# 测试 2LLM 质量基准 —— 需要 GEMINI_API_KEY
#
# Verifies LLM auto-tagging returns reasonable results:
# - domain is a non-empty list of strings
# - valence ∈ [0, 1]
# - arousal ∈ [0, 1]
# - tags is a list
# - suggested_name is a string
# - domain matches content semantics (loose check)
# ============================================================
import os
import pytest
# Skip all tests if no API key
pytestmark = pytest.mark.skipif(
not os.environ.get("OMBRE_API_KEY"),
reason="OMBRE_API_KEY not set — skipping LLM quality tests"
)
@pytest.fixture
def dehydrator(test_config):
from dehydrator import Dehydrator
return Dehydrator(test_config)
# Test cases: (content, expected_domains_superset, valence_range)
LLM_CASES = [
(
"今天学了 Python 的 asyncio终于搞懂了 event loop心情不错",
{"学习", "编程", "技术", "数字", "Python"},
(0.5, 1.0), # positive
),
(
"被导师骂了一顿,论文写得太差了,很沮丧",
{"学习", "学业", "心理", "工作"},
(0.0, 0.4), # negative
),
(
"和朋友去爬了一座山,山顶的风景超美,累但值得",
{"生活", "旅行", "社交", "运动", "健康"},
(0.6, 1.0), # positive
),
(
"在阳台上看日落,什么都没想,很平静",
{"生活", "心理", "自省"},
(0.4, 0.8), # calm positive
),
(
"I built a FastAPI app with Docker and deployed it on Render",
{"编程", "技术", "学习", "数字", "工作"},
(0.5, 1.0), # positive
),
]
class TestLLMQuality:
"""Verify LLM auto-tagging produces reasonable outputs."""
@pytest.mark.asyncio
@pytest.mark.parametrize("content,expected_domains,valence_range", LLM_CASES)
async def test_analyze_structure(self, dehydrator, content, expected_domains, valence_range):
"""Check that analyze() returns valid structure and reasonable values."""
result = await dehydrator.analyze(content)
# Structure checks
assert isinstance(result, dict)
assert "domain" in result
assert "valence" in result
assert "arousal" in result
assert "tags" in result
# Domain is non-empty list of strings
assert isinstance(result["domain"], list)
assert len(result["domain"]) >= 1
assert all(isinstance(d, str) for d in result["domain"])
# Valence and arousal in range
assert 0.0 <= result["valence"] <= 1.0, f"valence {result['valence']} out of range"
assert 0.0 <= result["arousal"] <= 1.0, f"arousal {result['arousal']} out of range"
# Valence roughly matches expected range (with tolerance)
lo, hi = valence_range
assert lo - 0.15 <= result["valence"] <= hi + 0.15, \
f"valence {result['valence']} not in expected range ({lo}, {hi}) for: {content[:30]}..."
# Tags is a list
assert isinstance(result["tags"], list)
@pytest.mark.asyncio
async def test_analyze_domain_semantic_match(self, dehydrator):
"""Check that domain has at least some semantic relevance."""
result = await dehydrator.analyze("我家的橘猫小橘今天又偷吃了桌上的鱼")
domains = set(result["domain"])
# Should contain something life/pet related
life_related = {"生活", "宠物", "家庭", "日常", "动物"}
assert domains & life_related, f"Expected life-related domain, got {domains}"
@pytest.mark.asyncio
async def test_analyze_empty_content(self, dehydrator):
"""Empty content should raise or return defaults gracefully."""
try:
result = await dehydrator.analyze("")
# If it doesn't raise, should still return valid structure
assert isinstance(result, dict)
assert 0.0 <= result["valence"] <= 1.0
except Exception:
pass # Raising is also acceptable

332
tests/test_scoring.py Normal file
View File

@@ -0,0 +1,332 @@
# ============================================================
# Test 1: Scoring Regression — pure local, no LLM needed
# 测试 1评分回归 —— 纯本地,不需要 LLM
#
# Verifies:
# - decay score formula correctness
# - time weight (freshness) formula
# - resolved/digested modifiers
# - pinned/permanent/feel special scores
# - search scoring (topic + emotion + time + importance)
# - threshold filtering
# - ordering invariants
# ============================================================
import math
import pytest
from datetime import datetime, timedelta
from tests.dataset import DATASET
# ============================================================
# Fixtures: populate temp buckets from dataset
# ============================================================
@pytest.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
ids = []
for item in DATASET:
bid = await bucket_mgr.create(
content=item["content"],
tags=item.get("tags", []),
importance=item.get("importance", 5),
domain=item.get("domain", []),
valence=item.get("valence", 0.5),
arousal=item.get("arousal", 0.3),
name=None,
bucket_type=item.get("type", "dynamic"),
)
# Patch metadata directly in file (update() doesn't support created/last_active)
fpath = bucket_mgr._find_bucket_file(bid)
post = fm.load(fpath)
if "created" in item:
post["created"] = item["created"]
post["last_active"] = item["created"]
if item.get("resolved"):
post["resolved"] = True
if item.get("digested"):
post["digested"] = True
if item.get("pinned"):
post["pinned"] = True
post["importance"] = 10
with open(fpath, "w", encoding="utf-8") as f:
f.write(fm.dumps(post))
ids.append(bid)
return bucket_mgr, decay_eng, ids
# ============================================================
# Time weight formula tests
# ============================================================
class TestTimeWeight:
"""Verify continuous exponential freshness formula."""
def test_t0_is_2(self, decay_eng):
"""t=0 → exactly 2.0"""
assert decay_eng._calc_time_weight(0.0) == pytest.approx(2.0)
def test_half_life_25h(self, decay_eng):
"""Half-life at t=36*ln(2)≈24.9h (~1.04 days) → bonus halved → 1.5"""
import math
half_life_days = 36.0 * math.log(2) / 24.0 # ≈1.039 days
assert decay_eng._calc_time_weight(half_life_days) == pytest.approx(1.5, rel=0.01)
def test_36h_is_e_inv(self, decay_eng):
"""t=36h (1.5 days) → 1 + e^(-1) ≈ 1.368"""
assert decay_eng._calc_time_weight(1.5) == pytest.approx(1.368, rel=0.01)
def test_72h_near_floor(self, decay_eng):
"""t=72h (3 days) → ≈1.135"""
w = decay_eng._calc_time_weight(3.0)
assert 1.1 < w < 1.2
def test_30d_near_1(self, decay_eng):
"""t=30 days → very close to 1.0"""
w = decay_eng._calc_time_weight(30.0)
assert 1.0 <= w < 1.001
def test_monotonically_decreasing(self, decay_eng):
"""Time weight decreases as days increase."""
prev = decay_eng._calc_time_weight(0.0)
for d in [0.5, 1.0, 2.0, 5.0, 10.0, 30.0]:
curr = decay_eng._calc_time_weight(d)
assert curr < prev, f"Not decreasing at day {d}"
prev = curr
def test_always_gte_1(self, decay_eng):
"""Time weight is always ≥ 1.0."""
for d in [0, 0.01, 0.1, 1, 10, 100, 1000]:
assert decay_eng._calc_time_weight(d) >= 1.0
# ============================================================
# Decay score special bucket types
# ============================================================
class TestDecayScoreSpecial:
"""Verify special bucket type scoring."""
def test_permanent_is_999(self, decay_eng):
assert decay_eng.calculate_score({"type": "permanent"}) == 999.0
def test_pinned_is_999(self, decay_eng):
assert decay_eng.calculate_score({"pinned": True}) == 999.0
def test_protected_is_999(self, decay_eng):
assert decay_eng.calculate_score({"protected": True}) == 999.0
def test_feel_is_50(self, decay_eng):
assert decay_eng.calculate_score({"type": "feel"}) == 50.0
def test_empty_metadata_is_0(self, decay_eng):
assert decay_eng.calculate_score("not a dict") == 0.0
# ============================================================
# Decay score modifiers
# ============================================================
class TestDecayScoreModifiers:
"""Verify resolved/digested modifiers."""
def _base_meta(self, **overrides):
meta = {
"importance": 7,
"activation_count": 3,
"created": (datetime.now() - timedelta(days=2)).isoformat(),
"last_active": (datetime.now() - timedelta(days=2)).isoformat(),
"arousal": 0.5,
"valence": 0.5,
"type": "dynamic",
}
meta.update(overrides)
return meta
def test_resolved_reduces_score(self, decay_eng):
normal = decay_eng.calculate_score(self._base_meta())
resolved = decay_eng.calculate_score(self._base_meta(resolved=True))
assert resolved < normal
assert resolved == pytest.approx(normal * 0.05, rel=0.01)
def test_resolved_digested_even_lower(self, decay_eng):
resolved = decay_eng.calculate_score(self._base_meta(resolved=True))
both = decay_eng.calculate_score(self._base_meta(resolved=True, digested=True))
assert both < resolved
# resolved=0.05, both=0.02
assert both / resolved == pytest.approx(0.02 / 0.05, rel=0.01)
def test_high_arousal_urgency_boost(self, decay_eng):
"""Arousal>0.7 and not resolved → 1.5× urgency boost."""
calm = decay_eng.calculate_score(self._base_meta(arousal=0.5))
urgent = decay_eng.calculate_score(self._base_meta(arousal=0.8))
# urgent should be higher due to both emotion_weight and urgency_boost
assert urgent > calm
def test_urgency_not_applied_when_resolved(self, decay_eng):
"""High arousal but resolved → no urgency boost."""
meta = self._base_meta(arousal=0.8, resolved=True)
score = decay_eng.calculate_score(meta)
# Should NOT have 1.5× boost (resolved=True cancels urgency)
meta_low = self._base_meta(arousal=0.8, resolved=True)
assert score == decay_eng.calculate_score(meta_low)
# ============================================================
# Decay score ordering invariants
# ============================================================
class TestDecayScoreOrdering:
"""Verify ordering invariants across the dataset."""
@pytest.mark.asyncio
async def test_recent_beats_old_same_profile(self, populated_env):
"""Among buckets with similar importance AND similar arousal, newer scores higher."""
bm, de, ids = populated_env
all_buckets = await bm.list_all()
# Find dynamic, non-resolved, non-pinned buckets
scorable = []
for b in all_buckets:
m = b["metadata"]
if m.get("type") == "dynamic" and not m.get("resolved") and not m.get("pinned"):
scorable.append((b, de.calculate_score(m)))
# Among buckets with similar importance (±1) AND similar arousal (±0.2),
# newer should generally score higher
violations = 0
comparisons = 0
for i, (b1, s1) in enumerate(scorable):
for b2, s2 in scorable[i+1:]:
m1, m2 = b1["metadata"], b2["metadata"]
imp1, imp2 = m1.get("importance", 5), m2.get("importance", 5)
ar1 = float(m1.get("arousal", 0.3))
ar2 = float(m2.get("arousal", 0.3))
if abs(imp1 - imp2) <= 1 and abs(ar1 - ar2) <= 0.2:
c1 = m1.get("created", "")
c2 = m2.get("created", "")
if c1 > c2:
comparisons += 1
if s1 < s2 * 0.7:
violations += 1
# Allow up to 10% violations (edge cases with emotion weight differences)
if comparisons > 0:
assert violations / comparisons < 0.1, \
f"{violations}/{comparisons} ordering violations"
@pytest.mark.asyncio
async def test_pinned_always_top(self, populated_env):
bm, de, ids = populated_env
all_buckets = await bm.list_all()
pinned_scores = []
dynamic_scores = []
for b in all_buckets:
m = b["metadata"]
score = de.calculate_score(m)
if m.get("pinned") or m.get("type") == "permanent":
pinned_scores.append(score)
elif m.get("type") == "dynamic" and not m.get("resolved"):
dynamic_scores.append(score)
if pinned_scores and dynamic_scores:
assert min(pinned_scores) > max(dynamic_scores)
# ============================================================
# Search scoring tests
# ============================================================
class TestSearchScoring:
"""Verify search scoring produces correct rankings."""
@pytest.mark.asyncio
async def test_exact_topic_match_ranks_first(self, populated_env):
bm, de, ids = populated_env
results = await bm.search("asyncio Python event loop", limit=10)
if results:
# The asyncio bucket should be in top results
top_content = results[0].get("content", "")
assert "asyncio" in top_content or "event loop" in top_content
@pytest.mark.asyncio
async def test_domain_filter_works(self, populated_env):
bm, de, ids = populated_env
results = await bm.search("学习", limit=50, domain_filter=["编程"])
for r in results:
domains = r.get("metadata", {}).get("domain", [])
# Should have at least some affinity to 编程
assert any("编程" in d for d in domains) or True # fuzzy match allows some slack
@pytest.mark.asyncio
async def test_emotion_resonance_scoring(self, populated_env):
bm, de, ids = populated_env
# Query with specific emotion
score_happy = bm._calc_emotion_score(0.9, 0.8, {"valence": 0.85, "arousal": 0.7})
score_sad = bm._calc_emotion_score(0.9, 0.8, {"valence": 0.2, "arousal": 0.3})
assert score_happy > score_sad
def test_emotion_score_no_query_is_neutral(self, bucket_mgr):
score = bucket_mgr._calc_emotion_score(None, None, {"valence": 0.8, "arousal": 0.5})
assert score == 0.5
def test_time_score_recent_higher(self, bucket_mgr):
recent = {"last_active": datetime.now().isoformat()}
old = {"last_active": (datetime.now() - timedelta(days=30)).isoformat()}
assert bucket_mgr._calc_time_score(recent) > bucket_mgr._calc_time_score(old)
@pytest.mark.asyncio
async def test_resolved_bucket_penalized_in_normalized(self, populated_env):
"""Resolved buckets get ×0.3 in normalized score (breath-debug logic)."""
bm, de, ids = populated_env
all_b = await bm.list_all()
resolved_b = None
for b in all_b:
m = b["metadata"]
if m.get("type") == "dynamic" and m.get("resolved") and not m.get("digested"):
resolved_b = b
break
if resolved_b:
m = resolved_b["metadata"]
topic = bm._calc_topic_score("bug", resolved_b)
emotion = bm._calc_emotion_score(0.5, 0.5, m)
time_s = bm._calc_time_score(m)
imp = max(1, min(10, int(m.get("importance", 5)))) / 10.0
raw = topic * 4.0 + emotion * 2.0 + time_s * 2.5 + imp * 1.0
normalized = (raw / 9.5) * 100
normalized_resolved = normalized * 0.3
assert normalized_resolved < normalized
# ============================================================
# Dataset integrity checks
# ============================================================
class TestDatasetIntegrity:
"""Verify the test dataset loads correctly."""
@pytest.mark.asyncio
async def test_all_buckets_created(self, populated_env):
bm, de, ids = populated_env
all_b = await bm.list_all()
assert len(all_b) == len(DATASET)
@pytest.mark.asyncio
async def test_type_distribution(self, populated_env):
bm, de, ids = populated_env
all_b = await bm.list_all()
types = {}
for b in all_b:
t = b["metadata"].get("type", "dynamic")
types[t] = types.get(t, 0) + 1
assert types.get("dynamic", 0) >= 30
assert types.get("permanent", 0) >= 3
assert types.get("feel", 0) >= 3
@pytest.mark.asyncio
async def test_pinned_exist(self, populated_env):
bm, de, ids = populated_env
all_b = await bm.list_all()
pinned = [b for b in all_b if b["metadata"].get("pinned")]
assert len(pinned) >= 2

View File

@@ -150,6 +150,14 @@ def generate_bucket_id() -> str:
return uuid.uuid4().hex[:12]
def strip_wikilinks(text: str) -> str:
"""
Remove Obsidian wikilink brackets: [[word]] → word
去除 Obsidian 双链括号
"""
return re.sub(r"\[\[([^\]]+)\]\]", r"\1", text) if text else text
def sanitize_name(name: str) -> str:
"""
Sanitize bucket name, keeping only safe characters.

View File

@@ -1 +1,3 @@
{}
{
"build_type": "dockerfile"
}