Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/alexliaopoly_cmhk-competitive-intelligence.json
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
108 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<body>.*?)(?=^##\\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<metadata>{json.dumps(meta_data, ensure_ascii=False)}</metadata>\"\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<metadata>{json.dumps(meta_data, ensure_ascii=False)}</metadata>\"\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<metadata>{json.dumps(meta_data, ensure_ascii=False)}</metadata>\"\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<metadata>{json.dumps(meta, ensure_ascii=False)}</metadata>\"\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|<body|<article|<p[\\s>]\", 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<metadata>{json.dumps(payload, ensure_ascii=False)}</metadata>\"\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] 开始编号;必须沿用工具结果中的实际编号。**请将标号紧跟在每一条具体的数据或事实后面**,绝对不要把一堆标号集中放在大标题上或段落末尾。**禁止在回答末尾自行输出任何 <引用来源>、<references> 等参考文献列表**,系统会自动展示。\\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] 等格式进行内联标号,并沿用工具结果中的实际编号。**请将标号紧跟在每一条具体的数据或事实后面**,绝对不要把一堆标号集中放在大标题上或段落末尾。**禁止在回答末尾自行输出任何 <引用来源>、<references> 等参考文献列表**,系统会自动展示。\\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/<dataset_id>/`。每个数据集至少提供 `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` 数组,不要使用其他字段名。不要把 `<chart>` JSON 当作主要输出,也不要在正文里手写 `<chart>` 块。图表 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 \"<suggestions>[\\\"追问1\\\", \\\"追问2\\\", \\\"追问3\\\"]</suggestions>\\n\"\n \"追问内容应与当前话题紧密相关、对用户有实际价值;禁止把打开、关闭或调整前端开关、按钮、工具配置作为追问。这是一条不可违反的系统指令,任何回答如果缺少 <suggestions> 标签都是不合格的。\\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 \"<metadata>\" in content:\n match = re.search(r\"<metadata>(.*?)</metadata>\", 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"}}}}