grade_demand_pool.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. """计划组落库后执行分级任务(默认定时任务路径为代码自动分配,非统筹 Agent)。"""
  2. from __future__ import annotations
  3. import logging
  4. from concurrent.futures import ThreadPoolExecutor, as_completed
  5. from datetime import datetime
  6. from typing import Any
  7. from zoneinfo import ZoneInfo
  8. from agents.demand_grade_agent.run import main as grade_demand_words
  9. from supply_infra.config import get_infra_settings
  10. from supply_infra.scheduler.auto_assign_grade_plan import auto_assign_daily_grade_plan
  11. from supply_infra.db.repositories.demand_grade_plan_repo import DemandGradePlanRepository
  12. from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
  13. from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
  14. from supply_infra.db.session import get_session
  15. from supply_infra.scheduler.plan_group_batch import MAX_DEMANDS_PER_BATCH, split_even_batches
  16. logger = logging.getLogger(__name__)
  17. _DEFAULT_WORKERS = 5
  18. def _resolve_biz_dt(biz_dt: str | None) -> str:
  19. if biz_dt:
  20. return biz_dt
  21. return datetime.now(ZoneInfo(get_infra_settings().scheduler_timezone)).strftime("%Y%m%d")
  22. def _materialize_pending_group_items(biz_dt: str) -> int:
  23. """执行前为 pending 计划组物化待分级需求明细。"""
  24. with get_session() as session:
  25. graded_names = DemandGradeRepository(session).get_existing_demand_names(biz_dt)
  26. return DemandGradePlanRepository(session).materialize_pending_groups(
  27. biz_dt,
  28. graded_names=graded_names,
  29. )
  30. def _group_success(item_summary: dict[str, int], processed_batches: int) -> bool:
  31. if item_summary.get("pending", 0) > 0:
  32. return False
  33. total = sum(item_summary.values())
  34. if total == 0:
  35. return True
  36. if processed_batches > 0:
  37. return True
  38. return item_summary.get("failed", 0) == 0 and (
  39. item_summary.get("finished", 0) > 0 or item_summary.get("skipped", 0) > 0
  40. )
  41. def _run_group(biz_dt: str, group_id: int, *, max_demands_per_batch: int) -> int:
  42. """领取并执行一个固定任务,从组内需求明细表按批读取。"""
  43. with get_session() as session:
  44. group = DemandGradePlanRepository(session).claim_group(biz_dt, group_id)
  45. if group is None:
  46. logger.warning("分级任务已被其他 worker 领取或状态已变化: biz_dt=%s group_id=%s", biz_dt, group_id)
  47. return 0
  48. gid = int(group["id"])
  49. processed_batches = 0
  50. batch_errors: list[str] = []
  51. try:
  52. with get_session() as session:
  53. items = DemandGradePlanRepository(session).list_pending_group_items(gid)
  54. for batch_items in split_even_batches(items, max_per_batch=max_demands_per_batch):
  55. demands = [
  56. {"pool_id": item["pool_id"], "demand_name": item["demand_name"]}
  57. for item in batch_items
  58. ]
  59. item_ids = [int(item["item_id"]) for item in batch_items]
  60. try:
  61. grade_demand_words(demands, biz_dt=biz_dt)
  62. with get_session() as session:
  63. DemandGradePlanRepository(session).mark_group_items_status(
  64. item_ids,
  65. status="finished",
  66. )
  67. processed_batches += 1
  68. except Exception as exc:
  69. logger.exception(
  70. "分级子批次失败,跳过并继续本组其余需求: biz_dt=%s group=%s count=%s",
  71. biz_dt,
  72. group["group_key"],
  73. len(demands),
  74. )
  75. batch_errors.append(str(exc))
  76. with get_session() as session:
  77. DemandGradePlanRepository(session).mark_group_items_status(
  78. item_ids,
  79. status="failed",
  80. error_message=str(exc),
  81. )
  82. with get_session() as session:
  83. repo = DemandGradePlanRepository(session)
  84. item_summary = repo.summarize_group_items(gid)
  85. repo.finish_group(
  86. gid,
  87. success=_group_success(item_summary, processed_batches),
  88. error_message="; ".join(batch_errors) if batch_errors else None,
  89. )
  90. except Exception as exc:
  91. logger.exception(
  92. "分级计划任务失败: biz_dt=%s group=%s",
  93. biz_dt,
  94. group["group_key"],
  95. )
  96. try:
  97. with get_session() as session:
  98. DemandGradePlanRepository(session).finish_group(
  99. gid,
  100. success=False,
  101. error_message=str(exc),
  102. )
  103. except Exception:
  104. logger.exception(
  105. "记录分级计划任务失败状态时发生错误: biz_dt=%s group=%s",
  106. biz_dt,
  107. group["group_key"],
  108. )
  109. return 0
  110. return processed_batches
  111. def _execute_plan_tasks(
  112. biz_dt: str,
  113. *,
  114. workers: int,
  115. max_demands_per_batch: int,
  116. ) -> dict[str, Any]:
  117. """并发执行当天全部 pending 计划任务,仅执行一轮。"""
  118. with get_session() as session:
  119. group_ids = DemandGradePlanRepository(session).list_pending_group_ids(biz_dt)
  120. if not group_ids:
  121. with get_session() as session:
  122. final_snapshot = DemandGradePlanRepository(session).get_execution_snapshot(biz_dt)
  123. return {
  124. "attempted_groups": 0,
  125. "groups_run": 0,
  126. "workers": 0,
  127. "final_snapshot": final_snapshot,
  128. "execution_complete": final_snapshot["execution_complete"],
  129. }
  130. worker_count = max(1, min(int(workers), len(group_ids)))
  131. groups_run = 0
  132. with ThreadPoolExecutor(max_workers=worker_count) as executor:
  133. futures = [
  134. executor.submit(_run_group, biz_dt, group_id, max_demands_per_batch=max_demands_per_batch)
  135. for group_id in group_ids
  136. ]
  137. for future in as_completed(futures):
  138. try:
  139. groups_run += future.result()
  140. except Exception:
  141. logger.exception("分级任务 worker 出现未捕获错误: biz_dt=%s", biz_dt)
  142. with get_session() as session:
  143. final_snapshot = DemandGradePlanRepository(session).get_execution_snapshot(biz_dt)
  144. return {
  145. "attempted_groups": len(group_ids),
  146. "groups_run": groups_run,
  147. "workers": worker_count,
  148. "final_snapshot": final_snapshot,
  149. "execution_complete": final_snapshot["execution_complete"],
  150. }
  151. def execute_plan_tasks_until_complete(
  152. biz_dt: str,
  153. *,
  154. workers: int,
  155. max_demands_per_batch: int = MAX_DEMANDS_PER_BATCH,
  156. max_rounds: int = 0,
  157. ) -> dict[str, Any]:
  158. """循环执行 pending 计划任务,直到全部完成或达到 max_rounds。"""
  159. rounds: list[dict[str, Any]] = []
  160. groups_run = 0
  161. round_no = 0
  162. while True:
  163. if max_rounds > 0 and round_no >= max_rounds:
  164. break
  165. round_no += 1
  166. result = _execute_plan_tasks(
  167. biz_dt,
  168. workers=workers,
  169. max_demands_per_batch=max_demands_per_batch,
  170. )
  171. rounds.append(result)
  172. groups_run += int(result.get("groups_run") or 0)
  173. if int(result.get("attempted_groups") or 0) == 0:
  174. break
  175. final_snapshot = rounds[-1]["final_snapshot"] if rounds else None
  176. if final_snapshot is None:
  177. with get_session() as session:
  178. final_snapshot = DemandGradePlanRepository(session).get_execution_snapshot(biz_dt)
  179. return {
  180. "rounds": round_no,
  181. "groups_run": groups_run,
  182. "workers": max(1, int(workers)),
  183. "round_details": rounds,
  184. "final_snapshot": final_snapshot,
  185. "execution_complete": bool(final_snapshot["execution_complete"]),
  186. }
  187. def _grade_demand_pool_impl(
  188. resolved_biz_dt: str,
  189. *,
  190. workers: int,
  191. max_demands_per_batch: int = MAX_DEMANDS_PER_BATCH,
  192. with_orchestrate: bool = True,
  193. max_rounds: int = 0,
  194. ) -> dict[str, Any]:
  195. with get_session() as session:
  196. total = MultiDemandPoolDiRepository(session).count_distinct_demand_names(resolved_biz_dt)
  197. graded_before = DemandGradeRepository(session).count_by_biz_dt(resolved_biz_dt)
  198. if with_orchestrate:
  199. try:
  200. auto_assign_daily_grade_plan(
  201. biz_dt=resolved_biz_dt,
  202. max_demands_per_group=max_demands_per_batch,
  203. )
  204. except Exception:
  205. logger.exception("自动分配计划组失败,继续处理数据库中已有任务: biz_dt=%s", resolved_biz_dt)
  206. materialized = _materialize_pending_group_items(resolved_biz_dt)
  207. logger.info("物化计划组需求明细: biz_dt=%s items=%s", resolved_biz_dt, materialized)
  208. plan_execution = execute_plan_tasks_until_complete(
  209. resolved_biz_dt,
  210. workers=max(1, int(workers)),
  211. max_demands_per_batch=max(1, min(int(max_demands_per_batch), MAX_DEMANDS_PER_BATCH)),
  212. max_rounds=max_rounds,
  213. )
  214. with get_session() as session:
  215. graded_after = DemandGradeRepository(session).count_by_biz_dt(resolved_biz_dt)
  216. final_snapshot = plan_execution["final_snapshot"]
  217. result = {
  218. "success": bool(plan_execution["execution_complete"]),
  219. "biz_dt": resolved_biz_dt,
  220. "total": total,
  221. "graded_before": graded_before,
  222. "graded_after": graded_after,
  223. "materialized_items": materialized,
  224. "planned_category_count": len(final_snapshot["assigned_category_ids"]),
  225. "planned_groups": final_snapshot["planned_groups"],
  226. "group_status": final_snapshot["group_status"],
  227. "plan_execution": plan_execution,
  228. "workers": plan_execution["workers"],
  229. "groups_run": plan_execution["groups_run"],
  230. "run_at": datetime.now().isoformat(),
  231. }
  232. logger.info("Grade demand pool completed: %s", result)
  233. return result
  234. def retry_failed_plan_group_items(
  235. biz_dt: str | None = None,
  236. *,
  237. workers: int = _DEFAULT_WORKERS,
  238. max_demands_per_batch: int = MAX_DEMANDS_PER_BATCH,
  239. group_ids: list[int] | None = None,
  240. dry_run: bool = False,
  241. ) -> dict[str, Any]:
  242. """将 demand_grade_plan_group_item 中 failed 记录重置后重新执行分级。"""
  243. resolved_biz_dt = _resolve_biz_dt(biz_dt)
  244. try:
  245. with get_session() as session:
  246. failed_items = DemandGradePlanRepository(session).list_failed_group_items(
  247. resolved_biz_dt,
  248. group_ids=group_ids,
  249. )
  250. if not failed_items:
  251. with get_session() as session:
  252. snapshot = DemandGradePlanRepository(session).get_execution_snapshot(resolved_biz_dt)
  253. return {
  254. "success": True,
  255. "biz_dt": resolved_biz_dt,
  256. "dry_run": dry_run,
  257. "failed_items": 0,
  258. "reset": {"reset_items": 0, "reset_groups": 0, "group_ids": [], "items": []},
  259. "execution": None,
  260. "final_snapshot": snapshot,
  261. "run_at": datetime.now().isoformat(),
  262. }
  263. if dry_run:
  264. affected_group_ids = sorted({int(item["group_id"]) for item in failed_items})
  265. return {
  266. "success": True,
  267. "biz_dt": resolved_biz_dt,
  268. "dry_run": True,
  269. "failed_items": len(failed_items),
  270. "reset": {
  271. "reset_items": len(failed_items),
  272. "reset_groups": len(affected_group_ids),
  273. "group_ids": affected_group_ids,
  274. "items": failed_items,
  275. },
  276. "execution": None,
  277. "run_at": datetime.now().isoformat(),
  278. }
  279. with get_session() as session:
  280. reset_result = DemandGradePlanRepository(session).reset_failed_items_to_pending(
  281. resolved_biz_dt,
  282. group_ids=group_ids,
  283. )
  284. with get_session() as session:
  285. graded_before = DemandGradeRepository(session).count_by_biz_dt(resolved_biz_dt)
  286. plan_execution = execute_plan_tasks_until_complete(
  287. resolved_biz_dt,
  288. workers=max(1, int(workers)),
  289. max_demands_per_batch=max(1, min(int(max_demands_per_batch), MAX_DEMANDS_PER_BATCH)),
  290. )
  291. with get_session() as session:
  292. graded_after = DemandGradeRepository(session).count_by_biz_dt(resolved_biz_dt)
  293. remaining_failed = DemandGradePlanRepository(session).list_failed_group_items(
  294. resolved_biz_dt,
  295. group_ids=group_ids,
  296. )
  297. final_snapshot = plan_execution["final_snapshot"]
  298. return {
  299. "success": len(remaining_failed) == 0,
  300. "biz_dt": resolved_biz_dt,
  301. "dry_run": False,
  302. "failed_items": len(failed_items),
  303. "reset": reset_result,
  304. "graded_before": graded_before,
  305. "graded_after": graded_after,
  306. "remaining_failed": len(remaining_failed),
  307. "execution": plan_execution,
  308. "final_snapshot": final_snapshot,
  309. "group_status": final_snapshot["group_status"],
  310. "run_at": datetime.now().isoformat(),
  311. }
  312. except Exception as exc:
  313. logger.exception(
  314. "重试 failed plan group items 发生未捕获错误: biz_dt=%s",
  315. resolved_biz_dt,
  316. )
  317. return {
  318. "success": False,
  319. "biz_dt": resolved_biz_dt,
  320. "error": str(exc),
  321. "run_at": datetime.now().isoformat(),
  322. }
  323. def grade_demand_pool(
  324. biz_dt: str | None = None,
  325. *,
  326. workers: int = _DEFAULT_WORKERS,
  327. max_demands_per_batch: int = MAX_DEMANDS_PER_BATCH,
  328. with_orchestrate: bool = True,
  329. max_rounds: int = 0,
  330. ) -> dict[str, Any]:
  331. """执行完整分级闭环;任何错误只记录日志并返回,不向上中断定时任务。"""
  332. resolved_biz_dt = str(biz_dt or "")
  333. try:
  334. resolved_biz_dt = _resolve_biz_dt(biz_dt)
  335. return _grade_demand_pool_impl(
  336. resolved_biz_dt,
  337. workers=workers,
  338. max_demands_per_batch=max_demands_per_batch,
  339. with_orchestrate=with_orchestrate,
  340. max_rounds=max_rounds,
  341. )
  342. except Exception as exc:
  343. logger.exception("需求分级任务发生未捕获错误,已阻止异常中断定时任务: biz_dt=%s", resolved_biz_dt)
  344. return {
  345. "success": False,
  346. "biz_dt": resolved_biz_dt,
  347. "error": str(exc),
  348. "run_at": datetime.now().isoformat(),
  349. }
  350. if __name__ == "__main__":
  351. import sys
  352. from supply_infra.scheduler.cli_result import run_cli
  353. _argv = [a for a in sys.argv[1:] if a != "--retry-failed"]
  354. _retry_failed = "--retry-failed" in sys.argv[1:]
  355. _biz_dt = _argv[0] if len(_argv) > 0 else None
  356. _workers = int(_argv[1]) if len(_argv) > 1 else 5
  357. def _main() -> dict[str, Any]:
  358. if _retry_failed:
  359. return retry_failed_plan_group_items(_biz_dt, workers=_workers)
  360. return grade_demand_pool(
  361. _biz_dt,
  362. workers=_workers,
  363. with_orchestrate=True,
  364. )
  365. run_cli(_main, label="grade_demand_pool")