Files
xiaoxia-saas/packages/domain/ai_parsing.py

250 lines
8.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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