fix: Feishu channel agents file_write permission blocked + memory system tests & docs

- Fix 8 Feishu agent handlers to use permission_level="acceptEdits" so file_write
  tool works without Web UI approval popup (lingxi/renshenguo/suyao/tiantian/orange/main/schedule)
- Add P5-P7 memory improvements: offline keyword fallback, team sharing, file-based memory
- Add auto_dream_service for daily memory consolidation
- Add 99 memory system test cases (basic 18 + advanced 43 + pytest 38)
- Add platform capability assessment report and unfinished project checklist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-06-14 20:35:12 +08:00
parent a7512a5423
commit 7f4aeb021b
22 changed files with 6191 additions and 68 deletions

View File

@@ -38,6 +38,12 @@ class AgentMemory:
max_history: int = 20,
vector_memory_enabled: bool = True,
vector_memory_top_k: int = 5,
vector_memory_rerank: bool = False,
memory_type_filter: Optional[List[str]] = None,
team_id: Optional[str] = None,
team_share_enabled: bool = False,
memory_dir_enabled: bool = False,
memory_dir_path: str = "",
):
self.scope_kind = scope_kind
self.scope_id = scope_id or "default"
@@ -46,11 +52,28 @@ class AgentMemory:
self.max_history = max_history
self.vector_memory_enabled = vector_memory_enabled
self.vector_memory_top_k = vector_memory_top_k
self.vector_memory_rerank = vector_memory_rerank
self.memory_type_filter = memory_type_filter # None = 全部类型
self.team_id = team_id # 团队共享 ID
self.team_share_enabled = team_share_enabled # 是否自动发布到团队池
# 文件式记忆
self.memory_dir_enabled = memory_dir_enabled
self.memory_dir_path = memory_dir_path
self._file_store = None # 延迟初始化
# 记忆类型分类: user / feedback / project / reference
self.MEMORY_TYPES = ("user", "feedback", "project", "reference")
# 从长期记忆加载的上下文(启动时加载)
self._long_term_context: Dict[str, Any] = {}
# 记录已压缩的消息数,避免重复压缩
self._last_compressed_msg_count = 0
def _get_file_store(self):
"""延迟初始化文件记忆存储。"""
if self._file_store is None and self.memory_dir_enabled:
from app.services.file_memory_service import get_file_memory_store
self._file_store = get_file_memory_store(self.memory_dir_path)
return self._file_store
async def initialize(self, query: str = "") -> str:
"""
初始化记忆:从 DB/Redis 加载长期记忆 + 向量检索相关历史。
@@ -95,7 +118,22 @@ class AgentMemory:
if vector_text:
parts.append(vector_text)
# 3. 全局知识检索:从 GlobalKnowledge 表加载相关条目
# 3. P7 文件式记忆:从本地 MEMORY.md 加载
store = self._get_file_store()
if store and store.memory_count > 0 and query:
file_results = store.search(query, top_k=3)
if file_results:
lines = ["## 文件记忆(本地 MEMORY.md"]
for i, r in enumerate(file_results, 1):
mem_type = r.get("type", "reference")
content = r.get("content", "")[:300]
score = r.get("score", 0)
lines.append(f"{i}. [{mem_type}] {content}")
if score < 1.0:
lines[-1] += f" (匹配度: {score:.2f})"
parts.append("\n".join(lines))
# 4. 全局知识检索:从 GlobalKnowledge 表加载相关条目
global_text = await self._global_knowledge_search(query)
if global_text:
parts.append(global_text)
@@ -106,6 +144,7 @@ class AgentMemory:
"""
向量检索语义相关的历史记忆,返回格式化的文本块。
若无 query 则返回最近 Top-5 条记忆。
支持 memory_type_filter 按类型过滤 + LLM Rerank 精选。
"""
from app.models.agent_vector_memory import AgentVectorMemory
@@ -113,22 +152,46 @@ class AgentMemory:
try:
db = SessionLocal()
# 查询当前 scope 的所有向量记忆(按时间倒序)
rows = (
query_builder = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.scope_kind == self.scope_kind,
AgentVectorMemory.scope_id == self.scope_id,
)
)
rows = (
query_builder
.order_by(AgentVectorMemory.created_at.desc())
.limit(50) # 最多取最近 50 条做相似度计算
.limit(50)
.all()
)
# P6 团队共享:同时查询团队记忆池
if self.team_id:
team_rows = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.scope_kind == "team",
AgentVectorMemory.scope_id == self.team_id,
)
.order_by(AgentVectorMemory.created_at.desc())
.limit(30)
.all()
)
rows = list(rows) + list(team_rows)
if not rows:
return ""
entries: List[VectorEntry] = []
for row in rows:
# 类型过滤memory_type_filter 不为空时生效)
meta = row.metadata_ or {}
row_memory_type = meta.get("memory_type", meta.get("type", "conversation_turn"))
if self.memory_type_filter:
if row_memory_type not in self.memory_type_filter:
continue
emb = embedding_service.deserialize_embedding(row.embedding) if row.embedding else []
entries.append({
"id": row.id,
@@ -136,17 +199,35 @@ class AgentMemory:
"scope_id": row.scope_id,
"content_text": row.content_text,
"embedding": emb,
"metadata": row.metadata_ or {},
"metadata": meta,
})
if not entries:
return ""
matched: List[VectorEntry] = []
if query and query.strip():
# 有 query生成 embedding 做语义搜索
query_emb = await embedding_service.generate_embedding(query)
if query_emb:
matched = await embedding_service.similarity_search(
query_emb, entries, top_k=self.vector_memory_top_k
# 向量检索取 top_k * 4 候选(为 rerank 留余量),最少 20 条
candidate_k = max(20, self.vector_memory_top_k * 4)
candidates = await embedding_service.similarity_search(
query_emb, entries, top_k=min(candidate_k, len(entries))
)
# LLM Rerank向量粗筛 → LLM 精选
if self.vector_memory_rerank and len(candidates) > self.vector_memory_top_k:
matched = await self._llm_rerank(query, candidates)
if not matched:
matched = candidates[: self.vector_memory_top_k]
else:
# P5 离线兜底Embedding API 不可用时降级为关键词匹配
logger.info("Embedding 不可用,降级为离线关键词匹配")
matched = embedding_service.keyword_search(
query, entries, top_k=self.vector_memory_top_k, min_score=0.05,
)
else:
# 无 query返回最近几条
@@ -162,8 +243,14 @@ class AgentMemory:
for i, m in enumerate(matched, 1):
text = m.get("content_text", "")[:500]
meta = m.get("metadata", {})
entry_type = meta.get("type", "对话")
lines.append(f"{i}. [{entry_type}] {text}")
mem_type = meta.get("memory_type", meta.get("type", "对话"))
scope_kind = m.get("scope_kind", "")
# 标注团队共享来源
source_tag = ""
if scope_kind == "team":
shared_by = meta.get("shared_by", meta.get("source_scope", "unknown"))
source_tag = f" [团队共享]"
lines.append(f"{i}. [{mem_type}]{source_tag} {text}")
if m.get("score", 1.0) < 1.0:
lines[-1] += f" (匹配度: {m['score']:.2f})"
@@ -176,6 +263,84 @@ class AgentMemory:
if db:
db.close()
async def _llm_rerank(
self, query: str, candidates: List[VectorEntry],
) -> List[VectorEntry]:
"""
LLM Rerank用轻量模型对向量粗筛结果打分排序返回精选 top-K。
流程:取向量检索 top-N 候选 → LLM 按与 query 相关性打分 (1-10)
→ 取 top-K 高分结果。失败时降级返回原始排序。
"""
from openai import AsyncOpenAI
from app.core.config import settings
if not candidates or len(candidates) <= self.vector_memory_top_k:
return candidates[: self.vector_memory_top_k]
try:
# 构建候选列表
items_text = []
for idx, c in enumerate(candidates):
content = c.get("content_text", "")[:300]
mem_type = c.get("metadata", {}).get("memory_type", "unknown")
items_text.append(f"[{idx}] [{mem_type}] {content}")
rerank_prompt = (
"你是一个记忆检索排序助手。请根据用户查询对以下记忆条目按相关性打分1-10分\n"
"只输出 JSON 数组,每个元素包含 index 和 score按 score 降序排列。\n"
"只保留 score >= 4 的结果。最多返回 {} 条。\n\n"
"用户查询: {}\n\n记忆条目:\n{}"
).format(
self.vector_memory_top_k,
query[:500],
"\n".join(items_text),
)
api_key = settings.DEEPSEEK_API_KEY or settings.OPENAI_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or settings.OPENAI_BASE_URL or "https://api.deepseek.com"
if api_key == "your-openai-api-key":
api_key = settings.DEEPSEEK_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
if not api_key:
return candidates[: self.vector_memory_top_k]
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
resp = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": rerank_prompt}],
temperature=0.1,
max_tokens=512,
timeout=15,
)
raw = resp.choices[0].message.content or ""
raw = raw.strip().removeprefix("```json").removesuffix("```").strip()
import json
scored = json.loads(raw)
if not isinstance(scored, list):
return candidates[: self.vector_memory_top_k]
# 按 score 排序取 top-K
scored.sort(key=lambda x: x.get("score", 0), reverse=True)
result: List[VectorEntry] = []
for item in scored[: self.vector_memory_top_k]:
idx = item.get("index", -1)
if 0 <= idx < len(candidates):
candidates[idx]["score"] = float(item.get("score", 5.0)) / 10.0
result.append(candidates[idx])
if result:
logger.info("LLM Rerank: %d 候选 → %d 精选", len(candidates), len(result))
return result
return candidates[: self.vector_memory_top_k]
except Exception as e:
logger.warning("LLM Rerank 失败,使用向量排序: %s", e)
return candidates[: self.vector_memory_top_k]
async def _global_knowledge_search(self, query: str = "") -> str:
"""从 GlobalKnowledge 表检索相关的全局知识条目。"""
from datetime import datetime
@@ -340,33 +505,52 @@ class AgentMemory:
self, user_message: str, assistant_reply: str,
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""将单轮对话保存到长期记忆。如有消息列表LLM 自动压缩总结。"""
"""将单轮对话保存到长期记忆。
快速路径(同步完成):向量记忆写入 + 基础上下文更新。
慢速路径fire-and-forgetLLM 压缩总结 → persistent_memory 更新。
后台压缩不阻塞对话响应。
"""
if not self.persist or not self.scope_id:
return
# 更新上下文
# 快速:更新基础上下文
ctx = self._long_term_context.get("context", {})
ctx["last_user_message"] = user_message[:500]
ctx["last_assistant_reply"] = assistant_reply[:500]
self._long_term_context["context"] = ctx
# 如果有完整消息列表且新增了足够多的消息,运行 LLM 压缩总结
# 后台LLM 压缩总结fire-and-forget不阻塞主对话
if messages and len(messages) > self._last_compressed_msg_count + 2:
await self._compress_and_summarize(messages)
self._last_compressed_msg_count = len(messages)
import asyncio as _asyncio
_asyncio.ensure_future(self._background_compress_and_save(messages))
db: Optional[Session] = None
try:
db = SessionLocal()
# 快速:保存基础上下文到 persistent_memory后续后台压缩会覆盖更新
save_persistent_memory(
db, self.scope_kind, self.scope_id,
self.session_key, self._long_term_context,
)
# 保存向量记忆(异步生成 embedding 并存储)
# 快速:保存向量记忆
if self.vector_memory_enabled:
mem_type = self._infer_memory_type(user_message, assistant_reply)
await self._save_vector_memory(
db, user_message, assistant_reply
db, user_message, assistant_reply, memory_type=mem_type,
)
# P7 文件式记忆兜底:同步写入本地 MEMORY.md
store = self._get_file_store()
if store:
mem_type = self._infer_memory_type(user_message, assistant_reply)
content = f"用户: {user_message[:300]}\n助手: {assistant_reply[:300]}"
store.save(
name=f"{self.scope_id}_{self.session_key}_{len(ctx)}",
content=content,
mem_type=mem_type,
)
except Exception as e:
logger.warning("保存长期记忆失败: %s", e)
@@ -376,6 +560,7 @@ class AgentMemory:
async def _save_vector_memory(
self, db: Session, user_message: str, assistant_reply: str,
memory_type: str = "conversation_turn",
) -> None:
"""生成 embedding 并保存到向量记忆表。"""
from app.models.agent_vector_memory import AgentVectorMemory
@@ -396,16 +581,66 @@ class AgentMemory:
content_text=content_text[:2000],
embedding=embedding_json or None,
metadata_={
"type": "conversation_turn",
"type": memory_type,
"memory_type": memory_type,
},
)
db.add(record)
db.commit()
logger.debug("已保存向量记忆 (scope=%s/%s)", self.scope_kind, self.scope_id)
# P6 团队共享:自动将记忆副本发布到团队池
if self.team_id and self.team_share_enabled:
try:
team_record = AgentVectorMemory(
scope_kind="team",
scope_id=self.team_id,
session_key=self.session_key,
content_text=content_text[:2000],
embedding=embedding_json or None,
metadata_={
"type": memory_type,
"memory_type": memory_type,
"source_scope": f"{self.scope_kind}/{self.scope_id}",
"shared_by": self.scope_id,
},
)
db.add(team_record)
db.commit()
logger.debug("已同步到团队记忆池 (team=%s)", self.team_id)
except Exception:
db.rollback() # 团队同步失败不影响主流程
logger.debug("已保存向量记忆 (scope=%s/%s, type=%s)", self.scope_kind, self.scope_id, memory_type)
except Exception as e:
logger.warning("保存向量记忆失败: %s", e)
db.rollback()
async def _background_compress_and_save(
self, messages: List[Dict[str, Any]],
) -> None:
"""
后台异步LLM 压缩总结 + 写入 persistent_memory。
从 save_context 中 fire-and-forget 调用,不阻塞对话响应。
"""
try:
await self._compress_and_summarize(messages)
# 将压缩更新后的长期上下文写回 DB
db: Optional[Session] = None
try:
db = SessionLocal()
save_persistent_memory(
db, self.scope_kind, self.scope_id,
self.session_key, self._long_term_context,
)
except Exception as e:
logger.warning("后台压缩保存 persistent_memory 失败: %s", e)
finally:
if db:
db.close()
except Exception as e:
logger.warning("后台压缩总结失败: %s", e)
async def _compress_and_summarize(
self, messages: List[Dict[str, Any]]
) -> None:
@@ -506,11 +741,71 @@ class AgentMemory:
"updated" if new_profile else "unchanged",
len(new_facts), len(topics))
# P1: 将压缩摘要向量化写入 AgentVectorMemory使其可被语义检索
await self._save_compressed_memories(summary, new_facts, topics)
except json.JSONDecodeError:
logger.warning("记忆压缩LLM 返回非 JSON 格式,跳过")
except Exception as e:
logger.warning("记忆压缩失败: %s", e)
async def _save_compressed_memories(
self, summary: str, facts: List[str], topics: List[str],
) -> None:
"""
将 LLM 压缩总结的结果向量化写入 AgentVectorMemory。
每个 fact/summary/topic 单独写入,标注 memory_type=project来自对话压缩
失败不影响主流程。
"""
from app.models.agent_vector_memory import AgentVectorMemory
memories_to_save: List[tuple] = [] # (content, memory_type)
if summary:
memories_to_save.append((f"[对话摘要] {summary[:1500]}", "project"))
for fact in facts:
if fact and len(fact) > 10:
memories_to_save.append((f"[关键事实] {fact[:1500]}", "reference"))
for topic in topics:
if topic:
memories_to_save.append((f"[话题] {topic[:500]}", "project"))
if not memories_to_save:
return
db: Optional[Session] = None
try:
db = SessionLocal()
for content, mem_type in memories_to_save:
try:
embedding = await embedding_service.generate_embedding(content)
embedding_json = embedding_service.serialize_embedding(embedding) if embedding else ""
record = AgentVectorMemory(
scope_kind=self.scope_kind,
scope_id=self.scope_id,
session_key=self.session_key,
content_text=content[:2000],
embedding=embedding_json or None,
metadata_={
"type": "compressed_summary",
"memory_type": mem_type,
"source": "auto_compress",
},
)
db.add(record)
except Exception:
pass # 单条失败不阻塞其他写入
db.commit()
logger.info("已向量化压缩记忆: %d 条 (scope=%s/%s)",
len(memories_to_save), self.scope_kind, self.scope_id)
except Exception as e:
logger.warning("压缩记忆向量化失败: %s", e)
if db:
db.rollback()
finally:
if db:
db.close()
def trim_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
裁剪消息列表:保留最近的 N 条,但始终保留第一条 system 消息。
@@ -566,3 +861,41 @@ class AgentMemory:
if m.get("role") == "user":
turns += 1
return f"{turns} 轮历史对话(详情已存入长期记忆)"
@staticmethod
def _infer_memory_type(user_message: str, assistant_reply: str) -> str:
"""
根据对话内容推断记忆类型 (user / feedback / project / reference)。
基于关键词快速分类,不做 LLM 调用。
"""
combined = (user_message + " " + assistant_reply).lower()
# feedback: 纠错、反馈、报错
feedback_keywords = [
"不对", "错误", "错了", "报错", "bug", "不正确", "有问题",
"改一下", "修正", "纠正", "不要这样", "不行", "不是这个",
"不对的", "反馈", "建议", "应该", "能不能", "可以不要",
]
if any(kw in combined for kw in feedback_keywords):
return "feedback"
# reference: 链接、配置、系统信息
reference_keywords = [
"http://", "https://", "配置", ".env", "api", "端口",
"数据库", "地址", "密码", "密钥", "token", "url",
"路径", "文件", "目录", "安装", "部署",
]
if any(kw in combined for kw in reference_keywords):
return "reference"
# project: 任务、目标、进度
project_keywords = [
"任务", "目标", "进度", "完成", "计划", "需求", "项目",
"开发", "测试", "上线", "版本", "发布", "迭代",
"bug", "修复", "功能", "实现", "提交",
]
if any(kw in combined for kw in project_keywords):
return "project"
# user: 默认,包含偏好、个人信息等
return "user"