| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- """持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
- from __future__ import annotations
- import hashlib
- import json
- import logging
- import uuid
- from decimal import Decimal
- from typing import Any
- from agents.find_agent.tools.decision_support import (
- audit_video_discovery_process,
- )
- from supply_agent.tools import tool
- from supply_infra.services.video_discovery_service import (
- RunNotFoundError,
- format_db_error,
- get_video_discovery_service,
- )
- logger = logging.getLogger(__name__)
- _RUN_STATUSES = {"running", "finished", "failed"}
- _FINAL_DECISION_BUCKETS = {"primary", "rejected"}
- _SOURCE_TYPES = {
- "demand",
- "seed",
- "point",
- "tag",
- "author",
- "pagination",
- "mixed",
- }
- _ROOT_SOURCE_TYPES = {"demand", "seed", "point", "mixed"}
- def _json(value: Any) -> str:
- return json.dumps(value, ensure_ascii=False, default=str)
- def _input_error(message: str) -> str:
- return _json({"error": message, "input_error": True})
- def _load_json(value: str | None, default: Any) -> Any:
- if not value:
- return default
- try:
- return json.loads(value)
- except (TypeError, ValueError):
- return default
- def _clean_text(value: Any, *, max_length: int | None = None) -> str | None:
- if value is None:
- return None
- text = str(value).strip()
- if not text:
- return None
- return text[:max_length] if max_length else text
- def _nonnegative_int(value: Any) -> int | None:
- if value is None or value == "":
- return None
- try:
- return max(0, int(value))
- except (TypeError, ValueError):
- return None
- def _optional_decimal(value: Any, places: int) -> Decimal | None:
- if value is None or value == "":
- return None
- try:
- number = Decimal(str(value))
- except (ArithmeticError, TypeError, ValueError):
- return None
- quantum = Decimal(1).scaleb(-places)
- return number.quantize(quantum)
- def _search_key(values: dict[str, Any]) -> str:
- identity = {
- key: values.get(key)
- for key in (
- "provider",
- "keyword",
- "content_type",
- "sort_type",
- "publish_time",
- "cursor",
- )
- }
- raw = json.dumps(identity, ensure_ascii=False, sort_keys=True)
- return hashlib.sha256(raw.encode("utf-8")).hexdigest()
- def _candidate_from_search_result(item: dict[str, Any], keyword: str) -> dict[str, Any] | None:
- aweme_id = _clean_text(item.get("aweme_id") or item.get("content_id"), max_length=64)
- if not aweme_id:
- return None
- author = item.get("author") if isinstance(item.get("author"), dict) else {}
- stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
- topics = item.get("topics") if isinstance(item.get("topics"), list) else []
- return {
- "aweme_id": aweme_id,
- "title": _clean_text(item.get("desc") or item.get("title"), max_length=512),
- "content_link": _clean_text(
- item.get("url") or item.get("content_link"), max_length=1024
- ),
- "author_name": _clean_text(
- author.get("nickname") or item.get("author_name"), max_length=256
- ),
- "author_sec_uid": _clean_text(
- author.get("sec_uid") or item.get("author_sec_uid"), max_length=256
- ),
- "like_count": _nonnegative_int(
- stats.get("digg_count") or item.get("like_count")
- ),
- "comment_count": _nonnegative_int(
- stats.get("comment_count") or item.get("comment_count")
- ),
- "share_count": _nonnegative_int(
- stats.get("share_count") or item.get("share_count")
- ),
- "collect_count": _nonnegative_int(
- stats.get("collect_count") or item.get("collect_count")
- ),
- "play_count": _nonnegative_int(
- stats.get("play_count") or item.get("play_count")
- ),
- "tags_json": _json(topics) if topics else None,
- "_source_keyword": keyword,
- }
- @tool
- def create_video_discovery_run(
- demand_word: str,
- seed_video_title: str,
- relevant_points: list[dict[str, Any]],
- seed_video_id: str | None = None,
- demand_grade_id: int | None = None,
- intent_summary: str | None = None,
- run_id: str | None = None,
- ) -> str:
- """
- 创建一次可追踪的视频发现运行。
- 若用户消息已提供预创建 run_id,必须原样传入 run_id;工具会复用已有记录,
- 不会重复创建。
- Args:
- demand_word: 用户给定需求词;它是输入语义,不强制作为实际搜索词。
- seed_video_title: 与需求相关的参考视频标题。
- relevant_points: 参考视频中与需求相关的点位对象列表。
- seed_video_id: 可选参考视频 id。
- demand_grade_id: 可选 demand_grade.id。
- intent_summary: Agent 对真正受欢迎内容的初步解释,可稍后更新。
- run_id: 系统预创建的运行 id;传入已存在记录时直接复用。
- Returns:
- JSON,包含后续存储工具必须使用的 run_id。
- """
- service = get_video_discovery_service()
- cleaned_run_id = _clean_text(run_id, max_length=64)
- if cleaned_run_id:
- try:
- existing = service.lookup_run(cleaned_run_id)
- if existing is not None:
- return _json(
- {
- "title": "视频发现运行已存在",
- "run_id": cleaned_run_id,
- "status": existing["status"],
- "pre_created": True,
- "output": f"run_id={cleaned_run_id}",
- }
- )
- except Exception as exc:
- logger.error("create_video_discovery_run lookup failed: %s", exc, exc_info=True)
- return _json({"error": format_db_error(exc), "title": "查询视频发现运行失败"})
- demand = _clean_text(demand_word, max_length=256)
- if not demand:
- return _input_error("demand_word 不能为空")
- new_run_id = cleaned_run_id or uuid.uuid4().hex
- values = {
- "run_id": new_run_id,
- "demand_grade_id": demand_grade_id,
- "demand_word": demand,
- "seed_video_id": _clean_text(seed_video_id, max_length=64),
- "seed_video_title": _clean_text(seed_video_title, max_length=512),
- "relevant_points_json": _json(relevant_points or []),
- "intent_summary": _clean_text(intent_summary),
- "status": "running",
- }
- try:
- created = service.create_run(values)
- return _json(
- {
- "title": "视频发现运行已创建",
- "run_id": created["run_id"],
- "status": created["status"],
- "output": f"run_id={created['run_id']}",
- }
- )
- except Exception as exc:
- logger.error("create_video_discovery_run failed: %s", exc, exc_info=True)
- return _json({"error": format_db_error(exc), "title": "创建视频发现运行失败"})
- @tool
- def record_video_search_page(
- run_id: str,
- keyword: str,
- query_reason: str,
- source_type: str,
- results: list[dict[str, Any]],
- cursor: str = "0",
- page_no: int = 1,
- has_more: bool = False,
- next_cursor: str | None = None,
- source_value: str | None = None,
- parent_search_id: int | None = None,
- provider: str = "internal_keyword",
- provider_state: dict[str, Any] | None = None,
- content_type: str = "视频",
- sort_type: str = "综合排序",
- publish_time: str = "不限",
- error_message: str | None = None,
- ) -> str:
- """
- 保存一次关键词搜索页,并把该页视频幂等并入候选集。
- 每次 douyin_search 后调用。关键词可来自需求语义、参考标题、点位、优质视频标签,
- 或前一页的 next_cursor;source_type 用于保留扩展来源。
- Args:
- run_id: create_video_discovery_run 返回值。
- keyword: Agent 本次自主确定的实际搜索词。
- query_reason: 该词验证的内容假设。
- source_type: demand / seed / point / tag / author / pagination / mixed。
- results: douyin_search.search_results 数组。
- cursor / page_no / has_more / next_cursor: 本页翻页状态。
- source_value: 触发扩展的点位、标签或父关键词。
- parent_search_id: 标签扩展或翻页对应的父搜索记录。
- provider: internal_keyword / tikhub / internal_blogger 等来源标识。
- provider_state: 来源特有的分页状态,如 TikHub 的 search_id/backtrace。
- content_type / sort_type / publish_time: 原样保存搜索条件。
- error_message: 搜索失败时保存错误;results 可为空。
- """
- run_text = _clean_text(run_id, max_length=64)
- keyword_text = _clean_text(keyword, max_length=256)
- reason_text = _clean_text(query_reason)
- if not run_text or not keyword_text or not reason_text:
- return _input_error("run_id、keyword、query_reason 不能为空")
- if source_type not in _SOURCE_TYPES:
- return _input_error(f"source_type 必须是: {sorted(_SOURCE_TYPES)}")
- normalized_page_no = max(1, int(page_no))
- normalized_source_type = (
- "pagination" if normalized_page_no > 1 else source_type
- )
- normalized_parent_search_id = (
- None
- if normalized_source_type in _ROOT_SOURCE_TYPES
- else parent_search_id
- )
- candidate_rows = [
- row
- for item in results or []
- if isinstance(item, dict)
- if (row := _candidate_from_search_result(dict(item), keyword_text)) is not None
- ]
- search_values: dict[str, Any] = {
- "run_id": run_text,
- "keyword": keyword_text,
- "query_reason": reason_text,
- "source_type": normalized_source_type,
- "source_value": _clean_text(source_value),
- "parent_search_id": normalized_parent_search_id,
- "provider": _clean_text(provider, max_length=32) or "internal_keyword",
- "provider_state_json": _json(provider_state) if provider_state else None,
- "content_type": _clean_text(content_type, max_length=16) or "视频",
- "sort_type": _clean_text(sort_type, max_length=32) or "综合排序",
- "publish_time": _clean_text(publish_time, max_length=32) or "不限",
- "cursor": _clean_text(cursor, max_length=128) or "0",
- "page_no": normalized_page_no,
- "results_count": len(results or []),
- "new_candidate_count": 0,
- "has_more": int(bool(has_more)),
- "next_cursor": _clean_text(next_cursor, max_length=128),
- "result_ids_json": None,
- "status": "failed" if error_message else "success",
- "error_message": _clean_text(error_message),
- }
- search_values["search_key"] = _search_key(search_values)
- try:
- saved = get_video_discovery_service().save_search_page(
- run_text,
- search_values,
- candidate_rows,
- )
- payload = {
- "title": "搜索页已保存",
- **saved,
- "output": (
- f"search_id={saved['search_id']},本页 {saved['results_count']} 条,"
- f"新增候选 {saved['new_candidate_count']} 条"
- ),
- }
- return _json(payload)
- except RunNotFoundError as exc:
- return _input_error(str(exc))
- except Exception as exc:
- logger.error("record_video_search_page failed: %s", exc, exc_info=True)
- return _json({"error": format_db_error(exc), "title": "保存搜索页失败"})
- def _normalize_evaluation(item: dict[str, Any]) -> dict[str, Any]:
- aweme_id = _clean_text(item.get("aweme_id"), max_length=64)
- if not aweme_id:
- raise ValueError("aweme_id 不能为空")
- content_age = item.get("content_age_evidence")
- account_age = item.get("account_age_evidence")
- age_normalization = item.get("age_normalization")
- detail_verified = bool(item.get("detail_verified"))
- content_portrait_attempted = bool(item.get("content_portrait_attempted"))
- account_portrait_attempted = bool(item.get("account_portrait_attempted"))
- age_portraits_normalized = bool(item.get("age_portraits_normalized"))
- decision_bucket = (
- _clean_text(item.get("decision_bucket"), max_length=24)
- or ""
- )
- if decision_bucket not in _FINAL_DECISION_BUCKETS:
- raise ValueError(
- "decision_bucket 必须是 primary 或 rejected"
- )
- mapping = {
- "aweme_id": aweme_id,
- "title": _clean_text(item.get("title"), max_length=512),
- "content_link": _clean_text(item.get("content_link"), max_length=1024),
- "author_name": _clean_text(item.get("author_name"), max_length=256),
- "author_sec_uid": _clean_text(item.get("author_sec_uid"), max_length=256),
- "source_keywords_json": item.get("source_keywords") or [],
- "source_search_ids_json": item.get("source_search_ids") or [],
- "tags_json": item.get("tags") or [],
- "hit_points_json": item.get("hit_points") or [],
- "play_count": _nonnegative_int(item.get("play_count")),
- "like_count": _nonnegative_int(item.get("like_count")),
- "comment_count": _nonnegative_int(item.get("comment_count")),
- "collect_count": _nonnegative_int(item.get("collect_count")),
- "share_count": _nonnegative_int(item.get("share_count")),
- "publish_timestamp": _nonnegative_int(item.get("publish_timestamp")),
- "content_age_evidence_json": (
- _json(content_age) if content_age is not None else None
- ),
- "account_age_evidence_json": (
- _json(account_age) if account_age is not None else None
- ),
- "age_normalization_json": (
- _json(age_normalization) if age_normalization is not None else None
- ),
- "detail_verified": int(detail_verified),
- "content_portrait_attempted": int(content_portrait_attempted),
- "account_portrait_attempted": int(account_portrait_attempted),
- "age_portraits_normalized": int(age_portraits_normalized),
- "expansion_worthy_tags_json": item.get("expansion_worthy_tags") or [],
- "relevance_score": _optional_decimal(item.get("relevance_score"), 6),
- "elder_score": _optional_decimal(item.get("elder_score"), 6),
- "share_score": _optional_decimal(item.get("share_score"), 6),
- "value_score": _optional_decimal(item.get("value_score"), 2),
- "confidence": _clean_text(item.get("confidence"), max_length=16),
- "relevance_reason": _clean_text(item.get("relevance_reason")),
- "elder_reason": _clean_text(item.get("elder_reason")),
- "share_reason": _clean_text(item.get("share_reason")),
- "decision_reason": _clean_text(item.get("decision_reason")),
- "decision_bucket": decision_bucket,
- }
- return mapping
- @tool
- def batch_save_video_candidate_evaluations(
- run_id: str,
- items: list[dict[str, Any]],
- run_status: str = "running",
- intent_summary: str | None = None,
- stop_reason: str | None = None,
- ) -> str:
- """
- 原样保存 Agent 给出的候选详情、证据、评分和分池。
- 本工具不重算 R/E/S/V,不执行画像证据上限,也不根据阈值修改
- decision_bucket。最终分池只接受 primary / rejected。
- Args:
- run_id: 发现运行 id。
- items: 候选数组。每项至少包含 aweme_id,并由 Agent 直接提供
- decision_bucket。其余详情、证据、评分和理由按模型输出原样保存。
- run_status: running / finished / failed。
- intent_summary: 对目标内容的最终解释。
- stop_reason: 完成或失败时的停止依据。
- """
- run_text = _clean_text(run_id, max_length=64)
- if not run_text:
- return _input_error("run_id 不能为空")
- if run_status not in _RUN_STATUSES:
- return _input_error(
- f"run_status 必须是: {sorted(_RUN_STATUSES)}"
- )
- rows: list[dict[str, Any]] = []
- errors: list[str] = []
- for index, item in enumerate(items or []):
- if not isinstance(item, dict):
- errors.append(f"[{index}] 不是对象")
- continue
- try:
- rows.append(_normalize_evaluation(dict(item)))
- except ValueError as exc:
- errors.append(f"[{index}] {exc}")
- try:
- saved = get_video_discovery_service().save_evaluations_and_finish(
- run_text,
- rows,
- status=run_status,
- intent_summary=_clean_text(intent_summary),
- stop_reason=_clean_text(stop_reason),
- )
- payload = {
- "title": "候选评估已保存",
- "run_id": run_text,
- "saved_count": saved["saved_count"],
- "error_count": len(errors),
- "errors": errors,
- "input_error": bool(errors and not rows),
- "audit_relevant_changed": saved["audit_relevant_changed"],
- "status": saved["status"],
- "search_count": saved["search_count"],
- "primary_count": saved["primary_count"],
- "output": (
- f"保存 {saved['saved_count']} 条;主推荐 {saved['primary_count']} 条;"
- f"状态 {saved['status']}"
- ),
- }
- return _json(payload)
- except RunNotFoundError as exc:
- return _input_error(str(exc))
- except Exception as exc:
- logger.error(
- "batch_save_video_candidate_evaluations failed: %s", exc, exc_info=True
- )
- return _json({"error": format_db_error(exc), "title": "保存候选评估失败"})
- @tool
- def query_video_discovery_state(
- run_id: str,
- include_rejected: bool = True,
- limit: int = 100,
- ) -> str:
- """
- 查询一次运行已经保存的搜索轨迹、主推荐与淘汰候选。
- 用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
- """
- run_text = _clean_text(run_id, max_length=64)
- if not run_text:
- return _json({"error": "run_id 不能为空"})
- try:
- state = get_video_discovery_service().get_full_state(
- run_text,
- include_rejected=include_rejected,
- limit=limit,
- )
- run = state["run"]
- payload = {
- "title": f"视频发现状态: {run_text}",
- "run": run,
- "searches": state["searches"],
- "candidates": state["candidates"],
- "output": (
- f"搜索页 {run['search_count']};主推荐 {run['primary_count']}"
- ),
- }
- return _json(payload)
- except RunNotFoundError:
- return _json({"error": f"run_id 不存在: {run_text}"})
- except Exception as exc:
- logger.error("query_video_discovery_state failed: %s", exc, exc_info=True)
- return _json({"error": format_db_error(exc), "title": "查询视频发现状态失败"})
- @tool
- def audit_video_discovery_run(
- run_id: str,
- intended_status: str = "finished",
- ) -> str:
- """
- 直接从数据库读取一次发现运行的完整状态并执行结束审计。
- 相比把 query_video_discovery_state 的大量 searches/candidates 再复制给审计工具,
- 本工具只需要 run_id,可避免长参数截断或 malformed function call。数据库可用时
- 应优先使用本工具;数据库不可用的降级流程仍使用 audit_video_discovery_process。
- Args:
- run_id: create_video_discovery_run 返回的运行 id。
- intended_status: 准备结束时传 finished。
- Returns:
- JSON,包含 can_finish、critical_violations、warnings、coverage 和持久化状态。
- """
- run_text = _clean_text(run_id, max_length=64)
- if not run_text:
- return _input_error("run_id 不能为空")
- if intended_status not in _RUN_STATUSES:
- return _input_error(
- f"intended_status 必须是: {sorted(_RUN_STATUSES)}"
- )
- try:
- snapshot = get_video_discovery_service().get_audit_snapshot(run_text)
- persisted_run = snapshot["persisted_run"]
- result = _load_json(
- audit_video_discovery_process(
- searches=snapshot["searches"],
- candidates=snapshot["candidates"],
- intended_status=intended_status,
- ),
- {},
- )
- if not result:
- return _json({"error": "审计工具返回了无效结果"})
- result.update(
- {
- "run_id": run_text,
- "persisted_status": persisted_run["status"],
- "persisted_search_count": persisted_run["search_count"],
- "persisted_primary_count": persisted_run["primary_count"],
- }
- )
- if (
- intended_status == "finished"
- and persisted_run["status"] != "finished"
- ):
- violations = list(result.get("critical_violations") or [])
- violations.append("运行状态尚未持久化为 finished")
- result["critical_violations"] = list(dict.fromkeys(violations))
- result["can_finish"] = False
- return _json(result)
- except RunNotFoundError as exc:
- return _input_error(str(exc))
- except Exception as exc:
- logger.error(
- "audit_video_discovery_run failed: %s",
- exc,
- exc_info=True,
- )
- return _json(
- {"error": format_db_error(exc), "title": "数据库运行审计失败"}
- )
|