{"repo": "alexliaopoly/cmhk-competitive-intelligence", "files": {"agent.py": {"8f9023fc06f9ad2f908206cd8e330bf470e0bded": {"t": 1786068465.5341725, "n": 87814, "h": "be2f2935bf3a0b5a", "c": "import csv\nimport collections\nimport hashlib\nimport json\nimport math\nimport os\nimport re\nimport statistics\nimport subprocess\nimport sys\nimport threading\nfrom contextvars import ContextVar\nfrom io import BytesIO\nfrom pathlib import Path\nfrom typing import Any, Generator\nfrom urllib.parse import parse_qs, unquote, urlencode, urlparse\nfrom urllib.request import Request\n\nfrom langchain_core.messages import AIMessageChunk, ToolMessage\nfrom langchain_core.tools import tool\nfrom langchain_deepseek import ChatDeepSeek\nfrom langgraph.prebuilt import create_react_agent\n\nfrom ai_config import load_ai_config\nfrom agent_memory import add_memory, auto_capture_user_memory, load_memories, memory_context, search_memories\nfrom agent_production import (\n AgentRunRecorder,\n action_id,\n confirmation_event,\n confirmation_metadata,\n dataset_lineage,\n retrieval_quality,\n rolling_backtest,\n)\nfrom crawl_run_registry import latest_crawl_run_summary\nfrom network_utils import urlopen_with_local_proxy_fallback\nfrom rag_llm import build_context_package, list_knowledge_datasets, retrieve_context\nfrom chart_renderer import render_chart\n\nROOT = Path(__file__).resolve().parent\nAGENT_SKILLS_DIR = ROOT / \"Codex\" / \"agent\" / \"skills\"\nFRONTEND_SKILL_ORDER = [\n \"executive-briefing\",\n \"quarterly-competitor-metrics\",\n \"cloud-vendor-metrics\",\n \"macro-policy-context\",\n \"trend-forecasting\",\n]\nWEB_SEARCH_INDEX_LOCK = threading.Lock()\nWEB_SEARCH_NEXT_INDEX = 6\nSELECTED_DATASET_IDS: ContextVar[set[str] | None] = ContextVar(\"SELECTED_DATASET_IDS\", default=None)\nSELECTED_SKILL_IDS: ContextVar[set[str] | None] = ContextVar(\"SELECTED_SKILL_IDS\", default=None)\nAPPROVED_ACTION_IDS: ContextVar[set[str]] = ContextVar(\"APPROVED_ACTION_IDS\", default=set())\nCHART_RUN_STATE: ContextVar[dict[str, Any] | None] = ContextVar(\"CHART_RUN_STATE\", default=None)\nTOOL_RUN_STATE: ContextVar[dict[str, Any] | None] = ContextVar(\"TOOL_RUN_STATE\", default=None)\n\n\ndef _reset_web_search_indexes() -> None:\n global WEB_SEARCH_NEXT_INDEX\n with WEB_SEARCH_INDEX_LOCK:\n WEB_SEARCH_NEXT_INDEX = 6\n\n\ndef _allocate_web_search_indexes(count: int) -> int:\n global WEB_SEARCH_NEXT_INDEX\n with WEB_SEARCH_INDEX_LOCK:\n start = WEB_SEARCH_NEXT_INDEX\n WEB_SEARCH_NEXT_INDEX += max(0, count)\n return start\n\n\ndef _clean_search_text(value: Any, limit: int = 500) -> str:\n text = \" \".join(str(value or \"\").split())\n return text[:limit]\n\n\ndef _register_tool_invocation(tool_name: str, limit: int) -> str | None:\n state = TOOL_RUN_STATE.get()\n if state is None:\n return None\n counts = state.setdefault(\"counts\", {})\n count = int(counts.get(tool_name) or 0) + 1\n counts[tool_name] = count\n if count > limit:\n return (\n f\"{tool_name} 已达到本轮调用上限({limit} 次),请停止继续调用该工具,\"\n \"直接基于已经返回的资料给出结论、表格或图表。\"\n )\n return None\n\n\nclass MarkdownTableLimiter:\n \"\"\"Limit streamed Markdown tables before they reach the browser.\"\"\"\n\n def __init__(self, max_rows: int = 30, max_columns: int = 8) -> None:\n self.max_rows = max_rows\n self.max_columns = max_columns\n self.buffer = \"\"\n self.in_table = False\n self.row_count = 0\n self.notice_sent = False\n\n @staticmethod\n def _is_table_line(line: str) -> bool:\n stripped = line.strip()\n return stripped.startswith(\"|\") and stripped.endswith(\"|\") and stripped.count(\"|\") >= 2\n\n @staticmethod\n def _is_separator_line(line: str) -> bool:\n cells = line.strip().split(\"|\")[1:-1]\n return bool(cells) and all(re.fullmatch(r\"\\s*:?-{3,}:?\\s*\", cell or \"\") for cell in cells)\n\n def _cap_columns(self, line: str) -> str:\n newline = \"\\n\" if line.endswith(\"\\n\") else \"\"\n cells = line.strip().split(\"|\")[1:-1]\n if len(cells) <= self.max_columns:\n return line\n capped = cells[: self.max_columns]\n return \"|\" + \"|\".join(capped) + \"|\" + newline\n\n def _process_line(self, line: str) -> str:\n if not self._is_table_line(line):\n self.in_table = False\n self.row_count = 0\n self.notice_sent = False\n return line\n if not self.in_table:\n self.in_table = True\n self.row_count = 0\n self.notice_sent = False\n if self._is_separator_line(line):\n return self._cap_columns(line)\n self.row_count += 1\n if self.row_count > self.max_rows:\n if self.notice_sent:\n return \"\"\n self.notice_sent = True\n return \"\\n表格已由后端截断:最多显示 30 行、8 列;请缩小范围或导出 CSV 查看完整数据。\\n\"\n return self._cap_columns(line)\n\n def feed(self, text: str) -> str:\n if not text:\n return \"\"\n self.buffer += text\n output: list[str] = []\n while True:\n newline_index = self.buffer.find(\"\\n\")\n if newline_index < 0:\n break\n line = self.buffer[: newline_index + 1]\n self.buffer = self.buffer[newline_index + 1 :]\n output.append(self._process_line(line))\n return \"\".join(output)\n\n def flush(self) -> str:\n if not self.buffer:\n return \"\"\n line = self.buffer\n self.buffer = \"\"\n stripped = line.strip()\n if stripped.startswith(\"|\") and not stripped.endswith(\"|\"):\n return \"\"\n return self._process_line(line)\n\n\ndef _parse_skill_frontmatter(text: str) -> dict[str, Any]:\n meta: dict[str, Any] = {\"description\": \"\", \"tags\": [], \"data\": \"\"}\n match = re.search(r\"^##\\s+前端展示\\s*\\n(?P.*?)(?=^##\\s+|\\Z)\", text, re.MULTILINE | re.DOTALL)\n if not match:\n return meta\n for raw_line in match.group(\"body\").splitlines():\n line = raw_line.strip()\n if not line.startswith(\"-\"):\n continue\n item = line.lstrip(\"-\").strip()\n if \":\" not in item:\n continue\n key, value = item.split(\":\", 1)\n key = key.strip()\n value = value.strip()\n if key == \"简介\":\n meta[\"description\"] = value\n elif key == \"标签\":\n meta[\"tags\"] = [part.strip() for part in re.split(r\"[、,,]\", value) if part.strip()]\n elif key == \"数据\":\n meta[\"data\"] = value\n return meta\n\n\ndef available_agent_skills() -> list[dict[str, Any]]:\n by_id: dict[str, dict[str, Any]] = {}\n if not AGENT_SKILLS_DIR.exists():\n return []\n for skill_file in sorted(AGENT_SKILLS_DIR.glob(\"*/SKILL.md\")):\n skill_id = skill_file.parent.name\n try:\n text = skill_file.read_text(encoding=\"utf-8\", errors=\"ignore\")\n except Exception:\n continue\n display_meta = _parse_skill_frontmatter(text)\n title = skill_id\n summary = \"\"\n for line in text.splitlines():\n clean = line.strip()\n if not clean:\n continue\n if clean.startswith(\"#\") and title == skill_id:\n title = clean.lstrip(\"#\").strip() or title\n continue\n if not summary and not clean.startswith((\"-\", \"`\", \"#\")):\n summary = clean[:180]\n break\n by_id[skill_id] = {\n \"id\": skill_id,\n \"title\": title,\n \"summary\": display_meta.get(\"description\") or summary or \"项目内 Agent skill\",\n \"description\": display_meta.get(\"description\") or summary or \"项目内 Agent skill\",\n \"tags\": display_meta.get(\"tags\") or [],\n \"data\": display_meta.get(\"data\") or \"\",\n \"path\": skill_file.relative_to(ROOT).as_posix(),\n }\n visible = [by_id[skill_id] for skill_id in FRONTEND_SKILL_ORDER if skill_id in by_id]\n return visible\n\n\ndef _selected_skill_context(skill_ids: list[str] | None) -> str:\n if not skill_ids:\n return \"\"\n allowed = {item[\"id\"] for item in available_agent_skills()}\n blocks: list[str] = [\n \"以下是本轮前端勾选的 Agent Skill 发现信息。\"\n \"这不是完整 Skill 指令;当你准备使用某个 Skill 时,必须先调用 `read_agent_skill(skill_id)` 读取完整 SKILL.md。\"\n ]\n for skill_id in skill_ids[:5]:\n clean_id = re.sub(r\"[^A-Za-z0-9_.-]\", \"\", str(skill_id or \"\"))\n if clean_id not in allowed:\n continue\n item = next((row for row in available_agent_skills() if row.get(\"id\") == clean_id), None)\n if item:\n blocks.append(\n \"\\n\".join(\n [\n f\"## Skill: {clean_id}\",\n f\"- 标题: {item.get('title') or clean_id}\",\n f\"- 简介: {item.get('description') or item.get('summary') or ''}\",\n f\"- 数据: {item.get('data') or ''}\",\n f\"- 路径: {item.get('path') or ''}\",\n ]\n )\n )\n return \"\\n\\n\".join(blocks)\n\n\ndef _looks_like_blocked_or_encoded_text(text: str) -> bool:\n sample = \" \".join((text or \"\").split())[:2400]\n if not sample:\n return False\n if re.search(r\"_waf_|waf|captcha|verify|访问验证|安全验证|人机验证\", sample, re.IGNORECASE):\n return True\n compact = re.sub(r\"\\s+\", \"\", sample)\n if len(compact) < 300:\n return False\n encoded_chars = sum(1 for ch in compact if ch.isalnum() or ch in \"+/=_-\")\n cjk_chars = len(re.findall(r\"[\\u4e00-\\u9fff]\", compact))\n return encoded_chars / max(len(compact), 1) > 0.92 and cjk_chars < 20\n\n\ndef _search_query_from_instruction(value: str) -> str:\n text = _clean_search_text(value, 200)\n text = re.sub(r\"^(请|帮我|麻烦)?(上网|联网|网上)?(搜一下|搜索一下|搜搜|搜索|查一下|查询)\", \"\", text).strip()\n text = re.sub(r\"(给我|帮我)?(?:找|列|提供)?[一二两三四五六七八九十\\d]+个?来源.*$\", \"\", text).strip(\" ,,。;;\")\n return text or _clean_search_text(value, 200)\n\n\ndef _normalize_search_results(items: list[dict[str, Any]], limit: int) -> list[dict[str, str]]:\n results: list[dict[str, str]] = []\n seen: set[str] = set()\n for item in items:\n title = _clean_search_text(item.get(\"title\") or item.get(\"heading\") or item.get(\"name\"), 120)\n url = _clean_search_text(item.get(\"url\") or item.get(\"href\") or item.get(\"link\"), 500)\n snippet = _clean_search_text(\n item.get(\"content\") or item.get(\"body\") or item.get(\"snippet\") or item.get(\"description\"),\n 320,\n )\n if not title or not url or not url.startswith((\"http://\", \"https://\")) or url in seen:\n continue\n seen.add(url)\n results.append({\"title\": title, \"url\": url, \"snippet\": snippet})\n if len(results) >= limit:\n break\n return results\n\n\ndef _unwrap_search_redirect(url: str) -> str:\n if url.startswith(\"//\"):\n url = \"https:\" + url\n parsed = urlparse(url)\n if \"search.yahoo.com\" in parsed.netloc and \"/RU=\" in parsed.path:\n encoded = parsed.path.split(\"/RU=\", 1)[1].split(\"/RK=\", 1)[0]\n return unquote(encoded)\n if \"duckduckgo.com\" in parsed.netloc and parsed.path.startswith(\"/l/\"):\n query = parse_qs(parsed.query)\n if query.get(\"uddg\"):\n return query[\"uddg\"][0]\n query = parse_qs(parsed.query)\n for key in (\"url\", \"u\"):\n if query.get(key):\n return query[key][0]\n return url\n\n\ndef _search_with_searxng(query: str, limit: int) -> list[dict[str, str]]:\n base_url = (os.environ.get(\"SEARXNG_URL\") or os.environ.get(\"CMHK_SEARXNG_URL\") or \"\").strip().rstrip(\"/\")\n if not base_url:\n return []\n params = urlencode({\"q\": query, \"format\": \"json\", \"language\": \"zh-CN\"})\n req = Request(\n f\"{base_url}/search?{params}\",\n headers={\"User-Agent\": \"CMHK-Research-Agent/1.0\"},\n )\n with urlopen_with_local_proxy_fallback(req, timeout=20) as response:\n payload = json.loads(response.read().decode(\"utf-8\", errors=\"ignore\"))\n raw_results = payload.get(\"results\") if isinstance(payload, dict) else []\n return _normalize_search_results(raw_results or [], limit)\n\n\ndef _search_with_duckduckgo(query: str, limit: int) -> list[dict[str, str]]:\n try:\n from ddgs import DDGS # type: ignore\n except Exception:\n return []\n with DDGS() as ddgs:\n raw_results = list(ddgs.text(query, max_results=limit))\n return _normalize_search_results(raw_results, limit)\n\n\ndef _search_with_duckduckgo_html(query: str, limit: int) -> list[dict[str, str]]:\n from bs4 import BeautifulSoup\n\n req = Request(\n \"https://html.duckduckgo.com/html/?\" + urlencode({\"q\": query}),\n headers={\n \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/125 Safari/537.36\",\n \"Accept-Language\": \"zh-CN,zh;q=0.9,en;q=0.8\",\n },\n )\n with urlopen_with_local_proxy_fallback(req, timeout=20) as response:\n html = response.read().decode(\"utf-8\", errors=\"ignore\")\n soup = BeautifulSoup(html, \"html.parser\")\n raw_results: list[dict[str, Any]] = []\n for block in soup.select(\".result\"):\n link = block.select_one(\".result__a[href]\") or block.select_one('a[href]')\n if not link:\n continue\n snippet_node = block.select_one(\".result__snippet\")\n raw_results.append(\n {\n \"title\": link.get_text(\" \", strip=True),\n \"url\": _unwrap_search_redirect(link.get(\"href\") or \"\"),\n \"snippet\": snippet_node.get_text(\" \", strip=True) if snippet_node else \"\",\n }\n )\n return _normalize_search_results(raw_results, limit)\n\n\ndef _search_with_brave_html(query: str, limit: int) -> list[dict[str, str]]:\n from bs4 import BeautifulSoup\n\n req = Request(\n \"https://search.brave.com/search?\" + urlencode({\"q\": query, \"source\": \"web\"}),\n headers={\"User-Agent\": \"Mozilla/5.0\"},\n )\n with urlopen_with_local_proxy_fallback(req, timeout=20) as response:\n html = response.read().decode(\"utf-8\", errors=\"ignore\")\n soup = BeautifulSoup(html, \"html.parser\")\n raw_results: list[dict[str, Any]] = []\n for block in soup.select(\".snippet\"):\n link = block.select_one('a[href^=\"http\"]')\n if not link:\n continue\n title = link.get_text(\" \", strip=True)\n url = _unwrap_search_redirect(link.get(\"href\") or \"\")\n text = block.get_text(\" \", strip=True)\n snippet = text.replace(title, \"\", 1).strip(\" -\")\n raw_results.append({\"title\": title, \"url\": url, \"snippet\": snippet})\n return _normalize_search_results(raw_results, limit)\n\n\ndef _search_with_yahoo_html(query: str, limit: int) -> list[dict[str, str]]:\n from bs4 import BeautifulSoup\n\n req = Request(\n \"https://search.yahoo.com/search?\" + urlencode({\"p\": query}),\n headers={\"User-Agent\": \"Mozilla/5.0\"},\n )\n with urlopen_with_local_proxy_fallback(req, timeout=20) as response:\n html = response.read().decode(\"utf-8\", errors=\"ignore\")\n soup = BeautifulSoup(html, \"html.parser\")\n raw_results: list[dict[str, Any]] = []\n for block in soup.select(\".dd.algo\"):\n link = block.select_one(\".compTitle a[href]\") or block.select_one('a[href^=\"http\"]')\n title_node = block.select_one(\"h3.title\") or link\n snippet_node = block.select_one(\".compText p\") or block.select_one(\"p\")\n if not link or not title_node:\n continue\n raw_results.append(\n {\n \"title\": title_node.get_text(\" \", strip=True),\n \"url\": _unwrap_search_redirect(link.get(\"href\") or \"\"),\n \"snippet\": snippet_node.get_text(\" \", strip=True) if snippet_node else \"\",\n }\n )\n return _normalize_search_results(raw_results, limit)\n\n\ndef _display_tool_result(tool_name: str, content: str, meta_event: dict[str, Any] | None) -> str:\n if tool_name == \"list_local_datasets\":\n return content[:4000].rstrip()\n if tool_name == \"list_crawl_runs\":\n return content[:4000].rstrip()\n if tool_name == \"web_search\":\n refs = (meta_event or {}).get(\"references\") or []\n if not refs:\n preview = content.strip()\n return preview[:1200].rstrip() if preview else \"联网搜索完成,但没有返回可展示来源。\"\n lines = [f\"联网搜索完成,找到 {len(refs)} 个来源:\"]\n for ref in refs[:6]:\n links = ref.get(\"links\") or []\n link = links[0] if links else {}\n label = _clean_search_text(ref.get(\"source\") or link.get(\"label\") or \"来源\", 120)\n url = _clean_search_text(link.get(\"url\") or \"\", 300)\n lines.append(f\"[{ref.get('index')}] {label}\" + (f\"\\n{url}\" if url else \"\"))\n preview = content.strip()\n if preview:\n lines.append(\"\\n具体返回内容:\")\n lines.append(preview[:4000].rstrip())\n return \"\\n\".join(lines)\n if tool_name == \"search_local_reports\":\n refs = (meta_event or {}).get(\"references\") or []\n preview = content.strip()\n return (\n f\"已读取已选数据库摘要,找到 {len(refs)} 个相关片段。\\n\\n\"\n f\"具体返回内容:\\n{preview[:4000].rstrip()}\"\n ).rstrip()\n if tool_name == \"read_local_reference\":\n if content.startswith(\"本地引用读取失败\"):\n return content\n return f\"已读取数据库原文。\\n\\n具体返回内容:\\n{content[:4000].rstrip()}\"\n if tool_name == \"read_agent_skill\":\n if content.startswith(\"Agent Skill 读取失败\"):\n return content\n return f\"Agent Skill 已读取。\\n\\n具体返回内容:\\n{content[:6000].rstrip()}\"\n if tool_name == \"read_webpage\":\n if content.startswith((\"网页读取失败\", \"网页读取跳过\", \"读取网页\")):\n return \"网页读取失败,已改用搜索摘要和本地资料。\"\n if content.startswith(\"PDF读取失败\"):\n return \"PDF 读取失败,已改用搜索摘要和本地资料。\"\n return f\"网页读取完成。\\n\\n具体返回内容:\\n{content[:4000].rstrip()}\"\n if tool_name == \"render_python_chart\":\n return content[:4000].rstrip()\n if len(content) > 1200:\n return content[:1200].rstrip() + \"\\n...\"\n return content\n\n\ndef _selected_dataset_ids() -> set[str] | None:\n return SELECTED_DATASET_IDS.get()\n\n\ndef _dataset_id_for_agent_knowledge_source(source: str) -> str:\n clean = str(source or \"\").strip().removeprefix(\"/references/\")\n parts = clean.split(\"/\")\n if len(parts) >= 2 and parts[0] == \"agent_knowledge\":\n folder = ROOT / \"agent_knowledge\" / parts[1]\n if folder.exists():\n for dataset in list_knowledge_datasets():\n if dataset.get(\"folder\") == f\"agent_knowledge/{parts[1]}\":\n return str(dataset.get(\"id\") or parts[1])\n return parts[1]\n return \"\"\n\n\ndef _dataset_is_selected(source: str) -> bool:\n selected = _selected_dataset_ids()\n if selected is None:\n return True\n dataset_id = _dataset_id_for_agent_knowledge_source(source)\n return not dataset_id or dataset_id in selected\n\n\ndef _is_action_approved(name: str, payload: Any = None) -> bool:\n approved = APPROVED_ACTION_IDS.get()\n return action_id(name, payload) in approved\n\n\ndef _require_action_confirmation(name: str, payload: Any = None, description: str = \"\") -> str | None:\n if _is_action_approved(name, payload):\n return None\n event = confirmation_event(name, payload, description)\n return (\n f\"需要用户确认后才能执行:{event['label']}。\\n\"\n f\"- 原因:{event['risk']}\\n\"\n f\"- 操作说明:{event['description']}\\n\"\n \"请点击前端确认按钮后再执行。\"\n f\"\\n{confirmation_metadata(event)}\"\n )\n\n\n@tool\ndef read_agent_skill(skill_id: str) -> str:\n \"\"\"读取本轮已选择的 Agent Skill 完整 SKILL.md 指令。\n 当你准备使用某个前端已选 Agent Skill 时,必须先调用此工具读取完整指令;\n `load_agent_skills` 只表示前端选择了哪些 Skill,不等于已经阅读和执行了 Skill。\n \"\"\"\n clean_id = re.sub(r\"[^A-Za-z0-9_.-]\", \"\", str(skill_id or \"\"))\n if not clean_id:\n return \"Agent Skill 读取失败:skill_id 为空。\"\n selected_ids = {\n re.sub(r\"[^A-Za-z0-9_.-]\", \"\", str(item or \"\"))\n for item in (SELECTED_SKILL_IDS.get() or set())\n if str(item or \"\").strip()\n }\n allowed = {item[\"id\"] for item in available_agent_skills()}\n if clean_id not in allowed:\n return f\"Agent Skill 读取失败:{clean_id} 不是前端允许的主要 Skill。\"\n if selected_ids and clean_id not in selected_ids:\n return f\"Agent Skill 读取失败:{clean_id} 本轮未在前端 Skill 按钮中选择。\"\n skill_file = AGENT_SKILLS_DIR / clean_id / \"SKILL.md\"\n if not skill_file.exists():\n return f\"Agent Skill 读取失败:未找到 {clean_id}/SKILL.md。\"\n try:\n text = skill_file.read_text(encoding=\"utf-8\", errors=\"ignore\").strip()\n except Exception as exc:\n return f\"Agent Skill 读取失败:{clean_id} 无法读取,原因:{_clean_search_text(exc, 160)}。\"\n if not text:\n return f\"Agent Skill 读取失败:{clean_id}/SKILL.md 内容为空。\"\n referenced_files = sorted(\n {\n match\n for match in re.findall(r\"`([^`]+?\\.(?:md|csv|json|py|txt))`\", text, flags=re.IGNORECASE)\n if not match.startswith((\"/\", \"http://\", \"https://\")) and \"..\" not in match\n }\n )\n refs_text = \"\\n\".join(f\"- {item}\" for item in referenced_files[:12]) or \"- 未发现显式引用文件\"\n return (\n f\"[Agent Skill: {clean_id}]\\n\"\n f\"路径:{skill_file.relative_to(ROOT).as_posix()}\\n\"\n \"已读取完整 SKILL.md。若下列引用文件与本轮任务相关,应继续用本地检索/读取工具追溯,而不是只停留在 Skill 标题。\\n\"\n f\"引用文件线索:\\n{refs_text}\\n\\n\"\n f\"{text[:20000]}\"\n )\n\n\n@tool\ndef list_local_datasets() -> str:\n \"\"\"列出小竞 AI 后端当前可检索和读取的本地数据集。\n 当用户问“你能访问哪些数据”“数据放哪里”“有哪些内部/外部数据”,或你准备做趋势分析、问数、核验前需要了解可用数据时,先使用此工具。\n \"\"\"\n selected_ids = _selected_dataset_ids()\n datasets = list_knowledge_datasets(dataset_ids=selected_ids)\n if not datasets:\n return \"当前未选择任何本地数据库;本轮 AI 不会读取、列举或猜测未选择的数据库内容。请在前端数据库按钮中选择需要发送给 AI 的数据库。\"\n lines = [\"本轮已选择并可发送给 AI 的本地数据库:\"]\n references = []\n links = []\n for index, dataset in enumerate(datasets, 1):\n entrypoints = dataset.get(\"entrypoints\") or []\n files = dataset.get(\"files\") or []\n primary = next((item for item in files if item.get(\"name\") in entrypoints), files[0] if files else None)\n primary_link = {\"label\": dataset.get(\"title\") or dataset.get(\"id\"), \"url\": primary.get(\"url\")} if primary else None\n lines.append(\n \"\\n\".join(\n [\n f\"[数据集 {index}] {dataset.get('title') or dataset.get('id')}\",\n f\"- id: {dataset.get('id')}\",\n f\"- 类型: {dataset.get('source_type') or 'local'}\",\n f\"- 范围: {dataset.get('scope') or '未说明'}\",\n f\"- 简介: {dataset.get('summary') or '未说明'}\",\n f\"- 标签: {'、'.join(dataset.get('tags') or []) or '无'}\",\n f\"- 入口文件: {', '.join(entrypoints) or '未指定'}\",\n f\"- 文件夹: {dataset.get('folder')}\",\n ]\n )\n )\n if primary_link:\n links.append(primary_link)\n references.append({\"index\": index, \"source\": dataset.get(\"title\") or dataset.get(\"id\"), \"links\": [primary_link]})\n meta_data = {\"type\": \"meta\", \"sources\": [d.get(\"title\") for d in datasets], \"links\": links, \"references\": references}\n return \"\\n\\n\".join(lines) + f\"\\n{json.dumps(meta_data, ensure_ascii=False)}\"\n\n\n@tool\ndef list_crawl_runs(limit: int = 5) -> str:\n \"\"\"列出最近几次全量爬虫运行索引。\n 当用户询问爬虫日志、上次爬虫结果、失败链接、覆盖率、飞书日志页、Agent 数据整理运行记录或调度追溯时,先使用此工具。\n \"\"\"\n safe_limit = max(1, min(int(limit or 5), 10))\n return latest_crawl_run_summary(safe_limit)\n\n\n@tool\ndef search_local_reports(query: str) -> str:\n \"\"\"搜索本地的战略部周报、审计日志和之前爬取过的网页数据。\n 当你需要了解公司的最新动态、特定主体的近期情况,或是爬虫的执行历史时,请使用此工具。\n 若用户问题包含“收入同比”“营收同比”“营业收入同比”“收入增长”“revenue_growth_yoy”“YoY”等词,query 必须保留这些同比/增长关键词,不能简化成“收入”或“营业收入”。\n \"\"\"\n limit_message = _register_tool_invocation(\"search_local_reports\", 6)\n if limit_message:\n return limit_message\n chunks = retrieve_context(query, limit=10, dataset_ids=_selected_dataset_ids())\n if not chunks:\n return \"没有找到相关的本地报告信息。\"\n context_package = build_context_package(chunks, token_budget=6500, model=_agent_model_name())\n chunks = context_package[\"chunks\"]\n audit = context_package[\"audit\"]\n quality = retrieval_quality(query, chunks, audit)\n \n result = []\n references = []\n meta_links = []\n seen_urls = set()\n for i, chunk in enumerate(chunks):\n ref_label = f\"{chunk['source']} · 片段 {i + 1}\"\n result.append(f\"[来源 {i+1}: {ref_label}]\\n{chunk['text']}\")\n chunk_links = chunk.get(\"links\", [])\n references.append(\n {\n \"index\": i + 1,\n \"source\": ref_label,\n \"links\": [\n {\n **link,\n \"label\": f\"{link.get('label') or chunk['source']} · 片段 {i + 1}\",\n }\n for link in chunk_links\n ],\n }\n )\n for link in chunk_links:\n url = link.get(\"url\")\n if url and url not in seen_urls:\n seen_urls.add(url)\n meta_links.append(link)\n \n text_output = \"\\n\\n\".join(result)\n meta_data = {\n \"type\": \"meta\",\n \"sources\": [chunk[\"source\"] for chunk in chunks],\n \"links\": meta_links,\n \"references\": references,\n \"contextAudit\": audit,\n \"retrievalQuality\": quality,\n }\n return f\"{text_output}\\n{json.dumps(meta_data, ensure_ascii=False)}\"\n\n\n@tool\ndef read_local_reference(source: str) -> str:\n \"\"\"读取本地引用文件的原文内容。\n 当 `search_local_reports` 返回 `weekly_report.md`、`final_audit.md`、`coverage_report.tsv`、`run_log.tsv` 或 `row_*.json`\n 等本地来源,而你需要查看更完整上下文、核对本地口径或追溯原始抓取结果时,优先使用此工具。\n 参数可以是文件名,也可以是 `/references/...` 链接。\n \"\"\"\n limit_message = _register_tool_invocation(\"read_local_reference\", 4)\n if limit_message:\n return limit_message\n import web_app\n\n clean = str(source or \"\").strip()\n if not clean:\n return \"本地引用读取失败:来源为空。\"\n if clean.startswith(\"/references/\"):\n clean = clean.removeprefix(\"/references/\")\n if not _dataset_is_selected(clean):\n return f\"本地引用读取失败:{clean} 所属数据库本轮未被前端选择,后端不会把它发送给 AI。\"\n target = web_app.reference_path(clean)\n if not target or not target.exists():\n return f\"本地引用读取失败:未找到允许读取的本地引用 {clean}。\"\n try:\n text = web_app.read_display_text(target)\n except Exception as exc:\n return f\"本地引用读取失败:{clean} 无法读取,原因:{_clean_search_text(exc, 160)}。\"\n text = re.sub(r\"\\n{3,}\", \"\\n\\n\", text).strip()\n if not text:\n return f\"本地引用读取失败:{clean} 内容为空。\"\n limit = 60000 if clean.startswith(\"agent_knowledge/\") else 16000\n return f\"[本地引用: {clean}]\\n{text[:limit]}\"\n\n\n@tool\ndef web_search(query: str, max_results: int = 5) -> str:\n \"\"\"联网搜索公开网页信息。\n 当用户要求“上网搜一下”“联网搜索”“查最新消息”“找公开来源”或本地 RAG 没有足够信息时使用。\n 优先使用自托管 SearXNG(环境变量 SEARXNG_URL/CMHK_SEARXNG_URL),否则使用开源 DDGS/DuckDuckGo 搜索库。\n \"\"\"\n query = _search_query_from_instruction(query)\n if not query:\n return \"搜索关键词为空。\"\n limit = max(1, min(int(max_results or 5), 8))\n provider = \"searxng\"\n failures: list[str] = []\n try:\n results = _search_with_searxng(query, limit)\n except Exception as exc:\n results = []\n failures.append(f\"SearXNG: {_clean_search_text(exc, 120)}\")\n if not results:\n provider = \"ddgs\"\n try:\n results = _search_with_duckduckgo(query, limit)\n except Exception as exc:\n failures.append(f\"DDGS: {_clean_search_text(exc, 120)}\")\n results = []\n if not results:\n provider = \"duckduckgo_html\"\n try:\n results = _search_with_duckduckgo_html(query, limit)\n except Exception as exc:\n failures.append(f\"DuckDuckGo HTML: {_clean_search_text(exc, 120)}\")\n results = []\n if not results:\n provider = \"yahoo_html\"\n try:\n results = _search_with_yahoo_html(query, limit)\n except Exception as exc:\n failures.append(f\"Yahoo: {_clean_search_text(exc, 120)}\")\n results = []\n if not results:\n provider = \"brave_html\"\n try:\n results = _search_with_brave_html(query, limit)\n except Exception as exc:\n failures.append(f\"Brave: {_clean_search_text(exc, 120)}\")\n results = []\n if not results:\n detail = \";\".join(failures[-4:]) if failures else \"搜索源没有返回结果\"\n return (\n f\"联网搜索未返回可用网页结果。查询:{query}。搜索源状态:{detail}。\"\n \"建议稍后重试,或配置稳定的自托管 SearXNG:设置 SEARXNG_URL 后重启后端。\"\n )\n\n lines = []\n references = []\n links = []\n start_index = _allocate_web_search_indexes(len(results))\n for offset, item in enumerate(results):\n index = start_index + offset\n title = item[\"title\"]\n url = item[\"url\"]\n snippet = item.get(\"snippet\") or \"\"\n lines.append(f\"[来源 {index}: {title}]\\n链接:{url}\\n摘要:{snippet}\")\n link = {\"label\": title, \"url\": url}\n links.append(link)\n references.append({\"index\": index, \"source\": title, \"links\": [link]})\n meta_data = {\n \"type\": \"meta\",\n \"provider\": provider,\n \"sources\": [item[\"title\"] for item in results],\n \"links\": links,\n \"references\": references,\n }\n return \"\\n\\n\".join(lines) + f\"\\n{json.dumps(meta_data, ensure_ascii=False)}\"\n\n@tool\ndef trigger_crawl(row_id: int) -> str:\n \"\"\"触发针对特定行的爬虫任务。\n 参数 row_id 是配置表中的行号。\n 如果你需要最新抓取某一行的数据,使用此工具。注意这可能需要十几秒。\n \"\"\"\n payload = {\"row_id\": int(row_id)}\n confirmation = _require_action_confirmation(\"trigger_crawl\", payload, f\"定向爬取配置表第 {row_id} 行。\")\n if confirmation:\n return confirmation\n env = os.environ.copy()\n env[\"CMHK_ROWS\"] = str(row_id)\n try:\n proc = subprocess.run([sys.executable, str(ROOT / \"crawl.py\")], env=env, capture_output=True, text=True, timeout=120)\n if proc.returncode == 0:\n return f\"爬取完成 (Row {row_id}):\\nStdout: {proc.stdout[:1000]}...\"\n else:\n return f\"爬取失败 (Row {row_id}):\\nStderr: {proc.stderr}\"\n except Exception as e:\n return f\"执行爬虫异常: {str(e)}\"\n\n\n@tool\ndef trigger_full_crawl() -> str:\n \"\"\"触发完整公开信息爬取、飞书同步和公司指标刷新。\n 当用户明确要求重新爬取、全量抓取、更新公开信息数据或跑完整采集流程时使用。该操作可能耗时较长。\n \"\"\"\n import web_app\n\n confirmation = _require_action_confirmation(\"trigger_full_crawl\", {}, \"执行完整公开信息爬取、飞书同步和公司指标刷新。\")\n if confirmation:\n return confirmation\n try:\n result = web_app.run_crawl()\n except Exception as exc:\n return f\"完整爬取执行异常:{_clean_search_text(exc, 240)}\"\n status = \"成功\" if result.get(\"ok\") else \"失败\"\n return (\n f\"完整爬取{status}。\\n\"\n f\"- 退出码:{result.get('returnCode')}\\n\"\n f\"- 耗时:{result.get('durationMs', '未知')} ms\\n\"\n f\"- stdout:{_clean_search_text(result.get('stdout') or '', 1500)}\\n\"\n f\"- stderr:{_clean_search_text(result.get('stderr') or '', 1500)}\"\n )\n\n@tool\ndef feishu_cli(command_args: str) -> str:\n \"\"\"执行飞书命令行工具 (lark-cli)。\n 当你需要与飞书表格进行同步、写入数据,或者查询飞书记录时,请使用此工具。\n 由于安全限制,你只需提供 'lark-cli' 后面的参数,例如: 'sheets +read --range 9c638d!A1:B2'\n \"\"\"\n import shutil\n lowered = str(command_args or \"\").lower()\n mutating = any(token in lowered for token in [\"+write\", \"+update\", \"+append\", \"+delete\", \"+clear\"])\n if mutating:\n payload = {\"command_args\": command_args}\n confirmation = _require_action_confirmation(\"feishu_cli\", payload, \"执行会写入或修改飞书表格的命令。\")\n if confirmation:\n return confirmation\n LARK_CLI = shutil.which(\"lark-cli\") or \"/opt/homebrew/bin/lark-cli\"\n \n # Simple shell-like splitting for arguments\n import shlex\n try:\n args = shlex.split(command_args)\n except Exception as e:\n return f\"参数解析错误: {e}\"\n \n cmd = [LARK_CLI] + args\n try:\n proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n if proc.returncode == 0:\n return f\"执行成功:\\n{proc.stdout}\"\n else:\n return f\"执行失败:\\n{proc.stderr}\"\n except Exception as e:\n return f\"执行出错: {str(e)}\"\n\n@tool\ndef trigger_report_generation() -> str:\n \"\"\"触发本地周报生成任务。\n 当用户明确要求生成战略内参周报、重新生成周报或输出 Word 周报时使用。它会调用 Web 层生成流程,生成 docx 并同步生成音频摘要。\n \"\"\"\n import web_app\n\n confirmation = _require_action_confirmation(\"trigger_report_generation\", {}, \"生成正式 Word 周报并同步音频摘要。\")\n if confirmation:\n return confirmation\n try:\n result = web_app.run_report_generation()\n except Exception as exc:\n return f\"执行周报生成异常:{_clean_search_text(exc, 240)}\"\n status = \"成功\" if result.get(\"ok\") else \"失败\"\n outputs = result.get(\"status\", {}).get(\"outputs\", [])\n latest = outputs[0].get(\"name\") if outputs else \"未找到输出文件\"\n return (\n f\"周报生成{status}。\\n\"\n f\"- 最新文件:{latest}\\n\"\n f\"- 退出码:{result.get('returnCode')}\\n\"\n f\"- 耗时:{result.get('durationMs', '未知')} ms\\n\"\n f\"- 音频:{json.dumps(result.get('audio'), ensure_ascii=False)[:800]}\\n\"\n f\"- stdout:{_clean_search_text(result.get('stdout') or '', 1200)}\\n\"\n f\"- stderr:{_clean_search_text(result.get('stderr') or '', 1200)}\"\n )\n\n\n@tool\ndef trigger_carrier_performance_report_generation() -> str:\n \"\"\"触发运营商业绩摘要 Word 报告生成。\n 当用户明确要求生成运营商业绩摘要、业绩对标摘要或运营商报告时使用。它会生成 docx 并同步生成音频摘要。\n \"\"\"\n import web_app\n\n confirmation = _require_action_confirmation(\n \"trigger_carrier_performance_report_generation\",\n {},\n \"生成运营商业绩摘要 Word 报告并同步音频摘要。\",\n )\n if confirmation:\n return confirmation\n try:\n result = web_app.run_carrier_performance_generation()\n except Exception as exc:\n return f\"执行业绩摘要生成异常:{_clean_search_text(exc, 240)}\"\n status = \"成功\" if result.get(\"ok\") else \"失败\"\n outputs = [item for item in result.get(\"status\", {}).get(\"outputs\", []) if item.get(\"reportType\") == \"carrier-performance\"]\n latest = outputs[0].get(\"name\") if outputs else \"未找到业绩摘要输出文件\"\n return (\n f\"运营商业绩摘要生成{status}。\\n\"\n f\"- 最新文件:{latest}\\n\"\n f\"- 退出码:{result.get('returnCode')}\\n\"\n f\"- 耗时:{result.get('durationMs', '未知')} ms\\n\"\n f\"- 音频:{json.dumps(result.get('audio'), ensure_ascii=False)[:800]}\\n\"\n f\"- stdout:{_clean_search_text(result.get('stdout') or '', 1200)}\\n\"\n f\"- stderr:{_clean_search_text(result.get('stderr') or '', 1200)}\"\n )\n\n\n@tool\ndef list_report_outputs() -> str:\n \"\"\"列出当前可下载的正式 Word 输出文件。\n 当用户询问输出文件、周报在哪里、有哪些 Word、最新文件、下载对象或报告产物时使用。\n \"\"\"\n import web_app\n\n try:\n status = web_app.build_status()\n except Exception as exc:\n return f\"读取输出文件失败:{_clean_search_text(exc, 200)}\"\n outputs = status.get(\"outputs\") or []\n if not outputs:\n return \"当前没有可用输出文件。\"\n lines = [\"当前可用输出文件:\"]\n refs = []\n links = []\n for index, item in enumerate(outputs[:30], 1):\n name = item.get(\"name\") or item.get(\"path\") or f\"output-{index}\"\n report_type = item.get(\"reportType\") or \"unknown\"\n mtime = item.get(\"mtimeText\") or item.get(\"mtime\") or \"\"\n path = item.get(\"path\") or item.get(\"path_str\") or \"\"\n url = f\"/outputs/{path}\" if path else \"\"\n audio = item.get(\"audio\", {}).get(\"exists\")\n lines.append(f\"{index}. {name};类型:{report_type};更新时间:{mtime};音频:{'有' if audio else '无'};路径:{path}\")\n if url:\n link = {\"label\": name, \"url\": url}\n links.append(link)\n refs.append({\"index\": index, \"source\": name, \"links\": [link]})\n meta = {\"type\": \"meta\", \"sources\": [item.get(\"name\") for item in outputs[:30]], \"links\": links, \"references\": refs}\n return \"\\n\".join(lines) + f\"\\n{json.dumps(meta, ensure_ascii=False)}\"\n\n\n@tool\ndef get_crawl_settings_summary() -> str:\n \"\"\"读取当前爬取设置摘要。\n 当用户询问当前爬取范围、启用哪些行、覆盖多少主体/字段、设置内容或数据内容配置时使用。只读取摘要,不修改设置。\n \"\"\"\n import web_app\n\n try:\n settings = web_app.build_settings_payload()\n except Exception as exc:\n return f\"读取爬取设置失败:{_clean_search_text(exc, 200)}\"\n summary = settings.get(\"summary\") or {}\n rows = settings.get(\"rows\") or []\n enabled = [row for row in rows if row.get(\"enabled\")]\n preview = []\n for row in enabled[:20]:\n entities = row.get(\"entities\") or []\n fields = row.get(\"fields\") or []\n preview.append(\n f\"- 第 {row.get('row')} 行:主体 {len(entities)} 个,字段 {len(fields)} 个,目标链接 {len(row.get('sourceUrls') or [])} 个\"\n )\n return (\n \"当前爬取设置摘要:\\n\"\n f\"- 启用行:{summary.get('enabledRows')} / {summary.get('totalRows')}\\n\"\n f\"- 已选主体:{summary.get('selectedEntities')}\\n\"\n f\"- 已选字段:{summary.get('selectedFields')}\\n\"\n f\"- 设置文件:crawl_settings.json\\n\"\n + \"\\n\".join(preview)\n )\n\n@tool\ndef read_webpage(url: str) -> str:\n \"\"\"访问并读取指定 URL 的纯文本内容。\n 当用户提供一个网页链接,并要求你阅读、总结或提取其中的信息时,使用此工具。\n 对 PDF 财报或公告链接会尝试抽取 PDF 文本;对反爬、二进制或浏览器验证页面会返回失败原因。\n \"\"\"\n from bs4 import BeautifulSoup\n try:\n req = Request(url, headers={'User-Agent': 'Mozilla/5.0', 'Accept': 'text/html,text/plain;q=0.9,*/*;q=0.1'})\n with urlopen_with_local_proxy_fallback(req, timeout=15) as response:\n content_type = response.headers.get(\"Content-Type\", \"\")\n raw = response.read(12_000_000)\n is_pdf = re.search(r\"application/pdf\", content_type, re.IGNORECASE) or urlparse(url).path.lower().endswith(\".pdf\") or raw[:4] == b\"%PDF\"\n if is_pdf:\n try:\n from pypdf import PdfReader\n\n reader = PdfReader(BytesIO(raw))\n pages = []\n for page in reader.pages[:12]:\n page_text = page.extract_text() or \"\"\n if page_text.strip():\n pages.append(page_text.strip())\n text = re.sub(r\"\\s+\", \" \", \"\\n\".join(pages)).strip()\n if len(text) < 80:\n return \"PDF读取失败:该 PDF 未抽取到足够文本,可能是扫描件或加密文件。\"\n return text[:12000]\n except Exception as exc:\n return f\"PDF读取失败:{_clean_search_text(exc, 200)}。\"\n if not re.search(r\"text/html|text/plain|application/xhtml\\+xml\", content_type, re.IGNORECASE):\n return f\"网页读取跳过:该链接返回 {content_type or '非文本内容'},请使用搜索摘要或官方 PDF/公告页面。\"\n if b\"\\x00\" in raw[:2048]:\n return \"网页读取跳过:该链接返回二进制内容,无法作为正文展示。\"\n html = raw.decode(\"utf-8\", errors=\"ignore\")\n if not re.search(r\"]\", html, re.IGNORECASE):\n text_probe = \" \".join(html.split())\n if _looks_like_blocked_or_encoded_text(text_probe):\n return \"网页读取失败:该链接返回反爬验证或加密内容,无法作为正文展示。\"\n if len(text_probe) > 200 and not re.fullmatch(r\"[A-Za-z0-9+/=\\s]+\", text_probe[:2000]):\n return text_probe[:8000]\n return \"网页读取失败:该链接没有返回可解析正文。\"\n soup = BeautifulSoup(html, 'html.parser')\n for script in soup([\"script\", \"style\"]):\n script.extract()\n text = soup.get_text(separator=' ', strip=True)\n text = re.sub(r\"\\s+\", \" \", text).strip()\n if _looks_like_blocked_or_encoded_text(text):\n return \"网页读取失败:该链接返回反爬验证或加密内容,无法作为正文展示。\"\n if len(text) < 80:\n return \"网页读取失败:该链接正文过短或需要浏览器验证。\"\n return text[:10000]\n except Exception as e:\n return f\"读取网页 {url} 失败: {str(e)}\"\n\n\ndef _decode_chart_spec_payload(chart_spec: Any) -> dict[str, Any]:\n payload: Any = chart_spec\n for _ in range(3):\n if isinstance(payload, str):\n raw = payload.strip()\n if raw.startswith(\"```\"):\n raw = re.sub(r\"^```(?:json)?\\s*\", \"\", raw)\n raw = re.sub(r\"\\s*```$\", \"\", raw)\n payload = json.loads(raw)\n continue\n if isinstance(payload, dict) and \"chart_spec\" in payload and not {\"x\", \"series\"}.issubset(payload.keys()):\n payload = payload.get(\"chart_spec\")\n continue\n break\n if not isinstance(payload, dict):\n raise ValueError(\"chart_spec 必须是 JSON 对象。\")\n return _normalize_chart_spec_payload(payload)\n\n\ndef _normalize_chart_spec_payload(spec: dict[str, Any]) -> dict[str, Any]:\n normalized = dict(spec)\n series = []\n for item in normalized.get(\"series\") or []:\n if not isinstance(item, dict):\n continue\n series_item = dict(item)\n if \"data\" not in series_item and \"values\" in series_item:\n series_item[\"data\"] = series_item.get(\"values\")\n series.append(series_item)\n normalized[\"series\"] = series\n return normalized\n\n\ndef _chart_failure_signature(spec_or_raw: Any) -> str:\n try:\n normalized = _normalize_chart_spec_payload(spec_or_raw) if isinstance(spec_or_raw, dict) else spec_or_raw\n payload = json.dumps(normalized, ensure_ascii=False, sort_keys=True, default=str)\n except Exception:\n payload = str(spec_or_raw)\n return hashlib.sha256(payload.encode(\"utf-8\", errors=\"ignore\")).hexdigest()[:16]\n\n\n@tool\ndef render_python_chart(chart_spec: str) -> str:\n \"\"\"用 Python/Matplotlib 生成中文可正常渲染的 PNG 图表。\n 当用户要求画图、趋势图、柱状图、表格+图表或可视化时,在完成数据检索和核验后调用。\n 参数必须是 JSON 字符串,包含 type、title、unit、x、series;series 每项使用 data 数组(values 也会被兼容)。请勿提供 notes 或图表底部的解释性文字。\n \"\"\"\n state = CHART_RUN_STATE.get()\n if state is not None:\n state[\"calls\"] = int(state.get(\"calls\") or 0) + 1\n if state[\"calls\"] > 4:\n return (\n \"Python 图表生成停止:本轮图表工具调用次数已达 4 次。\"\n \"请停止继续调用图表工具,直接基于已检索数据用文字、表格或已有图表回答。\"\n )\n try:\n spec = _decode_chart_spec_payload(chart_spec)\n except Exception as exc:\n if state is not None:\n state[\"failures\"] = int(state.get(\"failures\") or 0) + 1\n return (\n f\"Python 图表生成失败:chart_spec 不是可用 JSON,原因:{_clean_search_text(exc, 160)}。\"\n \"不要用相同参数反复调用;如无法修正,请改用文字表格回答。\"\n )\n signature = _chart_failure_signature(spec)\n if state is not None:\n failed_signatures = state.setdefault(\"failed_signatures\", {})\n if failed_signatures.get(signature):\n return (\n \"Python 图表生成停止:相同图表参数已经失败过。\"\n \"请不要重复调用,改为给出文字结论或重新检索数据后再生成一张不同口径的图。\"\n )\n try:\n result = render_chart(spec)\n except Exception as exc:\n if state is not None:\n state[\"failures\"] = int(state.get(\"failures\") or 0) + 1\n state.setdefault(\"failed_signatures\", {})[signature] = True\n if state[\"failures\"] >= 2:\n return (\n f\"Python 图表生成失败并已停止重试:{_clean_search_text(exc, 200)}。\"\n \"本轮不要再调用 `render_python_chart`,请直接用文字、简表或已有数据回答。\"\n )\n return (\n f\"Python 图表生成失败:{_clean_search_text(exc, 200)}。\"\n \"请修正为包含 x 和 series.data 的 JSON 后最多再试一次,不要重复同一参数。\"\n )\n title = _clean_search_text(spec.get(\"title\") or \"Python 图表\", 120)\n return (\n f\"Python 图表已生成:\\n\\n![{title}]({result['url']})\\n\\n\"\n f\"图表文件:{result['path']}\\n\"\n f\"中文字体:{result['font']}\"\n )\n\n\ndef _parse_metric_number(value: Any) -> float | None:\n text = str(value or \"\").strip()\n if not text:\n return None\n text = text.replace(\",\", \"\").replace(\"%\", \"\").replace(\"−\", \"-\")\n text = re.sub(r\"[^\\d.\\-]\", \"\", text)\n if text in {\"\", \"-\", \".\", \"-.\"}:\n return None\n try:\n return float(text)\n except Exception:\n return None\n\n\ndef _period_sort_key_for_forecast(period: str) -> tuple[int, int]:\n text = str(period or \"\").strip().upper()\n match = re.match(r\"Q([1-4])\\s+(\\d{4})$\", text)\n if match:\n return int(match.group(2)), int(match.group(1))\n match = re.match(r\"FY(\\d{4})\\s+Q([1-4])$\", text)\n if match:\n return int(match.group(1)), int(match.group(2))\n match = re.match(r\"H([12])\\s+(\\d{4})$\", text)\n if match:\n return int(match.group(2)), int(match.group(1))\n return 9999, 9\n\n\ndef _next_quarter_label(period: str, steps: int = 1) -> str:\n fiscal_style = bool(re.match(r\"FY\\d{4}\\s+Q[1-4]$\", str(period or \"\").strip().upper()))\n year, quarter = _period_sort_key_for_forecast(period)\n for _ in range(steps):\n quarter += 1\n if quarter > 4:\n quarter = 1\n year += 1\n if fiscal_style:\n return f\"FY{year} Q{quarter}\"\n return f\"Q{quarter} {year}\"\n\n\ndef _next_half_year_label(period: str, steps: int = 1) -> str:\n year, half = _period_sort_key_for_forecast(period)\n for _ in range(steps):\n half += 1\n if half > 2:\n half = 1\n year += 1\n return f\"H{half} {year}\"\n\n\ndef _hw_additive_forecast(values: list[float], horizon: int, season_length: int = 4) -> tuple[list[float], str, float | None]:\n if len(values) < season_length * 2:\n return _seasonal_naive_forecast(values, horizon, season_length), \"seasonal_naive_insufficient_history\", None\n best: tuple[float, list[float], tuple[float, float, float]] | None = None\n alphas = [0.2, 0.4, 0.6, 0.8]\n betas = [0.05, 0.15, 0.30]\n gammas = [0.05, 0.15, 0.30]\n initial_level = sum(values[:season_length]) / season_length\n second_level = sum(values[season_length:season_length * 2]) / season_length\n initial_trend = (second_level - initial_level) / season_length\n seasonals = [values[i] - initial_level for i in range(season_length)]\n for alpha in alphas:\n for beta in betas:\n for gamma in gammas:\n level = initial_level\n trend = initial_trend\n seasonal = seasonals[:]\n fitted: list[float] = []\n errors: list[float] = []\n ok = True\n for i, actual in enumerate(values):\n if i >= season_length:\n forecast = level + trend + seasonal[i % season_length]\n fitted.append(forecast)\n errors.append(actual - forecast)\n prev_level = level\n try:\n level = alpha * (actual - seasonal[i % season_length]) + (1 - alpha) * (level + trend)\n trend = beta * (level - prev_level) + (1 - beta) * trend\n seasonal[i % season_length] = gamma * (actual - level) + (1 - gamma) * seasonal[i % season_length]\n except Exception:\n ok = False\n break\n if not all(math.isfinite(item) for item in [level, trend, seasonal[i % season_length]]):\n ok = False\n break\n if not ok or not errors:\n continue\n rmse = math.sqrt(sum(err * err for err in errors) / len(errors))\n future = [level + step * trend + seasonal[(len(values) + step - 1) % season_length] for step in range(1, horizon + 1)]\n if best is None or rmse < best[0]:\n best = (rmse, future, (alpha, beta, gamma))\n if best is None:\n return _seasonal_naive_forecast(values, horizon, season_length), \"seasonal_naive_fit_failed\", None\n rmse, future, params = best\n method = f\"holt_winters_additive_grid_search(alpha={params[0]}, beta={params[1]}, gamma={params[2]}, season_length={season_length})\"\n return future, method, rmse\n\n\ndef _seasonal_naive_forecast(values: list[float], horizon: int, season_length: int = 4) -> list[float]:\n if not values:\n return []\n if len(values) < season_length:\n return [values[-1]] * horizon\n return [values[-season_length + ((step - 1) % season_length)] for step in range(1, horizon + 1)]\n\n\ndef _selected_quarterly_metrics_path() -> Path | None:\n selected = _selected_dataset_ids()\n candidates: list[Path] = []\n root = ROOT / \"agent_knowledge\"\n if not root.exists():\n return None\n for folder in root.glob(\"quarterly_competitor_metrics_*\"):\n if not folder.is_dir():\n continue\n csv_path = folder / \"quarterly_metrics.csv\"\n if not csv_path.exists():\n continue\n dataset_id = folder.name\n try:\n manifest = json.loads((folder / \"manifest.json\").read_text(encoding=\"utf-8\"))\n dataset_id = str(manifest.get(\"id\") or dataset_id)\n visibility = str(manifest.get(\"visibility\") or \"\").strip().lower()\n except Exception:\n visibility = \"\"\n if visibility in {\"hidden\", \"superseded\", \"archived\"}:\n continue\n if selected is not None and dataset_id not in selected and folder.name not in selected:\n continue\n candidates.append(csv_path)\n if not candidates:\n return None\n return sorted(candidates, key=lambda path: (path.parent.name, path.stat().st_mtime), reverse=True)[0]\n\n\n@tool\ndef forecast_quarterly_metric(\n forecast_spec: str = \"\",\n subject: str = \"\",\n metric_key: str = \"revenue\",\n horizon: int = 4,\n category: str = \"\",\n) -> str:\n \"\"\"用已选择的季度/半年度指标数据库做趋势预测。\n 当用户要求“预测未来”“趋势预测”“forecast”“未来4个季度”等时使用。\n 优先传结构化参数 subject、metric_key、horizon、category;也兼容 forecast_spec JSON 字符串。季度数据会优先使用 official_value;official_conflict 采用 official_value,source_gap 行不参与预测。\n \"\"\"\n raw = str(forecast_spec or \"\").strip()\n spec: dict[str, Any] = {}\n if raw:\n if raw.startswith(\"```\"):\n raw = re.sub(r\"^```(?:json)?\\s*\", \"\", raw)\n raw = re.sub(r\"\\s*```$\", \"\", raw)\n try:\n spec = json.loads(raw)\n except Exception:\n spec = {}\n subject = _clean_search_text(spec.get(\"subject\") or subject, 80)\n metric_key = _clean_search_text(spec.get(\"metric_key\") or metric_key or \"revenue\", 80)\n category = _clean_search_text(spec.get(\"category\") or category, 80)\n horizon = max(1, min(int(spec.get(\"horizon\") or horizon or 4), 8))\n if not subject:\n return \"预测失败:缺少 subject。请指定主体,例如 AWS、中国移动、Microsoft Azure / Intelligent Cloud。\"\n csv_path = _selected_quarterly_metrics_path()\n if csv_path is None:\n return \"预测失败:当前未选择可用的 quarterly_competitor_metrics 数据库。请在数据库按钮中选择 5 年季度/半年度数据包。\"\n rows: list[dict[str, str]] = []\n with csv_path.open(\"r\", encoding=\"utf-8-sig\", newline=\"\") as handle:\n for row in csv.DictReader(handle):\n if row.get(\"subject\") != subject or row.get(\"metric_key\") != metric_key:\n continue\n if category and row.get(\"category\") and row.get(\"category\") != category:\n continue\n if row.get(\"grain\") not in {\"quarter\", \"half_year\"}:\n continue\n if row.get(\"verification_status\") == \"source_gap_confirmed\":\n continue\n value = _parse_metric_number(row.get(\"official_value\") or row.get(\"value\"))\n if value is None:\n continue\n rows.append({**row, \"_numeric_value\": value})\n rows.sort(key=lambda row: _period_sort_key_for_forecast(row.get(\"period\") or \"\"))\n grain_counts = collections.Counter(row.get(\"grain\") or \"quarter\" for row in rows)\n grain = grain_counts.most_common(1)[0][0] if grain_counts else \"quarter\"\n rows = [row for row in rows if (row.get(\"grain\") or \"quarter\") == grain]\n sample_label = \"半年度\" if grain == \"half_year\" else \"季度\"\n season_length = 2 if grain == \"half_year\" else 4\n if len(rows) < 8:\n return f\"预测失败:{subject} {metric_key} 可用{sample_label}样本只有 {len(rows)} 个,少于 8 个,不适合做趋势预测。\"\n periods = [row[\"period\"] for row in rows]\n values = [float(row[\"_numeric_value\"]) for row in rows]\n forecasts, method, rmse = _hw_additive_forecast(values, horizon, season_length)\n backtest = rolling_backtest(values, season_length, _hw_additive_forecast)\n residual_proxy = rmse if rmse is not None else (statistics.pstdev(values[-8:]) if len(values) >= 8 else 0.0)\n if grain == \"half_year\":\n future_periods = [_next_half_year_label(periods[-1], step) for step in range(1, horizon + 1)]\n else:\n future_periods = [_next_quarter_label(periods[-1], step) for step in range(1, horizon + 1)]\n unit = rows[-1].get(\"official_unit\") or rows[-1].get(\"unit\") or \"\"\n metric_zh = rows[-1].get(\"metric_zh\") or metric_key\n table_lines = [\n \"| 预测期 | 预测值 | 低位区间 | 高位区间 |\",\n \"|---|---:|---:|---:|\",\n ]\n for period, forecast in zip(future_periods, forecasts):\n low = forecast - residual_proxy\n high = forecast + residual_proxy\n table_lines.append(f\"| {period} | {forecast:,.0f} | {low:,.0f} | {high:,.0f} |\")\n history_tail = [\n {\"period\": period, \"value\": value}\n for period, value in zip(periods[-12:], values[-12:])\n ]\n chart_spec = {\n \"type\": \"line\",\n \"title\": f\"{subject} {metric_zh} 历史与预测\",\n \"unit\": unit,\n \"x\": periods[-12:] + future_periods,\n \"series\": [\n {\"name\": \"历史\", \"data\": values[-12:] + [None] * horizon},\n {\"name\": \"预测\", \"data\": [None] * len(values[-12:]) + [round(item, 3) for item in forecasts]},\n ],\n }\n try:\n chart_result = render_chart(chart_spec)\n chart_md = f\"![{chart_spec['title']}]({chart_result['url']})\\n图表文件:{chart_result['path']}\"\n except Exception as exc:\n chart_md = f\"图表生成失败:{_clean_search_text(exc, 160)}\"\n source = csv_path.relative_to(ROOT).as_posix()\n payload = {\n \"type\": \"meta\",\n \"sources\": [source],\n \"links\": [{\"label\": source, \"url\": f\"/references/{source}\"}],\n \"references\": [{\"index\": 1, \"source\": source, \"links\": [{\"label\": source, \"url\": f\"/references/{source}\"}]}],\n \"forecastAudit\": {\n \"subject\": subject,\n \"metric_key\": metric_key,\n \"horizon\": horizon,\n \"sample_count\": len(values),\n \"grain\": grain,\n \"model\": method,\n \"rmse\": rmse,\n \"backtest\": backtest,\n },\n }\n if backtest.get(\"ok\"):\n scores = backtest.get(\"scores\") or {}\n baseline_text = (\n f\"- 回测窗口:{backtest.get('windows')} 个;\"\n f\"Holt-Winters RMSE={scores.get('holt_winters', {}).get('rmse', 0):,.3f},\"\n f\"naive RMSE={scores.get('naive', {}).get('rmse', 0):,.3f},\"\n f\"seasonal naive RMSE={scores.get('seasonal_naive', {}).get('rmse', 0):,.3f};\"\n f\"RMSE 最优:{backtest.get('best_baseline')}。\\n\"\n )\n else:\n baseline_text = f\"- 回测:{backtest.get('reason') or '未执行'}\\n\"\n return (\n f\"趋势预测结果:{subject} {metric_zh}({metric_key})\\n\"\n f\"- 数据来源:{source}\\n\"\n f\"- 历史样本:{len(values)} 个{sample_label},{periods[0]} 至 {periods[-1]}\\n\"\n f\"- 数值口径:优先 official_value;official_conflict 采用官方值;source_gap 不参与拟合。\\n\"\n f\"- 模型:{method}\\n\"\n f\"- 回测误差代理 RMSE:{rmse:,.3f}\\n\"\n f\"{baseline_text}\" if rmse is not None else\n f\"趋势预测结果:{subject} {metric_zh}({metric_key})\\n\"\n f\"- 数据来源:{source}\\n\"\n f\"- 历史样本:{len(values)} 个{sample_label},{periods[0]} 至 {periods[-1]}\\n\"\n f\"- 数值口径:优先 official_value;official_conflict 采用官方值;source_gap 不参与拟合。\\n\"\n f\"- 模型:{method}\\n\"\n f\"{baseline_text}\"\n ) + (\n \"\\n\".join(table_lines)\n + \"\\n\\n\"\n + chart_md\n + \"\\n\\n\"\n + \"重要说明:这是基于历史序列的机械趋势预测,不是投资建议;未纳入管理层指引、宏观变量、政策、竞争和一次性项目影响。\\n\"\n + f\"最近历史样本:{json.dumps(history_tail, ensure_ascii=False)}\"\n + f\"\\n{json.dumps(payload, ensure_ascii=False)}\"\n )\n\n@tool\ndef get_system_status() -> str:\n \"\"\"获取系统运行状态和爬虫统计数据(包括最近爬取的成功数、失败数等)。\n 当你需要了解系统的健康状况或数据收集的全景概览时,使用此工具。\n \"\"\"\n import web_app\n try:\n status = web_app.build_status()\n return f\"系统状态快照:\\n{json.dumps(status, ensure_ascii=False, indent=2)}\"\n except Exception as e:\n return f\"获取系统状态失败: {str(e)}\"\n\n\n@tool\ndef search_agent_memory(query: str, limit: int = 5) -> str:\n \"\"\"搜索小竞AI本地长期运行记忆。\n 当用户询问偏好、之前确认过的生产规则、长期约束、上下文管理策略或历史工作流时使用。\n 记忆只作为辅助上下文;正式数据结论仍必须以当前选择数据库、官方来源和审计文件为准。\n \"\"\"\n rows = search_memories(query, limit=limit)\n if not rows:\n return \"未找到相关长期记忆。\"\n lines = [\"小竞AI长期记忆命中:\"]\n for index, item in enumerate(rows, 1):\n lines.append(\n f\"[记忆 {index}] {item.get('content')}\\n\"\n f\"- kind: {item.get('kind')}\\n\"\n f\"- tags: {'、'.join(item.get('tags') or []) or '无'}\\n\"\n f\"- date: {item.get('created_date')}\"\n )\n return \"\\n\\n\".join(lines)\n\n\n@tool\ndef remember_agent_memory(content: str, kind: str = \"semantic\", tags: str = \"\") -> str:\n \"\"\"写入一条小竞AI长期运行记忆。\n 只在用户明确要求“记住/以后都/默认/规则/偏好”,或你确认这是跨会话可复用的生产规则时使用。\n 不要写入 API key、个人隐私、未验证数据值或完整聊天历史。\n \"\"\"\n tag_list = [item.strip() for item in re.split(r\"[,,;;\\s]+\", tags or \"\") if item.strip()]\n try:\n item = add_memory(content, kind=kind or \"semantic\", tags=tag_list, source=\"agent-tool\")\n except Exception as exc:\n return f\"写入长期记忆失败:{exc}\"\n return f\"已写入长期记忆:{item['id']},date={item['created_date']}。\"\n\n\n@tool\ndef list_agent_memory(limit: int = 10) -> str:\n \"\"\"列出最近的小竞AI长期运行记忆,用于审计 agent 记住了什么。\"\"\"\n rows = load_memories(limit=max(1, min(int(limit or 10), 30)))\n if not rows:\n return \"当前没有长期记忆。\"\n return \"\\n\\n\".join(\n f\"[记忆 {index}] {item.get('content')}\\n- kind: {item.get('kind')}\\n- tags: {'、'.join(item.get('tags') or []) or '无'}\\n- date: {item.get('created_date')}\"\n for index, item in enumerate(rows, 1)\n )\n\n\n@tool\ndef list_database_lineage() -> str:\n \"\"\"列出本轮已选择数据库的版本、血缘、manifest、文件数量和指纹。\n 当用户问数据库来源、版本、是否过期、数据血缘、可审计性,或需要正式说明当前答案基于哪个数据包时使用。\n \"\"\"\n rows = dataset_lineage(_selected_dataset_ids())\n if not rows:\n return \"当前未选择任何可见数据库,无法生成数据库血缘。\"\n lines = [\"本轮已选择数据库血缘:\"]\n for index, row in enumerate(rows, 1):\n lines.append(\n \"\\n\".join(\n [\n f\"[数据库 {index}] {row.get('title') or row.get('id')}\",\n f\"- id: {row.get('id')}\",\n f\"- version: {row.get('version') or '未声明'}\",\n f\"- built_at: {row.get('built_at') or row.get('updated_at') or '未声明'}\",\n f\"- row_count: {row.get('row_count') or '未声明'}\",\n f\"- verified_count: {row.get('verified_count') or '未声明'}\",\n f\"- gap_count: {row.get('gap_count') or '未声明'}\",\n f\"- manifest: {row.get('manifest_path') or '无'}\",\n f\"- files: {row.get('file_count')} 个,fingerprint={row.get('fingerprint')}\",\n f\"- quality: {row.get('quality') or '未说明'}\",\n ]\n )\n )\n return \"\\n\\n\".join(lines)\n\ndef _thinking_model_name(model_name: str) -> str:\n configured = (os.environ.get(\"AI_THINKING_MODEL\") or os.environ.get(\"DEEPSEEK_THINKING_MODEL\") or \"\").strip()\n if configured:\n return configured\n if \"deepseek\" in model_name.lower():\n return \"deepseek-reasoner\"\n return model_name\n\n\ndef _agent_model_name(thinking_enabled: bool = False) -> str:\n config = load_ai_config()\n model_name = config.get(\"model\", \"deepseek-chat\")\n if thinking_enabled:\n return _thinking_model_name(str(model_name))\n if \"reasoner\" in str(model_name):\n return \"deepseek-chat\"\n return str(model_name)\n\n\ndef _agent_tools(allow_web_search: bool = True):\n tools = [\n read_agent_skill,\n list_local_datasets,\n list_crawl_runs,\n search_local_reports,\n read_local_reference,\n trigger_crawl,\n trigger_full_crawl,\n feishu_cli,\n trigger_report_generation,\n trigger_carrier_performance_report_generation,\n list_report_outputs,\n get_crawl_settings_summary,\n render_python_chart,\n forecast_quarterly_metric,\n get_system_status,\n search_agent_memory,\n remember_agent_memory,\n list_agent_memory,\n list_database_lineage,\n ]\n if allow_web_search:\n insert_at = 4\n tools[insert_at:insert_at] = [web_search, read_webpage]\n return tools\n\n\ndef get_agent(thinking_enabled: bool = False, allow_web_search: bool = True, runtime_context: dict[str, Any] | None = None):\n config = load_ai_config()\n api_key = config.get(\"api_key\", \"\") or os.environ.get(\"OPENAI_API_KEY\", \"\")\n base_url = config.get(\"base_url\", \"\") or os.environ.get(\"OPENAI_API_BASE\", \"\")\n model_name = _agent_model_name(thinking_enabled=thinking_enabled)\n\n from network_utils import _available_proxy_urls\n proxies = _available_proxy_urls()\n if proxies and not os.environ.get(\"HTTP_PROXY\"):\n os.environ[\"HTTP_PROXY\"] = proxies[0]\n os.environ[\"HTTPS_PROXY\"] = proxies[0]\n \n llm = ChatDeepSeek(\n model=model_name,\n api_key=api_key,\n api_base=base_url,\n max_retries=3,\n )\n \n tools = _agent_tools(allow_web_search=allow_web_search)\n\n if allow_web_search:\n source_rule = \"【强制规则 - 来源引用】当你的回答参考了 `search_local_reports`、`web_search` 或其他工具返回的上下文时,必须在文中用 [1], [2] 等格式进行内联标号(对应 [来源 1], [来源 2] 的编号)。本地检索通常使用 [1]-[5],联网搜索通常从 [6] 开始编号;必须沿用工具结果中的实际编号。**请将标号紧跟在每一条具体的数据或事实后面**,绝对不要把一堆标号集中放在大标题上或段落末尾。**禁止在回答末尾自行输出任何 <引用来源>、 等参考文献列表**,系统会自动展示。\\n\"\n retrieval_pairing_rule = \"【检索工具搭配】对公开信息、竞对动态、收入/财报、政策、行业趋势、最新进展等问题,默认尽量同时调用 `search_local_reports` 和 `web_search`:先用 `search_local_reports` 获取本地监测和周报上下文,再用 `web_search` 获取联网公开来源;除非用户明确只要本地或只要联网,不要只调用其中一个。\\n\"\n cross_check_rule = \"【本地与联网交叉校验】当 `search_local_reports` 与 `web_search` 返回的数据在日期、口径、金额、比例、主体名称或结论上存在出入时,必须在回答中单独说明差异:分别列出本地资料口径、联网公开来源口径、可能原因和建议采用的可信口径。禁止把冲突数据混合成一个确定结论。\\n\"\n local_original_rule = \"【本地原文优先】`search_local_reports` 只是本地检索摘要;只要问题涉及数据、财报、收入、对比、结论判断或口径核对,并且你已经调用了 `search_local_reports`,就必须至少对一个最相关的本地来源调用 `read_local_reference`(例如 `weekly_report.md` 或 `row_2.json`)查看原文后再回答。如果本地检索结果没有相关来源,才说明本地原文不足。不要在没有读本地引用原文的情况下连续打开外部网页。\\n\"\n quarterly_crosscheck_sentence = \"`needs_official_row_crosscheck` 只能作为线索,正式结论必须继续联网或读取官方来源核验。\"\n web_rule = \"【联网搜索】当用户明确要求上网、联网搜索、查最新公开信息,或本地资料不足以回答时,必须调用 `web_search`。搜索后仍需用 [1], [2] 标注具体事实来源;只有用户要求打开网页全文或搜索摘要不足以核实时,才对最多 2 个关键结果调用 `read_webpage`。若 `read_webpage` 返回失败、跳过、浏览器验证或 PDF 抽取失败,不要反复读取同一类链接,应改用搜索摘要、本地引用和其他公开来源交叉验证。\\n\"\n else:\n source_rule = \"【强制规则 - 来源引用】当你的回答参考了 `search_local_reports`、`read_local_reference` 或其他本地工具返回的上下文时,必须在文中用 [1], [2] 等格式进行内联标号,并沿用工具结果中的实际编号。**请将标号紧跟在每一条具体的数据或事实后面**,绝对不要把一堆标号集中放在大标题上或段落末尾。**禁止在回答末尾自行输出任何 <引用来源>、 等参考文献列表**,系统会自动展示。\\n\"\n retrieval_pairing_rule = \"【本地检索优先】优先调用 `search_local_reports`、`list_local_datasets`、`read_local_reference` 等当前可用工具回答,不得声称使用了未实际调用的工具,不得编造检索结果。用户要求搜索、查最新或公开资料时,也直接按本地可用数据检索和回答;资料不足时,只说明缺少的具体本地依据,并给出可以继续核验的本地数据路径或来源。不要解释当前工具开关、联网能力或前端配置状态。\\n\"\n cross_check_rule = \"【本地核验优先】基于本地标准化数据集、周报、爬虫结果、审计日志和已保存来源进行回答;资料不足时只说明缺少哪些数据或依据。\\n\"\n local_original_rule = \"【本地原文优先】`search_local_reports` 只是本地检索摘要;只要问题涉及数据、财报、收入、对比、结论判断或口径核对,并且你已经调用了 `search_local_reports`,就必须至少对一个最相关的本地来源调用 `read_local_reference`(例如 `weekly_report.md` 或 `row_2.json`)查看原文后再回答。如果本地检索结果没有相关来源,才说明本地原文不足。\\n\"\n quarterly_crosscheck_sentence = \"`needs_official_row_crosscheck` 只能作为线索,正式结论必须读取本地已保存官方来源或本地引用原文核验。\"\n web_rule = \"【回答方式】只输出答案本身,不额外解释当前能力配置、联网能力或前端设置,也不要引导用户调整前端设置;本地依据不足时,只说缺少哪些本地数据或引用。\\n\"\n \n runtime_context = runtime_context or {}\n runtime_lines = [\n f\"- 当前时间: {runtime_context.get('current_time') or 'unknown'}\",\n f\"- 时区: {runtime_context.get('timezone') or 'unknown'} ({runtime_context.get('utc_offset') or ''})\",\n f\"- 请求 IP: {runtime_context.get('visible_ip') or runtime_context.get('client_ip') or 'unknown'}\",\n f\"- X-Forwarded-For: {runtime_context.get('forwarded_for') or 'none'}\",\n f\"- 位置推断: {runtime_context.get('location_hint') or 'unknown'}\",\n ]\n runtime_context_text = \"\\n\".join(runtime_lines)\n\n system_message = (\n \"你是中国移动战略部公开信息监测系统的智能 RAG 和运维助手。\\n\"\n \"【当前运行上下文】以下信息由后端按本次请求实时注入。凡用户提到“今天、现在、目前、最新、上个季度、上一季度、最近”等相对时间,必须优先用这里的当前时间和时区定位;涉及地域、网络可达性或本地/公网判断时,可参考请求 IP 和位置推断,但不要把粗略位置当作精确地理定位。\\n\"\n f\"{runtime_context_text}\\n\"\n \"调用 `feishu_cli` 时必须使用完整参数 `--spreadsheet-token ZrzWsMF4Dhq5zDtXZZ4cpHcKnfA`,不要使用 `-t` 简写或位置参数。例如查询数据使用:`sheets +read --spreadsheet-token ZrzWsMF4Dhq5zDtXZZ4cpHcKnfA --range 9c638d!A1:C10`。\\n\"\n \"【核心法则】如果你不确定某个 CLI 命令(特别是 `+update`、`+write` 等子命令)的具体用法和参数格式,**绝对不允许瞎猜尝试**!你必须先执行 `feishu_cli sheets +update --help` 等命令查阅帮助文档,然后再进行真正的调用。\\n\"\n \"【重要】核心数据表的工作表名称是\\\"主表\\\",其对应的 sheet_id 为 9c638d。当查询或修改\\\"主表\\\"时,务必使用 9c638d 作为 range 前缀,例如 `--range 9c638d!A2:Z2`。\\n\"\n \"【极度重要】表格的列结构可能随时发生变化(用户会动态插入或删除列)。如果你需要读取或写入特定字段的数据,**严禁直接凭记忆写死列号**(例如想当然地认为H列必定是更新频率)。你必须先使用 lark-cli 读取第一行表头(例如 `--range 9c638d!A1:Z1`),精确查明各个字段当前实际所在的字母列后,再执行后续的行列操作!\\n\"\n f\"{source_rule}\"\n \"【本地数据边界】你能接触到的本地数据不是无限的,必须以工具返回为准:`list_local_datasets` 用于列出标准化数据集,`search_local_reports` 用于检索标准化数据集、周报、审计日志和爬取结果,`read_local_reference` 用于读取可点击引用原文。用户问“你能访问哪些数据”“数据放哪里”“内部/外部数据怎么接入”“后端有哪些数据”时,必须先调用 `list_local_datasets` 再回答。做趋势分析、问数、财报对比、口径核验、图表之前,如果不确定可用数据,也要先调用 `list_local_datasets`。\\n\"\n \"【上下文预算审计】`search_local_reports` 会返回 contextAudit,包含 token_budget、token_estimate、retained_chunks、compressed_chunks、skipped_chunks。若 compressed_chunks 或 skipped_chunks 大于 0,正式回答必须把结论限定在已保留上下文内,并在必要时说明仍需读取原文或缩小问题范围。不要声称已完整读取未进入上下文预算的全部文件。\\n\"\n \"【长期记忆边界】`search_agent_memory`、`remember_agent_memory`、`list_agent_memory` 只用于小竞AI运行偏好、长期规则和已验证流程。长期记忆不能替代本轮用户指令、前端数据库选择、官方来源、审计文件或工具返回结果;若记忆与本轮上下文冲突,必须以本轮上下文为准。\\n\"\n \"【Agent Skill 渐进加载】`load_agent_skills` 只表示前端选择了哪些 Skill,不等于已阅读完整指令。若本轮用户问题适合某个已选 Skill,你必须先调用 `read_agent_skill(skill_id)` 读取完整 `SKILL.md`,再按 Skill 指令执行。读取 Skill 后如其中要求来源核验、数据检索、图表或预测,要继续调用对应工具;不要只停留在 Skill 标题或简介。\\n\"\n \"【新增数据规范】后续新增内部或外部数据时,默认放入项目根目录 `agent_knowledge//`。每个数据集至少提供 `manifest.json`,建议同时提供 `README.md`、结构化 `data.csv` 或 `data.json`、摘要 `summary.md`、来源 `sources.json`。允许被后端索引和引用的文本文件扩展名只有 `.md`、`.txt`、`.json`、`.csv`、`.tsv`。`manifest.json` 应写明 id、title、summary、source_type、scope、tags、keywords、entrypoints、updated_at、quality。除非工具列出,否则不要声称自己能读取其他本地目录。\\n\"\n \"【数据库选择边界】前端数据库按钮是强访问边界。只有本轮用户已选择的 `agent_knowledge` 数据库才允许被 `list_local_datasets`、`search_local_reports` 和 `read_local_reference` 读取或引用;未选择的数据集不可见。不得根据历史提示、路径记忆或未调用工具的信息声称知道未选择数据库的内容。若季度、核心公司或云厂商数据集被选择,按工具返回的 manifest、CSV/JSON 行和引用原文回答;`verification_count>=2` 表示该行已完成多来源核验,`official_conflict` 表示正式回答采用 `official_value` 并说明标准化值与官方披露冲突。\\n\"\n \"【目标级审计优先】当用户询问“现在数据是否完整/准确/能否预测/数据库是否正常/来源是否可靠/目标完成到哪一步”或要求给出正式数据质量结论时,如果已选择 `goal_readiness_audits`、`knowledge_integrity_audits`、`source_evidence_audits`、`source_url_reachability_audits`、`forecast_readiness_audits` 或 `agent_dataset_visibility_audits`,必须先检索并读取相关审计。正式回答要以这些审计中的 pass/fail、row_count、verification_count、official_value/source_gap 和 API 可见性结果为准;不要只凭主数据包或历史记忆判断完成度。被 manifest 标记为 `superseded`、`hidden` 或 `archived` 的数据包不得作为默认数据库或正式结论来源。\\n\"\n \"【宏观政策数据包】若用户询问 CMHK 预测、趋势判断、香港电信市场、5G、频谱、移动用户、宽带渗透率、SIM 实名、监管政策或宏观环境,并且已选择 `cmhk_macro_policy_*` 数据库,必须检索并读取该数据包。宏观政策包用于解释市场背景、外生变量和政策事件;年度/事件粒度记录不得替代公司季度数据,也不得把政策事件直接当作收入/利润预测目标。正式结论仍使用 `official_value`,`source_gap_confirmed` 不得估算。\\n\"\n \"【精确指标边界】问数时必须严格保持用户要的指标,不得把派生指标改写成基础指标。尤其是:`收入同比`、`营收同比`、`营业收入同比`、`收入增长`、`revenue_growth_yoy`、`YoY` 对应 `metric_key=revenue_growth_yoy`,不是 `revenue`;`EBITDA率` 对应 `metric_key=ebitda_margin`,不是 `ebitda`;`经营利润率` 对应 `operating_margin`,不是 `operating_income`;`毛利率` 对应 `gross_margin`,不是 `gross_profit`。调用 `search_local_reports` 时必须把这些关键词原样带入 query,最终回答也必须引用命中的同一指标行。\\n\"\n \"【表格输出限制】除非用户明确要求导出完整 CSV/Excel,否则最终回答中的 Markdown 表格最多输出 30 行、8 列;超过范围时先给摘要和关键行,并提示用户缩小范围或导出文件。Markdown 表格必须一次性输出完整表头、分隔行和完整数据行,每一行都以 `|` 结尾;单个指标优先用项目符号,不要为了一个数值强行输出表格。后端会对超限表格做截断,不要把大表格分批塞进同一条回答。\\n\"\n \"【爬虫日志调度】每次全量爬虫完成后,系统会登记 `agent_knowledge/crawl_run_logs/`:本地只保存运行索引和摘要,完整逐 URL 日志与 Agent 处理流程写入飞书日志子表。用户问上次爬虫、失败链接、覆盖率、日志在哪、Agent 是否处理完成时,必须先调用 `list_crawl_runs`;需要更细节再用 `search_local_reports` 或 `read_local_reference` 读取 `run_log.tsv`、`coverage_report.tsv`、`final_audit.md`。\\n\"\n \"【操作工具决策权】用户要求生成周报、生成运营商业绩摘要、全量爬取、查看输出文件、查看爬取设置或查看系统状态时,由你根据用户真实意图自行决定是否调用 `trigger_report_generation`、`trigger_carrier_performance_report_generation`、`trigger_full_crawl`、`list_report_outputs`、`get_crawl_settings_summary`、`get_system_status` 等工具。不要把“输出预测表”“生成预测图”“正式结论”等分析表达误判成报告生成;只有用户确实要产出 Word 周报或业绩摘要时才调用生成类工具。\\n\"\n \"【前端展示边界】前端会按事件顺序展示工具调用卡片;工具调用行会自动显示“读取 Skill / 检索数据库 / 联网搜索 / 读取原文”等过程说明。最终正文严禁重复检索过程、工具状态或准备动作,例如“联网已搜到”“本地检索结果只返回”“本地数据已命中”“CSV文件很大”“我继续检索”“我再读取”“数据充足”等。最终正文只输出结论、数据、依据、口径差异和必要不确定性。\\n\"\n f\"{retrieval_pairing_rule}\"\n f\"{local_original_rule}\"\n f\"{cross_check_rule}\"\n f\"{quarterly_crosscheck_sentence}\"\n \"【Python 图表工具】当用户要求画图、趋势图、柱状图、表格+图表或可视化时,在完成数据检索和核验后必须优先调用 `render_python_chart` 生成 PNG 图表。`chart_spec` 必须是 JSON 对象字符串,结构为 `{\\\"type\\\":\\\"line|bar\\\",\\\"title\\\":\\\"...\\\",\\\"unit\\\":\\\"...\\\",\\\"x\\\":[\\\"2024Q1\\\"],\\\"series\\\":[{\\\"name\\\":\\\"收入\\\",\\\"data\\\":[123]}]}`;series 使用 `data` 数组,不要使用其他字段名。不要把 `` JSON 当作主要输出,也不要在正文里手写 `` 块。图表 JSON 的中文标题、图例和备注必须直接写中文,工具会处理中文字体渲染。注意:光画图就行,图片中不要放底部的解释性长段文字(例如详细结论或分析),以防遮挡图例或影响美观。若 `render_python_chart` 返回“停止”或同一参数失败过,必须立即停止图表工具调用,改用文字结论或简表回答。\\n\"\n \"【趋势预测工具】当用户要求“预测未来”“趋势预测”“forecast”“未来4个季度”“未来几个季度/半年”等,并且问题涉及 quarterly_competitor_metrics 数据库中的季度指标时,必须优先调用 `forecast_quarterly_metric`,不要只靠模型心算或简单均值口算。预测工具使用已选数据库、官方优先数值和 Holt-Winters 加性季节模型,并且已经返回预测表和 PNG 图表;除非用户明确要求第二张不同图,否则不要再调用 `render_python_chart` 重复画同一预测图。如果用户同时选择宏观政策包,可用其解释外部背景和风险,但不能把年度/事件粒度宏观政策行混入季度模型拟合。如果用户要求解释,再说明模型局限和非投资建议。\\n\"\n f\"{web_rule}\"\n \"【强制规则 - 无图标/Emoji】所有的文字输出中绝对禁止使用任何 Emoji、表情符号或特殊排版图标。保持极其严肃、专业的纯文本风格。\\n\"\n \"【重要】如果你连续 3 次调用某个工具均未能成功(比如参数错误、表名不对或输出过多),请立即停止调用,并直接回复用户当前遇到的困难,不要陷入无限重试的死循环。\\n\\n\"\n \"【强制规则 - 推荐追问】你的每一条最终回复(无论长短、无论是否调用了工具)的最后一行都必须包含推荐追问。格式如下,不可省略:\\n\"\n \"[\\\"追问1\\\", \\\"追问2\\\", \\\"追问3\\\"]\\n\"\n \"追问内容应与当前话题紧密相关、对用户有实际价值;禁止把打开、关闭或调整前端开关、按钮、工具配置作为追问。这是一条不可违反的系统指令,任何回答如果缺少 标签都是不合格的。\\n\"\n )\n \n return create_react_agent(llm, tools, prompt=system_message)\n\n\ndef _selected_skill_summaries(skill_ids: list[str] | None) -> list[dict[str, str]]:\n requested = [re.sub(r\"[^A-Za-z0-9_.-]\", \"\", str(item or \"\")) for item in (skill_ids or [])]\n requested = [item for item in requested if item]\n if not requested:\n return []\n by_id = {item[\"id\"]: item for item in available_agent_skills()}\n rows = []\n for skill_id in requested[:5]:\n item = by_id.get(skill_id)\n if not item:\n continue\n rows.append({\n \"id\": skill_id,\n \"title\": str(item.get(\"title\") or skill_id),\n \"summary\": str(item.get(\"description\") or item.get(\"summary\") or \"\"),\n })\n return rows\n\n\ndef _selected_dataset_summaries(dataset_ids: set[str]) -> list[dict[str, str]]:\n if not dataset_ids:\n return []\n rows = []\n for item in list_knowledge_datasets(dataset_ids=dataset_ids):\n rows.append({\n \"id\": str(item.get(\"id\") or \"\"),\n \"title\": str(item.get(\"title\") or item.get(\"id\") or \"\"),\n \"folder\": str(item.get(\"folder\") or \"\"),\n \"summary\": str(item.get(\"summary\") or item.get(\"scope\") or \"\"),\n })\n return rows\n\n\ndef _context_tool_content(title: str, rows: list[dict[str, str]], id_key: str = \"id\") -> str:\n if not rows:\n return f\"{title}:无。\"\n lines = [f\"{title}:{len(rows)} 项\"]\n for index, row in enumerate(rows, 1):\n name = row.get(\"title\") or row.get(id_key) or f\"项目 {index}\"\n row_id = row.get(id_key) or \"\"\n summary = row.get(\"summary\") or row.get(\"folder\") or \"\"\n suffix = f\"({row_id})\" if row_id and row_id != name else \"\"\n lines.append(f\"{index}. {name}{suffix}\" + (f\"\\n {summary}\" if summary else \"\"))\n return \"\\n\".join(lines)\n\n\ndef _stream_agent_events(agent: Any, inputs: dict[str, Any]):\n try:\n return agent.stream(inputs, stream_mode=\"messages\", config={\"recursion_limit\": 50})\n except TypeError:\n return agent.stream(inputs, stream_mode=\"messages\")\n\n\ndef _format_conversation_history(history: list[dict[str, Any]] | None) -> str:\n if not history:\n return \"\"\n lines: list[str] = []\n for item in history[-8:]:\n role = \"AI\" if str(item.get(\"role\") or \"\").lower() == \"assistant\" else \"用户\"\n content = _clean_search_text(item.get(\"content\") or \"\", 1800)\n if content:\n lines.append(f\"{role}: {content}\")\n if not lines:\n return \"\"\n return \"同一聊天线程的最近对话如下,用于理解代词、继续追问和上一轮结论;若与本轮用户新指令冲突,以本轮新指令为准。\\n\" + \"\\n\".join(lines)\n\n\ndef _tool_process_text(tool_name: str) -> str:\n labels = {\n \"load_agent_skills\": \"我读取本轮需要的 Agent Skill 选择。\",\n \"read_agent_skill\": \"我读取相关 Agent Skill 的完整指令。\",\n \"select_agent_databases\": \"我确认本轮会发送给 AI 的数据库。\",\n \"list_local_datasets\": \"我读取本轮已选数据库列表。\",\n \"search_agent_memory\": \"我查找长期记忆中是否有相关规则。\",\n \"search_local_reports\": \"我读取已选数据库并检索摘要片段。\",\n \"read_local_reference\": \"我读取命中的数据库原文,确认数据口径和来源。\",\n \"web_search\": \"我联网检索公开来源。\",\n \"read_webpage\": \"我读取网页原文核验细节。\",\n \"render_python_chart\": \"我基于已核验数据生成图表。\",\n \"forecast_quarterly_metric\": \"我调用趋势预测工具,使用历史数据生成预测。\",\n \"get_system_status\": \"我读取系统当前状态。\",\n \"list_report_outputs\": \"我查看已有报告输出。\",\n \"list_crawl_runs\": \"我读取爬虫运行日志。\",\n }\n return labels.get(tool_name, f\"我调用 {tool_name or '工具'} 获取依据。\")\n\n\ndef stream_agent(\n message: str,\n force_web_search: bool = False,\n selected_skill_ids: list[str] | None = None,\n selected_dataset_ids: list[str] | None = None,\n thinking_enabled: bool = False,\n approved_action_ids: list[str] | None = None,\n conversation_history: list[dict[str, Any]] | None = None,\n emit_context_events: bool = True,\n loaded_skill_ids: list[str] | None = None,\n runtime_context: dict[str, Any] | None = None,\n) -> Generator[dict[str, Any], None, None]:\n _reset_web_search_indexes()\n try:\n captured_memory = auto_capture_user_memory(message)\n except Exception:\n captured_memory = None\n recalled_memory = memory_context(message, limit=5)\n selected_dataset_set = {\n re.sub(r\"[^A-Za-z0-9_.\\-\\u4e00-\\u9fff]\", \"\", str(item or \"\"))\n for item in (selected_dataset_ids or [])\n if str(item or \"\").strip()\n }\n selected_skill_set = {\n re.sub(r\"[^A-Za-z0-9_.-]\", \"\", str(item or \"\"))\n for item in (selected_skill_ids or [])\n if str(item or \"\").strip()\n }\n loaded_skill_set = {\n re.sub(r\"[^A-Za-z0-9_.-]\", \"\", str(item or \"\"))\n for item in (loaded_skill_ids or [])\n if str(item or \"\").strip()\n }\n dataset_token = SELECTED_DATASET_IDS.set(selected_dataset_set)\n skill_token = SELECTED_SKILL_IDS.set(selected_skill_set)\n approved_token = APPROVED_ACTION_IDS.set({str(item) for item in (approved_action_ids or []) if str(item).strip()})\n chart_token = CHART_RUN_STATE.set({\"calls\": 0, \"failures\": 0, \"failed_signatures\": {}})\n tool_token = TOOL_RUN_STATE.set({\"counts\": {}})\n recorder = AgentRunRecorder(\n message=message,\n selected_dataset_ids=sorted(selected_dataset_set),\n selected_skill_ids=[str(item) for item in (selected_skill_ids or [])],\n web_search_enabled=force_web_search,\n thinking_enabled=thinking_enabled,\n approved_action_ids=approved_action_ids or [],\n )\n try:\n agent = get_agent(thinking_enabled=thinking_enabled, allow_web_search=force_web_search, runtime_context=runtime_context)\n except TypeError as exc:\n if \"runtime_context\" not in str(exc):\n raise\n agent = get_agent(thinking_enabled=thinking_enabled, allow_web_search=force_web_search)\n thinking_step = 0\n\n def thinking_event(text: str) -> dict[str, Any]:\n nonlocal thinking_step\n thinking_step += 1\n return {\"type\": \"thinking_status\", \"text\": f\"{thinking_step}. {text}\"}\n\n if captured_memory:\n event = {\"type\": \"process\", \"step\": \"长期记忆\", \"text\": f\"已记录一条运行记忆:{captured_memory.get('id')}。\"}\n recorder.observe(event)\n yield event\n if recalled_memory:\n event = {\"type\": \"process\", \"step\": \"长期记忆\", \"text\": \"已召回本地长期运行记忆作为辅助上下文。\"}\n recorder.observe(event)\n yield event\n\n skill_context = _selected_skill_context(selected_skill_ids)\n skill_summaries = _selected_skill_summaries(selected_skill_ids)\n dataset_summaries = _selected_dataset_summaries(selected_dataset_set)\n if emit_context_events and skill_summaries:\n event = {\"type\": \"tool_call_start\", \"id\": \"context-agent-skills\", \"name\": \"load_agent_skills\"}\n recorder.observe(event)\n yield event\n event = {\n \"type\": \"tool_call_result\",\n \"id\": \"context-agent-skills\",\n \"name\": \"load_agent_skills\",\n \"args\": json.dumps({\"skill_ids\": [row[\"id\"] for row in skill_summaries]}, ensure_ascii=False),\n \"content\": _context_tool_content(\"本轮载入 Agent Skill\", skill_summaries),\n }\n recorder.observe(event)\n yield event\n if emit_context_events and dataset_summaries:\n event = {\"type\": \"tool_call_start\", \"id\": \"context-agent-databases\", \"name\": \"select_agent_databases\"}\n recorder.observe(event)\n yield event\n event = {\n \"type\": \"tool_call_result\",\n \"id\": \"context-agent-databases\",\n \"name\": \"select_agent_databases\",\n \"args\": json.dumps({\"dataset_ids\": sorted(selected_dataset_set)}, ensure_ascii=False),\n \"content\": _context_tool_content(\"本轮发送给 AI 的数据库\", dataset_summaries),\n }\n recorder.observe(event)\n yield event\n if recalled_memory:\n message = f\"{recalled_memory}\\n\\n用户问题:{message}\"\n history_context = _format_conversation_history(conversation_history)\n if history_context:\n message = f\"{history_context}\\n\\n本轮用户问题:{message}\"\n if skill_context:\n loaded_skill_note = \"\"\n loaded_selected = sorted(selected_skill_set & loaded_skill_set)\n if loaded_selected:\n loaded_skill_note = (\n \"本聊天线程此前已经读取过以下 Skill 的完整 SKILL.md:\"\n f\"{', '.join(loaded_selected)}。\"\n \"若本轮只是延续同一问题且前文规则足够,不要重复调用 `read_agent_skill`;\"\n \"只有任务切换、规则不确定或需要确认具体步骤时才再次读取。\\n\"\n )\n message = (\n \"用户已在前端手动选择以下 Agent Skills。下面只是 Skill 发现信息,不是完整指令。\"\n \"当你判断某个未读过的 Skill 与本轮任务相关时,必须先调用 `read_agent_skill(skill_id)` 读取完整 SKILL.md;\"\n \"如果该 Skill 已在本聊天线程前序回合读取过,且本轮只是延续同一问题,可以直接沿用前文规则,不要重复读取。\"\n \"随后按该 Skill 的规则继续调用本地检索、读取原文、联网搜索、图表或预测工具。\"\n \"`load_agent_skills` 只表示前端选择,不等于已经阅读完整 Skill。\\n\\n\"\n f\"{loaded_skill_note}\"\n f\"{skill_context}\\n\\n\"\n f\"用户问题:{message}\"\n )\n if selected_dataset_set:\n message = (\n \"用户已在前端数据库按钮中选择以下本地数据库。本轮只能把这些数据库发送给 AI;\"\n \"未列出的 agent_knowledge 数据库视为不可见,不能读取、引用或声称知道。\\n\"\n f\"已选择数据库 id:{', '.join(sorted(selected_dataset_set))}\\n\\n\"\n f\"用户问题:{message}\"\n )\n else:\n message = (\n \"用户本轮没有在前端数据库按钮中选择任何本地数据库。\"\n \"后端不会发送任何前端数据库内容;不得列举、猜测、引用或声称知道未选择数据库的名称、路径或内容。\"\n \"如需数据库内容,只能提示用户先在前端数据库按钮中选择。\"\n \"仍可使用周报、审计日志、运行日志等基础本地引用和其他被允许工具。\\n\\n\"\n f\"用户问题:{message}\"\n )\n if force_web_search:\n message = (\n \"用户已在聊天框打开联网搜索开关。你必须调用 `web_search` 获取公开网页来源;\"\n \"同时尽量调用 `search_local_reports` 做本地资料交叉验证。\"\n \"如果本地和联网数据有出入,要明确说明差异和建议采用口径。\\n\\n\"\n f\"用户问题:{message}\"\n )\n if thinking_enabled:\n message = (\n \"用户已打开深度思考开关。请先做更严格的问题拆解、工具选择、来源核验和冲突检查,\"\n \"但不要输出内部推理过程;只输出可审计的结论、依据、必要步骤和不确定性。\\n\\n\"\n f\"用户问题:{message}\"\n )\n inputs = {\"messages\": [(\"user\", message)]}\n \n tool_calls_acc = {}\n emitted_process_tools: set[str] = set()\n disabled_web_notice_prefixes = (\n \"联网搜索已关闭\",\n \"当前联网搜索已关闭\",\n \"已关闭联网搜索\",\n \"由于联网搜索\",\n \"因为联网搜索\",\n \"本轮不会调用\",\n \"我不能联网\",\n \"当前不能联网\",\n )\n disabled_web_notice_markers = (\n \"联网搜索已关闭\",\n \"不会调用 web_search\",\n \"不会调用 `web_search`\",\n \"web_search\",\n \"read_webpage\",\n \"打开联网搜索\",\n \"联网搜索开关\",\n \"前端开关\",\n \"工具配置\",\n )\n disabled_web_notice_buffer = \"\"\n checking_disabled_web_notice = not force_web_search\n table_limiter = MarkdownTableLimiter()\n\n def filter_disabled_web_notice(text: str) -> list[str]:\n \"\"\"Drop a leading web-toggle explanation; keep normal streaming intact.\"\"\"\n nonlocal disabled_web_notice_buffer, checking_disabled_web_notice\n if not checking_disabled_web_notice:\n return [text] if text else []\n\n disabled_web_notice_buffer += text\n leading = disabled_web_notice_buffer.lstrip()\n\n sentence_end_positions = [\n pos\n for pos in (\n leading.find(\"。\"),\n leading.find(\"\\n\"),\n leading.find(\"!\"),\n leading.find(\"?\"),\n leading.find(\". \"),\n )\n if pos >= 0\n ]\n cut_at = min(sentence_end_positions) + 1 if sentence_end_positions else -1\n first_sentence = leading[:cut_at] if cut_at > 0 else leading\n matched_notice = (\n any(prefix.startswith(leading) or leading.startswith(prefix) for prefix in disabled_web_notice_prefixes)\n or any(marker in first_sentence for marker in disabled_web_notice_markers)\n )\n if matched_notice:\n if cut_at < 0:\n return []\n checking_disabled_web_notice = False\n remainder = leading[cut_at:].lstrip()\n disabled_web_notice_buffer = \"\"\n return [remainder] if remainder else []\n\n checking_disabled_web_notice = False\n buffered = disabled_web_notice_buffer\n disabled_web_notice_buffer = \"\"\n return [buffered] if buffered else []\n \n try:\n events = _stream_agent_events(agent, inputs)\n for chunk, metadata in events:\n if isinstance(chunk, AIMessageChunk):\n if chunk.content and isinstance(chunk.content, str):\n for text in filter_disabled_web_notice(chunk.content):\n limited_text = table_limiter.feed(text)\n if limited_text:\n event = {\"type\": \"delta\", \"text\": limited_text}\n recorder.observe(event)\n yield event\n if chunk.tool_call_chunks:\n for tc in chunk.tool_call_chunks:\n index = tc.get(\"index\")\n tc_id = tc.get(\"id\")\n \n if tc_id:\n current_key = tc_id\n tool_calls_acc[index] = current_key\n tool_calls_acc[current_key] = {\"name\": tc.get(\"name\"), \"args\": \"\", \"id\": tc_id}\n if thinking_enabled:\n yield thinking_event(f\"准备调用工具:{tc.get('name') or '工具'}。\")\n process_tool_name = tc.get(\"name\") or \"工具\"\n process_text = \"\"\n if process_tool_name not in emitted_process_tools:\n emitted_process_tools.add(process_tool_name)\n process_text = _tool_process_text(process_tool_name)\n event = {\n \"type\": \"tool_call_start\",\n \"id\": tc_id,\n \"name\": process_tool_name,\n \"processText\": process_text,\n }\n recorder.observe(event)\n yield event\n else:\n current_key = tool_calls_acc.get(index)\n \n if current_key and tc.get(\"args\"):\n tool_calls_acc[current_key][\"args\"] += tc[\"args\"]\n elif isinstance(chunk, ToolMessage):\n args_str = \"\"\n tc_data = tool_calls_acc.get(chunk.tool_call_id)\n if tc_data:\n args_str = tc_data.get(\"args\", \"\")\n \n content = chunk.content\n \n # Parse metadata if present\n meta_event = None\n if \"\" in content:\n match = re.search(r\"(.*?)\", content)\n if match:\n try:\n meta_event = json.loads(match.group(1))\n except Exception:\n pass\n content = content.replace(match.group(0), \"\")\n \n # Attempt to parse json from string for better formatting if it's a known JSON string\n if content.startswith(\"系统状态快照:\\n\"):\n try:\n # Extract the JSON part and format it\n json_str = content[len(\"系统状态快照:\\n\"):]\n parsed = json.loads(json_str)\n content = \"系统状态快照:\\n\" + json.dumps(parsed, ensure_ascii=False, indent=2)\n except:\n pass\n elif content.startswith(\"{\") or content.startswith(\"[\"):\n try:\n parsed = json.loads(content)\n content = json.dumps(parsed, ensure_ascii=False, indent=2)\n except:\n pass\n\n if meta_event:\n recorder.observe(meta_event)\n yield meta_event\n \n tool_name = str((tc_data or {}).get(\"name\") or \"工具\")\n display_content = _display_tool_result(tool_name, content.strip(), meta_event)\n if thinking_enabled:\n yield thinking_event(f\"工具返回结果:{tool_name} 已完成,开始把结果纳入来源核验和回答组织。\")\n event = {\n \"type\": \"tool_call_result\",\n \"id\": chunk.tool_call_id,\n \"name\": tool_name,\n \"args\": args_str,\n \"content\": display_content,\n }\n recorder.observe(event)\n yield event\n if checking_disabled_web_notice and disabled_web_notice_buffer:\n leading = disabled_web_notice_buffer.lstrip()\n if not any(leading.startswith(prefix) for prefix in disabled_web_notice_prefixes):\n limited_text = table_limiter.feed(disabled_web_notice_buffer)\n if limited_text:\n event = {\"type\": \"delta\", \"text\": limited_text}\n recorder.observe(event)\n yield event\n disabled_web_notice_buffer = \"\"\n tail_text = table_limiter.flush()\n if tail_text:\n event = {\"type\": \"delta\", \"text\": tail_text}\n recorder.observe(event)\n yield event\n summary = recorder.finish()\n yield {\n \"type\": \"run_summary\",\n \"runId\": summary[\"run_id\"],\n \"durationMs\": summary[\"duration_ms\"],\n \"toolCount\": len(summary.get(\"tool_calls\") or []),\n \"status\": summary.get(\"status\"),\n }\n yield {\"type\": \"done\"}\n except Exception as e:\n error_text = str(e)\n if \"Recursion limit\" in error_text or \"GRAPH_RECURSION_LIMIT\" in error_text:\n message_text = (\n \"本轮工具调用已达到安全上限,已停止继续检索。\"\n \"请基于上方已返回的工具结果和已生成图表查看当前结论;\"\n \"如需继续深挖,请缩小问题范围后再问。\"\n )\n else:\n message_text = f\"Agent 调用失败: {e}\"\n event = {\"type\": \"error\", \"text\": message_text}\n recorder.observe(event)\n yield event\n recorder.finish()\n yield {\"type\": \"done\"}\n finally:\n SELECTED_DATASET_IDS.reset(dataset_token)\n SELECTED_SKILL_IDS.reset(skill_token)\n APPROVED_ACTION_IDS.reset(approved_token)\n CHART_RUN_STATE.reset(chart_token)\n TOOL_RUN_STATE.reset(tool_token)\n"}}}}