2331 lines
91 KiB
Python
2331 lines
91 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import shutil
|
||
import subprocess
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, Field
|
||
|
||
from .database import Database, utc_now
|
||
from .integrations import CutVideoClient, HuobaoDramaClient, N8NClient
|
||
from .openai_compat import OpenAICompatClient
|
||
|
||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||
DATA_DIR = Path(os.getenv("DATA_DIR", BASE_DIR / "data" / "collector"))
|
||
DOWNLOADS_DIR = DATA_DIR / "downloads"
|
||
JOBS_DIR = DATA_DIR / "jobs"
|
||
MODELS_DIR = DATA_DIR / "models"
|
||
DB_PATH = os.getenv("DATABASE_PATH", str(DATA_DIR / "storyforge.db"))
|
||
DEFAULT_EXTERNAL_BASE_URL = os.getenv("DEFAULT_EXTERNAL_BASE_URL", "https://test.hyzq.net/storyforge")
|
||
LOCAL_OPENAI_BASE_URL = os.getenv("LOCAL_OPENAI_BASE_URL", "http://127.0.0.1:8317/v1")
|
||
LOCAL_OPENAI_MODEL = os.getenv("LOCAL_OPENAI_MODEL", "GLM-5")
|
||
LOCAL_OPENAI_API_KEY = os.getenv("LOCAL_OPENAI_API_KEY", "")
|
||
YTDLP_BIN = os.getenv("YTDLP_BIN", "yt-dlp")
|
||
FFMPEG_BIN = os.getenv("FFMPEG_BIN", "ffmpeg")
|
||
WHISPER_BIN = os.getenv("WHISPER_BIN", "")
|
||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", str(MODELS_DIR / "ggml-base.en.bin"))
|
||
N8N_BASE_URL = os.getenv("N8N_BASE_URL", "http://127.0.0.1:5670")
|
||
N8N_ANALYSIS_WEBHOOK_PATH = os.getenv("N8N_ANALYSIS_WEBHOOK_PATH", "/webhook/storyforge-analysis")
|
||
N8N_REAL_CUT_WEBHOOK_PATH = os.getenv("N8N_REAL_CUT_WEBHOOK_PATH", "/webhook/storyforge-real-cut")
|
||
N8N_AI_VIDEO_WEBHOOK_PATH = os.getenv("N8N_AI_VIDEO_WEBHOOK_PATH", "/webhook/storyforge-ai-video")
|
||
ORCHESTRATOR_SHARED_SECRET = os.getenv("ORCHESTRATOR_SHARED_SECRET", "")
|
||
CUTVIDEO_BASE_URL = os.getenv("CUTVIDEO_BASE_URL", "")
|
||
CUTVIDEO_API_KEY = os.getenv("CUTVIDEO_API_KEY", "")
|
||
HUOBAO_BASE_URL = os.getenv("HUOBAO_BASE_URL", "http://127.0.0.1:5678")
|
||
CUTVIDEO_BASE_CONFIG = os.getenv("CUTVIDEO_BASE_CONFIG", "example.job.yaml")
|
||
CUTVIDEO_POLL_INTERVAL_SEC = int(os.getenv("CUTVIDEO_POLL_INTERVAL_SEC", "10"))
|
||
CUTVIDEO_MAX_WAIT_SEC = int(os.getenv("CUTVIDEO_MAX_WAIT_SEC", "1800"))
|
||
HUOBAO_POLL_INTERVAL_SEC = int(os.getenv("HUOBAO_POLL_INTERVAL_SEC", "10"))
|
||
HUOBAO_MAX_WAIT_SEC = int(os.getenv("HUOBAO_MAX_WAIT_SEC", "900"))
|
||
|
||
for path in (DATA_DIR, DOWNLOADS_DIR, JOBS_DIR, MODELS_DIR):
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
|
||
db = Database(DB_PATH)
|
||
openai_client = OpenAICompatClient()
|
||
n8n_client = N8NClient(
|
||
base_url=N8N_BASE_URL,
|
||
workflow_paths={
|
||
"analysis_pipeline": N8N_ANALYSIS_WEBHOOK_PATH,
|
||
"real_cut_pipeline": N8N_REAL_CUT_WEBHOOK_PATH,
|
||
"ai_video_pipeline": N8N_AI_VIDEO_WEBHOOK_PATH,
|
||
},
|
||
shared_secret=ORCHESTRATOR_SHARED_SECRET,
|
||
)
|
||
cutvideo_client = CutVideoClient(base_url=CUTVIDEO_BASE_URL, api_key=CUTVIDEO_API_KEY)
|
||
huobao_client = HuobaoDramaClient(base_url=HUOBAO_BASE_URL)
|
||
|
||
app = FastAPI(title="StoryForge Collector Service", version="0.2.0")
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
app.mount("/downloads", StaticFiles(directory=str(DOWNLOADS_DIR)), name="downloads")
|
||
|
||
|
||
class RegisterAccountRequest(BaseModel):
|
||
username: str
|
||
password: str
|
||
display_name: str = ""
|
||
|
||
|
||
class LoginRequest(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
|
||
class ModelProfileRequest(BaseModel):
|
||
name: str
|
||
base_url: str
|
||
api_key: str = ""
|
||
model_name: str
|
||
is_default: bool = False
|
||
|
||
|
||
class PreferredModelRequest(BaseModel):
|
||
model_profile_id: str
|
||
|
||
|
||
class KnowledgeBaseCreateRequest(BaseModel):
|
||
name: str
|
||
description: str = ""
|
||
project_id: str = ""
|
||
|
||
|
||
class ExploreVideoLinkRequest(BaseModel):
|
||
video_url: str
|
||
title: str | None = None
|
||
project_id: str | None = None
|
||
knowledge_base_id: str | None = None
|
||
assistant_id: str | None = None
|
||
analysis_model_profile_id: str | None = None
|
||
language: str = "auto"
|
||
|
||
|
||
class ExploreTextRequest(BaseModel):
|
||
title: str
|
||
content: str
|
||
project_id: str | None = None
|
||
knowledge_base_id: str | None = None
|
||
assistant_id: str | None = None
|
||
analysis_model_profile_id: str | None = None
|
||
|
||
|
||
class AssistantCreateRequest(BaseModel):
|
||
name: str
|
||
description: str = ""
|
||
system_prompt: str = ""
|
||
generation_goal: str = ""
|
||
knowledge_base_ids: list[str] = Field(default_factory=list)
|
||
project_id: str = ""
|
||
model_profile_id: str = ""
|
||
|
||
|
||
class AssistantUpdateRequest(BaseModel):
|
||
name: str | None = None
|
||
description: str | None = None
|
||
system_prompt: str | None = None
|
||
generation_goal: str | None = None
|
||
knowledge_base_ids: list[str] | None = None
|
||
project_id: str | None = None
|
||
model_profile_id: str | None = None
|
||
|
||
|
||
class GenerateCopyRequest(BaseModel):
|
||
brief: str
|
||
platform: str = "抖音"
|
||
audience: str = "创业者"
|
||
extra_requirements: str = ""
|
||
knowledge_base_ids: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class PublishAppUpdateRequest(BaseModel):
|
||
platform: str = "android"
|
||
channel: str = "stable"
|
||
versionCode: int
|
||
versionName: str
|
||
minSupportedCode: int
|
||
apkUrl: str
|
||
apkSha256: str = ""
|
||
notes: str = ""
|
||
forceUpdate: bool = False
|
||
isActive: bool = True
|
||
|
||
|
||
class ProjectCreateRequest(BaseModel):
|
||
name: str
|
||
description: str = ""
|
||
|
||
|
||
class ContentSourceCreateRequest(BaseModel):
|
||
project_id: str = ""
|
||
source_kind: str
|
||
platform: str = ""
|
||
handle: str = ""
|
||
source_url: str = ""
|
||
title: str = ""
|
||
local_path: str = ""
|
||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class RealCutJobRequest(BaseModel):
|
||
project_id: str = ""
|
||
title: str
|
||
input_dir: str
|
||
base_config: str = ""
|
||
objective: str = "保留高信息密度片段,输出适合短视频平台的粗剪结果"
|
||
target_duration_sec: int = 60
|
||
target_aspect_ratio: str = "9:16"
|
||
ideal_segment_duration_sec: int = 8
|
||
max_segment_duration_sec: int = 18
|
||
transcript_backend: str = "auto"
|
||
transcript_device: str = "cuda"
|
||
review_enabled: bool = False
|
||
dry_run: bool = False
|
||
|
||
|
||
class AiVideoJobRequest(BaseModel):
|
||
project_id: str = ""
|
||
assistant_id: str = ""
|
||
knowledge_base_id: str = ""
|
||
source_job_id: str = ""
|
||
title: str
|
||
brief: str
|
||
style: str = "realistic"
|
||
shots: int = 4
|
||
image_provider: str = "openai"
|
||
image_model: str = ""
|
||
video_provider: str = "doubao"
|
||
video_model: str = ""
|
||
aspect_ratio: str = "9:16"
|
||
duration: int = 5
|
||
|
||
|
||
class InternalStepRequest(BaseModel):
|
||
job_id: str = ""
|
||
jobId: str = ""
|
||
payload: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class JobStatusUpdateRequest(BaseModel):
|
||
status: str
|
||
error: str = ""
|
||
provider_name: str = ""
|
||
provider_task_id: str = ""
|
||
artifacts: dict[str, Any] = Field(default_factory=dict)
|
||
result: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
def now_ts() -> int:
|
||
return int(datetime.now(timezone.utc).timestamp())
|
||
|
||
|
||
def make_id(prefix: str) -> str:
|
||
return f"{prefix}_{uuid.uuid4().hex}"
|
||
|
||
|
||
def hash_password(password: str, salt: str) -> str:
|
||
import hashlib
|
||
|
||
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000).hex()
|
||
|
||
|
||
def create_password_hash(password: str) -> tuple[str, str]:
|
||
salt = secrets.token_hex(16)
|
||
return hash_password(password, salt), salt
|
||
|
||
|
||
def verify_password(password: str, hashed: str, salt: str) -> bool:
|
||
return secrets.compare_digest(hash_password(password, salt), hashed)
|
||
|
||
|
||
def mask_api_key(value: str) -> str:
|
||
if not value:
|
||
return ""
|
||
if len(value) <= 8:
|
||
return "*" * len(value)
|
||
return f"{value[:4]}***{value[-4:]}"
|
||
|
||
|
||
def normalize_model_profile(row: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": row["id"],
|
||
"owner_account_id": row.get("owner_account_id"),
|
||
"name": row["name"],
|
||
"provider": row["provider"],
|
||
"base_url": row["base_url"],
|
||
"api_key_masked": mask_api_key(row.get("api_key", "")),
|
||
"model_name": row["model_name"],
|
||
"is_system": bool(row.get("is_system", 0)),
|
||
"is_default": bool(row.get("is_default", 0)),
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def normalize_account(row: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": row["id"],
|
||
"username": row["username"],
|
||
"display_name": row["display_name"],
|
||
"role": row["role"],
|
||
"approval_status": row["approval_status"],
|
||
"approved_by": row.get("approved_by"),
|
||
"approved_at": row.get("approved_at"),
|
||
"preferred_analysis_model_id": row.get("preferred_analysis_model_id") or "",
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def model_profile_for_account(account_id: str, requested_id: str | None) -> dict[str, Any]:
|
||
if requested_id:
|
||
row = db.fetch_one(
|
||
"SELECT * FROM model_profiles WHERE id = ? AND (owner_account_id IS NULL OR owner_account_id = ?)",
|
||
(requested_id, account_id),
|
||
)
|
||
if row:
|
||
return row
|
||
account = db.fetch_one("SELECT preferred_analysis_model_id FROM accounts WHERE id = ?", (account_id,))
|
||
preferred_id = (account or {}).get("preferred_analysis_model_id") or ""
|
||
if preferred_id:
|
||
row = db.fetch_one(
|
||
"SELECT * FROM model_profiles WHERE id = ? AND (owner_account_id IS NULL OR owner_account_id = ?)",
|
||
(preferred_id, account_id),
|
||
)
|
||
if row:
|
||
return row
|
||
row = db.fetch_one("SELECT * FROM model_profiles WHERE is_default = 1 ORDER BY is_system DESC, created_at ASC LIMIT 1")
|
||
if not row:
|
||
raise HTTPException(status_code=500, detail="No model profile configured")
|
||
return row
|
||
|
||
|
||
def project_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": row["id"],
|
||
"user_id": row["user_id"],
|
||
"name": row["name"],
|
||
"description": row.get("description", ""),
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def content_source_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
metadata = row.get("metadata_json") or "{}"
|
||
try:
|
||
metadata_map = json.loads(metadata)
|
||
except json.JSONDecodeError:
|
||
metadata_map = {}
|
||
return {
|
||
"id": row["id"],
|
||
"user_id": row["user_id"],
|
||
"project_id": row.get("project_id", ""),
|
||
"source_kind": row["source_kind"],
|
||
"platform": row.get("platform", ""),
|
||
"handle": row.get("handle", ""),
|
||
"source_url": row.get("source_url", ""),
|
||
"title": row.get("title", ""),
|
||
"local_path": row.get("local_path", ""),
|
||
"metadata": metadata_map,
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def job_event_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": row["id"],
|
||
"job_id": row["job_id"],
|
||
"event_type": row["event_type"],
|
||
"payload": parse_json_object(row.get("payload_json") or "{}"),
|
||
"created_at": row["created_at"],
|
||
}
|
||
|
||
|
||
def ensure_default_project(account_id: str, username: str = "默认用户") -> dict[str, Any]:
|
||
project = db.fetch_one(
|
||
"SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC LIMIT 1",
|
||
(account_id,),
|
||
)
|
||
if project:
|
||
return project
|
||
now = utc_now()
|
||
project_id = make_id("project")
|
||
db.execute(
|
||
"""
|
||
INSERT INTO projects (id, user_id, name, description, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
project_id,
|
||
account_id,
|
||
f"{username} 默认项目",
|
||
"系统自动创建",
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
return db.fetch_one("SELECT * FROM projects WHERE id = ?", (project_id,))
|
||
|
||
|
||
def resolve_target_project(account_id: str, requested_project_id: str | None, username: str = "默认用户") -> dict[str, Any]:
|
||
if requested_project_id:
|
||
project = db.fetch_one(
|
||
"SELECT * FROM projects WHERE id = ? AND user_id = ?",
|
||
(requested_project_id, account_id),
|
||
)
|
||
if project:
|
||
return project
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
return ensure_default_project(account_id, username=username)
|
||
|
||
|
||
def resolve_target_assistant(account_id: str, requested_assistant_id: str | None, project_id: str = "") -> dict[str, Any] | None:
|
||
if not requested_assistant_id:
|
||
return None
|
||
assistant = db.fetch_one("SELECT * FROM assistants WHERE id = ? AND user_id = ?", (requested_assistant_id, account_id))
|
||
if not assistant:
|
||
raise HTTPException(status_code=404, detail="Assistant not found")
|
||
if project_id and assistant.get("project_id") and assistant.get("project_id") != project_id:
|
||
raise HTTPException(status_code=400, detail="Assistant does not belong to target project")
|
||
return assistant
|
||
|
||
|
||
def append_job_event(job_id: str, event_type: str, payload: dict[str, Any] | None = None) -> None:
|
||
db.execute(
|
||
"""
|
||
INSERT INTO job_events (id, job_id, event_type, payload_json, created_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
make_id("evt"),
|
||
job_id,
|
||
event_type,
|
||
json.dumps(payload or {}, ensure_ascii=False),
|
||
utc_now(),
|
||
),
|
||
)
|
||
|
||
|
||
def parse_json_object(raw_text: str) -> dict[str, Any]:
|
||
cleaned = raw_text.strip()
|
||
if not cleaned:
|
||
return {}
|
||
try:
|
||
data = json.loads(cleaned)
|
||
return data if isinstance(data, dict) else {}
|
||
except json.JSONDecodeError:
|
||
match = re.search(r"\{.*\}", cleaned, re.S)
|
||
if not match:
|
||
return {}
|
||
try:
|
||
data = json.loads(match.group(0))
|
||
return data if isinstance(data, dict) else {}
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
|
||
|
||
def knowledge_base_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
document_count = db.fetch_one(
|
||
"SELECT COUNT(*) AS count FROM knowledge_documents WHERE knowledge_base_id = ?",
|
||
(row["id"],),
|
||
)["count"]
|
||
linked_count = db.fetch_one(
|
||
"SELECT COUNT(*) AS count FROM assistant_knowledge_bases WHERE knowledge_base_id = ?",
|
||
(row["id"],),
|
||
)["count"]
|
||
return {
|
||
"id": row["id"],
|
||
"user_id": row["user_id"],
|
||
"project_id": row.get("project_id", ""),
|
||
"name": row["name"],
|
||
"description": row.get("description", ""),
|
||
"sync_status": row.get("sync_status", "ready"),
|
||
"document_count": document_count,
|
||
"linked_assistant_count": linked_count,
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def assistant_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
kb_rows = db.fetch_all(
|
||
"SELECT knowledge_base_id FROM assistant_knowledge_bases WHERE assistant_id = ? ORDER BY knowledge_base_id ASC",
|
||
(row["id"],),
|
||
)
|
||
return {
|
||
"id": row["id"],
|
||
"user_id": row["user_id"],
|
||
"project_id": row.get("project_id", ""),
|
||
"name": row["name"],
|
||
"description": row.get("description", ""),
|
||
"system_prompt": row.get("system_prompt", ""),
|
||
"generation_goal": row.get("generation_goal", ""),
|
||
"knowledge_base_ids": [item["knowledge_base_id"] for item in kb_rows],
|
||
"config": parse_json_object(row.get("config_json") or "{}"),
|
||
"model_profile_id": row.get("model_profile_id", ""),
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def document_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
analysis_map = parse_json_object(row.get("analysis_json") or "{}")
|
||
source_artifacts = parse_json_object(row.get("source_artifact_json") or "{}")
|
||
storyboard_raw = row.get("storyboard_json") or "[]"
|
||
try:
|
||
storyboard_items = json.loads(storyboard_raw)
|
||
except json.JSONDecodeError:
|
||
storyboard_items = []
|
||
return {
|
||
"id": row["id"],
|
||
"knowledge_base_id": row["knowledge_base_id"],
|
||
"title": row["title"],
|
||
"source_type": row["source_type"],
|
||
"source_url": row.get("source_url", ""),
|
||
"transcript_text": row.get("transcript_text", ""),
|
||
"style_summary": row.get("style_summary", ""),
|
||
"combined_text": row.get("combined_text", ""),
|
||
"analysis": analysis_map,
|
||
"storyboards": storyboard_items,
|
||
"source_artifacts": source_artifacts,
|
||
"analysis_model_profile_id": row.get("analysis_model_profile_id", ""),
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def job_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
artifacts = row.get("artifacts_json") or "{}"
|
||
result = row.get("result_json") or "{}"
|
||
try:
|
||
artifacts_map = json.loads(artifacts)
|
||
except json.JSONDecodeError:
|
||
artifacts_map = {}
|
||
try:
|
||
result_map = json.loads(result)
|
||
except json.JSONDecodeError:
|
||
result_map = {}
|
||
return {
|
||
"id": row["id"],
|
||
"user_id": row["user_id"],
|
||
"project_id": row.get("project_id", ""),
|
||
"assistant_id": row.get("assistant_id"),
|
||
"knowledge_base_id": row["knowledge_base_id"],
|
||
"content_source_id": row.get("content_source_id", ""),
|
||
"source_type": row["source_type"],
|
||
"line_type": row.get("line_type", "analysis"),
|
||
"workflow_key": row.get("workflow_key", ""),
|
||
"orchestrator": row.get("orchestrator", "n8n"),
|
||
"provider_name": row.get("provider_name", ""),
|
||
"provider_task_id": row.get("provider_task_id", ""),
|
||
"source_url": row.get("source_url"),
|
||
"title": row["title"],
|
||
"language": row.get("language", "auto"),
|
||
"status": row["status"],
|
||
"transcript_text": row.get("transcript_text", ""),
|
||
"style_summary": row.get("style_summary", ""),
|
||
"upload_status": row.get("upload_status", "pending"),
|
||
"error": row.get("error", ""),
|
||
"artifacts": artifacts_map,
|
||
"result": result_map,
|
||
"analysis_model_profile_id": row.get("analysis_model_profile_id", ""),
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def require_auth(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
if not authorization or not authorization.startswith("Bearer "):
|
||
raise HTTPException(status_code=401, detail="Missing bearer token")
|
||
token = authorization.split(" ", 1)[1].strip()
|
||
token_row = db.fetch_one("SELECT * FROM auth_tokens WHERE token = ?", (token,))
|
||
if not token_row:
|
||
raise HTTPException(status_code=401, detail="Invalid token")
|
||
account = db.fetch_one("SELECT * FROM accounts WHERE id = ?", (token_row["account_id"],))
|
||
if not account:
|
||
raise HTTPException(status_code=401, detail="Account not found")
|
||
return account
|
||
|
||
|
||
def require_approved(account: dict[str, Any] = Depends(require_auth)) -> dict[str, Any]:
|
||
if account["approval_status"] != "approved":
|
||
raise HTTPException(status_code=403, detail="Account pending approval")
|
||
return account
|
||
|
||
|
||
def require_super_admin(account: dict[str, Any] = Depends(require_auth)) -> dict[str, Any]:
|
||
if account["role"] != "super_admin":
|
||
raise HTTPException(status_code=403, detail="Super admin required")
|
||
return account
|
||
|
||
|
||
def require_orchestrator(x_orchestrator_secret: str | None = Header(default=None)) -> bool:
|
||
if ORCHESTRATOR_SHARED_SECRET and x_orchestrator_secret != ORCHESTRATOR_SHARED_SECRET:
|
||
raise HTTPException(status_code=401, detail="Invalid orchestrator secret")
|
||
return True
|
||
|
||
|
||
def create_content_source(
|
||
*,
|
||
account_id: str,
|
||
project_id: str,
|
||
source_kind: str,
|
||
platform: str = "",
|
||
handle: str = "",
|
||
source_url: str = "",
|
||
title: str = "",
|
||
local_path: str = "",
|
||
metadata: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
source_id = make_id("source")
|
||
now = utc_now()
|
||
db.execute(
|
||
"""
|
||
INSERT INTO content_sources (
|
||
id, user_id, project_id, source_kind, platform, handle,
|
||
source_url, title, local_path, metadata_json, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
source_id,
|
||
account_id,
|
||
project_id,
|
||
source_kind,
|
||
platform,
|
||
handle,
|
||
source_url,
|
||
title,
|
||
local_path,
|
||
json.dumps(metadata or {}, ensure_ascii=False),
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
return db.fetch_one("SELECT * FROM content_sources WHERE id = ?", (source_id,))
|
||
|
||
|
||
def merge_json_field(current_raw: str | None, updates: dict[str, Any]) -> str:
|
||
current = parse_json_object(current_raw or "{}")
|
||
current.update(updates)
|
||
return json.dumps(current, ensure_ascii=False)
|
||
|
||
|
||
def update_job_state(
|
||
job_id: str,
|
||
*,
|
||
status: str,
|
||
error: str = "",
|
||
provider_name: str | None = None,
|
||
provider_task_id: str | None = None,
|
||
artifacts: dict[str, Any] | None = None,
|
||
result: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
merged_artifacts = merge_json_field(row.get("artifacts_json") or "{}", artifacts or {})
|
||
merged_result = merge_json_field(row.get("result_json") or "{}", result or {})
|
||
db.execute(
|
||
"""
|
||
UPDATE jobs
|
||
SET status = ?, error = ?, provider_name = ?, provider_task_id = ?,
|
||
artifacts_json = ?, result_json = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""",
|
||
(
|
||
status,
|
||
error,
|
||
provider_name if provider_name is not None else row.get("provider_name", ""),
|
||
provider_task_id if provider_task_id is not None else row.get("provider_task_id", ""),
|
||
merged_artifacts,
|
||
merged_result,
|
||
utc_now(),
|
||
job_id,
|
||
),
|
||
)
|
||
append_job_event(
|
||
job_id,
|
||
f"job.{status}",
|
||
{
|
||
"provider_name": provider_name if provider_name is not None else row.get("provider_name", ""),
|
||
"provider_task_id": provider_task_id if provider_task_id is not None else row.get("provider_task_id", ""),
|
||
"error": error,
|
||
"artifacts": artifacts or {},
|
||
"result": result or {},
|
||
},
|
||
)
|
||
return db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||
|
||
|
||
def job_context_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||
payload = job_payload(row)
|
||
payload["project"] = None
|
||
payload["assistant"] = None
|
||
payload["knowledge_base"] = None
|
||
payload["content_source"] = None
|
||
payload["events"] = []
|
||
|
||
if row.get("project_id"):
|
||
project = db.fetch_one("SELECT * FROM projects WHERE id = ?", (row["project_id"],))
|
||
if project:
|
||
payload["project"] = project_payload(project)
|
||
|
||
if row.get("assistant_id"):
|
||
assistant = db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],))
|
||
if assistant:
|
||
payload["assistant"] = assistant_payload(assistant)
|
||
|
||
kb = db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ?", (row["knowledge_base_id"],))
|
||
if kb:
|
||
payload["knowledge_base"] = knowledge_base_payload(kb)
|
||
|
||
if row.get("content_source_id"):
|
||
source = db.fetch_one("SELECT * FROM content_sources WHERE id = ?", (row["content_source_id"],))
|
||
if source:
|
||
payload["content_source"] = content_source_payload(source)
|
||
|
||
payload["events"] = [
|
||
job_event_payload(item)
|
||
for item in db.fetch_all("SELECT * FROM job_events WHERE job_id = ? ORDER BY created_at ASC", (row["id"],))
|
||
]
|
||
|
||
return payload
|
||
|
||
|
||
async def trigger_orchestrated_job(job_row: dict[str, Any]) -> dict[str, Any]:
|
||
workflow_key = job_row.get("workflow_key") or "analysis_pipeline"
|
||
if not n8n_client.enabled:
|
||
raise HTTPException(status_code=503, detail="n8n is not configured")
|
||
append_job_event(job_row["id"], "workflow.trigger.requested", {"workflow_key": workflow_key})
|
||
update_job_state(
|
||
job_row["id"],
|
||
status="queued",
|
||
provider_name="n8n",
|
||
provider_task_id="",
|
||
result={"n8n_trigger": {"requested": True}},
|
||
)
|
||
trigger_result = await n8n_client.trigger(
|
||
workflow_key,
|
||
{
|
||
"jobId": job_row["id"],
|
||
"job_id": job_row["id"],
|
||
"workflowKey": workflow_key,
|
||
"workflow_key": workflow_key,
|
||
"lineType": job_row.get("line_type", "analysis"),
|
||
"line_type": job_row.get("line_type", "analysis"),
|
||
},
|
||
)
|
||
provider_task_id = str(trigger_result.get("executionId") or "")
|
||
db.execute(
|
||
"""
|
||
UPDATE jobs
|
||
SET provider_name = ?, provider_task_id = ?, result_json = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""",
|
||
(
|
||
"n8n",
|
||
provider_task_id,
|
||
merge_json_field(
|
||
db.fetch_one("SELECT result_json FROM jobs WHERE id = ?", (job_row["id"],)).get("result_json") or "{}",
|
||
{"n8n_trigger": trigger_result},
|
||
),
|
||
utc_now(),
|
||
job_row["id"],
|
||
),
|
||
)
|
||
append_job_event(
|
||
job_row["id"],
|
||
"workflow.trigger.accepted",
|
||
{"provider_task_id": provider_task_id, "trigger_result": trigger_result},
|
||
)
|
||
return db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_row["id"],))
|
||
|
||
|
||
async def call_model(profile: dict[str, Any], system_prompt: str, user_prompt: str, temperature: float = 0.4) -> str:
|
||
try:
|
||
content = await openai_client.chat_completion(
|
||
base_url=profile["base_url"],
|
||
api_key=profile.get("api_key", ""),
|
||
model=profile["model_name"],
|
||
system_prompt=system_prompt,
|
||
user_prompt=user_prompt,
|
||
temperature=temperature,
|
||
)
|
||
if content:
|
||
return content.strip()
|
||
except Exception:
|
||
pass
|
||
excerpt = user_prompt.strip().replace("\n", " ")[:220]
|
||
return f"风格摘要:内容以强结论开头,节奏偏短句,强调冲突转折和行动指令。素材摘要:{excerpt}"
|
||
|
||
|
||
async def summarize_style(profile: dict[str, Any], transcript_text: str, title: str) -> str:
|
||
prompt = (
|
||
f"标题:{title}\n\n"
|
||
f"素材全文:\n{transcript_text}\n\n"
|
||
"请提炼这段素材的文案风格、结构节奏、开头钩子、情绪推进、收尾 CTA,并给出可复用的学习结论。"
|
||
)
|
||
system_prompt = "你是短视频文案拆解师,输出简洁、结构化、适合沉淀进知识库。"
|
||
return await call_model(profile, system_prompt, prompt, temperature=0.3)
|
||
|
||
|
||
async def generate_content_blueprint(
|
||
profile: dict[str, Any],
|
||
*,
|
||
title: str,
|
||
transcript_text: str,
|
||
style_summary: str,
|
||
agent_prompt: str = "",
|
||
generation_goal: str = "",
|
||
) -> dict[str, Any]:
|
||
system_prompt = (
|
||
"你是短视频内容策略师。"
|
||
"必须输出 JSON 对象,不要输出 Markdown,不要输出多余解释。"
|
||
)
|
||
user_prompt = (
|
||
f"标题:{title}\n\n"
|
||
f"素材转写:\n{transcript_text}\n\n"
|
||
f"风格拆解:\n{style_summary}\n\n"
|
||
f"智能体补充约束:\n{agent_prompt or '无'}\n\n"
|
||
f"生成目标:\n{generation_goal or '围绕原素材做二创短视频'}\n\n"
|
||
"请输出如下 JSON 结构:"
|
||
"{"
|
||
'"analysis":{"hook":"","structure":[],"style_tags":[],"cta":""},'
|
||
'"rewrite":{"title":"","script":"","summary":""},'
|
||
'"storyboards":['
|
||
'{"shot_index":1,"title":"","narration":"","visual":"","first_frame_prompt":"","last_frame_prompt":"","video_prompt":"","duration_sec":5}'
|
||
"]"
|
||
"}"
|
||
)
|
||
raw = await call_model(profile, system_prompt, user_prompt, temperature=0.5)
|
||
parsed = parse_json_object(raw)
|
||
if parsed.get("storyboards"):
|
||
return parsed
|
||
|
||
fallback_storyboards: list[dict[str, Any]] = []
|
||
paragraphs = [part.strip() for part in transcript_text.split("\n") if part.strip()]
|
||
seed_segments = paragraphs[:4] or [transcript_text[:1200]]
|
||
for idx, segment in enumerate(seed_segments, start=1):
|
||
snippet = segment[:180]
|
||
fallback_storyboards.append(
|
||
{
|
||
"shot_index": idx,
|
||
"title": f"镜头{idx}",
|
||
"narration": snippet,
|
||
"visual": f"围绕这段内容构建具象画面:{snippet}",
|
||
"first_frame_prompt": f"短视频首帧,突出主题:{snippet}",
|
||
"last_frame_prompt": f"短视频尾帧,强化结论和行动指令:{snippet}",
|
||
"video_prompt": f"基于首尾帧生成连贯镜头,内容是:{snippet}",
|
||
"duration_sec": 5,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"analysis": {
|
||
"hook": title,
|
||
"structure": ["结论开场", "核心论点", "例证推进", "收尾行动"],
|
||
"style_tags": ["短句", "结论先行", "强 CTA"],
|
||
"cta": "引导用户采取下一步行动",
|
||
},
|
||
"rewrite": {
|
||
"title": title,
|
||
"script": transcript_text[:3000],
|
||
"summary": style_summary[:500],
|
||
},
|
||
"storyboards": fallback_storyboards,
|
||
}
|
||
|
||
|
||
def fallback_transcript_from_text(title: str, content: str) -> str:
|
||
return f"标题:{title}\n\n正文:\n{content.strip()}"
|
||
|
||
|
||
def command_exists(name: str) -> bool:
|
||
return shutil.which(name) is not None
|
||
|
||
|
||
def run_command(command: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||
proc = subprocess.run(command, cwd=str(cwd) if cwd else None, capture_output=True, text=True)
|
||
return proc.returncode, proc.stdout, proc.stderr
|
||
|
||
|
||
def create_job_record(
|
||
*,
|
||
account_id: str,
|
||
project_id: str,
|
||
knowledge_base_id: str,
|
||
source_type: str,
|
||
line_type: str,
|
||
workflow_key: str,
|
||
title: str,
|
||
language: str = "auto",
|
||
source_url: str = "",
|
||
assistant_id: str | None = None,
|
||
content_source_id: str | None = None,
|
||
artifacts: dict[str, Any] | None = None,
|
||
analysis_model_profile_id: str = "",
|
||
) -> dict[str, Any]:
|
||
job_id = make_id("job")
|
||
now = utc_now()
|
||
db.execute(
|
||
"""
|
||
INSERT INTO jobs (
|
||
id, user_id, project_id, assistant_id, knowledge_base_id, content_source_id,
|
||
source_type, line_type, workflow_key, orchestrator, provider_name, provider_task_id,
|
||
source_url, title, language, status, transcript_text, style_summary, upload_status,
|
||
error, artifacts_json, result_json, analysis_model_profile_id, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'n8n', '', '', ?, ?, ?, 'pending', '', '', 'pending', '', ?, '{}', ?, ?, ?)
|
||
""",
|
||
(
|
||
job_id,
|
||
account_id,
|
||
project_id,
|
||
assistant_id,
|
||
knowledge_base_id,
|
||
content_source_id,
|
||
source_type,
|
||
line_type,
|
||
workflow_key,
|
||
source_url or None,
|
||
title,
|
||
language,
|
||
json.dumps(artifacts or {}, ensure_ascii=False),
|
||
analysis_model_profile_id,
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
return db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||
|
||
|
||
async def wait_for_huobao_image(image_id: str | int) -> dict[str, Any]:
|
||
deadline = now_ts() + HUOBAO_MAX_WAIT_SEC
|
||
last_payload: dict[str, Any] = {}
|
||
while True:
|
||
last_payload = await huobao_client.get_image(str(image_id))
|
||
status = str(last_payload.get("status") or "").lower()
|
||
if status in {"completed", "failed"}:
|
||
return last_payload
|
||
if now_ts() >= deadline:
|
||
raise RuntimeError(f"Huobao image task timed out: {image_id}")
|
||
await asyncio.sleep(HUOBAO_POLL_INTERVAL_SEC)
|
||
|
||
|
||
async def wait_for_huobao_video(video_id: str | int) -> dict[str, Any]:
|
||
deadline = now_ts() + HUOBAO_MAX_WAIT_SEC
|
||
last_payload: dict[str, Any] = {}
|
||
while True:
|
||
last_payload = await huobao_client.get_video(str(video_id))
|
||
status = str(last_payload.get("status") or "").lower()
|
||
if status in {"completed", "failed"}:
|
||
return last_payload
|
||
if now_ts() >= deadline:
|
||
raise RuntimeError(f"Huobao video task timed out: {video_id}")
|
||
await asyncio.sleep(HUOBAO_POLL_INTERVAL_SEC)
|
||
|
||
|
||
def coerce_storyboards(items: Any) -> list[dict[str, Any]]:
|
||
if not isinstance(items, list):
|
||
return []
|
||
return [item for item in items if isinstance(item, dict)]
|
||
|
||
|
||
def huobao_image_size_for_aspect_ratio(aspect_ratio: str) -> str:
|
||
normalized = str(aspect_ratio or "").strip()
|
||
if normalized == "9:16":
|
||
return "1024x1536"
|
||
if normalized == "16:9":
|
||
return "1536x1024"
|
||
if normalized == "1:1":
|
||
return "1024x1024"
|
||
return "1024x1536"
|
||
|
||
|
||
def transcribe_media(job_dir: Path, source_path: Path, title: str, source_url: str = "") -> tuple[str, dict[str, str]]:
|
||
artifacts: dict[str, str] = {}
|
||
transcript = ""
|
||
media_path = source_path
|
||
artifacts["source_path"] = str(media_path)
|
||
|
||
if not source_path.exists():
|
||
transcript = (
|
||
f"素材标题:{title}\n"
|
||
f"素材来源:{source_url or source_path.name}\n\n"
|
||
"当前环境未找到可直接处理的本地视频文件,已记录来源信息并进入降级学习流程。"
|
||
)
|
||
return transcript, artifacts
|
||
|
||
audio_path = job_dir / "audio.wav"
|
||
if command_exists(FFMPEG_BIN):
|
||
code, _, err = run_command([FFMPEG_BIN, "-y", "-i", str(source_path), "-ar", "16000", "-ac", "1", str(audio_path)])
|
||
if code == 0 and audio_path.exists():
|
||
artifacts["audio_path"] = str(audio_path)
|
||
media_path = audio_path
|
||
elif err:
|
||
artifacts["ffmpeg_error"] = err.strip()[:500]
|
||
|
||
if WHISPER_BIN and Path(WHISPER_BIN).exists() and Path(WHISPER_MODEL).exists():
|
||
out_prefix = job_dir / "whisper"
|
||
code, stdout, stderr = run_command([
|
||
WHISPER_BIN,
|
||
"-m",
|
||
WHISPER_MODEL,
|
||
"-f",
|
||
str(media_path),
|
||
"-otxt",
|
||
"-of",
|
||
str(out_prefix),
|
||
])
|
||
txt_path = Path(str(out_prefix) + ".txt")
|
||
if code == 0 and txt_path.exists():
|
||
transcript = txt_path.read_text(encoding="utf-8", errors="ignore").strip()
|
||
artifacts["transcript_path"] = str(txt_path)
|
||
else:
|
||
artifacts["whisper_stdout"] = stdout.strip()[:500]
|
||
artifacts["whisper_error"] = stderr.strip()[:500]
|
||
|
||
if not transcript:
|
||
transcript = (
|
||
f"素材标题:{title}\n"
|
||
f"素材来源:{source_url or source_path.name}\n\n"
|
||
"当前环境未完成真实 ASR,已保留原始素材供后续转写。请结合标题、来源和上下文进行初步风格学习。"
|
||
)
|
||
return transcript, artifacts
|
||
|
||
|
||
def ensure_user_kb(account_id: str, project_id: str = "", username: str = "默认用户") -> dict[str, Any]:
|
||
project = resolve_target_project(account_id, project_id or None, username=username)
|
||
row = db.fetch_one(
|
||
"SELECT * FROM knowledge_bases WHERE user_id = ? AND project_id = ? ORDER BY created_at ASC LIMIT 1",
|
||
(account_id, project["id"]),
|
||
)
|
||
if row:
|
||
return row
|
||
kb_id = make_id("kb")
|
||
now = utc_now()
|
||
db.execute(
|
||
"""
|
||
INSERT INTO knowledge_bases (id, user_id, project_id, name, description, sync_status, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(kb_id, account_id, project["id"], "默认知识库", "系统为新用户自动创建", "ready", now, now),
|
||
)
|
||
return db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ?", (kb_id,))
|
||
|
||
|
||
async def process_job(job_id: str) -> None:
|
||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||
if not row:
|
||
return
|
||
now = utc_now()
|
||
db.execute("UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?", ("processing", now, job_id))
|
||
append_job_event(job_id, "job.processing", {})
|
||
|
||
try:
|
||
artifacts = json.loads(row.get("artifacts_json") or "{}")
|
||
transcript_text = row.get("transcript_text", "")
|
||
job_dir = JOBS_DIR / job_id
|
||
job_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
if row["source_type"] == "text":
|
||
transcript_text = fallback_transcript_from_text(row["title"], artifacts.get("input_text", ""))
|
||
elif row["source_type"] == "video_link":
|
||
downloaded = job_dir / "source.mp4"
|
||
if command_exists(YTDLP_BIN):
|
||
code, stdout, stderr = run_command([
|
||
YTDLP_BIN,
|
||
"--no-playlist",
|
||
"-o",
|
||
str(downloaded),
|
||
row.get("source_url") or "",
|
||
], cwd=job_dir)
|
||
if code == 0 and downloaded.exists():
|
||
artifacts["download_stdout"] = stdout.strip()[:500]
|
||
else:
|
||
artifacts["download_error"] = stderr.strip()[:500]
|
||
transcript_text, extra = transcribe_media(job_dir, downloaded if downloaded.exists() else job_dir / "placeholder.mp4", row["title"], row.get("source_url") or "")
|
||
artifacts.update(extra)
|
||
elif row["source_type"] == "upload_video":
|
||
source_path = Path(artifacts.get("uploaded_path", ""))
|
||
transcript_text, extra = transcribe_media(job_dir, source_path, row["title"], row.get("source_url") or "")
|
||
artifacts.update(extra)
|
||
|
||
profile = model_profile_for_account(row["user_id"], row.get("analysis_model_profile_id") or None)
|
||
style_summary = await summarize_style(profile, transcript_text, row["title"])
|
||
assistant = None
|
||
if row.get("assistant_id"):
|
||
assistant = db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],))
|
||
content_blueprint = await generate_content_blueprint(
|
||
profile,
|
||
title=row["title"],
|
||
transcript_text=transcript_text,
|
||
style_summary=style_summary,
|
||
agent_prompt=(assistant or {}).get("system_prompt", ""),
|
||
generation_goal=(assistant or {}).get("generation_goal", ""),
|
||
)
|
||
combined_text = (
|
||
f"{transcript_text}\n\n"
|
||
"------\n"
|
||
f"风格学习结论:\n{style_summary}\n\n"
|
||
"------\n"
|
||
f"二创文案:\n{(content_blueprint.get('rewrite') or {}).get('script', '')}\n\n"
|
||
"------\n"
|
||
f"分镜:\n{json.dumps(content_blueprint.get('storyboards') or [], ensure_ascii=False, indent=2)}"
|
||
)
|
||
kb_row = db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ?", (row["knowledge_base_id"],))
|
||
if not kb_row:
|
||
raise RuntimeError("Knowledge base not found")
|
||
document_id = make_id("doc")
|
||
timestamp = utc_now()
|
||
db.execute(
|
||
"""
|
||
INSERT INTO knowledge_documents (
|
||
id, knowledge_base_id, title, source_type, source_url, transcript_text,
|
||
style_summary, combined_text, analysis_json, storyboard_json, source_artifact_json,
|
||
analysis_model_profile_id, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
document_id,
|
||
row["knowledge_base_id"],
|
||
row["title"],
|
||
row["source_type"],
|
||
row.get("source_url") or "",
|
||
transcript_text,
|
||
style_summary,
|
||
combined_text,
|
||
json.dumps(content_blueprint.get("analysis") or {}, ensure_ascii=False),
|
||
json.dumps(content_blueprint.get("storyboards") or [], ensure_ascii=False),
|
||
json.dumps(artifacts, ensure_ascii=False),
|
||
profile["id"],
|
||
timestamp,
|
||
timestamp,
|
||
),
|
||
)
|
||
update_job_state(
|
||
job_id,
|
||
status="completed",
|
||
artifacts={
|
||
"document_id": document_id,
|
||
"project_job_dir": str(job_dir),
|
||
**artifacts,
|
||
},
|
||
result={
|
||
"analysis": content_blueprint.get("analysis") or {},
|
||
"rewrite": content_blueprint.get("rewrite") or {},
|
||
"storyboards": content_blueprint.get("storyboards") or [],
|
||
"document_id": document_id,
|
||
},
|
||
)
|
||
db.execute(
|
||
"""
|
||
UPDATE jobs
|
||
SET transcript_text = ?, style_summary = ?, upload_status = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""",
|
||
(transcript_text, style_summary, "ready", timestamp, job_id),
|
||
)
|
||
db.execute(
|
||
"UPDATE knowledge_bases SET sync_status = ?, updated_at = ? WHERE id = ?",
|
||
("ready", timestamp, kb_row["id"]),
|
||
)
|
||
except Exception as exc:
|
||
update_job_state(job_id, status="failed", error=str(exc))
|
||
|
||
|
||
@app.on_event("startup")
|
||
def on_startup() -> None:
|
||
db.init_schema()
|
||
seed_defaults()
|
||
|
||
|
||
@app.get("/healthz")
|
||
def healthz() -> dict[str, Any]:
|
||
return {
|
||
"status": "ok",
|
||
"dbPath": DB_PATH,
|
||
"defaultExternalBaseUrl": DEFAULT_EXTERNAL_BASE_URL,
|
||
"localModelBaseUrl": LOCAL_OPENAI_BASE_URL,
|
||
"n8nBaseUrl": N8N_BASE_URL,
|
||
"cutvideoBaseUrl": CUTVIDEO_BASE_URL,
|
||
"huobaoBaseUrl": HUOBAO_BASE_URL,
|
||
}
|
||
|
||
|
||
def seed_defaults() -> None:
|
||
if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"):
|
||
profile_id = make_id("model")
|
||
now = utc_now()
|
||
db.execute(
|
||
"""
|
||
INSERT INTO model_profiles (id, owner_account_id, name, provider, base_url, api_key, model_name, is_system, is_default, created_at, updated_at)
|
||
VALUES (?, NULL, ?, ?, ?, ?, ?, 1, 1, ?, ?)
|
||
""",
|
||
(
|
||
profile_id,
|
||
"本机默认模型",
|
||
"openai_compat",
|
||
LOCAL_OPENAI_BASE_URL,
|
||
LOCAL_OPENAI_API_KEY,
|
||
LOCAL_OPENAI_MODEL,
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
if not db.fetch_one("SELECT id FROM accounts WHERE username = ?", ("kris",)):
|
||
account_id = make_id("acct")
|
||
password_hash, password_salt = create_password_hash("Asd123456.")
|
||
now = utc_now()
|
||
model_row = db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1")
|
||
db.execute(
|
||
"""
|
||
INSERT INTO accounts (
|
||
id, username, password_hash, password_salt, display_name, role,
|
||
approval_status, approved_by, approved_at, preferred_analysis_model_id,
|
||
created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
account_id,
|
||
"kris",
|
||
password_hash,
|
||
password_salt,
|
||
"Kris",
|
||
"super_admin",
|
||
"approved",
|
||
account_id,
|
||
now,
|
||
model_row["id"] if model_row else "",
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
project = ensure_default_project(account_id, username="kris")
|
||
kb = ensure_user_kb(account_id, project["id"], username="kris")
|
||
assistant_id = make_id("assistant")
|
||
db.execute(
|
||
"""
|
||
INSERT INTO assistants (id, user_id, project_id, name, description, system_prompt, generation_goal, config_json, model_profile_id, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, '{}', ?, ?, ?)
|
||
""",
|
||
(
|
||
assistant_id,
|
||
account_id,
|
||
project["id"],
|
||
"默认文案助手",
|
||
"系统为超级管理员预置",
|
||
"你是一个擅长学习短视频文案风格的 AI 助手。",
|
||
"为用户生成稳定风格的短视频文案。",
|
||
model_row["id"] if model_row else "",
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
db.execute(
|
||
"INSERT INTO assistant_knowledge_bases (assistant_id, knowledge_base_id) VALUES (?, ?)",
|
||
(assistant_id, kb["id"]),
|
||
)
|
||
|
||
|
||
@app.post("/v2/auth/register")
|
||
def register(request: RegisterAccountRequest) -> dict[str, Any]:
|
||
username = request.username.strip()
|
||
password = request.password.strip()
|
||
display_name = request.display_name.strip() or username
|
||
if not username or not password:
|
||
raise HTTPException(status_code=400, detail="username and password are required")
|
||
if db.fetch_one("SELECT id FROM accounts WHERE username = ?", (username,)):
|
||
raise HTTPException(status_code=409, detail="username already exists")
|
||
account_id = make_id("acct")
|
||
password_hash, password_salt = create_password_hash(password)
|
||
now = utc_now()
|
||
default_model = db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1")
|
||
db.execute(
|
||
"""
|
||
INSERT INTO accounts (
|
||
id, username, password_hash, password_salt, display_name, role,
|
||
approval_status, approved_by, approved_at, preferred_analysis_model_id,
|
||
created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, 'user', 'pending', NULL, NULL, ?, ?, ?)
|
||
""",
|
||
(
|
||
account_id,
|
||
username,
|
||
password_hash,
|
||
password_salt,
|
||
display_name,
|
||
default_model["id"] if default_model else "",
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
ensure_default_project(account_id, username=username)
|
||
account = db.fetch_one("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||
return normalize_account(account)
|
||
|
||
|
||
@app.post("/v2/auth/login")
|
||
def login(request: LoginRequest) -> dict[str, Any]:
|
||
account = db.fetch_one("SELECT * FROM accounts WHERE username = ?", (request.username.strip(),))
|
||
if not account or not verify_password(request.password, account["password_hash"], account["password_salt"]):
|
||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||
token = secrets.token_urlsafe(32)
|
||
db.execute(
|
||
"INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)",
|
||
(token, account["id"], utc_now()),
|
||
)
|
||
return {
|
||
"token": token,
|
||
"account": normalize_account(account),
|
||
"default_external_base_url": DEFAULT_EXTERNAL_BASE_URL,
|
||
}
|
||
|
||
|
||
@app.post("/v2/auth/logout")
|
||
def logout(account: dict[str, Any] = Depends(require_auth), authorization: str | None = Header(default=None)) -> dict[str, bool]:
|
||
token = authorization.split(" ", 1)[1].strip()
|
||
db.execute("DELETE FROM auth_tokens WHERE token = ?", (token,))
|
||
return {"saved": True}
|
||
|
||
|
||
@app.get("/v2/me")
|
||
def me(account: dict[str, Any] = Depends(require_auth)) -> dict[str, Any]:
|
||
return normalize_account(account)
|
||
|
||
|
||
@app.get("/v2/me/dashboard")
|
||
def dashboard(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
projects = [project_payload(row) for row in db.fetch_all("SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC", (account["id"],))]
|
||
knowledge_bases = [knowledge_base_payload(row) for row in db.fetch_all("SELECT * FROM knowledge_bases WHERE user_id = ? ORDER BY created_at DESC", (account["id"],))]
|
||
assistants = [assistant_payload(row) for row in db.fetch_all("SELECT * FROM assistants WHERE user_id = ? ORDER BY created_at DESC", (account["id"],))]
|
||
jobs = [job_payload(row) for row in db.fetch_all("SELECT * FROM jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 20", (account["id"],))]
|
||
model_profiles = [normalize_model_profile(row) for row in db.fetch_all("SELECT * FROM model_profiles WHERE owner_account_id IS NULL OR owner_account_id = ? ORDER BY is_default DESC, created_at ASC", (account["id"],))]
|
||
return {
|
||
"account": normalize_account(account),
|
||
"projects": projects,
|
||
"knowledge_bases": knowledge_bases,
|
||
"assistants": assistants,
|
||
"recent_jobs": jobs,
|
||
"model_profiles": model_profiles,
|
||
}
|
||
|
||
|
||
@app.get("/v2/projects")
|
||
def list_projects(account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
return [project_payload(row) for row in db.fetch_all("SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC", (account["id"],))]
|
||
|
||
|
||
@app.post("/v2/projects")
|
||
def create_project(request: ProjectCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
project_id = make_id("project")
|
||
now = utc_now()
|
||
db.execute(
|
||
"""
|
||
INSERT INTO projects (id, user_id, name, description, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
project_id,
|
||
account["id"],
|
||
request.name.strip(),
|
||
request.description.strip(),
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
ensure_user_kb(account["id"], project_id, username=account["username"])
|
||
return project_payload(db.fetch_one("SELECT * FROM projects WHERE id = ?", (project_id,)))
|
||
|
||
|
||
@app.get("/v2/content-sources")
|
||
def list_content_sources(
|
||
project_id: str | None = Query(default=None),
|
||
account: dict[str, Any] = Depends(require_approved),
|
||
) -> list[dict[str, Any]]:
|
||
if project_id:
|
||
resolve_target_project(account["id"], project_id, username=account["username"])
|
||
rows = db.fetch_all(
|
||
"SELECT * FROM content_sources WHERE user_id = ? AND project_id = ? ORDER BY created_at DESC",
|
||
(account["id"], project_id),
|
||
)
|
||
else:
|
||
rows = db.fetch_all("SELECT * FROM content_sources WHERE user_id = ? ORDER BY created_at DESC", (account["id"],))
|
||
return [content_source_payload(row) for row in rows]
|
||
|
||
|
||
@app.post("/v2/content-sources")
|
||
def create_content_source_api(request: ContentSourceCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
project = resolve_target_project(account["id"], request.project_id or None, username=account["username"])
|
||
row = create_content_source(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
source_kind=request.source_kind.strip(),
|
||
platform=request.platform.strip(),
|
||
handle=request.handle.strip(),
|
||
source_url=request.source_url.strip(),
|
||
title=request.title.strip(),
|
||
local_path=request.local_path.strip(),
|
||
metadata=request.metadata,
|
||
)
|
||
return content_source_payload(row)
|
||
|
||
|
||
@app.get("/v2/model-profiles")
|
||
def list_model_profiles(account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
rows = db.fetch_all(
|
||
"SELECT * FROM model_profiles WHERE owner_account_id IS NULL OR owner_account_id = ? ORDER BY is_default DESC, is_system DESC, created_at ASC",
|
||
(account["id"],),
|
||
)
|
||
return [normalize_model_profile(row) for row in rows]
|
||
|
||
|
||
@app.post("/v2/model-profiles")
|
||
def create_model_profile(request: ModelProfileRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
model_id = make_id("model")
|
||
now = utc_now()
|
||
if request.is_default:
|
||
db.execute("UPDATE model_profiles SET is_default = 0 WHERE owner_account_id = ?", (account["id"],))
|
||
db.execute(
|
||
"""
|
||
INSERT INTO model_profiles (id, owner_account_id, name, provider, base_url, api_key, model_name, is_system, is_default, created_at, updated_at)
|
||
VALUES (?, ?, ?, 'openai_compat', ?, ?, ?, 0, ?, ?, ?)
|
||
""",
|
||
(model_id, account["id"], request.name.strip(), request.base_url.strip(), request.api_key.strip(), request.model_name.strip(), 1 if request.is_default else 0, now, now),
|
||
)
|
||
row = db.fetch_one("SELECT * FROM model_profiles WHERE id = ?", (model_id,))
|
||
return normalize_model_profile(row)
|
||
|
||
|
||
@app.post("/v2/me/preferences/analysis-model")
|
||
def set_preferred_analysis_model(request: PreferredModelRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
model = db.fetch_one(
|
||
"SELECT * FROM model_profiles WHERE id = ? AND (owner_account_id IS NULL OR owner_account_id = ?)",
|
||
(request.model_profile_id, account["id"]),
|
||
)
|
||
if not model:
|
||
raise HTTPException(status_code=404, detail="Model profile not found")
|
||
db.execute(
|
||
"UPDATE accounts SET preferred_analysis_model_id = ?, updated_at = ? WHERE id = ?",
|
||
(request.model_profile_id, utc_now(), account["id"]),
|
||
)
|
||
account = db.fetch_one("SELECT * FROM accounts WHERE id = ?", (account["id"],))
|
||
return normalize_account(account)
|
||
|
||
|
||
@app.get("/v2/knowledge-bases")
|
||
def list_knowledge_bases(account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
return [knowledge_base_payload(row) for row in db.fetch_all("SELECT * FROM knowledge_bases WHERE user_id = ? ORDER BY created_at DESC", (account["id"],))]
|
||
|
||
|
||
@app.post("/v2/knowledge-bases")
|
||
def create_knowledge_base(request: KnowledgeBaseCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
project = resolve_target_project(account["id"], request.project_id or None, username=account["username"])
|
||
kb_id = make_id("kb")
|
||
now = utc_now()
|
||
db.execute(
|
||
"""
|
||
INSERT INTO knowledge_bases (id, user_id, project_id, name, description, sync_status, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, 'ready', ?, ?)
|
||
""",
|
||
(kb_id, account["id"], project["id"], request.name.strip(), request.description.strip(), now, now),
|
||
)
|
||
row = db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ?", (kb_id,))
|
||
return knowledge_base_payload(row)
|
||
|
||
|
||
@app.get("/v2/knowledge-bases/{knowledge_base_id}/documents")
|
||
def list_knowledge_documents(knowledge_base_id: str, account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
kb = db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ? AND user_id = ?", (knowledge_base_id, account["id"]))
|
||
if not kb:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
rows = db.fetch_all("SELECT * FROM knowledge_documents WHERE knowledge_base_id = ? ORDER BY created_at DESC", (knowledge_base_id,))
|
||
return [document_payload(row) for row in rows]
|
||
|
||
|
||
@app.get("/v2/explore/jobs")
|
||
def list_jobs(account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
return [job_payload(row) for row in db.fetch_all("SELECT * FROM jobs WHERE user_id = ? ORDER BY created_at DESC", (account["id"],))]
|
||
|
||
|
||
@app.get("/v2/explore/jobs/{job_id}")
|
||
def get_job(job_id: str, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ? AND user_id = ?", (job_id, account["id"]))
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
return job_payload(row)
|
||
|
||
|
||
@app.get("/v2/explore/jobs/{job_id}/events")
|
||
def get_job_events(job_id: str, account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
row = db.fetch_one("SELECT id FROM jobs WHERE id = ? AND user_id = ?", (job_id, account["id"]))
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
return [
|
||
job_event_payload(item)
|
||
for item in db.fetch_all("SELECT * FROM job_events WHERE job_id = ? ORDER BY created_at ASC", (job_id,))
|
||
]
|
||
|
||
|
||
def resolve_target_kb(account_id: str, requested_kb_id: str | None, project_id: str = "", username: str = "默认用户") -> dict[str, Any]:
|
||
if requested_kb_id:
|
||
kb = db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ? AND user_id = ?", (requested_kb_id, account_id))
|
||
if kb:
|
||
if project_id and kb.get("project_id") and kb.get("project_id") != project_id:
|
||
raise HTTPException(status_code=400, detail="Knowledge base does not belong to target project")
|
||
return kb
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
return ensure_user_kb(account_id, project_id, username=username)
|
||
|
||
|
||
@app.post("/v2/explore/text")
|
||
async def create_text_job(request: ExploreTextRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
project = resolve_target_project(account["id"], request.project_id or None, username=account["username"])
|
||
kb = resolve_target_kb(account["id"], request.knowledge_base_id, project["id"], username=account["username"])
|
||
assistant = resolve_target_assistant(account["id"], request.assistant_id, project["id"])
|
||
profile = model_profile_for_account(account["id"], request.analysis_model_profile_id)
|
||
source = create_content_source(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
source_kind="inline_text",
|
||
title=request.title.strip(),
|
||
metadata={"content_preview": request.content[:280]},
|
||
)
|
||
job_row = create_job_record(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
knowledge_base_id=kb["id"],
|
||
source_type="text",
|
||
line_type="analysis",
|
||
workflow_key="analysis_pipeline",
|
||
title=request.title.strip(),
|
||
language="zh-CN",
|
||
assistant_id=(assistant or {}).get("id"),
|
||
content_source_id=source["id"],
|
||
artifacts={"input_text": request.content},
|
||
analysis_model_profile_id=profile["id"],
|
||
)
|
||
return job_payload(await trigger_orchestrated_job(job_row))
|
||
|
||
|
||
@app.post("/v2/explore/video-link")
|
||
async def create_video_link_job(request: ExploreVideoLinkRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
project = resolve_target_project(account["id"], request.project_id or None, username=account["username"])
|
||
kb = resolve_target_kb(account["id"], request.knowledge_base_id, project["id"], username=account["username"])
|
||
assistant = resolve_target_assistant(account["id"], request.assistant_id, project["id"])
|
||
profile = model_profile_for_account(account["id"], request.analysis_model_profile_id)
|
||
source = create_content_source(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
source_kind="video_link",
|
||
source_url=request.video_url.strip(),
|
||
title=(request.title or "短视频素材").strip(),
|
||
metadata={"platform": "video_link"},
|
||
)
|
||
job_row = create_job_record(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
knowledge_base_id=kb["id"],
|
||
source_type="video_link",
|
||
line_type="analysis",
|
||
workflow_key="analysis_pipeline",
|
||
title=(request.title or "短视频素材").strip(),
|
||
language=request.language,
|
||
source_url=request.video_url.strip(),
|
||
assistant_id=(assistant or {}).get("id"),
|
||
content_source_id=source["id"],
|
||
artifacts={},
|
||
analysis_model_profile_id=profile["id"],
|
||
)
|
||
return job_payload(await trigger_orchestrated_job(job_row))
|
||
|
||
|
||
@app.post("/v2/explore/upload-video")
|
||
async def upload_video(
|
||
file: UploadFile = File(...),
|
||
title: str = Form(""),
|
||
project_id: str = Form(""),
|
||
knowledge_base_id: str = Form(""),
|
||
assistant_id: str = Form(""),
|
||
analysis_model_profile_id: str = Form(""),
|
||
account: dict[str, Any] = Depends(require_approved),
|
||
) -> dict[str, Any]:
|
||
project = resolve_target_project(account["id"], project_id or None, username=account["username"])
|
||
kb = resolve_target_kb(account["id"], knowledge_base_id or None, project["id"], username=account["username"])
|
||
assistant = resolve_target_assistant(account["id"], assistant_id or None, project["id"])
|
||
profile = model_profile_for_account(account["id"], analysis_model_profile_id or None)
|
||
job_id = make_id("job_upload")
|
||
job_dir = JOBS_DIR / job_id
|
||
job_dir.mkdir(parents=True, exist_ok=True)
|
||
suffix = Path(file.filename or "upload.mp4").suffix or ".mp4"
|
||
target_path = job_dir / f"source{suffix}"
|
||
with target_path.open("wb") as handle:
|
||
shutil.copyfileobj(file.file, handle)
|
||
source = create_content_source(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
source_kind="upload_video",
|
||
source_url=file.filename or "",
|
||
title=(title or file.filename or "上传视频素材").strip(),
|
||
local_path=str(target_path),
|
||
metadata={"filename": file.filename or "", "size_bytes": target_path.stat().st_size},
|
||
)
|
||
job_row = create_job_record(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
knowledge_base_id=kb["id"],
|
||
source_type="upload_video",
|
||
line_type="analysis",
|
||
workflow_key="analysis_pipeline",
|
||
title=(title or file.filename or "上传视频素材").strip(),
|
||
source_url=file.filename or "",
|
||
assistant_id=(assistant or {}).get("id"),
|
||
content_source_id=source["id"],
|
||
artifacts={"uploaded_path": str(target_path)},
|
||
analysis_model_profile_id=profile["id"],
|
||
)
|
||
return job_payload(await trigger_orchestrated_job(job_row))
|
||
|
||
|
||
@app.post("/v2/pipelines/real-cut")
|
||
async def create_real_cut_job(request: RealCutJobRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
project = resolve_target_project(account["id"], request.project_id or None, username=account["username"])
|
||
kb = ensure_user_kb(account["id"], project["id"], username=account["username"])
|
||
source = create_content_source(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
source_kind="real_cut_input",
|
||
title=request.title.strip(),
|
||
local_path=request.input_dir.strip(),
|
||
metadata={"line_type": "real_cut"},
|
||
)
|
||
job_row = create_job_record(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
knowledge_base_id=kb["id"],
|
||
source_type="real_cut",
|
||
line_type="real_cut",
|
||
workflow_key="real_cut_pipeline",
|
||
title=request.title.strip(),
|
||
source_url=request.input_dir.strip(),
|
||
content_source_id=source["id"],
|
||
artifacts={
|
||
"cutvideo_request": {
|
||
"base_config": request.base_config.strip() or CUTVIDEO_BASE_CONFIG,
|
||
"name": request.title.strip(),
|
||
"input_dir": request.input_dir.strip(),
|
||
"objective": request.objective,
|
||
"target_duration_sec": request.target_duration_sec,
|
||
"target_aspect_ratio": request.target_aspect_ratio,
|
||
"ideal_segment_duration_sec": request.ideal_segment_duration_sec,
|
||
"max_segment_duration_sec": request.max_segment_duration_sec,
|
||
"transcript_backend": request.transcript_backend,
|
||
"transcript_device": request.transcript_device,
|
||
"review_enabled": request.review_enabled,
|
||
"dry_run": request.dry_run,
|
||
}
|
||
},
|
||
)
|
||
return job_payload(await trigger_orchestrated_job(job_row))
|
||
|
||
|
||
@app.post("/v2/pipelines/ai-video")
|
||
async def create_ai_video_job(request: AiVideoJobRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
source_job = None
|
||
source_project_id = ""
|
||
source_kb_id = ""
|
||
if request.source_job_id.strip():
|
||
source_job = db.fetch_one("SELECT * FROM jobs WHERE id = ? AND user_id = ?", (request.source_job_id.strip(), account["id"]))
|
||
if not source_job:
|
||
raise HTTPException(status_code=404, detail="Source job not found")
|
||
if source_job["status"] != "completed":
|
||
raise HTTPException(status_code=409, detail="Source job must be completed before AI video generation")
|
||
source_project_id = source_job.get("project_id", "")
|
||
source_kb_id = source_job.get("knowledge_base_id", "")
|
||
|
||
requested_project_id = request.project_id or source_project_id
|
||
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
|
||
kb = resolve_target_kb(account["id"], request.knowledge_base_id or source_kb_id or None, project["id"], username=account["username"])
|
||
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
|
||
source = create_content_source(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
source_kind="ai_video_brief",
|
||
title=request.title.strip(),
|
||
metadata={"source_job_id": request.source_job_id.strip()},
|
||
)
|
||
job_row = create_job_record(
|
||
account_id=account["id"],
|
||
project_id=project["id"],
|
||
knowledge_base_id=kb["id"],
|
||
source_type="ai_video",
|
||
line_type="ai_video",
|
||
workflow_key="ai_video_pipeline",
|
||
title=request.title.strip(),
|
||
assistant_id=(assistant or {}).get("id"),
|
||
content_source_id=source["id"],
|
||
artifacts={
|
||
"brief": request.brief,
|
||
"style": request.style,
|
||
"shots": request.shots,
|
||
"image_provider": request.image_provider,
|
||
"image_model": request.image_model,
|
||
"video_provider": request.video_provider,
|
||
"video_model": request.video_model,
|
||
"aspect_ratio": request.aspect_ratio,
|
||
"duration": request.duration,
|
||
"source_job_id": request.source_job_id.strip(),
|
||
},
|
||
)
|
||
return job_payload(await trigger_orchestrated_job(job_row))
|
||
|
||
|
||
@app.get("/v2/assistants")
|
||
def list_assistants(account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
return [assistant_payload(row) for row in db.fetch_all("SELECT * FROM assistants WHERE user_id = ? ORDER BY created_at DESC", (account["id"],))]
|
||
|
||
|
||
@app.post("/v2/assistants")
|
||
def create_assistant(request: AssistantCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
assistant_id = make_id("assistant")
|
||
now = utc_now()
|
||
project = resolve_target_project(account["id"], request.project_id or None, username=account["username"])
|
||
model_profile = model_profile_for_account(account["id"], request.model_profile_id or None)
|
||
db.execute(
|
||
"""
|
||
INSERT INTO assistants (id, user_id, project_id, name, description, system_prompt, generation_goal, config_json, model_profile_id, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, '{}', ?, ?, ?)
|
||
""",
|
||
(
|
||
assistant_id,
|
||
account["id"],
|
||
project["id"],
|
||
request.name.strip(),
|
||
request.description.strip(),
|
||
request.system_prompt.strip(),
|
||
request.generation_goal.strip(),
|
||
model_profile["id"],
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
for kb_id in request.knowledge_base_ids:
|
||
kb = db.fetch_one("SELECT id FROM knowledge_bases WHERE id = ? AND user_id = ?", (kb_id, account["id"]))
|
||
if kb:
|
||
db.execute("INSERT OR IGNORE INTO assistant_knowledge_bases (assistant_id, knowledge_base_id) VALUES (?, ?)", (assistant_id, kb_id))
|
||
return assistant_payload(db.fetch_one("SELECT * FROM assistants WHERE id = ?", (assistant_id,)))
|
||
|
||
|
||
@app.patch("/v2/assistants/{assistant_id}")
|
||
def update_assistant(assistant_id: str, request: AssistantUpdateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
current = db.fetch_one("SELECT * FROM assistants WHERE id = ? AND user_id = ?", (assistant_id, account["id"]))
|
||
if not current:
|
||
raise HTTPException(status_code=404, detail="Assistant not found")
|
||
project_id = current.get("project_id", "")
|
||
if request.project_id is not None:
|
||
project_id = resolve_target_project(account["id"], request.project_id, username=account["username"])["id"]
|
||
payload = {
|
||
"name": request.name if request.name is not None else current["name"],
|
||
"description": request.description if request.description is not None else current.get("description", ""),
|
||
"system_prompt": request.system_prompt if request.system_prompt is not None else current.get("system_prompt", ""),
|
||
"generation_goal": request.generation_goal if request.generation_goal is not None else current.get("generation_goal", ""),
|
||
"project_id": project_id,
|
||
"model_profile_id": current.get("model_profile_id", ""),
|
||
}
|
||
if request.model_profile_id is not None:
|
||
payload["model_profile_id"] = model_profile_for_account(account["id"], request.model_profile_id)["id"]
|
||
db.execute(
|
||
"""
|
||
UPDATE assistants
|
||
SET project_id = ?, name = ?, description = ?, system_prompt = ?, generation_goal = ?, model_profile_id = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""",
|
||
(
|
||
payload["project_id"],
|
||
payload["name"],
|
||
payload["description"],
|
||
payload["system_prompt"],
|
||
payload["generation_goal"],
|
||
payload["model_profile_id"],
|
||
utc_now(),
|
||
assistant_id,
|
||
),
|
||
)
|
||
if request.knowledge_base_ids is not None:
|
||
db.execute("DELETE FROM assistant_knowledge_bases WHERE assistant_id = ?", (assistant_id,))
|
||
for kb_id in request.knowledge_base_ids:
|
||
kb = db.fetch_one("SELECT id FROM knowledge_bases WHERE id = ? AND user_id = ?", (kb_id, account["id"]))
|
||
if kb:
|
||
db.execute("INSERT OR IGNORE INTO assistant_knowledge_bases (assistant_id, knowledge_base_id) VALUES (?, ?)", (assistant_id, kb_id))
|
||
return assistant_payload(db.fetch_one("SELECT * FROM assistants WHERE id = ?", (assistant_id,)))
|
||
|
||
|
||
@app.get("/v2/agents")
|
||
def list_agents(account: dict[str, Any] = Depends(require_approved)) -> list[dict[str, Any]]:
|
||
return list_assistants(account)
|
||
|
||
|
||
@app.post("/v2/agents")
|
||
def create_agent(request: AssistantCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
return create_assistant(request, account)
|
||
|
||
|
||
@app.patch("/v2/agents/{assistant_id}")
|
||
def update_agent(assistant_id: str, request: AssistantUpdateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
return update_assistant(assistant_id, request, account)
|
||
|
||
|
||
@app.post("/v2/assistants/{assistant_id}/generate")
|
||
async def generate_copy(assistant_id: str, request: GenerateCopyRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||
assistant = db.fetch_one("SELECT * FROM assistants WHERE id = ? AND user_id = ?", (assistant_id, account["id"]))
|
||
if not assistant:
|
||
raise HTTPException(status_code=404, detail="Assistant not found")
|
||
kb_ids = request.knowledge_base_ids or [row["knowledge_base_id"] for row in db.fetch_all("SELECT knowledge_base_id FROM assistant_knowledge_bases WHERE assistant_id = ?", (assistant_id,))]
|
||
used_documents: list[dict[str, Any]] = []
|
||
excerpts: list[str] = []
|
||
for kb_id in kb_ids:
|
||
docs = db.fetch_all("SELECT * FROM knowledge_documents WHERE knowledge_base_id = ? ORDER BY created_at DESC LIMIT 3", (kb_id,))
|
||
for doc in docs:
|
||
payload = document_payload(doc)
|
||
used_documents.append(payload)
|
||
excerpt = payload["combined_text"] or payload["style_summary"] or payload["transcript_text"]
|
||
excerpts.append(f"[{payload['title']}]\n{excerpt[:1200]}")
|
||
prompt_excerpt = "\n\n".join(excerpts)[:6000]
|
||
system_prompt = assistant.get("system_prompt") or "你是文案助手。"
|
||
generation_goal = assistant.get("generation_goal") or "生成短视频文案。"
|
||
user_prompt = (
|
||
f"任务目标:{generation_goal}\n"
|
||
f"创作需求:{request.brief}\n"
|
||
f"平台:{request.platform}\n"
|
||
f"受众:{request.audience}\n"
|
||
f"额外要求:{request.extra_requirements or '无'}\n\n"
|
||
f"参考知识库素材:\n{prompt_excerpt or '暂无参考素材,请按通用短视频结构输出。'}\n\n"
|
||
"请输出完整文案,包含标题、开场钩子、正文结构和结尾行动指令。"
|
||
)
|
||
profile = model_profile_for_account(account["id"], assistant.get("model_profile_id") or None)
|
||
content = await call_model(profile, system_prompt, user_prompt, temperature=0.7)
|
||
return {
|
||
"assistant_id": assistant_id,
|
||
"knowledge_base_ids": kb_ids,
|
||
"content": content,
|
||
"prompt_excerpt": prompt_excerpt[:2000],
|
||
"used_documents": used_documents,
|
||
}
|
||
|
||
|
||
def load_owned_job(job_id: str, account_id: str) -> dict[str, Any]:
|
||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ? AND user_id = ?", (job_id, account_id))
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
return row
|
||
|
||
|
||
def load_internal_job(job_id: str) -> dict[str, Any]:
|
||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
return row
|
||
|
||
|
||
def parse_job_artifacts(row: dict[str, Any]) -> dict[str, Any]:
|
||
raw = row.get("artifacts_json") or "{}"
|
||
try:
|
||
return json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
|
||
|
||
def parse_job_result(row: dict[str, Any]) -> dict[str, Any]:
|
||
raw = row.get("result_json") or "{}"
|
||
try:
|
||
data = json.loads(raw)
|
||
return data if isinstance(data, dict) else {}
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
|
||
|
||
def extract_source_storyboards(source_job: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||
if not source_job:
|
||
return []
|
||
return coerce_storyboards(parse_job_result(source_job).get("storyboards"))
|
||
|
||
|
||
def resolve_internal_job_id(request: InternalStepRequest | None, query_job_id: str = "") -> str:
|
||
resolved = (query_job_id or "").strip()
|
||
if not resolved and request is not None:
|
||
resolved = (
|
||
request.job_id
|
||
or request.jobId
|
||
or str(request.payload.get("job_id") or request.payload.get("jobId") or "")
|
||
).strip()
|
||
return resolved
|
||
|
||
|
||
def load_step_job(request: InternalStepRequest | None, query_job_id: str, workflow_key: str) -> dict[str, Any]:
|
||
resolved_job_id = resolve_internal_job_id(request, query_job_id)
|
||
if resolved_job_id:
|
||
return load_internal_job(resolved_job_id)
|
||
row = db.fetch_one(
|
||
"""
|
||
SELECT * FROM jobs
|
||
WHERE workflow_key = ? AND status IN ('pending', 'queued')
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
""",
|
||
(workflow_key,),
|
||
)
|
||
if not row:
|
||
raise HTTPException(status_code=400, detail="job_id is required")
|
||
return row
|
||
|
||
|
||
@app.get("/internal/jobs/{job_id}/context")
|
||
def internal_job_context(job_id: str, _: bool = Depends(require_orchestrator)) -> dict[str, Any]:
|
||
return job_context_payload(load_internal_job(job_id))
|
||
|
||
|
||
@app.post("/internal/jobs/steps/analyze")
|
||
async def internal_run_analysis(
|
||
request: InternalStepRequest | None = Body(default=None),
|
||
job_id: str = Query(default=""),
|
||
_: bool = Depends(require_orchestrator),
|
||
) -> dict[str, Any]:
|
||
row = load_step_job(request, job_id, "analysis_pipeline")
|
||
await process_job(row["id"])
|
||
return job_context_payload(load_internal_job(row["id"]))
|
||
|
||
|
||
@app.post("/internal/jobs/steps/real-cut/submit")
|
||
async def internal_real_cut_submit(
|
||
request: InternalStepRequest | None = Body(default=None),
|
||
job_id: str = Query(default=""),
|
||
_: bool = Depends(require_orchestrator),
|
||
) -> dict[str, Any]:
|
||
if not cutvideo_client.enabled:
|
||
raise HTTPException(status_code=503, detail="CutVideo is not configured")
|
||
row = load_step_job(request, job_id, "real_cut_pipeline")
|
||
artifacts = parse_job_artifacts(row)
|
||
cutvideo_request = artifacts.get("cutvideo_request") or {}
|
||
if not isinstance(cutvideo_request, dict):
|
||
raise HTTPException(status_code=400, detail="Invalid cutvideo request payload")
|
||
append_job_event(row["id"], "cutvideo.submit.requested", cutvideo_request)
|
||
submit_result = await cutvideo_client.submit_job(cutvideo_request)
|
||
task_id = str(submit_result.get("task_id") or "")
|
||
updated = update_job_state(
|
||
row["id"],
|
||
status="processing",
|
||
provider_name="cutvideo",
|
||
provider_task_id=task_id,
|
||
result={"cutvideo_submit": submit_result},
|
||
)
|
||
return job_context_payload(updated)
|
||
|
||
|
||
@app.post("/internal/jobs/steps/real-cut/poll")
|
||
async def internal_real_cut_poll(
|
||
request: InternalStepRequest | None = Body(default=None),
|
||
job_id: str = Query(default=""),
|
||
_: bool = Depends(require_orchestrator),
|
||
) -> dict[str, Any]:
|
||
row = load_step_job(request, job_id, "real_cut_pipeline")
|
||
if not row.get("provider_task_id"):
|
||
raise HTTPException(status_code=409, detail="CutVideo task has not been submitted")
|
||
task_payload = await cutvideo_client.get_task(row["provider_task_id"])
|
||
status = str(task_payload.get("status") or "").lower()
|
||
run_payload: dict[str, Any] = {}
|
||
artifacts: dict[str, Any] = {"cutvideo_task": task_payload}
|
||
next_status = row["status"]
|
||
error = row.get("error", "")
|
||
if status == "completed":
|
||
next_status = "completed"
|
||
run_id = str(task_payload.get("run_id") or "")
|
||
if run_id:
|
||
run_payload = await cutvideo_client.get_run(run_id)
|
||
artifacts["cutvideo_run"] = run_payload
|
||
elif status == "failed":
|
||
next_status = "failed"
|
||
error = str(task_payload.get("error") or "CutVideo task failed")
|
||
else:
|
||
next_status = "processing"
|
||
|
||
updated = update_job_state(
|
||
row["id"],
|
||
status=next_status,
|
||
error=error,
|
||
provider_name="cutvideo",
|
||
provider_task_id=row["provider_task_id"],
|
||
artifacts=artifacts,
|
||
result={"cutvideo_run": run_payload} if run_payload else {"cutvideo_task": task_payload},
|
||
)
|
||
return job_context_payload(updated)
|
||
|
||
|
||
@app.post("/internal/jobs/steps/real-cut/run")
|
||
async def internal_real_cut_run(
|
||
request: InternalStepRequest | None = Body(default=None),
|
||
job_id: str = Query(default=""),
|
||
_: bool = Depends(require_orchestrator),
|
||
) -> dict[str, Any]:
|
||
if not cutvideo_client.enabled:
|
||
raise HTTPException(status_code=503, detail="CutVideo is not configured")
|
||
|
||
row = load_step_job(request, job_id, "real_cut_pipeline")
|
||
if not row.get("provider_task_id"):
|
||
artifacts = parse_job_artifacts(row)
|
||
cutvideo_request = artifacts.get("cutvideo_request") or {}
|
||
if not isinstance(cutvideo_request, dict):
|
||
raise HTTPException(status_code=400, detail="Invalid cutvideo request payload")
|
||
submit_result = await cutvideo_client.submit_job(cutvideo_request)
|
||
row = update_job_state(
|
||
row["id"],
|
||
status="processing",
|
||
provider_name="cutvideo",
|
||
provider_task_id=str(submit_result.get("task_id") or ""),
|
||
result={"cutvideo_submit": submit_result},
|
||
)
|
||
|
||
deadline = now_ts() + HUOBAO_MAX_WAIT_SEC
|
||
while True:
|
||
task_payload = await cutvideo_client.get_task(row["provider_task_id"])
|
||
status = str(task_payload.get("status") or "").lower()
|
||
if status == "completed":
|
||
run_payload: dict[str, Any] = {}
|
||
run_id = str(task_payload.get("run_id") or "")
|
||
if run_id:
|
||
run_payload = await cutvideo_client.get_run(run_id)
|
||
updated = update_job_state(
|
||
row["id"],
|
||
status="completed",
|
||
provider_name="cutvideo",
|
||
provider_task_id=row["provider_task_id"],
|
||
artifacts={"cutvideo_task": task_payload, "cutvideo_run": run_payload},
|
||
result={"cutvideo_task": task_payload, "cutvideo_run": run_payload},
|
||
)
|
||
return job_context_payload(updated)
|
||
if status == "failed":
|
||
updated = update_job_state(
|
||
row["id"],
|
||
status="failed",
|
||
error=str(task_payload.get("error") or "CutVideo task failed"),
|
||
provider_name="cutvideo",
|
||
provider_task_id=row["provider_task_id"],
|
||
artifacts={"cutvideo_task": task_payload},
|
||
result={"cutvideo_task": task_payload},
|
||
)
|
||
return job_context_payload(updated)
|
||
if now_ts() >= deadline:
|
||
updated = update_job_state(
|
||
row["id"],
|
||
status="failed",
|
||
error="CutVideo task timed out",
|
||
provider_name="cutvideo",
|
||
provider_task_id=row["provider_task_id"],
|
||
artifacts={"cutvideo_task": task_payload},
|
||
result={"cutvideo_task": task_payload},
|
||
)
|
||
return job_context_payload(updated)
|
||
await asyncio.sleep(CUTVIDEO_POLL_INTERVAL_SEC)
|
||
row = load_internal_job(row["id"])
|
||
|
||
|
||
@app.post("/internal/jobs/steps/ai-video/render")
|
||
async def internal_ai_video_render(
|
||
request: InternalStepRequest | None = Body(default=None),
|
||
job_id: str = Query(default=""),
|
||
_: bool = Depends(require_orchestrator),
|
||
) -> dict[str, Any]:
|
||
if not huobao_client.enabled:
|
||
raise HTTPException(status_code=503, detail="Huobao is not configured")
|
||
|
||
row = load_step_job(request, job_id, "ai_video_pipeline")
|
||
artifacts = parse_job_artifacts(row)
|
||
assistant = db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],)) if row.get("assistant_id") else None
|
||
source_job = None
|
||
source_storyboards: list[dict[str, Any]] = []
|
||
source_job_id = str(artifacts.get("source_job_id") or "").strip()
|
||
if source_job_id:
|
||
source_job = db.fetch_one("SELECT * FROM jobs WHERE id = ? AND user_id = ?", (source_job_id, row["user_id"]))
|
||
if source_job:
|
||
source_storyboards = extract_source_storyboards(source_job)
|
||
|
||
if source_storyboards:
|
||
storyboard_items = source_storyboards[: max(int(artifacts.get("shots") or 4), 1)]
|
||
else:
|
||
profile = model_profile_for_account(row["user_id"], row.get("analysis_model_profile_id") or None)
|
||
blueprint = await generate_content_blueprint(
|
||
profile,
|
||
title=row["title"],
|
||
transcript_text=str(artifacts.get("brief") or row["title"]),
|
||
style_summary=str(artifacts.get("style") or ""),
|
||
agent_prompt=(assistant or {}).get("system_prompt", ""),
|
||
generation_goal=(assistant or {}).get("generation_goal", "") or "生成适合视频模型的分镜与提示词",
|
||
)
|
||
storyboard_items = coerce_storyboards(blueprint.get("storyboards"))[: max(int(artifacts.get("shots") or 4), 1)]
|
||
|
||
if not storyboard_items:
|
||
raise HTTPException(status_code=400, detail="No storyboards available for AI video rendering")
|
||
|
||
drama_payload = await huobao_client.create_drama(
|
||
{
|
||
"title": row["title"],
|
||
"description": str(artifacts.get("brief") or row["title"]),
|
||
"style": str(artifacts.get("style") or "realistic"),
|
||
"genre": "short_video",
|
||
"tags": "storyforge",
|
||
}
|
||
)
|
||
drama_id = str(drama_payload.get("id") or "")
|
||
if not drama_id:
|
||
raise RuntimeError("Huobao did not return drama id")
|
||
|
||
update_job_state(
|
||
row["id"],
|
||
status="processing",
|
||
provider_name="huobao-drama",
|
||
provider_task_id=drama_id,
|
||
result={"huobao_drama": drama_payload},
|
||
)
|
||
|
||
rendered_scenes: list[dict[str, Any]] = []
|
||
image_provider = str(artifacts.get("image_provider") or "openai")
|
||
image_model = str(artifacts.get("image_model") or "")
|
||
video_provider = str(artifacts.get("video_provider") or "doubao")
|
||
video_model = str(artifacts.get("video_model") or "")
|
||
aspect_ratio = str(artifacts.get("aspect_ratio") or "9:16")
|
||
image_size = huobao_image_size_for_aspect_ratio(aspect_ratio)
|
||
duration = int(artifacts.get("duration") or 5)
|
||
style = str(artifacts.get("style") or "realistic")
|
||
|
||
for idx, storyboard in enumerate(storyboard_items, start=1):
|
||
first_prompt = str(storyboard.get("first_frame_prompt") or storyboard.get("visual") or storyboard.get("title") or row["title"])
|
||
last_prompt = str(storyboard.get("last_frame_prompt") or storyboard.get("visual") or storyboard.get("title") or row["title"])
|
||
video_prompt = str(storyboard.get("video_prompt") or storyboard.get("narration") or storyboard.get("title") or row["title"])
|
||
|
||
first_image = await huobao_client.generate_image(
|
||
{
|
||
"drama_id": drama_id,
|
||
"image_type": "storyboard",
|
||
"frame_type": "first",
|
||
"prompt": first_prompt,
|
||
"provider": image_provider,
|
||
"model": image_model,
|
||
"size": image_size,
|
||
"style": style,
|
||
}
|
||
)
|
||
last_image = await huobao_client.generate_image(
|
||
{
|
||
"drama_id": drama_id,
|
||
"image_type": "storyboard",
|
||
"frame_type": "last",
|
||
"prompt": last_prompt,
|
||
"provider": image_provider,
|
||
"model": image_model,
|
||
"size": image_size,
|
||
"style": style,
|
||
}
|
||
)
|
||
|
||
first_ready = await wait_for_huobao_image(str(first_image.get("id") or ""))
|
||
last_ready = await wait_for_huobao_image(str(last_image.get("id") or ""))
|
||
if str(first_ready.get("status") or "").lower() != "completed":
|
||
raise RuntimeError(f"First frame generation failed for scene {idx}")
|
||
if str(last_ready.get("status") or "").lower() != "completed":
|
||
raise RuntimeError(f"Last frame generation failed for scene {idx}")
|
||
|
||
first_frame_url = first_ready.get("image_url") or first_ready.get("local_path")
|
||
last_frame_url = last_ready.get("image_url") or last_ready.get("local_path")
|
||
if not first_frame_url or not last_frame_url:
|
||
raise RuntimeError(f"Huobao image output missing for scene {idx}")
|
||
|
||
video_payload = await huobao_client.generate_video(
|
||
{
|
||
"drama_id": drama_id,
|
||
"prompt": video_prompt,
|
||
"provider": video_provider,
|
||
"model": video_model,
|
||
"reference_mode": "first_last",
|
||
"first_frame_url": first_frame_url,
|
||
"last_frame_url": last_frame_url,
|
||
"aspect_ratio": aspect_ratio,
|
||
"duration": duration,
|
||
"style": style,
|
||
}
|
||
)
|
||
video_ready = await wait_for_huobao_video(str(video_payload.get("id") or ""))
|
||
if str(video_ready.get("status") or "").lower() != "completed":
|
||
raise RuntimeError(f"Video generation failed for scene {idx}")
|
||
|
||
rendered_scenes.append(
|
||
{
|
||
"shot_index": storyboard.get("shot_index", idx),
|
||
"title": storyboard.get("title", f"镜头{idx}"),
|
||
"narration": storyboard.get("narration", ""),
|
||
"first_frame": first_ready,
|
||
"last_frame": last_ready,
|
||
"video": video_ready,
|
||
}
|
||
)
|
||
|
||
updated = update_job_state(
|
||
row["id"],
|
||
status="completed",
|
||
provider_name="huobao-drama",
|
||
provider_task_id=drama_id,
|
||
artifacts={
|
||
"huobao_drama_id": drama_id,
|
||
"source_job_id": source_job_id,
|
||
},
|
||
result={
|
||
"huobao_drama": drama_payload,
|
||
"rendered_scenes": rendered_scenes,
|
||
"storyboards": storyboard_items,
|
||
},
|
||
)
|
||
return job_context_payload(updated)
|
||
|
||
|
||
@app.post("/internal/jobs/{job_id}/status")
|
||
def internal_update_job_status(job_id: str, request: JobStatusUpdateRequest, _: bool = Depends(require_orchestrator)) -> dict[str, Any]:
|
||
updated = update_job_state(
|
||
job_id,
|
||
status=request.status,
|
||
error=request.error,
|
||
provider_name=request.provider_name or None,
|
||
provider_task_id=request.provider_task_id or None,
|
||
artifacts=request.artifacts,
|
||
result=request.result,
|
||
)
|
||
return job_context_payload(updated)
|
||
|
||
|
||
@app.get("/v2/admin/accounts/pending")
|
||
def pending_accounts(admin: dict[str, Any] = Depends(require_super_admin)) -> list[dict[str, Any]]:
|
||
rows = db.fetch_all("SELECT * FROM accounts WHERE approval_status = 'pending' ORDER BY created_at ASC")
|
||
return [normalize_account(row) for row in rows]
|
||
|
||
|
||
@app.post("/v2/admin/accounts/{account_id}/approve")
|
||
def approve_account(account_id: str, admin: dict[str, Any] = Depends(require_super_admin)) -> dict[str, Any]:
|
||
account = db.fetch_one("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||
if not account:
|
||
raise HTTPException(status_code=404, detail="Account not found")
|
||
db.execute(
|
||
"UPDATE accounts SET approval_status = 'approved', approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?",
|
||
(admin["id"], utc_now(), utc_now(), account_id),
|
||
)
|
||
approved = db.fetch_one("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||
project = ensure_default_project(account_id, username=approved["username"])
|
||
ensure_user_kb(account_id, project["id"], username=approved["username"])
|
||
return {"saved": True, "account": normalize_account(approved)}
|
||
|
||
|
||
@app.post("/v2/admin/accounts/{account_id}/reject")
|
||
def reject_account(account_id: str, admin: dict[str, Any] = Depends(require_super_admin)) -> dict[str, Any]:
|
||
account = db.fetch_one("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||
if not account:
|
||
raise HTTPException(status_code=404, detail="Account not found")
|
||
db.execute(
|
||
"UPDATE accounts SET approval_status = 'rejected', approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?",
|
||
(admin["id"], utc_now(), utc_now(), account_id),
|
||
)
|
||
rejected = db.fetch_one("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||
return {"saved": True, "account": normalize_account(rejected)}
|
||
|
||
|
||
@app.get("/api/v1/app/update/latest")
|
||
def latest_update(
|
||
platform: str = Query(default="android"),
|
||
channel: str = Query(default="stable"),
|
||
currentVersionCode: int | None = Query(default=None),
|
||
) -> dict[str, Any]:
|
||
row = db.fetch_one(
|
||
"SELECT * FROM app_updates WHERE platform = ? AND channel = ? AND is_active = 1 ORDER BY version_code DESC, published_at DESC LIMIT 1",
|
||
(platform, channel),
|
||
)
|
||
if not row:
|
||
return {
|
||
"platform": platform,
|
||
"channel": channel,
|
||
"hasUpdate": False,
|
||
"latestVersionCode": currentVersionCode or 0,
|
||
"latestVersionName": "",
|
||
"minSupportedCode": 0,
|
||
"downloadUrl": "",
|
||
"apkSha256": "",
|
||
"releaseNotes": "",
|
||
"forceUpdate": False,
|
||
"publishedAt": 0,
|
||
}
|
||
latest_version_code = int(row["version_code"])
|
||
return {
|
||
"platform": row["platform"],
|
||
"channel": row["channel"],
|
||
"hasUpdate": currentVersionCode is None or latest_version_code > currentVersionCode,
|
||
"latestVersionCode": latest_version_code,
|
||
"latestVersionName": row["version_name"],
|
||
"minSupportedCode": int(row["min_supported_code"]),
|
||
"downloadUrl": row["apk_url"],
|
||
"apkSha256": row.get("apk_sha256", ""),
|
||
"releaseNotes": row.get("notes", ""),
|
||
"forceUpdate": bool(row.get("force_update", 0)),
|
||
"publishedAt": int(row.get("published_at", 0)),
|
||
}
|
||
|
||
|
||
@app.post("/v2/admin/app/update/publish")
|
||
def publish_app_update(request: PublishAppUpdateRequest, admin: dict[str, Any] = Depends(require_super_admin)) -> dict[str, Any]:
|
||
db.execute(
|
||
"UPDATE app_updates SET is_active = 0 WHERE platform = ? AND channel = ?",
|
||
(request.platform, request.channel),
|
||
)
|
||
db.execute(
|
||
"""
|
||
INSERT INTO app_updates (
|
||
platform, channel, version_code, version_name, min_supported_code,
|
||
apk_url, apk_sha256, notes, force_update, is_active, published_at, created_by
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
request.platform,
|
||
request.channel,
|
||
request.versionCode,
|
||
request.versionName,
|
||
request.minSupportedCode,
|
||
request.apkUrl,
|
||
request.apkSha256,
|
||
request.notes,
|
||
1 if request.forceUpdate else 0,
|
||
1 if request.isActive else 0,
|
||
now_ts(),
|
||
admin["id"],
|
||
),
|
||
)
|
||
row = db.fetch_one(
|
||
"""
|
||
SELECT id
|
||
FROM app_updates
|
||
WHERE platform = ? AND channel = ? AND version_code = ?
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
""",
|
||
(request.platform, request.channel, request.versionCode),
|
||
)
|
||
return {"saved": True, "action": "published", "updateId": row["id"] if row else 0}
|