matching.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """需求词与当天活跃节日/节点事件的 LLM 语义匹配。"""
  2. from __future__ import annotations
  3. import json
  4. import time
  5. from typing import Any
  6. from app.core.open_router_llm import OpenRouterCallError, create_chat_completion
  7. from app.festival_demand.exceptions import FestivalDemandError
  8. from app.festival_demand.llm_json import extract_json_object
  9. from app.festival_demand.types import FestivalDemandConfig
  10. MATCH_SYSTEM_PROMPT = """
  11. 你是一个专业的视频内容主题匹配判断助手。你的任务是根据给定的"节点事件"及其类型,判断"视频特征词"是否符合该节点事件的核心主题,从而确定视频是否属于此节点事件。
  12. 判断规则
  13. 请严格参照以下节点事件类型与主题对应关系:
  14. - 类型为"节日/节气":主题以祝福、知识分享为主(如节日祝福、民俗科普、节气养生知识等)。
  15. - 类型为"纪念日":主题以家国仇恨、勿忘国耻、怀念英烈、歌颂伟人事迹为主(如历史事件回顾、英雄人物纪念等)。
  16. - 类型为"特定天气时段":主题以描述天气、健康养生为主(如天气变化提醒、时令养生方法等)。
  17. - 类型为"特定民生热点":主题以民生、知识分享为主(如政策解读、考试备考、消费警示等)。
  18. 输入信息
  19. 用户会提供以下内容:
  20. - 节点事件名称:<事件名称>
  21. - 节点事件类型:<从"节日/节气、纪念日、特定天气时段、特定民生热点"中选择其一>
  22. - 视频特征词列表:多个待判断的特征词
  23. 判断逻辑
  24. 1. 首先明确该节点事件所属类型对应的允许主题范围。
  25. 2. 逐条分析每个视频特征词所表达的核心内容。若特征词明显指向上述主题范围(不需要完全一致,语义相近、属正常延伸即可),则判定匹配;若特征词与主题无关(如纯娱乐、无关的日常生活、其他不相干领域),则判定不匹配。
  26. 3. 如遇模棱两可的情况,倾向于严格判断,即只有当特征词明显属于主题方向时才匹配,否则不匹配。
  27. 输出要求
  28. 严格只输出一个 JSON 对象,禁止输出 JSON 之外的任何内容。固定格式如下:
  29. {
  30. "matched_demand_names": ["匹配成功的特征词1", "匹配成功的特征词2"]
  31. }
  32. 约束:
  33. - 只返回判定为匹配的特征词,不匹配的不要放入数组。
  34. - matched_demand_names 中的每一项必须与用户提供的视频特征词原文完全一致,不得改写、不得编造。
  35. - 若没有任何特征词匹配,返回 {"matched_demand_names": []}。
  36. 示例1:
  37. 节点事件名称:春节
  38. 节点事件类型:节日/节气
  39. 视频特征词列表:拜年祝福、年夜饭做法、搞笑模仿
  40. 输出:{"matched_demand_names": ["拜年祝福", "年夜饭做法"]}
  41. 示例2:
  42. 节点事件名称:九一八事变纪念日
  43. 节点事件类型:纪念日
  44. 视频特征词列表:搞笑模仿、宠物日常
  45. 输出:{"matched_demand_names": []}
  46. 示例3:
  47. 节点事件名称:三伏
  48. 节点事件类型:特定天气时段
  49. 视频特征词列表:防暑小妙招、三伏贴教程、明星八卦
  50. 输出:{"matched_demand_names": ["防暑小妙招", "三伏贴教程"]}
  51. 现在,请根据上述规则处理用户输入。
  52. """
  53. def _chunked(items: list[str], batch_size: int) -> list[list[str]]:
  54. size = max(batch_size, 1)
  55. return [items[index : index + size] for index in range(0, len(items), size)]
  56. def _build_demand_lookup(demand_names: list[str]) -> dict[str, str]:
  57. lookup: dict[str, str] = {}
  58. for item in demand_names:
  59. demand_name = str(item).strip()
  60. if not demand_name:
  61. continue
  62. lookup.setdefault(demand_name, demand_name)
  63. compact_key = "".join(demand_name.split())
  64. if compact_key:
  65. lookup.setdefault(compact_key, demand_name)
  66. return lookup
  67. def _resolve_demand_name(demand_name: str, demand_lookup: dict[str, str]) -> str | None:
  68. value = demand_name.strip()
  69. if not value:
  70. return None
  71. return demand_lookup.get(value) or demand_lookup.get("".join(value.split()))
  72. def _build_batch_user_message(
  73. *,
  74. festival_name: str,
  75. event_type: str,
  76. demand_names: list[str],
  77. ) -> str:
  78. feature_lines = "\n".join(
  79. f"{index}. {demand_name}" for index, demand_name in enumerate(demand_names, start=1)
  80. )
  81. return f"""节点事件名称:{festival_name}
  82. 节点事件类型:{event_type}
  83. 视频特征词列表:
  84. {feature_lines}"""
  85. def _extract_matched_name_list(parsed: Any) -> list[Any] | None:
  86. if not isinstance(parsed, dict):
  87. return None
  88. for key in ("matched_demand_names", "matched", "demand_names", "results"):
  89. value = parsed.get(key)
  90. if isinstance(value, list):
  91. return value
  92. return None
  93. def _normalize_matched_demand_names(
  94. parsed: Any,
  95. *,
  96. festival_name: str,
  97. event_type: str,
  98. demand_names: list[str],
  99. demand_lookup: dict[str, str],
  100. ) -> list[dict[str, Any]]:
  101. matched_raw = _extract_matched_name_list(parsed)
  102. if matched_raw is None:
  103. raise FestivalDemandError("llm output missing matched_demand_names")
  104. allowed_demand_names = set(demand_names)
  105. normalized: list[dict[str, Any]] = []
  106. seen: set[str] = set()
  107. for item in matched_raw:
  108. if isinstance(item, str):
  109. raw_name = item
  110. elif isinstance(item, dict):
  111. raw_name = str(
  112. item.get("demand_name")
  113. or item.get("feature_word")
  114. or item.get("视频特征词")
  115. or item.get("name")
  116. or ""
  117. )
  118. else:
  119. continue
  120. demand_name = _resolve_demand_name(str(raw_name), demand_lookup)
  121. if not demand_name or demand_name not in allowed_demand_names:
  122. continue
  123. if demand_name in seen:
  124. continue
  125. seen.add(demand_name)
  126. normalized.append(
  127. {
  128. "demand_name": demand_name,
  129. "festival_name": festival_name,
  130. "event_type": event_type,
  131. }
  132. )
  133. return normalized
  134. def _llm_match_festival_batch(
  135. *,
  136. festival: dict[str, Any],
  137. demand_names: list[str],
  138. config: FestivalDemandConfig,
  139. ) -> list[dict[str, Any]]:
  140. festival_name = str(festival.get("name") or "").strip()
  141. event_type = str(festival.get("event_type") or "").strip()
  142. if not festival_name or not demand_names:
  143. return []
  144. demand_lookup = _build_demand_lookup(demand_names)
  145. user_message = _build_batch_user_message(
  146. festival_name=festival_name,
  147. event_type=event_type,
  148. demand_names=demand_names,
  149. )
  150. last_error: Exception | None = None
  151. for attempt in range(1, config.llm_max_attempts + 1):
  152. try:
  153. resp = create_chat_completion(
  154. [
  155. {"role": "system", "content": MATCH_SYSTEM_PROMPT.strip()},
  156. {"role": "user", "content": user_message},
  157. ],
  158. model=config.llm_model,
  159. temperature=config.llm_temperature,
  160. max_tokens=config.llm_max_tokens,
  161. )
  162. parsed = extract_json_object(str(resp.get("content") or ""))
  163. return _normalize_matched_demand_names(
  164. parsed,
  165. festival_name=festival_name,
  166. event_type=event_type,
  167. demand_names=demand_names,
  168. demand_lookup=demand_lookup,
  169. )
  170. except (OpenRouterCallError, FestivalDemandError, ValueError) as exc:
  171. last_error = exc
  172. if attempt < config.llm_max_attempts:
  173. time.sleep(config.llm_retry_sleep_seconds)
  174. raise FestivalDemandError(
  175. f"festival demand match failed for {festival_name} "
  176. f"after {config.llm_max_attempts} attempts: {last_error}"
  177. ) from last_error
  178. def match_demand_names_to_festivals(
  179. *,
  180. demand_names: list[str],
  181. festivals: list[dict[str, Any]],
  182. config: FestivalDemandConfig,
  183. ) -> list[dict[str, Any]]:
  184. """按批次将需求词与活跃节日列表做 LLM 匹配。"""
  185. if not demand_names or not festivals:
  186. return []
  187. all_matches: list[dict[str, Any]] = []
  188. seen: set[tuple[str, str]] = set()
  189. batches = _chunked(demand_names, config.match_batch_size)
  190. total_batches = len(batches)
  191. for batch_index, batch in enumerate(batches, start=1):
  192. for festival in festivals:
  193. festival_name = str(festival.get("name") or "").strip()
  194. print(
  195. f"festival demand: matching batch {batch_index}/{total_batches} "
  196. f"festival={festival_name} size={len(batch)}",
  197. flush=True,
  198. )
  199. batch_matches = _llm_match_festival_batch(
  200. festival=festival,
  201. demand_names=batch,
  202. config=config,
  203. )
  204. for row in batch_matches:
  205. dedupe_key = (row["demand_name"], row["festival_name"])
  206. if dedupe_key in seen:
  207. continue
  208. seen.add(dedupe_key)
  209. all_matches.append(row)
  210. return all_matches
  211. def collect_matched_demand_names(matches: list[dict[str, Any]]) -> list[str]:
  212. """收集去重后的 demand_name 列表。"""
  213. names: list[str] = []
  214. seen: set[str] = set()
  215. for item in matches:
  216. demand_name = str(item.get("demand_name") or "").strip()
  217. if not demand_name or demand_name in seen:
  218. continue
  219. seen.add(demand_name)
  220. names.append(demand_name)
  221. return names