service.py 25 KB

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