run.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. """demand 示例的最小可运行入口。"""
  2. import asyncio
  3. import copy
  4. import importlib
  5. import json
  6. import os
  7. import sys
  8. from datetime import datetime
  9. from pathlib import Path
  10. from typing import Any, Optional
  11. from zoneinfo import ZoneInfo
  12. # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
  13. os.environ.setdefault("no_proxy", "*")
  14. # 该示例仅使用项目侧能力,禁用框架内置 skills
  15. os.environ.setdefault("AGENT_DISABLE_BUILTIN_SKILLS", "1")
  16. # 禁用内置工具自动注册,并开启严格工具白名单
  17. os.environ.setdefault("AGENT_DISABLE_BUILTIN_TOOL_REGISTRATION", "1")
  18. os.environ.setdefault("AGENT_STRICT_TOOL_SELECTION", "1")
  19. # 禁用所有侧分支(压缩/反思)
  20. os.environ.setdefault("AGENT_DISABLE_SIDE_BRANCHES", "1")
  21. from dotenv import load_dotenv
  22. from examples.demand.db_manager import (
  23. DEFAULT_DEMAND_PLATFORM,
  24. normalize_platform,
  25. query_category_level,
  26. query_latest_success_execution_id,
  27. query_video_ids_by_names,
  28. )
  29. from examples.demand.demand_agent_context import TopicBuildAgentContext
  30. # 添加项目根目录到 Python 路径
  31. sys.path.insert(0, str(Path(__file__).parent.parent.parent))
  32. load_dotenv()
  33. from examples.demand.log_capture import build_log, log
  34. CUSTOM_TOOL_MODULES = {
  35. # demand 示例:严格按工具名白名单加载对应模块
  36. "think_and_plan": "examples.demand.agent_tools",
  37. "get_category_tree": "examples.demand.demand_pattern_tools",
  38. "get_frequent_itemsets": "examples.demand.demand_pattern_tools",
  39. "get_itemset_detail": "examples.demand.demand_pattern_tools",
  40. "get_post_elements": "examples.demand.demand_pattern_tools",
  41. "search_elements": "examples.demand.demand_pattern_tools",
  42. "get_element_category_chain": "examples.demand.demand_pattern_tools",
  43. "get_category_detail": "examples.demand.demand_pattern_tools",
  44. "search_categories": "examples.demand.demand_pattern_tools",
  45. "get_category_elements": "examples.demand.demand_pattern_tools",
  46. "get_category_co_occurrences": "examples.demand.demand_pattern_tools",
  47. "get_element_co_occurrences": "examples.demand.demand_pattern_tools",
  48. "get_weight_score_topn": "examples.demand.weight_score_query_tools",
  49. "get_weight_score_by_name": "examples.demand.weight_score_query_tools",
  50. "create_demand_item": "examples.demand.demand_build_agent_tools",
  51. "create_demand_items": "examples.demand.demand_build_agent_tools",
  52. "write_execution_summary": "examples.demand.demand_build_agent_tools",
  53. }
  54. LOCAL_JSON_MODE = "local_json"
  55. MYSQL_DEMAND_CONTENT_MODE = "mysql_demand_content"
  56. LOCAL_ENTRYPOINT_ENV = "DEMAND_LOCAL_ENTRYPOINT"
  57. LOCAL_ENTRYPOINT_NAME = "run_existing_execution_local"
  58. MYSQL_ENTRYPOINT_ENV = "DEMAND_MYSQL_ENTRYPOINT"
  59. MYSQL_ENTRYPOINT_NAMES = {"run_existing_execution_mysql", "run_hive_gap_mysql"}
  60. def _resolve_run_platform(platform_type: Optional[str], demand_scope: Optional[dict[str, Any]]) -> str:
  61. scope = demand_scope if isinstance(demand_scope, dict) else {}
  62. raw_platform = platform_type or scope.get("platform") or scope.get("platform_type") or DEFAULT_DEMAND_PLATFORM
  63. return normalize_platform(raw_platform)
  64. def _is_local_json_mode() -> bool:
  65. return os.getenv("DEMAND_OUTPUT_MODE", "").strip().lower() == LOCAL_JSON_MODE
  66. def _is_mysql_demand_content_mode() -> bool:
  67. return os.getenv("DEMAND_OUTPUT_MODE", "").strip().lower() == MYSQL_DEMAND_CONTENT_MODE
  68. def _raise_db_write_disabled(action: str) -> None:
  69. raise RuntimeError(
  70. f"{action} 已在 PG Pattern V2 MVP 中禁用;"
  71. "请通过 run_existing_execution_local 写本地 JSON,或通过 "
  72. "run_existing_execution_mysql 只写测试 MySQL demand_content"
  73. )
  74. def _require_local_entrypoint() -> None:
  75. if not _is_local_json_mode():
  76. return
  77. entrypoint = os.getenv(LOCAL_ENTRYPOINT_ENV, "").strip()
  78. if entrypoint != LOCAL_ENTRYPOINT_NAME:
  79. raise RuntimeError(
  80. "DEMAND_OUTPUT_MODE=local_json 只能通过 "
  81. "examples.demand.run_existing_execution_local 入口运行"
  82. )
  83. def _require_mysql_entrypoint() -> None:
  84. if not _is_mysql_demand_content_mode():
  85. return
  86. entrypoint = os.getenv(MYSQL_ENTRYPOINT_ENV, "").strip()
  87. if entrypoint not in MYSQL_ENTRYPOINT_NAMES:
  88. raise RuntimeError(
  89. "DEMAND_OUTPUT_MODE=mysql_demand_content 只能通过 "
  90. "examples.demand.run_existing_execution_mysql 或 "
  91. "examples.demand.run_hive_gap_mysql 入口运行"
  92. )
  93. def _default_local_output_root(execution_id: int) -> Path:
  94. ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d_%H%M%S")
  95. return Path(__file__).parent / "test_output_data" / f"execution_{execution_id}_{ts}"
  96. def _ensure_local_output_paths(execution_id: int) -> Path:
  97. root_value = os.getenv("DEMAND_LOCAL_OUTPUT_ROOT")
  98. root = Path(root_value) if root_value else _default_local_output_root(execution_id)
  99. os.environ["DEMAND_LOCAL_OUTPUT_ROOT"] = str(root)
  100. os.environ.setdefault("DEMAND_RESULT_BASE_DIR", str(root / "intermediate" / "result"))
  101. os.environ.setdefault("DEMAND_WEIGHT_DATA_DIR", str(root / "intermediate" / "data"))
  102. os.environ.setdefault("DEMAND_TRACE_STORE_PATH", str(root / ".trace"))
  103. os.environ.setdefault("DEMAND_OUTPUT_BASE_DIR", str(root / "output"))
  104. return root
  105. def _get_result_base_dir() -> Path:
  106. redirected = os.getenv("DEMAND_RESULT_BASE_DIR")
  107. if redirected:
  108. return Path(redirected)
  109. if _is_local_json_mode():
  110. return _ensure_local_output_paths(0) / "result"
  111. return Path.cwd() / "result"
  112. def _get_output_base_dir() -> Path:
  113. redirected = os.getenv("DEMAND_OUTPUT_BASE_DIR")
  114. if redirected:
  115. return Path(redirected)
  116. if _is_local_json_mode():
  117. return _ensure_local_output_paths(0) / "output"
  118. return Path(__file__).parent / "output"
  119. def _get_trace_store_base_dir() -> Path:
  120. redirected = os.getenv("DEMAND_TRACE_STORE_PATH")
  121. if redirected:
  122. return Path(redirected)
  123. if _is_local_json_mode():
  124. return _ensure_local_output_paths(0) / ".trace"
  125. from examples.demand.config import TRACE_STORE_PATH
  126. return Path(TRACE_STORE_PATH)
  127. def _get_weight_data_base_dir() -> Path:
  128. redirected = os.getenv("DEMAND_WEIGHT_DATA_DIR")
  129. if redirected:
  130. return Path(redirected)
  131. return Path(__file__).parent / "data"
  132. def _get_local_output_root() -> Optional[Path]:
  133. root = os.getenv("DEMAND_LOCAL_OUTPUT_ROOT")
  134. return Path(root) if root else None
  135. def _write_json_file(path: Path, payload: Any) -> None:
  136. path.parent.mkdir(parents=True, exist_ok=True)
  137. with open(path, "w", encoding="utf-8") as f:
  138. json.dump(payload, f, ensure_ascii=False, indent=2)
  139. def get_execution_id_by_merge_level2(cluster_name: str):
  140. """Return the latest successful PG Pattern V2 execution_id.
  141. PG Pattern V2 executions are global snapshots rather than the old MySQL
  142. merge_leve2-scoped execution rows. Local MVP callers should pass an explicit
  143. execution_id; this fallback only keeps non-local paths from using MySQL.
  144. """
  145. del cluster_name
  146. return query_latest_success_execution_id()
  147. def resolve_model(prompt: Any, run_config: Any) -> str:
  148. for env_key in ("DEMAND_LLM_MODEL", "ARK_MODEL", "OPEN_ROUTER_MODEL", "OPENROUTER_MODEL"):
  149. model_from_env = os.getenv(env_key)
  150. if model_from_env:
  151. return model_from_env.strip()
  152. model_from_prompt = prompt.config.get("model")
  153. if model_from_prompt:
  154. return model_from_prompt
  155. return f"anthropic/{run_config.model}" if "/" not in run_config.model else run_config.model
  156. def extract_assistant_text(message: Any) -> str:
  157. if message.role != "assistant":
  158. return ""
  159. content = message.content
  160. if isinstance(content, str):
  161. return content
  162. if isinstance(content, dict):
  163. text = content.get("text", "")
  164. # 即使本轮包含工具调用,也打印模型给出的文本,便于观察每一步输出
  165. if text:
  166. return text
  167. return ""
  168. def register_selected_tools(tool_names: list[str]) -> None:
  169. for tool_name in tool_names:
  170. module_path = CUSTOM_TOOL_MODULES.get(tool_name)
  171. if not module_path:
  172. raise ValueError(f"未配置工具模块映射: {tool_name}")
  173. importlib.import_module(module_path)
  174. def _enabled_tools_for_run(configured_tools: list[str]) -> list[str]:
  175. tools = configured_tools.copy()
  176. if _is_mysql_demand_content_mode():
  177. allowed = {
  178. "think_and_plan",
  179. "get_category_tree",
  180. "get_frequent_itemsets",
  181. "get_itemset_detail",
  182. "get_post_elements",
  183. "search_elements",
  184. "get_element_category_chain",
  185. "get_category_detail",
  186. "search_categories",
  187. "get_category_elements",
  188. "get_category_co_occurrences",
  189. "get_element_co_occurrences",
  190. "get_weight_score_topn",
  191. "get_weight_score_by_name",
  192. "create_demand_item",
  193. "create_demand_items",
  194. }
  195. return [tool_name for tool_name in tools if tool_name in allowed]
  196. if _is_local_json_mode():
  197. # 本地批量输出只需要 DemandItem JSON。长摘要会显著放大最后一轮上下文,
  198. # 且不参与下游 CFA 验证契约。
  199. tools = [tool_name for tool_name in tools if tool_name != "write_execution_summary"]
  200. return tools
  201. def _join_element_names_to_name(element_names: object) -> str:
  202. """把 tool 入参 element_names 转成 demand_content.name(逗号分隔)。"""
  203. if element_names is None:
  204. return ""
  205. if isinstance(element_names, list):
  206. parts = [str(x).strip() for x in element_names if x is not None and str(x).strip()]
  207. return ",".join(parts)
  208. # 兼容异常数据:比如历史版本可能传了字符串
  209. return str(element_names).strip()
  210. def _safe_truncate(s: object, max_len: int) -> str:
  211. if s is None:
  212. return ""
  213. s_str = str(s)
  214. if max_len and len(s_str) > max_len:
  215. return s_str[:max_len]
  216. return s_str
  217. def _load_name_score_map(execution_id: int) -> dict:
  218. """读取 data/{execution_id} 下所有 JSON 的「名字->score」(同名取最高分)。
  219. 兼容两类数据结构:
  220. - `*_元素.json`:字段 `name` 表示名字
  221. - `*_分类.json`:字段 `category` 表示名字
  222. """
  223. data_dir = _get_weight_data_base_dir() / str(execution_id)
  224. if not data_dir.exists():
  225. return {}
  226. score_map = {}
  227. for json_path in data_dir.glob("*.json"):
  228. try:
  229. with open(json_path, "r", encoding="utf-8") as f:
  230. payload = json.load(f)
  231. except Exception:
  232. continue
  233. if not isinstance(payload, list):
  234. continue
  235. for item in payload:
  236. if not isinstance(item, dict):
  237. continue
  238. # 元素数据以 name 为主;分类数据以 category 为主。
  239. name = item.get("name")
  240. if not isinstance(name, str) or not name:
  241. name = item.get("category")
  242. score = item.get("score")
  243. if isinstance(name, str) and isinstance(score, (int, float)):
  244. prev = score_map.get(name)
  245. score_f = float(score)
  246. if prev is None or score_f > prev:
  247. score_map[name] = score_f
  248. return score_map
  249. def _ensure_pg_weight_score_files(execution_id: int) -> None:
  250. """Build local PG weight files when local JSON mode needs them."""
  251. if not (_is_local_json_mode() or _is_mysql_demand_content_mode()):
  252. return
  253. from examples.demand.pg_weight_score_builder import build_pg_weight_score_files
  254. base_dir = _get_weight_data_base_dir()
  255. expected_dir = base_dir / str(execution_id)
  256. required_files = [
  257. expected_dir / f"{dimension}_{level}.json"
  258. for dimension in ("实质", "形式", "意图")
  259. for level in ("元素", "分类")
  260. ]
  261. if all(path.exists() for path in required_files):
  262. return
  263. counts = build_pg_weight_score_files(execution_id=execution_id, base_dir=base_dir)
  264. log(f"[weight][pg] 生成 PG 权重 JSON,execution_id={execution_id}, files={counts}")
  265. def _avg_score_for_joined_name(name: str, score_map: dict) -> float:
  266. """按逗号拆分 name,分别取分后求平均。"""
  267. parts = [part.strip() for part in str(name).split(",") if part and part.strip()]
  268. if not parts:
  269. return 0.0
  270. return sum(float(score_map.get(part, 0.0)) for part in parts) / len(parts)
  271. def _resolve_video_ids_by_name_and_execution_id(name: str, execution_id: int) -> list[str]:
  272. """按 name(逗号分隔) 与 execution_id 解析去重后的 post_id 列表。"""
  273. name_parts = [part.strip() for part in str(name).split(",") if part and part.strip()]
  274. if not name_parts:
  275. return []
  276. return query_video_ids_by_names(execution_id=execution_id, names=name_parts)
  277. def _demand_items_output_path(execution_id: int) -> Path:
  278. return _get_result_base_dir() / str(execution_id) / f"execution_id_{execution_id}_demand_items.json"
  279. def _resolve_demand_items_path(execution_id: int) -> Optional[Path]:
  280. configured_path = _demand_items_output_path(execution_id)
  281. if configured_path.exists() or _is_local_json_mode():
  282. return configured_path if configured_path.exists() else None
  283. fallbacks = [
  284. Path.cwd() / "result" / str(execution_id) / f"execution_id_{execution_id}_demand_items.json",
  285. Path(__file__).parent / "result" / str(execution_id) / f"execution_id_{execution_id}_demand_items.json",
  286. ]
  287. for path in fallbacks:
  288. if path.exists():
  289. return path
  290. return None
  291. def _load_demand_items(execution_id: int, log_prefix: str) -> list[dict]:
  292. demand_items_path = _resolve_demand_items_path(execution_id)
  293. if not demand_items_path:
  294. expected_path = _demand_items_output_path(execution_id)
  295. log(f"[{log_prefix}] 未找到需求 JSON:{expected_path},跳过写入")
  296. return []
  297. try:
  298. with open(demand_items_path, "r", encoding="utf-8") as f:
  299. loaded = json.load(f)
  300. except Exception as e:
  301. log(f"[{log_prefix}] 读取需求 JSON 失败:{demand_items_path},error={e}")
  302. return []
  303. items = loaded["items"] if isinstance(loaded, dict) and isinstance(loaded.get("items"), list) else loaded
  304. if not isinstance(items, list):
  305. log(f"[{log_prefix}] 需求 JSON 非数组,跳过写入:type={type(items)}")
  306. return []
  307. return [item for item in items if isinstance(item, dict)]
  308. def _normalize_evidence_refs(value: object) -> dict:
  309. if isinstance(value, dict):
  310. return value
  311. if isinstance(value, str) and value.strip():
  312. try:
  313. loaded = json.loads(value)
  314. if isinstance(loaded, dict):
  315. return loaded
  316. except json.JSONDecodeError:
  317. pass
  318. return {}
  319. def _build_demand_content_rows(
  320. execution_id: int,
  321. merge_level2: str,
  322. *,
  323. trace_id: Optional[str] = None,
  324. task_id: Optional[int] = None,
  325. serialize_ext_data: bool = True,
  326. require_evidence: bool = False,
  327. demand_scope: Optional[dict[str, Any]] = None,
  328. ) -> tuple[list[dict], list[dict], list[dict]]:
  329. from examples.demand.evidence_pack_builder import build_evidence_pack
  330. items = _load_demand_items(execution_id=execution_id, log_prefix="result")
  331. if not items:
  332. return [], [], []
  333. dt_value = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
  334. score_map = _load_name_score_map(execution_id)
  335. rows: list[dict] = []
  336. normalized_items: list[dict] = []
  337. rejected_items: list[dict] = []
  338. for index, di in enumerate(items, start=1):
  339. normalized_item = dict(di)
  340. evidence_refs = _normalize_evidence_refs(di.get("evidence_refs"))
  341. normalized_item["evidence_refs"] = evidence_refs
  342. normalized_items.append(normalized_item)
  343. name = _join_element_names_to_name(di.get("element_names"))
  344. if not name:
  345. rejected_items.append(
  346. {
  347. "item_index": index,
  348. "reject_reason": "missing element_names/name",
  349. "demand_item": normalized_item,
  350. }
  351. )
  352. continue
  353. score = _avg_score_for_joined_name(name, score_map)
  354. reason = di.get("reason")
  355. desc_value = di.get("desc")
  356. item_type = di.get("type")
  357. type_str = str(item_type).strip() if item_type is not None else ""
  358. if type_str == "分类":
  359. category_level = query_category_level(execution_id=execution_id, name=name)
  360. if category_level:
  361. type_str = type_str + f"L{category_level}"
  362. video_ids = _resolve_video_ids_by_name_and_execution_id(name=name, execution_id=execution_id)
  363. ext_data: dict[str, Any] = {
  364. "reason": reason,
  365. "desc": desc_value,
  366. "type": type_str,
  367. "video_ids": video_ids,
  368. }
  369. if trace_id:
  370. ext_data["trace_id"] = trace_id
  371. if task_id is not None:
  372. ext_data["demand_task_id"] = task_id
  373. content_id = len(rows) + 1 if not serialize_ext_data else None
  374. if require_evidence or evidence_refs:
  375. evidence_result = build_evidence_pack(
  376. execution_id=int(execution_id),
  377. demand_item=normalized_item,
  378. trace_id=trace_id or "",
  379. demand_task_id=task_id,
  380. demand_content_id=content_id,
  381. demand_scope=demand_scope or TopicBuildAgentContext.get_metadata("demand_scope", {}),
  382. merge_leve2=merge_level2,
  383. platform=TopicBuildAgentContext.get_metadata("platform"),
  384. )
  385. if not evidence_result.get("success"):
  386. if require_evidence:
  387. rejected_items.append(
  388. {
  389. "item_index": index,
  390. "reject_reason": evidence_result.get("reject_reason") or "evidence validation failed",
  391. "demand_item": normalized_item,
  392. }
  393. )
  394. continue
  395. log(
  396. "[result] evidence 校验未通过,生产兼容路径保留旧输出:"
  397. f"item_index={index}, reason={evidence_result.get('reject_reason')}"
  398. )
  399. else:
  400. evidence_pack = evidence_result["evidence_pack"]
  401. ext_data["evidence_pack"] = evidence_pack
  402. if evidence_pack.get("video_ids"):
  403. ext_data["video_ids"] = evidence_pack["video_ids"]
  404. row = {
  405. "merge_leve2": _safe_truncate(merge_level2, 32),
  406. "name": _safe_truncate(name, 64),
  407. "reason": reason,
  408. "suggestion": desc_value,
  409. "score": float(score),
  410. "ext_data": json.dumps(ext_data, ensure_ascii=False) if serialize_ext_data else ext_data,
  411. "dt": dt_value,
  412. }
  413. if not serialize_ext_data:
  414. row["id"] = content_id
  415. rows.append(row)
  416. if not rows:
  417. log("[result] 生成行为空,跳过写入")
  418. if rejected_items:
  419. log(f"[result] evidence 校验拒绝 {len(rejected_items)} 条 DemandItem")
  420. return rows, normalized_items, rejected_items
  421. def _create_demand_task(
  422. execution_id: int,
  423. name: Optional[str] = None,
  424. platform: Optional[str] = None,
  425. ) -> Optional[int]:
  426. """Disabled production demand_task writer retained for legacy imports."""
  427. del execution_id, name, platform
  428. _raise_db_write_disabled("生产 _create_demand_task")
  429. def _finish_demand_task(task_id: Optional[int], status: int, task_log: str) -> None:
  430. """Disabled production demand_task updater retained for legacy imports."""
  431. if not task_id:
  432. return
  433. del status, task_log
  434. _raise_db_write_disabled("生产 _finish_demand_task")
  435. def _start_local_demand_task(
  436. *,
  437. execution_id: int,
  438. task_id: Optional[int],
  439. name: Optional[str],
  440. platform: Optional[str],
  441. ) -> int:
  442. from examples.demand.local_output_sink import LocalOutputSink
  443. local_task_id = int(task_id or os.getenv("DEMAND_LOCAL_TASK_ID", "1"))
  444. os.environ["DEMAND_LOCAL_TASK_ID"] = str(local_task_id)
  445. root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
  446. task_payload = {
  447. "id": local_task_id,
  448. "execution_id": execution_id,
  449. "name": name,
  450. "platform": platform,
  451. "status": 0,
  452. "mode": LOCAL_JSON_MODE,
  453. "is_simulated_id": True,
  454. "log": "",
  455. "started_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
  456. }
  457. LocalOutputSink(root).write_all(
  458. run_manifest={
  459. "output_mode": LOCAL_JSON_MODE,
  460. "execution_id": execution_id,
  461. "merge_leve2": name,
  462. "platform_type": platform,
  463. "task_id": local_task_id,
  464. "status": 0,
  465. "initialized": True,
  466. "created_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
  467. },
  468. demand_task=task_payload,
  469. )
  470. log(f"[task][local_json] 写入本地 demand_task,task_id={local_task_id}, execution_id={execution_id}")
  471. return local_task_id
  472. def _finish_local_demand_task(task_id: Optional[int], status: int, task_log: str) -> None:
  473. if not task_id:
  474. return
  475. root = _get_local_output_root()
  476. if not root:
  477. return
  478. task_path = root / "demand_task.json"
  479. payload: dict[str, Any] = {}
  480. if task_path.exists():
  481. try:
  482. with open(task_path, "r", encoding="utf-8") as f:
  483. loaded = json.load(f)
  484. if isinstance(loaded, dict):
  485. payload = loaded
  486. except Exception:
  487. payload = {}
  488. payload.update(
  489. {
  490. "id": task_id,
  491. "status": int(status),
  492. "mode": LOCAL_JSON_MODE,
  493. "is_simulated_id": True,
  494. "log": task_log or "",
  495. "finished_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
  496. }
  497. )
  498. _write_json_file(task_path, payload)
  499. log(f"[task][local_json] 更新本地 demand_task,task_id={task_id}, status={status}")
  500. def write_demand_items_to_mysql(
  501. execution_id: int,
  502. merge_level2: str,
  503. *,
  504. trace_id: Optional[str] = None,
  505. task_id: Optional[int] = None,
  506. demand_scope: Optional[dict[str, Any]] = None,
  507. ) -> int:
  508. """Write DB-validated demand_content rows to the test MySQL sink only."""
  509. if not _is_mysql_demand_content_mode():
  510. _raise_db_write_disabled("生产 write_demand_items_to_mysql")
  511. from examples.demand.mysql_demand_content_sink import write_demand_content_rows
  512. rows, demand_items, rejected_items = _build_demand_content_rows(
  513. execution_id=execution_id,
  514. merge_level2=merge_level2,
  515. trace_id=trace_id,
  516. task_id=task_id,
  517. serialize_ext_data=False,
  518. require_evidence=True,
  519. demand_scope=demand_scope,
  520. )
  521. run_label = os.getenv("DEMAND_RUN_LABEL", "").strip()
  522. result = write_demand_content_rows(rows, run_label=run_label)
  523. log(
  524. "[mysql_demand_content] 写入测试 MySQL demand_content 完成,"
  525. f"demand_items={len(demand_items)}, inserted={result.inserted_count}, "
  526. f"rejected={len(rejected_items)}, skipped={result.skipped_count}"
  527. )
  528. if rejected_items:
  529. log(
  530. "[mysql_demand_content] rejected_items="
  531. + json.dumps(rejected_items, ensure_ascii=False, default=str)
  532. )
  533. if result.inserted_ids:
  534. log(f"[mysql_demand_content] inserted_ids={result.inserted_ids}")
  535. return result.inserted_count
  536. def write_demand_items_to_local_json(
  537. execution_id: int,
  538. merge_level2: str,
  539. *,
  540. trace_id: Optional[str] = None,
  541. task_id: Optional[int] = None,
  542. demand_scope: Optional[dict[str, Any]] = None,
  543. ) -> int:
  544. """把普通需求池输出镜像到本地 JSON,不写 demand_content / Hive。"""
  545. from examples.demand.local_output_sink import LocalOutputSink
  546. root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
  547. rows, demand_items, rejected_items = _build_demand_content_rows(
  548. execution_id=execution_id,
  549. merge_level2=merge_level2,
  550. trace_id=trace_id,
  551. task_id=task_id,
  552. serialize_ext_data=False,
  553. require_evidence=True,
  554. demand_scope=demand_scope,
  555. )
  556. sink = LocalOutputSink(root)
  557. sink.write_demand_items(demand_items)
  558. sink.write_rejected_demand_items(rejected_items)
  559. sink.write_demand_content(rows)
  560. sink.write_dwd_multi_demand_pool_di_from_demand_content(rows)
  561. sink.write_feature_point_data([])
  562. log(
  563. "[local_json] 写入本地 demand_content/dwd_multi_demand_pool_di 完成,"
  564. f"demand_content_rows={len(rows)}, rejected={len(rejected_items)}, root={root}"
  565. )
  566. return len(rows)
  567. def write_global_tree_demand_items_to_hive(execution_id: int, merge_level2: str) -> int:
  568. """Disabled production Hive writer retained for legacy imports."""
  569. del execution_id, merge_level2
  570. _raise_db_write_disabled("生产 write_global_tree_demand_items_to_hive")
  571. def write_global_tree_demand_items_to_local_json(
  572. execution_id: int,
  573. merge_level2: str,
  574. *,
  575. trace_id: Optional[str] = None,
  576. task_id: Optional[int] = None,
  577. ) -> int:
  578. """把全局树 feature_point_data 输出镜像到本地 JSON,不写 Hive。"""
  579. from examples.demand.local_output_sink import LocalOutputSink
  580. root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
  581. items = _load_demand_items(execution_id=execution_id, log_prefix="local_json")
  582. normalized_items = []
  583. names: list[str] = []
  584. for di in items:
  585. normalized = dict(di)
  586. normalized["evidence_refs"] = _normalize_evidence_refs(di.get("evidence_refs"))
  587. normalized_items.append(normalized)
  588. name = _join_element_names_to_name(di.get("element_names"))
  589. if name:
  590. names.append(name)
  591. sink = LocalOutputSink(root)
  592. sink.write_demand_items(normalized_items)
  593. sink.write_rejected_demand_items([])
  594. sink.write_demand_content([])
  595. sink.write_dwd_multi_demand_pool_di([])
  596. sink.write_feature_point_data_from_names(names)
  597. log(
  598. "[local_json] 写入本地 feature_point_data 完成,"
  599. f"rows={len(names)}, merge_leve2={merge_level2}, root={root}"
  600. )
  601. return len(names)
  602. def _write_local_run_manifest(
  603. *,
  604. execution_id: int,
  605. merge_level2: str,
  606. platform_type: Optional[str],
  607. count: int,
  608. task_id: Optional[int],
  609. trace_id: Optional[str],
  610. final_text: str,
  611. task_status: int,
  612. total_tokens: int,
  613. total_cost: float,
  614. result_write_count: int,
  615. log_file_path: Path,
  616. result_write_error: Optional[str] = None,
  617. ) -> None:
  618. if not _is_local_json_mode():
  619. return
  620. root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
  621. manifest = {
  622. "output_mode": LOCAL_JSON_MODE,
  623. "execution_id": execution_id,
  624. "merge_leve2": merge_level2,
  625. "platform_type": platform_type,
  626. "count": count,
  627. "task_id": task_id,
  628. "trace_id": trace_id,
  629. "status": task_status,
  630. "total_tokens": total_tokens,
  631. "total_cost": total_cost,
  632. "result_write_count": result_write_count,
  633. "result_write_error": result_write_error,
  634. "final_text_length": len(final_text or ""),
  635. "paths": {
  636. "root": str(root),
  637. "result": str(_get_result_base_dir()),
  638. "trace": str(_get_trace_store_base_dir()),
  639. "output": str(_get_output_base_dir()),
  640. "demand_items_raw": str(_demand_items_output_path(execution_id)),
  641. "log_file": str(log_file_path),
  642. },
  643. "created_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
  644. }
  645. _write_json_file(root / "run_manifest.json", manifest)
  646. async def run_once(
  647. execution_id,
  648. merge_level2,
  649. count: int = 30,
  650. task_id: Optional[int] = None,
  651. platform_type: Optional[str] = None,
  652. demand_scope: Optional[dict[str, Any]] = None,
  653. ) -> dict:
  654. from agent.core.runner import AgentRunner
  655. from agent.llm import create_openrouter_llm_call
  656. from agent.llm.prompts import SimplePrompt
  657. from agent.trace import FileSystemTraceStore, Message, Trace
  658. from agent.utils import setup_logging
  659. from examples.demand.config import DEBUG, ENABLED_TOOLS, LOG_FILE, LOG_LEVEL, RUN_CONFIG
  660. task_log_text = ""
  661. task_status = 0
  662. result_write_count = 0
  663. result_write_error: Optional[str] = None
  664. if _is_local_json_mode():
  665. _ensure_local_output_paths(execution_id)
  666. if _is_local_json_mode() or _is_mysql_demand_content_mode():
  667. _ensure_pg_weight_score_files(int(execution_id))
  668. platform_type = _resolve_run_platform(platform_type, demand_scope)
  669. TopicBuildAgentContext.set_execution_id(execution_id)
  670. TopicBuildAgentContext.set_metadata("result_base_dir", str(_get_result_base_dir()))
  671. TopicBuildAgentContext.set_metadata("merge_leve2", merge_level2)
  672. TopicBuildAgentContext.set_metadata("platform", platform_type)
  673. resolved_demand_scope = dict(demand_scope or {})
  674. resolved_demand_scope.setdefault("scope_source", "manual_cli")
  675. resolved_demand_scope.setdefault("merge_leve2", merge_level2)
  676. resolved_demand_scope["platform"] = _resolve_run_platform(platform_type, resolved_demand_scope)
  677. resolved_demand_scope.setdefault("pattern_execution_id", int(execution_id))
  678. TopicBuildAgentContext.set_metadata("demand_scope", resolved_demand_scope)
  679. base_dir = Path(__file__).parent
  680. output_dir = _get_output_base_dir()
  681. output_dir.mkdir(parents=True, exist_ok=True)
  682. setup_logging(level=LOG_LEVEL, file=LOG_FILE)
  683. enabled_tools = _enabled_tools_for_run(ENABLED_TOOLS)
  684. register_selected_tools(enabled_tools)
  685. prompt_file = "demand_mysql.md" if _is_mysql_demand_content_mode() else "demand.md"
  686. prompt = SimplePrompt(base_dir / prompt_file)
  687. run_config = copy.deepcopy(RUN_CONFIG)
  688. model = resolve_model(prompt, run_config)
  689. run_config.model = model
  690. run_config.temperature = float(prompt.config.get("temperature", run_config.temperature))
  691. run_config.max_iterations = int(prompt.config.get("max_iterations", run_config.max_iterations))
  692. run_config.tools = enabled_tools
  693. # 禁用反思/总结经验相关流程(避免进入 reflection 侧分支)
  694. run_config.enable_research_flow = False
  695. run_config.goal_compression = "none"
  696. run_config.force_side_branch = None
  697. run_config.knowledge.enable_extraction = False
  698. run_config.knowledge.enable_completion_extraction = False
  699. run_config.knowledge.enable_injection = False
  700. run_config.trace_id = None
  701. initial_messages = prompt.build_messages(merge_level2=merge_level2, count=count)
  702. trace_store_path = _get_trace_store_base_dir()
  703. store = FileSystemTraceStore(base_path=str(trace_store_path))
  704. runner = AgentRunner(
  705. trace_store=store,
  706. llm_call=create_openrouter_llm_call(model=model),
  707. skills_dir=None,
  708. debug=DEBUG,
  709. )
  710. final_text = ""
  711. total_tokens = 0
  712. total_cost = 0.0
  713. trace_id: Optional[str] = None
  714. has_completed_trace_cost = False
  715. log_file_path = output_dir / f"{execution_id}" / f"run_log_{execution_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
  716. log_file_path.parent.mkdir(parents=True, exist_ok=True)
  717. try:
  718. with build_log(execution_id) as log_buffer:
  719. async for item in runner.run(messages=initial_messages, config=run_config):
  720. if isinstance(item, Trace):
  721. trace_id = getattr(item, "trace_id", None) or trace_id
  722. if getattr(item, "status", None) == "completed":
  723. total_tokens = int(getattr(item, "total_tokens", 0) or 0)
  724. total_cost = float(getattr(item, "total_cost", 0.0) or 0.0)
  725. has_completed_trace_cost = True
  726. continue
  727. elif isinstance(item, Message):
  728. text = extract_assistant_text(item)
  729. if text:
  730. final_text = text
  731. log(f"[assistant] {text}")
  732. if not has_completed_trace_cost:
  733. total_tokens += int(getattr(item, "total_tokens", 0) or 0)
  734. total_cost += float(getattr(item, "cost", 0.0) or 0.0)
  735. if final_text:
  736. output_file = output_dir / f"{execution_id}" / "result.txt"
  737. output_file.parent.mkdir(parents=True, exist_ok=True)
  738. with open(output_file, "w", encoding="utf-8") as f:
  739. f.write(final_text)
  740. log(f"[cost] total_tokens={total_tokens}, total_cost=${total_cost:.6f}")
  741. # agent 执行完成后:全局树写 Hive,其他写 MySQL
  742. try:
  743. if str(merge_level2).strip() == "全局树":
  744. if _is_local_json_mode():
  745. result_write_count = write_global_tree_demand_items_to_local_json(
  746. execution_id=execution_id,
  747. merge_level2=merge_level2,
  748. trace_id=trace_id,
  749. task_id=task_id,
  750. )
  751. else:
  752. result_write_count = write_global_tree_demand_items_to_hive(
  753. execution_id=execution_id,
  754. merge_level2=merge_level2,
  755. )
  756. else:
  757. # element_names -> name(逗号分隔);reason -> demand_content.reason;desc -> demand_content.suggestion;dt -> demand_content.dt
  758. if _is_local_json_mode():
  759. result_write_count = write_demand_items_to_local_json(
  760. execution_id=execution_id,
  761. merge_level2=merge_level2,
  762. trace_id=trace_id,
  763. task_id=task_id,
  764. demand_scope=resolved_demand_scope,
  765. )
  766. else:
  767. result_write_count = write_demand_items_to_mysql(
  768. execution_id=execution_id,
  769. merge_level2=merge_level2,
  770. trace_id=trace_id,
  771. task_id=task_id,
  772. demand_scope=resolved_demand_scope,
  773. )
  774. except Exception as e:
  775. result_write_error = str(e)
  776. log(f"[result-write] 写入结果异常:{e}")
  777. if _is_local_json_mode():
  778. task_status = 2
  779. task_log_text = log_buffer.getvalue()
  780. if task_status != 2:
  781. task_status = 1
  782. except Exception as e:
  783. if not task_log_text:
  784. # 如果异常发生在 build_log 内部,尽量回收已产生的日志
  785. try:
  786. existing = locals().get("log_buffer")
  787. if existing is not None:
  788. task_log_text = existing.getvalue() # type: ignore[attr-defined]
  789. except Exception:
  790. pass
  791. if not task_log_text:
  792. task_log_text = f"[run] 执行异常: {e}"
  793. task_status = 2
  794. raise
  795. finally:
  796. if task_log_text:
  797. try:
  798. with open(log_file_path, "w", encoding="utf-8") as f:
  799. f.write(task_log_text)
  800. except Exception:
  801. # 兜底:即使写文件失败,也要确保 MySQL 状态被更新
  802. pass
  803. if _is_local_json_mode():
  804. _finish_local_demand_task(task_id=task_id, status=task_status, task_log=task_log_text)
  805. elif not _is_mysql_demand_content_mode():
  806. _finish_demand_task(task_id=task_id, status=task_status, task_log=task_log_text)
  807. _write_local_run_manifest(
  808. execution_id=execution_id,
  809. merge_level2=merge_level2,
  810. platform_type=platform_type,
  811. count=int(count),
  812. task_id=task_id,
  813. trace_id=trace_id,
  814. final_text=final_text,
  815. task_status=task_status,
  816. total_tokens=total_tokens,
  817. total_cost=total_cost,
  818. result_write_count=result_write_count,
  819. log_file_path=log_file_path,
  820. result_write_error=result_write_error,
  821. )
  822. return {
  823. "final_text": final_text,
  824. "trace_id": trace_id,
  825. "output_dir": str(output_dir / f"{execution_id}"),
  826. "trace_store_path": str(trace_store_path),
  827. "result_base_dir": str(_get_result_base_dir()),
  828. "result_write_count": result_write_count,
  829. }
  830. async def main(
  831. cluster_name: str,
  832. platform_type: str,
  833. count,
  834. execution_id: Optional[int] = None,
  835. task_id: Optional[int] = None,
  836. demand_scope: Optional[dict[str, Any]] = None,
  837. ) -> dict:
  838. if not (_is_local_json_mode() or _is_mysql_demand_content_mode()):
  839. _raise_db_write_disabled("非受控运行入口")
  840. if _is_local_json_mode():
  841. _require_local_entrypoint()
  842. if execution_id is None:
  843. raise ValueError("local_json 模式必须传入已有 execution_id,不能触发 prepare 写库流程")
  844. _ensure_local_output_paths(execution_id)
  845. if _is_mysql_demand_content_mode():
  846. _require_mysql_entrypoint()
  847. if execution_id is None:
  848. raise ValueError("mysql_demand_content 模式必须传入已有 execution_id,不能触发 prepare 写库流程")
  849. if str(cluster_name).strip() == "全局树":
  850. raise ValueError("mysql_demand_content 模式只写普通 demand_content,不支持全局树 Hive 输出")
  851. platform_type = _resolve_run_platform(platform_type, demand_scope)
  852. if execution_id is None:
  853. execution_id = get_execution_id_by_merge_level2(cluster_name)
  854. if not execution_id:
  855. return {"execution_id": None, "trace_id": None, "final_text": ""}
  856. if _is_local_json_mode():
  857. task_id = _start_local_demand_task(
  858. execution_id=int(execution_id),
  859. task_id=task_id,
  860. name=cluster_name,
  861. platform=platform_type,
  862. )
  863. run_result = await run_once(
  864. execution_id,
  865. cluster_name,
  866. count=count,
  867. task_id=task_id,
  868. platform_type=platform_type,
  869. demand_scope=demand_scope,
  870. )
  871. result = {
  872. "execution_id": execution_id,
  873. "trace_id": run_result.get("trace_id"),
  874. "final_text": run_result.get("final_text", ""),
  875. "output_dir": run_result.get("output_dir"),
  876. "trace_store_path": run_result.get("trace_store_path"),
  877. "result_base_dir": run_result.get("result_base_dir"),
  878. "result_write_count": run_result.get("result_write_count", 0),
  879. }
  880. if _is_local_json_mode():
  881. root = _get_local_output_root()
  882. result["local_output_root"] = str(root) if root else None
  883. return result
  884. if __name__ == "__main__":
  885. asyncio.run(main('全局树', 'piaoquan', 50))
  886. # write_demand_items_to_mysql(execution_id=8, merge_level2='贪污腐败')