feat: 创作工作台统一 + AI味检测/白标 + 移动端补全 + 合规发布闭环
- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式) - 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级) - 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包) - 发布闭环:publishing 放宽至 editor + records/mark-published 接口 - 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底) - 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立) - 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
长文 → 短视频脚本改写器
|
||||
读取某选题的长文(知乎/公众号 markdown),生成 N 条差异化短视频口播/分镜脚本。
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
||||
|
||||
from db_helper import get_topic_by_id, get_articles_by_topic, save_article
|
||||
from prompt_loader import get_prompt, get_prompt_params
|
||||
|
||||
try:
|
||||
from app.core.nvidia_client import call_llm
|
||||
HAVE_LLM = True
|
||||
except ImportError:
|
||||
HAVE_LLM = False
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(LOGS_DIR / f"shortvideo_{TODAY}.log"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT = "你是短视频脚本编剧,擅长把长文改成高完播率口播脚本"
|
||||
|
||||
_VARIANT_ANGLES = [
|
||||
("观点冲突型", "从一个反直觉/有争议的观点切入,制造认知冲突,让观众想反驳或认同"),
|
||||
("干货清单型", "提炼 3-5 条可立刻上手的干货要点,节奏快、信息密度高"),
|
||||
("故事共鸣型", "用一个真实人物/生活场景的故事开头,引发情绪共鸣,再带出观点"),
|
||||
]
|
||||
|
||||
|
||||
def _build_prompt(source_text: str, angle_name: str, angle_desc: str) -> str:
|
||||
return f"""你是一个短视频脚本编剧。下面是一篇长文的内容,请把它改写成一条短视频脚本。
|
||||
|
||||
改写角度:{angle_name}——{angle_desc}
|
||||
|
||||
长文素材:
|
||||
{source_text}
|
||||
|
||||
请严格按以下结构输出(用 markdown,各级标题不要省略):
|
||||
|
||||
## 标题(爆款钩子)
|
||||
写 1 个 15-25 字的短视频标题,带强钩子(悬念/冲突/数字/结果),不要标题党到离谱。
|
||||
|
||||
## 口播文案
|
||||
写 60-90 秒的口语化口播稿,约 200-350 字。像真人对着镜头说话,有停顿标记(用「[停顿]」标注换气/节奏点)。不要书面语,不要「首先其次最后」套路。
|
||||
|
||||
## 分镜建议
|
||||
给出 3-5 个镜头,每个镜头包含:
|
||||
- 画面:具体拍什么(景别/动作/场景)
|
||||
- 字幕:屏幕上打出的关键词
|
||||
- 时长:几秒
|
||||
|
||||
用列表或表格呈现,每个镜头一行。
|
||||
|
||||
## 适配平台
|
||||
说明这条脚本分别适配 抖音 / 视频号 / 小红书视频 时的微调建议(各 1 句话)。
|
||||
|
||||
只输出上面的 markdown,不要其他说明。"""
|
||||
|
||||
|
||||
def _template_markdown(topic_title: str, angle_name: str) -> str:
|
||||
return f"""## 标题(爆款钩子)
|
||||
(待生成){topic_title} - {angle_name}角度
|
||||
|
||||
## 口播文案
|
||||
[LLM 不可用,使用模板占位。请稍后手动补充 60-90 秒口播稿,并用[停顿]标记节奏。]
|
||||
|
||||
## 分镜建议
|
||||
- 画面:(占位) 字幕:(占位) 时长:(占位)
|
||||
|
||||
## 适配平台
|
||||
- 抖音:(占位)
|
||||
- 视频号:(占位)
|
||||
- 小红书视频:(占位)
|
||||
"""
|
||||
|
||||
|
||||
def _load_source(topic: Dict, articles: List[Dict]) -> str:
|
||||
for platform in ("zhihu", "wechat"):
|
||||
for a in articles:
|
||||
if a.get("platform") == platform and a.get("html_content"):
|
||||
return a["html_content"]
|
||||
if topic.get("core_concept"):
|
||||
return f"# {topic.get('title', '')}\n\n{topic.get('core_concept')}"
|
||||
return topic.get("title", "")
|
||||
|
||||
|
||||
def _generate_one(source: str, idx: int, topic_title: str) -> str:
|
||||
angle_name, angle_desc = _VARIANT_ANGLES[(idx - 1) % len(_VARIANT_ANGLES)]
|
||||
if HAVE_LLM:
|
||||
try:
|
||||
params = get_prompt_params("shortvideo_script") or {}
|
||||
temperature = params.get("temperature", 0.8)
|
||||
max_tokens = params.get("max_tokens", 2000)
|
||||
prompt = _build_prompt(source, angle_name, angle_desc)
|
||||
result = call_llm(
|
||||
prompt,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
if result and result.strip():
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
logger.warning("LLM 调用失败(variant %s):%s", idx, e)
|
||||
logger.warning("LLM 不可用或失败,写入模板占位(variant %s)", idx)
|
||||
return _template_markdown(topic_title, angle_name)
|
||||
|
||||
|
||||
def _extract_title(markdown: str, fallback: str) -> str:
|
||||
for line in markdown.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("## 标题") or line.startswith("# 标题"):
|
||||
rest = line.split("标题", 1)[-1].strip("(():: ")
|
||||
if rest:
|
||||
return rest[:60]
|
||||
first = next((l.strip("# ").strip() for l in markdown.splitlines() if l.strip()), "")
|
||||
return first[:60] if first else fallback
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="长文转短视频脚本")
|
||||
parser.add_argument("--topic-id", required=True, help="选题 ID")
|
||||
parser.add_argument("--count", type=int, default=3, help="生成脚本数量(默认 3)")
|
||||
args = parser.parse_args()
|
||||
|
||||
topic_id = args.topic_id
|
||||
count = max(1, args.count)
|
||||
|
||||
files: List[str] = []
|
||||
out_dir = DATA_DIR / "shortvideos" / TODAY
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
topic = get_topic_by_id(topic_id)
|
||||
if not topic:
|
||||
logger.error("选题不存在:%s", topic_id)
|
||||
print(json.dumps({"ok": False, "topic_id": topic_id, "count": 0, "files": []}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
articles = get_articles_by_topic(topic_id)
|
||||
source = _load_source(topic, articles)
|
||||
topic_title = topic.get("title", topic_id)
|
||||
|
||||
ok = True
|
||||
for i in range(1, count + 1):
|
||||
markdown = _generate_one(source, i, topic_title)
|
||||
title = _extract_title(markdown, f"{topic_title}_短视频{i}")
|
||||
file_path = out_dir / f"{topic_id}_shortvideo_{i}.md"
|
||||
try:
|
||||
file_path.write_text(markdown, encoding="utf-8")
|
||||
files.append(str(file_path))
|
||||
logger.info("已写出脚本文件:%s", file_path)
|
||||
except Exception as e:
|
||||
logger.error("写文件失败:%s", e)
|
||||
ok = False
|
||||
continue
|
||||
|
||||
try:
|
||||
save_article(topic_id, f"shortvideo_{i}", html_content=None, title=title, content=markdown)
|
||||
logger.info("已写入 DB:shortvideo_%s / %s", i, topic_id)
|
||||
except Exception as e:
|
||||
logger.warning("DB 保存失败(文件已落盘):%s", e)
|
||||
|
||||
summary = {"ok": ok, "topic_id": topic_id, "count": count, "files": files}
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+72
-3
@@ -69,6 +69,33 @@ def _load_platform_config() -> dict:
|
||||
|
||||
PLATFORM_CONFIG = _load_platform_config()
|
||||
|
||||
_AI_DISCLOSURE_TEXT = {
|
||||
"zhihu": "本文由 AI 辅助创作,核心观点与数据经宇之然人工审核校对。",
|
||||
"wechat": "本文由 AI 辅助创作,核心观点与数据经宇之然人工审核校对。",
|
||||
"xiaohongshu": "✨ 本文由 AI 辅助生成,内容已人工核对~",
|
||||
}
|
||||
|
||||
def _load_ai_disclosure_enabled() -> bool:
|
||||
"""根据《人工智能生成合成内容标识办法》(2025-09-01 施行),AI 生成内容须主动声明。
|
||||
默认开启;可通过 SystemConfig(disclose_ai=false) 或环境变量 YZR_DISCLOSE_AI=0 关闭。"""
|
||||
try:
|
||||
if os.environ.get("YZR_DISCLOSE_AI", "").strip() == "0":
|
||||
return False
|
||||
from app.database import SessionLocal
|
||||
from app.models import SystemConfig
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sc = db.query(SystemConfig).filter(SystemConfig.key == "disclose_ai").first()
|
||||
if sc and str(sc.value).strip().lower() in ("false", "0", "no"):
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
AI_DISCLOSURE_ENABLED = _load_ai_disclosure_enabled()
|
||||
|
||||
PLATFORM_NAMES = {
|
||||
"zhihu": "知乎专栏",
|
||||
"wechat": "微信公众号",
|
||||
@@ -235,10 +262,40 @@ def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags
|
||||
return html
|
||||
|
||||
|
||||
def inject_ai_disclosure(html: str, platform: str) -> str:
|
||||
"""按《人工智能生成合成内容标识办法》注入 AI 生成声明。
|
||||
显式:文首/文末标注「AI 生成」;隐式:head 注入 ai-generated meta 与 AIGC 隐式标识字段。"""
|
||||
if not AI_DISCLOSURE_ENABLED:
|
||||
return html
|
||||
text = _AI_DISCLOSURE_TEXT.get(platform, _AI_DISCLOSURE_TEXT["zhihu"])
|
||||
|
||||
disclosure_block = f'\n<blockquote class="ai-disclosure">🤖 {text}</blockquote>\n'
|
||||
|
||||
# 文末声明(置于正文末尾,封面/互动钩子之前不影响阅读)
|
||||
if "<!-- AI_DISCLOSURE -->" in html:
|
||||
html = html.replace("<!-- AI_DISCLOSURE -->", disclosure_block.strip())
|
||||
else:
|
||||
# 在 </body> 前插入文末声明
|
||||
html = html.replace("</body>", f"{disclosure_block}</body>")
|
||||
|
||||
# 隐式标识:head 注入声明 meta + AIGC 元数据字段(符合标识办法附录 E)
|
||||
head_meta = (
|
||||
'<meta name="ai-generated" content="true">\n'
|
||||
'<meta name="ai-disclosure" content="本文含人工智能生成合成内容">\n'
|
||||
'<script type="application/ld+json">\n'
|
||||
'{"AIGC":{"Label":"1","ContentProducer":"宇之然","ProduceID":"yzr-' + platform + '",'
|
||||
'"ContentPropagator":"宇之然","PropagateID":"yzr-' + platform + '"}}\n'
|
||||
'</script>\n'
|
||||
)
|
||||
html = html.replace("</head>", head_meta + "</head>")
|
||||
return html
|
||||
|
||||
|
||||
class Writer:
|
||||
def __init__(self, topic_id: str):
|
||||
self.topic_id = topic_id
|
||||
self.topic = self._load_topic()
|
||||
self._degraded = False
|
||||
outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md"
|
||||
if not outline_file.exists():
|
||||
raise FileNotFoundError(f"Outline not found: {outline_file}")
|
||||
@@ -334,6 +391,9 @@ class Writer:
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM 扩写失败 [{platform}]: {e}")
|
||||
|
||||
# LLM 不可用或返回为空 → 降级为大纲要点拼接(非真正成稿)
|
||||
self._degraded = True
|
||||
logger.warning(f"⚠️ [{platform}] 章节「{section['title']}」LLM 扩写缺失,降级为大纲要点拼接(内容未达发布质量)")
|
||||
lines = [l.strip() for l in content.split('\n') if not self._is_outline_noise(l)]
|
||||
if lines:
|
||||
sentences = []
|
||||
@@ -608,8 +668,11 @@ class Writer:
|
||||
html = html.replace("<!-- TAGS -->", tags_html)
|
||||
else:
|
||||
html = html.replace("<!-- TAGS -->", "")
|
||||
|
||||
html = inject_geo_metadata(html, title, adapted, platform, tags_html)
|
||||
|
||||
# 强制 AI 生成声明(合规要求,可配置关闭)
|
||||
html = inject_ai_disclosure(html, platform)
|
||||
|
||||
return html
|
||||
|
||||
def save_html(self, html: str, platform: str, *, title: str = "", content: str = "") -> str:
|
||||
@@ -634,8 +697,14 @@ class Writer:
|
||||
html = self.generate_platform_html(markdown, platform)
|
||||
results[platform] = str(self.save_html(html, platform, title=title, content=markdown))
|
||||
self.mark_draft()
|
||||
logger.info(f"撰写完成,状态已更新为待审查")
|
||||
return {"ok": True, "files": results}
|
||||
if self._degraded:
|
||||
logger.error(
|
||||
"❌ 撰写完成但存在 LLM 降级章节(大纲要点拼接,未达发布质量)。"
|
||||
"选题保留为待审查,请检查 LLM 提供商连通性后重跑,勿直接发布降级稿。"
|
||||
)
|
||||
else:
|
||||
logger.info(f"撰写完成,状态已更新为待审查")
|
||||
return {"ok": True, "files": results, "degraded": self._degraded}
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
Reference in New Issue
Block a user