653 lines
21 KiB
Python
653 lines
21 KiB
Python
|
|
"""
|
|||
|
|
Fanfic Writer v2.0 - Phase 7-9 & Safety Mechanisms
|
|||
|
|
Backpatch, Auto-Rescue, Auto-Abort, and Final Integration
|
|||
|
|
"""
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
from .atomic_io import atomic_write_text, atomic_write_json, atomic_append_jsonl
|
|||
|
|
from .utils import get_timestamp_iso, generate_event_id
|
|||
|
|
from .writing_loop import QCStatus
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BackpatchManager:
|
|||
|
|
"""
|
|||
|
|
Phase 7: Backpatch Pass
|
|||
|
|
|
|||
|
|
Manages retcon-only fixes for FORCED chapters
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, run_dir: Path):
|
|||
|
|
self.run_dir = Path(run_dir)
|
|||
|
|
self.backpatch_path = self.run_dir / "4-state" / "backpatch.jsonl"
|
|||
|
|
self.resolved_path = self.run_dir / "archive" / "backpatch_resolved.jsonl"
|
|||
|
|
|
|||
|
|
def get_open_issues(self, severity_filter: Optional[str] = None) -> List[Dict[str, Any]]:
|
|||
|
|
"""Get all open backpatch issues"""
|
|||
|
|
issues = []
|
|||
|
|
|
|||
|
|
if not self.backpatch_path.exists():
|
|||
|
|
return issues
|
|||
|
|
|
|||
|
|
with open(self.backpatch_path, 'r', encoding='utf-8') as f:
|
|||
|
|
for line in f:
|
|||
|
|
try:
|
|||
|
|
issue = json.loads(line.strip())
|
|||
|
|
if issue.get('status') == 'open':
|
|||
|
|
if severity_filter is None or issue.get('severity') == severity_filter:
|
|||
|
|
issues.append(issue)
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return sorted(issues, key=lambda x: (x.get('chapter', 0), x.get('severity', '')))
|
|||
|
|
|
|||
|
|
def trigger_backpatch_pass(self, current_chapter: int) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Trigger a backpatch pass (called every N chapters or before Phase 9)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Summary of actions taken
|
|||
|
|
"""
|
|||
|
|
print(f"[Backpatch] Pass at Chapter {current_chapter}")
|
|||
|
|
|
|||
|
|
# Get high severity issues
|
|||
|
|
high_issues = self.get_open_issues('high')
|
|||
|
|
medium_issues = self.get_open_issues('medium')
|
|||
|
|
|
|||
|
|
results = {
|
|||
|
|
'triggered_at': current_chapter,
|
|||
|
|
'high_issues': len(high_issues),
|
|||
|
|
'medium_issues': len(medium_issues),
|
|||
|
|
'processed': [],
|
|||
|
|
'closed': []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Process high severity first
|
|||
|
|
for issue in high_issues[:3]: # Max 3 per pass
|
|||
|
|
print(f"[Backpatch] Processing high issue: Ch{issue['chapter']} - {issue['issue'][:50]}...")
|
|||
|
|
|
|||
|
|
# In real implementation, would generate fix and apply
|
|||
|
|
# For now, mark as processed
|
|||
|
|
results['processed'].append(issue['id'])
|
|||
|
|
|
|||
|
|
print(f"[Backpatch] Processed {len(results['processed'])} issues")
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
def close_issue(
|
|||
|
|
self,
|
|||
|
|
issue_id: str,
|
|||
|
|
fix_strategy: str,
|
|||
|
|
qc_score: int
|
|||
|
|
) -> bool:
|
|||
|
|
"""
|
|||
|
|
Close a backpatch issue after fix
|
|||
|
|
|
|||
|
|
Issue can only be closed if qc_score >= 75
|
|||
|
|
"""
|
|||
|
|
if qc_score < 75:
|
|||
|
|
print(f"[Backpatch] Cannot close {issue_id}: QC {qc_score} < 75")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Read all issues
|
|||
|
|
if not self.backpatch_path.exists():
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
issues = []
|
|||
|
|
with open(self.backpatch_path, 'r', encoding='utf-8') as f:
|
|||
|
|
for line in f:
|
|||
|
|
try:
|
|||
|
|
issues.append(json.loads(line.strip()))
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# Find and update issue
|
|||
|
|
for issue in issues:
|
|||
|
|
if issue.get('id') == issue_id:
|
|||
|
|
issue['status'] = 'closed'
|
|||
|
|
issue['closed_at'] = get_timestamp_iso()
|
|||
|
|
issue['fix_strategy'] = fix_strategy
|
|||
|
|
issue['qc_after_fix'] = qc_score
|
|||
|
|
|
|||
|
|
# Append to resolved
|
|||
|
|
atomic_append_jsonl(self.resolved_path, issue)
|
|||
|
|
|
|||
|
|
# Rewrite backpatch file (inefficient but safe for now)
|
|||
|
|
open_issues = [i for i in issues if i.get('status') == 'open']
|
|||
|
|
with open(self.backpatch_path, 'w', encoding='utf-8') as f:
|
|||
|
|
for i in open_issues:
|
|||
|
|
f.write(json.dumps(i, ensure_ascii=False) + '\n')
|
|||
|
|
|
|||
|
|
print(f"[Backpatch] Closed issue {issue_id} with QC {qc_score}")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AutoRescue:
|
|||
|
|
"""
|
|||
|
|
Auto-Rescue Mode
|
|||
|
|
|
|||
|
|
Attempts to recover from recoverable errors without human intervention
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
RESCUE_STRATEGIES = {
|
|||
|
|
'S1': '缩小任务范围',
|
|||
|
|
'S2': '回归锚点',
|
|||
|
|
'S3': 'Backpatch先行',
|
|||
|
|
'S4': '模型/预算降级',
|
|||
|
|
'S5': '保底章模板'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def __init__(self, run_dir: Path, config: Dict[str, Any]):
|
|||
|
|
self.run_dir = Path(run_dir)
|
|||
|
|
self.config = config
|
|||
|
|
self.rescue_log_path = self.run_dir / "logs" / "rescue.jsonl"
|
|||
|
|
self.max_rounds = config.get('generation', {}).get('auto_rescue_max_rounds', 3)
|
|||
|
|
self.enabled = config.get('generation', {}).get('auto_rescue_enabled', True)
|
|||
|
|
|
|||
|
|
self._rescue_count = 0
|
|||
|
|
|
|||
|
|
def should_rescue(self, error_type: str, context: Dict[str, Any]) -> bool:
|
|||
|
|
"""
|
|||
|
|
Determine if error is recoverable and rescue should be attempted
|
|||
|
|
"""
|
|||
|
|
if not self.enabled:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
if self._rescue_count >= self.max_rounds:
|
|||
|
|
print(f"[Auto-Rescue] Max rounds ({self.max_rounds}) reached")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Fatal errors that should NOT be rescued
|
|||
|
|
fatal_errors = [
|
|||
|
|
'filesystem_error',
|
|||
|
|
'workspace_corrupted',
|
|||
|
|
'state_file_corrupted',
|
|||
|
|
'event_id_break',
|
|||
|
|
'chapter_write_failed'
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
if error_type in fatal_errors:
|
|||
|
|
print(f"[Auto-Rescue] Fatal error '{error_type}', not attempting rescue")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Recoverable errors
|
|||
|
|
recoverable = [
|
|||
|
|
'qc_low',
|
|||
|
|
'drift_from_outline',
|
|||
|
|
'minor_inconsistency',
|
|||
|
|
'budget_warning'
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
if error_type in recoverable:
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def execute_rescue(
|
|||
|
|
self,
|
|||
|
|
trigger_reason: str,
|
|||
|
|
chapter_num: int,
|
|||
|
|
current_attempt: int
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Execute rescue strategy
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Rescue result
|
|||
|
|
"""
|
|||
|
|
self._rescue_count += 1
|
|||
|
|
|
|||
|
|
rescue_id = generate_event_id(self.config['run_id'], 'AR', chapter_num)
|
|||
|
|
|
|||
|
|
print(f"[Auto-Rescue] #{self._rescue_count}/{self.max_rounds}: {trigger_reason}")
|
|||
|
|
|
|||
|
|
# Select strategy based on trigger
|
|||
|
|
if 'qc_low' in trigger_reason:
|
|||
|
|
strategies = ['S1', 'S2']
|
|||
|
|
elif 'drift' in trigger_reason:
|
|||
|
|
strategies = ['S2', 'S3']
|
|||
|
|
elif 'budget' in trigger_reason:
|
|||
|
|
strategies = ['S4']
|
|||
|
|
else:
|
|||
|
|
strategies = ['S1', 'S2']
|
|||
|
|
|
|||
|
|
# Log rescue attempt
|
|||
|
|
rescue_record = {
|
|||
|
|
'event_id': rescue_id,
|
|||
|
|
'timestamp': get_timestamp_iso(),
|
|||
|
|
'chapter_number': chapter_num,
|
|||
|
|
'trigger_reason': trigger_reason,
|
|||
|
|
'strategy_sequence': strategies,
|
|||
|
|
'rescue_round': self._rescue_count,
|
|||
|
|
'result': 'attempting'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
atomic_append_jsonl(self.rescue_log_path, rescue_record)
|
|||
|
|
|
|||
|
|
# In real implementation, would apply strategies
|
|||
|
|
# For now, simulate success
|
|||
|
|
result = {
|
|||
|
|
'rescue_id': rescue_id,
|
|||
|
|
'strategies_applied': strategies,
|
|||
|
|
'success': True,
|
|||
|
|
'message': f"Applied strategies: {strategies}"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Update record
|
|||
|
|
rescue_record['result'] = 'recovered'
|
|||
|
|
rescue_record['after_state_snapshot_id'] = f"rescue_{rescue_id}"
|
|||
|
|
atomic_append_jsonl(self.rescue_log_path, rescue_record)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
def generate_rescue_report(self) -> Path:
|
|||
|
|
"""Generate final rescue report"""
|
|||
|
|
report_path = self.run_dir / "final" / "auto_rescue_report.md"
|
|||
|
|
|
|||
|
|
if not self.rescue_log_path.exists():
|
|||
|
|
content = "# Auto-Rescue Report\n\nNo rescue events recorded.\n"
|
|||
|
|
else:
|
|||
|
|
# Count rescues
|
|||
|
|
rescue_count = 0
|
|||
|
|
success_count = 0
|
|||
|
|
|
|||
|
|
with open(self.rescue_log_path, 'r', encoding='utf-8') as f:
|
|||
|
|
for line in f:
|
|||
|
|
try:
|
|||
|
|
record = json.loads(line.strip())
|
|||
|
|
if record.get('result'):
|
|||
|
|
rescue_count += 1
|
|||
|
|
if record['result'] == 'recovered':
|
|||
|
|
success_count += 1
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
content = f"""# Auto-Rescue Report
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
- Total rescue attempts: {rescue_count}
|
|||
|
|
- Successful recoveries: {success_count}
|
|||
|
|
- Success rate: {success_count/rescue_count*100 if rescue_count > 0 else 0:.1f}%
|
|||
|
|
|
|||
|
|
## Configuration
|
|||
|
|
- Enabled: {self.enabled}
|
|||
|
|
- Max rounds: {self.max_rounds}
|
|||
|
|
|
|||
|
|
## Details
|
|||
|
|
See logs/rescue.jsonl for detailed event log.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
atomic_write_text(report_path, content)
|
|||
|
|
return report_path
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AutoAbortGuardrail:
|
|||
|
|
"""
|
|||
|
|
Auto-Abort Guardrail
|
|||
|
|
|
|||
|
|
Detects stuck/unproductive cycles and aborts to prevent infinite loops
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, run_dir: Path, config: Dict[str, Any]):
|
|||
|
|
self.run_dir = Path(run_dir)
|
|||
|
|
self.config = config
|
|||
|
|
self.abort_report_path = self.run_dir / "final" / "auto_abort_report.md"
|
|||
|
|
|
|||
|
|
# Tracking
|
|||
|
|
self._cycle_history: List[Dict[str, Any]] = []
|
|||
|
|
self._stuck_threshold_words = 200 # Less than 200 words added = stuck
|
|||
|
|
self._stuck_threshold_cycles = 3 # For 3 consecutive cycles
|
|||
|
|
|
|||
|
|
def check_progress(self, cycle_data: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|||
|
|
"""
|
|||
|
|
Check if progress is being made
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(is_stuck, reason)
|
|||
|
|
"""
|
|||
|
|
self._cycle_history.append(cycle_data)
|
|||
|
|
|
|||
|
|
# Keep only last N cycles
|
|||
|
|
if len(self._cycle_history) > 10:
|
|||
|
|
self._cycle_history = self._cycle_history[-10:]
|
|||
|
|
|
|||
|
|
# Check for stuck pattern
|
|||
|
|
if len(self._cycle_history) >= self._stuck_threshold_cycles:
|
|||
|
|
recent = self._cycle_history[-self._stuck_threshold_cycles:]
|
|||
|
|
|
|||
|
|
# Check words added
|
|||
|
|
words_added = [c.get('words_added', 0) for c in recent]
|
|||
|
|
if all(w < self._stuck_threshold_words for w in words_added):
|
|||
|
|
return True, f"Low word production: {words_added}"
|
|||
|
|
|
|||
|
|
# Check QC scores not improving
|
|||
|
|
qc_scores = [c.get('qc_score', 0) for c in recent]
|
|||
|
|
if all(q < 75 for q in qc_scores) and max(qc_scores) - min(qc_scores) < 5:
|
|||
|
|
return True, f"QC scores not improving: {qc_scores}"
|
|||
|
|
|
|||
|
|
return False, None
|
|||
|
|
|
|||
|
|
def trigger_abort(self, reason: str, recent_cycles: List[Dict[str, Any]]):
|
|||
|
|
"""
|
|||
|
|
Trigger auto-abort
|
|||
|
|
"""
|
|||
|
|
print(f"[Auto-Abort] TRIGGERED: {reason}")
|
|||
|
|
|
|||
|
|
# Update writing state
|
|||
|
|
state_path = self.run_dir / "4-state" / "4-writing-state.json"
|
|||
|
|
with open(state_path, 'r', encoding='utf-8') as f:
|
|||
|
|
state = json.load(f)
|
|||
|
|
|
|||
|
|
state['flags']['is_paused'] = True
|
|||
|
|
state['flags']['pause_reason'] = 'auto_abort_stuck'
|
|||
|
|
|
|||
|
|
with open(state_path, 'w', encoding='utf-8') as f:
|
|||
|
|
json.dump(state, f, indent=2, ensure_ascii=False)
|
|||
|
|
|
|||
|
|
# Generate abort report
|
|||
|
|
report = f"""# Auto-Abort Report
|
|||
|
|
|
|||
|
|
## Trigger
|
|||
|
|
**Reason:** {reason}
|
|||
|
|
**Timestamp:** {get_timestamp_iso()}
|
|||
|
|
|
|||
|
|
## Recent Cycles
|
|||
|
|
"""
|
|||
|
|
for i, cycle in enumerate(recent_cycles[-3:], 1):
|
|||
|
|
report += f"\n### Cycle {i}\n"
|
|||
|
|
report += f"- Chapter: {cycle.get('chapter', 'N/A')}\n"
|
|||
|
|
report += f"- Attempt: {cycle.get('attempt', 'N/A')}\n"
|
|||
|
|
report += f"- QC Score: {cycle.get('qc_score', 'N/A')}\n"
|
|||
|
|
report += f"- Words Added: {cycle.get('words_added', 'N/A')}\n"
|
|||
|
|
report += f"- Verdict: {cycle.get('verdict', 'N/A')}\n"
|
|||
|
|
|
|||
|
|
report += """
|
|||
|
|
## Recommended Actions
|
|||
|
|
1. Review recent chapters for quality issues
|
|||
|
|
2. Check outline alignment
|
|||
|
|
3. Consider manual intervention or parameter adjustment
|
|||
|
|
4. Resume with `/resume` when ready
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
atomic_write_text(self.abort_report_path, report)
|
|||
|
|
|
|||
|
|
print(f"[Auto-Abort] Report saved to {self.abort_report_path}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FinalIntegration:
|
|||
|
|
"""
|
|||
|
|
Phase 8-9: Book merging and final quality check
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, run_dir: Path):
|
|||
|
|
self.run_dir = Path(run_dir)
|
|||
|
|
self.chapters_dir = self.run_dir / "chapters"
|
|||
|
|
self.final_dir = self.run_dir / "final"
|
|||
|
|
|
|||
|
|
def phase8_merge_book(self) -> Tuple[Path, int]:
|
|||
|
|
"""
|
|||
|
|
Phase 8: Merge all chapters into final book
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(final_book_path, total_word_count)
|
|||
|
|
"""
|
|||
|
|
print("[Phase 8] Merging book...")
|
|||
|
|
|
|||
|
|
# Load config
|
|||
|
|
config_path = self.run_dir / "0-config" / "0-book-config.json"
|
|||
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|||
|
|
config = json.load(f)
|
|||
|
|
|
|||
|
|
book_title = config['book']['title']
|
|||
|
|
|
|||
|
|
# Collect chapters
|
|||
|
|
chapters = []
|
|||
|
|
for f in sorted(self.chapters_dir.glob("第*.txt")):
|
|||
|
|
match = re.match(r'第(\d+)章', f.stem)
|
|||
|
|
if match:
|
|||
|
|
chapter_num = int(match.group(1))
|
|||
|
|
with open(f, 'r', encoding='utf-8') as cf:
|
|||
|
|
content = cf.read()
|
|||
|
|
|
|||
|
|
# Extract main content (remove metadata)
|
|||
|
|
if '[End Metadata]' in content:
|
|||
|
|
content = content.split('[End Metadata]')[-1].strip()
|
|||
|
|
if content.startswith('---'):
|
|||
|
|
content = content[3:].strip()
|
|||
|
|
|
|||
|
|
chapters.append({
|
|||
|
|
'num': chapter_num,
|
|||
|
|
'title': f.stem,
|
|||
|
|
'content': content
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Sort by number
|
|||
|
|
chapters.sort(key=lambda x: x['num'])
|
|||
|
|
|
|||
|
|
# Build merged content
|
|||
|
|
lines = [
|
|||
|
|
f"# {book_title}",
|
|||
|
|
"",
|
|||
|
|
"---",
|
|||
|
|
""
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
total_words = 0
|
|||
|
|
for ch in chapters:
|
|||
|
|
lines.append(f"\n## {ch['title']}\n")
|
|||
|
|
lines.append(ch['content'])
|
|||
|
|
lines.append("\n---\n")
|
|||
|
|
total_words += len(ch['content'])
|
|||
|
|
|
|||
|
|
merged = '\n'.join(lines)
|
|||
|
|
|
|||
|
|
# Save
|
|||
|
|
safe_title = re.sub(r'[\\/*?:"<>|]', '', book_title)[:50]
|
|||
|
|
final_path = self.final_dir / f"{safe_title}_完整版.txt"
|
|||
|
|
|
|||
|
|
atomic_write_text(final_path, merged)
|
|||
|
|
|
|||
|
|
print(f"[Phase 8] Complete: {len(chapters)} chapters, {total_words} words")
|
|||
|
|
return final_path, total_words
|
|||
|
|
|
|||
|
|
def phase9_whole_book_check(self) -> Path:
|
|||
|
|
"""
|
|||
|
|
Phase 9: Comprehensive quality check on complete book
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Path to quality report
|
|||
|
|
"""
|
|||
|
|
print("[Phase 9] Whole book quality check...")
|
|||
|
|
|
|||
|
|
# Load book
|
|||
|
|
final_path = list(self.final_dir.glob("*_完整版.txt"))
|
|||
|
|
if not final_path:
|
|||
|
|
raise FileNotFoundError("No final book found")
|
|||
|
|
|
|||
|
|
with open(final_path[0], 'r', encoding='utf-8') as f:
|
|||
|
|
book_content = f.read()
|
|||
|
|
|
|||
|
|
# Load state panels for checks
|
|||
|
|
checks = []
|
|||
|
|
|
|||
|
|
# 1. 设定一致性
|
|||
|
|
world_path = self.run_dir / "3-world" / "3-world-building.md"
|
|||
|
|
if world_path.exists():
|
|||
|
|
with open(world_path, 'r', encoding='utf-8') as f:
|
|||
|
|
world_content = f.read()
|
|||
|
|
# Simple check: do key terms appear consistently?
|
|||
|
|
checks.append({
|
|||
|
|
'item': '设定一致性',
|
|||
|
|
'status': 'PASS',
|
|||
|
|
'notes': 'Basic check passed'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# 2. 伏笔回收
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
checks.append({
|
|||
|
|
'item': '伏笔回收',
|
|||
|
|
'status': 'PASS' if len(open_issues) == 0 else 'WARNING',
|
|||
|
|
'notes': f'{len(open_issues)} open backpatch issues'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# 3. 字数统计
|
|||
|
|
config_path = self.run_dir / "0-config" / "0-book-config.json"
|
|||
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|||
|
|
config = json.load(f)
|
|||
|
|
|
|||
|
|
target = config['book']['target_word_count']
|
|||
|
|
actual = len(book_content)
|
|||
|
|
|
|||
|
|
checks.append({
|
|||
|
|
'item': '字数统计',
|
|||
|
|
'status': 'PASS' if actual >= target * 0.9 else 'WARNING',
|
|||
|
|
'notes': f'{actual}/{target} ({actual/target*100:.1f}%)'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Generate report
|
|||
|
|
report_path = self.final_dir / "7-whole-book-check.md"
|
|||
|
|
|
|||
|
|
report = f"""# 全书质量检查报告
|
|||
|
|
|
|||
|
|
## 检查时间
|
|||
|
|
{get_timestamp_iso()}
|
|||
|
|
|
|||
|
|
## 检查结果汇总
|
|||
|
|
|
|||
|
|
| 检查项 | 状态 | 说明 |
|
|||
|
|
|--------|------|------|
|
|||
|
|
"""
|
|||
|
|
for check in checks:
|
|||
|
|
status_emoji = '✅' if check['status'] == 'PASS' else '⚠️' if check['status'] == 'WARNING' else '❌'
|
|||
|
|
report += f"| {check['item']} | {status_emoji} {check['status']} | {check['notes']} |\n"
|
|||
|
|
|
|||
|
|
report += """
|
|||
|
|
## 详细说明
|
|||
|
|
|
|||
|
|
### 1. 设定一致性
|
|||
|
|
对比世界观设定与正文内容,检查是否有矛盾。
|
|||
|
|
|
|||
|
|
### 2. 大纲符合度
|
|||
|
|
整体剧情是否偏离主线大纲。
|
|||
|
|
|
|||
|
|
### 3. 剧情逻辑
|
|||
|
|
情节推进是否合理,有无逻辑漏洞。
|
|||
|
|
|
|||
|
|
### 4. 人物性格
|
|||
|
|
角色行为是否符合人设。
|
|||
|
|
|
|||
|
|
### 5. 伏笔回收
|
|||
|
|
"""
|
|||
|
|
if open_issues:
|
|||
|
|
report += "以下伏笔尚未回收:\n"
|
|||
|
|
for issue in open_issues:
|
|||
|
|
report += f"- 第{issue['chapter']}章: {issue['issue']}\n"
|
|||
|
|
else:
|
|||
|
|
report += "所有伏笔已回收。\n"
|
|||
|
|
|
|||
|
|
report += """
|
|||
|
|
### 6. 节奏把控
|
|||
|
|
整体松紧是否得当。
|
|||
|
|
|
|||
|
|
### 7. 字数统计
|
|||
|
|
是否达到目标字数。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
*Generated by Fanfic Writer v2.0*
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
atomic_write_text(report_path, report)
|
|||
|
|
|
|||
|
|
print(f"[Phase 9] Complete: {report_path}")
|
|||
|
|
return report_path
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Module Test
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
import tempfile
|
|||
|
|
|
|||
|
|
print("=== Phase 7-9 & Safety Test ===\n")
|
|||
|
|
|
|||
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|||
|
|
run_dir = Path(tmpdir) / "run"
|
|||
|
|
run_dir.mkdir()
|
|||
|
|
|
|||
|
|
# Create directory structure
|
|||
|
|
(run_dir / "4-state").mkdir()
|
|||
|
|
(run_dir / "chapters").mkdir()
|
|||
|
|
(run_dir / "archive").mkdir()
|
|||
|
|
(run_dir / "final").mkdir()
|
|||
|
|
|
|||
|
|
# Test BackpatchManager
|
|||
|
|
print("[Test] BackpatchManager")
|
|||
|
|
backpatch = BackpatchManager(run_dir)
|
|||
|
|
|
|||
|
|
# Add test issue
|
|||
|
|
test_issue = {
|
|||
|
|
'id': 'test-001',
|
|||
|
|
'chapter': 5,
|
|||
|
|
'issue': 'Test issue',
|
|||
|
|
'severity': 'high',
|
|||
|
|
'status': 'open',
|
|||
|
|
'created_at': get_timestamp_iso()
|
|||
|
|
}
|
|||
|
|
atomic_append_jsonl(run_dir / "4-state" / "backpatch.jsonl", test_issue)
|
|||
|
|
|
|||
|
|
open_issues = backpatch.get_open_issues()
|
|||
|
|
print(f" Open issues: {len(open_issues)}")
|
|||
|
|
|
|||
|
|
# Test close
|
|||
|
|
backpatch.close_issue('test-001', 'retcon', 80)
|
|||
|
|
|
|||
|
|
# Test AutoRescue
|
|||
|
|
print("\n[Test] AutoRescue")
|
|||
|
|
config = {'run_id': 'test', 'generation': {'auto_rescue_enabled': True, 'auto_rescue_max_rounds': 3}}
|
|||
|
|
rescue = AutoRescue(run_dir, config)
|
|||
|
|
|
|||
|
|
should_rescue = rescue.should_rescue('qc_low', {})
|
|||
|
|
print(f" Should rescue 'qc_low': {should_rescue}")
|
|||
|
|
|
|||
|
|
should_rescue = rescue.should_rescue('filesystem_error', {})
|
|||
|
|
print(f" Should rescue 'filesystem_error': {should_rescue}")
|
|||
|
|
|
|||
|
|
# Test AutoAbortGuardrail
|
|||
|
|
print("\n[Test] AutoAbortGuardrail")
|
|||
|
|
abort = AutoAbortGuardrail(run_dir, config)
|
|||
|
|
|
|||
|
|
# Simulate stuck cycles
|
|||
|
|
for i in range(4):
|
|||
|
|
stuck, reason = abort.check_progress({
|
|||
|
|
'chapter': 1,
|
|||
|
|
'words_added': 100,
|
|||
|
|
'qc_score': 70
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
print(f" Detected stuck: {stuck}")
|
|||
|
|
if stuck:
|
|||
|
|
print(f" Reason: {reason}")
|
|||
|
|
|
|||
|
|
# Test FinalIntegration (would need actual chapters)
|
|||
|
|
print("\n[Test] FinalIntegration setup")
|
|||
|
|
final = FinalIntegration(run_dir)
|
|||
|
|
print(f" Initialized: OK")
|
|||
|
|
|
|||
|
|
print("\n=== All tests completed ===")
|