Initial commit
This commit is contained in:
391
skills/storyboard-manager/scripts/consistency_checker.py
Executable file
391
skills/storyboard-manager/scripts/consistency_checker.py
Executable file
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Consistency Checker for Storyboard Manager
|
||||
|
||||
This script analyzes markdown files in a storyboard project to detect inconsistencies
|
||||
in character details, plot elements, and world-building across the story.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Set, Tuple, Optional
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class ConsistencyIssue:
|
||||
"""Represents a consistency issue found in the story"""
|
||||
|
||||
def __init__(self, issue_type: str, severity: str, description: str,
|
||||
locations: List[str], details: Dict = None):
|
||||
self.issue_type = issue_type # character, plot, world, timeline
|
||||
self.severity = severity # critical, warning, info
|
||||
self.description = description
|
||||
self.locations = locations
|
||||
self.details = details or {}
|
||||
|
||||
def __repr__(self):
|
||||
return f"ConsistencyIssue({self.severity}: {self.description})"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'type': self.issue_type,
|
||||
'severity': self.severity,
|
||||
'description': self.description,
|
||||
'locations': self.locations,
|
||||
'details': self.details
|
||||
}
|
||||
|
||||
|
||||
class CharacterProfile:
|
||||
"""Stores character information from profile files"""
|
||||
|
||||
def __init__(self, name: str, file_path: str):
|
||||
self.name = name
|
||||
self.file_path = file_path
|
||||
self.attributes = {}
|
||||
self.aliases = []
|
||||
self.relationships = {}
|
||||
|
||||
def add_attribute(self, key: str, value: str):
|
||||
"""Add a character attribute"""
|
||||
self.attributes[key.lower()] = value
|
||||
|
||||
def get_attribute(self, key: str) -> Optional[str]:
|
||||
"""Get a character attribute"""
|
||||
return self.attributes.get(key.lower())
|
||||
|
||||
|
||||
class ConsistencyChecker:
|
||||
"""Main consistency checking class"""
|
||||
|
||||
# Patterns to extract character attributes
|
||||
ATTRIBUTE_PATTERNS = {
|
||||
'age': r'\*\*Age:\*\*\s*(.+?)(?:\n|$)',
|
||||
'appearance': r'\*\*Appearance:\*\*\s*(.+?)(?:\n|$)',
|
||||
'hair': r'(?:hair|Hair)[\s:]+([^,\n]+)',
|
||||
'eyes': r'(?:eyes|Eyes)[\s:]+([^,\n]+)',
|
||||
'height': r'\*\*Height:\*\*\s*(.+?)(?:\n|$)',
|
||||
'role': r'\*\*Role:\*\*\s*(.+?)(?:\n|$)',
|
||||
}
|
||||
|
||||
def __init__(self, project_root: str):
|
||||
self.project_root = Path(project_root)
|
||||
self.characters: Dict[str, CharacterProfile] = {}
|
||||
self.issues: List[ConsistencyIssue] = []
|
||||
self.world_facts: Dict[str, Tuple[str, str]] = {} # fact -> (value, location)
|
||||
|
||||
def scan_directory(self, directory: Path) -> List[Path]:
|
||||
"""Recursively find all markdown files in directory"""
|
||||
md_files = []
|
||||
if not directory.exists():
|
||||
return md_files
|
||||
|
||||
for item in directory.iterdir():
|
||||
if item.is_file() and item.suffix == '.md':
|
||||
md_files.append(item)
|
||||
elif item.is_dir() and not item.name.startswith('.'):
|
||||
md_files.extend(self.scan_directory(item))
|
||||
|
||||
return md_files
|
||||
|
||||
def load_character_profile(self, file_path: Path) -> Optional[CharacterProfile]:
|
||||
"""Load character information from a profile file"""
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
# Extract character name from title
|
||||
name_match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
|
||||
if not name_match:
|
||||
return None
|
||||
|
||||
name = name_match.group(1).strip()
|
||||
profile = CharacterProfile(name, str(file_path.relative_to(self.project_root)))
|
||||
|
||||
# Extract attributes
|
||||
for attr_name, pattern in self.ATTRIBUTE_PATTERNS.items():
|
||||
match = re.search(pattern, content, re.IGNORECASE)
|
||||
if match:
|
||||
profile.add_attribute(attr_name, match.group(1).strip())
|
||||
|
||||
# Extract aliases/nicknames
|
||||
alias_match = re.search(
|
||||
r'\*\*(?:Nicknames?|Aliases?):\*\*\s*(.+?)(?:\n|$)',
|
||||
content, re.IGNORECASE
|
||||
)
|
||||
if alias_match:
|
||||
aliases = re.split(r'[,;]', alias_match.group(1))
|
||||
profile.aliases = [a.strip() for a in aliases if a.strip()]
|
||||
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read character profile {file_path}: {e}",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
|
||||
def load_all_characters(self):
|
||||
"""Load all character profiles from the project"""
|
||||
char_dirs = ['characters', 'Characters', 'cast', 'Cast']
|
||||
|
||||
for dirname in char_dirs:
|
||||
char_dir = self.project_root / dirname
|
||||
if char_dir.exists():
|
||||
for char_file in self.scan_directory(char_dir):
|
||||
profile = self.load_character_profile(char_file)
|
||||
if profile:
|
||||
self.characters[profile.name] = profile
|
||||
|
||||
def check_character_mentions(self, file_path: Path):
|
||||
"""Check character mentions in content for inconsistencies"""
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
location = str(file_path.relative_to(self.project_root))
|
||||
|
||||
for char_name, profile in self.characters.items():
|
||||
# Check if character is mentioned
|
||||
if not re.search(r'\b' + re.escape(char_name) + r'\b', content, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
# Check for attribute contradictions
|
||||
for attr_name, attr_value in profile.attributes.items():
|
||||
# Look for contradicting descriptions
|
||||
if attr_name == 'age':
|
||||
age_mentions = re.finditer(
|
||||
r'\b' + re.escape(char_name) + r'\b[^.!?]*\b(\d+)[\s-](?:year|yr)',
|
||||
content, re.IGNORECASE
|
||||
)
|
||||
for match in age_mentions:
|
||||
mentioned_age = match.group(1)
|
||||
profile_age = re.search(r'\d+', attr_value)
|
||||
if profile_age and mentioned_age != profile_age.group(0):
|
||||
self.issues.append(ConsistencyIssue(
|
||||
issue_type='character',
|
||||
severity='warning',
|
||||
description=f"Age inconsistency for {char_name}",
|
||||
locations=[location, profile.file_path],
|
||||
details={
|
||||
'character': char_name,
|
||||
'profile_age': attr_value,
|
||||
'mentioned_age': mentioned_age
|
||||
}
|
||||
))
|
||||
|
||||
elif attr_name in ['hair', 'eyes']:
|
||||
# Check for contradicting physical descriptions
|
||||
desc_pattern = rf'\b{re.escape(char_name)}\b[^.!?]*\b({attr_name})\b[^.!?]*'
|
||||
desc_mentions = re.finditer(desc_pattern, content, re.IGNORECASE)
|
||||
for match in desc_mentions:
|
||||
context = match.group(0).lower()
|
||||
# Simple check: if profile says "black hair" but text says "blonde"
|
||||
profile_value_lower = attr_value.lower()
|
||||
if profile_value_lower not in context:
|
||||
# Extract the contradicting description
|
||||
color_pattern = r'\b(black|brown|blonde|red|auburn|white|gray|grey|blue|green|hazel)\b'
|
||||
colors = re.findall(color_pattern, context, re.IGNORECASE)
|
||||
if colors:
|
||||
self.issues.append(ConsistencyIssue(
|
||||
issue_type='character',
|
||||
severity='warning',
|
||||
description=f"{attr_name.capitalize()} color inconsistency for {char_name}",
|
||||
locations=[location, profile.file_path],
|
||||
details={
|
||||
'character': char_name,
|
||||
'profile': attr_value,
|
||||
'context': match.group(0)[:100]
|
||||
}
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error checking {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
def check_character_relationships(self):
|
||||
"""Check for inconsistent character relationships"""
|
||||
# This is a placeholder for more sophisticated relationship checking
|
||||
# Would analyze relationship declarations in character files and compare
|
||||
# with how relationships are portrayed in chapters
|
||||
|
||||
relationship_keywords = ['friend', 'enemy', 'lover', 'sibling', 'parent', 'child']
|
||||
|
||||
for char_name, profile in self.characters.items():
|
||||
# Extract relationship info from profile
|
||||
# Compare with relationships mentioned in story files
|
||||
# Flag inconsistencies
|
||||
pass
|
||||
|
||||
def check_world_building(self, file_path: Path):
|
||||
"""Check for world-building inconsistencies"""
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
location = str(file_path.relative_to(self.project_root))
|
||||
|
||||
# Look for world-building facts (places, magic systems, technology, etc.)
|
||||
# This is a simplified version - would need more sophisticated pattern matching
|
||||
|
||||
# Example: Check for location descriptions
|
||||
location_pattern = r'\*\*Location:\*\*\s*(.+?)(?:\n|$)'
|
||||
for match in re.finditer(location_pattern, content, re.IGNORECASE):
|
||||
loc_name = match.group(1).strip()
|
||||
|
||||
if loc_name in self.world_facts:
|
||||
# Check if description is consistent
|
||||
prev_value, prev_location = self.world_facts[loc_name]
|
||||
# In a real implementation, would do semantic comparison
|
||||
else:
|
||||
self.world_facts[loc_name] = (match.group(0), location)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error checking world-building in {file_path}: {e}",
|
||||
file=sys.stderr)
|
||||
|
||||
def check_plot_consistency(self):
|
||||
"""Check for plot inconsistencies"""
|
||||
# Placeholder for plot consistency checking
|
||||
# Would track plot points, events, and check for contradictions
|
||||
|
||||
# Examples to check:
|
||||
# - Events happening out of order
|
||||
# - Characters appearing after their death
|
||||
# - Objects used before acquisition
|
||||
# - Locations visited before discovery
|
||||
pass
|
||||
|
||||
def check_name_variations(self, file_path: Path):
|
||||
"""Check for inconsistent name usage"""
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
location = str(file_path.relative_to(self.project_root))
|
||||
|
||||
# Check if character names are spelled consistently
|
||||
for char_name, profile in self.characters.items():
|
||||
# Look for potential misspellings (Levenshtein distance)
|
||||
# This is simplified - would use actual string distance algorithm
|
||||
|
||||
# Check for variations in capitalization
|
||||
variations = re.findall(
|
||||
r'\b' + re.escape(char_name) + r'\b',
|
||||
content,
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
inconsistent_caps = [v for v in variations if v != char_name]
|
||||
if inconsistent_caps:
|
||||
unique_variations = list(set(inconsistent_caps))
|
||||
if len(unique_variations) > 0:
|
||||
self.issues.append(ConsistencyIssue(
|
||||
issue_type='character',
|
||||
severity='info',
|
||||
description=f"Name capitalization variations for {char_name}",
|
||||
locations=[location],
|
||||
details={
|
||||
'character': char_name,
|
||||
'variations': unique_variations
|
||||
}
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error checking names in {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
def analyze_project(self) -> Dict:
|
||||
"""Run all consistency checks on the project"""
|
||||
|
||||
# Load character profiles
|
||||
self.load_all_characters()
|
||||
|
||||
# Check all content files
|
||||
content_dirs = ['chapters', 'Chapters', 'scenes', 'Scenes', 'story']
|
||||
content_files = []
|
||||
|
||||
for dirname in content_dirs:
|
||||
content_dir = self.project_root / dirname
|
||||
if content_dir.exists():
|
||||
content_files.extend(self.scan_directory(content_dir))
|
||||
|
||||
# Run checks on each file
|
||||
for content_file in content_files:
|
||||
self.check_character_mentions(content_file)
|
||||
self.check_world_building(content_file)
|
||||
self.check_name_variations(content_file)
|
||||
|
||||
# Run project-wide checks
|
||||
self.check_character_relationships()
|
||||
self.check_plot_consistency()
|
||||
|
||||
# Organize results
|
||||
issues_by_severity = defaultdict(list)
|
||||
for issue in self.issues:
|
||||
issues_by_severity[issue.severity].append(issue.to_dict())
|
||||
|
||||
analysis = {
|
||||
'total_issues': len(self.issues),
|
||||
'critical_issues': len(issues_by_severity['critical']),
|
||||
'warnings': len(issues_by_severity['warning']),
|
||||
'info': len(issues_by_severity['info']),
|
||||
'characters_analyzed': len(self.characters),
|
||||
'issues_by_severity': dict(issues_by_severity),
|
||||
'all_issues': [issue.to_dict() for issue in self.issues]
|
||||
}
|
||||
|
||||
return analysis
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for consistency checker"""
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: consistency_checker.py <project_directory> [--output json|markdown]")
|
||||
sys.exit(1)
|
||||
|
||||
project_dir = sys.argv[1]
|
||||
output_format = 'markdown'
|
||||
|
||||
if len(sys.argv) > 2 and sys.argv[2] == '--output':
|
||||
output_format = sys.argv[3] if len(sys.argv) > 3 else 'markdown'
|
||||
|
||||
checker = ConsistencyChecker(project_dir)
|
||||
analysis = checker.analyze_project()
|
||||
|
||||
if output_format == 'json':
|
||||
print(json.dumps(analysis, indent=2))
|
||||
else:
|
||||
# Markdown output
|
||||
print("# Consistency Analysis\n")
|
||||
print(f"**Total Issues Found:** {analysis['total_issues']}")
|
||||
print(f"- Critical: {analysis['critical_issues']}")
|
||||
print(f"- Warnings: {analysis['warnings']}")
|
||||
print(f"- Info: {analysis['info']}\n")
|
||||
print(f"**Characters Analyzed:** {analysis['characters_analyzed']}\n")
|
||||
|
||||
if analysis['total_issues'] == 0:
|
||||
print("✅ No consistency issues found!\n")
|
||||
else:
|
||||
# Display issues by severity
|
||||
for severity in ['critical', 'warning', 'info']:
|
||||
issues = analysis['issues_by_severity'].get(severity, [])
|
||||
if issues:
|
||||
severity_emoji = {
|
||||
'critical': '🔴',
|
||||
'warning': '⚠️',
|
||||
'info': 'ℹ️'
|
||||
}
|
||||
print(f"\n## {severity_emoji[severity]} {severity.upper()}\n")
|
||||
|
||||
for issue in issues:
|
||||
print(f"### {issue['description']}")
|
||||
print(f"**Type:** {issue['type']}")
|
||||
print(f"**Locations:**")
|
||||
for loc in issue['locations']:
|
||||
print(f"- {loc}")
|
||||
|
||||
if issue['details']:
|
||||
print(f"**Details:**")
|
||||
for key, value in issue['details'].items():
|
||||
print(f"- {key}: {value}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
352
skills/storyboard-manager/scripts/timeline_tracker.py
Executable file
352
skills/storyboard-manager/scripts/timeline_tracker.py
Executable file
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Timeline Tracker for Storyboard Manager
|
||||
|
||||
This script analyzes markdown files in a storyboard project to extract and organize
|
||||
timeline events, helping writers maintain chronological consistency.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class TimelineEvent:
|
||||
"""Represents a single event in the story timeline"""
|
||||
|
||||
def __init__(self, content: str, location: str, chapter: str = None,
|
||||
timepoint: str = None, characters: List[str] = None):
|
||||
self.content = content
|
||||
self.location = location # File path where event was found
|
||||
self.chapter = chapter
|
||||
self.timepoint = timepoint # Relative time (e.g., "Day 1", "3 weeks later")
|
||||
self.characters = characters or []
|
||||
|
||||
def __repr__(self):
|
||||
return f"TimelineEvent({self.timepoint}: {self.content[:50]}...)"
|
||||
|
||||
|
||||
class TimelineTracker:
|
||||
"""Main timeline tracking and analysis class"""
|
||||
|
||||
# Patterns to detect time markers in text
|
||||
TIME_PATTERNS = [
|
||||
r'(?:Day|Night)\s+(\d+)', # Day 1, Night 3
|
||||
r'(\d+)\s+(?:days?|weeks?|months?|years?)\s+(?:later|ago|after|before)',
|
||||
r'(?:Morning|Afternoon|Evening|Night)\s+of\s+(?:Day\s+)?(\d+)',
|
||||
r'Chapter\s+(\d+)', # Chapter markers
|
||||
r'\*\*(?:Timeline|Time|When):\*\*\s*(.+?)(?:\n|$)', # Explicit timeline markers
|
||||
r'\*\*Date:\*\*\s*(.+?)(?:\n|$)',
|
||||
]
|
||||
|
||||
# Patterns to detect character mentions
|
||||
CHARACTER_PATTERNS = [
|
||||
r'\*\*Characters?:\*\*\s*(.+?)(?:\n|$)',
|
||||
r'\*\*(?:POV|Perspective):\*\*\s*(.+?)(?:\n|$)',
|
||||
]
|
||||
|
||||
def __init__(self, project_root: str):
|
||||
self.project_root = Path(project_root)
|
||||
self.events: List[TimelineEvent] = []
|
||||
self.characters: set = set()
|
||||
|
||||
def scan_directory(self, directory: Path) -> List[Path]:
|
||||
"""Recursively find all markdown files in directory"""
|
||||
md_files = []
|
||||
if not directory.exists():
|
||||
return md_files
|
||||
|
||||
for item in directory.iterdir():
|
||||
if item.is_file() and item.suffix == '.md':
|
||||
md_files.append(item)
|
||||
elif item.is_dir() and not item.name.startswith('.'):
|
||||
md_files.extend(self.scan_directory(item))
|
||||
|
||||
return md_files
|
||||
|
||||
def extract_characters_from_file(self, file_path: Path) -> List[str]:
|
||||
"""Extract character names from character profile files"""
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
# Look for character name in title (# Character Name)
|
||||
name_match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
|
||||
if name_match:
|
||||
return [name_match.group(1).strip()]
|
||||
|
||||
# Look for explicit name field
|
||||
name_match = re.search(r'\*\*Name:\*\*\s*(.+?)(?:\n|$)', content)
|
||||
if name_match:
|
||||
return [name_match.group(1).strip()]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
return []
|
||||
|
||||
def extract_timeline_markers(self, content: str) -> List[Tuple[str, int]]:
|
||||
"""Extract time markers from content, return list of (timepoint, position)"""
|
||||
markers = []
|
||||
|
||||
for pattern in self.TIME_PATTERNS:
|
||||
for match in re.finditer(pattern, content, re.IGNORECASE):
|
||||
timepoint = match.group(1) if match.lastindex else match.group(0)
|
||||
markers.append((timepoint.strip(), match.start()))
|
||||
|
||||
return sorted(markers, key=lambda x: x[1])
|
||||
|
||||
def extract_character_mentions(self, content: str) -> List[str]:
|
||||
"""Extract character names from explicit character markers"""
|
||||
characters = []
|
||||
|
||||
for pattern in self.CHARACTER_PATTERNS:
|
||||
matches = re.finditer(pattern, content, re.IGNORECASE)
|
||||
for match in matches:
|
||||
char_text = match.group(1)
|
||||
# Split by commas, 'and', '&'
|
||||
names = re.split(r'[,&]|\sand\s', char_text)
|
||||
characters.extend([name.strip() for name in names if name.strip()])
|
||||
|
||||
return characters
|
||||
|
||||
def find_character_references(self, content: str, known_characters: set) -> List[str]:
|
||||
"""Find mentions of known characters in content"""
|
||||
found = []
|
||||
for character in known_characters:
|
||||
# Simple word boundary check
|
||||
if re.search(r'\b' + re.escape(character) + r'\b', content, re.IGNORECASE):
|
||||
found.append(character)
|
||||
return found
|
||||
|
||||
def parse_chapter_file(self, file_path: Path) -> List[TimelineEvent]:
|
||||
"""Parse a chapter/scene file for timeline events"""
|
||||
events = []
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
# Get chapter number/name from filename or title
|
||||
chapter = file_path.stem
|
||||
title_match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
|
||||
if title_match:
|
||||
chapter = title_match.group(1).strip()
|
||||
|
||||
# Extract explicit character mentions
|
||||
explicit_chars = self.extract_character_mentions(content)
|
||||
|
||||
# Find timeline markers
|
||||
markers = self.extract_timeline_markers(content)
|
||||
|
||||
# Split content into sections based on markers
|
||||
if markers:
|
||||
sections = []
|
||||
for i, (timepoint, pos) in enumerate(markers):
|
||||
start_pos = pos
|
||||
end_pos = markers[i + 1][1] if i + 1 < len(markers) else len(content)
|
||||
section_content = content[start_pos:end_pos]
|
||||
|
||||
# Find characters in this section
|
||||
section_chars = explicit_chars.copy()
|
||||
section_chars.extend(self.find_character_references(
|
||||
section_content, self.characters))
|
||||
|
||||
event = TimelineEvent(
|
||||
content=section_content[:500], # First 500 chars as preview
|
||||
location=str(file_path.relative_to(self.project_root)),
|
||||
chapter=chapter,
|
||||
timepoint=timepoint,
|
||||
characters=list(set(section_chars))
|
||||
)
|
||||
events.append(event)
|
||||
else:
|
||||
# No explicit markers, treat whole file as one event
|
||||
all_chars = explicit_chars.copy()
|
||||
all_chars.extend(self.find_character_references(content, self.characters))
|
||||
|
||||
event = TimelineEvent(
|
||||
content=content[:500],
|
||||
location=str(file_path.relative_to(self.project_root)),
|
||||
chapter=chapter,
|
||||
timepoint=None,
|
||||
characters=list(set(all_chars))
|
||||
)
|
||||
events.append(event)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error parsing {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
return events
|
||||
|
||||
def analyze_project(self) -> Dict:
|
||||
"""Analyze entire project and build timeline"""
|
||||
|
||||
# First, find all characters
|
||||
char_dirs = ['characters', 'Characters', 'cast']
|
||||
for dirname in char_dirs:
|
||||
char_dir = self.project_root / dirname
|
||||
if char_dir.exists():
|
||||
for char_file in self.scan_directory(char_dir):
|
||||
names = self.extract_characters_from_file(char_file)
|
||||
self.characters.update(names)
|
||||
|
||||
# Then scan chapters/scenes
|
||||
content_dirs = ['chapters', 'Chapters', 'scenes', 'Scenes', 'story']
|
||||
for dirname in content_dirs:
|
||||
content_dir = self.project_root / dirname
|
||||
if content_dir.exists():
|
||||
for content_file in self.scan_directory(content_dir):
|
||||
events = self.parse_chapter_file(content_file)
|
||||
self.events.extend(events)
|
||||
|
||||
# Build analysis
|
||||
analysis = {
|
||||
'total_events': len(self.events),
|
||||
'total_characters': len(self.characters),
|
||||
'characters': sorted(list(self.characters)),
|
||||
'events_by_timepoint': self._group_events_by_time(),
|
||||
'events_by_character': self._group_events_by_character(),
|
||||
'events_by_chapter': self._group_events_by_chapter(),
|
||||
'timeline': self._build_timeline(),
|
||||
'warnings': self._check_consistency()
|
||||
}
|
||||
|
||||
return analysis
|
||||
|
||||
def _group_events_by_time(self) -> Dict[str, List[Dict]]:
|
||||
"""Group events by their timepoint"""
|
||||
grouped = defaultdict(list)
|
||||
|
||||
for event in self.events:
|
||||
timepoint = event.timepoint or "Unspecified"
|
||||
grouped[timepoint].append({
|
||||
'location': event.location,
|
||||
'chapter': event.chapter,
|
||||
'characters': event.characters,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return dict(grouped)
|
||||
|
||||
def _group_events_by_character(self) -> Dict[str, List[Dict]]:
|
||||
"""Group events by character appearance"""
|
||||
grouped = defaultdict(list)
|
||||
|
||||
for event in self.events:
|
||||
for character in event.characters:
|
||||
grouped[character].append({
|
||||
'location': event.location,
|
||||
'chapter': event.chapter,
|
||||
'timepoint': event.timepoint,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return dict(grouped)
|
||||
|
||||
def _group_events_by_chapter(self) -> Dict[str, List[Dict]]:
|
||||
"""Group events by chapter"""
|
||||
grouped = defaultdict(list)
|
||||
|
||||
for event in self.events:
|
||||
chapter = event.chapter or "Unknown"
|
||||
grouped[chapter].append({
|
||||
'location': event.location,
|
||||
'timepoint': event.timepoint,
|
||||
'characters': event.characters,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return dict(grouped)
|
||||
|
||||
def _build_timeline(self) -> List[Dict]:
|
||||
"""Build chronological timeline of events"""
|
||||
# Sort events by timepoint (this is simplified, real implementation
|
||||
# would need more sophisticated time parsing)
|
||||
timeline = []
|
||||
|
||||
for event in self.events:
|
||||
timeline.append({
|
||||
'timepoint': event.timepoint or "Unknown",
|
||||
'chapter': event.chapter,
|
||||
'location': event.location,
|
||||
'characters': event.characters,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return timeline
|
||||
|
||||
def _check_consistency(self) -> List[str]:
|
||||
"""Check for potential timeline inconsistencies"""
|
||||
warnings = []
|
||||
|
||||
# Check for events without time markers
|
||||
unmarked_events = [e for e in self.events if not e.timepoint]
|
||||
if unmarked_events:
|
||||
warnings.append(
|
||||
f"Found {len(unmarked_events)} events without timeline markers"
|
||||
)
|
||||
|
||||
# Check for characters appearing in timeline without character files
|
||||
mentioned_chars = set()
|
||||
for event in self.events:
|
||||
mentioned_chars.update(event.characters)
|
||||
|
||||
undefined_chars = mentioned_chars - self.characters
|
||||
if undefined_chars:
|
||||
warnings.append(
|
||||
f"Characters mentioned but not defined: {', '.join(sorted(undefined_chars))}"
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for timeline tracker"""
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: timeline_tracker.py <project_directory> [--output json|markdown]")
|
||||
sys.exit(1)
|
||||
|
||||
project_dir = sys.argv[1]
|
||||
output_format = 'markdown'
|
||||
|
||||
if len(sys.argv) > 2 and sys.argv[2] == '--output':
|
||||
output_format = sys.argv[3] if len(sys.argv) > 3 else 'markdown'
|
||||
|
||||
tracker = TimelineTracker(project_dir)
|
||||
analysis = tracker.analyze_project()
|
||||
|
||||
if output_format == 'json':
|
||||
print(json.dumps(analysis, indent=2))
|
||||
else:
|
||||
# Markdown output
|
||||
print("# Timeline Analysis\n")
|
||||
print(f"**Total Events:** {analysis['total_events']}")
|
||||
print(f"**Total Characters:** {analysis['total_characters']}\n")
|
||||
|
||||
print("## Characters")
|
||||
for char in analysis['characters']:
|
||||
appearances = len(analysis['events_by_character'].get(char, []))
|
||||
print(f"- {char} ({appearances} appearances)")
|
||||
|
||||
print("\n## Timeline")
|
||||
for event in analysis['timeline']:
|
||||
print(f"\n### {event['timepoint']} - {event['chapter']}")
|
||||
print(f"**Location:** {event['location']}")
|
||||
if event['characters']:
|
||||
print(f"**Characters:** {', '.join(event['characters'])}")
|
||||
print(f"\n{event['preview']}...\n")
|
||||
print("---")
|
||||
|
||||
if analysis['warnings']:
|
||||
print("\n## Warnings")
|
||||
for warning in analysis['warnings']:
|
||||
print(f"- ⚠️ {warning}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user