feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: memory_manager
|
||||
version: 1.2.0
|
||||
description: 智能体记忆管理技能——每日对话提炼、去重、冲突检测、版本管理、记忆导入导出、语义检索
|
||||
triggers:
|
||||
- 记忆管理
|
||||
- 记忆更新
|
||||
- 每日摘要
|
||||
- 每日提炼
|
||||
- 对话提炼
|
||||
- 整理记忆
|
||||
- 记忆去重
|
||||
- 冲突检测
|
||||
- 知识版本
|
||||
- 记忆备份
|
||||
- 记忆导出
|
||||
- 记忆检查
|
||||
- 查记忆
|
||||
- 语义检索
|
||||
- 记忆搜索
|
||||
- 搜索记忆
|
||||
- update memory
|
||||
---
|
||||
|
||||
# Memory Manager 技能
|
||||
|
||||
## 概述
|
||||
|
||||
管理 AgentOS 的 L0→L1→L1_vec→L2→L3 五级记忆体系统,负责每日对话提炼、去重、冲突消解、版本管理、语义检索和记忆导入导出。
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 1. 每日对话提炼(daily_digest)
|
||||
- 触发方式:每日凌晨 2:00 自动执行,或手动触发
|
||||
- 输入:昨日所有对话记录
|
||||
- 处理流程:
|
||||
1. 提取关键事实(who/what/when/where/decision)
|
||||
2. 去重(对比 L2 已有事实,相似度 > 0.9 则跳过)
|
||||
3. 冲突检测(新事实与旧事实矛盾?→ 标记 OVERRIDE 或提示用户确认)
|
||||
4. 生成摘要(100-200 字/条)
|
||||
5. 更新 L1 关键词索引(仅写入 L2 成功的事实,避免孤儿索引)
|
||||
6. 更新 L1_vec 向量索引(ChromaDB + oMLX embedding,新事实自动向量化)
|
||||
7. 关键对话原文 → 压缩后写入 L3
|
||||
- 输出:
|
||||
- 更新 04_memory/daily_summaries/YYYY-MM-DD.md
|
||||
- 更新 04_memory/long_term/facts.db
|
||||
- 更新 04_memory/vector_db/keyword_index.json
|
||||
- 更新 04_memory/vector_db/chroma/(ChromaDB 向量库)
|
||||
- 发送摘要到 Obsidian 01_daily/ 日记
|
||||
|
||||
### 2. 冲突消解(memory_cleanup)
|
||||
- 冲突检测优先级:新事实 > 旧事实,用户明确修正 > 系统推断
|
||||
- 三种变化处理:
|
||||
- 硬事实改变 → 更新原卡片,旧值写入 previous_version
|
||||
- 软升级/延伸 → 保留 V1,新建 V2,通过 related 双向链接
|
||||
- 观点→事实 → 更新 nature 字段,通知 kb_manager 移动文件
|
||||
|
||||
### 3. 记忆导入导出
|
||||
- export_memories.py:将记忆打包为 JSON + Markdown 压缩包
|
||||
- import_memories.py:从压缩包恢复记忆,合并时智能去重
|
||||
|
||||
### 4. 语义检索(semantic_search)
|
||||
- BM25 关键词检索 + 向量语义检索 + RRF 加权融合
|
||||
- 向量引擎:ChromaDB(持久化)+ oMLX Qwen3-Embedding-0.6B(1024维)
|
||||
- 融合策略:Reciprocal Rank Fusion (RRF),BM25 权重 0.4 / 向量权重 0.6
|
||||
- CLI 用法:
|
||||
- 检索:`python3 semantic_search.py search --root ~/workbuddy-agent-os/agent-sync --query "查询内容" --top-k 5`
|
||||
- 回填:`python3 semantic_search.py backfill --root ~/workbuddy-agent-os/agent-sync`
|
||||
- 重建:`python3 semantic_search.py rebuild --root ~/workbuddy-agent-os/agent-sync`
|
||||
- 测试:`python3 semantic_search.py embed --root ~/workbuddy-agent-os/agent-sync --text "测试"`
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
memory_manager/
|
||||
├── SKILL.md # 本文件
|
||||
├── daily_digest.py # 每日对话提炼脚本
|
||||
├── semantic_search.py # BM25 + 向量语义混合检索
|
||||
├── memory_cleanup.py # 冲突消解与过期记忆清理
|
||||
├── agent_memory_init.py # 首次初始化记忆体
|
||||
├── bootstrap_from_memory.py # 冷启动:历史数据全量导入
|
||||
├── export_memories.py # 记忆导出(JSON + L3 原文)
|
||||
├── import_memories.py # 记忆导入(智能去重合并)
|
||||
└── version.json
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
> **Python 路径**:所有脚本使用 agent-os 专用虚拟环境
|
||||
> `/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3`
|
||||
|
||||
1. 初始化:`python3 agent_memory_init.py --root ~/workbuddy-agent-os/agent-sync`
|
||||
2. 冷启动:`python3 bootstrap_from_memory.py --root ~/workbuddy-agent-os/agent-sync`
|
||||
3. 每日提炼:`python3 daily_digest.py --root ~/workbuddy-agent-os/agent-sync --date YYYY-MM-DD`
|
||||
4. 冲突清理:`python3 memory_cleanup.py --root ~/workbuddy-agent-os/agent-sync`
|
||||
5. 导出记忆:`python3 export_memories.py --root ~/workbuddy-agent-os/agent-sync --output ~/backup/`
|
||||
6. 导入记忆:`python3 import_memories.py --root ~/workbuddy-agent-os/agent-sync --input ~/backup/memories.zip`
|
||||
|
||||
## 数据源
|
||||
|
||||
| 数据源 | 路径 | 说明 |
|
||||
|--------|------|------|
|
||||
| Claw 工作日志 | `~/WorkBuddy/Claw/.workbuddy/memory/YYYY-MM-DD.md` | 每次对话后自动写入 |
|
||||
| WorkBuddy 系统画像 | `~/.workbuddy/memery/*.md` | 系统自动维护 |
|
||||
| 上轮摘要 | `04_memory/daily_summaries/YYYY-MM-DD.md` | 防丢失兜底 |
|
||||
|
||||
## 自动化
|
||||
|
||||
- WorkBuddy 自动化「AgentOS 每日记忆提炼」已配置:每日凌晨 2:00 执行
|
||||
- 命令:`/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3 ~/workbuddy-agent-os/agent-sync/02_skills/memory_manager/daily_digest.py --root ~/workbuddy-agent-os/agent-sync`
|
||||
|
||||
## 依赖
|
||||
|
||||
| 包 | 用途 | 安装状态 |
|
||||
|---|---|---|
|
||||
| sqlite3 | L2 事实库 | ✅ 标准库 |
|
||||
| sqlite-utils | SQLite 增强 | ✅ 已安装 |
|
||||
| rank_bm25 | BM25 关键词检索 | ✅ 已安装 |
|
||||
| chromadb | 向量存储与检索 | ✅ 1.5.8 |
|
||||
| numpy | 向量计算 | ✅ 2.4.4 |
|
||||
| requests | HTTP 客户端(embedding API) | ✅ 已安装 |
|
||||
| json/re/os/argparse | 基础 | ✅ 标准库 |
|
||||
|
||||
## 向量检索依赖
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| oMLX API | localhost:8000/v1/embeddings |
|
||||
| 模型 | Qwen3-Embedding-0.6B(1024 维) |
|
||||
| 向量库 | ChromaDB,持久化到 04_memory/vector_db/chroma/ |
|
||||
@@ -0,0 +1,57 @@
|
||||
skill:
|
||||
name: "记忆管理器"
|
||||
id: "memory_manager"
|
||||
version: "1.2.0"
|
||||
category: "core-system"
|
||||
status: "active"
|
||||
created: "2026-04-25"
|
||||
updated: "2026-04-29"
|
||||
|
||||
description:
|
||||
short: "每日对话提炼、记忆固化检测、向量化、版本管理"
|
||||
use_cases:
|
||||
- "每日凌晨自动提炼前一天对话记忆"
|
||||
- "检测高频/重要记忆,推送到协同inbox/memory/"
|
||||
- "冷启动时从已有记忆初始化知识库"
|
||||
tags: ["记忆", "提炼", "固化", "向量化"]
|
||||
|
||||
ownership:
|
||||
master_editor: "my-macbook_macOS14.4_16GB_master"
|
||||
readonly_on_other_devices: true
|
||||
|
||||
environment:
|
||||
os: "macos"
|
||||
python_path: "~/.workbuddy/binaries/python/envs/agent-os/bin/python3"
|
||||
pip_dependencies:
|
||||
- "sqlite-utils>=3.35"
|
||||
- "chromadb>=1.0.0"
|
||||
|
||||
setup:
|
||||
install_script: ""
|
||||
post_check: "python3 -c 'import sqlite3; print(\"OK\")'"
|
||||
|
||||
usage:
|
||||
entry_point: "daily_digest.py"
|
||||
scheduled: "每日 02:00(WorkBuddy 自动化)"
|
||||
manual_trigger: "记忆更新"
|
||||
|
||||
scripts:
|
||||
- daily_digest.py # 每日提炼主程序
|
||||
- memory_extractor.py # 记忆固化提取(推送inbox/memory/)
|
||||
- bootstrap_from_memory.py
|
||||
- agent_memory_init.py
|
||||
- export_memories.py
|
||||
- import_memories.py
|
||||
- memory_cleanup.py
|
||||
- semantic_search.py
|
||||
|
||||
changelog:
|
||||
- version: "1.2.0"
|
||||
date: "2026-04-29"
|
||||
changes: "新增 memory_extractor.py,支持固化条件检测+推送inbox/memory/"
|
||||
- version: "1.1.0"
|
||||
date: "2026-04-27"
|
||||
changes: "接入 oMLX Embedding,支持向量化"
|
||||
- version: "1.0.0"
|
||||
date: "2026-04-25"
|
||||
changes: "初始版本"
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 记忆体初始化脚本
|
||||
用途:首次运行时创建 L1 索引文件、L2 事实库、L3 原文目录
|
||||
使用:python3 agent_memory_init.py --root ~/workbuddy-agent-os/agent-sync
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 记忆体初始化")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def init_l1_index(root: str):
|
||||
"""初始化 L1 关键词索引"""
|
||||
index_path = Path(root) / "04_memory" / "vector_db" / "keyword_index.json"
|
||||
if index_path.exists():
|
||||
print(f"[INFO] L1 索引已存在: {index_path}")
|
||||
return
|
||||
|
||||
index_data = {
|
||||
"version": "1.0.0",
|
||||
"last_updated": "",
|
||||
"entries": []
|
||||
}
|
||||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
print(f"[OK] L1 关键词索引已创建: {index_path}")
|
||||
|
||||
|
||||
def init_l2_facts(root: str):
|
||||
"""初始化 L2 事实库"""
|
||||
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
|
||||
if db_path.exists():
|
||||
print(f"[INFO] L2 事实库已存在: {db_path}")
|
||||
return
|
||||
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.7,
|
||||
nature TEXT DEFAULT 'fact',
|
||||
domain TEXT,
|
||||
source TEXT,
|
||||
date_created TEXT,
|
||||
date_modified TEXT,
|
||||
previous_version TEXT,
|
||||
superseded_by TEXT,
|
||||
version INTEGER DEFAULT 1
|
||||
)''')
|
||||
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[OK] L2 事实库已创建: {db_path}")
|
||||
|
||||
|
||||
def init_l3_raw(root: str):
|
||||
"""初始化 L3 原文存档目录"""
|
||||
raw_dir = Path(root) / "04_memory" / "long_term" / "raw"
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
gitkeep = raw_dir / ".gitkeep"
|
||||
if not gitkeep.exists():
|
||||
gitkeep.touch()
|
||||
print(f"[OK] L3 原文目录已就绪: {raw_dir}")
|
||||
|
||||
|
||||
def init_logs(root: str):
|
||||
"""初始化日志目录"""
|
||||
log_dir = Path(root) / "04_memory" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建空日志文件
|
||||
for log_name in ["kb_ingest.log", "errors.log", "conflicts.log", "kb_reclassify.log"]:
|
||||
log_file = log_dir / log_name
|
||||
if not log_file.exists():
|
||||
log_file.touch()
|
||||
|
||||
print(f"[OK] 日志目录已就绪: {log_dir}")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = os.path.expanduser(args.root)
|
||||
|
||||
print("=" * 50)
|
||||
print("AgentOS 记忆体初始化")
|
||||
print("=" * 50)
|
||||
|
||||
init_l1_index(root)
|
||||
init_l2_facts(root)
|
||||
init_l3_raw(root)
|
||||
init_logs(root)
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("[OK] 记忆体初始化完成!")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 记忆体冷启动脚本
|
||||
用途:首次运行时,将已有的 MEMORY.md 和工作日志全量写入 L1/L2/L3
|
||||
这是记忆系统的「时间起点」——没有此步骤,L1/L2 将永远为空
|
||||
|
||||
使用:
|
||||
python3 bootstrap_from_memory.py --root ~/workbuddy-agent-os/agent-sync
|
||||
python3 bootstrap_from_memory.py --root ~/workbuddy-agent-os/agent-sync --dry-run # 预览不写入
|
||||
|
||||
数据来源(全量导入):
|
||||
1. ~/WorkBuddy/Claw/.workbuddy/memory/MEMORY.md ← 长期精炼记忆
|
||||
2. ~/WorkBuddy/Claw/.workbuddy/memory/YYYY-MM-DD.md ← 所有历史日志
|
||||
3. ~/.workbuddy/memery/*.md ← 系统用户画像(V79+)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
WORKBUDDY_MEMORY_DIR = Path.home() / "WorkBuddy" / "Claw" / ".workbuddy" / "memory"
|
||||
WORKBUDDY_SYSTEM_MEMERY_DIR = Path.home() / ".workbuddy" / "memery"
|
||||
|
||||
TODAY = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 记忆体冷启动")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def ensure_db(root: str):
|
||||
"""确保 L2 数据库存在并建好表"""
|
||||
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.7,
|
||||
nature TEXT DEFAULT 'fact',
|
||||
domain TEXT,
|
||||
source TEXT,
|
||||
date_created TEXT,
|
||||
date_modified TEXT,
|
||||
previous_version TEXT,
|
||||
superseded_by TEXT,
|
||||
version INTEGER DEFAULT 1
|
||||
)''')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
|
||||
def parse_memory_md(content: str) -> list:
|
||||
"""
|
||||
解析 MEMORY.md 的结构化内容,提取事实列表。
|
||||
支持格式:
|
||||
- **字段名**:值
|
||||
- 普通列表项
|
||||
"""
|
||||
facts = []
|
||||
lines = content.split("\n")
|
||||
current_section = "general"
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(">"):
|
||||
continue
|
||||
|
||||
# 章节标题
|
||||
if stripped.startswith("## "):
|
||||
current_section = stripped[3:].strip()
|
||||
continue
|
||||
if stripped.startswith("# "):
|
||||
continue
|
||||
|
||||
# 格式 1:**Key**:Value 或 **Key**: Value
|
||||
m = re.match(r'\*\*(.+?)\*\*[::]\s*(.+)', stripped)
|
||||
if m:
|
||||
key, value = m.group(1).strip(), m.group(2).strip()
|
||||
if len(value) > 5:
|
||||
facts.append({
|
||||
"subject": "ghai",
|
||||
"predicate": key,
|
||||
"object": value[:300],
|
||||
"confidence": 0.9,
|
||||
"nature": "fact",
|
||||
"domain": _infer_domain(current_section, value),
|
||||
"source": "MEMORY.md"
|
||||
})
|
||||
continue
|
||||
|
||||
# 格式 2:- 列表项(含有实质内容的行)
|
||||
if stripped.startswith("- ") and len(stripped) > 10:
|
||||
item = stripped[2:].strip()
|
||||
# 过滤纯说明性短句
|
||||
if len(item) < 15 or item.startswith("(") or item.startswith("("):
|
||||
continue
|
||||
predicate = _infer_predicate(current_section, item)
|
||||
facts.append({
|
||||
"subject": "ghai",
|
||||
"predicate": predicate,
|
||||
"object": item[:300],
|
||||
"confidence": 0.8,
|
||||
"nature": "fact",
|
||||
"domain": _infer_domain(current_section, item),
|
||||
"source": "MEMORY.md"
|
||||
})
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
def _infer_domain(section: str, text: str) -> str:
|
||||
"""根据章节标题和内容推断 domain"""
|
||||
section_lower = section.lower()
|
||||
if any(w in section_lower for w in ["项目", "工具", "agenos", "技能"]):
|
||||
return "engineering"
|
||||
if any(w in section_lower for w in ["偏好", "用户", "个人"]):
|
||||
return "personal-management"
|
||||
if any(w in section_lower for w in ["金融", "股票", "finance"]):
|
||||
return "finance"
|
||||
if any(w in text for w in ["代码", "脚本", "Node", "Python", "npm"]):
|
||||
return "engineering"
|
||||
return "personal-management"
|
||||
|
||||
|
||||
def _infer_predicate(section: str, text: str) -> str:
|
||||
"""根据章节和文本推断谓语"""
|
||||
if "偏好" in section or "偏好" in text:
|
||||
return "preference"
|
||||
if "工具" in section or "工具链" in section:
|
||||
return "uses_tool"
|
||||
if "项目" in section:
|
||||
return "owns_project"
|
||||
if "关注" in section:
|
||||
return "focuses_on"
|
||||
if "动态" in section:
|
||||
return "recent_activity"
|
||||
return "knows"
|
||||
|
||||
|
||||
def parse_daily_log(content: str, date: str) -> list:
|
||||
"""解析每日工作日志,提取完成事项和决策"""
|
||||
facts = []
|
||||
lines = content.split("\n")
|
||||
current_section = ""
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("## ") or stripped.startswith("### "):
|
||||
current_section = stripped.lstrip("#").strip()
|
||||
continue
|
||||
|
||||
# 提取列表项(已完成事项)
|
||||
if (stripped.startswith("- ") or stripped.startswith("✅") or
|
||||
stripped.startswith("1.") or re.match(r'^\d+\.', stripped)):
|
||||
item = re.sub(r'^[-\d\.\s✅]+', '', stripped).strip()
|
||||
if len(item) < 10:
|
||||
continue
|
||||
facts.append({
|
||||
"subject": "ghai",
|
||||
"predicate": "completed",
|
||||
"object": (f"[{current_section}] " if current_section else "") + item[:250],
|
||||
"confidence": 0.8,
|
||||
"nature": "fact",
|
||||
"domain": _infer_domain(current_section, item),
|
||||
"source": f"daily_log_{date}"
|
||||
})
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
def insert_facts_to_l2(db_path: Path, facts: list, dry_run: bool = False) -> dict:
|
||||
"""批量插入事实到 L2,自动去重"""
|
||||
if dry_run:
|
||||
print(f" [DRY-RUN] 将写入 {len(facts)} 条事实到 L2")
|
||||
for f in facts[:5]:
|
||||
print(f" → [{f['domain']}] {f['predicate']}: {f['object'][:60]}...")
|
||||
if len(facts) > 5:
|
||||
print(f" ... 还有 {len(facts)-5} 条")
|
||||
return {"inserted": 0, "skipped": len(facts)}
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
||||
# 获取全局已有总数,用于生成唯一 ID
|
||||
cursor.execute("SELECT COUNT(*) FROM facts")
|
||||
base = cursor.fetchone()[0]
|
||||
|
||||
for i, fact in enumerate(facts):
|
||||
# 精确去重:subject + predicate + object 三元组完全相同才跳过
|
||||
cursor.execute(
|
||||
"SELECT id FROM facts WHERE subject=? AND predicate=? AND object=?",
|
||||
(fact["subject"], fact["predicate"], fact["object"])
|
||||
)
|
||||
if cursor.fetchone():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
fact_id = f"FACT-BOOTSTRAP-{base + i + 1:05d}"
|
||||
cursor.execute(
|
||||
"""INSERT INTO facts
|
||||
(id, subject, predicate, object, confidence, nature, domain, source,
|
||||
date_created, date_modified, version)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(fact_id, fact["subject"], fact["predicate"], fact["object"],
|
||||
fact.get("confidence", 0.7), fact.get("nature", "fact"),
|
||||
fact.get("domain", ""), fact.get("source", "bootstrap"),
|
||||
TODAY, TODAY, 1)
|
||||
)
|
||||
fact["id"] = fact_id
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"inserted": inserted, "skipped": skipped}
|
||||
|
||||
|
||||
def update_l1_index(root: str, facts: list, dry_run: bool = False):
|
||||
"""将事实写入 L1 关键词索引"""
|
||||
index_path = Path(root) / "04_memory" / "vector_db" / "keyword_index.json"
|
||||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if index_path.exists():
|
||||
with open(index_path, "r", encoding="utf-8") as f:
|
||||
index_data = json.load(f)
|
||||
else:
|
||||
index_data = {"version": "1.0.0", "last_updated": "", "entries": []}
|
||||
|
||||
existing_ids = {e.get("fact_id") for e in index_data["entries"]}
|
||||
added = 0
|
||||
|
||||
for fact in facts:
|
||||
fid = fact.get("id", "")
|
||||
if not fid or fid in existing_ids:
|
||||
continue
|
||||
keywords = list(set(re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{3,}', fact["object"])))[:12]
|
||||
index_data["entries"].append({
|
||||
"fact_id": fid,
|
||||
"keywords": keywords,
|
||||
"domain": fact.get("domain", ""),
|
||||
"nature": fact.get("nature", "fact"),
|
||||
"summary": fact["object"][:120],
|
||||
"date": TODAY,
|
||||
"source": fact.get("source", "")
|
||||
})
|
||||
existing_ids.add(fid)
|
||||
added += 1
|
||||
|
||||
index_data["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
if not dry_run:
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return added
|
||||
|
||||
|
||||
def archive_to_l3(root: str, source_name: str, content: str, dry_run: bool = False):
|
||||
"""归档原文到 L3"""
|
||||
raw_dir = Path(root) / "04_memory" / "long_term" / "raw"
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_file = raw_dir / f"bootstrap_{source_name}.md"
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY-RUN] 将归档到 L3: {archive_file.name}")
|
||||
return
|
||||
|
||||
archive_file.write_text(
|
||||
f"# L3 Bootstrap 存档 — {source_name}\n> 冷启动时间: {datetime.now().isoformat()}\n\n{content}",
|
||||
encoding="utf-8"
|
||||
)
|
||||
print(f" [OK] L3 归档: {archive_file.name}")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = os.path.expanduser(args.root)
|
||||
dry_run = args.dry_run
|
||||
|
||||
print("=" * 60)
|
||||
print("AgentOS 记忆体冷启动(Bootstrap)")
|
||||
print(f"时间起点: {TODAY}")
|
||||
if dry_run:
|
||||
print("[DRY-RUN 模式:仅预览,不写入任何文件]")
|
||||
print("=" * 60)
|
||||
|
||||
db_path = ensure_db(root)
|
||||
all_facts = []
|
||||
|
||||
# ── 来源 1:MEMORY.md 长期精炼记忆 ─────────────────────────────
|
||||
memory_md = WORKBUDDY_MEMORY_DIR / "MEMORY.md"
|
||||
if memory_md.exists():
|
||||
print(f"\n[来源1] MEMORY.md 长期记忆")
|
||||
content = memory_md.read_text(encoding="utf-8")
|
||||
facts = parse_memory_md(content)
|
||||
print(f" → 解析出 {len(facts)} 条事实")
|
||||
archive_to_l3(root, "MEMORY_md", content, dry_run)
|
||||
all_facts.extend(facts)
|
||||
else:
|
||||
print(f"[WARN] MEMORY.md 不存在: {memory_md}")
|
||||
|
||||
# ── 来源 2:每日工作日志(所有历史日期)──────────────────────────
|
||||
print(f"\n[来源2] 工作日志({WORKBUDDY_MEMORY_DIR})")
|
||||
log_total = 0
|
||||
if WORKBUDDY_MEMORY_DIR.exists():
|
||||
for log_file in sorted(WORKBUDDY_MEMORY_DIR.glob("????-??-??.md")):
|
||||
date_str = log_file.stem
|
||||
content = log_file.read_text(encoding="utf-8")
|
||||
facts = parse_daily_log(content, date_str)
|
||||
print(f" → {date_str}: {len(facts)} 条")
|
||||
archive_to_l3(root, f"daily_{date_str}", content, dry_run)
|
||||
all_facts.extend(facts)
|
||||
log_total += len(facts)
|
||||
print(f" 合计: {log_total} 条")
|
||||
|
||||
# ── 来源 3:WorkBuddy 系统用户画像 ──────────────────────────────
|
||||
print(f"\n[来源3] 系统用户画像({WORKBUDDY_SYSTEM_MEMERY_DIR})")
|
||||
if WORKBUDDY_SYSTEM_MEMERY_DIR.exists():
|
||||
for md_file in WORKBUDDY_SYSTEM_MEMERY_DIR.glob("*_memery.md"):
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
# 从用户画像提取结构化信息
|
||||
facts = []
|
||||
# 提取偏好段
|
||||
for pattern, pred, conf in [
|
||||
(r"用户名\s*([\w]+),", "username", 0.95),
|
||||
(r"常居\s*([^\n。,,]+)", "city", 0.95),
|
||||
(r"(偏好.{20,200}?)[\n。]", "preference_summary", 0.85),
|
||||
(r"(期望.{20,200}?)[\n。]", "expectation", 0.85),
|
||||
]:
|
||||
m = re.search(pattern, content, re.DOTALL)
|
||||
if m:
|
||||
facts.append({
|
||||
"subject": "ghai",
|
||||
"predicate": pred,
|
||||
"object": m.group(1).strip()[:300],
|
||||
"confidence": conf,
|
||||
"nature": "fact",
|
||||
"domain": "personal-management",
|
||||
"source": f"system_memery_{md_file.stem}"
|
||||
})
|
||||
print(f" → {md_file.name}: {len(facts)} 条")
|
||||
archive_to_l3(root, f"system_{md_file.stem}", content, dry_run)
|
||||
all_facts.extend(facts)
|
||||
|
||||
# ── 写入 L2 + L1 ─────────────────────────────────────────────
|
||||
print(f"\n[总计] {len(all_facts)} 条候选事实")
|
||||
print("\n[写入 L2] 更新 facts.db...")
|
||||
stats = insert_facts_to_l2(db_path, all_facts, dry_run)
|
||||
print(f" → 新增 {stats['inserted']} 条,跳过重复 {stats['skipped']} 条")
|
||||
|
||||
print("\n[写入 L1] 更新 keyword_index.json...")
|
||||
l1_added = update_l1_index(root, all_facts, dry_run)
|
||||
print(f" → L1 索引新增 {l1_added} 条")
|
||||
|
||||
# ── 输出摘要 ─────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
if dry_run:
|
||||
print("✅ DRY-RUN 完成,未写入任何文件")
|
||||
else:
|
||||
print("✅ 冷启动完成!记忆系统已建立初始状态")
|
||||
print(f" L1 关键词索引: {Path(root) / '04_memory' / 'vector_db' / 'keyword_index.json'}")
|
||||
print(f" L2 事实库: {Path(root) / '04_memory' / 'long_term' / 'facts.db'}")
|
||||
print(f" L3 原文存档: {Path(root) / '04_memory' / 'long_term' / 'raw' / '*.md'}")
|
||||
print()
|
||||
print("下一步:每日自动提炼已配置,从明天起 daily_digest.py 将自动运行")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 每日对话提炼脚本
|
||||
用途:从对话记录中提取关键事实,更新 L1 索引、L2 摘要库、L3 原文存档
|
||||
|
||||
数据源优先级(按顺序逐一尝试,全部合并):
|
||||
1. WorkBuddy Claw 工作记忆日志 → ~/WorkBuddy/Claw/.workbuddy/memory/YYYY-MM-DD.md
|
||||
2. WorkBuddy 系统用户画像 → ~/.workbuddy/memery/*.md
|
||||
3. agent-os 每日摘要 → ~/workbuddy-agent-os/agent-sync/04_memory/daily_summaries/YYYY-MM-DD.md
|
||||
|
||||
使用:
|
||||
python3 daily_digest.py --root ~/workbuddy-agent-os/agent-sync --date 2026-04-25
|
||||
python3 daily_digest.py --root ~/workbuddy-agent-os/agent-sync # 默认处理昨天
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# ─── 角色检查:所有机器都执行记忆提炼,但各自保存本地 ───
|
||||
try:
|
||||
sys.path.insert(0, str(Path.home() / "workbuddy-agent-os" / "agent-sync" / "05_tools" / "01_system"))
|
||||
from role_check import check_capability
|
||||
if not check_capability("memory_digestion"):
|
||||
print("⛔ 能力 memory_digestion 未启用,跳过 daily_digest")
|
||||
sys.exit(0)
|
||||
except ImportError as e:
|
||||
print(f"⚠️ 角色检查模块不可用 ({e}),继续执行")
|
||||
|
||||
|
||||
# ─── 数据源路径配置 ──────────────────────────────────────────────
|
||||
# 如果工作记忆路径变更,只改这里即可
|
||||
WORKBUDDY_MEMORY_DIR = Path.home() / "WorkBuddy" / "Claw" / ".workbuddy" / "memory"
|
||||
WORKBUDDY_SYSTEM_MEMERY_DIR = Path.home() / ".workbuddy" / "memery"
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 每日对话提炼")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
parser.add_argument("--date", default=None, help="日期 (YYYY-MM-DD),默认昨天")
|
||||
parser.add_argument("--bootstrap", action="store_true",
|
||||
help="冷启动模式:处理所有可用日期,用于首次初始化")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_conversations(root: str, target_date: str) -> list:
|
||||
"""
|
||||
从以下来源加载对话内容(全部合并):
|
||||
1. WorkBuddy Claw 工作记忆日志(每次工作后由 AI 写入)
|
||||
2. WorkBuddy 系统用户画像文件
|
||||
3. agent-os/04_memory/daily_summaries/(上一轮提炼结果,避免信息丢失)
|
||||
"""
|
||||
conversations = []
|
||||
|
||||
# ── 来源 1:Claw 工作记忆日志(最核心的数据源)──────────────────
|
||||
claw_daily = WORKBUDDY_MEMORY_DIR / f"{target_date}.md"
|
||||
if claw_daily.exists():
|
||||
content = claw_daily.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
conversations.append({
|
||||
"source": str(claw_daily),
|
||||
"source_type": "claw_daily_log",
|
||||
"date": target_date,
|
||||
"content": content
|
||||
})
|
||||
print(f" [+] 来源1 Claw工作日志: {claw_daily} ({len(content)} 字符)")
|
||||
else:
|
||||
print(f" [-] 来源1 Claw工作日志不存在: {claw_daily}")
|
||||
|
||||
# ── 来源 2:WorkBuddy 系统用户画像 ──────────────────────────────
|
||||
if WORKBUDDY_SYSTEM_MEMERY_DIR.exists():
|
||||
for md_file in WORKBUDDY_SYSTEM_MEMERY_DIR.glob("*_memery.md"):
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
# 检查文件中是否包含目标日期(粗过滤)
|
||||
if target_date in content or "Last updated" in content:
|
||||
conversations.append({
|
||||
"source": str(md_file),
|
||||
"source_type": "workbuddy_system_memery",
|
||||
"date": target_date,
|
||||
"content": content
|
||||
})
|
||||
print(f" [+] 来源2 系统用户画像: {md_file.name} ({len(content)} 字符)")
|
||||
|
||||
# ── 来源 3:上一轮 agent-os 摘要(防丢失)──────────────────────
|
||||
daily_dir = Path(root) / "04_memory" / "daily_summaries"
|
||||
prev_summary = daily_dir / f"{target_date}.md"
|
||||
if prev_summary.exists():
|
||||
content = prev_summary.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
conversations.append({
|
||||
"source": str(prev_summary),
|
||||
"source_type": "agent_os_summary",
|
||||
"date": target_date,
|
||||
"content": content
|
||||
})
|
||||
print(f" [+] 来源3 上轮摘要: {prev_summary.name} ({len(content)} 字符)")
|
||||
|
||||
return conversations
|
||||
|
||||
|
||||
def load_all_available_dates(root: str) -> list:
|
||||
"""冷启动模式:扫描所有可用日期(用于首次运行)"""
|
||||
dates = set()
|
||||
|
||||
# 扫描 Claw 工作记忆日志
|
||||
if WORKBUDDY_MEMORY_DIR.exists():
|
||||
for f in WORKBUDDY_MEMORY_DIR.glob("????-??-??.md"):
|
||||
dates.add(f.stem)
|
||||
|
||||
# 扫描 agent-os daily_summaries
|
||||
daily_dir = Path(root) / "04_memory" / "daily_summaries"
|
||||
if daily_dir.exists():
|
||||
for f in daily_dir.glob("????-??-??.md"):
|
||||
dates.add(f.stem)
|
||||
|
||||
return sorted(dates)
|
||||
|
||||
|
||||
def extract_facts(content: str, date: str, source_type: str = "unknown") -> list:
|
||||
"""
|
||||
从对话内容中提取关键事实。
|
||||
|
||||
提取策略:
|
||||
- Claw工作日志(claw_daily_log):按 ## 章节和 ### 子项提取结构化决策
|
||||
- 系统用户画像(workbuddy_system_memery):提取用户基本信息和偏好
|
||||
- 通用:包含决策/配置/部署等关键词的行
|
||||
"""
|
||||
facts = []
|
||||
|
||||
if source_type == "workbuddy_system_memery":
|
||||
# 用户画像:提取姓名、城市、偏好等结构化信息
|
||||
patterns = [
|
||||
(r"\*\*Name\*\*[::]\s*(.+)", "user_profile", "name"),
|
||||
(r"\*\*City\*\*[::]\s*(.+)", "user_profile", "city"),
|
||||
(r"用户名\s*(.+?),", "user_profile", "username"),
|
||||
(r"常居\s*(.+?)。", "user_profile", "city"),
|
||||
]
|
||||
for pattern, subject, predicate in patterns:
|
||||
m = re.search(pattern, content)
|
||||
if m:
|
||||
facts.append({
|
||||
"subject": subject,
|
||||
"predicate": predicate,
|
||||
"object": m.group(1).strip()[:200],
|
||||
"confidence": 0.9,
|
||||
"nature": "fact",
|
||||
"domain": "personal-management",
|
||||
"date_created": date,
|
||||
"date_modified": date,
|
||||
"version": 1,
|
||||
"source": source_type
|
||||
})
|
||||
|
||||
# 提取偏好段落整体
|
||||
pref_match = re.search(r"(偏好.{10,200})", content, re.DOTALL)
|
||||
if pref_match:
|
||||
facts.append({
|
||||
"subject": "user",
|
||||
"predicate": "preference",
|
||||
"object": pref_match.group(1).strip()[:300],
|
||||
"confidence": 0.85,
|
||||
"nature": "opinion",
|
||||
"domain": "personal-management",
|
||||
"date_created": date,
|
||||
"date_modified": date,
|
||||
"version": 1,
|
||||
"source": source_type
|
||||
})
|
||||
return facts
|
||||
|
||||
# ── 通用提取:决策/配置/完成事项 ────────────────────────────────
|
||||
lines = content.split("\n")
|
||||
current_section = ""
|
||||
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
continue
|
||||
|
||||
# 记录当前章节标题(用于 domain 推断)
|
||||
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
|
||||
current_section = line_stripped.lstrip("#").strip()
|
||||
continue
|
||||
|
||||
# 决策性关键词
|
||||
decision_keywords = [
|
||||
"决定", "确认", "选择", "修改", "配置", "部署", "安装", "更新",
|
||||
"修复", "完成", "创建", "初始化", "验证", "生成", "已部署", "已完成",
|
||||
"设计决策", "关键决策"
|
||||
]
|
||||
|
||||
for kw in decision_keywords:
|
||||
if kw in line_stripped and len(line_stripped) > 10:
|
||||
# 推断领域
|
||||
domain = "personal-management"
|
||||
if any(w in current_section for w in ["代码", "脚本", "技能", "工具", "初始化", "部署"]):
|
||||
domain = "engineering"
|
||||
elif any(w in current_section for w in ["知识", "记忆", "学习"]):
|
||||
domain = "personal-management"
|
||||
|
||||
facts.append({
|
||||
"subject": "ghai",
|
||||
"predicate": kw,
|
||||
"object": (f"[{current_section}] " if current_section else "") + line_stripped[:250],
|
||||
"confidence": 0.75,
|
||||
"nature": "fact",
|
||||
"domain": domain,
|
||||
"date_created": date,
|
||||
"date_modified": date,
|
||||
"version": 1,
|
||||
"source": source_type
|
||||
})
|
||||
break # 一行只取一次
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
def check_duplicate(fact: dict, cursor) -> bool:
|
||||
"""检查事实是否已存在(去重)"""
|
||||
cursor.execute(
|
||||
"SELECT id FROM facts WHERE subject = ? AND predicate = ? AND object = ?",
|
||||
(fact["subject"], fact["predicate"], fact["object"])
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def check_conflict(fact: dict, cursor):
|
||||
"""检查是否存在内容不同但主谓相同的旧事实(冲突)"""
|
||||
cursor.execute(
|
||||
"SELECT id, object, confidence FROM facts WHERE subject = ? AND predicate = ? AND object != ?",
|
||||
(fact["subject"], fact["predicate"], fact["object"])
|
||||
)
|
||||
return cursor.fetchone()
|
||||
|
||||
|
||||
def generate_fact_id(date: str, index: int) -> str:
|
||||
return f"FACT-{date}-{index:04d}"
|
||||
|
||||
|
||||
def update_l1_index(root: str, facts: list):
|
||||
"""更新 L1 关键词索引"""
|
||||
index_path = Path(root) / "04_memory" / "vector_db" / "keyword_index.json"
|
||||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if index_path.exists():
|
||||
with open(index_path, "r", encoding="utf-8") as f:
|
||||
index_data = json.load(f)
|
||||
else:
|
||||
index_data = {"version": "1.0.0", "last_updated": "", "entries": []}
|
||||
|
||||
# 避免重复插入:已有 fact_id 的不重复加
|
||||
existing_ids = {e.get("fact_id") for e in index_data["entries"]}
|
||||
|
||||
for fact in facts:
|
||||
fid = fact.get("id", "")
|
||||
if fid in existing_ids:
|
||||
continue
|
||||
keywords = list(set(re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{3,}', fact["object"])))[:12]
|
||||
entry = {
|
||||
"fact_id": fid,
|
||||
"keywords": keywords,
|
||||
"domain": fact.get("domain", ""),
|
||||
"nature": fact.get("nature", "fact"),
|
||||
"summary": fact["object"][:120],
|
||||
"date": fact.get("date_created", ""),
|
||||
"source": fact.get("source", "")
|
||||
}
|
||||
index_data["entries"].append(entry)
|
||||
existing_ids.add(fid)
|
||||
|
||||
index_data["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def update_l2_facts(root: str, facts: list, date: str):
|
||||
"""更新 L2 事实库(SQLite)"""
|
||||
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.7,
|
||||
nature TEXT DEFAULT 'fact',
|
||||
domain TEXT,
|
||||
source TEXT,
|
||||
date_created TEXT,
|
||||
date_modified TEXT,
|
||||
previous_version TEXT,
|
||||
superseded_by TEXT,
|
||||
version INTEGER DEFAULT 1
|
||||
)''')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
|
||||
|
||||
# 获取当日已有多少条,生成连续 ID
|
||||
cursor.execute("SELECT COUNT(*) FROM facts WHERE date_created = ?", (date,))
|
||||
existing_count = cursor.fetchone()[0]
|
||||
|
||||
conflicts = []
|
||||
duplicates = 0
|
||||
inserted = 0
|
||||
|
||||
for i, fact in enumerate(facts):
|
||||
fact_id = generate_fact_id(date, existing_count + i + 1)
|
||||
fact["id"] = fact_id
|
||||
|
||||
if check_duplicate(fact, cursor):
|
||||
duplicates += 1
|
||||
fact["_skipped"] = True
|
||||
continue
|
||||
|
||||
conflict = check_conflict(fact, cursor)
|
||||
if conflict:
|
||||
existing_id, existing_obj, existing_conf = conflict
|
||||
if fact["confidence"] > existing_conf:
|
||||
# 新事实置信度更高 → 覆盖,保留旧值到 previous_version
|
||||
cursor.execute(
|
||||
"""UPDATE facts SET object=?, confidence=?, date_modified=?,
|
||||
previous_version=?, version=version+1 WHERE id=?""",
|
||||
(fact["object"], fact["confidence"], date, existing_obj, existing_id)
|
||||
)
|
||||
conflicts.append({"action": "override", "fact_id": existing_id,
|
||||
"reason": f"新事实置信度更高({fact['confidence']} > {existing_conf})"})
|
||||
else:
|
||||
conflicts.append({"action": "skip", "fact_id": fact_id,
|
||||
"reason": f"旧事实置信度更高({existing_conf} >= {fact['confidence']}),需用户确认"})
|
||||
duplicates += 1
|
||||
fact["_skipped"] = True
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT INTO facts
|
||||
(id, subject, predicate, object, confidence, nature, domain, source,
|
||||
date_created, date_modified, version)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(fact_id, fact["subject"], fact["predicate"], fact["object"],
|
||||
fact.get("confidence", 0.7), fact.get("nature", "fact"),
|
||||
fact.get("domain", ""), fact.get("source", ""),
|
||||
fact.get("date_created", date), fact.get("date_modified", date),
|
||||
fact.get("version", 1))
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"inserted": inserted, "duplicates": duplicates, "conflicts": conflicts}
|
||||
|
||||
|
||||
def archive_to_l3(root: str, date: str, conversations: list):
|
||||
"""将原始对话内容压缩归档到 L3"""
|
||||
raw_dir = Path(root) / "04_memory" / "long_term" / "raw"
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_file = raw_dir / f"{date}.md"
|
||||
|
||||
if archive_file.exists():
|
||||
# 追加模式,不覆盖历史
|
||||
return
|
||||
|
||||
content = f"# L3 原文存档 — {date}\n\n"
|
||||
for conv in conversations:
|
||||
content += f"## 来源:{conv['source_type']} | {conv['source']}\n\n"
|
||||
content += conv["content"]
|
||||
content += "\n\n---\n\n"
|
||||
|
||||
archive_file.write_text(content, encoding="utf-8")
|
||||
print(f" [OK] L3 原文已归档: {archive_file}")
|
||||
|
||||
|
||||
def save_daily_summary(root: str, date: str, facts: list, stats: dict):
|
||||
"""保存每日提炼摘要"""
|
||||
summary_dir = Path(root) / "04_memory" / "daily_summaries"
|
||||
summary_dir.mkdir(parents=True, exist_ok=True)
|
||||
summary_file = summary_dir / f"{date}.md"
|
||||
|
||||
content = f"# {date} 记忆提炼摘要\n\n"
|
||||
content += f"> 提炼时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
content += f"## 统计\n"
|
||||
content += f"- 新增事实: {stats['inserted']}\n"
|
||||
content += f"- 重复跳过: {stats['duplicates']}\n"
|
||||
content += f"- 冲突处理: {len(stats['conflicts'])}\n\n"
|
||||
|
||||
if facts:
|
||||
content += "## 提取的事实\n\n"
|
||||
for fact in facts:
|
||||
marker = "📌" if fact.get("nature") == "fact" else "💭"
|
||||
content += f"- {marker} **[{fact.get('domain', '?')}]** `{fact.get('predicate')}` → {fact['object'][:120]}\n"
|
||||
|
||||
if stats["conflicts"]:
|
||||
content += "\n## 冲突记录\n\n"
|
||||
for c in stats["conflicts"]:
|
||||
content += f"- `{c['fact_id']}`: **{c['action']}** — {c['reason']}\n"
|
||||
|
||||
summary_file.write_text(content, encoding="utf-8")
|
||||
print(f" [OK] 每日摘要已保存: 04_memory/daily_summaries/{date}.md")
|
||||
|
||||
|
||||
def write_conflict_log(root: str, conflicts: list, date: str):
|
||||
"""写入冲突日志"""
|
||||
if not conflicts:
|
||||
return
|
||||
log_dir = Path(root) / "04_memory" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "conflicts.log"
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
for c in conflicts:
|
||||
f.write(f"{datetime.now().isoformat()} | {date} | {c['fact_id']} | {c['action']} | {c['reason']}\n")
|
||||
|
||||
|
||||
def process_date(root: str, target_date: str):
|
||||
"""处理单个日期的完整流程"""
|
||||
print(f"\n{'='*55}")
|
||||
print(f"处理日期: {target_date}")
|
||||
print(f"{'='*55}")
|
||||
|
||||
# 1. 加载对话
|
||||
print("[步骤1] 加载对话数据源...")
|
||||
conversations = load_conversations(root, target_date)
|
||||
|
||||
if not conversations:
|
||||
print(f" [WARN] {target_date} 无可用数据源,跳过")
|
||||
return
|
||||
|
||||
# 2. 提取事实
|
||||
print(f"[步骤2] 提取关键事实...")
|
||||
all_facts = []
|
||||
for conv in conversations:
|
||||
facts = extract_facts(conv["content"], target_date, conv["source_type"])
|
||||
all_facts.extend(facts)
|
||||
print(f" → 从 {conv['source_type']} 提取 {len(facts)} 条候选事实")
|
||||
|
||||
if not all_facts:
|
||||
print(f" [INFO] {target_date} 无新事实可提取")
|
||||
return
|
||||
|
||||
print(f" [INFO] 共 {len(all_facts)} 条候选事实")
|
||||
|
||||
# 3. 更新 L2 事实库
|
||||
print("[步骤3] 更新 L2 事实库...")
|
||||
stats = update_l2_facts(root, all_facts, target_date)
|
||||
print(f" [OK] 新增 {stats['inserted']} 条,重复 {stats['duplicates']} 条,冲突 {len(stats['conflicts'])} 条")
|
||||
|
||||
# 4. 更新 L1 关键词索引(仅更新实际写入 L2 的事实,避免孤儿索引)
|
||||
print("[步骤4] 更新 L1 关键词索引...")
|
||||
inserted_facts = [f for f in all_facts if f.get("id") and not f.get("_skipped")]
|
||||
update_l1_index(root, inserted_facts)
|
||||
print(f" [OK] L1 索引已更新 ({len(inserted_facts)} 条)")
|
||||
|
||||
# 4.5 更新向量库(为新事实生成 embedding 写入 ChromaDB)
|
||||
if inserted_facts:
|
||||
try:
|
||||
print("[步骤4.5] 更新向量库...")
|
||||
# 动态导入同目录的 semantic_search 模块
|
||||
skill_dir = Path(__file__).parent
|
||||
if str(skill_dir) not in sys.path:
|
||||
sys.path.insert(0, str(skill_dir))
|
||||
from semantic_search import VectorStore
|
||||
vector_dir = str(Path(root) / "04_memory" / "vector_db" / "chroma")
|
||||
vs = VectorStore(vector_dir)
|
||||
items = []
|
||||
for f in inserted_facts:
|
||||
text = f"{f['subject']} {f['predicate']} {f['object']}"
|
||||
items.append({
|
||||
"fact_id": f["id"],
|
||||
"text": text,
|
||||
"metadata": {
|
||||
"summary": f["object"][:120],
|
||||
"domain": f.get("domain", ""),
|
||||
"nature": f.get("nature", "fact"),
|
||||
"source": f.get("source", ""),
|
||||
"date_created": f.get("date_created", target_date),
|
||||
"confidence": f.get("confidence", 0.7)
|
||||
}
|
||||
})
|
||||
vs.upsert_batch(items)
|
||||
print(f" [OK] 向量库已更新 ({len(items)} 条)")
|
||||
except Exception as e:
|
||||
print(f" [WARN] 向量库更新失败(不阻塞主流程): {e}")
|
||||
|
||||
# 5. 归档原文到 L3
|
||||
print("[步骤5] 归档原文到 L3...")
|
||||
archive_to_l3(root, target_date, conversations)
|
||||
|
||||
# 6. 保存摘要
|
||||
print("[步骤6] 保存每日摘要...")
|
||||
save_daily_summary(root, target_date, all_facts, stats)
|
||||
|
||||
# 7. 记录冲突
|
||||
print("[步骤7] 记录冲突...")
|
||||
write_conflict_log(root, stats["conflicts"], target_date)
|
||||
if stats["conflicts"]:
|
||||
print(f" [WARN] {len(stats['conflicts'])} 条冲突已记录到 conflicts.log")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = os.path.expanduser(args.root)
|
||||
|
||||
if args.bootstrap:
|
||||
# 冷启动模式:处理所有历史日期
|
||||
print("=" * 55)
|
||||
print("AgentOS 记忆体冷启动(处理所有历史记忆)")
|
||||
print("=" * 55)
|
||||
dates = load_all_available_dates(root)
|
||||
if not dates:
|
||||
print("[WARN] 未找到任何历史记忆文件")
|
||||
return
|
||||
print(f"发现 {len(dates)} 个日期: {', '.join(dates)}")
|
||||
for d in dates:
|
||||
process_date(root, d)
|
||||
print("\n[OK] 冷启动完成!")
|
||||
else:
|
||||
# 普通模式:处理指定日期(默认昨天)
|
||||
if args.date:
|
||||
target_date = args.date
|
||||
else:
|
||||
target_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
process_date(root, target_date)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 记忆导出脚本
|
||||
用途:将 L1 索引 + L2 事实库 + L3 原文目录 + 日志打包为 JSON + Markdown 归档
|
||||
使用:python3 export_memories.py --root ~/workbuddy-agent-os/agent-sync --output ~/backup/
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 记忆导出")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
parser.add_argument("--output", required=True, help="导出目录路径")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def export_l2(db_path: str, output_dir: Path):
|
||||
"""导出 L2 事实库为 JSON"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM facts ORDER BY date_created DESC")
|
||||
facts = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
out_file = output_dir / "l2_facts.json"
|
||||
with open(out_file, "w", encoding="utf-8") as f:
|
||||
json.dump(facts, f, ensure_ascii=False, indent=2)
|
||||
print(f" [OK] L2 导出: {len(facts)} 条事实 → {out_file.name}")
|
||||
return facts
|
||||
|
||||
|
||||
def export_l1(index_path: Path, output_dir: Path):
|
||||
"""导出 L1 关键词索引"""
|
||||
with open(index_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
out_file = output_dir / "l1_keyword_index.json"
|
||||
with open(out_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
print(f" [OK] L1 导出: {len(data['entries'])} 条索引 → {out_file.name}")
|
||||
|
||||
|
||||
def export_l3(raw_dir: Path, output_dir: Path):
|
||||
"""导出 L3 原文存档"""
|
||||
l3_out = output_dir / "l3_raw"
|
||||
l3_out.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
for md_file in raw_dir.glob("*.md"):
|
||||
shutil.copy2(md_file, l3_out / md_file.name)
|
||||
count += 1
|
||||
print(f" [OK] L3 导出: {count} 个文件 → l3_raw/")
|
||||
|
||||
|
||||
def export_logs(log_dir: Path, output_dir: Path):
|
||||
"""导出日志文件"""
|
||||
logs_out = output_dir / "logs"
|
||||
logs_out.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
for log_file in log_dir.glob("*.log"):
|
||||
shutil.copy2(log_file, logs_out / log_file.name)
|
||||
count += 1
|
||||
print(f" [OK] 日志导出: {count} 个文件 → logs/")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = os.path.expanduser(args.root)
|
||||
output_dir = Path(os.path.expanduser(args.output))
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
export_dir = output_dir / f"agent-os-memory-{timestamp}"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("=" * 50)
|
||||
print("AgentOS 记忆导出")
|
||||
print("=" * 50)
|
||||
print(f"源目录: {root}/04_memory/")
|
||||
print(f"导出到: {export_dir}\n")
|
||||
|
||||
# 写入元数据
|
||||
meta = {
|
||||
"export_time": datetime.now().isoformat(),
|
||||
"source_root": root,
|
||||
"agent_os_version": "2.0",
|
||||
}
|
||||
with open(export_dir / "export_meta.json", "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 逐层导出
|
||||
memory_root = Path(root) / "04_memory"
|
||||
export_l1(memory_root / "vector_db" / "keyword_index.json", export_dir)
|
||||
export_l2(str(memory_root / "long_term" / "facts.db"), export_dir)
|
||||
export_l3(memory_root / "long_term" / "raw", export_dir)
|
||||
export_logs(memory_root / "logs", export_dir)
|
||||
|
||||
# 打包(可选)
|
||||
archive_path = str(export_dir) + ".zip"
|
||||
shutil.make_archive(str(export_dir), "zip", export_dir.parent, export_dir.name)
|
||||
|
||||
print(f"\n[OK] 导出完成!")
|
||||
print(f" 目录: {export_dir}")
|
||||
print(f" 压缩包: {archive_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 记忆导入脚本
|
||||
用途:从导出归档恢复记忆,合并时智能去重
|
||||
使用:python3 import_memories.py --root ~/workbuddy-agent-os/agent-sync --input ~/backup/agent-os-memory-20260426.zip
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 记忆导入")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
parser.add_argument("--input", required=True, help="导入的 zip 文件或目录路径")
|
||||
parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际写入")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def extract_source(input_path: str) -> Path:
|
||||
"""从 zip 或目录获取源路径"""
|
||||
p = Path(input_path)
|
||||
if p.is_dir():
|
||||
return p
|
||||
if p.suffix == ".zip":
|
||||
extract_dir = p.parent / p.stem
|
||||
if extract_dir.exists():
|
||||
print(f" [INFO] 解压目录已存在: {extract_dir}")
|
||||
else:
|
||||
with zipfile.ZipFile(p, "r") as z:
|
||||
z.extractall(extract_dir)
|
||||
print(f" [OK] 已解压: {extract_dir}")
|
||||
return extract_dir
|
||||
raise ValueError(f"不支持的输入格式: {input_path}")
|
||||
|
||||
|
||||
def import_l2(src_dir: Path, db_path: Path, dry_run: bool):
|
||||
"""导入 L2 事实,智能去重"""
|
||||
src_file = src_dir / "l2_facts.json"
|
||||
if not src_file.exists():
|
||||
print(" [WARN] 未找到 l2_facts.json,跳过 L2 导入")
|
||||
return
|
||||
|
||||
with open(src_file, "r", encoding="utf-8") as f:
|
||||
imported_facts = json.load(f)
|
||||
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 确保表存在
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.7,
|
||||
nature TEXT DEFAULT 'fact',
|
||||
domain TEXT,
|
||||
source TEXT,
|
||||
date_created TEXT,
|
||||
date_modified TEXT,
|
||||
previous_version TEXT,
|
||||
superseded_by TEXT,
|
||||
version INTEGER DEFAULT 1
|
||||
)''')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
updated = 0
|
||||
|
||||
for fact in imported_facts:
|
||||
fid = fact.get("id", "")
|
||||
# 检查是否已存在
|
||||
cursor.execute("SELECT id, object, confidence FROM facts WHERE id = ?", (fid,))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# 已存在,检查是否需要更新
|
||||
if fact.get("date_modified", "") > (existing[2] or ""):
|
||||
# 导入版本更新 → 覆盖
|
||||
if not dry_run:
|
||||
cursor.execute(
|
||||
"""UPDATE facts SET object=?, confidence=?, nature=?, domain=?,
|
||||
source=?, date_modified=?, version=? WHERE id=?""",
|
||||
(fact["object"], fact["confidence"], fact["nature"],
|
||||
fact["domain"], fact["source"], fact["date_modified"],
|
||||
fact.get("version", 1), fid)
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 三元组去重
|
||||
cursor.execute(
|
||||
"SELECT id FROM facts WHERE subject=? AND predicate=? AND object=?",
|
||||
(fact["subject"], fact["predicate"], fact["object"])
|
||||
)
|
||||
if cursor.fetchone():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 新事实,插入
|
||||
if not dry_run:
|
||||
cursor.execute(
|
||||
"""INSERT INTO facts
|
||||
(id, subject, predicate, object, confidence, nature, domain, source,
|
||||
date_created, date_modified, version)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(fid, fact["subject"], fact["predicate"], fact["object"],
|
||||
fact.get("confidence", 0.7), fact.get("nature", "fact"),
|
||||
fact.get("domain", ""), fact.get("source", "import"),
|
||||
fact.get("date_created", ""), fact.get("date_modified", ""),
|
||||
fact.get("version", 1))
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
if not dry_run:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f" L2 导入: 新增 {inserted} 条,更新 {updated} 条,跳过 {skipped} 条")
|
||||
|
||||
|
||||
def import_l1(src_dir: Path, index_path: Path, db_path: Path, dry_run: bool):
|
||||
"""导入 L1 关键词索引,与 L2 对齐"""
|
||||
src_file = src_dir / "l1_keyword_index.json"
|
||||
if not src_file.exists():
|
||||
print(" [WARN] 未找到 l1_keyword_index.json,跳过 L1 导入")
|
||||
return
|
||||
|
||||
with open(src_file, "r", encoding="utf-8") as f:
|
||||
imported_data = json.load(f)
|
||||
|
||||
# 获取 L2 中实际存在的 ID
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
l2_ids = {row[0] for row in cursor.execute("SELECT id FROM facts").fetchall()}
|
||||
conn.close()
|
||||
|
||||
# 读取现有 L1
|
||||
if index_path.exists():
|
||||
with open(index_path, "r", encoding="utf-8") as f:
|
||||
l1 = json.load(f)
|
||||
else:
|
||||
l1 = {"version": "1.0.0", "last_updated": "", "entries": []}
|
||||
|
||||
existing_ids = {e["fact_id"] for e in l1["entries"]}
|
||||
added = 0
|
||||
|
||||
for entry in imported_data["entries"]:
|
||||
fid = entry["fact_id"]
|
||||
if fid in existing_ids or fid not in l2_ids:
|
||||
continue
|
||||
l1["entries"].append(entry)
|
||||
existing_ids.add(fid)
|
||||
added += 1
|
||||
|
||||
l1["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
if not dry_run:
|
||||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(l1, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f" L1 导入: 新增 {added} 条索引")
|
||||
|
||||
|
||||
def import_l3(src_dir: Path, raw_dir: Path, dry_run: bool):
|
||||
"""导入 L3 原文存档"""
|
||||
src_l3 = src_dir / "l3_raw"
|
||||
if not src_l3.exists():
|
||||
print(" [WARN] 未找到 l3_raw/ 目录,跳过 L3 导入")
|
||||
return
|
||||
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
for md_file in src_l3.glob("*.md"):
|
||||
dest = raw_dir / md_file.name
|
||||
if dest.exists():
|
||||
continue # 不覆盖已有文件
|
||||
if not dry_run:
|
||||
shutil.copy2(md_file, dest)
|
||||
count += 1
|
||||
|
||||
print(f" L3 导入: {count} 个文件")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = os.path.expanduser(args.root)
|
||||
dry_run = args.dry_run
|
||||
|
||||
print("=" * 50)
|
||||
print("AgentOS 记忆导入")
|
||||
if dry_run:
|
||||
print("[DRY-RUN 模式:仅预览,不写入任何文件]")
|
||||
print("=" * 50)
|
||||
|
||||
src_dir = extract_source(args.input)
|
||||
print(f"导入源: {src_dir}\n")
|
||||
|
||||
memory_root = Path(root) / "04_memory"
|
||||
db_path = memory_root / "long_term" / "facts.db"
|
||||
index_path = memory_root / "vector_db" / "keyword_index.json"
|
||||
raw_dir = memory_root / "long_term" / "raw"
|
||||
|
||||
import_l2(src_dir, db_path, dry_run)
|
||||
import_l1(src_dir, index_path, db_path, dry_run)
|
||||
import_l3(src_dir, raw_dir, dry_run)
|
||||
|
||||
print(f"\n{'✅ DRY-RUN 完成' if dry_run else '✅ 导入完成!'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 记忆清理与冲突消解脚本
|
||||
用途:清理过期记忆、消解冲突、归档冷数据
|
||||
使用:python3 memory_cleanup.py --root ~/workbuddy-agent-os/agent-sync [--dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 记忆清理")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际执行")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def cleanup_expired_facts(root: str, dry_run: bool = False):
|
||||
"""清理过期事实(技术类知识 180 天未更新自动标记待复核)"""
|
||||
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
|
||||
if not db_path.exists():
|
||||
print("[WARN] facts.db 不存在,跳过")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 查找 180 天未更新的技术类事实
|
||||
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
|
||||
cursor.execute(
|
||||
"SELECT id, subject, date_modified FROM facts WHERE date_modified < ? AND nature != 'axiom' AND nature != 'opinion'",
|
||||
(cutoff,)
|
||||
)
|
||||
expired = cursor.fetchall()
|
||||
|
||||
if not expired:
|
||||
print("[INFO] 无过期事实")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print(f"[INFO] 发现 {len(expired)} 条可能过期的事实:")
|
||||
for fact in expired:
|
||||
print(f" - {fact[0]}: {fact[1]} (最后更新: {fact[2]})")
|
||||
|
||||
if not dry_run:
|
||||
for fact in expired:
|
||||
cursor.execute(
|
||||
"UPDATE facts SET confidence = confidence * 0.8 WHERE id = ?",
|
||||
(fact[0],)
|
||||
)
|
||||
conn.commit()
|
||||
print(f"[OK] 已降低 {len(expired)} 条过期事实的置信度")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def archive_cold_data(root: str, dry_run: bool = False):
|
||||
"""归档冷数据(超过 180 天未被检索的数据)"""
|
||||
archive_dir = Path(root) / "03_knowledge" / "90_archive" / "deprecated"
|
||||
|
||||
# 检查 03_knowledge 下的文件修改时间
|
||||
knowledge_dir = Path(root) / "03_knowledge"
|
||||
cutoff = datetime.now() - timedelta(days=180)
|
||||
cold_files = []
|
||||
|
||||
for md_file in knowledge_dir.rglob("*.md"):
|
||||
if "99_system" in str(md_file) or "90_archive" in str(md_file):
|
||||
continue
|
||||
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
|
||||
if mtime < cutoff:
|
||||
cold_files.append(md_file)
|
||||
|
||||
if cold_files:
|
||||
print(f"[INFO] 发现 {len(cold_files)} 个冷知识文件:")
|
||||
for f in cold_files:
|
||||
print(f" - {f.relative_to(knowledge_dir)}")
|
||||
if not dry_run:
|
||||
print("[INFO] 冷数据归档需要确认,请手动处理或通过 kb_manager 技能执行")
|
||||
else:
|
||||
print("[INFO] 无冷数据需要归档")
|
||||
|
||||
|
||||
def resolve_conflicts(root: str, dry_run: bool = False):
|
||||
"""消解记忆冲突"""
|
||||
log_path = Path(root) / "04_memory" / "logs" / "conflicts.log"
|
||||
if not log_path.exists() or log_path.stat().st_size == 0:
|
||||
print("[INFO] 无待消解冲突")
|
||||
return
|
||||
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
conflicts = f.readlines()
|
||||
|
||||
if conflicts:
|
||||
print(f"[INFO] 发现 {len(conflicts)} 条冲突记录:")
|
||||
for c in conflicts[:10]: # 只显示前 10 条
|
||||
print(f" - {c.strip()}")
|
||||
if len(conflicts) > 10:
|
||||
print(f" ... 还有 {len(conflicts) - 10} 条")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = os.path.expanduser(args.root)
|
||||
dry_run = args.dry_run
|
||||
|
||||
print("=" * 50)
|
||||
print("AgentOS 记忆清理")
|
||||
if dry_run:
|
||||
print("[DRY-RUN 模式:仅预览,不执行]")
|
||||
print("=" * 50)
|
||||
|
||||
cleanup_expired_facts(root, dry_run)
|
||||
archive_cold_data(root, dry_run)
|
||||
resolve_conflicts(root, dry_run)
|
||||
|
||||
print()
|
||||
print("[OK] 记忆清理完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
memory_extractor.py — 记忆固化提取器
|
||||
|
||||
从本机 L2 事实库(facts.db)和每日摘要中,检测满足固化条件的记忆,
|
||||
生成标准 MD 知识卡并推送到协同收件箱 inbox/memory/。
|
||||
|
||||
固化条件(满足任意一条即触发):
|
||||
1. 用户主动标记 --solidify <fact_id>
|
||||
2. 高频引用:同一主谓对(subject+predicate)在 N 天内出现 >= threshold 次
|
||||
3. 高置信度:confidence >= 0.85 且 version >= 2(被多次确认未被推翻)
|
||||
|
||||
使用:
|
||||
python3 memory_extractor.py --root ~/workbuddy-agent-os/agent-sync --local ~/workbuddy-agent-os/agent-local
|
||||
python3 memory_extractor.py --root ~/workbuddy-agent-os/agent-sync --local ~/workbuddy-agent-os/agent-local --solidify FACT-2026-0001
|
||||
python3 memory_extractor.py --root ~/workbuddy-agent-os/agent-sync --local ~/workbuddy-agent-os/agent-local --dry-run
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ─── 固化阈值配置 ─────────────────────────────────────────────────
|
||||
DEFAULT_FREQ_DAYS = 7 # 高频检测窗口(天)
|
||||
DEFAULT_FREQ_THRESHOLD = 3 # 窗口内出现次数阈值
|
||||
DEFAULT_CONFIDENCE_MIN = 0.85 # 高置信度阈值
|
||||
DEFAULT_VERSION_MIN = 2 # 最小版本数(被多次确认)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 记忆固化提取器")
|
||||
parser.add_argument("--root", default="~/workbuddy-agent-os/agent-sync", help="agent-os 根目录")
|
||||
parser.add_argument("--local", default="~/workbuddy-agent-os/agent-local", help="~/workbuddy-agent-os/agent-local")
|
||||
parser.add_argument("--solidify", default=None, help="强制固化指定 fact_id")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不写文件")
|
||||
parser.add_argument("--freq-days", type=int, default=DEFAULT_FREQ_DAYS)
|
||||
parser.add_argument("--freq-threshold", type=int, default=DEFAULT_FREQ_THRESHOLD)
|
||||
parser.add_argument("--confidence-min", type=float, default=DEFAULT_CONFIDENCE_MIN)
|
||||
parser.add_argument("--version-min", type=int, default=DEFAULT_VERSION_MIN)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def open_facts_db(local_root: Path):
|
||||
"""打开本机 L2 事实库"""
|
||||
# L2 事实库路径(daily_digest.py 写入的路径)
|
||||
db_path = local_root / "memory" / "raw" / "04_memory" / "long_term" / "facts.db"
|
||||
if not db_path.exists():
|
||||
print(f"[WARN] 事实库不存在: {db_path}")
|
||||
return None, None
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn, conn.cursor()
|
||||
|
||||
|
||||
def detect_high_frequency(cursor, days: int, threshold: int) -> list:
|
||||
"""
|
||||
检测高频引用事实:
|
||||
在近 N 天内,subject+predicate 组合出现 >= threshold 次的最新事实
|
||||
"""
|
||||
since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
cursor.execute("""
|
||||
SELECT subject, predicate, COUNT(*) as cnt,
|
||||
MAX(confidence) as max_conf,
|
||||
MAX(date_modified) as latest_date
|
||||
FROM facts
|
||||
WHERE date_created >= ? AND superseded_by = ''
|
||||
GROUP BY subject, predicate
|
||||
HAVING cnt >= ?
|
||||
ORDER BY cnt DESC
|
||||
""", (since, threshold))
|
||||
groups = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in groups:
|
||||
# 取该组最新/最高置信度的事实记录
|
||||
cursor.execute("""
|
||||
SELECT * FROM facts
|
||||
WHERE subject = ? AND predicate = ? AND superseded_by = ''
|
||||
ORDER BY confidence DESC, version DESC
|
||||
LIMIT 1
|
||||
""", (row["subject"], row["predicate"]))
|
||||
fact = cursor.fetchone()
|
||||
if fact:
|
||||
results.append(dict(fact) | {"solidify_reason": f"高频引用({row['cnt']}次/{days}天)"})
|
||||
return results
|
||||
|
||||
|
||||
def detect_high_confidence(cursor, conf_min: float, version_min: int) -> list:
|
||||
"""检测高置信度且多次确认的事实"""
|
||||
cursor.execute("""
|
||||
SELECT * FROM facts
|
||||
WHERE confidence >= ? AND version >= ? AND superseded_by = ''
|
||||
ORDER BY confidence DESC, version DESC
|
||||
""", (conf_min, version_min))
|
||||
rows = cursor.fetchall()
|
||||
return [dict(r) | {"solidify_reason": f"高置信度({r['confidence']:.2f})·已确认{r['version']}次"} for r in rows]
|
||||
|
||||
|
||||
def get_fact_by_id(cursor, fact_id: str) -> dict:
|
||||
"""按ID获取指定事实"""
|
||||
cursor.execute("SELECT * FROM facts WHERE id = ?", (fact_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) | {"solidify_reason": "用户主动固化"} if row else None
|
||||
|
||||
|
||||
def already_solidified(inbox_memory_dir: Path, fact_id: str) -> bool:
|
||||
"""检查该事实是否已被推送到inbox(避免重复)"""
|
||||
for md in inbox_memory_dir.glob("*.md"):
|
||||
content = md.read_text(encoding="utf-8", errors="ignore")[:500]
|
||||
if fact_id in content:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def generate_memory_card(fact: dict, today: str) -> tuple[str, str]:
|
||||
"""
|
||||
生成记忆固化知识卡 MD 内容
|
||||
|
||||
返回: (filename, content)
|
||||
"""
|
||||
fact_id = fact.get("id", "")
|
||||
subject = fact.get("subject", "unknown")
|
||||
predicate = fact.get("predicate", "")
|
||||
obj = fact.get("object", "")
|
||||
domain = fact.get("domain", "personal-management")
|
||||
nature = fact.get("nature", "fact")
|
||||
confidence = fact.get("confidence", 0.7)
|
||||
source = fact.get("source", "memory_system")
|
||||
reason = fact.get("solidify_reason", "")
|
||||
version = fact.get("version", 1)
|
||||
date_created = fact.get("date_created", today)
|
||||
|
||||
# 生成知识卡 ID(MEM前缀区别于KB)
|
||||
mem_id = f"MEM-{today.replace('-', '')}-{fact_id[-4:] if len(fact_id) >= 4 else '0001'}"
|
||||
|
||||
# 标题:用 predicate + 前40字
|
||||
title = f"{predicate}: {obj[:40]}{'...' if len(obj) > 40 else ''}"
|
||||
|
||||
frontmatter = f"""---
|
||||
id: {mem_id}
|
||||
source_fact_id: {fact_id}
|
||||
title: "{title}"
|
||||
nature: {nature}
|
||||
domain: [{domain}]
|
||||
confidence: {confidence}
|
||||
source: "{source}"
|
||||
source_type: memory_solidification
|
||||
solidify_reason: "{reason}"
|
||||
subject: "{subject}"
|
||||
predicate: "{predicate}"
|
||||
version: {version}
|
||||
date_created: {date_created}
|
||||
date_solidified: {today}
|
||||
status: draft
|
||||
track: memory
|
||||
---"""
|
||||
|
||||
body = f"""# {title}
|
||||
|
||||
**来源**:本机记忆系统(自动固化)
|
||||
**固化原因**:{reason}
|
||||
**主体**:{subject}
|
||||
**谓语**:{predicate}
|
||||
**置信度**:{confidence:.2f}
|
||||
**原始事实ID**:`{fact_id}`
|
||||
|
||||
---
|
||||
|
||||
## 内容
|
||||
|
||||
{obj}
|
||||
|
||||
---
|
||||
|
||||
> *本卡片由 memory_extractor.py 自动生成,请主机审核后归档入知识库。*
|
||||
"""
|
||||
|
||||
safe_pred = re.sub(r'[^\w\u4e00-\u9fff-]', '_', predicate)[:20]
|
||||
filename = f"MEM-{today.replace('-', '')}-{safe_pred}-{fact_id[-6:]}.md"
|
||||
|
||||
return filename, frontmatter + "\n" + body
|
||||
|
||||
|
||||
def write_to_inbox(inbox_memory_dir: Path, filename: str, content: str, dry_run: bool) -> Path:
|
||||
"""写入协同 inbox/memory/ 分区"""
|
||||
inbox_memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = inbox_memory_dir / filename
|
||||
if not dry_run:
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def mark_solidified(cursor, conn, fact_id: str, dry_run: bool):
|
||||
"""在 facts.db 中标记该事实已固化(用 superseded_by 记录固化标记)"""
|
||||
if dry_run:
|
||||
return
|
||||
cursor.execute(
|
||||
"UPDATE facts SET superseded_by = ? WHERE id = ?",
|
||||
(f"SOLIDIFIED:{datetime.now().strftime('%Y-%m-%d')}", fact_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def write_log(root: Path, log_entries: list):
|
||||
"""记录固化日志"""
|
||||
log_dir = root / "04_memory" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "memory_solidification.log"
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
for entry in log_entries:
|
||||
f.write(f"{entry}\n")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = Path(args.root).expanduser()
|
||||
local_root = Path(args.local).expanduser()
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# 协同 inbox/memory/ 路径
|
||||
inbox_memory_dir = root / "03_knowledge" / "00_stream" / "inbox" / "memory"
|
||||
|
||||
print("=" * 60)
|
||||
print("AgentOS 记忆固化提取器")
|
||||
print(f" root: {root}")
|
||||
print(f" local: {local_root}")
|
||||
print(f" inbox: {inbox_memory_dir}")
|
||||
if args.dry_run:
|
||||
print(" [DRY-RUN 模式,不写入文件]")
|
||||
print("=" * 60)
|
||||
|
||||
conn, cursor = open_facts_db(local_root)
|
||||
if conn is None:
|
||||
print("[ERROR] 无法打开事实库,退出")
|
||||
sys.exit(1)
|
||||
|
||||
# ── 收集待固化事实 ────────────────────────────────────────────
|
||||
candidates = []
|
||||
seen_ids = set()
|
||||
|
||||
if args.solidify:
|
||||
# 模式1:用户主动指定
|
||||
fact = get_fact_by_id(cursor, args.solidify)
|
||||
if fact:
|
||||
candidates.append(fact)
|
||||
seen_ids.add(fact["id"])
|
||||
print(f"[INFO] 用户指定固化: {args.solidify}")
|
||||
else:
|
||||
print(f"[ERROR] 未找到事实: {args.solidify}")
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 模式2:高频检测
|
||||
freq_facts = detect_high_frequency(cursor, args.freq_days, args.freq_threshold)
|
||||
for f in freq_facts:
|
||||
if f["id"] not in seen_ids:
|
||||
candidates.append(f)
|
||||
seen_ids.add(f["id"])
|
||||
|
||||
# 模式3:高置信度检测
|
||||
conf_facts = detect_high_confidence(cursor, args.confidence_min, args.version_min)
|
||||
for f in conf_facts:
|
||||
if f["id"] not in seen_ids:
|
||||
candidates.append(f)
|
||||
seen_ids.add(f["id"])
|
||||
|
||||
print(f"[INFO] 检测到 {len(candidates)} 条候选固化事实")
|
||||
|
||||
if not candidates:
|
||||
print("[INFO] 无需固化,退出")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# ── 过滤已固化 ────────────────────────────────────────────────
|
||||
new_candidates = []
|
||||
for fact in candidates:
|
||||
if already_solidified(inbox_memory_dir, fact["id"]):
|
||||
print(f" ⏭ 已固化跳过: {fact['id']}")
|
||||
else:
|
||||
new_candidates.append(fact)
|
||||
|
||||
print(f"[INFO] 新增固化: {len(new_candidates)} 条")
|
||||
|
||||
if not new_candidates:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# ── 生成并写入知识卡 ──────────────────────────────────────────
|
||||
log_entries = []
|
||||
pushed = 0
|
||||
|
||||
for fact in new_candidates:
|
||||
filename, content = generate_memory_card(fact, today)
|
||||
target = write_to_inbox(inbox_memory_dir, filename, content, args.dry_run)
|
||||
|
||||
action = "[DRY-RUN]" if args.dry_run else "[写入]"
|
||||
print(f" {action} {filename}")
|
||||
print(f" 原因: {fact['solidify_reason']}")
|
||||
|
||||
# 标记已固化(仅非dry-run)
|
||||
mark_solidified(cursor, conn, fact["id"], args.dry_run)
|
||||
|
||||
log_entries.append(
|
||||
f"{datetime.now().isoformat()} | {fact['id']} | {fact['solidify_reason']} | {filename}"
|
||||
)
|
||||
pushed += 1
|
||||
|
||||
# ── 记录日志 ──────────────────────────────────────────────────
|
||||
if not args.dry_run and log_entries:
|
||||
write_log(root, log_entries)
|
||||
|
||||
conn.close()
|
||||
|
||||
print(f"\n[完成] 固化推送 {pushed} 条 → {inbox_memory_dir}")
|
||||
print(" 主维护机可通过 inbox_refine.py 将这些内容归档入知识库。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,664 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 语义检索模块
|
||||
用途:BM25 + 向量语义混合检索,为记忆体提供语义搜索能力
|
||||
|
||||
架构:
|
||||
L1 BM25 检索(关键词匹配,基于 keyword_index.json)
|
||||
+ L1_vec 向量检索(语义匹配,基于 ChromaDB + oMLX embedding)
|
||||
→ Reciprocal Rank Fusion (RRF) 加权融合
|
||||
→ 返回排序后的 fact_id 列表
|
||||
|
||||
使用:
|
||||
# 检索
|
||||
python3 semantic_search.py search --root ~/workbuddy-agent-os/agent-sync --query "用户偏好什么编程语言" --top-k 5
|
||||
|
||||
# 向量化单条
|
||||
python3 semantic_search.py embed --root ~/workbuddy-agent-os/agent-sync --text "测试文本"
|
||||
|
||||
# 回填所有历史数据
|
||||
python3 semantic_search.py backfill --root ~/workbuddy-agent-os/agent-sync
|
||||
|
||||
# 清除向量库并重建
|
||||
python3 semantic_search.py rebuild --root ~/workbuddy-agent-os/agent-sync
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ─── 默认配置 ──────────────────────────────────────────────────
|
||||
EMBEDDING_API_URL = "http://localhost:8000/v1/embeddings"
|
||||
EMBEDDING_MODEL = "Qwen3-Embedding-0.6B"
|
||||
EMBEDDING_API_KEY = "omlx"
|
||||
EMBEDDING_DIM = 1024
|
||||
CHROMA_COLLECTION = "agentos_memories"
|
||||
BM25_WEIGHT = 0.4 # BM25 在融合中的权重
|
||||
VECTOR_WEIGHT = 0.6 # 向量相似度在融合中的权重
|
||||
RRF_K = 60 # RRF 常数,越大排名差异的影响越小
|
||||
|
||||
|
||||
# ─── Embedding API 调用 ─────────────────────────────────────────
|
||||
def get_embeddings(texts, batch_size=5, retry=3, delay=1.0):
|
||||
"""
|
||||
调用 oMLX embedding API 获取文本向量。
|
||||
|
||||
Args:
|
||||
texts: 字符串或字符串列表
|
||||
batch_size: 每批请求的文本数量(oMLX 对大批量可能超时)
|
||||
retry: 失败重试次数
|
||||
delay: 批次间延迟(秒)
|
||||
|
||||
Returns:
|
||||
向量列表(与输入顺序对应)
|
||||
"""
|
||||
import requests
|
||||
|
||||
if isinstance(texts, str):
|
||||
texts = [texts]
|
||||
|
||||
all_embeddings = []
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i:i + batch_size]
|
||||
for attempt in range(retry):
|
||||
try:
|
||||
resp = requests.post(
|
||||
EMBEDDING_API_URL,
|
||||
json={"model": EMBEDDING_MODEL, "input": batch},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {EMBEDDING_API_KEY}"
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# 按 index 排序确保顺序一致
|
||||
sorted_data = sorted(data["data"], key=lambda x: x["index"])
|
||||
batch_embeddings = [item["embedding"] for item in sorted_data]
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < retry - 1:
|
||||
time.sleep(delay * (attempt + 1))
|
||||
else:
|
||||
raise RuntimeError(f"Embedding API 调用失败(批次 {i//batch_size + 1}): {e}")
|
||||
|
||||
if i + batch_size < len(texts):
|
||||
time.sleep(delay)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
|
||||
# ─── ChromaDB 向量存储 ─────────────────────────────────────────
|
||||
class VectorStore:
|
||||
"""基于 ChromaDB 的向量存储,持久化到磁盘"""
|
||||
|
||||
def __init__(self, persist_dir: str):
|
||||
import chromadb
|
||||
self.persist_dir = Path(persist_dir)
|
||||
self.persist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.client = chromadb.PersistentClient(path=str(self.persist_dir))
|
||||
self.collection = self.client.get_or_create_collection(
|
||||
name=CHROMA_COLLECTION,
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
def upsert(self, fact_id: str, text: str, metadata: dict, embedding: list = None):
|
||||
"""添加或更新一条向量记录"""
|
||||
if embedding is None:
|
||||
embeddings = get_embeddings([text])
|
||||
embedding = embeddings[0]
|
||||
|
||||
self.collection.upsert(
|
||||
ids=[fact_id],
|
||||
documents=[text],
|
||||
embeddings=[embedding],
|
||||
metadatas=[metadata]
|
||||
)
|
||||
|
||||
def upsert_batch(self, items: list):
|
||||
"""
|
||||
批量添加/更新向量记录。
|
||||
|
||||
Args:
|
||||
items: [{"fact_id": str, "text": str, "metadata": dict}, ...]
|
||||
"""
|
||||
if not items:
|
||||
return
|
||||
|
||||
ids = [item["fact_id"] for item in items]
|
||||
texts = [item["text"] for item in items]
|
||||
metadatas = [item["metadata"] for item in items]
|
||||
|
||||
# 分批 embedding(避免 API 超时)
|
||||
batch_size = 5
|
||||
all_embeddings = []
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch_emb = get_embeddings(texts[i:i + batch_size])
|
||||
all_embeddings.extend(batch_emb)
|
||||
if i + batch_size < len(texts):
|
||||
time.sleep(0.5)
|
||||
|
||||
# 分批 upsert(ChromaDB 建议每批 < 100)
|
||||
upsert_batch_size = 50
|
||||
for i in range(0, len(ids), upsert_batch_size):
|
||||
self.collection.upsert(
|
||||
ids=ids[i:i + upsert_batch_size],
|
||||
documents=texts[i:i + upsert_batch_size],
|
||||
embeddings=all_embeddings[i:i + upsert_batch_size],
|
||||
metadatas=metadatas[i:i + upsert_batch_size]
|
||||
)
|
||||
|
||||
def search(self, query: str, top_k: int = 10, query_embedding: list = None) -> list:
|
||||
"""
|
||||
向量相似度搜索。
|
||||
|
||||
Returns:
|
||||
[{"fact_id": str, "score": float, "metadata": dict}, ...]
|
||||
"""
|
||||
if query_embedding is None:
|
||||
query_embedding = get_embeddings([query])[0]
|
||||
|
||||
results = self.collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=min(top_k, self.collection.count()),
|
||||
include=["metadatas", "documents", "distances"]
|
||||
)
|
||||
|
||||
items = []
|
||||
if results["ids"] and results["ids"][0]:
|
||||
for i, fid in enumerate(results["ids"][0]):
|
||||
distance = results["distances"][0][i] if results["distances"] else 0
|
||||
# ChromaDB cosine distance → 相似度分数(1 - distance)
|
||||
score = 1.0 - distance
|
||||
items.append({
|
||||
"fact_id": fid,
|
||||
"score": max(0, score),
|
||||
"metadata": results["metadatas"][0][i] if results["metadatas"] else {},
|
||||
"document": results["documents"][0][i] if results["documents"] else ""
|
||||
})
|
||||
return items
|
||||
|
||||
def delete(self, fact_id: str):
|
||||
"""删除一条向量记录"""
|
||||
self.collection.delete(ids=[fact_id])
|
||||
|
||||
def count(self) -> int:
|
||||
"""返回向量总数"""
|
||||
return self.collection.count()
|
||||
|
||||
def get_all_ids(self) -> list:
|
||||
"""获取所有 fact_id"""
|
||||
result = self.collection.get(include=[])
|
||||
return result["ids"] if result["ids"] else []
|
||||
|
||||
|
||||
# ─── BM25 关键词检索 ────────────────────────────────────────────
|
||||
class BM25Searcher:
|
||||
"""基于 L1 keyword_index.json 的 BM25 检索"""
|
||||
|
||||
def __init__(self, index_path: str):
|
||||
self.index_path = Path(index_path)
|
||||
self._build_index()
|
||||
|
||||
def _build_index(self):
|
||||
"""构建 BM25 索引"""
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
if not self.index_path.exists():
|
||||
self.entries = []
|
||||
self.bm25 = None
|
||||
return
|
||||
|
||||
with open(self.index_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.entries = data.get("entries", [])
|
||||
if not self.entries:
|
||||
self.bm25 = None
|
||||
return
|
||||
|
||||
# 用 keywords + summary 构建 BM25 语料库
|
||||
self.corpus = []
|
||||
for entry in self.entries:
|
||||
# 合并关键词和摘要作为 BM25 文档
|
||||
doc_words = entry.get("keywords", []) + [entry.get("summary", "")]
|
||||
# 中文分词:按 2-4 字切分 + 英文按空格分
|
||||
tokens = []
|
||||
for w in doc_words:
|
||||
if re.match(r'^[\u4e00-\u9fff]+$', w):
|
||||
# 中文:2-4 字 ngram
|
||||
for n in range(2, min(5, len(w) + 1)):
|
||||
tokens.extend([w[i:i+n] for i in range(len(w) - n + 1)])
|
||||
tokens.append(w) # 也保留完整词
|
||||
else:
|
||||
tokens.extend(w.lower().split())
|
||||
self.corpus.append(tokens)
|
||||
|
||||
self.bm25 = BM25Okapi(self.corpus)
|
||||
|
||||
def search(self, query: str, top_k: int = 10) -> list:
|
||||
"""
|
||||
BM25 关键词搜索。
|
||||
|
||||
Returns:
|
||||
[{"fact_id": str, "score": float, "keywords": list}, ...]
|
||||
"""
|
||||
if not self.bm25:
|
||||
return []
|
||||
|
||||
# 查询分词
|
||||
query_tokens = []
|
||||
for w in re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{2,}', query):
|
||||
if re.match(r'^[\u4e00-\u9fff]+$', w):
|
||||
for n in range(2, min(5, len(w) + 1)):
|
||||
query_tokens.extend([w[i:i+n] for i in range(len(w) - n + 1)])
|
||||
query_tokens.append(w)
|
||||
else:
|
||||
query_tokens.extend(w.lower().split())
|
||||
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
scores = self.bm25.get_scores(query_tokens)
|
||||
|
||||
# 取 top_k
|
||||
ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)[:top_k]
|
||||
results = []
|
||||
for idx, score in ranked:
|
||||
if score > 0:
|
||||
entry = self.entries[idx]
|
||||
results.append({
|
||||
"fact_id": entry.get("fact_id", ""),
|
||||
"score": float(score),
|
||||
"keywords": entry.get("keywords", []),
|
||||
"summary": entry.get("summary", ""),
|
||||
"domain": entry.get("domain", ""),
|
||||
"nature": entry.get("nature", "fact")
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Reciprocal Rank Fusion ─────────────────────────────────────
|
||||
def reciprocal_rank_fusion(bm25_results: list, vector_results: list,
|
||||
bm25_weight: float = BM25_WEIGHT,
|
||||
vector_weight: float = VECTOR_WEIGHT,
|
||||
rrf_k: int = RRF_K) -> list:
|
||||
"""
|
||||
使用 Reciprocal Rank Fusion 融合 BM25 和向量检索结果。
|
||||
|
||||
RRF 公式:score(d) = Σ 1/(k + rank(d))
|
||||
加权:score(d) = w_bm25 * rrf_bm25(d) + w_vec * rrf_vec(d)
|
||||
|
||||
Returns:
|
||||
[{"fact_id": str, "rrf_score": float, "bm25_rank": int, "vec_rank": int,
|
||||
"bm25_score": float, "vec_score": float}, ...]
|
||||
"""
|
||||
fact_scores = defaultdict(lambda: {
|
||||
"rrf_bm25": 0.0, "rrf_vec": 0.0,
|
||||
"bm25_rank": None, "vec_rank": None,
|
||||
"bm25_score": 0.0, "vec_score": 0.0,
|
||||
"summary": "", "domain": "", "nature": ""
|
||||
})
|
||||
|
||||
# BM25 排名贡献
|
||||
for rank, item in enumerate(bm25_results, 1):
|
||||
fid = item["fact_id"]
|
||||
fact_scores[fid]["rrf_bm25"] = 1.0 / (rrf_k + rank)
|
||||
fact_scores[fid]["bm25_rank"] = rank
|
||||
fact_scores[fid]["bm25_score"] = item["score"]
|
||||
fact_scores[fid]["summary"] = item.get("summary", "")
|
||||
fact_scores[fid]["domain"] = item.get("domain", "")
|
||||
fact_scores[fid]["nature"] = item.get("nature", "fact")
|
||||
|
||||
# 向量排名贡献
|
||||
for rank, item in enumerate(vector_results, 1):
|
||||
fid = item["fact_id"]
|
||||
fact_scores[fid]["rrf_vec"] = 1.0 / (rrf_k + rank)
|
||||
fact_scores[fid]["vec_rank"] = rank
|
||||
fact_scores[fid]["vec_score"] = item["score"]
|
||||
if item.get("metadata"):
|
||||
fact_scores[fid]["summary"] = item["metadata"].get("summary", fact_scores[fid]["summary"])
|
||||
fact_scores[fid]["domain"] = item["metadata"].get("domain", fact_scores[fid]["domain"])
|
||||
fact_scores[fid]["nature"] = item["metadata"].get("nature", fact_scores[fid]["nature"])
|
||||
if item.get("document") and not fact_scores[fid]["summary"]:
|
||||
fact_scores[fid]["summary"] = item["document"][:120]
|
||||
|
||||
# 加权融合 + 排序
|
||||
fused = []
|
||||
for fid, scores in fact_scores.items():
|
||||
rrf_score = bm25_weight * scores["rrf_bm25"] + vector_weight * scores["rrf_vec"]
|
||||
fused.append({
|
||||
"fact_id": fid,
|
||||
"rrf_score": rrf_score,
|
||||
"bm25_rank": scores["bm25_rank"],
|
||||
"vec_rank": scores["vec_rank"],
|
||||
"bm25_score": scores["bm25_score"],
|
||||
"vec_score": scores["vec_score"],
|
||||
"summary": scores["summary"],
|
||||
"domain": scores["domain"],
|
||||
"nature": scores["nature"]
|
||||
})
|
||||
|
||||
fused.sort(key=lambda x: x["rrf_score"], reverse=True)
|
||||
return fused
|
||||
|
||||
|
||||
# ─── 统一检索入口 ──────────────────────────────────────────────
|
||||
class MemorySearcher:
|
||||
"""
|
||||
AgentOS 记忆检索器 — BM25 + 向量语义混合检索
|
||||
|
||||
Usage:
|
||||
searcher = MemorySearcher("/path/to/agent-os")
|
||||
results = searcher.search("用户偏好什么编程语言", top_k=5)
|
||||
"""
|
||||
|
||||
def __init__(self, root: str):
|
||||
self.root = Path(root)
|
||||
self.vector_store = VectorStore(str(self.root / "04_memory" / "vector_db" / "chroma"))
|
||||
self.bm25_searcher = BM25Searcher(str(self.root / "04_memory" / "vector_db" / "keyword_index.json"))
|
||||
self.db_path = self.root / "04_memory" / "long_term" / "facts.db"
|
||||
|
||||
def search(self, query: str, top_k: int = 10,
|
||||
bm25_weight: float = BM25_WEIGHT,
|
||||
vector_weight: float = VECTOR_WEIGHT) -> dict:
|
||||
"""
|
||||
混合检索入口。
|
||||
|
||||
Args:
|
||||
query: 检索查询文本
|
||||
top_k: 返回结果数量
|
||||
bm25_weight: BM25 权重(0-1)
|
||||
vector_weight: 向量权重(0-1)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"query": str,
|
||||
"total_vector": int, # 向量库总数
|
||||
"results": [
|
||||
{
|
||||
"fact_id": str,
|
||||
"rrf_score": float,
|
||||
"bm25_rank": int|null,
|
||||
"vec_rank": int|null,
|
||||
"confidence": float, # L2 置信度
|
||||
"subject": str,
|
||||
"predicate": str,
|
||||
"object": str, # L2 完整事实
|
||||
"summary": str, # L1 摘要
|
||||
"domain": str,
|
||||
"nature": str
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
"""
|
||||
# 并行执行两种检索
|
||||
bm25_results = self.bm25_searcher.search(query, top_k=top_k * 2)
|
||||
vector_results = self.vector_store.search(query, top_k=top_k * 2)
|
||||
|
||||
# RRF 融合
|
||||
fused = reciprocal_rank_fusion(
|
||||
bm25_results, vector_results,
|
||||
bm25_weight=bm25_weight,
|
||||
vector_weight=vector_weight
|
||||
)
|
||||
|
||||
# 截取 top_k
|
||||
fused = fused[:top_k]
|
||||
|
||||
# 从 L2 获取完整事实信息
|
||||
results = self._enrich_with_l2(fused)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"total_vector": self.vector_store.count(),
|
||||
"bm25_results": len(bm25_results),
|
||||
"vector_results": len(vector_results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
def _enrich_with_l2(self, fused_results: list) -> list:
|
||||
"""从 L2 facts.db 补充完整事实信息"""
|
||||
if not self.db_path.exists():
|
||||
return fused_results
|
||||
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
enriched = []
|
||||
for item in fused_results:
|
||||
fid = item["fact_id"]
|
||||
row = cursor.execute(
|
||||
"SELECT subject, predicate, object, confidence, nature, domain FROM facts WHERE id = ?",
|
||||
(fid,)
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
enriched.append({
|
||||
"fact_id": fid,
|
||||
"rrf_score": round(item["rrf_score"], 6),
|
||||
"bm25_rank": item["bm25_rank"],
|
||||
"vec_rank": item["vec_rank"],
|
||||
"confidence": row["confidence"],
|
||||
"subject": row["subject"],
|
||||
"predicate": row["predicate"],
|
||||
"object": row["object"],
|
||||
"summary": item.get("summary", row["object"][:120]),
|
||||
"domain": row["domain"],
|
||||
"nature": row["nature"]
|
||||
})
|
||||
else:
|
||||
# L2 中不存在(不应该发生,但防御性处理)
|
||||
enriched.append({
|
||||
"fact_id": fid,
|
||||
"rrf_score": round(item["rrf_score"], 6),
|
||||
"bm25_rank": item["bm25_rank"],
|
||||
"vec_rank": item["vec_rank"],
|
||||
"confidence": 0,
|
||||
"subject": "",
|
||||
"predicate": "",
|
||||
"object": item.get("summary", ""),
|
||||
"summary": item.get("summary", ""),
|
||||
"domain": item.get("domain", ""),
|
||||
"nature": item.get("nature", "")
|
||||
})
|
||||
|
||||
conn.close()
|
||||
return enriched
|
||||
|
||||
def add_fact(self, fact_id: str, text: str, metadata: dict):
|
||||
"""添加单条事实到向量库"""
|
||||
self.vector_store.upsert(fact_id, text, metadata)
|
||||
|
||||
def remove_fact(self, fact_id: str):
|
||||
"""从向量库删除一条事实"""
|
||||
self.vector_store.delete(fact_id)
|
||||
|
||||
def backfill_all(self, batch_size: int = 5, verbose: bool = True):
|
||||
"""
|
||||
回填所有 L2 事实到向量库。
|
||||
从 facts.db 读取所有事实,生成 embedding 并写入 ChromaDB。
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
print("[ERROR] facts.db 不存在")
|
||||
return 0
|
||||
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
facts = cursor.execute(
|
||||
"SELECT id, subject, predicate, object, confidence, nature, domain, source, date_created FROM facts"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not facts:
|
||||
print("[INFO] facts.db 为空,无需回填")
|
||||
return 0
|
||||
|
||||
# 获取向量库已有 ID
|
||||
existing_ids = set(self.vector_store.get_all_ids())
|
||||
|
||||
# 构建待回填列表
|
||||
to_fill = []
|
||||
for fact in facts:
|
||||
if fact["id"] not in existing_ids:
|
||||
text = f"{fact['subject']} {fact['predicate']} {fact['object']}"
|
||||
metadata = {
|
||||
"summary": fact["object"][:120],
|
||||
"domain": fact["domain"],
|
||||
"nature": fact["nature"],
|
||||
"source": fact["source"],
|
||||
"date_created": fact["date_created"],
|
||||
"confidence": fact["confidence"]
|
||||
}
|
||||
to_fill.append({
|
||||
"fact_id": fact["id"],
|
||||
"text": text,
|
||||
"metadata": metadata
|
||||
})
|
||||
|
||||
if not to_fill:
|
||||
print(f"[INFO] 全部 {len(facts)} 条事实已在向量库中,无需回填")
|
||||
return 0
|
||||
|
||||
if verbose:
|
||||
print(f"[INFO] 需要回填 {len(to_fill)} 条事实(总共 {len(facts)} 条)")
|
||||
|
||||
# 分批处理
|
||||
filled = 0
|
||||
for i in range(0, len(to_fill), batch_size):
|
||||
batch = to_fill[i:i + batch_size]
|
||||
try:
|
||||
self.vector_store.upsert_batch(batch)
|
||||
filled += len(batch)
|
||||
if verbose:
|
||||
pct = filled / len(to_fill) * 100
|
||||
print(f" [{pct:5.1f}%] {filled}/{len(to_fill)} 已回填")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] 批次 {i//batch_size + 1} 失败: {e}")
|
||||
# 尝试逐条重试
|
||||
for item in batch:
|
||||
try:
|
||||
self.vector_store.upsert(
|
||||
item["fact_id"], item["text"],
|
||||
item["metadata"]
|
||||
)
|
||||
filled += 1
|
||||
if verbose:
|
||||
print(f" [RETRY] {item['fact_id']} OK")
|
||||
except Exception as e2:
|
||||
print(f" [FAIL] {item['fact_id']}: {e2}")
|
||||
|
||||
if verbose:
|
||||
print(f"[OK] 回填完成: {filled}/{len(to_fill)} 成功, 向量库总数: {self.vector_store.count()}")
|
||||
return filled
|
||||
|
||||
def rebuild(self):
|
||||
"""清除向量库并完全重建"""
|
||||
import chromadb
|
||||
persist_dir = str(self.root / "04_memory" / "vector_db" / "chroma")
|
||||
|
||||
# 删除旧数据库
|
||||
import shutil
|
||||
if os.path.exists(persist_dir):
|
||||
shutil.rmtree(persist_dir)
|
||||
print(f"[OK] 已清除旧向量库: {persist_dir}")
|
||||
|
||||
# 重新初始化
|
||||
self.vector_store = VectorStore(persist_dir)
|
||||
print("[OK] 向量库已重建")
|
||||
|
||||
# 回填
|
||||
return self.backfill_all()
|
||||
|
||||
|
||||
# ─── CLI 入口 ──────────────────────────────────────────────────
|
||||
def cmd_search(args):
|
||||
searcher = MemorySearcher(args.root)
|
||||
result = searcher.search(args.query, top_k=args.top_k)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"检索: {result['query']}")
|
||||
print(f"向量库总数: {result['total_vector']} | "
|
||||
f"BM25命中: {result['bm25_results']} | 向量命中: {result['vector_results']}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
if not result["results"]:
|
||||
print("(无匹配结果)")
|
||||
return
|
||||
|
||||
for i, item in enumerate(result["results"], 1):
|
||||
bm25_r = f"BM25#{item['bm25_rank']}" if item["bm25_rank"] else "BM25#-"
|
||||
vec_r = f"VEC#{item['vec_rank']}" if item["vec_rank"] else "VEC#-"
|
||||
print(f"[{i}] {item['fact_id']} RRF={item['rrf_score']:.4f} conf={item['confidence']} "
|
||||
f"{bm25_r} {vec_r}")
|
||||
print(f" [{item['domain']}] {item['subject']} → {item['object'][:100]}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_embed(args):
|
||||
embeddings = get_embeddings([args.text])
|
||||
print(f"维度: {len(embeddings[0])}")
|
||||
print(f"前5值: {embeddings[0][:5]}")
|
||||
print(f"范数: {math.sqrt(sum(x*x for x in embeddings[0])):.4f}")
|
||||
|
||||
|
||||
def cmd_backfill(args):
|
||||
searcher = MemorySearcher(args.root)
|
||||
searcher.backfill_all()
|
||||
|
||||
|
||||
def cmd_rebuild(args):
|
||||
searcher = MemorySearcher(args.root)
|
||||
searcher.rebuild()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 语义检索模块")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# search
|
||||
sp_search = subparsers.add_parser("search", help="混合检索")
|
||||
sp_search.add_argument("--query", required=True, help="检索查询")
|
||||
sp_search.add_argument("--top-k", type=int, default=5, help="返回结果数量")
|
||||
|
||||
# embed
|
||||
sp_embed = subparsers.add_parser("embed", help="测试 embedding")
|
||||
sp_embed.add_argument("--text", required=True, help="测试文本")
|
||||
|
||||
# backfill
|
||||
subparsers.add_parser("backfill", help="回填所有历史数据到向量库")
|
||||
|
||||
# rebuild
|
||||
subparsers.add_parser("rebuild", help="清除并重建向量库")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "search":
|
||||
cmd_search(args)
|
||||
elif args.command == "embed":
|
||||
cmd_embed(args)
|
||||
elif args.command == "backfill":
|
||||
cmd_backfill(args)
|
||||
elif args.command == "rebuild":
|
||||
cmd_rebuild(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# 向量库增量更新脚本 (vector_backfill.sh v1.1)
|
||||
# 联邦系统定时任务 — 将 L2 新事实回填到 ChromaDB 向量索引
|
||||
# 依赖: oMLX macOS App + matrix Python venv
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
AGENT_SYNC="/Users/7kecheng/workbuddy-agent-os/agent-sync"
|
||||
PYTHON="/Users/7kecheng/.workbuddy/binaries/python/envs/matrix/bin/python3"
|
||||
SKILL_DIR="/Users/7kecheng/.workbuddy/skills/memory_manager"
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
LOG_FILE="$AGENT_SYNC/04_memory/vector_db/backfill.log"
|
||||
|
||||
OMLX_APP="/Applications/oMLX.app"
|
||||
OMLX_PORT=8000
|
||||
OMLX_API_KEY="omlx"
|
||||
OMLX_CHECK_URL="http://localhost:${OMLX_PORT}/v1/models"
|
||||
OMLX_CHECK_HEADER="Authorization: Bearer ${OMLX_API_KEY}"
|
||||
OMLX_WAIT_MAX=60 # 最多等 60 秒(模型加载可能较慢)
|
||||
|
||||
echo "[${TIMESTAMP}] 🚀 向量库增量更新开始"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 1. 检查 oMLX 是否运行,未运行则自动启动
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
if curl -sf -H "$OMLX_CHECK_HEADER" "$OMLX_CHECK_URL" > /dev/null 2>&1; then
|
||||
echo "[${TIMESTAMP}] ✅ oMLX 运行中"
|
||||
else
|
||||
echo "[${TIMESTAMP}] 🚀 oMLX 未运行,正在启动..."
|
||||
open "$OMLX_APP"
|
||||
|
||||
# 等待就绪(最多 60s)
|
||||
for i in $(seq 1 $OMLX_WAIT_MAX); do
|
||||
sleep 1
|
||||
if curl -sf -H "$OMLX_CHECK_HEADER" "$OMLX_CHECK_URL" > /dev/null 2>&1; then
|
||||
echo "[${TIMESTAMP}] ✅ oMLX 启动完成(${i}s)"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq $OMLX_WAIT_MAX ]; then
|
||||
echo "[${TIMESTAMP}] ❌ oMLX 启动超时(${OMLX_WAIT_MAX}s),放弃本次更新"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 2. 统计更新前 chroma 文件数
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
BEFORE=0
|
||||
if [ -d "$AGENT_SYNC/04_memory/vector_db/chroma" ]; then
|
||||
BEFORE=$(find "$AGENT_SYNC/04_memory/vector_db/chroma" -name "*.chroma.sqlite3" 2>/dev/null | wc -l)
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 3. 执行增量回填
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
echo "[${TIMESTAMP}] 🔄 执行增量回填..."
|
||||
cd "$SKILL_DIR"
|
||||
OUTPUT=$($PYTHON semantic_search.py --root "$AGENT_SYNC" backfill 2>&1)
|
||||
EXIT_CODE=$?
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 4. 统计结果
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
SUCCESS=$(echo "$OUTPUT" | grep -c "\[OK\]" || true)
|
||||
FAIL=$(echo "$OUTPUT" | grep -c "\[FAIL\]" || true)
|
||||
echo "$OUTPUT" >> "$LOG_FILE"
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo "[${TIMESTAMP}] ✅ 向量库更新完成 (成功: $SUCCESS, 失败: $FAIL)"
|
||||
echo "[${TIMESTAMP}] 更新前 chroma 文件数: $BEFORE"
|
||||
else
|
||||
echo "[${TIMESTAMP}] ❌ 向量库更新异常 (code=$EXIT_CODE)"
|
||||
fi
|
||||
|
||||
echo "[${TIMESTAMP}] 📝 日志已保存: $LOG_FILE"
|
||||
echo "[${TIMESTAMP}] 🏁 结束"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "memory_manager",
|
||||
"version": "1.2.0",
|
||||
"description": "智能体记忆管理技能——每日对话提炼、去重、冲突检测、版本管理",
|
||||
"dependencies": ["sqlite3"],
|
||||
"mcp_tools": []
|
||||
}
|
||||
Reference in New Issue
Block a user