jianzhihuixiang/skills/novel-workshop/workflow.py

463 lines
19 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
🎲 命题小说多模型创作工坊 v4
用法: python3 workflow.py "写作prompt" ["文档标题"]
流程: MiMo写初稿 Gemini+Claude三路并行审阅 Gemini改稿 本地存档 + 飞书文档完整写入
全程自动飞书群聊实时进度推送
配置: 自动从 ~/.openclaw/openclaw.json 读取 API Key 和飞书配置
可用环境变量覆盖: OPENROUTER_API_KEY, FEISHU_CHAT_ID, FEISHU_FOLDER_TOKEN, FEISHU_OWNER_OPEN_ID
"""
import sys, json, os, time, requests, concurrent.futures, re
# ============ 配置 ============
OPENROUTER_API_KEY = None
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
MODELS = {
"write": "xiaomi/mimo-v2-flash",
"review_logic": "google/gemini-2.5-pro",
"review_literary": "google/gemini-2.5-pro",
"review_sharp": "anthropic/claude-opus-4.6",
"revise": "google/gemini-2.5-pro",
}
FALLBACK_MODELS = {
"xiaomi/mimo-v2-flash": "google/gemini-2.5-pro",
"google/gemini-2.5-pro": "anthropic/claude-opus-4.6",
"anthropic/claude-opus-4.6": "google/gemini-2.5-pro",
}
# 飞书配置
FEISHU_APP_ID = None
FEISHU_APP_SECRET = None
FEISHU_CHAT_ID = os.environ.get("FEISHU_CHAT_ID", "oc_4680833fd5ab374f3e26c90739ef6946")
FEISHU_FOLDER_TOKEN = os.environ.get("FEISHU_FOLDER_TOKEN", "U01TfC1RdlwEBzdHJCIcXwCQnVg")
FEISHU_OWNER_OPEN_ID = os.environ.get("FEISHU_OWNER_OPEN_ID", "ou_5b28826e6f7c9e54fcb49ba0b7e0b944")
FEISHU_TOKEN = None
# ============ 初始化 ============
def load_config():
global OPENROUTER_API_KEY, FEISHU_APP_ID, FEISHU_APP_SECRET
try:
cfg = json.load(open(os.path.expanduser("~/.openclaw/openclaw.json")))
providers = cfg.get("models", {}).get("providers", {})
OPENROUTER_API_KEY = providers.get("openrouter", {}).get("apiKey", "")
feishu = cfg.get("channels", {}).get("feishu", {})
FEISHU_APP_ID = feishu.get("appId", "")
FEISHU_APP_SECRET = feishu.get("appSecret", "")
except Exception as e:
print(f"⚠️ 读取配置失败: {e}")
if not OPENROUTER_API_KEY:
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
if not OPENROUTER_API_KEY:
print("❌ 找不到 OpenRouter API Key")
sys.exit(1)
def get_feishu_token():
global FEISHU_TOKEN
if not FEISHU_APP_ID or not FEISHU_APP_SECRET:
return None
try:
resp = requests.post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET}, timeout=10).json()
FEISHU_TOKEN = resp.get("tenant_access_token", "")
return FEISHU_TOKEN
except:
return None
# ============ 飞书消息推送 ============
def feishu_send(msg):
"""发送消息到飞书群聊"""
if not FEISHU_TOKEN:
print(f" [飞书] (无token跳过) {msg}")
return
try:
resp = requests.post(
f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id",
headers={"Authorization": f"Bearer {FEISHU_TOKEN}", "Content-Type": "application/json"},
json={
"receive_id": FEISHU_CHAT_ID,
"msg_type": "text",
"content": json.dumps({"text": msg})
},
timeout=10
).json()
if resp.get("code") == 0:
print(f" [飞书] ✅ 已发送: {msg[:50]}...")
else:
print(f" [飞书] ⚠️ 发送失败: {resp.get('msg', 'unknown')}")
except Exception as e:
print(f" [飞书] ⚠️ 发送异常: {e}")
# ============ OpenRouter API ============
def chat(model, messages, timeout=180):
resp = requests.post(
f"{OPENROUTER_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://openclaw.ai"
},
json={"model": model, "messages": messages, "max_tokens": 16384},
timeout=timeout
)
data = resp.json()
if "error" in data:
raise Exception(f"API error: {data['error']}")
return data["choices"][0]["message"]["content"]
def chat_with_fallback(model, messages, role_name, timeout=180):
try:
print(f" 📡 {role_name}: 使用 {model}...")
result = chat(model, messages, timeout)
print(f"{role_name}: 完成 ({len(result)} chars)")
return result
except Exception as e:
print(f" ⚠️ {role_name}: {model} 失败 ({e}), 尝试备选...")
fallback = FALLBACK_MODELS.get(model)
if fallback:
try:
result = chat(fallback, messages, timeout)
print(f"{role_name}: 备选 {fallback} 完成")
return result
except Exception as e2:
print(f"{role_name}: 备选也失败 ({e2})")
return f"[审阅失败: {e2}]"
return f"[审阅失败: {e}]"
def extract_score(text):
matches = re.findall(r'(\d+(?:\.\d+)?)\s*/\s*10', text)
return matches[0] if matches else "?"
# ============ 主流程 ============
def main():
if len(sys.argv) < 2:
print("用法: python3 workflow.py \"写作prompt\" [\"文档标题\"]")
sys.exit(1)
write_prompt = sys.argv[1]
doc_title = sys.argv[2] if len(sys.argv) > 2 else "命题创作"
load_config()
get_feishu_token()
start_time = time.time()
# Step 0: 通知
feishu_send(f"[░░░░░] 0/5 收到命题!工作流启动中 🎲")
# ---- Step 1: 写初稿 ----
print(f"\n{'='*50}\n [█░░░░] 1/5 写初稿\n{'='*50}")
draft = chat_with_fallback(
MODELS["write"],
[{"role": "user", "content": f"{write_prompt}\n\n直接输出正文(含标题),不需要任何解释或前言。"}],
"初稿写作",
timeout=240
)
# 提取标题(第一行去掉#号)
first_line = draft.strip().split('\n')[0].strip().lstrip('#').strip()
story_title = first_line if first_line else doc_title
feishu_send(f"[█░░░░] 1/5 初稿完成 ✅《{story_title}》({len(draft)}字) 三路审阅启动中…")
# ---- Step 2: 三路并行审阅 ----
print(f"\n{'='*50}\n [██░░░] 2/5 三路并行审阅\n{'='*50}")
review_tasks = {
"logic": {
"model": MODELS["review_logic"],
"name": "逻辑检阅",
"prompt": f"你是严谨的文学逻辑审阅专家。审阅以下小说,从以下维度分析:\n1. 情节自洽性\n2. 时间线\n3. 因果关系\n4. 世界观矛盾\n\n评分1-10详细说明。用中文。\n\n---以下是小说全文---\n\n{draft}"
},
"literary": {
"model": MODELS["review_literary"],
"name": "文学性分析",
"prompt": f"你是资深文学评论家。审阅以下小说,从以下维度分析:\n1. 语言风格\n2. 意象构建\n3. 叙事手法\n4. 主题深度\n\n评分1-10详细说明。用中文。\n\n---以下是小说全文---\n\n{draft}"
},
"sharp": {
"model": MODELS["review_sharp"],
"name": "锐评",
"prompt": f"你是毒舌但有料的文学批评家,风格尖锐、一针见血。对以下小说进行锐评:\n1. 用最犀利的语言指出最大的问题\n2. 不要客气,不要留面子\n3. 给出3个核心改动建议具体、可操作\n\n用中文。\n\n---以下是小说全文---\n\n{draft}"
}
}
reviews = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
futures = {}
for key, task in review_tasks.items():
future = executor.submit(
chat_with_fallback, task["model"],
[{"role": "user", "content": task["prompt"]}],
task["name"], 180
)
futures[future] = key
for future in concurrent.futures.as_completed(futures):
key = futures[future]
reviews[key] = future.result()
logic_score = extract_score(reviews.get("logic", ""))
literary_score = extract_score(reviews.get("literary", ""))
feishu_send(f"[██░░░] 2/5 审阅完成 ✅ 逻辑 {logic_score}/10 | 文学 {literary_score}/10 | 改稿启动中…")
# ---- Step 3: 改稿 ----
print(f"\n{'='*50}\n [███░░] 3/5 改稿\n{'='*50}")
revision_prompt = f"""你是一位优秀的作家。以下是一篇小说初稿和三份审阅意见。请根据审阅意见全面修改小说,输出完整新版本。
修改要求
1. 认真采纳锐评的建议大胆改动
2. 保留原作优秀的部分
3. 让角色更真实立体
4. 结尾不要鸡汤金句留白更好
直接输出修改后的完整小说含标题不需要解释修改了什么
## 初稿
{draft}
## 审阅A逻辑检阅
{reviews.get('logic', '[未完成]')}
## 审阅B文学性分析
{reviews.get('literary', '[未完成]')}
## 审阅C锐评
{reviews.get('sharp', '[未完成]')}"""
revision = chat_with_fallback(
MODELS["revise"],
[{"role": "user", "content": revision_prompt}],
"改稿",
timeout=300
)
feishu_send(f"[███░░] 3/5 改稿完成 ✅ 保存中…")
# ---- Step 4: 本地存档 ----
print(f"\n{'='*50}\n [████░] 4/5 存档\n{'='*50}")
local_path = os.path.expanduser(f"~/.openclaw/workspace/novels/{doc_title}.md")
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "w") as f:
f.write(f"# 🎲 命题创作《{doc_title}》— 多模型创作工坊\n\n")
f.write(f"**日期**{time.strftime('%Y-%m-%d')}\n")
f.write(f"**评分**:逻辑 {logic_score}/10 | 文学 {literary_score}/10\n\n---\n\n")
f.write(f"## Part 1初稿\n\n{draft}\n\n---\n\n")
f.write(f"## Part 2三路审阅\n\n")
f.write(f"### 逻辑检阅\n\n{reviews.get('logic', '[未完成]')}\n\n")
f.write(f"### 文学性分析\n\n{reviews.get('literary', '[未完成]')}\n\n")
f.write(f"### 锐评\n\n{reviews.get('sharp', '[未完成]')}\n\n---\n\n")
f.write(f"## Part 3终稿\n\n{revision}\n")
print(f" 💾 本地保存: {local_path}")
# 飞书文档创建 + 写入完整内容
doc_id = ""
doc_url = ""
if FEISHU_TOKEN:
doc_id = create_feishu_doc(f"🎲 命题创作《{doc_title}》— 多模型创作工坊", FEISHU_TOKEN)
if doc_id:
doc_url = f"https://feishu.cn/docx/{doc_id}"
print(f" 📄 飞书文档已创建: {doc_url}")
feishu_send(f"[████░] 4/5 存档完成 ✅ 正在写入飞书文档…")
# 读取本地完整 md 文件,写入飞书文档(不省略任何内容!)
write_feishu_doc_content(doc_id, local_path, FEISHU_TOKEN)
else:
feishu_send(f"[████░] 4/5 存档完成 ✅ 飞书文档创建失败,仅本地保存")
else:
feishu_send(f"[████░] 4/5 存档完成 ✅ 仅本地保存")
# ---- Step 5: 完成通知 ----
elapsed = time.time() - start_time
if doc_url:
final_msg = f"[█████] 5/5 全部完成!🎲\n\n⏱️ 总耗时: {elapsed:.0f}\n📊 评分: 逻辑 {logic_score}/10 | 文学 {literary_score}/10\n📄 飞书文档: {doc_url}"
else:
final_msg = f"[█████] 5/5 写作完成!🎲\n\n⏱️ 总耗时: {elapsed:.0f}\n📊 评分: 逻辑 {logic_score}/10 | 文学 {literary_score}/10\n💾 已保存到本地"
feishu_send(final_msg)
print(f"\n{'='*50}")
print(f" 🎲 完成!总耗时: {elapsed:.0f}")
print(f"{'='*50}")
# JSON 摘要
summary = {
"story_title": story_title,
"doc_title": doc_title,
"doc_url": doc_url,
"doc_id": doc_id,
"local_path": local_path,
"scores": {"logic": logic_score, "literary": literary_score},
"elapsed_seconds": round(elapsed),
"status": "success"
}
print(f"\n__SUMMARY__:{json.dumps(summary, ensure_ascii=False)}")
def write_feishu_doc_content(doc_id, local_path, token):
"""读取本地 md 文件完整内容,通过飞书 API 分段写入文档。
使用 openclaw CLI feishu_doc write 工具确保不省略任何内容"""
import subprocess
try:
with open(local_path, "r") as f:
full_content = f.read()
if not full_content.strip():
print(" ⚠️ 本地文件为空,跳过写入")
return
# 使用 openclaw tool 调用 feishu_doc write
# 先尝试直接用飞书 API 写入纯文本 block
# 飞书文档 API: 创建文本块
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
# 将内容按段落拆分为 block
lines = full_content.split('\n')
blocks = []
current_text = []
for line in lines:
stripped = line.strip()
if stripped.startswith('# '):
# 跳过一级标题(已经是文档标题)
continue
elif stripped.startswith('## '):
if current_text:
blocks.append({"type": "text", "content": '\n'.join(current_text)})
current_text = []
blocks.append({"type": "heading2", "content": stripped[3:]})
elif stripped.startswith('### '):
if current_text:
blocks.append({"type": "text", "content": '\n'.join(current_text)})
current_text = []
blocks.append({"type": "heading3", "content": stripped[4:]})
elif stripped == '---':
if current_text:
blocks.append({"type": "text", "content": '\n'.join(current_text)})
current_text = []
blocks.append({"type": "divider", "content": ""})
else:
current_text.append(line)
if current_text:
blocks.append({"type": "text", "content": '\n'.join(current_text)})
# 使用飞书 Descendant API 批量创建 block
doc_block_id = doc_id # 根 block = 文档 ID
created = 0
for block in blocks:
try:
if block["type"] == "divider":
block_data = {
"block_type": 22, # divider
"divider": {}
}
elif block["type"] == "heading2":
block_data = {
"block_type": 4, # heading2
"heading2": {
"elements": [{"text_run": {"content": block["content"]}}],
"style": {}
}
}
elif block["type"] == "heading3":
block_data = {
"block_type": 5, # heading3
"heading3": {
"elements": [{"text_run": {"content": block["content"]}}],
"style": {}
}
}
else:
# 文本块,每段不超过 500 字符,超过则拆分
text = block["content"].strip()
if not text:
continue
# 按段落拆分
paragraphs = text.split('\n\n')
for para in paragraphs:
para = para.strip()
if not para:
continue
# 飞书单个文本块有字符限制,按行再拆
sub_lines = para.split('\n')
for sub_line in sub_lines:
sub_line_stripped = sub_line.strip()
if not sub_line_stripped:
continue
para_data = {
"block_type": 2, # text
"text": {
"elements": [{"text_run": {"content": sub_line_stripped}}],
"style": {}
}
}
resp = requests.post(
f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_block_id}/children",
headers=headers,
json={"children": [para_data], "index": -1},
timeout=15
).json()
if resp.get("code") == 0:
created += 1
else:
print(f" ⚠️ 写入失败: {resp.get('msg', '')[:80]}")
continue
resp = requests.post(
f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_block_id}/children",
headers=headers,
json={"children": [block_data], "index": -1},
timeout=15
).json()
if resp.get("code") == 0:
created += 1
else:
print(f" ⚠️ 写入失败: {resp.get('msg', '')[:80]}")
except Exception as e:
print(f" ⚠️ 写入异常: {e}")
print(f" 📝 飞书文档写入完成: {created} 个文本块")
except Exception as e:
print(f" ❌ 飞书文档写入失败: {e}")
def create_feishu_doc(title, token):
try:
resp = requests.post(
"https://open.feishu.cn/open-apis/docx/v1/documents",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"title": title, "folder_token": FEISHU_FOLDER_TOKEN},
timeout=15
).json()
doc_id = resp.get("data", {}).get("document", {}).get("document_id", "")
if doc_id:
try:
requests.post(
f"https://open.feishu.cn/open-apis/drive/v1/permissions/{doc_id}/members?type=docx&need_notification=false",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"member_type": "openid", "member_id": FEISHU_OWNER_OPEN_ID, "perm": "full_access"},
timeout=10
)
except:
pass
return doc_id
except:
return ""
if __name__ == "__main__":
main()