service.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. """Transactional service for the isolated ``find_agent_v2_*`` tables."""
  2. from __future__ import annotations
  3. import json
  4. import uuid
  5. from dataclasses import asdict
  6. from decimal import Decimal
  7. from typing import Any
  8. from sqlalchemy import func, select
  9. from find_agent_v2.models import (
  10. FindAgentV2Candidate,
  11. FindAgentV2Evidence,
  12. FindAgentV2Round,
  13. FindAgentV2Run,
  14. FindAgentV2Search,
  15. )
  16. from find_agent_v2.state import DiscoverySnapshot
  17. from supply_infra.db.session import get_session
  18. from find_agent_v2.gates import (
  19. build_rule_snapshot,
  20. evaluate_candidate_gate,
  21. parse_datetime_value,
  22. )
  23. class FindAgentV2RunNotFound(LookupError):
  24. pass
  25. def _json(value: Any) -> str:
  26. return json.dumps(value, ensure_ascii=False, default=str)
  27. def _loads(value: str | None, default: Any) -> Any:
  28. try:
  29. return json.loads(value) if value else default
  30. except (TypeError, ValueError):
  31. return default
  32. def _ratio(value: Any) -> Decimal | None:
  33. if value in (None, "") or isinstance(value, bool):
  34. return None
  35. number = Decimal(str(value))
  36. if number > 1 and number <= 100:
  37. number /= 100
  38. if number < 0 or number > 1:
  39. raise ValueError("ratio 必须在 0~1")
  40. return number.quantize(Decimal("0.000001"))
  41. def _candidate_dict(row: FindAgentV2Candidate) -> dict[str, Any]:
  42. return {
  43. "candidate_id": int(row.id),
  44. "aweme_id": row.aweme_id,
  45. "title": row.title,
  46. "content_link": row.content_link,
  47. "author_name": row.author_name,
  48. "author_sec_uid": row.author_sec_uid,
  49. "source_keywords": _loads(row.source_keywords_json, []),
  50. "tags": _loads(row.tags_json, []),
  51. "publish_at": row.publish_at.isoformat() if row.publish_at else None,
  52. "duration_seconds": float(row.duration_seconds) if row.duration_seconds is not None else None,
  53. "play_count": row.play_count,
  54. "like_count": row.like_count,
  55. "comment_count": row.comment_count,
  56. "collect_count": row.collect_count,
  57. "share_count": row.share_count,
  58. "detail_status": row.detail_status,
  59. "portrait_status": row.portrait_status,
  60. "content_50_plus_ratio": float(row.content_50_plus_ratio) if row.content_50_plus_ratio is not None else None,
  61. "account_50_plus_ratio": float(row.account_50_plus_ratio) if row.account_50_plus_ratio is not None else None,
  62. "relevance_score": float(row.relevance_score) if row.relevance_score is not None else None,
  63. "elder_score": float(row.elder_score) if row.elder_score is not None else None,
  64. "share_score": float(row.share_score) if row.share_score is not None else None,
  65. "value_score": float(row.value_score) if row.value_score is not None else None,
  66. "gate_status": row.gate_status,
  67. "gate_result": _loads(row.gate_result_json, {}),
  68. "decision_bucket": row.decision_bucket,
  69. "decision_reason": row.decision_reason,
  70. "reject_reason_code": row.reject_reason_code,
  71. }
  72. class FindAgentV2Service:
  73. def create_run(
  74. self,
  75. *,
  76. user_input: str,
  77. demand_word: str,
  78. demand_grade_id: int | None = None,
  79. run_id: str | None = None,
  80. rule_config: dict[str, Any] | None = None,
  81. ) -> str:
  82. run_key = str(run_id or uuid.uuid4().hex)[:64]
  83. with get_session() as session:
  84. exists = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_key))
  85. if exists is not None:
  86. raise ValueError(f"find_agent_v2 run_id 已存在: {run_key}")
  87. rules = rule_config or build_rule_snapshot()
  88. session.add(FindAgentV2Run(
  89. run_id=run_key,
  90. demand_grade_id=demand_grade_id,
  91. demand_word=str(demand_word)[:256],
  92. input_json=_json({"user_input": user_input}),
  93. rule_config_json=_json(rules),
  94. status="running",
  95. ))
  96. return run_key
  97. def lookup_run(self, run_id: str) -> dict[str, Any] | None:
  98. with get_session() as session:
  99. row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  100. if row is None:
  101. return None
  102. return {
  103. "run_id": row.run_id,
  104. "demand_grade_id": row.demand_grade_id,
  105. "demand_word": row.demand_word,
  106. "status": row.status,
  107. "outcome_status": row.outcome_status,
  108. "current_round": row.current_round,
  109. "search_count": row.search_count,
  110. "candidate_count": row.candidate_count,
  111. "valid_primary_count": row.valid_primary_count,
  112. "input_tokens": row.input_tokens,
  113. "output_tokens": row.output_tokens,
  114. "total_tokens": row.total_tokens,
  115. "cost_usd": float(row.cost_usd or 0),
  116. "intent_summary": row.intent_summary,
  117. "stop_reason": row.stop_reason,
  118. "obagent_run_uid": row.obagent_run_uid,
  119. "rule_config": _loads(row.rule_config_json, {}),
  120. }
  121. def set_obagent_run_uid(self, run_id: str, run_uid: str | None) -> None:
  122. if not run_uid:
  123. return
  124. with get_session() as session:
  125. row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  126. if row is None:
  127. raise FindAgentV2RunNotFound(run_id)
  128. row.obagent_run_uid = str(run_uid)[:64]
  129. def add_usage(self, run_id: str, usage: dict[str, Any]) -> None:
  130. """Accumulate one execution attempt; resume never erases earlier usage."""
  131. with get_session() as session:
  132. row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  133. if row is None:
  134. raise FindAgentV2RunNotFound(run_id)
  135. row.input_tokens += int(usage.get("input_tokens") or 0)
  136. row.output_tokens += int(usage.get("output_tokens") or 0)
  137. row.total_tokens += int(usage.get("total_tokens") or 0)
  138. row.cost_usd = (
  139. Decimal(str(row.cost_usd or 0)) + Decimal(str(usage.get("cost") or 0))
  140. ).quantize(Decimal("0.00000001"))
  141. def prepare_resume(self, run_id: str) -> dict[str, Any]:
  142. """Reset a terminal run for a recovery round without deleting audit rows."""
  143. with get_session() as session:
  144. row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  145. if row is None:
  146. raise FindAgentV2RunNotFound(run_id)
  147. if row.status == "running":
  148. raise ValueError(f"run_id={run_id} 已处于 running,不能重复 resume")
  149. row.status = "running"
  150. row.outcome_status = None
  151. row.stop_reason = None
  152. return self.require_run(run_id)
  153. def require_run(self, run_id: str) -> dict[str, Any]:
  154. run = self.lookup_run(run_id)
  155. if run is None:
  156. raise FindAgentV2RunNotFound(f"find_agent_v2 run_id 不存在: {run_id}")
  157. return run
  158. def get_run_user_input(self, run_id: str) -> str:
  159. """Return the immutable task input stored when a v2 run was prepared."""
  160. with get_session() as session:
  161. row = session.scalar(select(FindAgentV2Run).where(
  162. FindAgentV2Run.run_id == run_id
  163. ))
  164. if row is None:
  165. raise FindAgentV2RunNotFound(run_id)
  166. user_input = _loads(row.input_json, {}).get("user_input")
  167. if not isinstance(user_input, str) or not user_input.strip():
  168. raise ValueError(f"run_id={run_id} 缺少有效 user_input")
  169. return user_input
  170. def begin_round(self, run_id: str, round_index: int, snapshot: DiscoverySnapshot) -> None:
  171. with get_session() as session:
  172. run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  173. if run is None:
  174. raise FindAgentV2RunNotFound(run_id)
  175. run.current_round = int(round_index)
  176. session.add(FindAgentV2Round(
  177. run_id=run_id,
  178. round_index=int(round_index),
  179. phase="planning",
  180. status="open",
  181. start_snapshot_json=_json(asdict(snapshot)),
  182. ))
  183. def update_round(
  184. self,
  185. run_id: str,
  186. round_index: int,
  187. *,
  188. phase: str | None = None,
  189. plan: str | None = None,
  190. status: str | None = None,
  191. snapshot: DiscoverySnapshot | None = None,
  192. error: str | None = None,
  193. ) -> None:
  194. with get_session() as session:
  195. row = session.scalar(select(FindAgentV2Round).where(
  196. FindAgentV2Round.run_id == run_id,
  197. FindAgentV2Round.round_index == int(round_index),
  198. ))
  199. if row is None:
  200. raise FindAgentV2RunNotFound(f"round 不存在: {run_id}/{round_index}")
  201. if phase is not None:
  202. row.phase = phase
  203. if plan is not None:
  204. row.plan_json = plan
  205. if status is not None:
  206. row.status = status
  207. if snapshot is not None:
  208. row.end_snapshot_json = _json(asdict(snapshot))
  209. if error is not None:
  210. row.error_message = error[:2000]
  211. def save_search(
  212. self,
  213. *,
  214. run_id: str,
  215. round_index: int,
  216. keyword: str,
  217. query_reason: str,
  218. source_type: str,
  219. provider: str,
  220. cursor: str,
  221. page_no: int,
  222. payload: dict[str, Any],
  223. ) -> dict[str, Any]:
  224. results = list(payload.get("search_results") or [])
  225. with get_session() as session:
  226. run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  227. if run is None:
  228. raise FindAgentV2RunNotFound(run_id)
  229. search = FindAgentV2Search(
  230. run_id=run_id,
  231. round_index=round_index,
  232. keyword=keyword[:256],
  233. query_reason=query_reason,
  234. source_type=source_type[:32],
  235. provider=provider[:32],
  236. cursor=str(cursor)[:128],
  237. page_no=page_no,
  238. has_more=int(bool(payload.get("has_more"))),
  239. next_cursor=str(payload.get("next_cursor") or "")[:128] or None,
  240. provider_state_json=_json({
  241. "search_id": payload.get("search_id"),
  242. "backtrace": payload.get("backtrace"),
  243. }),
  244. result_count=len(results),
  245. status="failed" if payload.get("error") else "success",
  246. error_message=str(payload.get("error") or "") or None,
  247. raw_response_json=_json(payload),
  248. )
  249. session.add(search)
  250. session.flush()
  251. new_count = 0
  252. for item in results:
  253. aweme_id = str(item.get("aweme_id") or "").strip()
  254. if not aweme_id:
  255. continue
  256. candidate = session.scalar(select(FindAgentV2Candidate).where(
  257. FindAgentV2Candidate.run_id == run_id,
  258. FindAgentV2Candidate.aweme_id == aweme_id,
  259. ))
  260. author = item.get("author") if isinstance(item.get("author"), dict) else {}
  261. stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
  262. if candidate is None:
  263. candidate = FindAgentV2Candidate(
  264. run_id=run_id,
  265. first_search_id=search.id,
  266. aweme_id=aweme_id,
  267. decision_bucket="pending_evaluation",
  268. )
  269. session.add(candidate)
  270. new_count += 1
  271. keywords = _loads(candidate.source_keywords_json, [])
  272. if keyword not in keywords:
  273. keywords.append(keyword)
  274. candidate.source_keywords_json = _json(keywords)
  275. candidate.title = str(item.get("desc") or item.get("title") or candidate.title or "")[:512] or None
  276. candidate.content_link = str(item.get("url") or candidate.content_link or "")[:1024] or None
  277. candidate.author_name = str(author.get("nickname") or candidate.author_name or "")[:256] or None
  278. candidate.author_sec_uid = str(author.get("sec_uid") or candidate.author_sec_uid or "")[:256] or None
  279. candidate.tags_json = _json(item.get("topics") or item.get("tags") or [])
  280. duration_ms = item.get("duration_ms")
  281. if duration_ms:
  282. candidate.duration_seconds = Decimal(str(duration_ms)) / 1000
  283. candidate.play_count = stats.get("play_count") or candidate.play_count
  284. candidate.like_count = stats.get("digg_count") or stats.get("like_count") or candidate.like_count
  285. candidate.comment_count = stats.get("comment_count") or candidate.comment_count
  286. candidate.collect_count = stats.get("collect_count") or candidate.collect_count
  287. candidate.share_count = stats.get("share_count") or candidate.share_count
  288. run.search_count = int(session.scalar(select(func.count()).select_from(FindAgentV2Search).where(FindAgentV2Search.run_id == run_id)) or 0)
  289. session.flush()
  290. run.candidate_count = int(session.scalar(select(func.count()).select_from(FindAgentV2Candidate).where(FindAgentV2Candidate.run_id == run_id)) or 0)
  291. return {"search_id": int(search.id), "new_candidate_count": new_count, "result_count": len(results)}
  292. def candidate_inputs(self, run_id: str, candidate_ids: list[int]) -> list[dict[str, Any]]:
  293. with get_session() as session:
  294. rows = list(session.scalars(select(FindAgentV2Candidate).where(
  295. FindAgentV2Candidate.run_id == run_id,
  296. FindAgentV2Candidate.id.in_([int(v) for v in candidate_ids]),
  297. )))
  298. return [_candidate_dict(row) for row in rows]
  299. def save_details(self, run_id: str, details: list[dict[str, Any]], errors: list[dict[str, Any]]) -> None:
  300. by_id = {str(item.get("content_id") or ""): item for item in details}
  301. error_by_id = {str(item.get("content_id") or ""): item for item in errors}
  302. with get_session() as session:
  303. rows = list(session.scalars(select(FindAgentV2Candidate).where(
  304. FindAgentV2Candidate.run_id == run_id,
  305. FindAgentV2Candidate.aweme_id.in_(list(by_id) + list(error_by_id)),
  306. )))
  307. for row in rows:
  308. detail = by_id.get(row.aweme_id)
  309. error = error_by_id.get(row.aweme_id)
  310. if detail:
  311. row.detail_status = "success"
  312. row.detail_json = _json(detail)
  313. row.title = str(detail.get("title") or detail.get("body_text") or row.title or "")[:512] or None
  314. row.content_link = str(detail.get("content_link") or row.content_link or "")[:1024] or None
  315. row.author_name = str(detail.get("channel_account_name") or row.author_name or "")[:256] or None
  316. row.author_sec_uid = str(detail.get("channel_account_id") or row.author_sec_uid or "")[:256] or None
  317. row.tags_json = _json(detail.get("topic_list") or [])
  318. parsed = parse_datetime_value(detail.get("publish_at"))
  319. row.publish_at = parsed.replace(tzinfo=None) if parsed else None
  320. row.duration_seconds = detail.get("duration_seconds") or None
  321. for key in ("play_count", "like_count", "comment_count", "collect_count", "share_count"):
  322. value = detail.get(key)
  323. if value is not None:
  324. setattr(row, key, value)
  325. status, raw = "success", detail
  326. else:
  327. row.detail_status = "failed"
  328. status, raw = "failed", error or {}
  329. session.add(FindAgentV2Evidence(
  330. run_id=run_id,
  331. candidate_id=row.id,
  332. evidence_type="detail",
  333. provider="crawler",
  334. status=status,
  335. raw_json=_json(raw),
  336. error_message=str((error or {}).get("error") or "") or None,
  337. ))
  338. def save_portraits(self, run_id: str, results: list[dict[str, Any]]) -> None:
  339. with get_session() as session:
  340. for item in results:
  341. aweme_id = str(item.get("aweme_id") or "")
  342. row = session.scalar(select(FindAgentV2Candidate).where(
  343. FindAgentV2Candidate.run_id == run_id,
  344. FindAgentV2Candidate.aweme_id == aweme_id,
  345. ))
  346. if row is None:
  347. continue
  348. normalization = item.get("age_normalization") or {}
  349. content = normalization.get("content") or {}
  350. account = normalization.get("account") or {}
  351. row.portrait_json = _json(item)
  352. row.portrait_status = "failed" if item.get("error") else "success"
  353. row.content_50_plus_ratio = _ratio(
  354. content.get("older_ratio") if content.get("has_age_portrait") else None
  355. )
  356. row.account_50_plus_ratio = _ratio(
  357. account.get("older_ratio") if account.get("has_age_portrait") else None
  358. )
  359. session.add(FindAgentV2Evidence(
  360. run_id=run_id,
  361. candidate_id=row.id,
  362. evidence_type="portrait",
  363. provider="douhot",
  364. status=row.portrait_status,
  365. raw_json=_json(item),
  366. normalized_json=_json(normalization),
  367. error_message=str(item.get("error") or "") or None,
  368. ))
  369. def evaluate(self, run_id: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
  370. run_data = self.require_run(run_id)
  371. output: list[dict[str, Any]] = []
  372. with get_session() as session:
  373. run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  374. assert run is not None
  375. for item in items:
  376. row = session.scalar(select(FindAgentV2Candidate).where(
  377. FindAgentV2Candidate.run_id == run_id,
  378. FindAgentV2Candidate.id == int(item.get("candidate_id") or 0),
  379. ))
  380. if row is None:
  381. raise ValueError(f"candidate_id 不属于 run: {item.get('candidate_id')}")
  382. for key in ("relevance_score", "elder_score", "share_score", "value_score"):
  383. setattr(row, key, _ratio(item.get(key)))
  384. requested = str(item.get("decision_bucket") or "rejected")
  385. if requested not in {"primary", "rejected"}:
  386. raise ValueError("decision_bucket 只能是 primary/rejected")
  387. gate_input = _candidate_dict(row)
  388. gate = evaluate_candidate_gate(gate_input, run_data["rule_config"])
  389. row.gate_status = gate["status"]
  390. row.gate_result_json = _json(gate)
  391. row.decision_bucket = "primary" if requested == "primary" and gate["status"] == "pass" else "rejected"
  392. row.decision_reason = str(item.get("decision_reason") or "")
  393. failed = list(gate.get("failed_reason_codes") or [])
  394. row.reject_reason_code = (str(item.get("reject_reason_code") or "") or (failed[0] if failed else None))
  395. output.append({"candidate_id": int(row.id), "decision_bucket": row.decision_bucket, "gate_status": row.gate_status})
  396. session.flush()
  397. primaries = list(session.scalars(select(FindAgentV2Candidate).where(
  398. FindAgentV2Candidate.run_id == run_id,
  399. FindAgentV2Candidate.decision_bucket == "primary",
  400. )))
  401. run.valid_primary_count = len({row.aweme_id for row in primaries if row.gate_status == "pass"})
  402. return output
  403. def recount_valid_primary(self, run_id: str) -> int:
  404. """Recompute the denormalized primary counter after parallel worker writes."""
  405. with get_session() as session:
  406. run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  407. if run is None:
  408. raise FindAgentV2RunNotFound(run_id)
  409. count = int(session.scalar(select(func.count(func.distinct(
  410. FindAgentV2Candidate.aweme_id,
  411. ))).where(
  412. FindAgentV2Candidate.run_id == run_id,
  413. FindAgentV2Candidate.decision_bucket == "primary",
  414. FindAgentV2Candidate.gate_status == "pass",
  415. )) or 0)
  416. run.valid_primary_count = count
  417. return count
  418. def get_full_state(
  419. self, run_id: str, *, limit: int = 100, pending_only: bool = False,
  420. ) -> dict[str, Any]:
  421. run = self.require_run(run_id)
  422. with get_session() as session:
  423. searches = list(session.scalars(select(FindAgentV2Search).where(
  424. FindAgentV2Search.run_id == run_id,
  425. ).order_by(FindAgentV2Search.id)))
  426. candidate_query = select(FindAgentV2Candidate).where(
  427. FindAgentV2Candidate.run_id == run_id,
  428. )
  429. if pending_only:
  430. candidate_query = candidate_query.where(
  431. FindAgentV2Candidate.decision_bucket == "pending_evaluation",
  432. )
  433. candidates = list(session.scalars(candidate_query.order_by(
  434. FindAgentV2Candidate.value_score.desc(), FindAgentV2Candidate.id,
  435. ).limit(max(1, min(limit, 500)))))
  436. return {
  437. "run": run,
  438. "searches": [{
  439. "search_id": int(row.id), "round_index": row.round_index,
  440. "keyword": row.keyword, "query_reason": row.query_reason,
  441. "provider": row.provider, "page_no": row.page_no,
  442. "has_more": bool(row.has_more), "next_cursor": row.next_cursor,
  443. "status": row.status, "result_count": row.result_count,
  444. } for row in searches],
  445. "candidates": [_candidate_dict(row) for row in candidates],
  446. }
  447. def snapshot(self, run_id: str) -> DiscoverySnapshot:
  448. state = self.get_full_state(run_id)
  449. candidates = state["candidates"]
  450. buckets = [item["decision_bucket"] for item in candidates]
  451. run = state["run"]
  452. return DiscoverySnapshot(
  453. status=run["status"],
  454. search_count=run["search_count"],
  455. candidate_count=run["candidate_count"],
  456. pending_count=sum(value == "pending_evaluation" for value in buckets),
  457. primary_count=sum(value == "primary" for value in buckets),
  458. valid_primary_count=run["valid_primary_count"],
  459. rejected_count=sum(value == "rejected" for value in buckets),
  460. outcome_status=run.get("outcome_status") or "",
  461. )
  462. def finalize(
  463. self,
  464. run_id: str,
  465. *,
  466. failed: bool = False,
  467. reason: str = "",
  468. target_primary_count: int = 5,
  469. ) -> dict[str, Any]:
  470. snapshot = self.snapshot(run_id)
  471. if failed:
  472. outcome, status = "failed", "failed"
  473. elif snapshot.valid_primary_count >= max(1, int(target_primary_count)):
  474. outcome, status = "goal_met", "finished"
  475. elif snapshot.valid_primary_count > 0:
  476. outcome, status = "partial", "finished"
  477. else:
  478. outcome, status = "no_match", "finished"
  479. with get_session() as session:
  480. run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  481. if run is None:
  482. raise FindAgentV2RunNotFound(run_id)
  483. run.status = status
  484. run.outcome_status = outcome
  485. run.stop_reason = reason[:2000] or None
  486. return self.require_run(run_id)
  487. _SERVICE = FindAgentV2Service()
  488. def get_find_agent_v2_service() -> FindAgentV2Service:
  489. return _SERVICE