""" Fanfic Writer v2.0 - State Manager Manages all state panels with evidence-based updates and confidence scoring """ import json from datetime import datetime from pathlib import Path from typing import Dict, Any, List, Optional, Tuple, Union from dataclasses import dataclass, asdict from .atomic_io import atomic_write_json, atomic_append_jsonl from .utils import get_timestamp_iso # ============================================================================ # Evidence Chain Data Structure # ============================================================================ @dataclass class Evidence: """Evidence for a state change""" chapter: str # e.g., "第015章" snippet: str # Text snippet as evidence confidence: float # 0.0 - 1.0 def to_dict(self) -> Dict[str, Any]: return { 'evidence_chapter': self.chapter, 'evidence_snippet': self.snippet, 'confidence': self.confidence } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Evidence': return cls( chapter=data.get('evidence_chapter', ''), snippet=data.get('evidence_snippet', ''), confidence=data.get('confidence', 0.0) ) @dataclass class StateEntry: """A single state entry with value, metadata, and evidence""" value: Any evidence_chapter: str evidence_snippet: str confidence: float update_timestamp: str def to_dict(self) -> Dict[str, Any]: return { 'value': self.value, 'evidence_chapter': self.evidence_chapter, 'evidence_snippet': self.evidence_snippet, 'confidence': self.confidence, 'update_timestamp': self.update_timestamp } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'StateEntry': return cls( value=data.get('value'), evidence_chapter=data.get('evidence_chapter', ''), evidence_snippet=data.get('evidence_snippet', ''), confidence=data.get('confidence', 0.0), update_timestamp=data.get('update_timestamp', get_timestamp_iso()) ) # ============================================================================ # Base State Panel # ============================================================================ class StatePanel: """ Base class for all state panels Implements evidence-based updates with confidence scoring """ CONFIDENCE_THRESHOLD = 0.7 # Below this goes to pending_changes def __init__(self, file_path: Path): self.file_path = Path(file_path) self._data: Optional[Dict[str, Any]] = None self._pending_changes: List[Dict[str, Any]] = [] def load(self) -> Dict[str, Any]: """Load state from file""" if self._data is not None: return self._data if not self.file_path.exists(): self._data = self._create_default() self.save() else: with open(self.file_path, 'r', encoding='utf-8') as f: self._data = json.load(f) return self._data def save(self) -> bool: """Save state to file atomically""" if self._data is None: return False return atomic_write_json(self.file_path, self._data) def _create_default(self) -> Dict[str, Any]: """Create default empty state - override in subclasses""" return { 'entities': {}, 'pending_changes': [], 'last_updated': get_timestamp_iso() } def update_entity( self, entity_name: str, field: str, value: Any, evidence: Evidence ) -> bool: """ Update entity field with evidence If confidence < 0.7, goes to pending_changes instead of active_state """ data = self.load() # Ensure entity exists if 'entities' not in data: data['entities'] = {} if entity_name not in data['entities']: data['entities'][entity_name] = {'values': {}, 'meta': {}} # Check confidence threshold if evidence.confidence < self.CONFIDENCE_THRESHOLD: # Add to pending_changes if 'pending_changes' not in data: data['pending_changes'] = [] data['pending_changes'].append({ 'entity': entity_name, 'field': field, 'proposed_value': value, 'evidence_chapter': evidence.chapter, 'evidence_snippet': evidence.snippet, 'confidence': evidence.confidence, 'timestamp': get_timestamp_iso() }) else: # Update active state entity = data['entities'][entity_name] entity['values'][field] = value entity['meta'][field] = { 'evidence_chapter': evidence.chapter, 'evidence_snippet': evidence.snippet, 'confidence': evidence.confidence, 'update_timestamp': get_timestamp_iso() } data['last_updated'] = get_timestamp_iso() return self.save() def get_entity(self, entity_name: str) -> Optional[Dict[str, Any]]: """Get entity with all its values and metadata""" data = self.load() return data.get('entities', {}).get(entity_name) def get_value(self, entity_name: str, field: str) -> Any: """Get specific field value for entity""" entity = self.get_entity(entity_name) if entity: return entity.get('values', {}).get(field) return None def get_pending_changes(self) -> List[Dict[str, Any]]: """Get list of pending changes waiting for review""" data = self.load() return data.get('pending_changes', []) def confirm_pending_change(self, change_id: int) -> bool: """ Confirm a pending change and move it to active state Args: change_id: Index in pending_changes list """ data = self.load() pending = data.get('pending_changes', []) if change_id < 0 or change_id >= len(pending): return False change = pending[change_id] # Move to active state with boosted confidence entity_name = change['entity'] field = change['field'] value = change['proposed_value'] if 'entities' not in data: data['entities'] = {} if entity_name not in data['entities']: data['entities'][entity_name] = {'values': {}, 'meta': {}} entity = data['entities'][entity_name] entity['values'][field] = value entity['meta'][field] = { 'evidence_chapter': change['evidence_chapter'], 'evidence_snippet': change['evidence_snippet'], 'confidence': 0.85, # Boosted confidence on manual confirm 'update_timestamp': get_timestamp_iso() } # Remove from pending data['pending_changes'].pop(change_id) return self.save() def reject_pending_change(self, change_id: int) -> bool: """Reject and remove a pending change""" data = self.load() pending = data.get('pending_changes', []) if change_id < 0 or change_id >= len(pending): return False pending.pop(change_id) return self.save() def list_entities(self) -> List[str]: """List all entity names""" data = self.load() return list(data.get('entities', {}).keys()) # ============================================================================ # Specialized State Panels # ============================================================================ class CharactersPanel(StatePanel): """Character state panel: motivation, status, injuries, relationships""" def _create_default(self) -> Dict[str, Any]: return { 'entities': {}, # character_name -> {values, meta} 'pending_changes': [], 'last_updated': get_timestamp_iso(), 'panel_type': 'characters' } def update_character_status( self, character_name: str, status: str, # 健康|轻伤|重伤|死亡 evidence: Evidence ) -> bool: """Update character health status""" return self.update_entity(character_name, 'status', status, evidence) def add_injury( self, character_name: str, injury_type: str, chapter: int, evidence: Evidence ) -> bool: """Add injury to character""" data = self.load() if 'entities' not in data: data['entities'] = {} if character_name not in data['entities']: data['entities'][character_name] = {'values': {}, 'meta': {}} entity = data['entities'][character_name] # Initialize injuries list if needed if 'injuries' not in entity['values']: entity['values']['injuries'] = [] injury = { 'type': injury_type, 'chapter': chapter, 'healed': False } entity['values']['injuries'].append(injury) entity['meta']['injuries'] = evidence.to_dict() data['last_updated'] = get_timestamp_iso() return self.save() def update_relationship( self, character_name: str, target_character: str, score: int, # -5 to +5 evidence: Evidence ) -> bool: """Update relationship score between characters""" data = self.load() if 'entities' not in data: data['entities'] = {} if character_name not in data['entities']: data['entities'][character_name] = {'values': {}, 'meta': {}} entity = data['entities'][character_name] if 'relationships' not in entity['values']: entity['values']['relationships'] = {} entity['values']['relationships'][target_character] = max(-5, min(5, score)) rel_key = f'relationship_{target_character}' entity['meta'][rel_key] = evidence.to_dict() data['last_updated'] = get_timestamp_iso() return self.save() class PlotThreadsPanel(StatePanel): """Plot thread panel:伏笔/线索状态跟踪""" def _create_default(self) -> Dict[str, Any]: return { 'entities': {}, # thread_name -> thread_data 'pending_changes': [], 'last_updated': get_timestamp_iso(), 'panel_type': 'plot_threads' } def add_thread( self, thread_name: str, introduced_chapter: int, promised_payoff: str, urgency: str = 'pending', # immediate|pending|background evidence: Optional[Evidence] = None ) -> bool: """Add a new plot thread (伏笔)""" if evidence is None: evidence = Evidence( chapter=f"第{introduced_chapter:03d}章", snippet="初始设定", confidence=0.9 ) data = self.load() data['entities'][thread_name] = { 'values': { 'status': 'active', 'introduced_chapter': introduced_chapter, 'promised_payoff': promised_payoff, 'urgency': urgency }, 'meta': evidence.to_dict() } data['last_updated'] = get_timestamp_iso() return self.save() def resolve_thread( self, thread_name: str, resolved_chapter: int, resolution_summary: str, evidence: Evidence ) -> bool: """Mark a thread as resolved""" return self.update_entity( thread_name, 'status', 'resolved', evidence ) def drop_thread( self, thread_name: str, reason: str, evidence: Evidence ) -> bool: """Mark a thread as dropped (废弃伏笔)""" data = self.load() if thread_name in data.get('entities', {}): data['entities'][thread_name]['values']['status'] = 'dropped' data['entities'][thread_name]['values']['drop_reason'] = reason data['last_updated'] = get_timestamp_iso() return self.save() return False def get_active_threads(self) -> List[Dict[str, Any]]: """Get all active (unresolved) threads""" data = self.load() active = [] for name, entity in data.get('entities', {}).items(): if entity.get('values', {}).get('status') == 'active': active.append({ 'name': name, **entity['values'], **entity['meta'] }) return active def get_resolution_ratio(self) -> float: """Get ratio of resolved threads (for ending check)""" data = self.load() entities = data.get('entities', {}) if not entities: return 1.0 # No threads = all resolved resolved = sum(1 for e in entities.values() if e.get('values', {}).get('status') == 'resolved') return resolved / len(entities) class TimelinePanel(StatePanel): """Timeline panel: 故事内时间线管理""" def _create_default(self) -> Dict[str, Any]: return { 'current_date': '第1天', 'total_days_passed': 0, 'events': [], 'last_updated': get_timestamp_iso(), 'panel_type': 'timeline' } def add_event( self, chapter: int, event_description: str, day_offset: int = 0, # 0 = same day, 1 = next day, etc. evidence: Optional[Evidence] = None ) -> bool: """Add event to timeline""" data = self.load() # Update current date data['total_days_passed'] += day_offset data['current_date'] = f"第{data['total_days_passed'] + 1}天" # Add event event = { 'chapter': chapter, 'day_offset': day_offset, 'event': event_description, 'story_day': data['total_days_passed'] + 1 } if evidence: event.update(evidence.to_dict()) data['events'].append(event) data['last_updated'] = get_timestamp_iso() return self.save() def get_current_day(self) -> int: """Get current story day""" data = self.load() return data.get('total_days_passed', 0) + 1 def get_events_for_chapter(self, chapter: int) -> List[Dict[str, Any]]: """Get all events in a specific chapter""" data = self.load() return [e for e in data.get('events', []) if e.get('chapter') == chapter] class InventoryPanel(StatePanel): """Inventory panel: 道具/物品管理""" def _create_default(self) -> Dict[str, Any]: return { 'entities': {}, # item_name -> item_data 'pending_changes': [], 'last_updated': get_timestamp_iso(), 'panel_type': 'inventory' } def add_item( self, item_name: str, owner: str, description: str, acquired_chapter: int, evidence: Evidence ) -> bool: """Add new item to inventory""" data = self.load() data['entities'][item_name] = { 'values': { 'owner': owner, 'status': 'active', # active|lost|consumed|destroyed 'acquired_chapter': acquired_chapter, 'description': description }, 'meta': evidence.to_dict() } data['last_updated'] = get_timestamp_iso() return self.save() def transfer_item( self, item_name: str, new_owner: str, evidence: Evidence ) -> bool: """Transfer item ownership""" return self.update_entity(item_name, 'owner', new_owner, evidence) def change_item_status( self, item_name: str, new_status: str, # active|lost|consumed|destroyed evidence: Evidence ) -> bool: """Change item status""" return self.update_entity(item_name, 'status', new_status, evidence) def get_items_by_owner(self, owner: str) -> List[Dict[str, Any]]: """Get all items owned by a character""" data = self.load() items = [] for name, entity in data.get('entities', {}).items(): if entity.get('values', {}).get('owner') == owner: items.append({ 'name': name, **entity['values'] }) return items # ============================================================================ # State Manager (Aggregate) # ============================================================================ class StateManager: """ Aggregates all state panels for convenient access """ def __init__(self, run_dir: Path): self.run_dir = Path(run_dir) self.state_dir = self.run_dir / "4-state" # Initialize all panels self.characters = CharactersPanel(self.state_dir / "characters.json") self.plot_threads = PlotThreadsPanel(self.state_dir / "plot_threads.json") self.timeline = TimelinePanel(self.state_dir / "timeline.json") self.inventory = InventoryPanel(self.state_dir / "inventory.json") self.locations_factions = StatePanel(self.state_dir / "locations_factions.json") self.pov_rules = StatePanel(self.state_dir / "pov_rules.json") def load_all(self) -> Dict[str, Any]: """Load all state panels""" return { 'characters': self.characters.load(), 'plot_threads': self.plot_threads.load(), 'timeline': self.timeline.load(), 'inventory': self.inventory.load(), 'locations_factions': self.locations_factions.load(), 'pov_rules': self.pov_rules.load() } def get_invariants(self, current_chapter: int) -> Dict[str, Any]: """ Extract invariants for Sanitizer Returns all state that MUST be continued (confidence >= 0.7) """ invariants = { 'characters': {}, 'plot_threads': {}, 'inventory': {}, 'timeline': {} } # Get characters with high-confidence states for char_name in self.characters.list_entities(): entity = self.characters.get_entity(char_name) if entity: high_conf_fields = {} for field, meta in entity.get('meta', {}).items(): if meta.get('confidence', 0) >= 0.7: high_conf_fields[field] = entity['values'].get(field) if high_conf_fields: invariants['characters'][char_name] = high_conf_fields # Get active plot threads invariants['plot_threads'] = self.plot_threads.get_active_threads() return invariants def commit_chapter_state( self, chapter_num: int, changes: Dict[str, Any] ) -> bool: """ Commit state changes after chapter completion Args: chapter_num: Current chapter number changes: Dictionary of changes to apply """ success = True # Apply character changes if 'characters' in changes: for char_name, char_changes in changes['characters'].items(): for field, change_data in char_changes.items(): evidence = Evidence( chapter=f"第{chapter_num:03d}章", snippet=change_data.get('snippet', ''), confidence=change_data.get('confidence', 0.8) ) if not self.characters.update_entity( char_name, field, change_data['value'], evidence ): success = False # Apply plot thread changes if 'plot_threads' in changes: for thread_name, thread_data in changes['plot_threads'].items(): # Handle thread resolution if thread_data.get('resolved'): evidence = Evidence( chapter=f"第{chapter_num:03d}章", snippet=thread_data.get('snippet', ''), confidence=thread_data.get('confidence', 0.8) ) if not self.plot_threads.resolve_thread( thread_name, chapter_num, thread_data.get('resolution', ''), evidence ): success = False # Apply timeline changes if 'timeline' in changes: timeline_data = changes['timeline'] evidence = Evidence( chapter=f"第{chapter_num:03d}章", snippet=timeline_data.get('snippet', ''), confidence=timeline_data.get('confidence', 0.8) ) if not self.timeline.add_event( chapter_num, timeline_data.get('event', ''), timeline_data.get('day_offset', 0), evidence ): success = False return success # ============================================================================ # Module Test # ============================================================================ if __name__ == "__main__": import tempfile print("=== State Manager Test ===\n") with tempfile.TemporaryDirectory() as tmpdir: state_dir = Path(tmpdir) / "4-state" state_dir.mkdir(parents=True) # Test Characters Panel print("[Test] Characters Panel") chars = CharactersPanel(state_dir / "characters.json") evidence = Evidence("第001章", "张大胆获得系统", 0.95) chars.update_entity("张大胆", "motivation", "还清阴债", evidence) chars.update_character_status("张大胆", "健康", evidence) chars.add_injury("张大胆", "胸口剑伤", 5, Evidence("第005章", "张大胆胸口中了一剑", 0.9)) chars.update_relationship("张大胆", "饿死鬼", -2, Evidence("第003章", "饿死鬼想害他", 0.8)) entity = chars.get_entity("张大胆") print(f" Character created: {'PASS' if entity else 'FAIL'}") print(f" Motivation: {entity['values'].get('motivation')}") # Test Plot Threads Panel print("\n[Test] Plot Threads Panel") threads = PlotThreadsPanel(state_dir / "plot_threads.json") threads.add_thread( "系统真实来源", introduced_chapter=1, promised_payoff="揭示系统是上古神器", urgency="pending" ) active = threads.get_active_threads() print(f" Active threads: {len(active)}") # Test Timeline Panel print("\n[Test] Timeline Panel") timeline = TimelinePanel(state_dir / "timeline.json") timeline.add_event(1, "获得系统", 0) timeline.add_event(2, "送第一单", 0) timeline.add_event(3, "遭遇恶鬼", 1) # Next day print(f" Current day: {timeline.get_current_day()}") print(f" Total events: {len(timeline.load()['events'])}") # Test Inventory Panel print("\n[Test] Inventory Panel") inv = InventoryPanel(state_dir / "inventory.json") inv.add_item( "阴间外卖箱", owner="张大胆", description="系统赠送,可容纳鬼物", acquired_chapter=1, evidence=Evidence("第001章", "获得外卖箱", 0.98) ) items = inv.get_items_by_owner("张大胆") print(f" Items owned: {len(items)}") # Test StateManager aggregate print("\n[Test] StateManager Aggregate") mgr = StateManager(Path(tmpdir)) all_state = mgr.load_all() print(f" Loaded panels: {list(all_state.keys())}") invariants = mgr.get_invariants(5) print(f" Invariants extracted: {'characters' in invariants}") print("\n=== All tests completed ===")