test(wave178): ai_parsing AI解析配置 +53测 #1132
Executable
+414
@@ -0,0 +1,414 @@
|
||||
"""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 # 应该有较高分数
|
||||
Reference in New Issue
Block a user