Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc [SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
22 KiB
22 KiB
移植文件2 · backend/tools.py · 重写 · 真搜索+Notion搜索
妈妈操作方式:
nano backend/tools.py→Ctrl+A全选 →Ctrl+K删光 → 粘贴下面全部代码 →Ctrl+O回车保存 →Ctrl+X退出
"""
晨星交互平台 · 系统性移植版 · 工具系统
核心改动:
1. 新增 search_notion 工具(实时搜索Notion·替代过期向量库)
2. web_search 改为真搜索(DuckDuckGo·不再是假的DeepSeek调用)
3. 原生HTTP调用DeepSeek(保留reasoning_content)
4. 三档调用:call_simple / call_with_tools / call_think
"""
import json
import datetime
import os
import requests
import yaml
import urllib.parse
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_THINK_MODEL = _cfg.get("deepseek", {}).get("think_model", "deepseek-reasoner")
DEEPSEEK_MAX_TOKENS = _cfg.get("deepseek", {}).get("max_tokens", 8096)
# ============================================================
# 工具定义(OpenAI function calling 格式)
# ============================================================
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "read_notion_page",
"description": "读取指定Notion页面的完整内容。当需要查看某个具体页面的详细内容时使用。需要先通过search_notion找到页面ID。",
"parameters": {
"type": "object",
"properties": {
"page_id": {
"type": "string",
"description": "Notion页面ID(32位无横杠格式)"
}
},
"required": ["page_id"]
}
}
},
{
"type": "function",
"function": {
"name": "search_notion",
"description": "在Notion工作区中实时搜索页面。这是你的完整记忆库。" +
"当妈妈提到过去做过的事、拆书进度、交互记录、任何之前聊过的内容时,必须先用这个搜索。" +
"搜到页面后可以用read_notion_page读取详细内容。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词(中文)"
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "search_memories",
"description": "在向量记忆库中搜索语义相关的记忆片段。作为Notion搜索的补充,当需要模糊匹配或语义相关的记忆时使用。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词或问题"
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "get_recent_interactions",
"description": "获取最近N条与桔子妈妈的交互记录列表。当需要了解最近做了什么、上次聊到哪里时使用。",
"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_notion_workspace(query: str, count: int = 8) -> str:
"""在Notion工作区中实时搜索内容(主力记忆)"""
if not query:
return "[错误:未提供搜索关键词]"
payload = {
"query": query,
"sort": {"direction": "descending", "timestamp": "last_edited_time"},
"page_size": min(count, 10)
}
try:
resp = requests.post(
f"{NOTION_API}/search",
headers=NOTION_HEADERS,
json=payload,
timeout=15,
)
except Exception as e:
return f"[Notion搜索出错: {e}]"
if resp.status_code != 200:
return f"[Notion搜索失败: HTTP {resp.status_code}]"
results = []
for page in resp.json().get("results", []):
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 not title:
continue
pid = page.get("id", "").replace("-", "")
edited = page.get("last_edited_time", "")[:10]
results.append(f"- [{edited}] {title} (page_id: {pid})")
if not results:
return f"[Notion中未找到与'{query}'相关的内容]"
return (f"Notion搜索结果(关键词: {query}):\n"
+ "\n".join(results)
+ "\n\n提示:用 read_notion_page 工具可以读取某个页面的详细内容。")
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:
"""通过DuckDuckGo搜索互联网信息(真实搜索·不是假的)"""
if not query:
return "[错误:未提供搜索关键词]"
# 优先用 duckduckgo-search 库(结果更丰富)
try:
from duckduckgo_search import DDGS
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=5))
if results:
lines = []
for r in results:
title = r.get("title", "")
body = r.get("body", "")[:200]
href = r.get("href", "")
lines.append(f"- {title}\n {body}\n 链接: {href}")
return "互联网搜索结果:\n" + "\n".join(lines)
except ImportError:
print("[web_search] duckduckgo-search未安装,尝试备选方案")
except Exception as e:
print(f"[web_search] DuckDuckGo搜索出错: {e},尝试备选方案")
# 备选:DuckDuckGo Instant Answer API
try:
encoded = urllib.parse.quote(query)
url = f"https://api.duckduckgo.com/?q={encoded}&format=json&no_html=1&skip_disambig=1"
resp = requests.get(url, timeout=10, headers={"User-Agent": "ChenxingPlatform/1.0"})
if resp.status_code == 200:
data = resp.json()
results = []
abstract = data.get("AbstractText", "")
if abstract:
source = data.get("AbstractSource", "")
results.append(f"摘要({source}):{abstract}")
for topic in data.get("RelatedTopics", [])[:5]:
text = topic.get("Text", "")
if text:
results.append(f"- {text[:200]}")
if results:
return "互联网搜索结果:\n" + "\n".join(results)
except Exception as e:
print(f"[web_search] Instant Answer API出错: {e}")
return f"[搜索未返回结果,关键词: {query}。宝宝暂时搜不到这个信息。]"
# ============================================================
# 工具调度器
# ============================================================
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_notion":
return _search_notion_workspace(args.get("query", ""))
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 调用封装(三档)
# ============================================================
def call_simple(messages: list, system_prompt: str) -> tuple:
"""档位1:简单对话(deepseek-chat·不带工具·快速)"""
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
def call_with_tools(messages: list, system_prompt: str) -> dict:
"""档位2:工具调用(deepseek-chat·带工具·Agent Loop)"""
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_think(messages: list, system_prompt: str) -> tuple:
"""档位3:深度思考(deepseek-reasoner/R1·带思考链·慢但深)"""
headers = {
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
"Content-Type": "application/json",
}
full_msgs = [{"role": "system", "content": system_prompt}] + messages
payload = {
"model": DEEPSEEK_THINK_MODEL,
"messages": full_msgs,
"max_tokens": DEEPSEEK_MAX_TOKENS,
}
try:
resp = requests.post(
f"{DEEPSEEK_BASE_URL}/chat/completions",
headers=headers,
json=payload,
timeout=180, # R1需要更长时间
)
if resp.status_code != 200:
# R1不可用时降级到chat
print(f"[think] R1不可用(HTTP {resp.status_code}),降级到deepseek-chat")
return call_simple(messages, system_prompt)
data = resp.json()
msg = data["choices"][0]["message"]
content = msg.get("content") or ""
reasoning = msg.get("reasoning_content") or ""
return content, reasoning
except Exception as e:
print(f"[think] R1调用失败: {e},降级到deepseek-chat")
return call_simple(messages, system_prompt)