airflow_e2e.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. """风量研判的真实端到端验证:用真实模型跑一条提示词,观察技能是否被触发与遵循。
  2. 观察点(按 skill-creator 的评测要求,同时看结果与轨迹):
  3. 1. 技能是否被触发(主 Agent 是否委派给 airflow-review-orchestrator 子 Agent);
  4. 2. 子 Agent 是否读了 /skills/airflow-review/SKILL.md,并按索引读了 references/stages/01-*.md;
  5. 3. 是否按契约调用了 parse_airflow_measurement_report(参数名与 scope 是否正确);
  6. 4. 返回值里的真实读数是否被如实引用(总进风 35181 / 总回风 35713);
  7. 5. 是否把 pending_review 与依据不足原样报出,而不是编造结论。
  8. 用法(需服务已启动且模型密钥可用):
  9. python main.py & # 或 bash scripts/start_server.sh start
  10. python scripts/airflow_e2e.py # 默认:测风报表 + 数据准备提示词
  11. AIRFLOW_E2E_FILE="布尔台矿矿井风量分配计划20241201(1).pdf" AIRFLOW_E2E_PROMPT="请对布尔台矿做完整研判……" python scripts/airflow_e2e.py
  12. 这不是回归测试(依赖真实模型与网络),是一次需要人工判读的端到端验证;
  13. 离线回归在 tests/test_airflow_*.py。
  14. """
  15. from __future__ import annotations
  16. import json
  17. import os
  18. import sys
  19. from typing import Any
  20. from pathlib import Path
  21. import httpx
  22. ROOT = Path(__file__).resolve().parents[1]
  23. BASE = os.getenv("AIRFLOW_E2E_BASE", "http://127.0.0.1:8080")
  24. def raw_materials_dir() -> Path | None:
  25. """真实资料目录:位置由使用者决定,因此可探测或显式指定。
  26. 依次看:AIRFLOW_RAW_DIR(本地便利开关)→ 仓库根 矿井风量研判资料。
  27. 真实资料不入库,缺失时跳过。
  28. """
  29. candidates = []
  30. override = os.getenv("AIRFLOW_RAW_DIR", "").strip()
  31. if override:
  32. candidates.append(Path(override) if Path(override).is_absolute() else ROOT / override)
  33. candidates.append(ROOT / "矿井风量研判资料")
  34. for candidate in candidates:
  35. if candidate.is_dir() and (any(candidate.glob("*.pdf")) or any(candidate.glob("*.xlsx"))):
  36. return candidate
  37. return None
  38. PROMPT = os.getenv("AIRFLOW_E2E_PROMPT") or (
  39. "用户上传了布尔台矿的测风报表,请先把这份报表的数据接进来做规范化,"
  40. "把矿井总进风、总回风、各回风井、主要通风机读出来,"
  41. "并说明哪些测点需要人工确认。不要做需风量比对。")
  42. UPLOAD = os.getenv("AIRFLOW_E2E_FILE", "测风报表20260528 .xlsx")
  43. # 置 AIRFLOW_E2E_FILE="" 则不传附件,改为让智能体自行确认资料是否齐备
  44. def sse_events(response: httpx.Response) -> list[dict]:
  45. events: list[dict] = []
  46. for line in response.iter_lines():
  47. if line.startswith("data: "):
  48. try:
  49. events.append(json.loads(line[6:]))
  50. except json.JSONDecodeError:
  51. continue
  52. return events
  53. def main() -> int:
  54. files: dict[str, Any] = {}
  55. directory = raw_materials_dir() if UPLOAD else None
  56. if not UPLOAD:
  57. data = {"message": PROMPT, "mode": "full"}
  58. return _run(data, {})
  59. if directory is None:
  60. print("未找到真实资料目录(可用 AIRFLOW_RAW_DIR 指定),跳过")
  61. return 0
  62. # 支持一次上传多份(用 2.0 的 files 多附件字段):AIRFLOW_E2E_FILES 逗号分隔,
  63. # 不给就用 AIRFLOW_E2E_FILE 的单份 file 字段。
  64. multi = [name.strip() for name in os.getenv("AIRFLOW_E2E_FILES", "").split(",") if name.strip()]
  65. if multi:
  66. # httpx 的多值字段要传「(字段名, (文件名, 内容)) 的列表」,不是字典
  67. payload = [("files", (name, (directory / name).read_bytes(), "application/octet-stream"))
  68. for name in multi]
  69. return _run({"message": PROMPT, "mode": "full"}, payload)
  70. report = directory / UPLOAD
  71. if not report.is_file():
  72. print("缺少真实报表,跳过")
  73. return 0
  74. files = {"file": (report.name, report.read_bytes(),
  75. "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
  76. data = {"message": PROMPT, "file_name": report.name, "mode": "full"}
  77. return _run(data, files)
  78. def _run(data: dict, files: dict) -> int:
  79. with httpx.Client(timeout=float(os.getenv("AIRFLOW_E2E_TIMEOUT", "560"))) as client:
  80. with client.stream("POST", f"{BASE}/api/chat", data=data, files=files) as response:
  81. response.raise_for_status()
  82. events = sse_events(response)
  83. print(f"事件数:{len(events)}")
  84. types = [event.get("type") for event in events]
  85. print("事件类型:", {kind: types.count(kind) for kind in sorted(set(types))})
  86. tool_calls = [event for event in events if event.get("type") == "tool_call"]
  87. print(f"\n工具调用({len(tool_calls)} 次):")
  88. for event in tool_calls:
  89. print(f" - {event.get('tool')} {str(event.get('args'))[:150]}")
  90. text = "".join(event.get("content", "") for event in events if event.get("type") == "token")
  91. print(f"\n回答正文({len(text)} 字符):\n")
  92. print(text[:3000])
  93. print("\n---- 判读 ----")
  94. names = [event.get("tool") for event in tool_calls]
  95. print("① 委派子 Agent(task 调用):", "task" in names)
  96. print(" 全流程技能是否被读:", any("/skills/airflow-review" in str(e.get("args")) for e in tool_calls))
  97. print("② 调了报表解析工具:", "parse_airflow_measurement_report" in names)
  98. print("③ 回答含真实总进风 35181:", "35181" in text)
  99. print("④ 回答含真实总回风 35713:", "35713" in text)
  100. print("⑤ 提到待人工确认的测点:", ("人工" in text or "复核" in text))
  101. return 0
  102. if __name__ == "__main__":
  103. sys.exit(main())