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

748 lines
25 KiB
Python
Raw Normal View History

"""
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 ===")