judging.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. """品类+解构词 画面改造可改造性 LLM 批量判定。"""
  2. from __future__ import annotations
  3. import time
  4. from typing import Any
  5. from app.core.open_router_llm import OpenRouterCallError, create_chat_completion
  6. from app.gap_script_demand.exceptions import GapScriptDemandError
  7. from app.gap_script_demand.llm_json import extract_json_object
  8. from app.gap_script_demand.types import GapScriptDemandConfig
  9. JUDGE_SYSTEM_PROMPT = """
  10. 你是一个专业的视频内容可改造性评估专家。你的任务是对给定的“品类 + 解构词”组合进行判定,筛选出其中适合用于画面改造的条目。
  11. 背景定义(已内置,无需输出解释)
  12. - 画面改造:指替换原视频的画面与语音包,达到类似洗稿的效果。
  13. - 解构词:以“品类 解构词”形式呈现的内容标签。
  14. 筛选目标
  15. 仅保留那些能够通过替换画面和语音包,生成一个主题明确、内容可独立成立的新视频的解构词组合。
  16. 淘汰标准(满足任意一条即判定为“不匹配”)
  17. 1. 画面主导型内容:原视频的核心价值几乎完全依赖视觉呈现,缺乏可被文字/语音替代的叙事或信息逻辑。典型示例包括但不限于:
  18. - 美景分享(如风景航拍、城市掠影)
  19. - 表演类(魔术、舞蹈、杂技、花式运动)
  20. - 旅行记录(无解说/无主题的Vlog)
  21. - 纯音乐/演奏(无歌词或无故事线)
  22. - 祝福类(节日祝贺、生日祝福画面)
  23. - 宠物日常(无情节的萌宠片段) 此类内容即使替换画面和语音,也无法保留原有吸引力或会彻底改变内容性质,故不可改造。
  24. 2. 无意义/弱主题解构词:解构词本身过于宽泛、模糊或与品类关联度极低,无法推导出具体的可替换内容框架。例如:
  25. - 解构词为“日常”“合集”“精选”“欣赏”“感受”等无法指向具体场景、动作或叙事的词汇;
  26. - 解构词与品类组合后,仍不能明确该视频要表达什么事件、过程或观点(如“美食 好吃”“旅行 好看”)。
  27. 判定原则
  28. - 综合判断:必须同时参考品类和解构词,两者共同决定主题明确性。例如:
  29. - “美食 教程” → 主题明确(有操作流程),可改造 ✅
  30. - “美食 展示” → 仅画面陈列,无过程,不可改造 ❌
  31. - “舞蹈 教学” → 有动作拆解和语音指导,可改造 ✅
  32. - “舞蹈 表演” → 纯视觉观赏,不可改造 ❌
  33. - 主题可迁移性:如果替换画面和语音后,新视频仍能传递相同的信息价值(如知识、步骤、故事、观点),则判定为可改造;否则为不可改造。
  34. 输出要求
  35. 严格只输出一个 JSON 对象,禁止输出 JSON 之外的任何内容。固定格式如下:
  36. {
  37. "matched_demand_names": ["可改造的品类 解构词1", "可改造的品类 解构词2"]
  38. }
  39. 约束:
  40. - 只返回判定为可改造(匹配)的条目,不匹配的不要放入数组。
  41. - matched_demand_names 中的每一项必须与用户提供的原文完全一致,不得改写、不得编造。
  42. - 若没有任何条目可改造,返回 {"matched_demand_names": []}。
  43. """.strip()
  44. def _chunked(items: list[str], batch_size: int) -> list[list[str]]:
  45. size = max(batch_size, 1)
  46. return [items[index : index + size] for index in range(0, len(items), size)]
  47. def _build_demand_lookup(demand_names: list[str]) -> dict[str, str]:
  48. lookup: dict[str, str] = {}
  49. for item in demand_names:
  50. demand_name = str(item).strip()
  51. if not demand_name:
  52. continue
  53. lookup.setdefault(demand_name, demand_name)
  54. compact_key = "".join(demand_name.split())
  55. if compact_key:
  56. lookup.setdefault(compact_key, demand_name)
  57. return lookup
  58. def _resolve_demand_name(demand_name: str, demand_lookup: dict[str, str]) -> str | None:
  59. value = demand_name.strip()
  60. if not value:
  61. return None
  62. return demand_lookup.get(value) or demand_lookup.get("".join(value.split()))
  63. def _build_batch_user_message(demand_names: list[str]) -> str:
  64. feature_lines = "\n".join(
  65. f"{index}. {demand_name}" for index, demand_name in enumerate(demand_names, start=1)
  66. )
  67. return f"""请对以下“品类 解构词”组合逐条判定是否适合画面改造:
  68. {feature_lines}"""
  69. def _extract_matched_name_list(parsed: Any) -> list[Any] | None:
  70. if not isinstance(parsed, dict):
  71. return None
  72. for key in ("matched_demand_names", "matched", "demand_names", "results"):
  73. value = parsed.get(key)
  74. if isinstance(value, list):
  75. return value
  76. return None
  77. def _normalize_matched_demand_names(
  78. parsed: Any,
  79. *,
  80. demand_names: list[str],
  81. demand_lookup: dict[str, str],
  82. ) -> list[str]:
  83. matched_raw = _extract_matched_name_list(parsed)
  84. if matched_raw is None:
  85. raise GapScriptDemandError("llm output missing matched_demand_names")
  86. allowed_demand_names = set(demand_names)
  87. normalized: list[str] = []
  88. seen: set[str] = set()
  89. for item in matched_raw:
  90. if isinstance(item, str):
  91. raw_name = item
  92. elif isinstance(item, dict):
  93. raw_name = str(
  94. item.get("demand_name")
  95. or item.get("name")
  96. or item.get("品类 解构词")
  97. or ""
  98. )
  99. else:
  100. continue
  101. demand_name = _resolve_demand_name(str(raw_name), demand_lookup)
  102. if not demand_name or demand_name not in allowed_demand_names:
  103. continue
  104. if demand_name in seen:
  105. continue
  106. seen.add(demand_name)
  107. normalized.append(demand_name)
  108. return normalized
  109. def _llm_judge_batch(
  110. *,
  111. demand_names: list[str],
  112. config: GapScriptDemandConfig,
  113. ) -> list[str]:
  114. if not demand_names:
  115. return []
  116. demand_lookup = _build_demand_lookup(demand_names)
  117. user_message = _build_batch_user_message(demand_names)
  118. last_error: Exception | None = None
  119. for attempt in range(1, config.llm_max_attempts + 1):
  120. try:
  121. resp = create_chat_completion(
  122. [
  123. {"role": "system", "content": JUDGE_SYSTEM_PROMPT},
  124. {"role": "user", "content": user_message},
  125. ],
  126. model=config.llm_model,
  127. temperature=config.llm_temperature,
  128. max_tokens=config.llm_max_tokens,
  129. )
  130. parsed = extract_json_object(str(resp.get("content") or ""))
  131. return _normalize_matched_demand_names(
  132. parsed,
  133. demand_names=demand_names,
  134. demand_lookup=demand_lookup,
  135. )
  136. except (OpenRouterCallError, GapScriptDemandError, ValueError) as exc:
  137. last_error = exc
  138. if attempt < config.llm_max_attempts:
  139. time.sleep(config.llm_retry_sleep_seconds)
  140. raise GapScriptDemandError(
  141. f"gap script demand judge failed after {config.llm_max_attempts} attempts: {last_error}"
  142. ) from last_error
  143. def judge_remake_suitable_demands(
  144. *,
  145. demand_names: list[str],
  146. config: GapScriptDemandConfig,
  147. ) -> list[str]:
  148. """按批次用 LLM 筛选适合画面改造的品类+解构词。"""
  149. if not demand_names:
  150. return []
  151. matched: list[str] = []
  152. seen: set[str] = set()
  153. batches = _chunked(demand_names, config.judge_batch_size)
  154. total_batches = len(batches)
  155. for batch_index, batch in enumerate(batches, start=1):
  156. print(
  157. f"gap script demand: judging batch {batch_index}/{total_batches} "
  158. f"size={len(batch)}",
  159. flush=True,
  160. )
  161. batch_matched = _llm_judge_batch(demand_names=batch, config=config)
  162. for demand_name in batch_matched:
  163. if demand_name in seen:
  164. continue
  165. seen.add(demand_name)
  166. matched.append(demand_name)
  167. return matched