| 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 |