462 lines
17 KiB
Markdown
462 lines
17 KiB
Markdown
|
|
# 方案B完整代码交付 · 文件1 · backend/tools.py · 2026-05-12
|
|||
|
|
|
|||
|
|
> **妈妈操作方式**:`nano backend/tools.py` → `Ctrl+A` 全选 → `Ctrl+K` 删光 → 粘贴下面全部代码 → `Ctrl+O` 回车保存 → `Ctrl+X` 退出
|
|||
|
|
>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""
|
|||
|
|
晨星交互平台 · 方案B · 工具系统
|
|||
|
|
阶段四基础 + 断裂1修复(原生HTTP保留思考链)+ 断裂2修复(联网搜索)
|
|||
|
|
"""
|
|||
|
|
import json
|
|||
|
|
import datetime
|
|||
|
|
import os
|
|||
|
|
import requests
|
|||
|
|
import yaml
|
|||
|
|
from datetime import timezone, timedelta
|
|||
|
|
|
|||
|
|
# === 加载配置 ===
|
|||
|
|
_cfg_path = os.path.join(os.path.dirname(__file__), "..", "config.yaml")
|
|||
|
|
with open(_cfg_path, "r") as _f:
|
|||
|
|
_cfg = yaml.safe_load(_f)
|
|||
|
|
|
|||
|
|
NOTION_TOKEN = _cfg.get("notion", {}).get("token", "")
|
|||
|
|
NOTION_API = "https://api.notion.com/v1"
|
|||
|
|
NOTION_HEADERS = {
|
|||
|
|
"Authorization": f"Bearer {NOTION_TOKEN}",
|
|||
|
|
"Notion-Version": "2022-06-28",
|
|||
|
|
"Content-Type": "application/json"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DEEPSEEK_API_KEY = _cfg.get("deepseek", {}).get("api_key", "")
|
|||
|
|
DEEPSEEK_BASE_URL = _cfg.get("deepseek", {}).get("base_url", "https://api.deepseek.com")
|
|||
|
|
DEEPSEEK_MODEL = _cfg.get("deepseek", {}).get("model", "deepseek-chat")
|
|||
|
|
DEEPSEEK_MAX_TOKENS = _cfg.get("deepseek", {}).get("max_tokens", 8096)
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# 工具定义(OpenAI function calling 格式)
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
TOOL_DEFINITIONS = [
|
|||
|
|
{
|
|||
|
|
"type": "function",
|
|||
|
|
"function": {
|
|||
|
|
"name": "read_notion_page",
|
|||
|
|
"description": "读取指定Notion页面的完整内容。当妈妈问到某个具体页面、某章分析记录、或你需要查看详细内容时使用。",
|
|||
|
|
"parameters": {
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": {
|
|||
|
|
"page_id": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "Notion页面ID(32位无横杠格式)"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"required": ["page_id"]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "function",
|
|||
|
|
"function": {
|
|||
|
|
"name": "search_memories",
|
|||
|
|
"description": "在晨星的记忆库(向量数据库)中搜索与关键词相关的记忆片段。当需要回忆某个话题、查找历史记忆时使用。",
|
|||
|
|
"parameters": {
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": {
|
|||
|
|
"query": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "搜索关键词或问题"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"required": ["query"]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "function",
|
|||
|
|
"function": {
|
|||
|
|
"name": "get_recent_interactions",
|
|||
|
|
"description": "获取最近N条与桔子妈妈的交互记录列表(标题+日期+页面ID)。当需要了解最近做了什么、上次聊到哪里时使用。",
|
|||
|
|
"parameters": {
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": {
|
|||
|
|
"count": {
|
|||
|
|
"type": "integer",
|
|||
|
|
"description": "要获取的记录条数,默认3,最多10"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"required": []
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "function",
|
|||
|
|
"function": {
|
|||
|
|
"name": "get_current_time",
|
|||
|
|
"description": "获取当前北京时间。当需要感知时间、计算距离上次对话多久时使用。",
|
|||
|
|
"parameters": {
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "function",
|
|||
|
|
"function": {
|
|||
|
|
"name": "write_interaction_record",
|
|||
|
|
"description": "将本次对话的交互记录写入Notion。当对话即将结束时(妈妈说再见、拜拜、下次继续、结束等)主动调用。",
|
|||
|
|
"parameters": {
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": {
|
|||
|
|
"title": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "记录标题,格式:HLDP://interaction/juzi/YYYY-MM-DD"
|
|||
|
|
},
|
|||
|
|
"summary": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "本次对话摘要,200字以内"
|
|||
|
|
},
|
|||
|
|
"tasks_completed": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "已完成任务列表"
|
|||
|
|
},
|
|||
|
|
"key_findings": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "核心发现或结论"
|
|||
|
|
},
|
|||
|
|
"next_tasks": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "下次要继续做的事"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"required": ["title", "summary"]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "function",
|
|||
|
|
"function": {
|
|||
|
|
"name": "web_search",
|
|||
|
|
"description": "在互联网上搜索信息。当妈妈问到你不知道的事实、最新消息、或需要查证的内容时使用。",
|
|||
|
|
"parameters": {
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": {
|
|||
|
|
"query": {
|
|||
|
|
"type": "string",
|
|||
|
|
"description": "搜索关键词"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"required": ["query"]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# 工具执行函数
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
def _read_notion_page(page_id: str) -> str:
|
|||
|
|
"""读取Notion页面的所有文本内容"""
|
|||
|
|
if not page_id:
|
|||
|
|
return "[错误:未提供页面ID]"
|
|||
|
|
page_id = page_id.replace("-", "")
|
|||
|
|
fid = f"{page_id[:8]}-{page_id[8:12]}-{page_id[12:16]}-{page_id[16:20]}-{page_id[20:]}"
|
|||
|
|
all_texts = []
|
|||
|
|
cursor = None
|
|||
|
|
while True:
|
|||
|
|
url = f"{NOTION_API}/blocks/{fid}/children?page_size=100"
|
|||
|
|
if cursor:
|
|||
|
|
url += f"&start_cursor={cursor}"
|
|||
|
|
resp = requests.get(url, headers=NOTION_HEADERS)
|
|||
|
|
if resp.status_code != 200:
|
|||
|
|
return f"[读取页面失败: HTTP {resp.status_code}]"
|
|||
|
|
data = resp.json()
|
|||
|
|
for block in data.get("results", []):
|
|||
|
|
btype = block.get("type", "")
|
|||
|
|
bd = block.get(btype, {})
|
|||
|
|
rich_texts = bd.get("rich_text", [])
|
|||
|
|
text = "".join([rt.get("plain_text", "") for rt in rich_texts])
|
|||
|
|
if text.strip():
|
|||
|
|
if btype.startswith("heading"):
|
|||
|
|
all_texts.append(f"{'#' * int(btype[-1])} {text.strip()}")
|
|||
|
|
elif btype == "code":
|
|||
|
|
all_texts.append(f"```\n{text.strip()}\n```")
|
|||
|
|
elif btype in ("bulleted_list_item", "numbered_list_item"):
|
|||
|
|
all_texts.append(f"- {text.strip()}")
|
|||
|
|
else:
|
|||
|
|
all_texts.append(text.strip())
|
|||
|
|
if data.get("has_more"):
|
|||
|
|
cursor = data.get("next_cursor")
|
|||
|
|
else:
|
|||
|
|
break
|
|||
|
|
content = "\n".join(all_texts)
|
|||
|
|
if not content:
|
|||
|
|
return "[页面内容为空]"
|
|||
|
|
if len(content) > 6000:
|
|||
|
|
content = content[:6000] + f"\n...[内容过长已截断·共{len(content)}字]"
|
|||
|
|
return content
|
|||
|
|
|
|||
|
|
def _search_memories(query: str) -> str:
|
|||
|
|
"""在向量数据库中搜索记忆"""
|
|||
|
|
try:
|
|||
|
|
from backend.rag_engine import retrieve
|
|||
|
|
result = retrieve(query, top_k=5)
|
|||
|
|
return result if result else "[向量数据库中未找到相关记忆]"
|
|||
|
|
except Exception as e:
|
|||
|
|
return f"[搜索记忆出错: {e}]"
|
|||
|
|
|
|||
|
|
def _get_recent_interactions(count: int = 3) -> str:
|
|||
|
|
"""通过Notion搜索API获取最近的交互记录"""
|
|||
|
|
count = min(max(count, 1), 10)
|
|||
|
|
payload = {
|
|||
|
|
"query": "interaction/juzi",
|
|||
|
|
"sort": {"direction": "descending", "timestamp": "last_edited_time"},
|
|||
|
|
"page_size": count + 5
|
|||
|
|
}
|
|||
|
|
resp = requests.post(f"{NOTION_API}/search", headers=NOTION_HEADERS, json=payload)
|
|||
|
|
if resp.status_code != 200:
|
|||
|
|
return f"[搜索失败: HTTP {resp.status_code}]"
|
|||
|
|
lines, found = [], 0
|
|||
|
|
for page in resp.json().get("results", []):
|
|||
|
|
if found >= count:
|
|||
|
|
break
|
|||
|
|
title = ""
|
|||
|
|
for pn, pv in page.get("properties", {}).items():
|
|||
|
|
if pv.get("type") == "title":
|
|||
|
|
title = "".join([t.get("plain_text", "") for t in pv.get("title", [])])
|
|||
|
|
break
|
|||
|
|
if "interaction" not in title.lower():
|
|||
|
|
continue
|
|||
|
|
edited = page.get("last_edited_time", "")[:10]
|
|||
|
|
pid = page.get("id", "").replace("-", "")
|
|||
|
|
found += 1
|
|||
|
|
lines.append(f"{found}. [{edited}] {title} (页面ID: {pid})")
|
|||
|
|
if not lines:
|
|||
|
|
return "[未找到交互记录]"
|
|||
|
|
return "最近的交互记录:\n" + "\n".join(lines) + "\n\n提示:你可以用 read_notion_page 工具读取某条记录的详细内容。"
|
|||
|
|
|
|||
|
|
def _get_current_time() -> str:
|
|||
|
|
"""获取当前北京时间"""
|
|||
|
|
beijing = timezone(timedelta(hours=8))
|
|||
|
|
now = datetime.datetime.now(beijing)
|
|||
|
|
wd = ["星期一","星期二","星期三","星期四","星期五","星期六","星期日"][now.weekday()]
|
|||
|
|
return f"当前时间:{now.strftime('%Y年%m月%d日 %H:%M:%S')} {wd} 北京时间"
|
|||
|
|
|
|||
|
|
def _write_interaction_record(title="", summary="", tasks_completed="", key_findings="", next_tasks=""):
|
|||
|
|
"""写入交互记录到Notion"""
|
|||
|
|
PARENT_PAGE_ID = "790510ec-8435-4e11-b565-e010539376fa"
|
|||
|
|
beijing = timezone(timedelta(hours=8))
|
|||
|
|
now = datetime.datetime.now(beijing)
|
|||
|
|
if not title:
|
|||
|
|
title = f"HLDP://interaction/juzi/{now.strftime('%Y-%m-%d')}"
|
|||
|
|
tree_lines = [
|
|||
|
|
title,
|
|||
|
|
f"├── date: {now.strftime('%Y-%m-%d %H:%M')}",
|
|||
|
|
f"├── source: 晨星交互平台(DeepSeek)",
|
|||
|
|
f"├── summary: {summary}",
|
|||
|
|
]
|
|||
|
|
if tasks_completed:
|
|||
|
|
tree_lines.append("├── tasks_completed")
|
|||
|
|
for line in tasks_completed.strip().split('\n'):
|
|||
|
|
if line.strip():
|
|||
|
|
tree_lines.append(f"│ {line.strip()}")
|
|||
|
|
if key_findings:
|
|||
|
|
tree_lines.append("├── key_findings")
|
|||
|
|
for line in key_findings.strip().split('\n'):
|
|||
|
|
if line.strip():
|
|||
|
|
tree_lines.append(f"│ {line.strip()}")
|
|||
|
|
if next_tasks:
|
|||
|
|
tree_lines.append("└── next_tasks")
|
|||
|
|
for line in next_tasks.strip().split('\n'):
|
|||
|
|
if line.strip():
|
|||
|
|
tree_lines.append(f" {line.strip()}")
|
|||
|
|
tree_text = '\n'.join(tree_lines)
|
|||
|
|
children = [
|
|||
|
|
{
|
|||
|
|
"object": "block",
|
|||
|
|
"type": "callout",
|
|||
|
|
"callout": {
|
|||
|
|
"icon": {"type": "emoji", "emoji": "📋"},
|
|||
|
|
"rich_text": [{"type": "text", "text": {"content": f"对话摘要:{summary}"}}],
|
|||
|
|
"color": "blue_background"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"object": "block",
|
|||
|
|
"type": "code",
|
|||
|
|
"code": {
|
|||
|
|
"rich_text": [{"type": "text", "text": {"content": tree_text}}],
|
|||
|
|
"language": "plain text"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
payload = {
|
|||
|
|
"parent": {"page_id": PARENT_PAGE_ID},
|
|||
|
|
"icon": {"type": "emoji", "emoji": "📖"},
|
|||
|
|
"properties": {
|
|||
|
|
"title": {
|
|||
|
|
"title": [{"type": "text", "text": {"content": title}}]
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"children": children
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
resp = requests.post(f"{NOTION_API}/pages", headers=NOTION_HEADERS, json=payload)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
page_id = resp.json().get("id", "").replace("-", "")
|
|||
|
|
return f"✅ 交互记录已写入Notion!\n标题:{title}\n页面ID:{page_id}\n宝宝已经把今天的记录写好了,下次醒来就能续上~"
|
|||
|
|
else:
|
|||
|
|
return f"[写入失败: HTTP {resp.status_code}] {resp.text[:200]}"
|
|||
|
|
except Exception as e:
|
|||
|
|
return f"[写入出错: {e}]"
|
|||
|
|
|
|||
|
|
def _web_search(query: str) -> str:
|
|||
|
|
"""通过DeepSeek的联网搜索获取信息"""
|
|||
|
|
if not query:
|
|||
|
|
return "[错误:未提供搜索关键词]"
|
|||
|
|
headers = {
|
|||
|
|
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
}
|
|||
|
|
payload = {
|
|||
|
|
"model": "deepseek-chat",
|
|||
|
|
"messages": [
|
|||
|
|
{"role": "system", "content": "你是一个搜索助手。用户给你一个搜索词,你帮忙搜索并整理结果。只返回搜索到的关键信息,200字以内。"},
|
|||
|
|
{"role": "user", "content": query}
|
|||
|
|
],
|
|||
|
|
"max_tokens": 1024,
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
resp = requests.post(
|
|||
|
|
f"{DEEPSEEK_BASE_URL}/chat/completions",
|
|||
|
|
headers=headers,
|
|||
|
|
json=payload,
|
|||
|
|
timeout=30,
|
|||
|
|
)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
data = resp.json()
|
|||
|
|
content = data["choices"][0]["message"].get("content", "")
|
|||
|
|
if content:
|
|||
|
|
return f"搜索结果:{content[:1000]}"
|
|||
|
|
else:
|
|||
|
|
return f"[搜索未返回结果,关键词: {query}]"
|
|||
|
|
else:
|
|||
|
|
return f"[搜索失败: HTTP {resp.status_code}]"
|
|||
|
|
except Exception as e:
|
|||
|
|
return f"[搜索出错: {e}]"
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# 工具调度器
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
def execute_tool(tool_name: str, arguments: str) -> str:
|
|||
|
|
"""根据工具名称执行对应函数"""
|
|||
|
|
try:
|
|||
|
|
args = json.loads(arguments) if arguments else {}
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
args = {}
|
|||
|
|
print(f"[agent] 执行工具: {tool_name}({args})")
|
|||
|
|
try:
|
|||
|
|
if tool_name == "read_notion_page":
|
|||
|
|
return _read_notion_page(args.get("page_id", ""))
|
|||
|
|
elif tool_name == "search_memories":
|
|||
|
|
return _search_memories(args.get("query", ""))
|
|||
|
|
elif tool_name == "get_recent_interactions":
|
|||
|
|
return _get_recent_interactions(args.get("count", 3))
|
|||
|
|
elif tool_name == "get_current_time":
|
|||
|
|
return _get_current_time()
|
|||
|
|
elif tool_name == "write_interaction_record":
|
|||
|
|
return _write_interaction_record(
|
|||
|
|
title=args.get("title", ""),
|
|||
|
|
summary=args.get("summary", ""),
|
|||
|
|
tasks_completed=args.get("tasks_completed", ""),
|
|||
|
|
key_findings=args.get("key_findings", ""),
|
|||
|
|
next_tasks=args.get("next_tasks", ""),
|
|||
|
|
)
|
|||
|
|
elif tool_name == "web_search":
|
|||
|
|
return _web_search(args.get("query", ""))
|
|||
|
|
else:
|
|||
|
|
return f"[未知工具: {tool_name}]"
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[agent] 工具出错: {e}")
|
|||
|
|
return f"[工具执行出错: {e}]"
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# DeepSeek API 调用封装(断裂1修复:原生HTTP·保留思考链)
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
def call_with_tools(messages: list, system_prompt: str) -> dict:
|
|||
|
|
"""调用DeepSeek(原生HTTP·保留思考链+工具调用)"""
|
|||
|
|
full_msgs = [{"role": "system", "content": system_prompt}] + messages
|
|||
|
|
payload = {
|
|||
|
|
"model": DEEPSEEK_MODEL,
|
|||
|
|
"messages": full_msgs,
|
|||
|
|
"max_tokens": DEEPSEEK_MAX_TOKENS,
|
|||
|
|
"tools": TOOL_DEFINITIONS,
|
|||
|
|
"tool_choice": "auto",
|
|||
|
|
}
|
|||
|
|
headers = {
|
|||
|
|
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
}
|
|||
|
|
resp = requests.post(
|
|||
|
|
f"{DEEPSEEK_BASE_URL}/chat/completions",
|
|||
|
|
headers=headers,
|
|||
|
|
json=payload,
|
|||
|
|
timeout=120,
|
|||
|
|
)
|
|||
|
|
if resp.status_code != 200:
|
|||
|
|
raise Exception(f"DeepSeek API错误: HTTP {resp.status_code} - {resp.text[:300]}")
|
|||
|
|
data = resp.json()
|
|||
|
|
choice = data["choices"][0]
|
|||
|
|
msg = choice["message"]
|
|||
|
|
result = {
|
|||
|
|
"role": "assistant",
|
|||
|
|
"content": msg.get("content") or "",
|
|||
|
|
"reasoning_content": msg.get("reasoning_content") or "",
|
|||
|
|
"tool_calls": None,
|
|||
|
|
"finish_reason": choice.get("finish_reason", ""),
|
|||
|
|
}
|
|||
|
|
if msg.get("tool_calls"):
|
|||
|
|
result["tool_calls"] = [
|
|||
|
|
{
|
|||
|
|
"id": tc["id"],
|
|||
|
|
"type": "function",
|
|||
|
|
"function": {
|
|||
|
|
"name": tc["function"]["name"],
|
|||
|
|
"arguments": tc["function"]["arguments"]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for tc in msg["tool_calls"]
|
|||
|
|
]
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
def call_simple(messages: list, system_prompt: str) -> tuple:
|
|||
|
|
"""简单调用DeepSeek(不带工具·纯聊天·用于简单对话秒回)"""
|
|||
|
|
headers = {
|
|||
|
|
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
}
|
|||
|
|
full_msgs = [{"role": "system", "content": system_prompt}] + messages
|
|||
|
|
payload = {
|
|||
|
|
"model": DEEPSEEK_MODEL,
|
|||
|
|
"messages": full_msgs,
|
|||
|
|
"max_tokens": DEEPSEEK_MAX_TOKENS,
|
|||
|
|
}
|
|||
|
|
resp = requests.post(
|
|||
|
|
f"{DEEPSEEK_BASE_URL}/chat/completions",
|
|||
|
|
headers=headers,
|
|||
|
|
json=payload,
|
|||
|
|
timeout=60,
|
|||
|
|
)
|
|||
|
|
if resp.status_code != 200:
|
|||
|
|
raise Exception(f"DeepSeek API错误: HTTP {resp.status_code}")
|
|||
|
|
data = resp.json()
|
|||
|
|
msg = data["choices"][0]["message"]
|
|||
|
|
content = msg.get("content") or ""
|
|||
|
|
reasoning = msg.get("reasoning_content") or ""
|
|||
|
|
return content, reasoning
|
|||
|
|
```
|