novel-doomsday-resurgence/skills/fanfic-writer/scripts/v2/phase_runner.py

601 lines
19 KiB
Python
Raw Normal View History

"""
Fanfic Writer v2.0 - Phase Runner (Phases 1-5)
Orchestrates initialization through worldbuilding
"""
import json
import os
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Callable
from datetime import datetime
from .workspace import WorkspaceManager, generate_intent_checklist
from .config_manager import ConfigManager
from .state_manager import StateManager
from .prompt_registry import PromptRegistry
from .price_table import PriceTableManager, CostBudgetManager
from .resume_manager import RunLock, ResumeManager, RuntimeConfigManager
from .atomic_io import atomic_write_json, atomic_write_text
from .utils import get_timestamp_iso
class PhaseRunner:
"""
Runs Phases 1-5 of the novel writing pipeline:
- Phase 1: Initialization (config, workspace)
- Phase 2: Style Guide
- Phase 3: Main Outline
- Phase 4: Chapter Planning
- Phase 5: World Building
- Phase 5.5: Alignment Check
"""
def __init__(
self,
workspace_manager: WorkspaceManager,
model_callable: Optional[Callable] = None
):
self.workspace = workspace_manager
self.model_callable = model_callable
self._current_run_dir: Optional[Path] = None
self._config_manager: Optional[ConfigManager] = None
self._state_manager: Optional[StateManager] = None
# =========================================================================
# Phase 1: Initialization
# =========================================================================
def phase1_initialize(
self,
book_title: str,
genre: str,
target_words: int,
**kwargs
) -> Tuple[Path, str, str]:
"""
Phase 1: Initialize book workspace
Returns:
(run_dir, book_uid, run_id)
"""
print(f"[Phase 1] Initializing: {book_title}")
# Create workspace
run_dir, book_uid, run_id, paths = self.workspace.create_new_book(
book_title=book_title,
genre=genre,
target_words=target_words,
**kwargs
)
self._current_run_dir = run_dir
# Initialize config manager
self._config_manager = ConfigManager(run_dir)
config = self._config_manager.load()
# Generate intent checklist
checklist = generate_intent_checklist(config)
checklist_path = paths['intent_checklist']
atomic_write_json(checklist_path, checklist)
# Initialize price table
price_mgr = PriceTableManager(run_dir)
usd_cny_rate = kwargs.get('usd_cny_rate', 6.90)
price_mgr.initialize(usd_cny_rate=usd_cny_rate)
# Acquire run lock
run_lock = RunLock(run_dir)
lock_success, lock_error = run_lock.acquire(mode=kwargs.get('mode', 'manual'))
if not lock_success:
raise RuntimeError(f"Cannot acquire run lock: {lock_error}")
# Generate runtime effective config
rt_mgr = RuntimeConfigManager(run_dir)
rt_mgr.generate(
cli_args={k: v for k, v in kwargs.items() if v is not None},
env_vars={k: v for k, v in os.environ.items() if k.startswith('FANFIC_')},
config_file={'mode': config['generation']['mode'],
'model': config['generation']['model']},
defaults={'max_attempts': 3, 'auto_threshold': 85}
)
print(f"[Phase 1] Complete: {run_dir}")
return run_dir, book_uid, run_id
# =========================================================================
# Phase 2: Style Guide
# =========================================================================
def phase2_style_guide(
self,
narrative_voice: str = "第三人称限知",
dialogue_style: str = "口语化",
description_density: str = "动作>心理>环境",
humor_tension_balance: str = "70%轻松+30%紧张",
custom_rules: Optional[list] = None
) -> Path:
"""
Phase 2: Generate style guide
Can be auto-generated or use provided values
"""
print("[Phase 2] Generating style guide...")
if not self._current_run_dir:
raise RuntimeError("Must run Phase 1 first")
style_guide_path = self._current_run_dir / "0-config" / "style_guide.md"
# Build style guide content
content_lines = [
f"# Style Guide",
"",
f"## Narrative Voice",
f"{narrative_voice}",
"",
f"## Dialogue Style",
f"{dialogue_style}",
"",
f"## Description Density",
f"{description_density}",
"",
f"## Humor/Tension Balance",
f"{humor_tension_balance}",
"",
"## Forbidden Phrases",
"- \"突然\" (用\"猛然\"或具体动作代替)",
"- \"非常\"\"特别\" (用具体描写代替)",
"- 连续超过3句对话无动作/心理描写",
]
if custom_rules:
content_lines.extend(["", "## Custom Rules"])
for rule in custom_rules:
content_lines.append(f"- {rule}")
content = "\n".join(content_lines)
atomic_write_text(style_guide_path, content)
print(f"[Phase 2] Complete: {style_guide_path}")
return style_guide_path
# =========================================================================
# Phase 3: Main Outline
# =========================================================================
def phase3_main_outline(
self,
outline_content: Optional[str] = None
) -> Path:
"""
Phase 3: Generate or save main outline
If outline_content provided, save it directly.
Otherwise, would call model (placeholder for future)
"""
print("[Phase 3] Main outline...")
if not self._current_run_dir:
raise RuntimeError("Must run Phase 1 first")
outline_path = self._current_run_dir / "1-outline" / "1-main-outline.md"
if outline_content:
# Use provided content
atomic_write_text(outline_path, outline_content)
else:
# Placeholder: would call model
# For now, create template
config = self._config_manager.load()
title = config['book']['title']
genre = config['book']['genre']
template = f"""# {title} - 主线大纲
## 题材
{genre}
## 一句话简介
待填写20字内核心卖点
## 核心卖点
- 卖点1...
- 卖点2...
- 卖点3...
## 世界背景
待填写
## 主要角色
### 主角
- 姓名/代号
- 身份背景
- 性格特点
- 核心目标
## 主线剧情
### 第一卷【卷名】第1-?章)
卷主题
核心冲突
### 第二卷:【卷名】
...
## 关键转折点
1. 第X章...
## 预计完结
待填写
"""
atomic_write_text(outline_path, template)
print(f"[Phase 3] Complete: {outline_path}")
return outline_path
# =========================================================================
# Phase 4: Chapter Planning
# =========================================================================
def phase4_chapter_plan(
self,
chapters_data: Optional[list] = None
) -> Tuple[Path, Path]:
"""
Phase 4: Generate chapter plan and detailed outlines index
Returns:
(chapter_plan_path, chapter_outlines_path)
"""
print("[Phase 4] Chapter planning...")
if not self._current_run_dir:
raise RuntimeError("Must run Phase 1 first")
config = self._config_manager.load()
target_words = config['book']['target_word_count']
chapter_target = config['book']['chapter_target_words']
# Calculate chapter count
estimated_chapters = max(10, target_words // chapter_target)
if chapters_data:
# Use provided data
chapter_plan = {
'total_chapters': len(chapters_data),
'chapters': chapters_data
}
chapter_outlines = {
'chapters': [
{
'chapter_number': c['number'],
'title': c['title'],
'theme': c.get('key_event', ''),
'purpose': c.get('summary', ''),
'word_count_target': c.get('target_words', chapter_target),
'hook': c.get('cliffhanger', '')
}
for c in chapters_data
]
}
else:
# Create template structure
chapter_plan = {
'total_chapters': estimated_chapters,
'chapters': [
{
'chapter_number': i,
'title': f"{i}",
'word_count': chapter_target,
'pacing': 'medium',
'tension': 'medium',
'scene_breakdown': []
}
for i in range(1, min(estimated_chapters + 1, 101))
],
'volume_breakdown': [
{'volume': 1, 'name': '第一卷', 'chapters': f'1-{min(20, estimated_chapters)}'}
]
}
chapter_outlines = {
'chapters': [
{
'chapter_number': i,
'title': f"{i}",
'theme': '待设定',
'purpose': '待设定',
'word_count_target': chapter_target,
'scenes': [],
'characters': [],
'plot_points': [],
'hook': ''
}
for i in range(1, min(estimated_chapters + 1, 101))
]
}
# Save files
plan_path = self._current_run_dir / "2-planning" / "2-chapter-plan.json"
outlines_path = self._current_run_dir / "1-outline" / "5-chapter-outlines.json"
atomic_write_json(plan_path, chapter_plan)
atomic_write_json(outlines_path, chapter_outlines)
print(f"[Phase 4] Complete: {plan_path}, {outlines_path}")
return plan_path, outlines_path
# =========================================================================
# Phase 5: World Building
# =========================================================================
def phase5_world_building(
self,
world_content: Optional[str] = None
) -> Path:
"""
Phase 5: Generate world building document
"""
print("[Phase 5] World building...")
if not self._current_run_dir:
raise RuntimeError("Must run Phase 1 first")
world_path = self._current_run_dir / "3-world" / "3-world-building.md"
if world_content:
atomic_write_text(world_path, world_content)
else:
# Create template
config = self._config_manager.load()
title = config['book']['title']
template = f"""# {title} - 世界观设定
## 世界观
### 时空背景
- 时间
- 空间
- 基本规则
### 势力分布
待填写
### 力量/技能体系
- 体系名称
- 等级划分
- 核心规则
- 限制条件
## 主要角色
### 主角
基础信息
- 姓名
- 年龄
- 外貌特征
性格
- 表层性格
- 深层性格
- 性格缺陷
背景
- 出身
- 关键经历
- 关系网
目标与成长
- 短期目标
- 长期目标
- 成长弧线
## 关键设定
### 重要道具
...
### 重要地点
...
### 关键规则
...
"""
atomic_write_text(world_path, template)
print(f"[Phase 5] Complete: {world_path}")
return world_path
# =========================================================================
# Phase 5.5: Alignment Check
# =========================================================================
def phase5_5_alignment_check(self) -> Tuple[float, Optional[Path]]:
"""
Phase 5.5: Check alignment between intent checklist and world building
Returns:
(deviation_score, warning_path or None)
deviation_score: 0.0 = perfect alignment, 1.0 = completely off
"""
print("[Phase 5.5] Alignment check...")
if not self._current_run_dir:
raise RuntimeError("Must run Phase 1 first")
# Load checklist
checklist_path = self._current_run_dir / "0-config" / "intent_checklist.json"
if not checklist_path.exists():
print("[Phase 5.5] Warning: No checklist found")
return 0.0, None
with open(checklist_path, 'r', encoding='utf-8') as f:
checklist = json.load(f)
# Load world building
world_path = self._current_run_dir / "3-world" / "3-world-building.md"
if not world_path.exists():
print("[Phase 5.5] Warning: No world building found")
return 1.0, None
with open(world_path, 'r', encoding='utf-8') as f:
world_content = f.read()
# Check each item
items = checklist.get('items', [])
failed_items = []
for item in items:
if not item.get('required', False):
continue
# Simple check: see if must_be content is in world_content
must_be = item.get('must_be', '')
if isinstance(must_be, list):
must_be = ' '.join(must_be)
# Very naive check - in real implementation would use more sophisticated matching
if must_be and must_be != '待设定' and len(must_be) > 2:
if must_be not in world_content:
failed_items.append(item)
# Calculate deviation
if not items:
deviation = 0.0
else:
required_items = [i for i in items if i.get('required', False)]
if not required_items:
deviation = 0.0
else:
deviation = len(failed_items) / len(required_items)
# Generate warning if needed
warning_path = None
if deviation >= 0.2: # 20% or more deviation
warning_path = self._current_run_dir / "drafts" / "alignment" / f"alignment-warning_{get_timestamp_iso().replace(':', '-')}.md"
warning_content = f"""# Alignment Warning
Deviation Score: {deviation:.1%}
Failed Items: {len(failed_items)}/{len([i for i in items if i.get('required', False)])}
## Failed Checks
"""
for item in failed_items:
warning_content += f"\n- **{item['name']}**: {item['description']}\n"
warning_content += f" - Expected: {item.get('must_be', 'N/A')}\n"
warning_content += """
## Recommended Actions
1. Review world building against intent checklist
2. Update world building to align with original intent
3. Or update intent checklist if requirements have changed
"""
atomic_write_text(warning_path, warning_content)
print(f"[Phase 5.5] Warning generated: {warning_path}")
print(f"[Phase 5.5] Complete: deviation = {deviation:.1%}")
return deviation, warning_path
# =========================================================================
# Run All Phases 1-5
# =========================================================================
def run_all(
self,
book_title: str,
genre: str,
target_words: int,
**kwargs
) -> Dict[str, Any]:
"""
Run all phases 1-5 in sequence
Returns:
Dict with paths to all generated files
"""
results = {}
# Phase 1
run_dir, book_uid, run_id = self.phase1_initialize(
book_title, genre, target_words, **kwargs
)
results['run_dir'] = run_dir
results['book_uid'] = book_uid
results['run_id'] = run_id
# Phase 2
style_path = self.phase2_style_guide(
**kwargs.get('style_guide', {})
)
results['style_guide'] = style_path
# Phase 3
outline_path = self.phase3_main_outline(
kwargs.get('outline_content')
)
results['main_outline'] = outline_path
# Phase 4
plan_path, outlines_path = self.phase4_chapter_plan(
kwargs.get('chapters_data')
)
results['chapter_plan'] = plan_path
results['chapter_outlines'] = outlines_path
# Phase 5
world_path = self.phase5_world_building(
kwargs.get('world_content')
)
results['world_building'] = world_path
# Phase 5.5
deviation, warning_path = self.phase5_5_alignment_check()
results['alignment_deviation'] = deviation
results['alignment_warning'] = warning_path
print("\n[Phases 1-5] All complete!")
return results
# ============================================================================
# Module Test
# ============================================================================
if __name__ == "__main__":
import tempfile
print("=== Phase Runner Test (Phases 1-5) ===\n")
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir) / "novels"
# Create workspace manager
workspace = WorkspaceManager(base_dir)
# Create phase runner
runner = PhaseRunner(workspace)
# Test full run
results = runner.run_all(
book_title="阴间外卖",
genre="都市灵异",
target_words=100000,
subgenre="系统流",
mode="manual"
)
print(f"\n[Test] Run complete:")
print(f" run_dir: {results['run_dir']}")
print(f" book_uid: {results['book_uid']}")
print(f" style_guide: {results['style_guide'].exists()}")
print(f" main_outline: {results['main_outline'].exists()}")
print(f" chapter_plan: {results['chapter_plan'].exists()}")
print(f" world_building: {results['world_building'].exists()}")
print(f" alignment_deviation: {results['alignment_deviation']:.1%}")
print("\n=== All tests completed ===")