| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- """品类+解构词 画面改造可改造性 LLM 批量判定。"""
- from __future__ import annotations
- import time
- from typing import Any
- from app.core.open_router_llm import OpenRouterCallError, create_chat_completion
- from app.gap_script_demand.exceptions import GapScriptDemandError
- from app.gap_script_demand.llm_json import extract_json_object
- from app.gap_script_demand.types import GapScriptDemandConfig
- JUDGE_SYSTEM_PROMPT = """
- 你是一个专业的视频内容可改造性评估专家。你的任务是对给定的“品类 + 解构词”组合进行判定,筛选出其中适合用于画面改造的条目。
- 背景定义(已内置,无需输出解释)
- - 画面改造:指替换原视频的画面与语音包,达到类似洗稿的效果。
- - 解构词:以“品类 解构词”形式呈现的内容标签。
- 筛选目标
- 仅保留那些能够通过替换画面和语音包,生成一个主题明确、内容可独立成立的新视频的解构词组合。
- 淘汰标准(满足任意一条即判定为“不匹配”)
- 1. 画面主导型内容:原视频的核心价值几乎完全依赖视觉呈现,缺乏可被文字/语音替代的叙事或信息逻辑。典型示例包括但不限于:
- - 美景分享(如风景航拍、城市掠影)
- - 表演类(魔术、舞蹈、杂技、花式运动)
- - 旅行记录(无解说/无主题的Vlog)
- - 纯音乐/演奏(无歌词或无故事线)
- - 祝福类(节日祝贺、生日祝福画面)
- - 宠物日常(无情节的萌宠片段) 此类内容即使替换画面和语音,也无法保留原有吸引力或会彻底改变内容性质,故不可改造。
- 2. 无意义/弱主题解构词:解构词本身过于宽泛、模糊或与品类关联度极低,无法推导出具体的可替换内容框架。例如:
- - 解构词为“日常”“合集”“精选”“欣赏”“感受”等无法指向具体场景、动作或叙事的词汇;
- - 解构词与品类组合后,仍不能明确该视频要表达什么事件、过程或观点(如“美食 好吃”“旅行 好看”)。
- 判定原则
- - 综合判断:必须同时参考品类和解构词,两者共同决定主题明确性。例如:
- - “美食 教程” → 主题明确(有操作流程),可改造 ✅
- - “美食 展示” → 仅画面陈列,无过程,不可改造 ❌
- - “舞蹈 教学” → 有动作拆解和语音指导,可改造 ✅
- - “舞蹈 表演” → 纯视觉观赏,不可改造 ❌
- - 主题可迁移性:如果替换画面和语音后,新视频仍能传递相同的信息价值(如知识、步骤、故事、观点),则判定为可改造;否则为不可改造。
- 输出要求
- 严格只输出一个 JSON 对象,禁止输出 JSON 之外的任何内容。固定格式如下:
- {
- "matched_demand_names": ["可改造的品类 解构词1", "可改造的品类 解构词2"]
- }
- 约束:
- - 只返回判定为可改造(匹配)的条目,不匹配的不要放入数组。
- - matched_demand_names 中的每一项必须与用户提供的原文完全一致,不得改写、不得编造。
- - 若没有任何条目可改造,返回 {"matched_demand_names": []}。
- """.strip()
- def _chunked(items: list[str], batch_size: int) -> list[list[str]]:
- size = max(batch_size, 1)
- return [items[index : index + size] for index in range(0, len(items), size)]
- def _build_demand_lookup(demand_names: list[str]) -> dict[str, str]:
- lookup: dict[str, str] = {}
- for item in demand_names:
- demand_name = str(item).strip()
- if not demand_name:
- continue
- lookup.setdefault(demand_name, demand_name)
- compact_key = "".join(demand_name.split())
- if compact_key:
- lookup.setdefault(compact_key, demand_name)
- return lookup
- def _resolve_demand_name(demand_name: str, demand_lookup: dict[str, str]) -> str | None:
- value = demand_name.strip()
- if not value:
- return None
- return demand_lookup.get(value) or demand_lookup.get("".join(value.split()))
- def _build_batch_user_message(demand_names: list[str]) -> str:
- feature_lines = "\n".join(
- f"{index}. {demand_name}" for index, demand_name in enumerate(demand_names, start=1)
- )
- return f"""请对以下“品类 解构词”组合逐条判定是否适合画面改造:
- {feature_lines}"""
- def _extract_matched_name_list(parsed: Any) -> list[Any] | None:
- if not isinstance(parsed, dict):
- return None
- for key in ("matched_demand_names", "matched", "demand_names", "results"):
- value = parsed.get(key)
- if isinstance(value, list):
- return value
- return None
- def _normalize_matched_demand_names(
- parsed: Any,
- *,
- demand_names: list[str],
- demand_lookup: dict[str, str],
- ) -> list[str]:
- matched_raw = _extract_matched_name_list(parsed)
- if matched_raw is None:
- raise GapScriptDemandError("llm output missing matched_demand_names")
- allowed_demand_names = set(demand_names)
- normalized: list[str] = []
- seen: set[str] = set()
- for item in matched_raw:
- if isinstance(item, str):
- raw_name = item
- elif isinstance(item, dict):
- raw_name = str(
- item.get("demand_name")
- or item.get("name")
- or item.get("品类 解构词")
- or ""
- )
- else:
- continue
- demand_name = _resolve_demand_name(str(raw_name), demand_lookup)
- if not demand_name or demand_name not in allowed_demand_names:
- continue
- if demand_name in seen:
- continue
- seen.add(demand_name)
- normalized.append(demand_name)
- return normalized
- def _llm_judge_batch(
- *,
- demand_names: list[str],
- config: GapScriptDemandConfig,
- ) -> list[str]:
- if not demand_names:
- return []
- demand_lookup = _build_demand_lookup(demand_names)
- user_message = _build_batch_user_message(demand_names)
- last_error: Exception | None = None
- for attempt in range(1, config.llm_max_attempts + 1):
- try:
- resp = create_chat_completion(
- [
- {"role": "system", "content": JUDGE_SYSTEM_PROMPT},
- {"role": "user", "content": user_message},
- ],
- model=config.llm_model,
- temperature=config.llm_temperature,
- max_tokens=config.llm_max_tokens,
- )
- parsed = extract_json_object(str(resp.get("content") or ""))
- return _normalize_matched_demand_names(
- parsed,
- demand_names=demand_names,
- demand_lookup=demand_lookup,
- )
- except (OpenRouterCallError, GapScriptDemandError, ValueError) as exc:
- last_error = exc
- if attempt < config.llm_max_attempts:
- time.sleep(config.llm_retry_sleep_seconds)
- raise GapScriptDemandError(
- f"gap script demand judge failed after {config.llm_max_attempts} attempts: {last_error}"
- ) from last_error
- def judge_remake_suitable_demands(
- *,
- demand_names: list[str],
- config: GapScriptDemandConfig,
- ) -> list[str]:
- """按批次用 LLM 筛选适合画面改造的品类+解构词。"""
- if not demand_names:
- return []
- matched: list[str] = []
- seen: set[str] = set()
- batches = _chunked(demand_names, config.judge_batch_size)
- total_batches = len(batches)
- for batch_index, batch in enumerate(batches, start=1):
- print(
- f"gap script demand: judging batch {batch_index}/{total_batches} "
- f"size={len(batch)}",
- flush=True,
- )
- batch_matched = _llm_judge_batch(demand_names=batch, config=config)
- for demand_name in batch_matched:
- if demand_name in seen:
- continue
- seen.add(demand_name)
- matched.append(demand_name)
- return matched
|