250 lines
8.7 KiB
Python
Executable File
250 lines
8.7 KiB
Python
Executable File
"""AI 响应解析纯逻辑模块.
|
||
|
||
抽离自 ai_service.py 的解析函数,方便单测覆盖,同时保持向后兼容。
|
||
包括:
|
||
- 标题列表解析(JSON/编号/换行/破折号格式)
|
||
- 语义匹配结果解析(多种JSON格式)
|
||
- 标题降级生成
|
||
- 关键词匹配降级
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import random
|
||
import re
|
||
from typing import Any
|
||
|
||
# ── 标题解析 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def parse_titles_from_response(content: str) -> list[str]:
|
||
"""从模型返回中解析标题列表.
|
||
|
||
支持多种返回格式:
|
||
- JSON 数组: ["标题1", "标题2"]
|
||
- 编号列表: 1. 标题1 / 2. 标题2
|
||
- 换行分隔: 标题1\n标题2
|
||
- 带破折号: - 标题1
|
||
"""
|
||
if not content:
|
||
return []
|
||
|
||
# 尝试解析 JSON
|
||
try:
|
||
cleaned = content.strip()
|
||
if cleaned.startswith("```"):
|
||
cleaned = cleaned.strip("`")
|
||
if cleaned.lower().startswith("json"):
|
||
cleaned = cleaned[4:]
|
||
cleaned = cleaned.strip()
|
||
|
||
data = json.loads(cleaned)
|
||
if isinstance(data, list):
|
||
return [str(item).strip() for item in data if str(item).strip()]
|
||
if isinstance(data, dict) and "titles" in data:
|
||
titles = data["titles"]
|
||
if isinstance(titles, list):
|
||
return [str(t).strip() for t in titles if str(t).strip()]
|
||
except (json.JSONDecodeError, ValueError):
|
||
pass
|
||
|
||
# 尝试按行解析
|
||
titles: list[str] = []
|
||
for line in content.strip().split("\n"):
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
# 去掉编号前缀 "1. " "1、" "(1)"
|
||
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
||
# 去掉破折号前缀 "- " "• "
|
||
line = re.sub(r"^[-•·]\s*", "", line)
|
||
# 去掉引号
|
||
line = line.strip('"').strip("'").strip("「」")
|
||
if line and len(line) < 100: # 过滤过长的行
|
||
titles.append(line)
|
||
|
||
return titles
|
||
|
||
|
||
# ── 语义匹配解析 ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def parse_semantic_match_response(
|
||
content: str,
|
||
asset_ids: list[str],
|
||
) -> dict[str, float] | None:
|
||
"""从模型返回中解析素材匹配度.
|
||
|
||
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
||
score 范围 0-1,自动截断到 [0, 1]。
|
||
"""
|
||
if not content:
|
||
return None
|
||
|
||
try:
|
||
cleaned = content.strip()
|
||
if cleaned.startswith("```"):
|
||
cleaned = cleaned.strip("`")
|
||
if cleaned.lower().startswith("json"):
|
||
cleaned = cleaned[4:]
|
||
cleaned = cleaned.strip()
|
||
|
||
data = json.loads(cleaned)
|
||
|
||
result: dict[str, float] = {}
|
||
|
||
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
||
if isinstance(data, dict):
|
||
if "matches" in data and isinstance(data["matches"], list):
|
||
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
||
for item in data["matches"]:
|
||
if isinstance(item, dict):
|
||
aid = item.get("asset_id") or item.get("id")
|
||
score = item.get("score", 0)
|
||
if aid and isinstance(score, (int, float)):
|
||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||
else:
|
||
for key, value in data.items():
|
||
if isinstance(value, (int, float)):
|
||
result[str(key)] = max(0.0, min(1.0, float(value)))
|
||
|
||
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
||
elif isinstance(data, list):
|
||
for item in data:
|
||
if isinstance(item, dict):
|
||
aid = item.get("asset_id") or item.get("id")
|
||
score = item.get("score", 0)
|
||
if aid and isinstance(score, (int, float)):
|
||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||
|
||
# 至少一半素材有评分才算成功
|
||
if asset_ids and len(result) >= max(1, len(asset_ids) // 2):
|
||
return result
|
||
# 没有 asset_ids 时,只要有结果就返回
|
||
if not asset_ids and result:
|
||
return result
|
||
|
||
except (json.JSONDecodeError, ValueError):
|
||
pass
|
||
|
||
return None
|
||
|
||
|
||
# ── 标题降级生成 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def generate_titles_fallback(
|
||
description: str,
|
||
style_info: dict[str, Any],
|
||
count: int = 5,
|
||
) -> list[str]:
|
||
"""本地降级:基于模板规则生成标题.
|
||
|
||
Args:
|
||
description: 视频内容描述
|
||
style_info: 标题风格配置 {"name": ..., "examples": [...]}
|
||
count: 生成数量
|
||
"""
|
||
examples = style_info.get("examples", [])
|
||
|
||
# 从描述中提取关键词(取前几个词)
|
||
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
||
keyword = keywords[0] if keywords else "精彩内容"
|
||
|
||
# 基于模板生成
|
||
example_0 = examples[0][:10] + "..." if examples else "必看"
|
||
example_1 = examples[1] if len(examples) > 1 else "你不知道的事"
|
||
|
||
templates = [
|
||
f"「{keyword}」{example_0}",
|
||
f"{keyword}:{example_1}",
|
||
f"关于{keyword},你不知道的3件事",
|
||
f"{keyword}入门指南,新手必看",
|
||
f"深度解析:{keyword}背后的秘密",
|
||
f"{keyword}怎么做?手把手教你",
|
||
f"干货分享 | {keyword}全攻略",
|
||
f"建议收藏:{keyword}实用技巧",
|
||
f"{keyword}避坑指南,别再踩雷了",
|
||
f"一分钟搞懂{keyword}",
|
||
]
|
||
|
||
random.shuffle(templates)
|
||
return templates[: min(count, len(templates))]
|
||
|
||
|
||
# ── 关键词匹配降级 ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def keyword_match_fallback(
|
||
description: str,
|
||
assets: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
"""本地降级:基于关键词的简单匹配.
|
||
|
||
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
||
作为匹配度评分。0-1分。
|
||
"""
|
||
# 提取关键词(中文按2字以上片段,英文按单词)
|
||
desc = description.lower()
|
||
keywords: set[str] = set()
|
||
|
||
# 英文单词
|
||
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
||
keywords.add(word)
|
||
# 中文2-4字片段
|
||
for i in range(len(desc)):
|
||
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
||
fragment = desc[i:j]
|
||
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
||
keywords.add(fragment)
|
||
|
||
if not keywords:
|
||
# 没有关键词时给所有素材中等分数
|
||
results = []
|
||
for asset in assets:
|
||
new_asset = dict(asset)
|
||
new_asset["match_score"] = 0.5
|
||
new_asset["match_reason"] = "fallback_default"
|
||
results.append(new_asset)
|
||
return results
|
||
|
||
results = []
|
||
for asset in assets:
|
||
# 组合素材的文本信息:名称 + 标签 + 描述
|
||
asset_text_parts = [
|
||
str(asset.get("name", "")).lower(),
|
||
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
||
str(asset.get("description", "")).lower(),
|
||
]
|
||
asset_text = " | ".join(asset_text_parts)
|
||
|
||
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
||
hit_count = 0
|
||
hit_keywords: list[str] = []
|
||
for kw in keywords:
|
||
if kw in asset_text:
|
||
hit_count += 1
|
||
hit_keywords.append(kw)
|
||
|
||
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
||
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
||
|
||
# 名称命中加分(名称匹配更重要)
|
||
name = str(asset.get("name", "")).lower()
|
||
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
||
name_bonus = min(0.2, name_hits * 0.05)
|
||
|
||
score = min(1.0, base_score * 0.8 + name_bonus)
|
||
score = round(score, 3)
|
||
|
||
new_asset = dict(asset)
|
||
new_asset["match_score"] = score
|
||
new_asset["match_reason"] = "fallback_keyword"
|
||
results.append(new_asset)
|
||
|
||
# 按匹配度降序
|
||
results.sort(key=lambda x: x["match_score"], reverse=True)
|
||
return results
|