|
|
@@ -0,0 +1,745 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import copy
|
|
|
+import os
|
|
|
+import time
|
|
|
+from collections import Counter
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from content_agent.dashboard_service import (
|
|
|
+ DATA_ORIGIN_PRODUCTION_DB,
|
|
|
+ DATA_ORIGIN_RUNTIME_EXPORT,
|
|
|
+ DashboardService,
|
|
|
+)
|
|
|
+from content_agent.integrations.database_runtime import DatabaseRuntimeStore
|
|
|
+from content_agent.interfaces import RuntimeStore
|
|
|
+
|
|
|
+
|
|
|
+FIRST_ROUND_QUERY_METHODS = {
|
|
|
+ "seed_term",
|
|
|
+ "piaoquan_topic_point",
|
|
|
+ "category_leaf_element",
|
|
|
+}
|
|
|
+EXTENSION_QUERY_METHODS = {
|
|
|
+ "query_next_page",
|
|
|
+ "tag_query",
|
|
|
+ "author_works",
|
|
|
+}
|
|
|
+
|
|
|
+SOURCE_LABELS = {
|
|
|
+ "seed_term": "Pattern 种子词",
|
|
|
+ "piaoquan_topic_point": "票圈内容点",
|
|
|
+ "category_leaf_element": "分类叶子元素",
|
|
|
+}
|
|
|
+QUERY_METHOD_LABELS = {
|
|
|
+ "seed_term": "Pattern 种子词",
|
|
|
+ "piaoquan_topic_point": "票圈内容点",
|
|
|
+ "category_leaf_element": "分类叶子元素",
|
|
|
+ "query_next_page": "翻页继续找",
|
|
|
+ "tag_query": "根据标签继续搜",
|
|
|
+ "author_works": "查看作者更多作品",
|
|
|
+}
|
|
|
+ACTION_LABELS = {
|
|
|
+ "ADD_TO_CONTENT_POOL": "入池",
|
|
|
+ "KEEP_CONTENT_FOR_REVIEW": "待复看",
|
|
|
+ "REJECT_CONTENT": "淘汰",
|
|
|
+}
|
|
|
+MEDIA_STATUS_LABELS = {
|
|
|
+ "oss_uploaded": "已保存到 OSS",
|
|
|
+ "oss_upload_pending": "待补传",
|
|
|
+ "metadata_only": "尚未拿到视频",
|
|
|
+ "unavailable": "无可用视频",
|
|
|
+}
|
|
|
+WALK_EDGE_LABELS = {
|
|
|
+ "query_next_page": "翻页继续找",
|
|
|
+ "hashtag_to_query": "根据标签继续搜",
|
|
|
+ "tag_query": "根据标签继续搜",
|
|
|
+ "author_to_works": "查看作者更多作品",
|
|
|
+ "video_to_author": "查看作者更多作品",
|
|
|
+ "author_works": "查看作者更多作品",
|
|
|
+ "path_stop": "到这里停止",
|
|
|
+ "terminal": "到这里停止",
|
|
|
+}
|
|
|
+REASON_LABELS = {
|
|
|
+ "content_pattern_recall_required": "内容和本次需求没有形成有效关联",
|
|
|
+ "blocked_by_rule_decision": "上一步内容未通过判断",
|
|
|
+ "missing_content_portrait": "缺少判断所需的内容画像",
|
|
|
+ "category_or_element_binding_required": "没有命中本次需求的类目或要素",
|
|
|
+ "missing_score": "没有足够信息进入打分",
|
|
|
+ "v4_query_and_platform_pass": "搜索词和平台表现都符合要求",
|
|
|
+ "v4_query_or_score_below_threshold": "相关性或平台表现未达到要求",
|
|
|
+ "v4_technical_retry_needed": "系统需要重试后再判断",
|
|
|
+ "v4_allow_walk_denied": "不适合继续向外扩展",
|
|
|
+ "no_decision": "暂未产生判断结果",
|
|
|
+}
|
|
|
+
|
|
|
+_FLOW_LEDGER_CACHE: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
|
|
|
+_DEFAULT_CACHE_TTL_SECONDS = 300
|
|
|
+
|
|
|
+
|
|
|
+class FlowLedgerService:
|
|
|
+ def __init__(self, runtime: RuntimeStore) -> None:
|
|
|
+ dashboard = DashboardService.from_runtime(runtime)
|
|
|
+ self.runtime = dashboard.runtime
|
|
|
+ self.export_runtime = dashboard.export_runtime
|
|
|
+ self.data_origin = (
|
|
|
+ DATA_ORIGIN_PRODUCTION_DB
|
|
|
+ if isinstance(self.runtime, DatabaseRuntimeStore)
|
|
|
+ else DATA_ORIGIN_RUNTIME_EXPORT
|
|
|
+ )
|
|
|
+
|
|
|
+ def ledger(self, run_id: str, *, debug: bool = False) -> dict[str, Any]:
|
|
|
+ cache_key = ("ledger", run_id)
|
|
|
+ if not debug:
|
|
|
+ cached = _cache_get(cache_key)
|
|
|
+ if cached is not None:
|
|
|
+ return cached
|
|
|
+ bundle = self._bundle(run_id, cache=not debug)
|
|
|
+ rows = [
|
|
|
+ self._ledger_row(query, bundle, debug=debug)
|
|
|
+ for query in bundle["queries"]
|
|
|
+ if _query_method(query) in FIRST_ROUND_QUERY_METHODS
|
|
|
+ ]
|
|
|
+ extension_queries = [
|
|
|
+ self._extension_query(query, bundle, debug=debug)
|
|
|
+ for query in bundle["queries"]
|
|
|
+ if _query_method(query) in EXTENSION_QUERY_METHODS
|
|
|
+ ]
|
|
|
+ media_counts = Counter(_media_status(media) for media in bundle["media"])
|
|
|
+ action_counts = Counter(_decision_action(decision) for decision in bundle["decisions"])
|
|
|
+ gemini_counts = Counter(_text(evidence.get("decode_status") or evidence.get("gemini_status"), "unknown") for evidence in bundle["evidence"])
|
|
|
+ payload = {
|
|
|
+ "run_id": run_id,
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
|
|
|
+ "summary": self._summary(bundle, rows, extension_queries, media_counts, action_counts, gemini_counts),
|
|
|
+ "rows": rows,
|
|
|
+ "extension_queries": extension_queries,
|
|
|
+ "debug": bundle if debug else None,
|
|
|
+ }
|
|
|
+ if not debug:
|
|
|
+ _cache_set(cache_key, payload)
|
|
|
+ return payload
|
|
|
+
|
|
|
+ def query_videos(self, run_id: str, query_id: str, *, debug: bool = False) -> dict[str, Any]:
|
|
|
+ bundle = self._bundle(run_id, cache=not debug)
|
|
|
+ query = self._query_by_id(bundle, query_id)
|
|
|
+ related = self._query_content(query_id, bundle, first_round_only=True)
|
|
|
+ videos = [self._video(content, bundle, debug=debug) for content in related]
|
|
|
+ return {
|
|
|
+ "run_id": run_id,
|
|
|
+ "query": self._query_summary(query, bundle, debug=debug) if query else None,
|
|
|
+ "videos": videos,
|
|
|
+ "summary": self._video_summary(videos),
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
|
|
|
+ }
|
|
|
+
|
|
|
+ def query_walk(self, run_id: str, query_id: str, *, debug: bool = False) -> dict[str, Any]:
|
|
|
+ bundle = self._bundle(run_id, cache=not debug)
|
|
|
+ query = self._query_by_id(bundle, query_id)
|
|
|
+ related_ids = self._query_related_content_ids(query_id, bundle)
|
|
|
+ actions = [
|
|
|
+ self._walk_action(action, query, debug=debug)
|
|
|
+ for action in bundle["walk_actions"]
|
|
|
+ if _walk_action_related(action, query_id, related_ids)
|
|
|
+ ]
|
|
|
+ lanes: dict[str, list[dict[str, Any]]] = {key: [] for key in ["query_next_page", "tag_query", "author_works", "path_stop"]}
|
|
|
+ for action in actions:
|
|
|
+ lanes[_walk_lane(action["edge_id"])].append(action)
|
|
|
+ extension_queries = [
|
|
|
+ self._extension_query(item, bundle, debug=debug)
|
|
|
+ for item in bundle["queries"]
|
|
|
+ if _query_method(item) in EXTENSION_QUERY_METHODS
|
|
|
+ ]
|
|
|
+ return {
|
|
|
+ "run_id": run_id,
|
|
|
+ "query": self._query_summary(query, bundle, debug=debug) if query else None,
|
|
|
+ "actions": actions,
|
|
|
+ "lanes": lanes,
|
|
|
+ "extension_queries": extension_queries,
|
|
|
+ "summary": {
|
|
|
+ "total_actions": len(actions),
|
|
|
+ "success_count": sum(1 for item in actions if item["status"] == "success"),
|
|
|
+ "skipped_count": sum(1 for item in actions if item["status"] == "skipped"),
|
|
|
+ "extension_query_count": len(extension_queries),
|
|
|
+ },
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
|
|
|
+ }
|
|
|
+
|
|
|
+ def video_detail(self, run_id: str, content_id: str, *, debug: bool = False) -> dict[str, Any]:
|
|
|
+ bundle = self._bundle(run_id, cache=not debug)
|
|
|
+ content = next(
|
|
|
+ (
|
|
|
+ item
|
|
|
+ for item in bundle["content_items"]
|
|
|
+ if content_id in {
|
|
|
+ _text(item.get("platform_content_id")),
|
|
|
+ _text(item.get("content_discovery_id")),
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if not content:
|
|
|
+ return {
|
|
|
+ "run_id": run_id,
|
|
|
+ "video": None,
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
|
|
|
+ }
|
|
|
+ video = self._video(content, bundle, debug=debug)
|
|
|
+ platform_id = _text(content.get("platform_content_id"))
|
|
|
+ decision = _decision_for_content(content, bundle["decisions"])
|
|
|
+ source_paths = [
|
|
|
+ path
|
|
|
+ for path in bundle["source_paths"]
|
|
|
+ if platform_id and platform_id in {_text(path.get("from_node_id")), _text(path.get("to_node_id")), _text(path.get("decision_target_id"))}
|
|
|
+ ]
|
|
|
+ return {
|
|
|
+ "run_id": run_id,
|
|
|
+ "video": video,
|
|
|
+ "source_paths": [_source_path(path, debug=debug) for path in source_paths],
|
|
|
+ "decision": self._decision_summary(decision, debug=debug) if decision else None,
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
|
|
|
+ }
|
|
|
+
|
|
|
+ def _bundle(self, run_id: str, *, cache: bool = True) -> dict[str, Any]:
|
|
|
+ cache_key = ("bundle", run_id)
|
|
|
+ if cache:
|
|
|
+ cached = _cache_get(cache_key)
|
|
|
+ if cached is not None:
|
|
|
+ return cached
|
|
|
+ payload = {
|
|
|
+ "queries": self._read_jsonl(run_id, "search_queries.jsonl"),
|
|
|
+ "content_items": self._read_jsonl(run_id, "discovered_content_items.jsonl"),
|
|
|
+ "media": self._read_jsonl(run_id, "content_media_records.jsonl"),
|
|
|
+ "evidence": self._read_jsonl(run_id, "pattern_recall_evidence.jsonl"),
|
|
|
+ "decisions": self._read_jsonl(run_id, "rule_decisions.jsonl"),
|
|
|
+ "walk_actions": self._read_jsonl(run_id, "walk_actions.jsonl"),
|
|
|
+ "source_paths": self._read_jsonl(run_id, "source_path_records.jsonl"),
|
|
|
+ "final_output": self._read_json(run_id, "final_output.json") or {},
|
|
|
+ }
|
|
|
+ if cache:
|
|
|
+ _cache_set(cache_key, payload)
|
|
|
+ return payload
|
|
|
+
|
|
|
+ def _read_json(self, run_id: str, filename: str) -> dict[str, Any] | None:
|
|
|
+ try:
|
|
|
+ return self.runtime.read_json(run_id, filename)
|
|
|
+ except Exception:
|
|
|
+ if self.export_runtime:
|
|
|
+ try:
|
|
|
+ return self.export_runtime.read_json(run_id, filename)
|
|
|
+ except Exception:
|
|
|
+ return None
|
|
|
+ return None
|
|
|
+
|
|
|
+ def _read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]:
|
|
|
+ try:
|
|
|
+ return self.runtime.read_jsonl(run_id, filename)
|
|
|
+ except Exception:
|
|
|
+ if self.export_runtime:
|
|
|
+ try:
|
|
|
+ return self.export_runtime.read_jsonl(run_id, filename)
|
|
|
+ except Exception:
|
|
|
+ return []
|
|
|
+ return []
|
|
|
+
|
|
|
+ def _query_by_id(self, bundle: dict[str, Any], query_id: str) -> dict[str, Any] | None:
|
|
|
+ return next((query for query in bundle["queries"] if _text(query.get("search_query_id")) == query_id), None)
|
|
|
+
|
|
|
+ def _ledger_row(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ query_id = _text(query.get("search_query_id"))
|
|
|
+ videos = [self._video(content, bundle, debug=debug) for content in self._query_content(query_id, bundle, first_round_only=True)]
|
|
|
+ decisions = [video.get("decision") for video in videos if video.get("decision")]
|
|
|
+ related_ids = self._query_related_content_ids(query_id, bundle)
|
|
|
+ actions = [action for action in bundle["walk_actions"] if _walk_action_related(action, query_id, related_ids)]
|
|
|
+ source = self._source_summary(query, bundle, debug=debug)
|
|
|
+ return {
|
|
|
+ "id": query_id,
|
|
|
+ "source": source,
|
|
|
+ "query": self._query_summary(query, bundle, debug=debug),
|
|
|
+ "first_round_videos": videos[:3],
|
|
|
+ "first_round_video_total": len(videos),
|
|
|
+ "video_stats": self._video_summary(videos),
|
|
|
+ "rule_summary": self._rule_summary(decisions, debug=debug),
|
|
|
+ "walk_summary": self._walk_summary(actions),
|
|
|
+ "asset_summary": self._asset_summary(decisions, bundle["final_output"]),
|
|
|
+ "debug": {"query": query} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _query_content(self, query_id: str, bundle: dict[str, Any], *, first_round_only: bool) -> list[dict[str, Any]]:
|
|
|
+ items = [item for item in bundle["content_items"] if _content_belongs_to_query(item, query_id)]
|
|
|
+ if first_round_only:
|
|
|
+ items = [item for item in items if _is_first_round_content(item)]
|
|
|
+ return items
|
|
|
+
|
|
|
+ def _query_related_content_ids(self, query_id: str, bundle: dict[str, Any]) -> set[str]:
|
|
|
+ ids: set[str] = set()
|
|
|
+ for item in self._query_content(query_id, bundle, first_round_only=True):
|
|
|
+ ids.update(_content_ids(item))
|
|
|
+ return {item for item in ids if item}
|
|
|
+
|
|
|
+ def _source_summary(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ method = _query_method(query)
|
|
|
+ seed_ref = _record(query.get("pattern_seed_ref"))
|
|
|
+ raw_payload = _record(query.get("raw_payload"))
|
|
|
+ refs = _list(raw_payload.get("query_source_refs")) or _list(seed_ref.get("query_source_refs"))
|
|
|
+ source_text = (
|
|
|
+ _text(seed_ref.get("query_source_text"))
|
|
|
+ or _text(seed_ref.get("seed_term"))
|
|
|
+ or _text(query.get("search_query"))
|
|
|
+ )
|
|
|
+ terms = _text_list(query.get("query_source_terms"))
|
|
|
+ return {
|
|
|
+ "type": method,
|
|
|
+ "label": SOURCE_LABELS.get(method, "来源待确认"),
|
|
|
+ "evidence": source_text or (terms[0] if terms else "来源证据待确认"),
|
|
|
+ "source_ref": _text(seed_ref.get("source_post_id") or seed_ref.get("pattern_execution_id"), "来源编号待确认"),
|
|
|
+ "terms": terms,
|
|
|
+ "query_source_refs": refs,
|
|
|
+ "debug": {"pattern_seed_ref": seed_ref} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _query_summary(self, query: dict[str, Any] | None, bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ if not query:
|
|
|
+ return {
|
|
|
+ "id": "",
|
|
|
+ "text": "搜索词待确认",
|
|
|
+ "method": "unknown",
|
|
|
+ "method_label": "生成方式待确认",
|
|
|
+ "source_terms": [],
|
|
|
+ "effect_label": "处理状态待确认",
|
|
|
+ }
|
|
|
+ method = _query_method(query)
|
|
|
+ return {
|
|
|
+ "id": _text(query.get("search_query_id")),
|
|
|
+ "text": _text(query.get("search_query"), "搜索词待确认"),
|
|
|
+ "method": method,
|
|
|
+ "method_label": QUERY_METHOD_LABELS.get(method, "生成方式待确认"),
|
|
|
+ "source_terms": _text_list(query.get("query_source_terms")),
|
|
|
+ "effect_status": _text(query.get("search_query_effect_status"), "pending"),
|
|
|
+ "effect_label": _effect_label(_text(query.get("search_query_effect_status"), "pending")),
|
|
|
+ "debug": {"query": query} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _extension_query(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ summary = self._query_summary(query, bundle, debug=debug)
|
|
|
+ raw_payload = _record(query.get("raw_payload"))
|
|
|
+ summary.update({
|
|
|
+ "parent_query_id": _text(raw_payload.get("parent_search_query_id") or query.get("llm_variant_of")),
|
|
|
+ "trigger": _text(raw_payload.get("hashtag") or raw_payload.get("author_id") or raw_payload.get("cursor")),
|
|
|
+ })
|
|
|
+ return summary
|
|
|
+
|
|
|
+ def _video(self, content: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ platform_id = _text(content.get("platform_content_id"))
|
|
|
+ media = _media_for_content(content, bundle["media"])
|
|
|
+ evidence = _evidence_for_content(content, bundle["evidence"])
|
|
|
+ decision = _decision_for_content(content, bundle["decisions"])
|
|
|
+ status = _media_status(media)
|
|
|
+ stats = _record(content.get("statistics"))
|
|
|
+ return {
|
|
|
+ "id": platform_id or _text(content.get("content_discovery_id"), "content"),
|
|
|
+ "content_discovery_id": _text(content.get("content_discovery_id")),
|
|
|
+ "platform_content_id": platform_id,
|
|
|
+ "title": _text(content.get("description") or content.get("title"), "无标题视频"),
|
|
|
+ "author": _text(content.get("author_display_name") or content.get("platform_author_id"), "未知作者"),
|
|
|
+ "platform": _text(content.get("platform"), "unknown"),
|
|
|
+ "platform_label": _platform_label(_text(content.get("platform"), "unknown")),
|
|
|
+ "tags": _text_list(content.get("tags")),
|
|
|
+ "statistics": {key: _number(value) for key, value in stats.items()},
|
|
|
+ "published_at": _text(content.get("published_at") or content.get("publish_time")),
|
|
|
+ "oss_url": _text(media.get("oss_url")) if media else "",
|
|
|
+ "playable_url": _text(media.get("oss_url")) if media else "",
|
|
|
+ "media_status": status,
|
|
|
+ "media_status_label": MEDIA_STATUS_LABELS.get(status, "视频状态待确认"),
|
|
|
+ "media_failure_reason": _media_failure_reason(media),
|
|
|
+ "decision": self._decision_summary(decision, debug=debug) if decision else None,
|
|
|
+ "gemini": self._evidence_summary(evidence, debug=debug) if evidence else None,
|
|
|
+ "debug": {"content": content, "media": media, "evidence": evidence} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _decision_summary(self, decision: dict[str, Any] | None, *, debug: bool) -> dict[str, Any]:
|
|
|
+ if not decision:
|
|
|
+ return {
|
|
|
+ "action": "NOT_JUDGED",
|
|
|
+ "label": "未进入判断",
|
|
|
+ "reason": "no_decision",
|
|
|
+ "reason_label": REASON_LABELS["no_decision"],
|
|
|
+ }
|
|
|
+ reason = _text(decision.get("decision_reason_code"), "no_decision")
|
|
|
+ scorecard = _record(decision.get("scorecard"))
|
|
|
+ score = decision.get("score") if decision.get("score") is not None else scorecard.get("total_score")
|
|
|
+ action = _decision_action(decision)
|
|
|
+ return {
|
|
|
+ "id": _text(decision.get("decision_id")),
|
|
|
+ "action": action,
|
|
|
+ "label": ACTION_LABELS.get(action, "待判断"),
|
|
|
+ "reason": reason,
|
|
|
+ "reason_label": REASON_LABELS.get(reason, "系统原因待补充"),
|
|
|
+ "score": score,
|
|
|
+ "score_label": f"{round(float(score))} 分" if isinstance(score, (int, float)) else "",
|
|
|
+ "allow_walk": bool(_record(decision.get("decision_replay_data")).get("allow_walk")),
|
|
|
+ "v4_explanation": _record(decision.get("v4_explanation") or _record(decision.get("raw_payload")).get("v4_explanation")),
|
|
|
+ "debug": {"decision": decision} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _evidence_summary(self, evidence: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ status = _text(evidence.get("decode_status") or evidence.get("gemini_status"), "unknown")
|
|
|
+ return {
|
|
|
+ "status": status,
|
|
|
+ "label": "Gemini 已看过" if status in {"success", "ok", "passed"} else "Gemini 未完成判断",
|
|
|
+ "summary": _text(evidence.get("reason") or evidence.get("evidence_summary") or evidence.get("summary")),
|
|
|
+ "debug": {"evidence": evidence} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _rule_summary(self, decisions: list[dict[str, Any]], *, debug: bool) -> dict[str, Any]:
|
|
|
+ actions = Counter(_decision_action(item) for item in decisions)
|
|
|
+ reasons = Counter(_text(item.get("decision_reason_code") or item.get("reason"), "no_decision") for item in decisions)
|
|
|
+ primary_reason = reasons.most_common(1)[0][0] if reasons else "no_decision"
|
|
|
+ return {
|
|
|
+ "total": len(decisions),
|
|
|
+ "pool_count": actions["ADD_TO_CONTENT_POOL"],
|
|
|
+ "review_count": actions["KEEP_CONTENT_FOR_REVIEW"],
|
|
|
+ "reject_count": actions["REJECT_CONTENT"],
|
|
|
+ "primary_reason": primary_reason,
|
|
|
+ "primary_reason_label": REASON_LABELS.get(primary_reason, "系统原因待补充"),
|
|
|
+ "headline": _rule_headline(actions, len(decisions)),
|
|
|
+ "debug": {"decisions": decisions} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _walk_summary(self, actions: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
+ edges = Counter(_walk_lane_id(action) for action in actions)
|
|
|
+ reasons = Counter(_text(action.get("reason_code")) for action in actions if _text(action.get("reason_code")))
|
|
|
+ statuses = Counter(_text(action.get("walk_status"), "unknown") for action in actions)
|
|
|
+ return {
|
|
|
+ "total": len(actions),
|
|
|
+ "success_count": statuses["success"],
|
|
|
+ "skipped_count": statuses["skipped"],
|
|
|
+ "edge_counts": dict(edges),
|
|
|
+ "edge_labels": {key: WALK_EDGE_LABELS.get(key, "系统原因待补充") for key in edges},
|
|
|
+ "stop_reason": reasons.most_common(1)[0][0] if reasons else "",
|
|
|
+ "stop_reason_label": REASON_LABELS.get(reasons.most_common(1)[0][0], "本轮没有继续扩展") if reasons else "本轮没有继续扩展",
|
|
|
+ "headline": f"{len(actions)} 次扩展动作" if actions else "本轮没有继续扩展",
|
|
|
+ }
|
|
|
+
|
|
|
+ def _asset_summary(self, decisions: list[dict[str, Any]], final_output: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ decision_ids = {_text(item.get("decision_id") or item.get("id")) for item in decisions}
|
|
|
+ sections = {
|
|
|
+ "content_assets": "入池",
|
|
|
+ "review_records": "待复看",
|
|
|
+ "reject_records": "淘汰",
|
|
|
+ }
|
|
|
+ counts = {}
|
|
|
+ total = 0
|
|
|
+ for section, label in sections.items():
|
|
|
+ rows = [
|
|
|
+ item
|
|
|
+ for item in _list(final_output.get(section))
|
|
|
+ if not decision_ids or _text(item.get("decision_id")) in decision_ids
|
|
|
+ ]
|
|
|
+ counts[label] = len(rows)
|
|
|
+ total += len(rows)
|
|
|
+ return {
|
|
|
+ "total": total,
|
|
|
+ "pool_count": counts.get("入池", 0),
|
|
|
+ "review_count": counts.get("待复看", 0),
|
|
|
+ "reject_count": counts.get("淘汰", 0),
|
|
|
+ "headline": f"已沉淀 {counts.get('入池', 0)} 条内容" if counts.get("入池", 0) else "暂无可沉淀内容",
|
|
|
+ }
|
|
|
+
|
|
|
+ def _video_summary(self, videos: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
+ actions = Counter(_record(video.get("decision")).get("action") or "NOT_JUDGED" for video in videos)
|
|
|
+ media = Counter(video.get("media_status") or "unknown" for video in videos)
|
|
|
+ return {
|
|
|
+ "total": len(videos),
|
|
|
+ "pool_count": actions["ADD_TO_CONTENT_POOL"],
|
|
|
+ "review_count": actions["KEEP_CONTENT_FOR_REVIEW"],
|
|
|
+ "reject_count": actions["REJECT_CONTENT"],
|
|
|
+ "not_judged_count": actions["NOT_JUDGED"],
|
|
|
+ "oss_uploaded_count": media["oss_uploaded"],
|
|
|
+ "oss_pending_count": media["oss_upload_pending"],
|
|
|
+ "unavailable_count": media["unavailable"] + media["metadata_only"],
|
|
|
+ }
|
|
|
+
|
|
|
+ def _walk_action(self, action: dict[str, Any], query: dict[str, Any] | None, *, debug: bool) -> dict[str, Any]:
|
|
|
+ edge = _walk_lane_id(action)
|
|
|
+ reason = _text(action.get("reason_code"))
|
|
|
+ status = _text(action.get("walk_status"), "unknown")
|
|
|
+ return {
|
|
|
+ "id": _text(action.get("walk_action_id")),
|
|
|
+ "edge_id": edge,
|
|
|
+ "edge_label": WALK_EDGE_LABELS.get(edge, "系统原因待补充"),
|
|
|
+ "status": status,
|
|
|
+ "status_label": _walk_status_label(status),
|
|
|
+ "from_label": _walk_from_label(action, query),
|
|
|
+ "result": "已带入后续流程" if status == "success" else "没有继续执行",
|
|
|
+ "reason": reason,
|
|
|
+ "reason_label": REASON_LABELS.get(reason, "这一步没有记录额外原因") if reason else "这一步没有记录额外原因",
|
|
|
+ "impact": _walk_impact(status, reason),
|
|
|
+ "debug": {"action": action} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _summary(
|
|
|
+ self,
|
|
|
+ bundle: dict[str, Any],
|
|
|
+ rows: list[dict[str, Any]],
|
|
|
+ extension_queries: list[dict[str, Any]],
|
|
|
+ media_counts: Counter[str],
|
|
|
+ action_counts: Counter[str],
|
|
|
+ gemini_counts: Counter[str],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "first_round_query_count": len(rows),
|
|
|
+ "extension_query_count": len(extension_queries),
|
|
|
+ "all_query_count": len(bundle["queries"]),
|
|
|
+ "content_count": len(bundle["content_items"]),
|
|
|
+ "oss_uploaded_count": media_counts["oss_uploaded"],
|
|
|
+ "oss_pending_count": media_counts["oss_upload_pending"],
|
|
|
+ "gemini_completed_count": gemini_counts["ok"] + gemini_counts["success"] + gemini_counts["passed"],
|
|
|
+ "gemini_failed_count": gemini_counts["failed"],
|
|
|
+ "pool_count": action_counts["ADD_TO_CONTENT_POOL"],
|
|
|
+ "review_count": action_counts["KEEP_CONTENT_FOR_REVIEW"],
|
|
|
+ "reject_count": action_counts["REJECT_CONTENT"],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _query_method(query: dict[str, Any]) -> str:
|
|
|
+ return _text(query.get("search_query_generation_method"), "unknown")
|
|
|
+
|
|
|
+
|
|
|
+def _content_belongs_to_query(content: dict[str, Any], query_id: str) -> bool:
|
|
|
+ if not query_id:
|
|
|
+ return False
|
|
|
+ if _text(content.get("search_query_id")) == query_id:
|
|
|
+ return True
|
|
|
+ if query_id in _text_list(content.get("matched_search_query_ids")):
|
|
|
+ return True
|
|
|
+ raw_payload = _record(content.get("raw_payload"))
|
|
|
+ if query_id in _text_list(raw_payload.get("matched_search_query_ids")):
|
|
|
+ return True
|
|
|
+ for source in _list(content.get("query_sources")) + _list(raw_payload.get("query_sources")):
|
|
|
+ if _text(source.get("search_query_id")) == query_id:
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def _is_first_round_content(content: dict[str, Any]) -> bool:
|
|
|
+ previous = _text(content.get("previous_discovery_step") or _record(content.get("raw_payload")).get("previous_discovery_step"))
|
|
|
+ if not previous:
|
|
|
+ return True
|
|
|
+ return not any(token in previous for token in ["tag", "hashtag", "author", "next_page", "walk", "pagination"])
|
|
|
+
|
|
|
+
|
|
|
+def _content_ids(content: dict[str, Any]) -> set[str]:
|
|
|
+ return {
|
|
|
+ _text(content.get("content_discovery_id")),
|
|
|
+ _text(content.get("platform_content_id")),
|
|
|
+ _text(content.get("decision_target_id")),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _decision_for_content(content: dict[str, Any], decisions: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
|
+ ids = _content_ids(content)
|
|
|
+ return next((decision for decision in decisions if _text(decision.get("decision_target_id")) in ids), None)
|
|
|
+
|
|
|
+
|
|
|
+def _media_for_content(content: dict[str, Any], media_rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
|
+ platform_id = _text(content.get("platform_content_id"))
|
|
|
+ return next((media for media in media_rows if _text(media.get("platform_content_id")) == platform_id), None)
|
|
|
+
|
|
|
+
|
|
|
+def _evidence_for_content(content: dict[str, Any], evidence_rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
|
+ platform_id = _text(content.get("platform_content_id"))
|
|
|
+ discovery_id = _text(content.get("content_discovery_id"))
|
|
|
+ return next(
|
|
|
+ (
|
|
|
+ evidence
|
|
|
+ for evidence in evidence_rows
|
|
|
+ if _text(evidence.get("platform_content_id")) == platform_id
|
|
|
+ or _text(evidence.get("content_discovery_id")) == discovery_id
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _walk_action_related(action: dict[str, Any], query_id: str, related_ids: set[str]) -> bool:
|
|
|
+ values = {
|
|
|
+ _text(action.get("from_node_id")),
|
|
|
+ _text(action.get("to_node_id")),
|
|
|
+ _text(action.get("decision_target_id")),
|
|
|
+ }
|
|
|
+ raw = _record(action.get("raw_payload"))
|
|
|
+ values.update({
|
|
|
+ _text(raw.get("parent_search_query_id")),
|
|
|
+ _text(raw.get("search_query_id")),
|
|
|
+ _text(raw.get("decision_target_id")),
|
|
|
+ })
|
|
|
+ return query_id in values or bool(values & related_ids)
|
|
|
+
|
|
|
+
|
|
|
+def _walk_lane_id(action: dict[str, Any]) -> str:
|
|
|
+ return _text(action.get("edge_id") or action.get("edge_type"), "unknown")
|
|
|
+
|
|
|
+
|
|
|
+def _walk_lane(edge: str) -> str:
|
|
|
+ if edge in {"query_next_page"}:
|
|
|
+ return "query_next_page"
|
|
|
+ if edge in {"hashtag_to_query", "tag_query"}:
|
|
|
+ return "tag_query"
|
|
|
+ if edge in {"author_to_works", "video_to_author", "author_works"}:
|
|
|
+ return "author_works"
|
|
|
+ return "path_stop"
|
|
|
+
|
|
|
+
|
|
|
+def _source_path(path: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "type": _text(path.get("source_path_type")),
|
|
|
+ "label": "内容来源路径",
|
|
|
+ "status": _text(path.get("status"), "unknown"),
|
|
|
+ "debug": {"source_path": path} if debug else None,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _decision_action(decision: dict[str, Any]) -> str:
|
|
|
+ action = _text(decision.get("decision_action") or decision.get("action"), "NOT_JUDGED")
|
|
|
+ if action == "ADD_CONTENT_TO_POOL":
|
|
|
+ return "ADD_TO_CONTENT_POOL"
|
|
|
+ return action
|
|
|
+
|
|
|
+
|
|
|
+def _media_status(media: dict[str, Any] | None) -> str:
|
|
|
+ if not media:
|
|
|
+ return "unavailable"
|
|
|
+ return _text(media.get("content_media_status"), "metadata_only")
|
|
|
+
|
|
|
+
|
|
|
+def _media_failure_reason(media: dict[str, Any] | None) -> str:
|
|
|
+ if not media:
|
|
|
+ return ""
|
|
|
+ raw = _record(media.get("raw_payload"))
|
|
|
+ return _text(media.get("failure_reason") or raw.get("upload_failure_reason") or raw.get("failure_reason"))
|
|
|
+
|
|
|
+
|
|
|
+def _rule_headline(actions: Counter[str], total: int) -> str:
|
|
|
+ if not total:
|
|
|
+ return "首轮内容暂未进入判断"
|
|
|
+ if actions["ADD_TO_CONTENT_POOL"]:
|
|
|
+ return f"有 {actions['ADD_TO_CONTENT_POOL']} 条内容入池"
|
|
|
+ if actions["KEEP_CONTENT_FOR_REVIEW"]:
|
|
|
+ return f"有 {actions['KEEP_CONTENT_FOR_REVIEW']} 条内容待复看"
|
|
|
+ return "本轮没有可沉淀内容"
|
|
|
+
|
|
|
+
|
|
|
+def _effect_label(status: str) -> str:
|
|
|
+ return {
|
|
|
+ "success": "已找到可用结果",
|
|
|
+ "rule_blocked": "找到结果,但未通过内容判断",
|
|
|
+ "pending": "处理中",
|
|
|
+ "failed": "搜索失败",
|
|
|
+ "skipped": "已跳过",
|
|
|
+ }.get(status, "处理状态待确认")
|
|
|
+
|
|
|
+
|
|
|
+def _walk_status_label(status: str) -> str:
|
|
|
+ return {
|
|
|
+ "success": "已执行",
|
|
|
+ "skipped": "未继续",
|
|
|
+ "failed": "执行失败",
|
|
|
+ "pending": "处理中",
|
|
|
+ }.get(status, "状态待确认")
|
|
|
+
|
|
|
+
|
|
|
+def _walk_from_label(action: dict[str, Any], query: dict[str, Any] | None) -> str:
|
|
|
+ from_type = _text(action.get("from_node_type"))
|
|
|
+ if "query" in from_type:
|
|
|
+ return f"从搜索词“{_text((query or {}).get('search_query'), '搜索词待确认')}”出发"
|
|
|
+ if "content" in from_type or "video" in from_type:
|
|
|
+ return "从视频内容出发"
|
|
|
+ if "tag" in from_type:
|
|
|
+ return "从视频标签出发"
|
|
|
+ return "从上一环节出发"
|
|
|
+
|
|
|
+
|
|
|
+def _walk_impact(status: str, reason: str) -> str:
|
|
|
+ if reason == "blocked_by_rule_decision":
|
|
|
+ return "这条内容不会继续带出更多视频。"
|
|
|
+ if status == "success":
|
|
|
+ return "这一步已经执行,结果会进入后续判断。"
|
|
|
+ if status == "skipped":
|
|
|
+ return "这一步没有继续执行,不影响最终沉淀。"
|
|
|
+ return "这一步暂不影响最终沉淀。"
|
|
|
+
|
|
|
+
|
|
|
+def _platform_label(platform: str) -> str:
|
|
|
+ return {
|
|
|
+ "douyin": "抖音",
|
|
|
+ "tiktok": "TikTok",
|
|
|
+ "bilibili": "B 站",
|
|
|
+ "kuaishou": "快手",
|
|
|
+ "xhs": "小红书",
|
|
|
+ }.get(platform.lower(), platform or "平台待确认")
|
|
|
+
|
|
|
+
|
|
|
+def _text(value: Any, default: str = "") -> str:
|
|
|
+ if value is None:
|
|
|
+ return default
|
|
|
+ text = str(value).strip()
|
|
|
+ return text or default
|
|
|
+
|
|
|
+
|
|
|
+def _text_list(value: Any) -> list[str]:
|
|
|
+ if isinstance(value, list):
|
|
|
+ return [_text(item) for item in value if _text(item)]
|
|
|
+ if isinstance(value, str) and value.strip():
|
|
|
+ return [value.strip()]
|
|
|
+ return []
|
|
|
+
|
|
|
+
|
|
|
+def _record(value: Any) -> dict[str, Any]:
|
|
|
+ return value if isinstance(value, dict) else {}
|
|
|
+
|
|
|
+
|
|
|
+def _list(value: Any) -> list[dict[str, Any]]:
|
|
|
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
|
+
|
|
|
+
|
|
|
+def _number(value: Any) -> int:
|
|
|
+ try:
|
|
|
+ return int(float(value))
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+def _cache_ttl_seconds() -> int:
|
|
|
+ raw = os.environ.get("CONTENT_AGENT_FLOW_LEDGER_CACHE_TTL_SECONDS")
|
|
|
+ if raw is None:
|
|
|
+ return _DEFAULT_CACHE_TTL_SECONDS
|
|
|
+ try:
|
|
|
+ return max(0, int(raw))
|
|
|
+ except ValueError:
|
|
|
+ return _DEFAULT_CACHE_TTL_SECONDS
|
|
|
+
|
|
|
+
|
|
|
+def _cache_get(key: tuple[str, str]) -> dict[str, Any] | None:
|
|
|
+ ttl = _cache_ttl_seconds()
|
|
|
+ if ttl <= 0:
|
|
|
+ return None
|
|
|
+ cached = _FLOW_LEDGER_CACHE.get(key)
|
|
|
+ if not cached:
|
|
|
+ return None
|
|
|
+ expires_at, payload = cached
|
|
|
+ if expires_at <= time.monotonic():
|
|
|
+ _FLOW_LEDGER_CACHE.pop(key, None)
|
|
|
+ return None
|
|
|
+ return copy.deepcopy(payload)
|
|
|
+
|
|
|
+
|
|
|
+def _cache_set(key: tuple[str, str], payload: dict[str, Any]) -> None:
|
|
|
+ ttl = _cache_ttl_seconds()
|
|
|
+ if ttl <= 0:
|
|
|
+ return
|
|
|
+ _FLOW_LEDGER_CACHE[key] = (time.monotonic() + ttl, copy.deepcopy(payload))
|