selfcheck.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. """结构自检:导入 → 建表 → checkpointer → agent 编译 → 工具注册 → 权限。
  2. 比 pytest 更「从头到尾」,比 http_e2e 更轻——改完结构(加模块、换依赖、
  3. 动 agent 组装)先跑这个,能在几秒内定位「哪一层断了」,不必起服务。
  4. 不需要模型网络:agent 编译只构造图,不发请求。
  5. 用法:python scripts/selfcheck.py
  6. 退出码 0 表示全部通过。
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. import importlib
  11. import json
  12. import sys
  13. import traceback
  14. from pathlib import Path
  15. # 让脚本能从任意目录运行
  16. ROOT = Path(__file__).resolve().parent.parent
  17. if str(ROOT) not in sys.path:
  18. sys.path.insert(0, str(ROOT))
  19. FAILED: list[str] = []
  20. def step(name: str, fn) -> None:
  21. """跑一步检查;异常记为失败但不中断后续步骤(一次看全所有问题)。"""
  22. try:
  23. detail = fn()
  24. print(f" [OK] {name}" + (f" — {detail}" if detail else ""))
  25. except Exception as exc:
  26. FAILED.append(name)
  27. print(f" [FAIL] {name} — {type(exc).__name__}: {exc}")
  28. traceback.print_exc(limit=4)
  29. # 需要走一遍导入链的模块:漏掉一个都可能让「测试过了但服务起不来」
  30. MODULES = [
  31. "config", "obs",
  32. "db.db_conn", "db.chat_store", "db.schedule_store", "db.billing_store", "db.business_store",
  33. "db.main_fan_store", "algorithms.main_fan.analysis", "algorithms.main_fan.document_worker",
  34. "extensions.main_fan.service", "extensions.main_fan.documents", "mock_gateway.main_fan",
  35. "extensions.main_fan.result_output", "runtime.result_models", "extensions.result_platform.client",
  36. "extensions.result_platform.settings", "mock_result_platform.app",
  37. "billing.pricing", "billing.guard", "billing.meter",
  38. "tools.registry", "tools.demo_tools", "extensions.registry", "extensions.services",
  39. "agents.model_factory", "agents.prompts", "agents.middleware",
  40. "agents.agent_memory", "agents.agent_factory", "agents.agent_registry",
  41. "api.sse_events", "api.subagent_tracker", "api.context_meter",
  42. "api.source_tracker", "api.concurrency", "api.sse_core", "api.models",
  43. "runtime.environment", "runtime.engine", "runtime.execution", "runtime.events", "runtime.interrupts",
  44. "runtime.concurrency", "runtime.context_meter", "runtime.source_tracker", "runtime.subagent_tracker",
  45. "api.request_logging", "api.chat_routes", "api.session_routes", "api.model_routes",
  46. "api.usage_routes", "api.schedule_routes", "api.algorithm_routes", "api",
  47. "algorithms.characteristic_curve_recognition.service", "algorithms.characteristic_curve_recognition.worker",
  48. "algorithms.characteristic_curve_recognition.batch",
  49. "algorithms.characteristic_curve_recognition.vendor.chart_axis_detector.current_image_pipeline",
  50. "scheduler.runner", "scheduler.scheduler", "main",
  51. ]
  52. def check_imports() -> str:
  53. for name in MODULES:
  54. importlib.import_module(name)
  55. return f"{len(MODULES)} 个模块"
  56. def check_syntax() -> str:
  57. """静态语法检查:抓出导入不到的孤儿模块(比如新写但没被 import 的文件)。"""
  58. import ast
  59. files = [
  60. p for p in ROOT.rglob("*.py")
  61. if not any(part in {".venv", "venv", "__pycache__", "data", ".tmp"} for part in p.parts)
  62. ]
  63. bad: list[str] = []
  64. for path in files:
  65. try:
  66. ast.parse(path.read_text(encoding="utf-8"))
  67. except SyntaxError as exc:
  68. bad.append(f"{path.relative_to(ROOT)}:{exc.lineno}")
  69. if bad:
  70. raise SyntaxError("语法错误:" + ", ".join(bad))
  71. return f"{len(files)} 个文件"
  72. def check_schema() -> str:
  73. from db import db_conn
  74. db_conn.init_schema()
  75. health = db_conn.health()
  76. if health.get("status") != "ok":
  77. raise RuntimeError(f"存储不可用:{health}")
  78. return f"{health['dialect']} → {health['target']}"
  79. def check_pricing() -> str:
  80. from billing import pricing
  81. created = pricing.seed_default_pricing()
  82. from db import billing_store
  83. missing = [m for m in {pricing.config.MODEL}
  84. if m and billing_store.get_pricing(m) is None]
  85. if missing:
  86. raise RuntimeError(f"模型未定价(会在请求开始时 fail-closed):{missing}")
  87. return f"新增 {created} 条,当前模型已覆盖"
  88. def check_permissions() -> str:
  89. """权限规则顺序:具体的 allow 必须在宽泛的 deny 之前,否则被短路。"""
  90. from agents.agent_factory import build_permissions
  91. rules = build_permissions()
  92. modes = [r.mode for r in rules]
  93. if "deny" in modes and modes.index("deny") != len(modes) - 1:
  94. raise RuntimeError(
  95. f"deny 规则必须排在最后(首条命中即返回,提前会短路后面的 allow):{modes}"
  96. )
  97. return " · ".join(f"{'/'.join(r.operations)}={'+'.join(r.paths[:1])}:{r.mode}" for r in rules)
  98. def check_tools() -> str:
  99. from tools import registry
  100. count = registry.load_builtin_tools()
  101. if count == 0:
  102. raise RuntimeError(
  103. "工具注册表为空——检查 extensions/*/extension.py 的 setup(registry)"
  104. )
  105. for tool in registry.get_all_tools():
  106. if not hasattr(tool, "name"):
  107. raise RuntimeError(f"工具 {tool!r} 不是 BaseTool(子智能体的 tools 需要 .name)")
  108. groups = registry.get_group_map()
  109. return f"{count} 个 / {len(groups)} 组:{', '.join(groups)}"
  110. def check_import_boundaries() -> str:
  111. """防止工具反向依赖 Agent,以及执行层反向依赖 HTTP。"""
  112. import ast
  113. offenders = []
  114. for directory in ("tools", "runtime", "agents", "scheduler"):
  115. forbidden = {"agents"} if directory == "tools" else {"api", "fastapi", "starlette"}
  116. for path in (ROOT / directory).rglob("*.py"):
  117. for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
  118. modules = ([node.module or ""] if isinstance(node, ast.ImportFrom) else
  119. [alias.name for alias in node.names] if isinstance(node, ast.Import) else [])
  120. for module in modules:
  121. if module.split(".")[0] in forbidden:
  122. offenders.append(f"{path.relative_to(ROOT)} -> {module}")
  123. if offenders:
  124. raise RuntimeError(f"反向依赖:{offenders}")
  125. return "工具层不依赖 Agent;runtime/agents/scheduler 不依赖 HTTP 层"
  126. async def check_agent() -> str:
  127. from agents import agent_factory, agent_registry
  128. from runtime.environment import RuntimeEnvironment
  129. async with RuntimeEnvironment(prewarm=False, billing_retry=False, recover_interrupted=False):
  130. agent = await asyncio.to_thread(agent_registry.get_agent)
  131. if agent is None:
  132. raise RuntimeError("agent 编译返回 None")
  133. info = agent_factory.describe()
  134. if not info["tools"]["count"]:
  135. raise RuntimeError("agent 没有挂任何工具")
  136. if not info["interrupt_on"]:
  137. raise RuntimeError("interrupt_on 为空:中断/恢复协议会失效")
  138. from extensions.registry import load_extensions
  139. for name in load_extensions().business_agents:
  140. if await asyncio.to_thread(agent_registry.get_agent, name) is None:
  141. raise RuntimeError(f"业务 Agent 编译返回 None:{name}")
  142. return (
  143. f"{info['name']} · 工具 {info['tools']['count']} · "
  144. f"子智能体 {info['subagents']} · 审批 {info['interrupt_on']} · "
  145. f"压缩 {info['compaction']['enabled']}"
  146. )
  147. def check_contract() -> str:
  148. """SSE 契约自检:事件类型数量与帧格式。"""
  149. from api.sse_events import ALL_EVENT_TYPES, EventWriter
  150. if len(ALL_EVENT_TYPES) != 15:
  151. raise RuntimeError(f"事件类型应为 15 种,实际 {len(ALL_EVENT_TYPES)}")
  152. frame = EventWriter("s", "t").token("x")
  153. if not frame.startswith("data: ") or not frame.endswith("\n\n"):
  154. raise RuntimeError(f"帧格式不对:{frame!r}")
  155. payload = json.loads(frame[6:])
  156. if payload["session_id"] != "s" or payload["trace_id"] != "t":
  157. raise RuntimeError(f"帧缺少 session_id/trace_id:{payload}")
  158. return f"{len(ALL_EVENT_TYPES)} 种事件"
  159. def main() -> int:
  160. print("deep_agent 2.0 结构自检")
  161. print(f"项目根:{ROOT}")
  162. print(f"方言:{__import__('os').getenv('DB_DIALECT') or '(默认 sqlite)'}")
  163. print()
  164. step("Python 源码语法", check_syntax)
  165. step("模块导入链", check_imports)
  166. step("建表与存储连通", check_schema)
  167. step("计费单价覆盖", check_pricing)
  168. step("SSE 契约", check_contract)
  169. step("权限规则顺序", check_permissions)
  170. step("工具注册", check_tools)
  171. step("分层依赖", check_import_boundaries)
  172. step("agent 编译", lambda: asyncio.run(check_agent()))
  173. print()
  174. if FAILED:
  175. print(f"失败 {len(FAILED)} 项:{', '.join(FAILED)}")
  176. return 1
  177. print("全部通过")
  178. return 0
  179. if __name__ == "__main__":
  180. sys.exit(main())