486f1a092e
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m15s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 44s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m33s
CI/CD Pipeline / Unit Tests (push) Successful in 7m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m50s
537 lines
17 KiB
Python
Executable File
537 lines
17 KiB
Python
Executable File
"""统一 AI 服务层 — 豆包大模型接入.
|
||
|
||
提供基于字节跳动豆包大模型的 AI 能力:
|
||
- 智能标题生成(爆款/情感/信息三种风格)
|
||
- 后续扩展:智能素材匹配、AI 推荐片段编排等
|
||
|
||
设计原则:
|
||
1. 无 API Key 或调用失败时自动降级为本地模拟,不阻塞主流程
|
||
2. 统一的客户端封装,新增能力只需加方法
|
||
3. 所有模型相关配置集中在 Settings
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import math
|
||
import random
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from packages.shared.ai_client import get_doubao_client
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ── 智能标题风格定义 ─────────────────────────────────────────────────────────
|
||
|
||
TITLE_STYLES = {
|
||
"viral": {
|
||
"name": "爆款",
|
||
"description": "吸引点击、引发好奇的爆款标题,带有数字、疑问或反差感",
|
||
"examples": [
|
||
"3个方法让你效率翻倍,第2个最绝",
|
||
"为什么越努力越穷?真相扎心了",
|
||
"看完这个,我删掉了手机里一半的APP",
|
||
],
|
||
},
|
||
"emotional": {
|
||
"name": "情感",
|
||
"description": "触动人心、引发共鸣的情感向标题",
|
||
"examples": [
|
||
"那些年我们一起追过的梦想",
|
||
"生活不易,但请相信光",
|
||
"致每一个在城市里打拼的你",
|
||
],
|
||
},
|
||
"informative": {
|
||
"name": "信息",
|
||
"description": "清晰直白、传递核心信息的干货标题",
|
||
"examples": [
|
||
"2026年最新个税政策解读,一文讲透",
|
||
"新手剪辑入门:从0到1完整指南",
|
||
"产品对比:10款热门手机深度评测",
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _generate_titles_fallback(
|
||
description: str,
|
||
style: str = "viral",
|
||
count: int = 5,
|
||
) -> List[str]:
|
||
"""本地降级:基于模板规则生成标题.
|
||
|
||
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
|
||
"""
|
||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||
examples = style_info["examples"]
|
||
|
||
# 从描述中提取关键词(取前几个词)
|
||
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
||
keyword = keywords[0] if keywords else "精彩内容"
|
||
|
||
# 基于模板生成
|
||
templates = [
|
||
f"「{keyword}」{examples[0][:10]}...",
|
||
f"{keyword}:{examples[1]}",
|
||
f"关于{keyword},你不知道的3件事",
|
||
f"{keyword}入门指南,新手必看",
|
||
f"深度解析:{keyword}背后的秘密",
|
||
f"{keyword}怎么做?手把手教你",
|
||
f"干货分享 | {keyword}全攻略",
|
||
f"建议收藏:{keyword}实用技巧",
|
||
f"{keyword}避坑指南,别再踩雷了",
|
||
f"一分钟搞懂{keyword}",
|
||
]
|
||
|
||
random.shuffle(templates)
|
||
return templates[: min(count, len(templates))]
|
||
|
||
|
||
def _parse_titles_from_response(content: str) -> List[str]:
|
||
"""从模型返回中解析标题列表.
|
||
|
||
支持多种返回格式:
|
||
- JSON 数组: ["标题1", "标题2"]
|
||
- 编号列表: 1. 标题1 / 2. 标题2
|
||
- 换行分隔: 标题1\n标题2
|
||
- 带破折号: - 标题1
|
||
"""
|
||
if not content:
|
||
return []
|
||
|
||
# 尝试解析 JSON
|
||
try:
|
||
# 清理可能的 markdown 代码块标记
|
||
cleaned = content.strip()
|
||
if cleaned.startswith("```"):
|
||
cleaned = cleaned.strip("`")
|
||
if cleaned.lower().startswith("json"):
|
||
cleaned = cleaned[4:]
|
||
cleaned = cleaned.strip()
|
||
|
||
data = json.loads(cleaned)
|
||
if isinstance(data, list):
|
||
return [str(item).strip() for item in data if str(item).strip()]
|
||
if isinstance(data, dict) and "titles" in data:
|
||
titles = data["titles"]
|
||
if isinstance(titles, list):
|
||
return [str(t).strip() for t in titles if str(t).strip()]
|
||
except (json.JSONDecodeError, ValueError):
|
||
pass
|
||
|
||
# 尝试按行解析
|
||
titles: List[str] = []
|
||
for line in content.strip().split("\n"):
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
# 去掉编号前缀 "1. " "1、" "(1)"
|
||
import re
|
||
|
||
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
||
# 去掉破折号前缀 "- " "• "
|
||
line = re.sub(r"^[-•·]\s*", "", line)
|
||
# 去掉引号
|
||
line = line.strip('"').strip("'").strip("「」")
|
||
if line and len(line) < 100: # 过滤过长的行
|
||
titles.append(line)
|
||
|
||
return titles
|
||
|
||
|
||
def generate_smart_titles(
|
||
description: str,
|
||
style: str = "viral",
|
||
count: int = 5,
|
||
) -> Dict[str, Any]:
|
||
"""生成智能标题.
|
||
|
||
Args:
|
||
description: 视频内容描述
|
||
style: 标题风格 viral/emotional/informative
|
||
count: 生成数量(5-10)
|
||
|
||
Returns:
|
||
{
|
||
"titles": [...],
|
||
"style": "viral",
|
||
"source": "doubao" | "fallback", # 实际来源
|
||
"description": "...",
|
||
}
|
||
"""
|
||
# 参数校验与边界处理
|
||
if style not in TITLE_STYLES:
|
||
style = "viral"
|
||
count = max(3, min(10, count)) # 3-10 个
|
||
description = (description or "").strip()
|
||
|
||
client = get_doubao_client()
|
||
if not client.is_available:
|
||
logger.info("豆包API未配置,使用本地降级生成标题")
|
||
titles = _generate_titles_fallback(description, style, count)
|
||
return {
|
||
"titles": titles,
|
||
"style": style,
|
||
"source": "fallback",
|
||
"description": description,
|
||
}
|
||
|
||
style_info = TITLE_STYLES[style]
|
||
system_prompt = (
|
||
f"你是一个专业的短视频标题创作专家,擅长根据视频内容生成吸引人的标题。\n"
|
||
f"请根据以下视频描述,生成{count}个{style_info['name']}风格的标题。\n"
|
||
f"风格说明:{style_info['description']}\n"
|
||
f"要求:\n"
|
||
f"1. 每个标题控制在8-25字之间\n"
|
||
f"2. 直接返回JSON数组格式,不要其他文字\n"
|
||
f"3. 标题要贴合内容,有吸引力"
|
||
)
|
||
|
||
user_prompt = f"视频描述:{description}\n\n请生成标题:"
|
||
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
]
|
||
|
||
result = client.chat_completion(
|
||
messages=messages,
|
||
temperature=0.8,
|
||
max_tokens=512,
|
||
)
|
||
|
||
if result:
|
||
titles = _parse_titles_from_response(result)
|
||
if len(titles) >= 2: # 至少解析出2个才算成功
|
||
titles = titles[:count]
|
||
logger.info(
|
||
"豆包智能标题生成成功: style=%s count=%d description=%s...",
|
||
style,
|
||
len(titles),
|
||
description[:20],
|
||
)
|
||
return {
|
||
"titles": titles,
|
||
"style": style,
|
||
"source": "doubao",
|
||
"description": description,
|
||
}
|
||
logger.warning("豆包返回内容解析失败,降级到本地生成: %s", result[:100])
|
||
|
||
# 降级到本地生成
|
||
titles = _generate_titles_fallback(description, style, count)
|
||
return {
|
||
"titles": titles,
|
||
"style": style,
|
||
"source": "fallback",
|
||
"description": description,
|
||
}
|
||
|
||
|
||
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
|
||
|
||
|
||
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) + "\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),
|
||
}
|
||
|
||
|
||
# ── 单例入口 ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def get_ai_service() -> "AIService":
|
||
"""获取 AI 服务单例."""
|
||
global _ai_service
|
||
if _ai_service is None:
|
||
_ai_service = AIService()
|
||
return _ai_service
|
||
|
||
|
||
_ai_service: Optional["AIService"] = None
|
||
|
||
|
||
class AIService:
|
||
"""AI 服务统一入口,便于后续扩展更多能力."""
|
||
|
||
def __init__(self) -> None:
|
||
self._client = get_doubao_client()
|
||
|
||
@property
|
||
def is_available(self) -> bool:
|
||
return self._client.is_available
|
||
|
||
def generate_titles(
|
||
self,
|
||
description: str,
|
||
style: str = "viral",
|
||
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)
|