Files

415 lines
16 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响应解析纯逻辑单测.
覆盖:标题解析(多格式)、语义匹配解析、
标题降级生成、关键词匹配降级。
"""
from __future__ import annotations
import random
from unittest.mock import patch
from packages.domain.ai_parsing import (
generate_titles_fallback,
keyword_match_fallback,
parse_semantic_match_response,
parse_titles_from_response,
)
class TestParseTitlesFromResponse:
def test_empty_content(self):
assert parse_titles_from_response("") == []
def test_json_array(self):
content = '["标题一", "标题二", "标题三"]'
result = parse_titles_from_response(content)
assert result == ["标题一", "标题二", "标题三"]
def test_json_array_with_whitespace_items(self):
content = '[" 标题一 ", "", "标题二"]'
result = parse_titles_from_response(content)
assert result == ["标题一", "标题二"]
def test_json_dict_with_titles_key(self):
content = '{"titles": ["爆款标题1", "爆款标题2"]}'
result = parse_titles_from_response(content)
assert result == ["爆款标题1", "爆款标题2"]
def test_json_code_block(self):
content = '```json\n["标题A", "标题B"]\n```'
result = parse_titles_from_response(content)
assert result == ["标题A", "标题B"]
def test_json_code_block_with_backticks_only(self):
content = '```\n["X", "Y"]\n```'
result = parse_titles_from_response(content)
assert result == ["X", "Y"]
def test_numbered_list_dot(self):
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
result = parse_titles_from_response(content)
assert result == ["第一个标题", "第二个标题", "第三个标题"]
def test_numbered_list_chinese_comma(self):
content = "1、标题甲\n2、标题乙"
result = parse_titles_from_response(content)
assert result == ["标题甲", "标题乙"]
def test_numbered_list_parenthesis(self):
"""右括号格式编号能被去掉,左括号保留(实际行为)."""
content = "1) 标题1\n2) 标题2"
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2"]
def test_dash_prefix(self):
content = "- 标题A\n- 标题B\n- 标题C"
result = parse_titles_from_response(content)
assert result == ["标题A", "标题B", "标题C"]
def test_bullet_prefix(self):
content = "• 要点一\n• 要点二"
result = parse_titles_from_response(content)
assert result == ["要点一", "要点二"]
def test_newline_only(self):
content = "标题一\n标题二\n标题三"
result = parse_titles_from_response(content)
assert result == ["标题一", "标题二", "标题三"]
def test_quoted_titles(self):
content = "\"双引号标题\"\n'单引号标题'\n「中文引号」"
result = parse_titles_from_response(content)
assert result == ["双引号标题", "单引号标题", "中文引号"]
def test_skip_empty_lines(self):
content = "标题1\n\n标题2\n\n标题3"
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2", "标题3"]
def test_filter_long_lines(self):
"""超过100字符的行被过滤."""
long_title = "a" * 150
content = f"短标题\n{long_title}\n另一个短标题"
result = parse_titles_from_response(content)
assert len(result) == 2
assert "短标题" in result
assert "另一个短标题" in result
def test_invalid_json_falls_back_to_line_parse(self):
content = '["标题1", "标题2", invalid]' # 非法JSON
result = parse_titles_from_response(content)
# 会走到按行解析
assert len(result) >= 1
def test_mixed_format_numbered_and_dash(self):
content = "1. 第一题\n- 第二题\n2. 第三题"
result = parse_titles_from_response(content)
assert "第一题" in result
assert "第二题" in result
assert "第三题" in result
class TestParseSemanticMatchResponse:
def test_empty_content(self):
assert parse_semantic_match_response("", ["a1", "a2"]) is None
def test_dict_format_asset_id_score(self):
content = '{"asset_1": 0.85, "asset_2": 0.6}'
result = parse_semantic_match_response(content, ["asset_1", "asset_2"])
assert result is not None
assert result["asset_1"] == 0.85
assert result["asset_2"] == 0.6
def test_matches_array_format(self):
content = '{"matches": [{"asset_id": "a1", "score": 0.9}, {"asset_id": "a2", "score": 0.7}]}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 0.9
assert result["a2"] == 0.7
def test_list_format(self):
content = '[{"asset_id": "x", "score": 0.5}, {"asset_id": "y", "score": 0.8}]'
result = parse_semantic_match_response(content, ["x", "y"])
assert result is not None
assert result["x"] == 0.5
assert result["y"] == 0.8
def test_id_alias_in_matches(self):
"""matches中用id替代asset_id."""
content = '{"matches": [{"id": "a1", "score": 0.75}]}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 0.75
def test_score_clamped_to_0_1(self):
"""分数超出0-1范围会被截断."""
content = '{"a1": -0.5, "a2": 1.5, "a3": 0.5}'
result = parse_semantic_match_response(content, ["a1", "a2", "a3"])
assert result is not None
assert result["a1"] == 0.0
assert result["a2"] == 1.0
assert result["a3"] == 0.5
def test_score_int_converted_to_float(self):
content = '{"a1": 1, "a2": 0}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 1.0
assert result["a2"] == 0.0
def test_json_code_block(self):
content = '```json\n{"a1": 0.9, "a2": 0.8}\n```'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 0.9
def test_half_threshold_with_asset_ids(self):
"""提供asset_ids时,至少一半有评分才算成功."""
# 4个assets,只有1个有评分(<2)→ 失败
content = '{"a1": 0.9}'
result = parse_semantic_match_response(content, ["a1", "a2", "a3", "a4"])
assert result is None
def test_half_threshold_passes(self):
# 4个assets,2个有评分(=一半)→ 成功
content = '{"a1": 0.9, "a2": 0.8}'
result = parse_semantic_match_response(content, ["a1", "a2", "a3", "a4"])
assert result is not None
def test_no_asset_ids_returns_any_result(self):
content = '{"x1": 0.7}'
result = parse_semantic_match_response(content, [])
assert result is not None
assert result["x1"] == 0.7
def test_no_asset_ids_empty_result_returns_none(self):
content = "{}"
result = parse_semantic_match_response(content, [])
assert result is None
def test_invalid_json_returns_none(self):
content = "not json at all"
result = parse_semantic_match_response(content, ["a1"])
assert result is None
def test_non_numeric_values_ignored(self):
content = '{"a1": "high", "a2": 0.8}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert "a1" not in result
assert result["a2"] == 0.8
def test_single_asset_id_needs_at_least_1(self):
"""1个asset,需要至少max(1, 0)=1个评分."""
content = '{"a1": 0.5}'
result = parse_semantic_match_response(content, ["a1"])
assert result is not None
assert result["a1"] == 0.5
class TestGenerateTitlesFallback:
def test_basic_generation(self):
with patch.object(random, "shuffle", lambda x: None): # 禁用shuffle
result = generate_titles_fallback(
"美食 探店 川菜",
{"examples": ["必看攻略", "绝密技巧"]},
count=3,
)
assert len(result) == 3
assert all(isinstance(t, str) for t in result)
assert all(len(t) > 0 for t in result)
def test_count_limited_by_templates(self):
"""最多10个模板."""
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"测试",
{"examples": ["例1", "例2"]},
count=20,
)
assert len(result) == 10 # 模板总数上限
def test_default_count(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"科技 产品",
{"examples": ["测试标题", "另一个例子"]},
)
assert len(result) == 5
def test_empty_description_uses_default_keyword(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
" ",
{"examples": ["例A", "例B"]},
count=1,
)
assert "精彩内容" in result[0]
def test_keyword_extracted_from_description(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"Python编程入门教程",
{"examples": ["入门", "技巧"]},
count=5,
)
# 第一个关键词应该出现在某些标题中
assert any("Python编程入门教程" in t for t in result)
def test_examples_truncated(self):
"""第一个example超过10字符会被截断."""
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"美食",
{"examples": ["这是一个非常长的例子超过十个字", "第二个例子"]},
count=1,
)
# 第一个标题应该包含截断的example + "..."
assert "..." in result[0]
def test_no_examples_uses_default(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"健身",
{"examples": []},
count=2,
)
assert len(result) == 2
assert "必看" in result[0] # 默认example_0
def test_second_example_default(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"健身",
{"examples": ["只有一个"]},
count=3,
)
# 第二个标题应该包含默认的"你不知道的事"
assert any("你不知道的事" in t for t in result)
def test_single_word_keyword(self):
"""单字会被过滤掉,使用默认关键词."""
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"a b c",
{"examples": ["例"]},
count=1,
)
# 所有词都是1个字符,应该用默认关键词
assert "精彩内容" in result[0]
class TestKeywordMatchFallback:
def test_basic_matching(self):
assets = [
{"id": "a1", "name": "美食探店视频", "tags": ["美食", "探店"], "description": "成都美食"},
{"id": "a2", "name": "科技产品评测", "tags": ["科技"], "description": "手机评测"},
{"id": "a3", "name": "旅行Vlog", "tags": ["旅行"], "description": "日本旅行"},
]
result = keyword_match_fallback("美食 探店 成都", assets)
assert len(result) == 3
# 美食相关的应该排第一
assert result[0]["id"] == "a1"
assert 0 < result[0]["match_score"] <= 1.0
def test_score_between_0_and_1(self):
assets = [{"id": "a1", "name": "测试素材", "tags": [], "description": ""}]
result = keyword_match_fallback("完全不相关的关键词", assets)
assert 0 <= result[0]["match_score"] <= 1
def test_no_keywords_default_score(self):
"""描述中没有有效关键词时,所有素材0.5分."""
assets = [
{"id": "a1", "name": "素材1", "tags": [], "description": ""},
{"id": "a2", "name": "素材2", "tags": [], "description": ""},
]
result = keyword_match_fallback(" ", assets) # 空描述
assert len(result) == 2
assert result[0]["match_score"] == 0.5
assert result[0]["match_reason"] == "fallback_default"
def test_sorted_descending(self):
assets = [
{"id": "a_low", "name": "不相关", "tags": [], "description": ""},
{"id": "a_high", "name": "美食推荐", "tags": ["美食"], "description": "美食攻略"},
]
result = keyword_match_fallback("美食 推荐", assets)
assert result[0]["id"] == "a_high"
assert result[0]["match_score"] > result[1]["match_score"]
def test_match_reason_keyword(self):
assets = [{"id": "a1", "name": "测试", "tags": [], "description": ""}]
result = keyword_match_fallback("测试关键词", assets)
assert result[0]["match_reason"] == "fallback_keyword"
def test_name_bonus(self):
"""名称命中应该有额外加分."""
assets = [
{
"id": "a1",
"name": "完全不相关的名字",
"tags": [],
"description": "美食教程", # 描述里有关键词
},
{
"id": "a2",
"name": "美食分享", # 名称里有关键词
"tags": [],
"description": "", # 描述里没有
},
]
result = keyword_match_fallback("美食", assets)
# 名称命中的a2应该分数更高(name bonus
assert result[0]["id"] == "a2"
def test_empty_assets(self):
result = keyword_match_fallback("美食", [])
assert result == []
def test_asset_dict_not_mutated(self):
"""不修改原始asset字典."""
asset = {"id": "a1", "name": "测试", "tags": []}
original = dict(asset)
keyword_match_fallback("测试", [asset])
assert asset == original
def test_chinese_keywords_used(self):
"""中文2-4字片段应该被用作关键词."""
assets = [
{"id": "a1", "name": "编程入门", "tags": [], "description": ""},
{"id": "a2", "name": "美食推荐", "tags": [], "description": ""},
]
result = keyword_match_fallback("编程入门教程", assets)
assert result[0]["id"] == "a1"
assert result[0]["match_score"] > 0
def test_english_keywords_used(self):
"""英文3字符以上单词应该被用作关键词."""
assets = [
{"id": "a1", "name": "Python tutorial", "tags": [], "description": ""},
{"id": "a2", "name": "Java course", "tags": [], "description": ""},
]
result = keyword_match_fallback("python programming", assets)
assert result[0]["id"] == "a1"
assert result[0]["match_score"] > 0
def test_score_is_rounded_to_3_decimals(self):
assets = [{"id": "a1", "name": "测试素材", "tags": [], "description": ""}]
result = keyword_match_fallback("测试关键词", assets)
# 3位小数
assert len(str(result[0]["match_score"]).split(".")[-1]) <= 3
def test_perfect_match_score(self):
assets = [
{
"id": "a1",
"name": "美食探店推荐",
"tags": ["美食", "探店", "推荐"],
"description": "美食探店推荐视频",
}
]
result = keyword_match_fallback("美食 探店 推荐", assets)
assert result[0]["match_score"] <= 1.0
assert result[0]["match_score"] > 0.5 # 应该有较高分数