feat(#674): 豆包大模型 Phase 2 - 智能素材语义匹配 #753

Merged
xiaoxia merged 1 commits from feat/doubao-ai-integration-phase2 into develop 2026-07-23 17:33:08 +08:00
3 changed files with 562 additions and 1 deletions
+65 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import List, Literal, Optional
from app.services.ai_service import TITLE_STYLES, generate_smart_titles
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
from fastapi import APIRouter
from pydantic import BaseModel, Field
@@ -45,6 +45,42 @@ class TitleStyleInfo(BaseModel):
description: str
# ── 素材语义匹配 ────────────────────────────────────────────────────────────
class AssetMatchItem(BaseModel):
"""待匹配素材项."""
id: str = Field(..., description="素材ID")
name: str = Field(default="", description="素材名称")
tags: List[str] = Field(default_factory=list, description="标签列表")
description: str = Field(default="", description="素材描述")
class SemanticMatchRequest(BaseModel):
"""语义匹配请求."""
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
class SemanticMatchResultItem(AssetMatchItem):
"""匹配结果项."""
match_score: float = Field(..., description="匹配度评分 0-1")
match_reason: str = Field(..., description="匹配方式:doubao_semantic / fallback_keyword / fallback_default")
class SemanticMatchResponse(BaseModel):
"""语义匹配响应."""
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
source: str = Field(..., description="来源:doubao / fallback")
description: str = Field(..., description="原始描述")
total: int = Field(..., description="输入素材总数")
# ── 路由 ────────────────────────────────────────────────────────────────────
@@ -70,3 +106,31 @@ def list_title_styles():
TitleStyleInfo(key=key, name=info["name"], description=info["description"])
for key, info in TITLE_STYLES.items()
]
@router.post("/assets/match", response_model=SemanticMatchResponse)
def match_assets(request: SemanticMatchRequest):
"""智能素材语义匹配.
根据用户描述,对素材列表做语义匹配并按匹配度排序。
未配置豆包 API Key 时自动降级为关键词匹配。
- 支持最多 100 个素材同时匹配
- 返回 match_score (0-1),按降序排列
- top_k 可限制返回数量
"""
# 转为 dict 传给服务层
assets_dict = [asset.model_dump() for asset in request.assets]
result = semantic_match_assets(
description=request.description,
assets=assets_dict,
top_k=request.top_k,
)
return SemanticMatchResponse(
matches=[SemanticMatchResultItem(**m) for m in result["matches"]],
source=result["source"],
description=result["description"],
total=result["total"],
)
+269
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import json
import logging
import math
import random
import time
from typing import Any, Dict, List, Optional
@@ -314,6 +315,266 @@ def generate_smart_titles(
}
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
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
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:
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
def semantic_match_assets(
description: str,
assets: List[Dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
"""智能素材语义匹配.
根据用户描述,评估每个素材的语义匹配度并排序。
Args:
description: 用户描述的目标视频内容
assets: 素材列表,每个素材需含 id/name/tags/description 等字段
top_k: 返回前K个,0表示返回全部
Returns:
{
"matches": [{"asset_id": ..., "match_score": ..., ...}],
"source": "doubao" | "fallback",
"description": "...",
"total": 总数,
}
"""
description = (description or "").strip()
if not assets:
return {"matches": [], "source": "fallback", "description": description, "total": 0}
client = DoubaoAIClient()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级做素材语义匹配")
matched = _semantic_match_fallback(description, assets)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "fallback",
"description": description,
"total": len(assets),
}
# 构建素材信息(控制 token 数量)
asset_summaries = []
for asset in assets[:50]: # 最多传50个素材给模型
aid = asset.get("id", "")
name = asset.get("name", "")[:50]
tags = asset.get("tags", [])
tags_str = ",".join(str(t) for t in tags[:5])
desc = str(asset.get("description", ""))[:80]
asset_summaries.append(
f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}"
)
asset_ids = [str(a.get("id", "")) for a in assets[:50]]
system_prompt = (
"你是一个专业的视频素材匹配助手。"
"根据用户的视频目标描述,评估每个素材的匹配程度。\n"
"评分规则:\n"
"- 0.0-0.3: 完全不相关\n"
"- 0.3-0.6: 有一定关联但不够匹配\n"
"- 0.6-0.8: 比较匹配,适合使用\n"
"- 0.8-1.0: 高度匹配,非常适合\n"
"只返回JSON对象,key为素材ID,value为匹配分数(0-1之间的小数)。"
"不要其他文字说明。"
)
user_prompt = (
f"目标视频描述:{description}\n\n"
f"素材列表:\n" + "\n".join(asset_summaries) +
f"\n\n请返回每个素材的匹配分数JSON"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client._chat_completion(
messages=messages,
temperature=0.3,
max_tokens=1024,
)
if result:
scores = _parse_semantic_match_response(result, asset_ids)
if scores:
# 把评分填回素材
matched = []
for asset in assets:
aid = str(asset.get("id", ""))
score = scores.get(aid, 0.3) # 没评分的给默认偏低分
matched.append({
**asset,
"match_score": round(score, 3),
"match_reason": "doubao_semantic",
})
matched.sort(key=lambda x: x["match_score"], reverse=True)
logger.info(
"豆包语义匹配完成: assets=%d top_score=%.2f description=%s...",
len(matched),
matched[0]["match_score"] if matched else 0,
description[:20],
)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "doubao",
"description": description,
"total": len(assets),
}
logger.warning("豆包语义匹配返回解析失败,降级到本地: %s", result[:100])
# 降级
matched = _semantic_match_fallback(description, assets)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "fallback",
"description": description,
"total": len(assets),
}
# ── 单例入口 ─────────────────────────────────────────────────────────────────
@@ -345,3 +606,11 @@ class AIService:
count: int = 5,
) -> Dict[str, Any]:
return generate_smart_titles(description, style, count)
def semantic_match(
self,
description: str,
assets: List[Dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
return semantic_match_assets(description, assets, top_k)
+228
View File
@@ -22,7 +22,10 @@ from app.services.ai_service import ( # noqa: E402
TITLE_STYLES,
_generate_titles_fallback,
_parse_titles_from_response,
_parse_semantic_match_response,
_semantic_match_fallback,
generate_smart_titles,
semantic_match_assets,
)
@@ -295,5 +298,230 @@ class TestTitleStyles(unittest.TestCase):
self.assertIn("informative", TITLE_STYLES)
# ── 语义匹配测试 ──────────────────────────────────────────────────────────────
class TestSemanticMatchFallback(unittest.TestCase):
"""降级关键词匹配测试."""
def _make_assets(self):
return [
{"id": "a1", "name": "海边日落风景", "tags": ["风景", "海边", "日落"], "description": "美丽的海边日落"},
{"id": "a2", "name": "城市夜景航拍", "tags": ["城市", "夜景", "航拍"], "description": "城市夜景航拍素材"},
{"id": "a3", "name": "美食制作过程", "tags": ["美食", "烹饪", "教程"], "description": "美食制作教程"},
]
def test_fallback_returns_sorted_scores(self):
"""返回按匹配度降序排列."""
assets = self._make_assets()
result = _semantic_match_fallback("海边日落风景视频", assets)
self.assertEqual(len(result), 3)
# 第一个应该是海边日落
self.assertEqual(result[0]["id"], "a1")
self.assertGreater(result[0]["match_score"], result[2]["match_score"])
def test_fallback_each_has_match_score(self):
"""每个素材都有 match_score."""
assets = self._make_assets()
result = _semantic_match_fallback("测试", assets)
for item in result:
self.assertIn("match_score", item)
self.assertGreaterEqual(item["match_score"], 0.0)
self.assertLessEqual(item["match_score"], 1.0)
self.assertIn("match_reason", item)
def test_fallback_unrelated_desc_low_scores(self):
"""完全不相关的描述得分低."""
assets = self._make_assets()
result = _semantic_match_fallback("篮球比赛运动", assets)
# 所有素材得分都应该较低
for item in result:
self.assertLess(item["match_score"], 0.8)
def test_fallback_empty_keywords_default_score(self):
"""无有效关键词时给默认分."""
assets = self._make_assets()
result = _semantic_match_fallback("a", assets) # 单字符无有效关键词
for item in result:
self.assertEqual(item["match_score"], 0.5)
self.assertEqual(item["match_reason"], "fallback_default")
def test_fallback_name_match_higher(self):
"""名称命中得分更高."""
assets = [
{"id": "a1", "name": "美食探店vlog", "tags": [], "description": ""},
{"id": "a2", "name": "风景视频", "tags": ["美食"], "description": ""},
]
result = _semantic_match_fallback("美食", assets)
# a1名称含美食,a2标签含美食,名称命中应有额外加分
self.assertEqual(result[0]["id"], "a1")
self.assertGreater(result[0]["match_score"], result[1]["match_score"])
class TestSemanticMatchParsing(unittest.TestCase):
"""语义匹配返回解析测试."""
def test_parse_dict_format(self):
"""解析 {id: score} 格式."""
content = json.dumps({"asset1": 0.85, "asset2": 0.62, "asset3": 0.3})
result = _parse_semantic_match_response(content, ["asset1", "asset2", "asset3"])
self.assertIsNotNone(result)
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result["asset1"], 0.85)
def test_parse_matches_list_format(self):
"""解析 {matches: [...]} 格式."""
content = json.dumps({
"matches": [
{"asset_id": "a1", "score": 0.9},
{"asset_id": "a2", "score": 0.7},
]
})
result = _parse_semantic_match_response(content, ["a1", "a2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 0.9)
self.assertAlmostEqual(result["a2"], 0.7)
def test_parse_array_format(self):
"""解析数组格式."""
content = json.dumps([
{"id": "x1", "score": 0.5},
{"id": "x2", "score": 0.88},
])
result = _parse_semantic_match_response(content, ["x1", "x2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["x1"], 0.5)
def test_parse_score_clamped(self):
"""分数被限制在0-1."""
content = json.dumps({"a1": 1.5, "a2": -0.2})
result = _parse_semantic_match_response(content, ["a1", "a2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 1.0)
self.assertAlmostEqual(result["a2"], 0.0)
def test_parse_markdown_code_block(self):
"""解析markdown代码块."""
content = "```json\n{\"a1\": 0.7}\n```"
result = _parse_semantic_match_response(content, ["a1", "a2"])
# 只有1个素材评分,少于一半(需要至少1个,max(1, 2//2)=1
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 0.7)
def test_parse_empty_returns_none(self):
"""空内容返回None."""
result = _parse_semantic_match_response("", ["a1"])
self.assertIsNone(result)
def test_parse_invalid_json_returns_none(self):
"""无效JSON返回None."""
result = _parse_semantic_match_response("不是json", ["a1", "a2", "a3"])
self.assertIsNone(result)
class TestSemanticMatchAssets(unittest.TestCase):
"""semantic_match_assets 集成测试."""
def _make_assets(self):
return [
{"id": "a1", "name": "海边日落", "tags": ["风景"], "description": ""},
{"id": "a2", "name": "城市夜景", "tags": ["城市"], "description": ""},
{"id": "a3", "name": "美食制作", "tags": ["美食"], "description": ""},
]
def test_fallback_mode_without_api_key(self):
"""无API Key时走降级."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=0,
)
result = semantic_match_assets("海边", self._make_assets())
self.assertEqual(result["source"], "fallback")
self.assertEqual(result["total"], 3)
self.assertEqual(len(result["matches"]), 3)
def test_empty_assets(self):
"""空素材列表."""
result = semantic_match_assets("test", [])
self.assertEqual(result["total"], 0)
self.assertEqual(len(result["matches"]), 0)
def test_top_k_limit(self):
"""top_k 限制返回数量."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=0,
)
result = semantic_match_assets("测试", self._make_assets(), top_k=2)
self.assertEqual(len(result["matches"]), 2)
def test_with_doubao_success(self):
"""豆包调用成功路径."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="sk-test-123",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=0,
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"choices": [{
"message": {
"content": json.dumps({"a1": 0.9, "a2": 0.5, "a3": 0.2})
}
}]
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.post", return_value=mock_response):
result = semantic_match_assets("风景视频", self._make_assets())
self.assertEqual(result["source"], "doubao")
self.assertEqual(len(result["matches"]), 3)
# 按分数降序,a1最高
self.assertEqual(result["matches"][0]["id"], "a1")
self.assertAlmostEqual(result["matches"][0]["match_score"], 0.9)
def test_with_doubao_failure_fallback(self):
"""豆包调用失败降级."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="sk-test-123",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=1,
DOUBAO_MAX_RETRIES=0,
)
with patch("httpx.post", side_effect=Exception("API Error")):
result = semantic_match_assets("测试", self._make_assets())
self.assertEqual(result["source"], "fallback")
def test_each_match_has_required_fields(self):
"""每个匹配结果都有必要字段."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=0,
)
result = semantic_match_assets("测试", self._make_assets())
for item in result["matches"]:
self.assertIn("id", item)
self.assertIn("match_score", item)
self.assertIn("match_reason", item)
if __name__ == "__main__":
unittest.main()