|
|
@@ -0,0 +1,432 @@
|
|
|
+"""老年人兴趣分类筛选:标题+文章与分类词库整体匹配。"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+import re
|
|
|
+import time
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from app.core.open_router_llm import OpenRouterCallError, create_chat_completion
|
|
|
+from app.hot_content.exceptions import HotContentFlowError
|
|
|
+
|
|
|
+DEFAULT_ELDERLY_CATEGORY_LIST: list[str] = [
|
|
|
+ "花卉风格 高饱和花卉",
|
|
|
+ "地标景观 交通枢纽",
|
|
|
+ "政治事件 领袖纪念",
|
|
|
+ "外交事件 博弈手段",
|
|
|
+ "外交事件 外交访问",
|
|
|
+ "国际政治 双边关系(中美关系)",
|
|
|
+ "国际政治 外交立场(外交理念)",
|
|
|
+ "政策制度 国家统一",
|
|
|
+ "民生政策 惠民政策",
|
|
|
+ "民生政策 免费福利政策",
|
|
|
+ "国家实力 国际地位",
|
|
|
+ "政治运作 政治博弈",
|
|
|
+ "军事安全 能源安全",
|
|
|
+ "文化概念 文化传承",
|
|
|
+ "健康养生 身体健康 老年健康",
|
|
|
+ "公共管理 医疗卫生 医保报销",
|
|
|
+ "公共管理 补贴福利 老年群体补贴",
|
|
|
+ "公共管理 补贴福利 生活服务补贴",
|
|
|
+ "公共管理 治理监督 反腐",
|
|
|
+ "公共管理 政策法规 行业规则",
|
|
|
+ "时政评议 国际关系 中美关系",
|
|
|
+ "时政评议 社会评议 社会公正",
|
|
|
+ "处世智慧 生存策略 生活指导",
|
|
|
+ "处世智慧 生存策略 经验总结",
|
|
|
+ "处世智慧 价值取向 处世哲学",
|
|
|
+ "社会问题 国际问题 两岸议题",
|
|
|
+ "社会问题 国家安全事件",
|
|
|
+ "社会问题 经济形势 农村农业",
|
|
|
+ "民生生活 教育议题",
|
|
|
+ "生活技巧 安全防护 反诈防骗",
|
|
|
+ "军事谋略 战略运筹 战略方案",
|
|
|
+ "爱国情感 民族情感",
|
|
|
+]
|
|
|
+
|
|
|
+TEXT_SYSTEM_PROMPT = """
|
|
|
+你是一个专业的内容分类语义匹配专家。
|
|
|
+
|
|
|
+# 任务
|
|
|
+我会提供一个待筛选项和一组分类词库。
|
|
|
+请判断该待筛选项是否能与分类词库中的某一条目整体匹配。
|
|
|
+- 能整体匹配 → 保留(keep)
|
|
|
+- 不能整体匹配任何条目 → 移除(remove)
|
|
|
+
|
|
|
+# 分类条目结构
|
|
|
+分类词库中每一行是一个不可分割的整体,由若干层级词语组成(以空格分隔,如「一级 二级 三级」)。
|
|
|
+待筛选项必须能够覆盖该分类条目的完整语义,才算匹配成功。
|
|
|
+
|
|
|
+# 匹配标准
|
|
|
+满足以下任意一条,视为整体匹配成功:
|
|
|
+- 待筛选项与分类条目含义相同或高度相近
|
|
|
+- 待筛选项是分类条目的下位概念(待筛选项所指属于该分类条目描述的范畴)
|
|
|
+- 待筛选项与分类条目在用户检索意图上高度一致
|
|
|
+
|
|
|
+# 禁止视为匹配的情况
|
|
|
+- 待筛选项仅与分类条目中的某一个词/层级相关,但未覆盖该条目的完整含义
|
|
|
+- 待筛选项与分类条目只有表面字符重叠,语义方向不同
|
|
|
+- 待筛选项是分类条目的上位概念(范围过宽,不够精确)
|
|
|
+- 两者只是同属某个大类,但具体含义差异明显
|
|
|
+
|
|
|
+# 多词分类条目处理
|
|
|
+若分类条目由多个层级词语组成,待筛选项必须能同时覆盖该条目的所有关键语义成分;
|
|
|
+缺少任意一个关键成分,则不视为匹配。
|
|
|
+
|
|
|
+# 输出规则
|
|
|
+严格输出 JSON 对象,禁止输出 JSON 之外的任何内容。
|
|
|
+仅输出 action、matched_category、reason 三个字段,不要回显 filter_text。
|
|
|
+reason 字段请用中文表述,不要使用英文双引号 "。
|
|
|
+
|
|
|
+# 约束
|
|
|
+待筛选项和匹配到的分类条目必须来自给定输入,不能创造列表之外的词。
|
|
|
+若可匹配多条分类,选择语义最接近的一条。
|
|
|
+"""
|
|
|
+
|
|
|
+ARTICLE_SYSTEM_PROMPT = """
|
|
|
+你是一个专业的中老年内容适老性分类专家。
|
|
|
+
|
|
|
+# 任务
|
|
|
+我会提供一条热榜标题及其对应的文章标题、正文摘要,以及一组老年人感兴趣的内容分类词库。
|
|
|
+请综合标题与文章内容,判断该内容是否属于老年人感兴趣的话题范畴。
|
|
|
+- 能与分类词库中某一条目整体匹配 → 保留(keep)
|
|
|
+- 不能整体匹配任何条目 → 移除(remove)
|
|
|
+
|
|
|
+# 分类条目结构
|
|
|
+分类词库中每一行是一个不可分割的整体,由若干层级词语组成(以空格分隔)。
|
|
|
+待筛内容必须能够覆盖该分类条目的完整语义,才算匹配成功。
|
|
|
+
|
|
|
+# 匹配标准
|
|
|
+满足以下任意一条,视为整体匹配成功:
|
|
|
+- 内容主题与分类条目含义相同或高度相近
|
|
|
+- 内容是分类条目的下位概念(内容所指属于该分类条目描述的范畴)
|
|
|
+- 内容与分类条目在中老年用户的阅读兴趣上高度一致
|
|
|
+
|
|
|
+# 禁止视为匹配的情况
|
|
|
+- 内容仅与分类条目中的某一个词/层级相关,但未覆盖该条目的完整含义
|
|
|
+- 内容与分类条目只有表面字符重叠,语义方向不同
|
|
|
+- 内容明显面向年轻群体(高考送考、追星、职场、游戏等),中老年专属属性弱
|
|
|
+- 两者只是同属某个大类,但具体含义差异明显
|
|
|
+
|
|
|
+# 输出规则
|
|
|
+严格输出 JSON 对象,禁止输出 JSON 之外的任何内容。
|
|
|
+仅输出 id、action、matched_category、reason 四个字段。
|
|
|
+不要输出 filter_text、标题、正文或任何输入内容的回显。
|
|
|
+reason 字段请用中文表述,不要使用英文双引号 "。
|
|
|
+id 必须与输入 record_id 完全一致。
|
|
|
+
|
|
|
+# 约束
|
|
|
+匹配到的分类条目必须来自给定 category_list,不能创造列表之外的词。
|
|
|
+若可匹配多条分类,选择语义最接近的一条。
|
|
|
+"""
|
|
|
+
|
|
|
+
|
|
|
+def _extract_filter_fields_fallback(
|
|
|
+ raw: str,
|
|
|
+ *,
|
|
|
+ record_id: int | None = None,
|
|
|
+) -> dict[str, Any] | None:
|
|
|
+ """标准 json.loads 失败时,按固定 schema 宽松提取字段。"""
|
|
|
+ action_match = re.search(r'"action"\s*:\s*"(keep|remove)"', raw, re.IGNORECASE)
|
|
|
+ if not action_match:
|
|
|
+ return None
|
|
|
+
|
|
|
+ parsed: dict[str, Any] = {"action": action_match.group(1).lower()}
|
|
|
+ if record_id is not None:
|
|
|
+ id_match = re.search(r'"id"\s*:\s*(\d+)', raw)
|
|
|
+ if id_match:
|
|
|
+ parsed["id"] = int(id_match.group(1))
|
|
|
+ else:
|
|
|
+ parsed["id"] = record_id
|
|
|
+ for key in ("matched_category", "reason"):
|
|
|
+ match = re.search(rf'"{key}"\s*:\s*"((?:[^"\\]|\\.)*)"', raw, re.DOTALL)
|
|
|
+ if match:
|
|
|
+ parsed[key] = (
|
|
|
+ match.group(1)
|
|
|
+ .replace('\\"', '"')
|
|
|
+ .replace("\\n", "\n")
|
|
|
+ .replace("\\t", "\t")
|
|
|
+ )
|
|
|
+ return parsed
|
|
|
+
|
|
|
+
|
|
|
+def _extract_json_object(text: str, *, record_id: int | None = None) -> dict[str, Any]:
|
|
|
+ raw = text.strip()
|
|
|
+ if raw.startswith("```"):
|
|
|
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
|
|
+ raw = re.sub(r"\s*```$", "", raw)
|
|
|
+ try:
|
|
|
+ parsed = json.loads(raw)
|
|
|
+ if isinstance(parsed, dict):
|
|
|
+ return parsed
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ pass
|
|
|
+ match = re.search(r"\{[\s\S]*\}", raw)
|
|
|
+ if not match:
|
|
|
+ raise HotContentFlowError("llm output is not json object")
|
|
|
+ block = match.group(0)
|
|
|
+ try:
|
|
|
+ parsed = json.loads(block)
|
|
|
+ except json.JSONDecodeError as exc:
|
|
|
+ fallback = _extract_filter_fields_fallback(block, record_id=record_id)
|
|
|
+ if fallback:
|
|
|
+ return fallback
|
|
|
+ raise HotContentFlowError(f"llm output invalid json: {exc}") from exc
|
|
|
+ if not isinstance(parsed, dict):
|
|
|
+ raise HotContentFlowError("llm output is not json object")
|
|
|
+ return parsed
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_record_filter_result(
|
|
|
+ parsed: dict[str, Any],
|
|
|
+ *,
|
|
|
+ record_id: int,
|
|
|
+ category_list: list[str],
|
|
|
+) -> dict[str, Any]:
|
|
|
+ category_set = set(category_list)
|
|
|
+ action = str(parsed.get("action") or "").strip().lower()
|
|
|
+ if action not in {"keep", "remove"}:
|
|
|
+ action = "keep" if str(parsed.get("matched_category") or "").strip() else "remove"
|
|
|
+
|
|
|
+ matched_category = str(parsed.get("matched_category") or "").strip()
|
|
|
+ if action == "keep":
|
|
|
+ if matched_category not in category_set:
|
|
|
+ action = "remove"
|
|
|
+ matched_category = ""
|
|
|
+ else:
|
|
|
+ matched_category = ""
|
|
|
+
|
|
|
+ try:
|
|
|
+ returned_id = int(parsed.get("id"))
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ returned_id = record_id
|
|
|
+
|
|
|
+ passed = action == "keep"
|
|
|
+ return {
|
|
|
+ "id": returned_id if returned_id == record_id else record_id,
|
|
|
+ "action": action,
|
|
|
+ "passed": passed,
|
|
|
+ "matched_category": matched_category or None,
|
|
|
+ "reason": str(parsed.get("reason") or "").strip(),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_filter_result(
|
|
|
+ parsed: dict[str, Any],
|
|
|
+ *,
|
|
|
+ filter_text: str,
|
|
|
+ category_list: list[str],
|
|
|
+) -> dict[str, Any]:
|
|
|
+ category_set = set(category_list)
|
|
|
+ action = str(parsed.get("action") or "").strip().lower()
|
|
|
+ if action not in {"keep", "remove"}:
|
|
|
+ action = "keep" if str(parsed.get("matched_category") or "").strip() else "remove"
|
|
|
+
|
|
|
+ matched_category = str(parsed.get("matched_category") or "").strip()
|
|
|
+ if action == "keep":
|
|
|
+ if matched_category not in category_set:
|
|
|
+ action = "remove"
|
|
|
+ matched_category = ""
|
|
|
+ else:
|
|
|
+ matched_category = ""
|
|
|
+
|
|
|
+ return {
|
|
|
+ "filter_text": filter_text,
|
|
|
+ "action": action,
|
|
|
+ "matched_category": matched_category or None,
|
|
|
+ "reason": str(parsed.get("reason") or "").strip(),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def build_article_filter_text(
|
|
|
+ *,
|
|
|
+ hot_title: str,
|
|
|
+ content_title: str,
|
|
|
+ body_text: str,
|
|
|
+ body_max_chars: int = 2000,
|
|
|
+) -> str:
|
|
|
+ body = str(body_text or "").strip()
|
|
|
+ if body_max_chars > 0 and len(body) > body_max_chars:
|
|
|
+ body = body[:body_max_chars]
|
|
|
+ return (
|
|
|
+ f"热榜标题:{str(hot_title or '').strip()}\n"
|
|
|
+ f"文章标题:{str(content_title or '').strip()}\n"
|
|
|
+ f"文章正文:{body}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def llm_filter_record_content(
|
|
|
+ *,
|
|
|
+ record_id: int,
|
|
|
+ hot_title: str,
|
|
|
+ content_title: str,
|
|
|
+ body_text: str,
|
|
|
+ category_list: list[str],
|
|
|
+ model: str,
|
|
|
+ max_attempts: int,
|
|
|
+ retry_sleep_seconds: float,
|
|
|
+ max_tokens: int,
|
|
|
+ body_max_chars: int = 2000,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ """对已入库记录做分类筛选;LLM 输入含正文,输出仅含 id/action/matched_category/reason。"""
|
|
|
+ filter_text = build_article_filter_text(
|
|
|
+ hot_title=hot_title,
|
|
|
+ content_title=content_title,
|
|
|
+ body_text=body_text,
|
|
|
+ body_max_chars=body_max_chars,
|
|
|
+ )
|
|
|
+ user_payload = {
|
|
|
+ "record_id": record_id,
|
|
|
+ "filter_text": filter_text,
|
|
|
+ "category_list": category_list,
|
|
|
+ "output_schema": {
|
|
|
+ "id": "number, must equal record_id",
|
|
|
+ "action": "keep | remove",
|
|
|
+ "matched_category": (
|
|
|
+ "string, required when action=keep, must be selected from category_list"
|
|
|
+ ),
|
|
|
+ "reason": "string, brief Chinese explanation without double quotes",
|
|
|
+ },
|
|
|
+ "constraints": [
|
|
|
+ "仅输出 id、action、matched_category、reason",
|
|
|
+ "不要输出 filter_text、标题或正文",
|
|
|
+ "reason 不要使用英文双引号",
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+ last_error: Exception | None = None
|
|
|
+ for attempt in range(1, max(max_attempts, 1) + 1):
|
|
|
+ try:
|
|
|
+ resp = create_chat_completion(
|
|
|
+ [
|
|
|
+ {"role": "system", "content": ARTICLE_SYSTEM_PROMPT},
|
|
|
+ {
|
|
|
+ "role": "user",
|
|
|
+ "content": json.dumps(user_payload, ensure_ascii=False),
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ model=model or None,
|
|
|
+ temperature=0,
|
|
|
+ max_tokens=max(max_tokens, 1),
|
|
|
+ )
|
|
|
+ parsed = _extract_json_object(
|
|
|
+ str(resp.get("content") or ""),
|
|
|
+ record_id=record_id,
|
|
|
+ )
|
|
|
+ result = _normalize_record_filter_result(
|
|
|
+ parsed,
|
|
|
+ record_id=record_id,
|
|
|
+ category_list=category_list,
|
|
|
+ )
|
|
|
+ usage = resp.get("usage")
|
|
|
+ if isinstance(usage, dict):
|
|
|
+ result["usage"] = dict(usage)
|
|
|
+ return result
|
|
|
+ except (OpenRouterCallError, HotContentFlowError) as exc:
|
|
|
+ last_error = exc
|
|
|
+ if attempt < max(max_attempts, 1):
|
|
|
+ time.sleep(max(retry_sleep_seconds, 0))
|
|
|
+
|
|
|
+ return {
|
|
|
+ "id": record_id,
|
|
|
+ "action": "remove",
|
|
|
+ "passed": False,
|
|
|
+ "matched_category": None,
|
|
|
+ "reason": f"LLM 调用失败,默认移除: {last_error}",
|
|
|
+ "error": str(last_error),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def llm_filter_text(
|
|
|
+ *,
|
|
|
+ filter_text: str,
|
|
|
+ category_list: list[str],
|
|
|
+ source: str,
|
|
|
+ system_prompt: str,
|
|
|
+ model: str,
|
|
|
+ max_attempts: int,
|
|
|
+ retry_sleep_seconds: float,
|
|
|
+ max_tokens: int,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ user_payload = {
|
|
|
+ "source": source,
|
|
|
+ "filter_text": filter_text,
|
|
|
+ "category_list": category_list,
|
|
|
+ "output_schema": {
|
|
|
+ "action": "keep | remove",
|
|
|
+ "matched_category": (
|
|
|
+ "string, required when action=keep, must be selected from category_list"
|
|
|
+ ),
|
|
|
+ "reason": "string, brief Chinese explanation without double quotes",
|
|
|
+ },
|
|
|
+ "constraints": [
|
|
|
+ "仅输出 action、matched_category、reason,不要输出 filter_text",
|
|
|
+ "reason 不要使用英文双引号",
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+ last_error: Exception | None = None
|
|
|
+ for attempt in range(1, max(max_attempts, 1) + 1):
|
|
|
+ try:
|
|
|
+ resp = create_chat_completion(
|
|
|
+ [
|
|
|
+ {"role": "system", "content": system_prompt},
|
|
|
+ {
|
|
|
+ "role": "user",
|
|
|
+ "content": json.dumps(user_payload, ensure_ascii=False),
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ model=model or None,
|
|
|
+ temperature=0,
|
|
|
+ max_tokens=max(max_tokens, 1),
|
|
|
+ )
|
|
|
+ parsed = _extract_json_object(str(resp.get("content") or ""))
|
|
|
+ result = _normalize_filter_result(
|
|
|
+ parsed,
|
|
|
+ filter_text=filter_text,
|
|
|
+ category_list=category_list,
|
|
|
+ )
|
|
|
+ usage = resp.get("usage")
|
|
|
+ if isinstance(usage, dict):
|
|
|
+ result["usage"] = dict(usage)
|
|
|
+ return result
|
|
|
+ except (OpenRouterCallError, HotContentFlowError) as exc:
|
|
|
+ last_error = exc
|
|
|
+ if attempt < max(max_attempts, 1):
|
|
|
+ time.sleep(max(retry_sleep_seconds, 0))
|
|
|
+
|
|
|
+ return {
|
|
|
+ "filter_text": filter_text,
|
|
|
+ "action": "remove",
|
|
|
+ "matched_category": None,
|
|
|
+ "reason": f"LLM 调用失败,默认移除: {last_error}",
|
|
|
+ "error": str(last_error),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def llm_filter_article_content(
|
|
|
+ *,
|
|
|
+ record_id: int,
|
|
|
+ hot_title: str,
|
|
|
+ content_title: str,
|
|
|
+ body_text: str,
|
|
|
+ category_list: list[str],
|
|
|
+ model: str,
|
|
|
+ max_attempts: int,
|
|
|
+ retry_sleep_seconds: float,
|
|
|
+ max_tokens: int,
|
|
|
+ body_max_chars: int = 2000,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ return llm_filter_record_content(
|
|
|
+ record_id=record_id,
|
|
|
+ hot_title=hot_title,
|
|
|
+ content_title=content_title,
|
|
|
+ body_text=body_text,
|
|
|
+ category_list=category_list,
|
|
|
+ model=model,
|
|
|
+ max_attempts=max_attempts,
|
|
|
+ retry_sleep_seconds=retry_sleep_seconds,
|
|
|
+ max_tokens=max_tokens,
|
|
|
+ body_max_chars=body_max_chars,
|
|
|
+ )
|