|
|
@@ -0,0 +1,703 @@
|
|
|
+"""持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+import logging
|
|
|
+import uuid
|
|
|
+from decimal import Decimal
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from sqlalchemy.exc import OperationalError, ProgrammingError
|
|
|
+
|
|
|
+from agents.find_agent.tools.decision_support import (
|
|
|
+ audit_video_discovery_process,
|
|
|
+)
|
|
|
+from supply_agent.tools import tool
|
|
|
+from supply_infra.db.repositories.video_discovery_repo import (
|
|
|
+ VideoDiscoveryRepository,
|
|
|
+)
|
|
|
+from supply_infra.db.session import get_session
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+_RUN_STATUSES = {"running", "finished", "failed"}
|
|
|
+_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 _db_error_message(error: Exception) -> str:
|
|
|
+ if isinstance(error, (OperationalError, ProgrammingError)):
|
|
|
+ return (
|
|
|
+ f"数据库表尚未初始化或不可用: {error}。"
|
|
|
+ "请先运行 `.venv/bin/python -m supply_infra.db`。"
|
|
|
+ )
|
|
|
+ return str(error)
|
|
|
+
|
|
|
+
|
|
|
+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,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _serialize_candidate(item: Any) -> dict[str, Any]:
|
|
|
+ decision_bucket = (
|
|
|
+ "pending_evaluation"
|
|
|
+ if item.decision_bucket == "unreviewed"
|
|
|
+ else item.decision_bucket
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "aweme_id": item.aweme_id,
|
|
|
+ "title": item.title,
|
|
|
+ "content_link": item.content_link,
|
|
|
+ "author_name": item.author_name,
|
|
|
+ "author_sec_uid": item.author_sec_uid,
|
|
|
+ "source_keywords": _load_json(item.source_keywords_json, []),
|
|
|
+ "source_search_ids": _load_json(item.source_search_ids_json, []),
|
|
|
+ "tags": _load_json(item.tags_json, []),
|
|
|
+ "hit_points": _load_json(item.hit_points_json, []),
|
|
|
+ "play_count": item.play_count,
|
|
|
+ "like_count": item.like_count,
|
|
|
+ "comment_count": item.comment_count,
|
|
|
+ "collect_count": item.collect_count,
|
|
|
+ "share_count": item.share_count,
|
|
|
+ "relevance_score": (
|
|
|
+ float(item.relevance_score) if item.relevance_score is not None else None
|
|
|
+ ),
|
|
|
+ "elder_score": float(item.elder_score) if item.elder_score is not None else None,
|
|
|
+ "share_score": float(item.share_score) if item.share_score is not None else None,
|
|
|
+ "value_score": float(item.value_score) if item.value_score is not None else None,
|
|
|
+ "confidence": item.confidence,
|
|
|
+ "decision_bucket": decision_bucket,
|
|
|
+ "content_age_evidence": _load_json(item.content_age_evidence_json, {}),
|
|
|
+ "account_age_evidence": _load_json(item.account_age_evidence_json, {}),
|
|
|
+ "age_normalization": _load_json(item.age_normalization_json, {}),
|
|
|
+ "detail_verified": bool(item.detail_verified),
|
|
|
+ "content_portrait_attempted": bool(item.content_portrait_attempted),
|
|
|
+ "account_portrait_attempted": bool(item.account_portrait_attempted),
|
|
|
+ "age_portraits_normalized": bool(item.age_portraits_normalized),
|
|
|
+ "has_video_url": bool(item.video_url),
|
|
|
+ "content_analysis": item.content_analysis,
|
|
|
+ "content_analysis_verified": bool(item.content_analysis_verified),
|
|
|
+ "expansion_worthy_tags": _load_json(
|
|
|
+ item.expansion_worthy_tags_json, []
|
|
|
+ ),
|
|
|
+ "relevance_reason": item.relevance_reason,
|
|
|
+ "elder_reason": item.elder_reason,
|
|
|
+ "share_reason": item.share_reason,
|
|
|
+ "decision_reason": item.decision_reason,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _serialize_search(item: Any) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "search_id": int(item.id),
|
|
|
+ "keyword": item.keyword,
|
|
|
+ "query_reason": item.query_reason,
|
|
|
+ "source_type": item.source_type,
|
|
|
+ "source_value": item.source_value,
|
|
|
+ "parent_search_id": item.parent_search_id,
|
|
|
+ "provider": item.provider,
|
|
|
+ "provider_state": _load_json(item.provider_state_json, {}),
|
|
|
+ "cursor": item.cursor,
|
|
|
+ "page_no": item.page_no,
|
|
|
+ "results_count": item.results_count,
|
|
|
+ "new_candidate_count": item.new_candidate_count,
|
|
|
+ "has_more": bool(item.has_more),
|
|
|
+ "next_cursor": item.next_cursor,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@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。
|
|
|
+ """
|
|
|
+ cleaned_run_id = _clean_text(run_id, max_length=64)
|
|
|
+ if cleaned_run_id:
|
|
|
+ try:
|
|
|
+ with get_session() as session:
|
|
|
+ existing = VideoDiscoveryRepository(session).get_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": _db_error_message(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:
|
|
|
+ with get_session() as session:
|
|
|
+ VideoDiscoveryRepository(session).create_run(values)
|
|
|
+ return _json(
|
|
|
+ {
|
|
|
+ "title": "视频发现运行已创建",
|
|
|
+ "run_id": new_run_id,
|
|
|
+ "status": "running",
|
|
|
+ "output": f"run_id={new_run_id}",
|
|
|
+ }
|
|
|
+ )
|
|
|
+ except Exception as exc:
|
|
|
+ logger.error("create_video_discovery_run failed: %s", exc, exc_info=True)
|
|
|
+ return _json({"error": _db_error_message(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:
|
|
|
+ with get_session() as session:
|
|
|
+ repo = VideoDiscoveryRepository(session)
|
|
|
+ if repo.get_run(run_text) is None:
|
|
|
+ return _input_error(f"run_id 不存在: {run_text}")
|
|
|
+ search, new_count = repo.save_search_page(search_values, candidate_rows)
|
|
|
+ payload = {
|
|
|
+ "title": "搜索页已保存",
|
|
|
+ "search_id": int(search.id),
|
|
|
+ "run_id": run_text,
|
|
|
+ "keyword": keyword_text,
|
|
|
+ "source_type": search.source_type,
|
|
|
+ "parent_search_id": search.parent_search_id,
|
|
|
+ "page_no": search.page_no,
|
|
|
+ "results_count": search.results_count,
|
|
|
+ "new_candidate_count": new_count,
|
|
|
+ "has_more": bool(search.has_more),
|
|
|
+ "next_cursor": search.next_cursor,
|
|
|
+ "output": (
|
|
|
+ f"search_id={search.id},本页 {search.results_count} 条,"
|
|
|
+ f"新增候选 {new_count} 条"
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ return _json(payload)
|
|
|
+ except Exception as exc:
|
|
|
+ logger.error("record_video_search_page failed: %s", exc, exc_info=True)
|
|
|
+ return _json({"error": _db_error_message(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")
|
|
|
+ content_analysis = _clean_text(item.get("content_analysis"))
|
|
|
+ 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"))
|
|
|
+ content_analysis_verified = bool(item.get("content_analysis_verified"))
|
|
|
+ age_portraits_normalized = bool(item.get("age_portraits_normalized"))
|
|
|
+ decision_bucket = (
|
|
|
+ _clean_text(item.get("decision_bucket"), max_length=24)
|
|
|
+ or "pending_evaluation"
|
|
|
+ )
|
|
|
+
|
|
|
+ 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),
|
|
|
+ "video_url": _clean_text(item.get("video_url")),
|
|
|
+ "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),
|
|
|
+ "content_analysis": content_analysis,
|
|
|
+ "content_analysis_verified": int(content_analysis_verified),
|
|
|
+ "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。Agent 给出的 primary / backup / 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:
|
|
|
+ with get_session() as session:
|
|
|
+ repo = VideoDiscoveryRepository(session)
|
|
|
+ if repo.get_run(run_text) is None:
|
|
|
+ return _input_error(f"run_id 不存在: {run_text}")
|
|
|
+ if rows:
|
|
|
+ saved, audit_relevant_changed = repo.save_candidate_evaluations(
|
|
|
+ run_text, rows
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ saved, audit_relevant_changed = 0, False
|
|
|
+ run = repo.finish_run(
|
|
|
+ run_text,
|
|
|
+ status=run_status,
|
|
|
+ intent_summary=_clean_text(intent_summary),
|
|
|
+ stop_reason=_clean_text(stop_reason),
|
|
|
+ )
|
|
|
+ payload = {
|
|
|
+ "title": "候选评估已保存",
|
|
|
+ "run_id": run_text,
|
|
|
+ "saved_count": saved,
|
|
|
+ "error_count": len(errors),
|
|
|
+ "errors": errors,
|
|
|
+ "input_error": bool(errors and not rows),
|
|
|
+ "audit_relevant_changed": audit_relevant_changed,
|
|
|
+ "status": run.status,
|
|
|
+ "search_count": run.search_count,
|
|
|
+ "primary_count": run.primary_count,
|
|
|
+ "backup_count": run.backup_count,
|
|
|
+ "output": (
|
|
|
+ f"保存 {saved} 条;主推荐 {run.primary_count} 条;"
|
|
|
+ f"补充候选 {run.backup_count} 条;状态 {run.status}"
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ return _json(payload)
|
|
|
+ except Exception as exc:
|
|
|
+ logger.error(
|
|
|
+ "batch_save_video_candidate_evaluations failed: %s", exc, exc_info=True
|
|
|
+ )
|
|
|
+ return _json({"error": _db_error_message(exc), "title": "保存候选评估失败"})
|
|
|
+
|
|
|
+
|
|
|
+@tool
|
|
|
+def query_video_discovery_state(
|
|
|
+ run_id: str,
|
|
|
+ include_rejected: bool = False,
|
|
|
+ limit: int = 100,
|
|
|
+) -> str:
|
|
|
+ """
|
|
|
+ 查询一次运行已经保存的搜索轨迹、主推荐与 Agent 补充候选。
|
|
|
+
|
|
|
+ 用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
|
|
|
+ """
|
|
|
+ run_text = _clean_text(run_id, max_length=64)
|
|
|
+ if not run_text:
|
|
|
+ return _json({"error": "run_id 不能为空"})
|
|
|
+ try:
|
|
|
+ with get_session() as session:
|
|
|
+ repo = VideoDiscoveryRepository(session)
|
|
|
+ run = repo.get_run(run_text)
|
|
|
+ if run is None:
|
|
|
+ return _json({"error": f"run_id 不存在: {run_text}"})
|
|
|
+ searches = repo.list_searches(run_text)
|
|
|
+ buckets = (
|
|
|
+ None
|
|
|
+ if include_rejected
|
|
|
+ else (
|
|
|
+ "primary",
|
|
|
+ "backup",
|
|
|
+ "pending_evaluation",
|
|
|
+ "unreviewed",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ candidates = repo.list_candidates(
|
|
|
+ run_text, buckets=buckets, limit=max(1, min(int(limit), 500))
|
|
|
+ )
|
|
|
+ payload = {
|
|
|
+ "title": f"视频发现状态: {run_text}",
|
|
|
+ "run": {
|
|
|
+ "run_id": run.run_id,
|
|
|
+ "demand_word": run.demand_word,
|
|
|
+ "intent_summary": run.intent_summary,
|
|
|
+ "status": run.status,
|
|
|
+ "search_count": run.search_count,
|
|
|
+ "primary_count": run.primary_count,
|
|
|
+ "backup_count": run.backup_count,
|
|
|
+ "stop_reason": run.stop_reason,
|
|
|
+ },
|
|
|
+ "searches": [_serialize_search(item) for item in searches],
|
|
|
+ "candidates": [_serialize_candidate(item) for item in candidates],
|
|
|
+ "output": (
|
|
|
+ f"搜索页 {run.search_count};主推荐 {run.primary_count};"
|
|
|
+ f"补充候选 {run.backup_count}"
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ return _json(payload)
|
|
|
+ except Exception as exc:
|
|
|
+ logger.error("query_video_discovery_state failed: %s", exc, exc_info=True)
|
|
|
+ return _json({"error": _db_error_message(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:
|
|
|
+ with get_session() as session:
|
|
|
+ repo = VideoDiscoveryRepository(session)
|
|
|
+ run = repo.get_run(run_text)
|
|
|
+ if run is None:
|
|
|
+ return _input_error(f"run_id 不存在: {run_text}")
|
|
|
+ searches = [
|
|
|
+ _serialize_search(item)
|
|
|
+ for item in repo.list_searches(run_text)
|
|
|
+ ]
|
|
|
+ candidates = [
|
|
|
+ _serialize_candidate(item)
|
|
|
+ for item in repo.list_candidates(run_text, limit=500)
|
|
|
+ ]
|
|
|
+ persisted_run = {
|
|
|
+ "status": run.status,
|
|
|
+ "search_count": run.search_count,
|
|
|
+ "primary_count": run.primary_count,
|
|
|
+ "backup_count": run.backup_count,
|
|
|
+ }
|
|
|
+
|
|
|
+ result = _load_json(
|
|
|
+ audit_video_discovery_process(
|
|
|
+ searches=searches,
|
|
|
+ candidates=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"],
|
|
|
+ "persisted_backup_count": persisted_run["backup_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 Exception as exc:
|
|
|
+ logger.error(
|
|
|
+ "audit_video_discovery_run failed: %s",
|
|
|
+ exc,
|
|
|
+ exc_info=True,
|
|
|
+ )
|
|
|
+ return _json(
|
|
|
+ {"error": _db_error_message(exc), "title": "数据库运行审计失败"}
|
|
|
+ )
|