返回 VideoClaw
script_agent.py
根目录 / video-claw / video-claw / backend / core / agents / script_agent.py
1 # -*- coding: utf-8 -*-
2 """
3 阶段1: 编剧智能体 (直出一遍过版本)
4 """
5
6 import os
7 import re
8 import json
9 import asyncio
10 import logging
11 from functools import partial
12 from datetime import datetime, timezone
13 from typing import Any, Optional, Dict, List
14
15 from prompts.loader import load_prompt_with_fallback
16 from .base_agent import AgentInterface
17
18 logger = logging.getLogger(__name__)
19
20 def _get_script_prompt(name: str, lang: str = "zh") -> str:
21 return load_prompt_with_fallback("script", name, lang, "zh")
22
23 class ScriptWriterAgent(AgentInterface):
24 MIN_EPISODE_LINES = 25
25 MAX_EPISODE_LINES = 30
26
27 def __init__(self):
28 super().__init__(name="ScriptWriter")
29
30 @staticmethod
31 def _extract_json_from_text(text: str) -> Optional[Any]:
32 text = text.strip()
33 text = re.sub(r'^```(?:json)?\s*', '', text)
34 text = re.sub(r'\s*```$', '', text)
35 text = text.strip()
36 try:
37 return json.loads(text)
38 except json.JSONDecodeError:
39 pass
40
41 # 尝试匹配第一个 { 或 [ 到底部对应的 } 或 ]
42 start_obj = text.find('{')
43 start_arr = text.find('[')
44
45 # 确定起始位置
46 if start_obj == -1 and start_arr == -1:
47 return None
48
49 start = start_obj if (start_obj != -1 and (start_arr == -1 or start_obj < start_arr)) else start_arr
50 end_char = '}' if start == start_obj else ']'
51 end = text.rfind(end_char)
52
53 if start != -1 and end != -1 and end > start:
54 try:
55 return json.loads(text[start:end + 1])
56 except json.JSONDecodeError:
57 pass
58 return None
59
60 def _gen_id(self, prefix: str = "char") -> str:
61 import uuid
62 return f"{prefix}_{uuid.uuid4().hex[:6]}"
63
64 def _save_result(self, json_data: dict, sid: str, is_zh: bool):
65 from config import settings as app_settings
66 os.makedirs(os.path.join(app_settings.RESULT_DIR, 'script'), exist_ok=True)
67 out_path = os.path.join(app_settings.RESULT_DIR, 'script', f'{sid}.json')
68 with open(out_path, 'w', encoding='utf-8') as f:
69 json.dump(json_data, f, ensure_ascii=False, indent=2)
70 logger.info(f"[ScriptWriter] script saved to {out_path}")
71
72 def _save_progress(self, sid: str, phase: str, data: dict):
73 pass
74
75 @classmethod
76 def _split_episode_blocks(cls, script_text: str) -> List[dict]:
77 episode_re = re.compile(
78 r"^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*(?:第\s*(\d+)\s*集(?![--])|Episode\s+(\d+)\b)",
79 re.IGNORECASE,
80 )
81 blocks: List[dict] = []
82 current = {"episode_number": 1, "lines": []}
83 seen_header = False
84
85 for line in script_text.splitlines():
86 match = episode_re.match(line.strip())
87 if match:
88 if seen_header and current["lines"]:
89 blocks.append(current)
90 ep_no = int(match.group(1) or match.group(2) or len(blocks) + 1)
91 current = {"episode_number": ep_no, "lines": [line]}
92 seen_header = True
93 else:
94 current["lines"].append(line)
95
96 if current["lines"]:
97 blocks.append(current)
98 return blocks
99
100 @staticmethod
101 def _is_counted_script_line(line: str) -> bool:
102 text = line.strip()
103 if not text:
104 return False
105 if re.match(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*(?:第\s*\d+\s*集(?![--])|Episode\s+\d+\b)", text, re.IGNORECASE):
106 return False
107 if re.match(r"^\s*(?:\*\*)?\s*(?:第\s*\d+\s*集[--]第\s*\d+\s*场|\d+\s*[--]\s*\d+\b)", text, re.IGNORECASE):
108 return False
109 if text.startswith("**") and text.endswith("**"):
110 return False
111 if re.match(r"^(人物|角色|Characters)\s*[::]", text, re.IGNORECASE):
112 return False
113 return True
114
115 @classmethod
116 def _script_length_stats(cls, script_text: str) -> List[dict]:
117 stats: List[dict] = []
118 for block in cls._split_episode_blocks(script_text):
119 counted = [line for line in block["lines"] if cls._is_counted_script_line(line)]
120 stats.append({
121 "episode_number": block["episode_number"],
122 "line_count": len(counted),
123 "too_long": len(counted) > cls.MAX_EPISODE_LINES,
124 })
125 return stats
126
127 @classmethod
128 def _length_feedback(cls, stats: List[dict]) -> str:
129 return "\n".join(
130 f"- 第{item['episode_number']}集:{item['line_count']}行"
131 for item in stats
132 )
133
134 @staticmethod
135 def _expected_episode_numbers(episodes: int) -> List[int]:
136 return list(range(1, max(1, int(episodes)) + 1))
137
138 @classmethod
139 def _episode_numbers_from_script(cls, script_text: str) -> List[int]:
140 return [
141 int(block["episode_number"])
142 for block in cls._split_episode_blocks(script_text)
143 if block.get("lines")
144 ]
145
146 @classmethod
147 def _episode_count_feedback(cls, script_text: str, episodes: int) -> str:
148 expected = cls._expected_episode_numbers(episodes)
149 found = cls._episode_numbers_from_script(script_text)
150 missing = [num for num in expected if num not in found]
151 extra = [num for num in found if num not in expected]
152 return (
153 f"目标集数:{episodes};应包含集号:{expected};"
154 f"当前识别到集号:{found or '无'};缺失:{missing or '无'};多余:{extra or '无'}。"
155 )
156
157 @classmethod
158 def _episode_count_matches(cls, script_text: str, episodes: int) -> bool:
159 found = sorted(set(cls._episode_numbers_from_script(script_text)))
160 return found == cls._expected_episode_numbers(episodes)
161
162 @classmethod
163 def _build_episodes_from_script_text(cls, script_text: str, expected_episodes: int) -> List[dict]:
164 episodes: List[dict] = []
165 for block in cls._split_episode_blocks(script_text):
166 ep_no = int(block.get("episode_number") or len(episodes) + 1)
167 if ep_no < 1 or ep_no > expected_episodes:
168 continue
169 content = "\n".join(block.get("lines") or []).strip()
170 if not content:
171 continue
172 episodes.append({
173 "episode_number": ep_no,
174 "act_title": f"第{ep_no}集",
175 "content": content,
176 })
177 deduped: Dict[int, dict] = {}
178 for ep in episodes:
179 deduped[ep["episode_number"]] = ep
180 return [deduped[num] for num in cls._expected_episode_numbers(expected_episodes) if num in deduped]
181
182 async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
183 if intervention and "modified_script" in intervention:
184 modified = intervention["modified_script"]
185 sid = input_data.get("session_id", "")
186 if isinstance(modified, str):
187 modified = self._extract_json_from_text(modified) or {}
188 is_zh = any('\u4e00' <= c <= '\u9fff' for c in modified.get("title", ""))
189 modified["session_id"] = sid
190 # 【优化】移除手动调用 self._save_result,依靠 Orchestrator 自动保存
191 return {"payload": modified, "requires_intervention": False, "stage_completed": True}
192
193 # 处理确认续写或删除续写的结果,更新script_genenration和character_design数据结构,并保存最终结果
194 if intervention and intervention.get("action") in ["confirm_continue", "delete_continue"]:
195 import copy
196 final_data = copy.deepcopy(input_data)
197 sid = final_data.get("session_id", "")
198
199 if intervention.get("action") == "confirm_continue":
200 new_chars = final_data.get("new_characters", [])
201 new_settings = final_data.get("new_settings", [])
202 new_ep_list = final_data.get("new_episodes", [])
203
204 # 更新第一阶段剧本数据 (内存)
205 final_data.setdefault("episodes", []).extend(new_ep_list)
206 final_data.setdefault("characters", []).extend(new_chars)
207 final_data.setdefault("settings", []).extend(new_settings)
208
209 # 创建一个包含增量信息的返回结果,供 Orchestrator 钩子使用
210 result_payload = copy.deepcopy(final_data)
211 result_payload["new_characters"] = new_chars
212 result_payload["new_settings"] = new_settings
213 result_payload["new_episodes"] = new_ep_list
214
215 logger.info(f"[ScriptWriter] Confirmed continuation. Providing incremental data to Orchestrator.")
216 return {"payload": result_payload, "requires_intervention": False, "stage_completed": True}
217
218 # 处理 delete_continue 的情况,直接丢弃新增内容,保持原有剧本数据不变
219 for key in ["new_episodes", "new_characters", "new_settings", "sequel_idea"]:
220 final_data.pop(key, None)
221 return {"payload": final_data, "requires_intervention": False, "stage_completed": True}
222 # ---------------------------------------------------- #
223
224 async def run_smart_continue():
225 import copy
226 sid = input_data.get("session_id", "")
227 llm_model = self._require_input(input_data, "llm_model")
228 web_search = input_data.get("web_search", False)
229 episodes_to_add = intervention.get("episodes_to_add", 1)
230 sequel_idea = intervention.get("sequel_idea", "").strip()
231
232 from config import settings as app_settings
233 from models.llm_client import LLM
234 llm = LLM()
235
236 def _log_progress(pct, msg):
237 self._report_progress("智能续写", msg, pct)
238 logger.info(f"[{pct}%] {msg}")
239
240 loop = asyncio.get_running_loop()
241
242 existing_episodes_text = json.dumps(input_data.get("episodes", []), ensure_ascii=False)
243 existing_chars_text = json.dumps(input_data.get("characters", []), ensure_ascii=False)
244 existing_settings_text = json.dumps(input_data.get("settings", []), ensure_ascii=False)
245
246 last_episode_num = 0
247 if input_data.get("episodes"):
248 last_episode_num = input_data["episodes"][-1].get("episode_number", len(input_data["episodes"]))
249
250 if not sequel_idea:
251 _log_progress(10, "生成续写灵感...")
252 idea_prompt = f"根据以下已有的剧集内容,在100字内,提供一个后续{episodes_to_add}集的简短续写灵感(主线方向): {existing_episodes_text}"
253 sequel_idea = await loop.run_in_executor(None, self._cancellable_query, llm, idea_prompt, [], llm_model, True, sid, web_search)
254 sequel_idea = sequel_idea.strip()
255
256 _log_progress(30, "正在生成续写剧本文本...")
257 prompt_name = "smart_continue_script"
258 prompt = _get_script_prompt(prompt_name, "zh").format(
259 episodes_text=existing_episodes_text,
260 chars_text=existing_chars_text,
261 settings_text=existing_settings_text,
262 episodes_to_add=episodes_to_add,
263 sequel_idea=sequel_idea,
264 start_episode_num=last_episode_num + 1
265 )
266
267 _log_progress(45, "正在生成续写初稿...")
268 sequel_script_text = await loop.run_in_executor(None, self._cancellable_query, llm, prompt, [], llm_model, True, sid, web_search)
269
270 _log_progress(50, "正在进行台词评估...")
271 eval_dialogue_prompt = _get_script_prompt("eval_dialogue", "zh" if is_zh else "en").format(script_text=sequel_script_text)
272 dialogue_critique = await loop.run_in_executor(None, self._cancellable_query, llm, eval_dialogue_prompt, [], llm_model, True, sid, web_search)
273
274 _log_progress(55, "正在进行情节评估...")
275 eval_plot_prompt = _get_script_prompt("eval_plot", "zh" if is_zh else "en").format(script_text=sequel_script_text)
276 plot_critique = await loop.run_in_executor(None, self._cancellable_query, llm, eval_plot_prompt, [], llm_model, True, sid, web_search)
277
278 _log_progress(58, "正在根据评估意见优化续写内容...")
279 revise_prompt = _get_script_prompt("revise_script", "zh" if is_zh else "en").format(
280 script_text=sequel_script_text,
281 dialogue_critique=dialogue_critique,
282 plot_critique=plot_critique
283 )
284 sequel_script_text = await loop.run_in_executor(None, self._cancellable_query, llm, revise_prompt, [], llm_model, True, sid, web_search)
285
286 _log_progress(60, "提取新增人物/场景...")
287 meta_prompt = _get_script_prompt("meta_extract_sequel", "zh").format(
288 existing_chars=existing_chars_text,
289 existing_settings=existing_settings_text,
290 sequel_script=sequel_script_text
291 )
292 meta_raw = await loop.run_in_executor(None, self._cancellable_query, llm, meta_prompt, [], llm_model, True, sid, web_search)
293 meta_res = self._extract_json_from_text(meta_raw)
294 meta_data = meta_res if isinstance(meta_res, dict) else {}
295
296 new_chars = meta_data.get("new_characters", [])
297 new_settings = meta_data.get("new_settings", [])
298 for c in new_chars:
299 c["character_id"] = self._gen_id("char")
300 for s in new_settings:
301 s["setting_id"] = self._gen_id("set")
302
303 _log_progress(80, "结构化续写集数据...")
304 extract_prompt = _get_script_prompt("act_extract_sequel", "zh").format(
305 sequel_script=sequel_script_text,
306 start_episode_num=last_episode_num + 1,
307 episodes_to_add=episodes_to_add
308 )
309
310 new_episodes = []
311 max_retries = 3
312 raw_acts = ""
313 for attempt in range(max_retries):
314 raw_acts = await loop.run_in_executor(None, self._cancellable_query, llm, extract_prompt, [], llm_model, True, sid, web_search)
315 parsed_acts = self._extract_json_from_text(raw_acts)
316
317 new_episodes.clear()
318 if isinstance(parsed_acts, list):
319 for act in parsed_acts:
320 if isinstance(act, dict):
321 new_episodes.append({
322 "episode_number": act.get("episode_number"),
323 "act_title": act.get("act_title") or f"第{act.get('episode_number')}集",
324 "content": act.get("content", "")
325 })
326 elif isinstance(parsed_acts, dict):
327 act_list = parsed_acts.get("new_episodes") or parsed_acts.get("episodes") or list(parsed_acts.values())[0]
328 if isinstance(act_list, list):
329 for act in act_list:
330 if isinstance(act, dict):
331 new_episodes.append({
332 "episode_number": act.get("episode_number"),
333 "act_title": act.get("act_title") or f"第{act.get('episode_number')}集",
334 "content": act.get("content", "")
335 })
336
337 if new_episodes:
338 break
339 logger.warning(f"[ScriptWriter] Extraction failed on attempt {attempt+1}, retrying...")
340 _log_progress(85, f"数据解析失败,自动进行第 {attempt+1} 次重试...")
341
342 # 最终兜底:如果重试多次依然失败,直接将返回的文本全塞进一集里
343 if not new_episodes and sequel_script_text:
344 logger.error(f"[ScriptWriter] All {max_retries} attempts to parse new episodes failed.")
345 new_episodes.append({
346 "episode_number": last_episode_num + 1,
347 "act_title": f"第{last_episode_num + 1}集 续集",
348 "content": sequel_script_text.strip()
349 })
350
351 final_data = copy.deepcopy(input_data)
352 final_data["new_episodes"] = new_episodes
353 final_data["new_characters"] = new_chars
354 final_data["new_settings"] = new_settings
355 final_data["sequel_idea"] = sequel_idea
356
357 is_zh = any('\u4e00' <= c <= '\u9fff' for c in final_data.get("title", "Generated Script"))
358 self._save_result(final_data, sid, is_zh)
359 _log_progress(100, "智能续写完成")
360 return final_data
361
362 if intervention and intervention.get("action") == "smart_continue":
363 result = await run_smart_continue()
364 # 设置 requires_intervention=True 以触发表单确认按钮
365 return {"payload": result, "requires_intervention": True, "stage_completed": False}
366
367 async def run_logic():
368 idea = input_data.get("idea", "")
369 sid = input_data.get("session_id", "")
370 style = input_data.get("style", "anime")
371 llm_model = self._require_input(input_data, "llm_model")
372 web_search = input_data.get("web_search", False)
373 episodes = input_data.get("episodes")
374 if episodes is None:
375 logger.warning("[ScriptWriter] episodes missing from input_data; falling back to 4. session=%s", sid)
376 episodes = 4
377 try:
378 episodes = max(1, int(episodes))
379 except (TypeError, ValueError):
380 logger.warning("[ScriptWriter] invalid episodes=%r; falling back to 4. session=%s", episodes, sid)
381 episodes = 4
382 is_zh = any('\u4e00' <= c <= '\u9fff' for c in idea)
383
384 from config import settings as app_settings
385 from models.llm_client import LLM
386 os.makedirs(app_settings.TEMP_DIR, exist_ok=True)
387 llm = LLM()
388
389 def _log_progress(pct, msg):
390 self._report_progress("剧本生成", msg, pct)
391 logger.info(f"[{pct}%] {msg}")
392
393 loop = asyncio.get_running_loop()
394
395 async def _trim_script_if_needed(script_text: str, phase: str) -> str:
396 trimmed = script_text
397 for attempt in range(2):
398 stats = self._script_length_stats(trimmed)
399 overlong = [item for item in stats if item.get("too_long")]
400 if not overlong:
401 return trimmed
402 logger.warning(
403 "[ScriptWriter] %s script is too long; trimming attempt=%d stats=%s",
404 phase,
405 attempt + 1,
406 stats,
407 )
408 _log_progress(12 if phase == "初稿" else 45, f"{phase}篇幅超限,正在删减到每集{self.MAX_EPISODE_LINES}行以内...")
409 trim_prompt = _get_script_prompt("trim_script", "zh" if is_zh else "en").format(
410 script_text=trimmed,
411 min_lines=self.MIN_EPISODE_LINES,
412 max_lines=self.MAX_EPISODE_LINES,
413 line_report=self._length_feedback(stats),
414 )
415 trimmed = await loop.run_in_executor(None, self._cancellable_query, llm, trim_prompt, [], llm_model, True, sid, web_search)
416 logger.info("[ScriptWriter] Trimmed %s script generated (%d chars)", phase, len(trimmed))
417 return trimmed
418
419 async def _repair_episode_count_if_needed(script_text: str, phase: str) -> str:
420 repaired = script_text
421 for attempt in range(2):
422 if self._episode_count_matches(repaired, episodes):
423 return repaired
424 feedback = self._episode_count_feedback(repaired, episodes)
425 logger.warning(
426 "[ScriptWriter] %s script episode count mismatch; repair attempt=%d %s",
427 phase,
428 attempt + 1,
429 feedback,
430 )
431 _log_progress(14 if phase == "初稿" else 48, f"{phase}集数不一致,正在修正为{episodes}集...")
432 repair_prompt = _get_script_prompt("repair_episode_count", "zh" if is_zh else "en").format(
433 script_text=repaired,
434 episodes=episodes,
435 episode_report=feedback,
436 min_lines=self.MIN_EPISODE_LINES,
437 max_lines=self.MAX_EPISODE_LINES,
438 )
439 repaired = await loop.run_in_executor(None, self._cancellable_query, llm, repair_prompt, [], llm_model, True, sid, web_search)
440 repaired = await _trim_script_if_needed(repaired, f"{phase}集数修复后")
441 return repaired
442
443 # 1. Generate full script
444 _log_progress(10, "正在生成完整剧本文本初稿...")
445 prompt = _get_script_prompt("generate_script", "zh" if is_zh else "en").format(idea=idea, style=style, episodes=episodes)
446
447 full_script_text = await loop.run_in_executor(None, self._cancellable_query, llm, prompt, [], llm_model, True, sid, web_search)
448 logger.info(f"[ScriptWriter] Initial script generated ({len(full_script_text)} chars)")
449 full_script_text = await _trim_script_if_needed(full_script_text, "初稿")
450 full_script_text = await _repair_episode_count_if_needed(full_script_text, "初稿")
451
452 _log_progress(20, "正在进行台词评估...")
453 eval_dialogue_prompt = _get_script_prompt("eval_dialogue", "zh" if is_zh else "en").format(script_text=full_script_text)
454 dialogue_critique = await loop.run_in_executor(None, self._cancellable_query, llm, eval_dialogue_prompt, [], llm_model, True, sid, web_search)
455
456 _log_progress(30, "正在进行情节评估...")
457 eval_plot_prompt = _get_script_prompt("eval_plot", "zh" if is_zh else "en").format(script_text=full_script_text)
458 plot_critique = await loop.run_in_executor(None, self._cancellable_query, llm, eval_plot_prompt, [], llm_model, True, sid, web_search)
459
460 _log_progress(40, "正在根据评估意见优化剧本...")
461 revise_prompt = _get_script_prompt("revise_script", "zh" if is_zh else "en").format(
462 script_text=full_script_text,
463 dialogue_critique=dialogue_critique,
464 plot_critique=plot_critique
465 )
466 full_script_text = await loop.run_in_executor(None, self._cancellable_query, llm, revise_prompt, [], llm_model, True, sid, web_search)
467 logger.info(f"[ScriptWriter] Final script generated ({len(full_script_text)} chars)")
468 full_script_text = await _trim_script_if_needed(full_script_text, "优化后")
469 full_script_text = await _repair_episode_count_if_needed(full_script_text, "优化后")
470
471 _log_progress(60, "最终剧本生成完成,正在提取人物/场景信息...")
472
473 # 2. Extract meta data -> total_episodes, characters, settings
474 meta_prompt = _get_script_prompt("meta_extract", "zh" if is_zh else "en").format(script_text=full_script_text, outline=full_script_text)
475 meta_raw = await loop.run_in_executor(None, self._cancellable_query, llm, meta_prompt, [], llm_model, True, sid, web_search)
476 meta_res = self._extract_json_from_text(meta_raw)
477 meta_data = meta_res if isinstance(meta_res, dict) else {}
478
479 all_characters = meta_data.get("characters", [])
480 all_settings = meta_data.get("settings", [])
481 for c in all_characters:
482 c["character_id"] = c.get("character_id") or self._gen_id("char")
483 for s in all_settings:
484 s["setting_id"] = s.get("setting_id") or self._gen_id("set")
485
486 asset_chars_str = json.dumps([{"name": c.get("name"), "description": c.get("description"), "role": c.get("role")} for c in all_characters], ensure_ascii=False)
487 asset_sets_str = json.dumps([{"name": s.get("name"), "description": s.get("description")} for s in all_settings], ensure_ascii=False)
488
489 # 3. 解析各集数据 - 针对新版数组输出格式进行优化
490 _log_progress(80, "开始结构化全集数据...")
491
492 extract_prompt = _get_script_prompt("act_extract", "zh" if is_zh else "en").format(
493 script_text=full_script_text,
494 episodes=episodes,
495 )
496
497 raw_acts = await loop.run_in_executor(None, self._cancellable_query, llm, extract_prompt, [], llm_model, True, sid, web_search)
498 parsed_acts = self._extract_json_from_text(raw_acts)
499
500 all_episodes = []
501 if isinstance(parsed_acts, list):
502 for act in parsed_acts:
503 if isinstance(act, dict):
504 all_episodes.append({
505 "episode_number": act.get("episode_number"),
506 "act_title": act.get("act_title") or f"第{act.get('episode_number')}集",
507 "content": act.get("content", "")
508 })
509
510 if not all_episodes:
511 logger.error(f"[ScriptWriter] Failed to parse episodes from LLM output. Raw: {raw_acts[:200]}...")
512 expected_numbers = self._expected_episode_numbers(episodes)
513 parsed_numbers = sorted({
514 int(ep.get("episode_number"))
515 for ep in all_episodes
516 if isinstance(ep.get("episode_number"), int) or str(ep.get("episode_number", "")).isdigit()
517 })
518 if parsed_numbers != expected_numbers:
519 logger.warning(
520 "[ScriptWriter] Structured episodes mismatch; expected=%s parsed=%s. Falling back to regex split.",
521 expected_numbers,
522 parsed_numbers,
523 )
524 fallback_episodes = self._build_episodes_from_script_text(full_script_text, episodes)
525 fallback_numbers = [ep["episode_number"] for ep in fallback_episodes]
526 if fallback_numbers == expected_numbers:
527 all_episodes = fallback_episodes
528 else:
529 logger.error(
530 "[ScriptWriter] Regex split still mismatched; expected=%s fallback=%s. Keeping parsed output.",
531 expected_numbers,
532 fallback_numbers,
533 )
534
535 final_json = {
536 "project_id": f"proj_{sid}",
537 "session_id": sid,
538 "version": 1,
539 "created_at": datetime.now(timezone.utc).isoformat(),
540 "meta": {
541 "generation_model": llm_model,
542 "generation_prompt": idea,
543 "original_text": full_script_text
544 },
545 "title": meta_data.get("title", "Generated Script"),
546 "logline": meta_data.get("logline", ""),
547 "genre": meta_data.get("genre", []),
548 "mood": meta_data.get("mood", ""),
549 "characters": all_characters,
550 "settings": all_settings,
551 "episodes": all_episodes
552 }
553
554 self._save_result(final_json, sid, is_zh)
555 _log_progress(100, "剧本结构化解析完成!")
556 return final_json
557
558 result = await run_logic()
559 return {"payload": result, "requires_intervention": False, "stage_completed": True}
560
560 lines PYTHON