| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- """需求词与当天活跃节日/节点事件的 LLM 语义匹配。"""
- from __future__ import annotations
- import json
- import time
- from typing import Any
- from app.core.open_router_llm import OpenRouterCallError, create_chat_completion
- from app.festival_demand.exceptions import FestivalDemandError
- from app.festival_demand.llm_json import extract_json_object
- from app.festival_demand.types import FestivalDemandConfig
- MATCH_SYSTEM_PROMPT = """
- 你是一个专业的视频内容主题匹配判断助手。你的任务是根据给定的"节点事件"及其类型,判断"视频特征词"是否符合该节点事件的核心主题,从而确定视频是否属于此节点事件。
- 判断规则
- 请严格参照以下节点事件类型与主题对应关系:
- - 类型为"节日/节气":主题以祝福、知识分享为主(如节日祝福、民俗科普、节气养生知识等)。
- - 类型为"纪念日":主题以家国仇恨、勿忘国耻、怀念英烈、歌颂伟人事迹为主(如历史事件回顾、英雄人物纪念等)。
- - 类型为"特定天气时段":主题以描述天气、健康养生为主(如天气变化提醒、时令养生方法等)。
- - 类型为"特定民生热点":主题以民生、知识分享为主(如政策解读、考试备考、消费警示等)。
- 输入信息
- 用户会提供以下内容:
- - 节点事件名称:<事件名称>
- - 节点事件类型:<从"节日/节气、纪念日、特定天气时段、特定民生热点"中选择其一>
- - 视频特征词列表:多个待判断的特征词
- 判断逻辑
- 1. 首先明确该节点事件所属类型对应的允许主题范围。
- 2. 逐条分析每个视频特征词所表达的核心内容。若特征词明显指向上述主题范围(不需要完全一致,语义相近、属正常延伸即可),则判定匹配;若特征词与主题无关(如纯娱乐、无关的日常生活、其他不相干领域),则判定不匹配。
- 3. 如遇模棱两可的情况,倾向于严格判断,即只有当特征词明显属于主题方向时才匹配,否则不匹配。
- 输出要求
- 严格只输出一个 JSON 对象,禁止输出 JSON 之外的任何内容。固定格式如下:
- {
- "matched_demand_names": ["匹配成功的特征词1", "匹配成功的特征词2"]
- }
- 约束:
- - 只返回判定为匹配的特征词,不匹配的不要放入数组。
- - matched_demand_names 中的每一项必须与用户提供的视频特征词原文完全一致,不得改写、不得编造。
- - 若没有任何特征词匹配,返回 {"matched_demand_names": []}。
- 示例1:
- 节点事件名称:春节
- 节点事件类型:节日/节气
- 视频特征词列表:拜年祝福、年夜饭做法、搞笑模仿
- 输出:{"matched_demand_names": ["拜年祝福", "年夜饭做法"]}
- 示例2:
- 节点事件名称:九一八事变纪念日
- 节点事件类型:纪念日
- 视频特征词列表:搞笑模仿、宠物日常
- 输出:{"matched_demand_names": []}
- 示例3:
- 节点事件名称:三伏
- 节点事件类型:特定天气时段
- 视频特征词列表:防暑小妙招、三伏贴教程、明星八卦
- 输出:{"matched_demand_names": ["防暑小妙招", "三伏贴教程"]}
- 现在,请根据上述规则处理用户输入。
- """
- 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(
- *,
- festival_name: str,
- event_type: str,
- 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"""节点事件名称:{festival_name}
- 节点事件类型:{event_type}
- 视频特征词列表:
- {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,
- *,
- festival_name: str,
- event_type: str,
- demand_names: list[str],
- demand_lookup: dict[str, str],
- ) -> list[dict[str, Any]]:
- matched_raw = _extract_matched_name_list(parsed)
- if matched_raw is None:
- raise FestivalDemandError("llm output missing matched_demand_names")
- allowed_demand_names = set(demand_names)
- normalized: list[dict[str, Any]] = []
- 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("feature_word")
- or item.get("视频特征词")
- or item.get("name")
- 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": demand_name,
- "festival_name": festival_name,
- "event_type": event_type,
- }
- )
- return normalized
- def _llm_match_festival_batch(
- *,
- festival: dict[str, Any],
- demand_names: list[str],
- config: FestivalDemandConfig,
- ) -> list[dict[str, Any]]:
- festival_name = str(festival.get("name") or "").strip()
- event_type = str(festival.get("event_type") or "").strip()
- if not festival_name or not demand_names:
- return []
- demand_lookup = _build_demand_lookup(demand_names)
- user_message = _build_batch_user_message(
- festival_name=festival_name,
- event_type=event_type,
- demand_names=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": MATCH_SYSTEM_PROMPT.strip()},
- {"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,
- festival_name=festival_name,
- event_type=event_type,
- demand_names=demand_names,
- demand_lookup=demand_lookup,
- )
- except (OpenRouterCallError, FestivalDemandError, ValueError) as exc:
- last_error = exc
- if attempt < config.llm_max_attempts:
- time.sleep(config.llm_retry_sleep_seconds)
- raise FestivalDemandError(
- f"festival demand match failed for {festival_name} "
- f"after {config.llm_max_attempts} attempts: {last_error}"
- ) from last_error
- def match_demand_names_to_festivals(
- *,
- demand_names: list[str],
- festivals: list[dict[str, Any]],
- config: FestivalDemandConfig,
- ) -> list[dict[str, Any]]:
- """按批次将需求词与活跃节日列表做 LLM 匹配。"""
- if not demand_names or not festivals:
- return []
- all_matches: list[dict[str, Any]] = []
- seen: set[tuple[str, str]] = set()
- batches = _chunked(demand_names, config.match_batch_size)
- total_batches = len(batches)
- for batch_index, batch in enumerate(batches, start=1):
- for festival in festivals:
- festival_name = str(festival.get("name") or "").strip()
- print(
- f"festival demand: matching batch {batch_index}/{total_batches} "
- f"festival={festival_name} size={len(batch)}",
- flush=True,
- )
- batch_matches = _llm_match_festival_batch(
- festival=festival,
- demand_names=batch,
- config=config,
- )
- for row in batch_matches:
- dedupe_key = (row["demand_name"], row["festival_name"])
- if dedupe_key in seen:
- continue
- seen.add(dedupe_key)
- all_matches.append(row)
- return all_matches
- def collect_matched_demand_names(matches: list[dict[str, Any]]) -> list[str]:
- """收集去重后的 demand_name 列表。"""
- names: list[str] = []
- seen: set[str] = set()
- for item in matches:
- demand_name = str(item.get("demand_name") or "").strip()
- if not demand_name or demand_name in seen:
- continue
- seen.add(demand_name)
- names.append(demand_name)
- return names
|