包含: - 核心配置文件(AGENTS.md, SOUL.md, USER.md等) - 记忆系统(memory/文件夹) - 技能库(skills/文件夹) - 小说内容(novel/文件夹) - .gitignore配置
601 lines
19 KiB
Python
601 lines
19 KiB
Python
"""
|
||
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 ===")
|