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

599 lines
21 KiB
Python
Raw Normal View History

"""
Fanfic Writer v2.0 - Writing Loop (Phase 6)
Core writing pipeline with QC, Attempt cycle, and FORCED handling
"""
import json
import re
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple, Callable
from dataclasses import dataclass
from enum import Enum
from .config_manager import ConfigManager
from .state_manager import StateManager, Evidence
from .prompt_assembly import PromptBuilder
from .price_table import PriceTableManager
from .atomic_io import atomic_write_text, atomic_write_json, atomic_append_jsonl
from .utils import get_timestamp_iso, generate_event_id, sanitize_chapter_filename
class QCStatus(Enum):
INIT = "INIT"
PASS = "PASS"
WARNING = "WARNING"
REVISE = "REVISE"
FORCED = "FORCED"
@dataclass
class QCResult:
"""Quality Check result"""
score: int
status: QCStatus
dimensions: Dict[str, int]
pros: List[str]
cons: List[str]
rewrite_plan: str
def to_dict(self) -> Dict[str, Any]:
return {
'score': self.score,
'status': self.status.value,
'dimensions': self.dimensions,
'pros': self.pros,
'cons': self.cons,
'rewrite_plan': self.rewrite_plan
}
class WritingLoop:
"""
Phase 6: Chapter-by-chapter writing with quality control
Flow:
6.1 Sanitizer -> 6.2 Outline Confirm -> 6.3 Draft -> 6.4 QC -> 6.5 Content Confirm -> 6.6 State Commit
"""
def __init__(
self,
run_dir: Path,
model_callable: Callable,
config_manager: Optional[ConfigManager] = None,
state_manager: Optional[StateManager] = None,
prompt_builder: Optional[PromptBuilder] = None
):
self.run_dir = Path(run_dir)
self.model_callable = model_callable
self.config = (config_manager or ConfigManager(run_dir)).load()
self.state = state_manager or StateManager(run_dir)
self.prompt_builder = prompt_builder
self.price_mgr = PriceTableManager(run_dir)
# QC config
qc_config = self.config.get('qc', {})
self.pass_threshold = qc_config.get('pass_threshold', 85)
self.warning_threshold = qc_config.get('warning_threshold', 75)
self.max_attempts = self.config.get('generation', {}).get('max_attempts', 3)
self.mode = self.config.get('generation', {}).get('mode', 'manual')
# Ending state check
self.target_words = self.config.get('book', {}).get('target_word_count', 100000)
self.max_chapters = 200 # Hard limit
def _get_forced_streak(self) -> int:
"""Get current forced_streak value"""
state_path = self.run_dir / "4-state" / "4-writing-state.json"
try:
with open(state_path, 'r', encoding='utf-8') as f:
state = json.load(f)
return state.get('forced_streak', 0)
except:
return 0
# ========================================================================
# 6.1 Sanitizer
# ========================================================================
def sanitizer(self, chapter_num: int) -> Dict[str, Any]:
"""
6.1: State Sanitizer
Reads state panels and extracts invariants for chapter generation
"""
print(f"[6.1] Sanitizer for Chapter {chapter_num}")
# Get invariants
invariants = self.state.get_invariants(chapter_num)
# Check for open backpatch issues
backpatch_path = self.run_dir / "4-state" / "backpatch.jsonl"
open_issues = []
if backpatch_path.exists():
with open(backpatch_path, 'r', encoding='utf-8') as f:
for line in f:
try:
issue = json.loads(line.strip())
if issue.get('status') == 'open':
open_issues.append(issue)
except:
pass
# Build sanitized context
context_parts = ["## 不变量 (Invariants) - 必须延续"]
for char_name, fields in invariants.get('characters', {}).items():
context_parts.append(f"\n### {char_name}")
for field, value in fields.items():
context_parts.append(f"- {field}: {value}")
if open_issues:
context_parts.append("\n## Open Backpatch Issues")
for issue in open_issues[:3]: # Top 3
context_parts.append(f"- 第{issue.get('chapter')}章: {issue.get('issue', '')}")
sanitized_context = "\n".join(context_parts)
# Log sanitizer output
sanitizer_record = {
'timestamp': get_timestamp_iso(),
'chapter': chapter_num,
'invariants_enforced': list(invariants.get('characters', {}).keys()),
'soft_retcons_applied': [],
'reason': '提取不变量用于第{chapter_num}章生成',
'sanitized_context': sanitized_context[:500] # Truncated
}
atomic_append_jsonl(
self.run_dir / "4-state" / "sanitizer_output.jsonl",
sanitizer_record
)
print(f"[6.1] Complete: {len(invariants.get('characters', {}))} characters")
return {
'invariants': invariants,
'sanitized_context': sanitized_context,
'open_backpatch_issues': open_issues
}
# ========================================================================
# 6.2 Outline Generation & Confirm
# ========================================================================
def generate_chapter_outline(
self,
chapter_num: int,
previous_content: str = ""
) -> str:
"""6.2: Generate detailed chapter outline"""
print(f"[6.2] Generate outline for Chapter {chapter_num}")
# Load chapter summary from plan
plan_path = self.run_dir / "2-planning" / "2-chapter-plan.json"
chapter_summary = "(暂无概要)"
chapter_title = f"{chapter_num}"
if plan_path.exists():
with open(plan_path, 'r', encoding='utf-8') as f:
plan = json.load(f)
for ch in plan.get('chapters', []):
if ch.get('chapter_number') == chapter_num:
chapter_summary = ch.get('summary', chapter_summary)
chapter_title = ch.get('title', chapter_title)
break
target_words = self.config['book']['chapter_target_words']
# Build prompt
if self.prompt_builder:
prompt, log_path = self.prompt_builder.build_chapter_outline_prompt(
run_id=self.config['run_id'],
chapter_num=chapter_num,
chapter_title=chapter_title,
chapter_summary=chapter_summary,
previous_content=previous_content,
target_words=target_words
)
else:
# Simple fallback
prompt = f"生成第{chapter_num}章详细大纲"
# Call model (placeholder)
outline = self.model_callable(prompt)
# Save to drafts
outline_path = self.run_dir / "drafts" / "outlines" / f"Ch{chapter_num:03d}_outline_attempt1.md"
atomic_write_text(outline_path, outline)
print(f"[6.2] Complete: outline saved")
return outline
# ========================================================================
# 6.3 Draft Generation
# ========================================================================
def generate_draft(
self,
chapter_num: int,
outline: str,
previous_content: str = "",
attempt: int = 1
) -> str:
"""6.3: Generate chapter draft"""
print(f"[6.3] Generate draft for Chapter {chapter_num} (Attempt {attempt})")
# Split outline into segments if needed
# For now, generate as one piece
target_words = self.config['book']['chapter_target_words']
# Build prompt
if self.prompt_builder:
prompt, log_path = self.prompt_builder.build_chapter_draft_prompt(
run_id=self.config['run_id'],
chapter_num=chapter_num,
chapter_title=f"{chapter_num}",
detailed_outline=outline,
previous_content=previous_content,
segment_summary="本章全部内容",
segment_words=target_words,
is_first_segment=True,
event_id=generate_event_id(self.config['run_id'], '6.3', chapter_num)
)
else:
prompt = f"根据大纲生成第{chapter_num}章正文"
# Call model
draft = self.model_callable(prompt)
# Save to drafts
draft_path = self.run_dir / "drafts" / "chapters" / f"Ch{chapter_num:03d}_draft_attempt{attempt}.md"
atomic_write_text(draft_path, draft)
print(f"[6.3] Complete: {len(draft)} chars")
return draft
# ========================================================================
# 6.4 Quality Check
# ========================================================================
def qc_evaluate(
self,
chapter_num: int,
draft: str,
outline: str,
previous_content: str = ""
) -> QCResult:
"""
6.4: Quality Check with multi-perspective review
"""
print(f"[6.4] QC for Chapter {chapter_num}")
# In real implementation, would call 3 critic models
# For now, placeholder with simple heuristic
# Simple QC: check word count, basic format
word_count = len(draft)
target = self.config['book']['chapter_target_words']
# Calculate base score
score = 80 # Start neutral
# Word count check (+/- 10% is fine)
if abs(word_count - target) / target > 0.1:
score -= 5
# Check for outline elements
if outline[:100] in draft or any(line[:30] in draft for line in outline.split('\n')[:5]):
score -= 10 # Outline leaked into draft
# Multi-perspective simulation
perspectives = ['editor', 'logic', 'continuity']
scores = []
for perspective in perspectives:
# Would call actual critic model here
perspective_score = score + (5 if perspective == 'editor' else 0)
scores.append(perspective_score)
final_score = int(sum(scores) / len(scores))
# Determine status
if final_score >= self.pass_threshold:
status = QCStatus.PASS
elif final_score >= self.warning_threshold:
status = QCStatus.WARNING
else:
status = QCStatus.REVISE
# Build result
result = QCResult(
score=final_score,
status=status,
dimensions={
'outline_adherence': final_score - 5,
'main_plot': final_score,
'character': final_score - 2,
'logic': final_score - 3,
'continuity': final_score - 1,
'pacing': final_score + 2,
'style': final_score
},
pros=[
"章节结构完整",
"情节推进自然" if final_score > 75 else "情节有待加强",
"人物行为符合设定" if final_score > 80 else "人物刻画需改进"
],
cons=[] if status == QCStatus.PASS else [
"建议加强细节描写",
"部分对话可更口语化"
],
rewrite_plan="" if status == QCStatus.PASS else "根据cons进行针对性修改"
)
# Save QC result
qc_path = self.run_dir / "drafts" / "qc" / f"Ch{chapter_num:03d}_qc_attempt1.md"
atomic_write_json(qc_path, result.to_dict())
print(f"[6.4] Complete: score={final_score}, status={status.value}")
return result
# ========================================================================
# Attempt Cycle
# ========================================================================
def attempt_cycle(
self,
chapter_num: int,
outline: str,
previous_content: str = ""
) -> Tuple[str, QCResult, int]:
"""
Run Attempt 1-3 cycle
Returns:
(final_draft, qc_result, attempt_number)
"""
attempt = 1
best_draft = ""
best_result = None
while attempt <= self.max_attempts:
print(f"\n[Attempt {attempt}/{self.max_attempts}]")
# Generate draft
draft = self.generate_draft(
chapter_num, outline, previous_content, attempt
)
# QC
result = self.qc_evaluate(chapter_num, draft, outline, previous_content)
# Track best
if best_result is None or result.score > best_result.score:
best_draft = draft
best_result = result
# Check if passed
if result.status in [QCStatus.PASS, QCStatus.WARNING]:
print(f"[Attempt {attempt}] Passed with score {result.score}")
return draft, result, attempt
# Continue to next attempt
if attempt < self.max_attempts:
print(f"[Attempt {attempt}] Failed ({result.status.value}), retrying...")
# In real implementation, would apply rewrite_plan for targeted refinement
attempt += 1
# All attempts exhausted -> FORCED
print(f"[FORCED] All {self.max_attempts} attempts failed, best score: {best_result.score}")
best_result.status = QCStatus.FORCED
return best_draft, best_result, self.max_attempts
# ========================================================================
# 6.5 Save Chapter
# ========================================================================
def save_chapter(
self,
chapter_num: int,
draft: str,
qc_result: QCResult,
chapter_title: str = ""
) -> Path:
"""6.5: Save chapter to chapters/ directory"""
print(f"[6.5] Save Chapter {chapter_num}")
if not chapter_title:
chapter_title = f"{chapter_num}"
# Determine filename based on QC status
is_forced = qc_result.status == QCStatus.FORCED
filename = sanitize_chapter_filename(chapter_num, chapter_title, is_forced)
chapter_path = self.run_dir / "chapters" / filename
# Add metadata header
metadata = f"""[Chapter Metadata]
Number: {chapter_num}
Title: {chapter_title}
Word Count: {len(draft)}
QC Score: {qc_result.score}
QC Status: {qc_result.status.value}
Saved: {get_timestamp_iso()}
[End Metadata]
---
"""
full_content = metadata + draft
atomic_write_text(chapter_path, full_content)
print(f"[6.5] Complete: {chapter_path}")
return chapter_path
# ========================================================================
# 6.6 State Commit
# ========================================================================
def state_commit(
self,
chapter_num: int,
draft: str,
qc_result: QCResult,
state_changes: Optional[Dict[str, Any]] = None
):
"""6.6: Commit state changes after chapter completion"""
print(f"[6.6] State Commit for Chapter {chapter_num}")
# Update writing state
writing_state_path = self.run_dir / "4-state" / "4-writing-state.json"
with open(writing_state_path, 'r', encoding='utf-8') as f:
writing_state = json.load(f)
writing_state['current_chapter'] = chapter_num
writing_state['completed_chapters'].append(chapter_num)
writing_state['qc_score'] = qc_result.score
writing_state['qc_status'] = qc_result.status.value
writing_state['updated_at'] = get_timestamp_iso()
# Handle forced_streak
if qc_result.status == QCStatus.FORCED:
writing_state['forced_streak'] = writing_state.get('forced_streak', 0) + 1
writing_state['flags']['prev_chapter_forced'] = True
# Add to backpatch
backpatch_record = {
'id': generate_event_id(self.config['run_id'], 'BP', chapter_num),
'chapter': chapter_num,
'issue': f"QC score {qc_result.score} below threshold",
'severity': 'high' if qc_result.score < 70 else 'medium',
'evidence': f"QC result: {qc_result.cons}",
'status': 'open',
'created_at': get_timestamp_iso(),
'closed_at': None,
'fix_strategy': None,
'qc_after_fix': None
}
atomic_append_jsonl(
self.run_dir / "4-state" / "backpatch.jsonl",
backpatch_record
)
else:
writing_state['forced_streak'] = 0
writing_state['flags']['prev_chapter_forced'] = False
# Check forced_streak threshold
if writing_state['forced_streak'] >= 2:
writing_state['flags']['is_paused'] = True
print("[ALERT] forced_streak >= 2, pausing for manual review")
atomic_write_json(writing_state_path, writing_state)
# Commit state changes if provided
if state_changes:
self.state.commit_chapter_state(chapter_num, state_changes)
print(f"[6.6] Complete: forced_streak={writing_state['forced_streak']}")
# ========================================================================
# Write Single Chapter
# ========================================================================
def write_chapter(
self,
chapter_num: int,
previous_content: str = "",
auto_continue: bool = False
) -> Dict[str, Any]:
"""
Write a single chapter through full 6.x pipeline
Returns:
Result dict with paths and status
"""
print(f"\n{'='*50}")
print(f"Writing Chapter {chapter_num}")
print(f"{'='*50}\n")
# 6.1 Sanitizer
sanitizer_result = self.sanitizer(chapter_num)
# 6.2 Generate Outline
outline = self.generate_chapter_outline(chapter_num, previous_content)
# In manual mode, would wait for user confirmation here
if self.mode == 'manual' and not auto_continue:
print("[Manual Mode] Waiting for outline confirmation...")
# Would pause here in real implementation
# 6.3-6.4 Attempt Cycle
draft, qc_result, attempt_num = self.attempt_cycle(
chapter_num, outline, previous_content
)
# 6.5 Save Chapter
chapter_path = self.save_chapter(chapter_num, draft, qc_result)
# 6.6 State Commit
self.state_commit(chapter_num, draft, qc_result)
return {
'chapter_num': chapter_num,
'chapter_path': chapter_path,
'qc_score': qc_result.score,
'qc_status': qc_result.status.value,
'attempt': attempt_num,
'forced_streak': self._get_forced_streak()
}
# ============================================================================
# Module Test
# ============================================================================
if __name__ == "__main__":
import tempfile
print("=== Writing Loop Test (Phase 6) ===\n")
# Mock model callable
def mock_model(prompt: str) -> str:
return "这是生成的内容...\n(模拟模型输出)\n字数填充:" + "内容" * 100
with tempfile.TemporaryDirectory() as tmpdir:
# Setup
from .phase_runner import PhaseRunner, WorkspaceManager
workspace = WorkspaceManager(Path(tmpdir) / "novels")
runner = PhaseRunner(workspace)
# Run phases 1-5
results = runner.run_all(
book_title="测试小说",
genre="都市异能",
target_words=50000,
mode="auto"
)
run_dir = results['run_dir']
# Create writing loop
loop = WritingLoop(
run_dir=run_dir,
model_callable=mock_model
)
# Write chapter 1
result = loop.write_chapter(1)
print(f"\n[Test] Chapter 1 result:")
print(f" Status: {result['qc_status']}")
print(f" Score: {result['qc_score']}")
print(f" Attempt: {result['attempt']}")
print(f" Path: {result['chapter_path'].exists()}")
print("\n=== All tests completed ===")