Guanghu Domestic Migration a27e87cb99 chore: import sanitized domestic snapshot for REPO-007
Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc

[SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
2026-07-17 15:59:55 +08:00

22 KiB
Raw Permalink Blame History

移植文件2 · backend/tools.py · 重写 · 真搜索+Notion搜索

妈妈操作方式nano backend/tools.pyCtrl+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页面ID32位无横杠格式"
                    }
                },
                "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)