"""统一 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)