3a6c6677fb
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m7s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 6m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m1s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m36s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m23s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 25h59m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 26h8m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 26h8m30s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 26h7m58s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 26h8m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 26h8m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 26h8m7s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 25h59m3s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 25h59m5s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 25h59m5s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 26h7m47s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 25h59m3s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 26h7m47s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 26h7m58s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 26h7m48s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 26h7m58s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 26h8m14s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 26h8m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 26h43m40s
235 lines
7.5 KiB
Python
235 lines
7.5 KiB
Python
"""Scripts AI 能力路由 — Issue #1893.
|
||
|
||
三个 AI 工具接口(均挂载在 /api/v1/scripts 前缀下):
|
||
- POST /extract-from-douyin 从抖音视频提取文案(yt-dlp 下载 + ASR 转写)
|
||
- POST /ai-rewrite AI 文案改写(复用豆包 LLM)
|
||
- POST /ai-generate-titles AI 标题生成(复用 generate_smart_titles)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import tempfile
|
||
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.schemas.scripts_ai import (
|
||
AiGenerateTitlesRequest,
|
||
AiGenerateTitlesResponse,
|
||
AiRewriteRequest,
|
||
AiRewriteResponse,
|
||
ExtractFromDouyinRequest,
|
||
ExtractFromDouyinResponse,
|
||
)
|
||
from app.services.script_asr_service import (
|
||
ASRNotConfiguredError,
|
||
ASRTranscriptionError,
|
||
transcribe_to_text,
|
||
)
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
|
||
from packages.shared.ai_client import get_doubao_client
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
# 抖音 URL 校验:支持短链 v.douyin.com 和长链 www.douyin.com/video/
|
||
_DOUYIN_URL_RE = re.compile(
|
||
r"^(https?://)?(v\.douyin\.com/\S+|www\.douyin\.com/video/\S+)$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _validate_douyin_url(url: str) -> None:
|
||
"""校验抖音 URL 格式,不合法时抛 HTTPException(400)."""
|
||
if not url or not url.strip():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="链接不能为空",
|
||
)
|
||
if not _DOUYIN_URL_RE.match(url.strip()):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="无效的抖音链接,仅支持 v.douyin.com 短链或 www.douyin.com/video/ 长链",
|
||
)
|
||
|
||
|
||
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/extract-from-douyin",
|
||
response_model=ExtractFromDouyinResponse,
|
||
)
|
||
def extract_from_douyin(
|
||
request: ExtractFromDouyinRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
) -> ExtractFromDouyinResponse:
|
||
"""从抖音视频下载无水印视频并通过 ASR 提取文案."""
|
||
source_url = request.url.strip()
|
||
_validate_douyin_url(source_url)
|
||
|
||
# 确保 URL 有 scheme(yt-dlp 需要完整 URL)
|
||
url_for_download = source_url
|
||
if not re.match(r"^https?://", url_for_download, re.IGNORECASE):
|
||
url_for_download = "https://" + url_for_download
|
||
|
||
# 使用临时目录下载视频,退出时自动清理
|
||
try:
|
||
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
|
||
import yt_dlp
|
||
|
||
ydl_opts = {
|
||
"format": "best[ext=mp4]/best",
|
||
"outtmpl": f"{temp_dir}/%(id)s.%(ext)s",
|
||
"quiet": True,
|
||
"no_warnings": True,
|
||
"noplaylist": True,
|
||
}
|
||
|
||
try:
|
||
ydl = yt_dlp.YoutubeDL(ydl_opts)
|
||
info = ydl.extract_info(url_for_download, download=True)
|
||
except Exception as exc:
|
||
logger.error("抖音视频下载失败: url=%s error=%s", source_url, exc)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
detail=f"视频下载失败: {exc}",
|
||
) from exc
|
||
|
||
if info is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="无法解析该抖音链接",
|
||
)
|
||
|
||
video_path = ydl.prepare_filename(info)
|
||
duration = float(info.get("duration") or 0)
|
||
|
||
# ASR 转写
|
||
try:
|
||
text = transcribe_to_text(video_path)
|
||
except ASRNotConfiguredError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=str(exc),
|
||
) from exc
|
||
except ASRTranscriptionError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
detail=str(exc),
|
||
) from exc
|
||
|
||
except HTTPException:
|
||
raise
|
||
|
||
return ExtractFromDouyinResponse(
|
||
text=text,
|
||
duration_seconds=duration,
|
||
source_url=source_url,
|
||
)
|
||
|
||
|
||
# ── 2. AI 文案改写 ───────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/ai-rewrite",
|
||
response_model=AiRewriteResponse,
|
||
)
|
||
def ai_rewrite(
|
||
request: AiRewriteRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
) -> AiRewriteResponse:
|
||
"""使用豆包大模型改写文案."""
|
||
content = (request.content or "").strip()
|
||
if not content:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="文案内容不能为空",
|
||
)
|
||
|
||
style = request.style or "口语化"
|
||
|
||
client = get_doubao_client()
|
||
if not client.is_available:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
detail="AI 服务不可用,请联系管理员配置豆包大模型 API Key",
|
||
)
|
||
|
||
system_prompt = (
|
||
"你是一个专业的短视频文案改写专家。请对以下文案进行改写,"
|
||
"要求:保留原意、口语化、适合短视频口播、调整语序避免查重。"
|
||
)
|
||
if style:
|
||
system_prompt += f"\n风格要求:{style}"
|
||
|
||
user_prompt = f"请改写以下文案:\n\n{content}"
|
||
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
]
|
||
|
||
try:
|
||
rewritten = client.chat_completion(
|
||
messages=messages,
|
||
temperature=0.8,
|
||
max_tokens=2048,
|
||
)
|
||
except Exception as exc:
|
||
logger.error("AI 改写调用失败: %s", exc)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
detail=f"AI 改写失败: {exc}",
|
||
) from exc
|
||
|
||
if not rewritten:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
detail="AI 改写未返回有效结果",
|
||
)
|
||
|
||
return AiRewriteResponse(
|
||
original=content,
|
||
rewritten=rewritten.strip(),
|
||
style=style,
|
||
)
|
||
|
||
|
||
# ── 3. AI 标题生成 ───────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/ai-generate-titles",
|
||
response_model=AiGenerateTitlesResponse,
|
||
)
|
||
def ai_generate_titles(
|
||
request: AiGenerateTitlesRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
) -> AiGenerateTitlesResponse:
|
||
"""使用现有 generate_smart_titles 生成标题."""
|
||
content = (request.content or "").strip()
|
||
if not content:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="文案内容不能为空",
|
||
)
|
||
|
||
# count 限制在 1-5(Pydantic ge=1 le=5 已校验),但为兼容直接调用场景截断
|
||
count = max(1, min(5, request.count))
|
||
|
||
from app.services.ai_service import generate_smart_titles
|
||
|
||
result = generate_smart_titles(
|
||
description=content,
|
||
style="viral",
|
||
count=count,
|
||
)
|
||
|
||
titles = result.get("titles", [])[:count]
|
||
|
||
return AiGenerateTitlesResponse(titles=titles)
|