Compare commits

...

1 Commits

Author SHA1 Message Date
CI Bot 6b8c07de16 refactor(wave108): 抽离ai_parsing纯逻辑模块 + 44单测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 55s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m10s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m20s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m19s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 34s
- 从ai_service.py抽离4个解析/降级函数到packages/domain/ai_parsing.py
- ai_service保留薄包装函数,完全向后兼容
- 新增44个纯逻辑单测,覆盖标题解析、语义匹配解析、标题降级、关键词匹配
- ai_service.py: 536→351行 (-185行, -35%)
2026-07-27 00:26:55 +08:00
3 changed files with 578 additions and 199 deletions
+14 -199
View File
@@ -14,10 +14,14 @@ from __future__ import annotations
import json
import logging
import math
import random
from typing import Any, Dict, List, Optional
from packages.domain.ai_parsing import (
generate_titles_fallback as _generate_titles_fallback_base,
keyword_match_fallback as _semantic_match_fallback_base,
parse_semantic_match_response as _parse_semantic_match_base,
parse_titles_from_response as _parse_titles_from_response,
)
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
@@ -64,85 +68,9 @@ def _generate_titles_fallback(
style: str = "viral",
count: int = 5,
) -> List[str]:
"""本地降级:基于模板规则生成标题.
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
"""
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
examples = style_info["examples"]
# 从描述中提取关键词(取前几个词)
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
keyword = keywords[0] if keywords else "精彩内容"
# 基于模板生成
templates = [
f"{keyword}{examples[0][:10]}...",
f"{keyword}{examples[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 _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:
# 清理可能的 markdown 代码块标记
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"
import re
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
return _generate_titles_fallback_base(description, style_info, count)
def generate_smart_titles(
@@ -241,132 +169,19 @@ def _semantic_match_fallback(
description: str,
assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""本地降级:基于关键词的简单匹配.
计算描述中的关键词与素材名称/标签/描述的重叠度,
作为匹配度评分。0-1分。
"""
import re
# 提取关键词(中文按2字以上片段,英文按单词)
desc = description.lower()
# 简单分词:提取2字以上的中文字符串和英文单词
keywords = 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:
# 没有关键词时给所有素材中等分数
for asset in assets:
asset["match_score"] = 0.5
asset["match_reason"] = "fallback_default"
return assets
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 = []
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)
results.append(
{
**asset,
"match_score": score,
"match_reason": "fallback_keyword",
}
)
# 按匹配度降序
results.sort(key=lambda x: x["match_score"], reverse=True)
return results
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
return _semantic_match_fallback_base(description, assets)
def _parse_semantic_match_response(
content: str,
asset_ids: List[str],
) -> Optional[Dict[str, float]]:
"""从模型返回中解析素材匹配度.
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
score 范围 0-1。
"""
if not content:
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
result = _parse_semantic_match_base(content, asset_ids)
if result is None:
return None
# 尝试解析 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)
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 len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
return result
except (json.JSONDecodeError, ValueError):
pass
return None
return dict(result)
def semantic_match_assets(
+249
View File
@@ -0,0 +1,249 @@
"""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
+315
View File
@@ -0,0 +1,315 @@
"""ai_parsing 模块单测 — 纯逻辑."""
from __future__ import annotations
import pytest
from packages.domain.ai_parsing import (
generate_titles_fallback,
keyword_match_fallback,
parse_semantic_match_response,
parse_titles_from_response,
)
# ── parse_titles_from_response 测试 ──────────────────────────────────────────
class TestParseTitlesJsonArray:
def test_simple_json_array(self):
result = parse_titles_from_response('["标题1", "标题2", "标题3"]')
assert result == ["标题1", "标题2", "标题3"]
def test_json_array_with_empty_strings_skipped(self):
result = parse_titles_from_response('["标题1", "", "标题2"]')
assert result == ["标题1", "标题2"]
def test_json_dict_with_titles_key(self):
result = parse_titles_from_response('{"titles": ["a", "b", "c"]}')
assert result == ["a", "b", "c"]
def test_json_with_markdown_code_block(self):
content = '```json\n["标题1", "标题2"]\n```'
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2"]
def test_json_with_backticks_no_lang(self):
content = '```\n["标题1", "标题2"]\n```'
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2"]
def test_none_returns_empty(self):
assert parse_titles_from_response(None) == [] # type: ignore
def test_empty_string_returns_empty(self):
assert parse_titles_from_response("") == []
class TestParseTitlesNumberedList:
def test_dot_numbered(self):
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
result = parse_titles_from_response(content)
assert len(result) == 3
assert result[0] == "第一个标题"
assert result[1] == "第二个标题"
def test_chinese_period_numbered(self):
content = "1、第一个标题\n2、第二个标题"
result = parse_titles_from_response(content)
assert result == ["第一个标题", "第二个标题"]
def test_parentheses_numbered(self):
content = "1) 第一个标题\n2) 第二个标题"
result = parse_titles_from_response(content)
assert result == ["第一个标题", "第二个标题"]
def test_chinese_paren_numbered(self):
# 原实现只支持半角括号,全角括号保留原样(不影响实际使用)
content = "1)第一个标题\n2)第二个标题"
result = parse_titles_from_response(content)
assert len(result) == 2
class TestParseTitlesDash:
def test_dash_prefix(self):
content = "- 标题一\n- 标题二\n- 标题三"
result = parse_titles_from_response(content)
assert len(result) == 3
assert result[0] == "标题一"
def test_bullet_prefix(self):
content = "• 标题一\n• 标题二"
result = parse_titles_from_response(content)
assert len(result) == 2
assert result[0] == "标题一"
class TestParseTitlesQuoted:
def test_strips_quotes(self):
content = '1. "带引号的标题"\n2. 正常标题'
result = parse_titles_from_response(content)
assert "带引号的标题" in result
def test_strips_chinese_quotes(self):
content = "1. 「中文引号标题」\n2. 正常标题"
result = parse_titles_from_response(content)
assert "中文引号标题" in result
class TestParseTitlesEdgeCases:
def test_skips_empty_lines(self):
content = "标题一\n\n标题二\n\n标题三"
result = parse_titles_from_response(content)
assert len(result) == 3
def test_filters_long_lines(self):
long_title = "A" * 150
content = f"短标题\n{long_title}\n另一个短标题"
result = parse_titles_from_response(content)
assert len(result) == 2
assert long_title not in result
def test_invalid_json_falls_back_to_lines(self):
content = "标题1\n标题2\n标题3"
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2", "标题3"]
# ── parse_semantic_match_response 测试 ──────────────────────────────────────
class TestParseSemanticMatchDictFormat:
def test_simple_dict(self):
asset_ids = ["a1", "a2", "a3"]
content = '{"a1": 0.8, "a2": 0.6, "a3": 0.9}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.8
assert result["a2"] == 0.6
assert result["a3"] == 0.9
def test_score_clamped_to_0_1(self):
asset_ids = ["a1", "a2"]
content = '{"a1": 1.5, "a2": -0.5}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 1.0
assert result["a2"] == 0.0
class TestParseSemanticMatchMatchesFormat:
def test_matches_array(self):
asset_ids = ["a1", "a2"]
content = '{"matches": [{"asset_id": "a1", "score": 0.8}, {"asset_id": "a2", "score": 0.6}]}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.8
assert result["a2"] == 0.6
def test_matches_with_id_key(self):
asset_ids = ["a1", "a2"]
content = '{"matches": [{"id": "a1", "score": 0.7}, {"id": "a2", "score": 0.5}]}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.7
class TestParseSemanticMatchArrayFormat:
def test_array_of_objects(self):
asset_ids = ["a1", "a2", "a3"]
content = (
'[{"asset_id": "a1", "score": 0.8}, {"asset_id": "a2", "score": 0.6}, {"asset_id": "a3", "score": 0.3}]'
)
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert len(result) == 3
class TestParseSemanticMatchEdgeCases:
def test_empty_content_returns_none(self):
assert parse_semantic_match_response("", ["a1"]) is None
def test_invalid_json_returns_none(self):
assert parse_semantic_match_response("not json", ["a1"]) is None
def test_less_than_half_returns_none(self):
asset_ids = ["a1", "a2", "a3", "a4", "a5"]
# 只返回1个,少于 5//2=2,应该返回 None
content = '{"a1": 0.8}'
result = parse_semantic_match_response(content, asset_ids)
assert result is None
def test_at_least_half_returns_result(self):
asset_ids = ["a1", "a2", "a3", "a4", "a5"]
# 返回3个,>= 5//2=2
content = '{"a1": 0.8, "a2": 0.7, "a3": 0.6}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert len(result) == 3
def test_markdown_code_block(self):
asset_ids = ["a1", "a2"]
content = '```json\n{"a1": 0.8, "a2": 0.6}\n```'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.8
def test_no_asset_ids_returns_result_if_any(self):
content = '{"a1": 0.8, "a2": 0.6}'
result = parse_semantic_match_response(content, [])
assert result is not None
assert len(result) == 2
def test_single_asset_id_needs_at_least_1(self):
# max(1, 1//2) = max(1, 0) = 1
content = '{"a1": 0.8}'
result = parse_semantic_match_response(content, ["a1"])
assert result is not None
# ── generate_titles_fallback 测试 ────────────────────────────────────────────
class TestGenerateTitlesFallback:
def test_returns_requested_count(self):
style = {"examples": ["例1", "例2", "例3"]}
result = generate_titles_fallback("测试描述 关键词", style, count=5)
assert len(result) == 5
def test_uses_keyword_from_description(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("美食 探店 打卡", style, count=3)
# 第一个关键词是"美食"
assert any("美食" in t for t in result)
def test_no_keywords_uses_default(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("", style, count=3)
assert any("精彩内容" in t for t in result)
def test_count_limited_by_templates(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("测试", style, count=100)
assert len(result) <= 10 # 模板只有10个
def test_all_titles_are_strings(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("测试", style, count=5)
assert all(isinstance(t, str) and t for t in result)
def test_empty_examples(self):
style = {"examples": []}
result = generate_titles_fallback("测试", style, count=3)
assert len(result) == 3
assert all(isinstance(t, str) for t in result)
# ── keyword_match_fallback 测试 ──────────────────────────────────────────────
class TestKeywordMatchFallback:
def test_basic_matching(self):
assets = [
{"id": "1", "name": "美食探店视频", "tags": ["美食", "探店"], "description": "好吃的"},
{"id": "2", "name": "旅行vlog", "tags": ["旅行", "风景"], "description": "出去玩"},
]
result = keyword_match_fallback("美食探店 好吃的美食", assets)
assert len(result) == 2
# 第一个应该是美食相关的
assert result[0]["id"] == "1"
assert result[0]["match_score"] >= result[1]["match_score"]
def test_returns_match_score_and_reason(self):
assets = [{"id": "1", "name": "测试素材", "tags": [], "description": ""}]
result = keyword_match_fallback("美食", assets)
assert len(result) == 1
assert "match_score" in result[0]
assert "match_reason" in result[0]
assert 0.0 <= result[0]["match_score"] <= 1.0
def test_no_keywords_default_score(self):
assets = [
{"id": "1", "name": "素材1", "tags": [], "description": ""},
{"id": "2", "name": "素材2", "tags": [], "description": ""},
]
# 单个字符不算关键词
result = keyword_match_fallback("a", assets)
assert len(result) == 2
assert all(r["match_score"] == 0.5 for r in result)
assert all(r["match_reason"] == "fallback_default" for r in result)
def test_sorted_by_score_descending(self):
assets = [
{"id": "low", "name": "无关素材", "tags": [], "description": ""},
{"id": "high", "name": "美食视频", "tags": ["美食"], "description": "美食分享"},
]
result = keyword_match_fallback("美食分享", assets)
assert result[0]["id"] == "high"
assert result[0]["match_score"] > result[1]["match_score"]
def test_does_not_modify_original_assets(self):
original = {"id": "1", "name": "测试", "tags": [], "description": ""}
assets = [dict(original)]
keyword_match_fallback("美食", assets)
assert "match_score" not in assets[0]
def test_name_matches_higher_score(self):
assets = [
{"id": "name_match", "name": "美食教程", "tags": [], "description": "内容"},
{"id": "desc_match", "name": "视频1", "tags": [], "description": "美食教程内容"},
]
result = keyword_match_fallback("美食教程", assets)
# 名称命中应该加分更多
name_idx = next(i for i, r in enumerate(result) if r["id"] == "name_match")
desc_idx = next(i for i, r in enumerate(result) if r["id"] == "desc_match")
assert name_idx < desc_idx
def test_empty_assets_returns_empty(self):
result = keyword_match_fallback("美食", [])
assert result == []
def test_score_is_rounded_to_3_decimals(self):
assets = [{"id": "1", "name": "测试素材", "tags": [], "description": "内容描述"}]
result = keyword_match_fallback("美食探店旅行", assets)
score = result[0]["match_score"]
# 验证是3位小数
assert round(score, 3) == score