Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf572b225 | |||
| 375692e838 | |||
| 2f237c80cf | |||
| ee916acb1b | |||
| f9f7eae37d | |||
| 5a9b6d9890 | |||
| 27cb7381ad | |||
| 8cec4069fa | |||
| 2ee710ca16 | |||
| 5c5aabd311 | |||
| 5c260c1c87 | |||
| b5ad67830e | |||
| 545293fe5c | |||
| df7dafb8c4 | |||
| b701182f6b |
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -124,10 +124,19 @@ def _select_assets_from_library(
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 智能匹配:多维度综合评分 + 时长多样性保证
|
||||
selector = SmartAssetSelector()
|
||||
result = selector.select(ready_video_assets, count=count, ensure_diversity=True)
|
||||
return result.selected_ids
|
||||
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
||||
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
||||
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
||||
scored_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
-(a.quality_score if a.quality_score is not None else 0.0),
|
||||
-(getattr(a, "duration", 0.0) or 0.0),
|
||||
),
|
||||
)
|
||||
if count > 0:
|
||||
scored_assets = scored_assets[:count]
|
||||
return [a.id for a in scored_assets]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
@@ -109,14 +109,6 @@ class Settings(BaseSettings):
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
RENDER_ENGINE: str = "legacy"
|
||||
|
||||
# 豆包大模型配置(火山引擎方舟平台)
|
||||
# 未配置 API Key 时自动降级为本地模拟生成
|
||||
DOUBAO_API_KEY: str = ""
|
||||
DOUBAO_MODEL: str = "doubao-seed-1-6-250615"
|
||||
DOUBAO_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
DOUBAO_TIMEOUT: int = 30
|
||||
DOUBAO_MAX_RETRIES: int = 2
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -14,12 +14,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from app.config import get_settings
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,85 +56,6 @@ TITLE_STYLES = {
|
||||
}
|
||||
|
||||
|
||||
# ── 豆包 AI 客户端 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DoubaoAIClient:
|
||||
"""豆包大模型 API 客户端.
|
||||
|
||||
使用火山引擎方舟平台的 OpenAI 兼容接口。
|
||||
未配置 API Key 时,is_available 返回 False,调用方应降级处理。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.api_key: str = settings.DOUBAO_API_KEY
|
||||
self.model: str = settings.DOUBAO_MODEL
|
||||
self.base_url: str = settings.DOUBAO_BASE_URL.rstrip("/")
|
||||
self.timeout: int = settings.DOUBAO_TIMEOUT
|
||||
self.max_retries: int = settings.DOUBAO_MAX_RETRIES
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否可用(配置了 API Key)."""
|
||||
return bool(self.api_key)
|
||||
|
||||
def _chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> Optional[str]:
|
||||
"""调用豆包 Chat Completion 接口.
|
||||
|
||||
Returns:
|
||||
模型返回的文本内容,失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包API调用失败,%s秒后重试 (第%d/%d次): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("豆包API调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
|
||||
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -251,7 +171,7 @@ def generate_smart_titles(
|
||||
count = max(3, min(10, count)) # 3-10 个
|
||||
description = (description or "").strip()
|
||||
|
||||
client = DoubaoAIClient()
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.info("豆包API未配置,使用本地降级生成标题")
|
||||
titles = _generate_titles_fallback(description, style, count)
|
||||
@@ -280,7 +200,7 @@ def generate_smart_titles(
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = client._chat_completion(
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.8,
|
||||
max_tokens=512,
|
||||
@@ -314,6 +234,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 = get_doubao_client()
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
# ── 单例入口 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -332,7 +512,7 @@ class AIService:
|
||||
"""AI 服务统一入口,便于后续扩展更多能力."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = DoubaoAIClient()
|
||||
self._client = get_doubao_client()
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
@@ -345,3 +525,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)
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -278,9 +277,7 @@ class SmartAssetSelector:
|
||||
# 分桶
|
||||
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
|
||||
medium_bucket = [
|
||||
d
|
||||
for d in scored
|
||||
if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||
]
|
||||
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
|
||||
unknown_bucket = [d for d in scored if d.duration is None]
|
||||
@@ -295,7 +292,7 @@ class SmartAssetSelector:
|
||||
selected_ids: set[str] = set()
|
||||
|
||||
# 先按配额从每个桶取
|
||||
for bucket, name in zip(buckets, bucket_names):
|
||||
for bucket, _name in zip(buckets, bucket_names, strict=False):
|
||||
quota = min(base_quota, len(bucket))
|
||||
if quota <= 0:
|
||||
continue
|
||||
|
||||
@@ -39,7 +39,13 @@ const WechatCallback: React.FC = () => {
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
|
||||
// 获取用户信息
|
||||
// 先把 token 存到 localStorage,让请求拦截器能拿到(getCurrentUser 需要带 token)
|
||||
localStorage.setItem("access_token", result.access_token)
|
||||
if (result.refresh_token) {
|
||||
localStorage.setItem("refresh_token", result.refresh_token)
|
||||
}
|
||||
|
||||
// 获取用户信息(这时候请求拦截器能拿到 token 了)
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import WechatCallback from "@/pages/auth/WechatCallback"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [new URLSearchParams({ code: "test_code", state: "test_state" })],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
wechatCallback: vi.fn(() => new Promise(() => {})), // pending promise,保持loading
|
||||
getCurrentUser: vi.fn(),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: () => ({
|
||||
setAuth: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/auth/BindContactModal", () => ({
|
||||
default: ({ open }: { open: boolean }) => (
|
||||
<div data-testid="bind-contact-modal" style={{ display: open ? "block" : "none" }}>
|
||||
BindContactModal
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual("antd")
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe("WechatCallback Page", () => {
|
||||
beforeEach(() => {
|
||||
// mock localStorage,设置wechat_state匹配,让校验通过
|
||||
const store: Record<string, string> = {
|
||||
wechat_state: "test_state",
|
||||
}
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => store[key] || null)
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
|
||||
store[key] = val
|
||||
})
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
delete store[key]
|
||||
})
|
||||
})
|
||||
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should show loading state while processing", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// wechatCallback 返回 pending promise,所以应该显示 loading
|
||||
expect(screen.getByText("正在登录...")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Regular → Executable
+164
-5
@@ -10,12 +10,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,17 +25,16 @@ logger = logging.getLogger(__name__)
|
||||
# ── AI 推荐片段方案 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _call_ai_recommend_service(
|
||||
def _fallback_recommend_clips(
|
||||
plan_id: str,
|
||||
template_id: str,
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
target_duration: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 推荐服务(stub)
|
||||
"""本地降级推荐方案(原 stub 逻辑).
|
||||
|
||||
TODO: 接入真实 AI 服务,分析素材内容并生成推荐方案。
|
||||
当前返回基于模板规则的模拟推荐数据。
|
||||
当豆包 API 不可用或调用失败时使用,基于模板规则生成模拟推荐数据。
|
||||
"""
|
||||
# 模拟 AI 分析耗时
|
||||
time.sleep(0.5)
|
||||
@@ -87,6 +88,7 @@ def _call_ai_recommend_service(
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
order += 1
|
||||
|
||||
# 生成推荐 config
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
@@ -101,6 +103,163 @@ def _call_ai_recommend_service(
|
||||
}
|
||||
|
||||
|
||||
def _parse_recommend_response(
|
||||
content: str,
|
||||
asset_ids: List[str],
|
||||
target_duration: float,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""解析豆包返回的推荐方案.
|
||||
|
||||
期望返回结构:
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro/showcase/outro", "order": 0,
|
||||
"text_content": "...", "duration": 3.0,
|
||||
"transition_effect": "fade/cut", "asset_id": "...",
|
||||
"start_time": 0.0, "config": {}}
|
||||
],
|
||||
"title": "视频标题",
|
||||
"confidence": 0.85
|
||||
}
|
||||
"""
|
||||
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)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
clips_data = data.get("clips", [])
|
||||
if not isinstance(clips_data, list) or len(clips_data) == 0:
|
||||
return None
|
||||
|
||||
clips: List[Dict[str, Any]] = []
|
||||
for i, clip in enumerate(clips_data):
|
||||
if not isinstance(clip, dict):
|
||||
continue
|
||||
asset_id = str(clip.get("asset_id", ""))
|
||||
# 校验 asset_id 是否在输入列表中
|
||||
if asset_id and asset_id not in asset_ids:
|
||||
asset_id = ""
|
||||
clips.append({
|
||||
"clip_type": clip.get("clip_type", "showcase"),
|
||||
"order": clip.get("order", len(clips)),
|
||||
"text_content": str(clip.get("text_content", "")),
|
||||
"duration": max(1.0, min(30.0, float(clip.get("duration", 3.0)))),
|
||||
"transition_effect": clip.get("transition_effect", "cut"),
|
||||
"asset_id": asset_id,
|
||||
"start_time": max(0.0, float(clip.get("start_time", 0.0))),
|
||||
"config": clip.get("config", {}) or {},
|
||||
})
|
||||
|
||||
if not clips:
|
||||
return None
|
||||
|
||||
# 按 order 排序
|
||||
clips.sort(key=lambda c: c["order"])
|
||||
# 重新编号 order 保证连续
|
||||
for i, clip in enumerate(clips):
|
||||
clip["order"] = i
|
||||
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
title = data.get("title", "")
|
||||
if title:
|
||||
config["title"]["text"] = str(title)
|
||||
config["title"]["ai_auto"] = True
|
||||
|
||||
confidence = float(data.get("confidence", 0.7))
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
total_duration = round(sum(c["duration"] for c in clips), 1)
|
||||
|
||||
return {
|
||||
"clips": clips,
|
||||
"config": config,
|
||||
"total_duration": total_duration,
|
||||
"confidence": round(confidence, 2),
|
||||
}
|
||||
|
||||
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def _call_ai_recommend_service(
|
||||
plan_id: str,
|
||||
template_id: str,
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
target_duration: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 推荐服务生成片段编排方案.
|
||||
|
||||
优先使用豆包大模型生成,失败或未配置时降级为本地规则生成。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.info("豆包API未配置,使用本地降级生成AI推荐方案")
|
||||
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
|
||||
|
||||
# 构建 prompt
|
||||
system_prompt = (
|
||||
"你是一个专业的视频剪辑导演助手。"
|
||||
"根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
|
||||
"要求:\n"
|
||||
"1. 片段类型分为三类:intro(开场)、showcase(展示)、outro(结尾)\n"
|
||||
"2. 每个片段包含:clip_type、order、text_content(字幕/标题文字)、"
|
||||
"duration(时长秒)、transition_effect(转场效果:fade/cut/dissolve)、"
|
||||
"asset_id(使用的素材ID)、start_time(素材起始时间秒)\n"
|
||||
"3. 总时长接近 target_duration,每个素材至少用一次\n"
|
||||
"4. 转场效果合理分配,不要全用cut\n"
|
||||
"5. 返回纯JSON,不要其他文字\n"
|
||||
"返回格式:{\"clips\": [...], \"title\": \"视频标题\", \"confidence\": 0.85}"
|
||||
)
|
||||
|
||||
assets_desc = "\n".join([f" - 素材ID: {aid}" for i, aid in enumerate(asset_ids[:30])])
|
||||
user_prompt = (
|
||||
f"剪辑计划ID: {plan_id}\n"
|
||||
f"模板ID: {template_id}\n"
|
||||
f"剪辑模式: {editing_mode}\n"
|
||||
f"目标时长: {target_duration}秒\n"
|
||||
f"素材列表(共{len(asset_ids)}个):\n{assets_desc}\n\n"
|
||||
f"请设计完整的片段编排方案:"
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
if result:
|
||||
parsed = _parse_recommend_response(result, asset_ids, target_duration)
|
||||
if parsed and len(parsed["clips"]) >= 2:
|
||||
logger.info(
|
||||
"豆包AI推荐生成成功: plan_id=%s clips=%d duration=%.1f confidence=%.2f",
|
||||
plan_id,
|
||||
len(parsed["clips"]),
|
||||
parsed["total_duration"],
|
||||
parsed["confidence"],
|
||||
)
|
||||
return parsed
|
||||
logger.warning("豆包AI推荐返回解析失败,降级到本地方案: %s", result[:100])
|
||||
|
||||
# 降级
|
||||
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
|
||||
|
||||
|
||||
# ── AI 封面生成 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -67,13 +67,24 @@ class SQLAlchemyVerificationCodeRepository(VerificationCodeRepository):
|
||||
def _to_entity(model: VerificationCodeModel | None) -> VerificationCode | None:
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
# SQLAlchemy 从数据库读出的 DateTime 是 naive(不带时区),
|
||||
# 领域模型期望 aware datetime(带 timezone.utc),直接用会报
|
||||
# "can't compare offset-naive and offset-aware datetimes"
|
||||
def _ensure_aware(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
return VerificationCode(
|
||||
id=model.id,
|
||||
recipient=model.recipient,
|
||||
code=model.code,
|
||||
code_type=model.code_type,
|
||||
expires_at=model.expires_at,
|
||||
used_at=model.used_at,
|
||||
expires_at=_ensure_aware(model.expires_at),
|
||||
used_at=_ensure_aware(model.used_at),
|
||||
attempts=model.attempts,
|
||||
created_at=model.created_at,
|
||||
created_at=_ensure_aware(model.created_at),
|
||||
)
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
"""豆包大模型 API 客户端(共享层).
|
||||
|
||||
API 和 Worker 两边共用。基于火山引擎方舟平台的 OpenAI 兼容接口。
|
||||
|
||||
使用方式:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
client = get_doubao_client()
|
||||
if client.is_available:
|
||||
result = client.chat_completion(messages=[...])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DoubaoClient:
|
||||
"""豆包大模型 API 客户端.
|
||||
|
||||
封装 OpenAI 兼容的 Chat Completion 接口,支持自动重试。
|
||||
未配置 API Key 时 is_available 为 False,调用方应降级处理。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_shared_settings()
|
||||
self.api_key: str = settings.doubao_api_key
|
||||
self.model: str = settings.doubao_model
|
||||
self.base_url: str = settings.doubao_base_url.rstrip("/")
|
||||
self.timeout: int = settings.doubao_timeout
|
||||
self.max_retries: int = settings.doubao_max_retries
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否可用(配置了 API Key)."""
|
||||
return bool(self.api_key)
|
||||
|
||||
def chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> Optional[str]:
|
||||
"""调用 Chat Completion 接口.
|
||||
|
||||
Args:
|
||||
messages: 对话消息列表,[{"role": "user"/"system"/"assistant", "content": "..."}]
|
||||
temperature: 采样温度,0-2,默认0.7
|
||||
max_tokens: 最大生成token数,默认1024
|
||||
|
||||
Returns:
|
||||
模型返回的文本内容,失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包API调用失败,%.1fs后重试 (第%d/%d次): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("豆包API调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
|
||||
# ── 单例 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_client: Optional[DoubaoClient] = None
|
||||
|
||||
|
||||
def get_doubao_client() -> DoubaoClient:
|
||||
"""获取豆包客户端单例."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = DoubaoClient()
|
||||
return _client
|
||||
@@ -39,6 +39,13 @@ class SharedSettings(BaseSettings):
|
||||
# 音色克隆模型名(固定为 voice-enrollment)
|
||||
cosyvoice_clone_model: str = "voice-enrollment"
|
||||
|
||||
# 豆包大模型(火山引擎方舟)
|
||||
doubao_api_key: str = ""
|
||||
doubao_model: str = "doubao-seed-1-6-250615"
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
|
||||
# Environment
|
||||
environment: str = "development"
|
||||
auto_create_schema: bool = False
|
||||
|
||||
@@ -7,6 +7,11 @@ JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
|
||||
# --- 配置 pip 国内源(加速下载,减少网络失败)---
|
||||
python3 -m pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
|
||||
python3 -m pip config set global.timeout 120
|
||||
python3 -m pip config set global.retries 5
|
||||
|
||||
# --- 安装依赖 ---
|
||||
echo ""
|
||||
echo "=== 安装 Python 依赖 ==="
|
||||
@@ -23,6 +28,12 @@ for i in 1 2 3; do
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-worker.txt && break
|
||||
echo "pip install requirements-worker.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
@@ -64,8 +75,8 @@ echo "=== 运行单元测试 (模式: $UNIT_TEST_MODE) ==="
|
||||
|
||||
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
|
||||
echo "=== 增量测试模式 ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,apps/worker/worker_app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest $SELECTED_TEST_FILES -q
|
||||
@@ -73,8 +84,8 @@ if [ "$UNIT_TEST_MODE" = "incremental" ]; then
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=10 > /dev/null || true
|
||||
else
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,apps/worker/worker_app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/unit -q
|
||||
|
||||
@@ -1,117 +1,18 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端依赖安装(Docker Volume 持久化缓存方案)
|
||||
# 通过 Docker named volume 缓存 node_modules,按 package-lock.json hash 命名
|
||||
# 缓存命中时跳过 npm ci,直接复用已有 volume
|
||||
# CI 公共步骤:前端依赖安装
|
||||
# 直接在 CI 容器内运行(CI 镜像已包含 Node.js),无需 Docker 嵌套
|
||||
set -eu
|
||||
|
||||
MODE="${1:-full}"
|
||||
|
||||
echo "=== 前端依赖安装开始 (模式: $MODE) ==="
|
||||
|
||||
# npm国内镜像源(加速下载,减少网络失败)
|
||||
NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
cd apps/web
|
||||
|
||||
# 缓存配置 — 与 step_frontend_run.sh 保持一致
|
||||
LOCK_FILE="apps/web/package-lock.json"
|
||||
VOLUME_PREFIX="ci-web-nm-"
|
||||
KEEP_CACHE_COUNT=5
|
||||
# 配置国内镜像源加速
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 计算 package-lock.json 的 md5 hash 作为缓存 key
|
||||
VOLUME_NAME=""
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
|
||||
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
|
||||
echo "缓存 key: $LOCK_HASH (volume: $VOLUME_NAME)"
|
||||
else
|
||||
echo "警告: 未找到 $LOCK_FILE,将不使用持久化缓存"
|
||||
fi
|
||||
|
||||
# 检查 volume 是否存在(缓存命中)
|
||||
CACHE_HIT=0
|
||||
if [ -n "$VOLUME_NAME" ]; then
|
||||
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
CACHE_HIT=1
|
||||
echo "缓存命中!复用 volume: $VOLUME_NAME"
|
||||
else
|
||||
echo "缓存未命中,创建 volume 并安装依赖..."
|
||||
# 创建 volume(失败则降级为无缓存模式)
|
||||
if ! docker volume create "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
echo "警告: 创建 volume 失败,降级为无缓存模式"
|
||||
VOLUME_NAME=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 构建 docker run 的 volume 挂载参数(空时不挂载)
|
||||
VOLUME_ARGS=""
|
||||
if [ -n "$VOLUME_NAME" ]; then
|
||||
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
|
||||
fi
|
||||
|
||||
# 缓存未命中时执行 npm ci
|
||||
if [ "$CACHE_HIT" -eq 0 ]; then
|
||||
for i in 1 2 3; do
|
||||
echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)"
|
||||
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
|
||||
echo "npm ci 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
done
|
||||
else
|
||||
echo "缓存命中,验证依赖完整性..."
|
||||
# 验证关键依赖是否存在(防止缓存损坏或版本漂移)
|
||||
DEPS_OK=1
|
||||
if ! docker run --rm $VOLUME_ARGS -v "$PWD:/workspace" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npx --yes vitest --version > /dev/null 2>&1 && npx --yes vite --version > /dev/null 2>&1" 2>/dev/null; then
|
||||
echo "⚠️ 缓存依赖不完整(vitest/vite缺失),废弃缓存重新安装"
|
||||
DEPS_OK=0
|
||||
docker volume rm "$VOLUME_NAME" > /dev/null 2>&1 || true
|
||||
docker volume create "$VOLUME_NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ "$DEPS_OK" -eq 1 ]; then
|
||||
echo "✅ 依赖完整性校验通过,跳过 npm ci"
|
||||
else
|
||||
# 重新安装
|
||||
for i in 1 2 3; do
|
||||
echo "npm ci 重新安装尝试 $i/3 (镜像: $NPM_REGISTRY)"
|
||||
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
|
||||
echo "npm ci 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# 清理旧缓存 volume(保留最近 N 个,防止磁盘占用无限增长)
|
||||
if [ -n "$VOLUME_PREFIX" ]; then
|
||||
echo "清理旧缓存 volume(保留最近 ${KEEP_CACHE_COUNT} 个)..."
|
||||
ALL_VOLUMES=$(docker volume ls -q --filter "name=${VOLUME_PREFIX}" 2>/dev/null || true)
|
||||
if [ -n "$ALL_VOLUMES" ]; then
|
||||
TOTAL=$(echo "$ALL_VOLUMES" | wc -l)
|
||||
if [ "$TOTAL" -gt "$KEEP_CACHE_COUNT" ]; then
|
||||
# 按创建时间排序,保留最新的 N 个
|
||||
SORTED_VOLUMES=$(for v in $ALL_VOLUMES; do
|
||||
CREATED=$(docker volume inspect --format '{{.CreatedAt}}' "$v" 2>/dev/null || echo "0")
|
||||
echo "$CREATED $v"
|
||||
done | sort | awk '{print $2}')
|
||||
|
||||
# 删除超出保留数量的旧 volume
|
||||
REMOVE_COUNT=$((TOTAL - KEEP_CACHE_COUNT))
|
||||
TO_DELETE=$(echo "$SORTED_VOLUMES" | head -n "$REMOVE_COUNT")
|
||||
REMOVED=0
|
||||
for v in $TO_DELETE; do
|
||||
# 跳过当前正在使用的 volume
|
||||
if [ "$v" != "$VOLUME_NAME" ]; then
|
||||
if docker volume rm "$v" >/dev/null 2>&1; then
|
||||
REMOVED=$((REMOVED + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "已清理 $REMOVED 个旧缓存 volume,当前共 $((TOTAL - REMOVED)) 个"
|
||||
else
|
||||
echo "当前缓存 volume 数量: $TOTAL,无需清理"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 安装依赖
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
echo "=== 前端依赖安装完成 ==="
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端命令执行(在 docker node 容器中运行)
|
||||
# 用法:step_frontend_run.sh "要执行的命令"
|
||||
# 支持 Docker Volume 持久化缓存的 node_modules
|
||||
# CI 公共步骤:前端命令执行
|
||||
# 直接在 CI 容器内运行(CI 镜像已包含 Node.js + pnpm),无需 Docker 嵌套
|
||||
set -eu
|
||||
|
||||
CMD="${1:-echo 'no command'}"
|
||||
|
||||
# 缓存配置 — 与 step_frontend_install.sh 保持一致
|
||||
LOCK_FILE="apps/web/package-lock.json"
|
||||
VOLUME_PREFIX="ci-web-nm-"
|
||||
|
||||
# 计算 package-lock.json 的 hash,挂载对应的 volume
|
||||
VOLUME_ARGS=""
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
|
||||
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
|
||||
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
|
||||
echo "使用缓存 volume: $VOLUME_NAME"
|
||||
else
|
||||
echo "提示: 未找到缓存 volume $VOLUME_NAME,将使用源码目录 node_modules"
|
||||
fi
|
||||
fi
|
||||
|
||||
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "$CMD"
|
||||
cd apps/web
|
||||
sh -lc "$CMD"
|
||||
|
||||
+257
-124
@@ -18,56 +18,44 @@ from unittest.mock import MagicMock, patch
|
||||
sys.path.insert(0, "apps/api")
|
||||
|
||||
from app.services.ai_service import ( # noqa: E402
|
||||
DoubaoAIClient,
|
||||
TITLE_STYLES,
|
||||
_generate_titles_fallback,
|
||||
_parse_semantic_match_response,
|
||||
_parse_titles_from_response,
|
||||
_semantic_match_fallback,
|
||||
generate_smart_titles,
|
||||
semantic_match_assets,
|
||||
)
|
||||
|
||||
|
||||
class TestDoubaoAIClient(unittest.TestCase):
|
||||
"""豆包客户端基础测试."""
|
||||
class TestAIClientAvailability(unittest.TestCase):
|
||||
"""AI客户端可用性检测(通过mock get_doubao_client)."""
|
||||
|
||||
def test_client_availability_without_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-model",
|
||||
DOUBAO_BASE_URL="https://test.com",
|
||||
DOUBAO_TIMEOUT=30,
|
||||
DOUBAO_MAX_RETRIES=2,
|
||||
)
|
||||
client = DoubaoAIClient()
|
||||
self.assertFalse(client.is_available)
|
||||
def test_generate_fallback_when_client_unavailable(self):
|
||||
"""客户端不可用时走降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
def test_client_availability_with_key(self):
|
||||
"""配置了 API Key 时可用."""
|
||||
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=2,
|
||||
)
|
||||
client = DoubaoAIClient()
|
||||
self.assertTrue(client.is_available)
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试内容", "viral", 5)
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
# 不可用时不应调用 chat_completion
|
||||
mock_client.chat_completion.assert_not_called()
|
||||
|
||||
def test_chat_completion_not_available_returns_none(self):
|
||||
"""不可用时调用返回 None."""
|
||||
with patch("app.services.ai_service.get_settings") as mock_settings:
|
||||
mock_settings.return_value = MagicMock(
|
||||
DOUBAO_API_KEY="",
|
||||
DOUBAO_MODEL="test-model",
|
||||
DOUBAO_BASE_URL="https://test.com",
|
||||
DOUBAO_TIMEOUT=30,
|
||||
DOUBAO_MAX_RETRIES=2,
|
||||
)
|
||||
client = DoubaoAIClient()
|
||||
result = client._chat_completion([{"role": "user", "content": "hi"}])
|
||||
self.assertIsNone(result)
|
||||
def test_generate_calls_client_when_available(self):
|
||||
"""客户端可用时调用API."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(
|
||||
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
|
||||
)
|
||||
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试", "viral", 5)
|
||||
self.assertEqual(result["source"], "doubao")
|
||||
mock_client.chat_completion.assert_called_once()
|
||||
|
||||
|
||||
class TestTitleParsing(unittest.TestCase):
|
||||
@@ -88,7 +76,7 @@ class TestTitleParsing(unittest.TestCase):
|
||||
|
||||
def test_parse_markdown_code_block_json(self):
|
||||
"""解析 markdown 代码块包裹的 JSON."""
|
||||
content = "```json\n[\"标题1\", \"标题2\"]\n```"
|
||||
content = '```json\n["标题1", "标题2"]\n```'
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
@@ -165,14 +153,9 @@ class TestGenerateSmartTitles(unittest.TestCase):
|
||||
|
||||
def test_generate_without_api_key_fallback(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=2,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频内容", "viral", 5)
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(result["style"], "viral")
|
||||
@@ -180,27 +163,17 @@ class TestGenerateSmartTitles(unittest.TestCase):
|
||||
|
||||
def test_generate_invalid_style_defaults_to_viral(self):
|
||||
"""无效风格默认 viral."""
|
||||
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=2,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试", "invalid_style", 5)
|
||||
self.assertEqual(result["style"], "viral")
|
||||
|
||||
def test_generate_count_bounds(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=2,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
# 小于最小值
|
||||
result = generate_smart_titles("测试", "viral", 1)
|
||||
self.assertEqual(len(result["titles"]), 3)
|
||||
@@ -210,70 +183,37 @@ class TestGenerateSmartTitles(unittest.TestCase):
|
||||
|
||||
def test_generate_with_api_success(self):
|
||||
"""API 调用成功路径."""
|
||||
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(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("httpx.post", return_value=mock_response):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
self.assertEqual(result["source"], "doubao")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
self.assertIn("AI标题1", result["titles"])
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(
|
||||
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
|
||||
)
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
self.assertEqual(result["source"], "doubao")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
self.assertIn("AI标题1", result["titles"])
|
||||
|
||||
def test_generate_with_api_failure_fallback(self):
|
||||
"""API 调用失败时降级."""
|
||||
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 = generate_smart_titles("测试视频", "viral", 5)
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
|
||||
def test_generate_api_returns_unparseable_fallback(self):
|
||||
"""API 返回无法解析时降级."""
|
||||
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": "一段文字说明,不是标题列表"}}]
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("httpx.post", return_value=mock_response):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
# 只有1个有效标题,不足2个触发降级
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
# 返回无法解析的内容(只有一个标题且格式异常)
|
||||
mock_client.chat_completion = MagicMock(return_value="一段文字说明,不是标题列表")
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
# 只有1个有效标题,不足2个触发降级
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
|
||||
|
||||
class TestTitleStyles(unittest.TestCase):
|
||||
@@ -281,7 +221,7 @@ class TestTitleStyles(unittest.TestCase):
|
||||
|
||||
def test_all_styles_have_required_fields(self):
|
||||
"""所有风格都有必要字段."""
|
||||
for key, info in TITLE_STYLES.items():
|
||||
for _key, info in TITLE_STYLES.items():
|
||||
self.assertIn("name", info)
|
||||
self.assertIn("description", info)
|
||||
self.assertIn("examples", info)
|
||||
@@ -295,5 +235,198 @@ 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时走降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
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 限制返回数量."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = semantic_match_assets("测试", self._make_assets(), top_k=2)
|
||||
self.assertEqual(len(result["matches"]), 2)
|
||||
|
||||
def test_with_doubao_success(self):
|
||||
"""豆包调用成功路径."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=json.dumps({"a1": 0.9, "a2": 0.5, "a3": 0.2}))
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
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):
|
||||
"""豆包调用失败降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = semantic_match_assets("测试", self._make_assets())
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
|
||||
def test_each_match_has_required_fields(self):
|
||||
"""每个匹配结果都有必要字段."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
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()
|
||||
|
||||
Executable
+359
@@ -0,0 +1,359 @@
|
||||
"""Worker AI 任务单元测试.
|
||||
|
||||
测试覆盖:
|
||||
- AI推荐(豆包调用成功/失败/降级)
|
||||
- 推荐响应解析(多种格式)
|
||||
- 封面生成降级
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, "apps/worker")
|
||||
sys.path.insert(0, "packages")
|
||||
|
||||
from worker_app.tasks.ai_tasks import ( # noqa: E402
|
||||
_fallback_recommend_clips,
|
||||
_parse_recommend_response,
|
||||
run_ai_recommend,
|
||||
run_generate_cover,
|
||||
)
|
||||
|
||||
|
||||
class TestFallbackRecommend(unittest.TestCase):
|
||||
"""降级推荐方案测试."""
|
||||
|
||||
def test_fallback_returns_expected_structure(self):
|
||||
"""降级推荐返回正确结构."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2", "a3"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
self.assertIn("clips", result)
|
||||
self.assertIn("config", result)
|
||||
self.assertIn("total_duration", result)
|
||||
self.assertIn("confidence", result)
|
||||
|
||||
def test_fallback_clips_structure(self):
|
||||
"""每个片段都有必要字段."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=20.0,
|
||||
)
|
||||
clips = result["clips"]
|
||||
self.assertTrue(len(clips) >= 3) # intro + showcase + outro
|
||||
for clip in clips:
|
||||
self.assertIn("clip_type", clip)
|
||||
self.assertIn("order", clip)
|
||||
self.assertIn("text_content", clip)
|
||||
self.assertIn("duration", clip)
|
||||
self.assertIn("transition_effect", clip)
|
||||
self.assertIn("asset_id", clip)
|
||||
self.assertIn("start_time", clip)
|
||||
self.assertIn("config", clip)
|
||||
|
||||
def test_fallback_first_is_intro_last_is_outro(self):
|
||||
"""第一个是开场,最后一个是结尾."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2", "a3"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
clips = result["clips"]
|
||||
self.assertEqual(clips[0]["clip_type"], "intro")
|
||||
self.assertEqual(clips[-1]["clip_type"], "outro")
|
||||
|
||||
def test_fallback_order_sequential(self):
|
||||
"""order 连续递增."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
for i, clip in enumerate(result["clips"]):
|
||||
self.assertEqual(clip["order"], i)
|
||||
|
||||
def test_fallback_empty_assets(self):
|
||||
"""空素材列表也能生成."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=[],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
self.assertTrue(len(result["clips"]) >= 2)
|
||||
|
||||
def test_fallback_confidence_in_range(self):
|
||||
"""置信度在0-1之间."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1"],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
self.assertGreaterEqual(result["confidence"], 0.0)
|
||||
self.assertLessEqual(result["confidence"], 1.0)
|
||||
|
||||
|
||||
class TestRecommendResponseParsing(unittest.TestCase):
|
||||
"""推荐响应解析测试."""
|
||||
|
||||
def _asset_ids(self):
|
||||
return ["a1", "a2", "a3"]
|
||||
|
||||
def test_parse_valid_response(self):
|
||||
"""解析正常响应."""
|
||||
data = {
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "text_content": "开场",
|
||||
"duration": 3.0, "transition_effect": "fade",
|
||||
"asset_id": "a1", "start_time": 0.0, "config": {}},
|
||||
{"clip_type": "showcase", "order": 1, "text_content": "展示",
|
||||
"duration": 5.0, "transition_effect": "cut",
|
||||
"asset_id": "a2", "start_time": 1.0, "config": {}},
|
||||
{"clip_type": "outro", "order": 2, "text_content": "结尾",
|
||||
"duration": 2.0, "transition_effect": "fade",
|
||||
"asset_id": "", "start_time": 0.0, "config": {}},
|
||||
],
|
||||
"title": "精彩视频",
|
||||
"confidence": 0.85,
|
||||
}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result["clips"]), 3)
|
||||
self.assertEqual(result["clips"][0]["clip_type"], "intro")
|
||||
self.assertEqual(result["confidence"], 0.85)
|
||||
self.assertIn("精彩视频", result["config"].get("title", {}).get("text", ""))
|
||||
|
||||
def test_parse_markdown_code_block(self):
|
||||
"""解析markdown代码块."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}}
|
||||
], "confidence": 0.7}
|
||||
content = "```json\n" + json.dumps(data) + "\n```"
|
||||
result = _parse_recommend_response(content, self._asset_ids(), 30.0)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result["clips"]), 1)
|
||||
|
||||
def test_parse_empty_content(self):
|
||||
"""空内容返回None."""
|
||||
result = _parse_recommend_response("", self._asset_ids(), 30.0)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_invalid_json(self):
|
||||
"""无效JSON返回None."""
|
||||
result = _parse_recommend_response("不是json", self._asset_ids(), 30.0)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_no_clips(self):
|
||||
"""无clips字段返回None."""
|
||||
result = _parse_recommend_response(
|
||||
json.dumps({"title": "abc"}), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_filters_invalid_asset_ids(self):
|
||||
"""过滤不在输入列表中的asset_id."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "fake-id", "start_time": 0, "config": {}}
|
||||
], "confidence": 0.7}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
# 非法asset_id被清空
|
||||
self.assertEqual(result["clips"][0]["asset_id"], "")
|
||||
|
||||
def test_parse_clamps_duration(self):
|
||||
"""时长被限制在合理范围."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 100, "transition_effect": "cut",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}}
|
||||
]}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertLessEqual(result["clips"][0]["duration"], 30.0)
|
||||
|
||||
def test_parse_reorders_clips(self):
|
||||
"""clips按order排序并重新编号."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 5, "text_content": "b",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "a2", "start_time": 0, "config": {}},
|
||||
{"clip_type": "intro", "order": 0, "text_content": "a",
|
||||
"duration": 3, "transition_effect": "fade",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}},
|
||||
]}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
# 第一个应该是order=0的intro
|
||||
self.assertEqual(result["clips"][0]["clip_type"], "intro")
|
||||
# order被重新编号为连续
|
||||
self.assertEqual(result["clips"][0]["order"], 0)
|
||||
self.assertEqual(result["clips"][1]["order"], 1)
|
||||
|
||||
def test_parse_confidence_clamped(self):
|
||||
"""confidence被限制在0-1."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}}
|
||||
], "confidence": 2.5}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertLessEqual(result["confidence"], 1.0)
|
||||
|
||||
|
||||
class TestRunAIRecommend(unittest.TestCase):
|
||||
"""run_ai_recommend 集成测试."""
|
||||
|
||||
def test_fallback_when_client_unavailable(self):
|
||||
"""客户端不可用时走降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=20.0,
|
||||
)
|
||||
self.assertIn("clips", result)
|
||||
self.assertIn("total_duration", result)
|
||||
mock_client.chat_completion.assert_not_called()
|
||||
|
||||
def test_doubao_success(self):
|
||||
"""豆包调用成功路径."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_response = {
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "text_content": "开场",
|
||||
"duration": 3.0, "transition_effect": "fade",
|
||||
"asset_id": "a1", "start_time": 0.0, "config": {}},
|
||||
{"clip_type": "outro", "order": 1, "text_content": "结尾",
|
||||
"duration": 2.0, "transition_effect": "fade",
|
||||
"asset_id": "a2", "start_time": 0.0, "config": {}},
|
||||
],
|
||||
"title": "AI生成标题",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
mock_client.chat_completion = MagicMock(return_value=json.dumps(mock_response))
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
self.assertEqual(result["confidence"], 0.9)
|
||||
self.assertEqual(len(result["clips"]), 2)
|
||||
mock_client.chat_completion.assert_called_once()
|
||||
|
||||
def test_doubao_failure_fallback(self):
|
||||
"""豆包调用失败降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1"],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
# 降级后有结果
|
||||
self.assertTrue(len(result["clips"]) >= 2)
|
||||
mock_client.chat_completion.assert_called_once()
|
||||
|
||||
def test_doubao_unparseable_fallback(self):
|
||||
"""豆包返回无法解析时降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value="一堆废话不是json")
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1"],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
# 降级后有结果
|
||||
self.assertTrue(len(result["clips"]) >= 2)
|
||||
|
||||
|
||||
class TestGenerateCover(unittest.TestCase):
|
||||
"""封面生成测试(降级路径)."""
|
||||
|
||||
def test_ai_frame_type(self):
|
||||
"""AI封面模式返回预期结构."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
self.assertIn("type", result)
|
||||
self.assertEqual(result["type"], "ai_frame")
|
||||
self.assertIn("image_url", result)
|
||||
|
||||
def test_manual_type(self):
|
||||
"""手动选帧模式."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="manual",
|
||||
frame_time=5.0,
|
||||
)
|
||||
self.assertEqual(result["type"], "manual")
|
||||
self.assertEqual(result["frame_time"], 5.0)
|
||||
|
||||
def test_upload_type(self):
|
||||
"""上传封面模式."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="upload",
|
||||
)
|
||||
self.assertEqual(result["type"], "upload")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user