599 lines
21 KiB
Python
599 lines
21 KiB
Python
|
|
"""
|
||
|
|
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 ===")
|