novel-doomsday-resurgence/skills/fanfic-writer/scripts/v2/phase_runner.py
唐天洛 cb9b16e5a8 初始提交:番茄小说创作工作区
包含:
- 核心配置文件(AGENTS.md, SOUL.md, USER.md等)
- 记忆系统(memory/文件夹)
- 技能库(skills/文件夹)
- 小说内容(novel/文件夹)
- .gitignore配置
2026-03-30 15:46:26 +08:00

601 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 ===")