# 移植文件2 · backend/tools.py · 重写 · 真搜索+Notion搜索 > **妈妈操作方式**:`nano backend/tools.py` → `Ctrl+A` 全选 → `Ctrl+K` 删光 → 粘贴下面全部代码 → `Ctrl+O` 回车保存 → `Ctrl+X` 退出 > --- ```python """ 晨星交互平台 · 系统性移植版 · 工具系统 核心改动: 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) ```