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

17 KiB
Raw Blame History

方案B完整代码交付 · 文件1 · backend/tools.py · 2026-05-12

妈妈操作方式nano backend/tools.pyCtrl+A 全选 → Ctrl+K 删光 → 粘贴下面全部代码 → Ctrl+O 回车保存 → Ctrl+X 退出


"""
晨星交互平台 · 方案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页面ID32位无横杠格式"
                    }
                },
                "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