""" Config Linting / Story Health Validation Automated checks for game config quality: - Branching validation (graph integrity) - Story flags (potential issues) - Mermaid diagram generation """ import json from collections import deque def parse_config(config_input): """Parse config from string or dict, handle wrapper keys.""" if isinstance(config_input, str): try: data = json.loads(config_input) except json.JSONDecodeError as e: return None, f"JSON Parse Error: {e}" else: data = config_input # Handle wrapper keys (masterlocation1, etc.) if len(data) == 1: key = list(data.keys())[0] if isinstance(data[key], dict) and 'end' in data[key]: return data[key], None return data, None def flatten_nested_config(data): """ Flatten a nested location->state config into flat state dict. Detects if config is nested (location contains states with 'description') vs flat (states directly have 'description'). Returns: (flattened_dict, is_nested) """ if not data or not isinstance(data, dict): return data, False # Check if this is a nested structure # A nested structure has: location -> state -> {description, choices, transitions} # A flat structure has: state -> {description, choices, transitions} first_key = list(data.keys())[0] first_value = data[first_key] if not isinstance(first_value, dict): return data, False # If the first value has 'description', it's flat if 'description' in first_value: return data, False # Check if first value contains dicts with 'description' (nested) for inner_key, inner_value in first_value.items(): if isinstance(inner_value, dict) and 'description' in inner_value: # This is nested - flatten it flattened = {} for location, states in data.items(): if isinstance(states, dict): for state_name, state_data in states.items(): if isinstance(state_data, dict) and 'description' in state_data: flat_key = f"{location}_{state_name}" flattened[flat_key] = state_data return flattened, True # Not clearly nested, return as-is return data, False def validate_branching(config_input): """ Validate config branching/graph integrity. Returns dict with: - errors: Critical issues (broken transitions) - warnings: Potential issues (orphaned states, dead ends) - stats: Metrics about the config """ data, error = parse_config(config_input) if error: return {"errors": [error], "warnings": [], "stats": {}} # Flatten nested configs (location -> state -> data) data, is_nested = flatten_nested_config(data) errors = [] warnings = [] all_states = set(data.keys()) # Track which states are targeted by transitions targeted_states = set() for state_name, state_data in data.items(): if not isinstance(state_data, dict): continue choices = state_data.get('choices', []) transitions = state_data.get('transitions', {}) # Check: All transition targets exist for choice, target in transitions.items(): # Handle nested location_state format if '_' in target and target not in all_states: # Try parsing as location_state parts = target.rsplit('_', 1) if len(parts) == 2: location, inner_state = parts if location in all_states: loc_data = data.get(location, {}) if isinstance(loc_data, dict) and inner_state in loc_data: targeted_states.add(location) continue errors.append(f"'{state_name}': transition '{choice}' -> '{target}' (target doesn't exist)") else: if target in all_states: targeted_states.add(target) else: errors.append(f"'{state_name}': transition '{choice}' -> '{target}' (target doesn't exist)") # Check: Choices match transition keys choice_set = set(choices) if choices else set() transition_keys = set(transitions.keys()) missing_transitions = choice_set - transition_keys extra_transitions = transition_keys - choice_set if missing_transitions: errors.append(f"'{state_name}': choices without transitions: {missing_transitions}") if extra_transitions: warnings.append(f"'{state_name}': transitions without choices: {extra_transitions}") # Find orphaned states (not targeted by any transition, except start states) # Assume first state or common names are start states start_candidates = {'start', 'intro', 'beginning', 'arrival', 'location1'} first_state = list(data.keys())[0] if data else None orphaned = all_states - targeted_states - {'end'} - start_candidates if first_state: orphaned.discard(first_state) if orphaned: warnings.append(f"Potentially orphaned states (unreachable): {orphaned}") # Find dead ends (no transitions out, excluding valid ending states) dead_ends = [] valid_endings = [] for state_name, state_data in data.items(): if not isinstance(state_data, dict): continue transitions = state_data.get('transitions', {}) if not transitions: # Check if this looks like an intentional ending state_lower = state_name.lower() is_ending = ( state_lower == 'end' or state_lower.startswith('end_') or state_lower.startswith('ending_') or '_end' in state_lower or '_ending' in state_lower or 'finale' in state_lower or 'conclusion' in state_lower ) if is_ending: valid_endings.append(state_name) else: dead_ends.append(state_name) if dead_ends: warnings.append(f"Dead-end states (no exits): {dead_ends}") # Calculate stats stats = { "total_states": len(all_states), "valid_endings": len(valid_endings), "reachable_states": len(targeted_states) + 1, # +1 for start "orphaned_count": len(orphaned), "dead_end_count": len(dead_ends), "total_transitions": sum( len(d.get('transitions', {})) for d in data.values() if isinstance(d, dict) ), "total_choices": sum( len(d.get('choices', [])) for d in data.values() if isinstance(d, dict) ), } if stats["total_states"] > 0: stats["reachability_pct"] = round( (stats["reachable_states"] / stats["total_states"]) * 100, 1 ) else: stats["reachability_pct"] = 0 return { "errors": errors, "warnings": warnings, "stats": stats } def generate_story_flags(config_input): """ Generate story-level flags/warnings (qualitative hints). These are softer warnings about potential narrative issues. """ data, error = parse_config(config_input) if error: return [f"Cannot analyze: {error}"] # Flatten nested configs data, is_nested = flatten_nested_config(data) flags = [] # Collect path lengths from each state state_depths = {} descriptions = {} for state_name, state_data in data.items(): if not isinstance(state_data, dict): continue desc = state_data.get('description', '') descriptions[state_name] = desc choices = state_data.get('choices', []) # Flag: Empty or very short description if len(desc) < 20: flags.append({ "type": "sparse_content", "state": state_name, "message": f"Very short description ({len(desc)} chars)", "severity": "info" }) # Flag: Many choices (complexity) if len(choices) > 5: flags.append({ "type": "high_complexity", "state": state_name, "message": f"Many choices ({len(choices)}) - may overwhelm player", "severity": "info" }) # Flag: Description doesn't mention any choices # Note: This is just informational - good narrative often sets scene # without literally mentioning the choice text if choices and desc: desc_lower = desc.lower() mentioned = any(c.lower() in desc_lower for c in choices) if not mentioned: flags.append({ "type": "disconnected_choices", "state": state_name, "message": "Description doesn't reference any of the available choices", "severity": "info" # Changed from warning - this is just a hint }) # Flag: No media in state (if others have media) media = state_data.get('media', []) if not media: # This is just tracked, we'll flag if inconsistent pass # Flag: Inconsistent media usage states_with_media = sum( 1 for s, d in data.items() if isinstance(d, dict) and d.get('media') ) states_without_media = len([ s for s, d in data.items() if isinstance(d, dict) and not d.get('media') and s != 'end' ]) if states_with_media > 0 and states_without_media > 0: ratio = states_with_media / (states_with_media + states_without_media) if 0.2 < ratio < 0.8: flags.append({ "type": "inconsistent_media", "state": "global", "message": f"Inconsistent media: {states_with_media} states have media, {states_without_media} don't", "severity": "info" }) # Flag: Very different description lengths (tone/pacing inconsistency) desc_lengths = [len(d) for d in descriptions.values() if d] if desc_lengths: avg_len = sum(desc_lengths) / len(desc_lengths) for state_name, desc in descriptions.items(): if desc and len(desc) > avg_len * 3: flags.append({ "type": "length_outlier", "state": state_name, "message": f"Description much longer than average ({len(desc)} vs avg {int(avg_len)})", "severity": "info" }) elif desc and len(desc) < avg_len * 0.3 and len(desc) > 0: flags.append({ "type": "length_outlier", "state": state_name, "message": f"Description much shorter than average ({len(desc)} vs avg {int(avg_len)})", "severity": "info" }) return flags def generate_mermaid_diagram(config_input): """ Generate a Mermaid flowchart diagram from config. Returns mermaid code string. """ data, error = parse_config(config_input) if error: return f"%%Error: {error}" # Flatten nested configs data, is_nested = flatten_nested_config(data) lines = ["flowchart TD"] # Mermaid reserved keywords that need escaping RESERVED_KEYWORDS = {'end', 'graph', 'subgraph', 'direction', 'click', 'style', 'class', 'linkStyle', 'classDef'} def make_safe_name(name): """Convert state name to mermaid-safe identifier.""" safe = name.replace(' ', '_').replace('-', '_') # Escape reserved keywords by prefixing with 'state_' if safe.lower() in RESERVED_KEYWORDS: safe = f"state_{safe}" return safe # Track states for styling all_states = set(data.keys()) targeted_states = set() dead_ends = set() # First pass: identify targeted states for state_name, state_data in data.items(): if not isinstance(state_data, dict): continue for target in state_data.get('transitions', {}).values(): if target in all_states: targeted_states.add(target) elif '_' in target: parts = target.rsplit('_', 1) if parts[0] in all_states: targeted_states.add(parts[0]) # Generate nodes and edges for state_name, state_data in data.items(): if not isinstance(state_data, dict): continue # Sanitize state name for mermaid safe_name = make_safe_name(state_name) # Get short description for node label desc = state_data.get('description', state_name) short_desc = desc[:30] + '...' if len(desc) > 30 else desc short_desc = short_desc.replace('"', "'").replace('\n', ' ') # Node shape based on type if state_name == 'end': lines.append(f' {safe_name}(["{short_desc}"])') elif state_name in ['start', 'intro', 'beginning', 'arrival'] or state_name == list(data.keys())[0]: lines.append(f' {safe_name}[/"{short_desc}"\\]') else: lines.append(f' {safe_name}["{short_desc}"]') # Edges for transitions transitions = state_data.get('transitions', {}) if not transitions: dead_ends.add(state_name) for choice, target in transitions.items(): safe_target = make_safe_name(target) safe_choice = choice[:20].replace('"', "'") lines.append(f' {safe_name} -->|"{safe_choice}"| {safe_target}') # Add styling lines.append("") lines.append(" %% Styling") # Start state styling first_state = list(data.keys())[0] if data else None if first_state: safe_first = make_safe_name(first_state) lines.append(f" style {safe_first} fill:#90EE90") # End state styling if 'end' in all_states: lines.append(f" style {make_safe_name('end')} fill:#FFB6C1") # Dead end styling for de in dead_ends: if de != 'end': safe_de = make_safe_name(de) lines.append(f" style {safe_de} fill:#FFD700") # Orphaned state styling orphaned = all_states - targeted_states - {'end'} if first_state: orphaned.discard(first_state) for orph in orphaned: safe_orph = make_safe_name(orph) lines.append(f" style {safe_orph} fill:#FF6347") return "\n".join(lines) def get_config_health_summary(config_input): """ Get a comprehensive health summary of the config. Returns formatted string for display. """ validation = validate_branching(config_input) flags = generate_story_flags(config_input) stats = validation['stats'] errors = validation['errors'] warnings = validation['warnings'] lines = [] # Overall health score (simple heuristic) error_penalty = len(errors) * 20 warning_penalty = len(warnings) * 5 flag_penalty = len([f for f in flags if f.get('severity') == 'warning']) * 3 health_score = max(0, 100 - error_penalty - warning_penalty - flag_penalty) lines.append(f"## Config Health Score: {health_score}/100") lines.append("") # Stats lines.append("### Statistics") lines.append(f"- Total States: {stats.get('total_states', 0)}") lines.append(f"- Reachability: {stats.get('reachability_pct', 0)}%") lines.append(f"- Total Transitions: {stats.get('total_transitions', 0)}") lines.append(f"- Valid Endings: {stats.get('valid_endings', 0)}") lines.append(f"- Dead Ends (unintentional): {stats.get('dead_end_count', 0)}") lines.append(f"- Orphaned States: {stats.get('orphaned_count', 0)}") lines.append("") # Errors if errors: lines.append("### Errors (Must Fix)") for err in errors: lines.append(f"- {err}") lines.append("") # Warnings if warnings: lines.append("### Warnings") for warn in warnings: lines.append(f"- {warn}") lines.append("") # Story Flags if flags: lines.append("### Story Flags") for flag in flags: severity_icon = "" if flag.get('severity') == 'warning' else "" lines.append(f"- {severity_icon} [{flag.get('state')}] {flag.get('message')}") lines.append("") if not errors and not warnings and not flags: lines.append("*No issues detected*") return "\n".join(lines) def get_llm_cohesion_prompts(): """ Return preset prompts for LLM-assisted story cohesion checks. """ return { "summarize_paths": """Analyze this game config and summarize each possible path through the story. For each major branch, describe: 1. The path taken (which states) 2. Key events/decisions 3. The ending reached Config: {config}""", "find_contradictions": """Review this game config for narrative contradictions or plot holes. Look for: 1. Events that contradict each other across branches 2. Character actions that don't make sense 3. World/setting inconsistencies 4. Logical impossibilities Config: {config}""", "tone_check": """Analyze the tone and mood consistency across this game config. Check: 1. Does the writing style stay consistent? 2. Are there jarring tone shifts? 3. Does the atmosphere match the story type? Config: {config}""", "improve_descriptions": """Review the descriptions in this game config and suggest improvements. For each state, note if the description: 1. Sets the scene adequately 2. Connects to the available choices 3. Maintains narrative flow Config: {config}""", "suggest_branches": """Analyze this game config and suggest additional branching opportunities. Look for: 1. States where more choices would make sense 2. Missing consequences for actions 3. Opportunities for alternate paths Config: {config}""" } # ============================================================ # LOGIC GATES VALIDATION # ============================================================ # Valid keys for condition expressions VALID_CONDITION_KEYS = { 'and', 'or', 'not', 'has_item', 'not_has_item', 'met_person', 'not_met_person', 'flag', 'not_flag', 'visited', 'not_visited', 'discovered', 'mission_complete', 'mission_active', 'mission_failed', 'money', 'counter', 'knowledge', 'knowledge_value', 'reputation', 'visit_count' } # Valid keys for effect specifications VALID_EFFECT_KEYS = { 'add_item', 'remove_item', 'add_money', 'remove_money', 'set_money', 'add_person', 'add_location', 'visit_location', 'set_flag', 'clear_flag', 'toggle_flag', 'set_counter', 'increment', 'decrement', 'set_knowledge', 'remove_knowledge', 'start_mission', 'complete_mission', 'fail_mission', 'update_mission', 'adjust_reputation', 'set_reputation' } # Valid keys for dynamic transitions VALID_TRANSITION_KEYS = { 'random', 'random_from', 'if', 'then', 'else', 'conditions', 'default' } def validate_condition(condition, path, issues): """ Recursively validate a condition expression. Args: condition: The condition to validate path: String path for error reporting issues: List to append issues to """ if condition is None or condition == {}: return if isinstance(condition, str): return # Simple flag name - valid if not isinstance(condition, dict): issues.append({ "type": "invalid_condition", "path": path, "message": f"Condition must be dict or string, got {type(condition).__name__}" }) return for key in condition: if key not in VALID_CONDITION_KEYS: issues.append({ "type": "unknown_condition_key", "path": path, "message": f"Unknown condition key: '{key}'" }) # Recurse into compound conditions if key in ('and', 'or'): if not isinstance(condition[key], list): issues.append({ "type": "invalid_condition", "path": path, "message": f"'{key}' must be a list" }) else: for i, sub in enumerate(condition[key]): validate_condition(sub, f"{path}.{key}[{i}]", issues) if key == 'not': validate_condition(condition[key], f"{path}.not", issues) # Validate numeric comparisons if key in ('money', 'counter', 'reputation', 'visit_count'): val = condition[key] if isinstance(val, dict): valid_comparisons = {'gte', 'lte', 'gt', 'lt', 'eq', 'neq', 'npc', 'state'} for cmp_key in val: if cmp_key not in valid_comparisons and not isinstance(val[cmp_key], dict): issues.append({ "type": "invalid_comparison", "path": f"{path}.{key}", "message": f"Unknown comparison operator: '{cmp_key}'" }) def validate_effects(effects, path, issues): """ Validate effect specifications. Args: effects: The effects dict to validate path: String path for error reporting issues: List to append issues to """ if not effects or not isinstance(effects, dict): return for key in effects: if key not in VALID_EFFECT_KEYS: issues.append({ "type": "unknown_effect_key", "path": path, "message": f"Unknown effect key: '{key}'" }) def validate_transition(transition, path, issues): """ Validate transition specification. Args: transition: The transition spec to validate path: String path for error reporting issues: List to append issues to """ if isinstance(transition, str): return # Simple string target - valid (basic check only) if not isinstance(transition, dict): issues.append({ "type": "invalid_transition", "path": path, "message": f"Transition must be string or dict, got {type(transition).__name__}" }) return # Check for unknown keys for key in transition: if key not in VALID_TRANSITION_KEYS: issues.append({ "type": "unknown_transition_key", "path": path, "message": f"Unknown transition key: '{key}'" }) # Validate random weights if 'random' in transition: weights = transition['random'] if not isinstance(weights, list): issues.append({ "type": "invalid_transition", "path": path, "message": "'random' must be a list of [state, weight] pairs" }) else: total = 0 for item in weights: if not isinstance(item, list) or len(item) != 2: issues.append({ "type": "invalid_transition", "path": path, "message": f"Random item must be [state, weight]: {item}" }) else: total += item[1] if isinstance(item[1], (int, float)) else 0 if abs(total - 1.0) > 0.01 and total > 0: issues.append({ "type": "weight_warning", "path": path, "message": f"Random weights sum to {total}, not 1.0 (will be normalized)" }) # Validate random_from pool if 'random_from' in transition: pool = transition['random_from'] if not isinstance(pool, list) or len(pool) == 0: issues.append({ "type": "invalid_transition", "path": path, "message": "'random_from' must be a non-empty list" }) # Validate conditional transition if 'if' in transition: validate_condition(transition['if'], f"{path}.if", issues) if 'then' not in transition and 'else' not in transition: issues.append({ "type": "invalid_transition", "path": path, "message": "Conditional transition needs 'then' or 'else'" }) if 'then' in transition: validate_transition(transition['then'], f"{path}.then", issues) if 'else' in transition: validate_transition(transition['else'], f"{path}.else", issues) # Validate chained conditions if 'conditions' in transition: has_default = False for i, cond_block in enumerate(transition['conditions']): if 'default' in cond_block: has_default = True validate_transition(cond_block['default'], f"{path}.conditions[{i}].default", issues) elif 'if' in cond_block: validate_condition(cond_block['if'], f"{path}.conditions[{i}].if", issues) if 'then' in cond_block: validate_transition(cond_block['then'], f"{path}.conditions[{i}].then", issues) else: issues.append({ "type": "invalid_transition", "path": f"{path}.conditions[{i}]", "message": "Condition block needs 'if' or 'default'" }) if not has_default: issues.append({ "type": "missing_default", "path": path, "message": "Chained conditions should have a 'default' fallback" }) def validate_logic_gates(config_input): """ Validate all logic gates (conditions, effects, transitions) in a config. Returns list of validation issues. """ data, error = parse_config(config_input) if error: return [{"type": "parse_error", "path": "", "message": error}] data, _ = flatten_nested_config(data) issues = [] for state_name, state_data in data.items(): if not isinstance(state_data, dict): continue base_path = state_name # Validate choice_config conditions choice_config = state_data.get('choice_config', {}) for choice, config in choice_config.items(): if isinstance(config, dict) and 'condition' in config: validate_condition( config['condition'], f"{base_path}.choice_config.{choice}.condition", issues ) # Validate effects effects = state_data.get('effects', {}) for choice, effect_spec in effects.items(): if isinstance(effect_spec, dict): validate_effects(effect_spec, f"{base_path}.effects.{choice}", issues) # Validate on_enter effects on_enter = state_data.get('on_enter') if on_enter and isinstance(on_enter, dict): validate_effects(on_enter, f"{base_path}.on_enter", issues) # Validate transitions (including dynamic ones) transitions = state_data.get('transitions', {}) for choice, trans in transitions.items(): validate_transition(trans, f"{base_path}.transitions.{choice}", issues) # Validate encounter_chance encounter = state_data.get('encounter_chance') if encounter and isinstance(encounter, dict): if 'bypass_conditions' in encounter: validate_condition( encounter['bypass_conditions'], f"{base_path}.encounter_chance.bypass_conditions", issues ) if 'probability' in encounter: prob = encounter['probability'] if not isinstance(prob, (int, float)) or prob < 0 or prob > 1: issues.append({ "type": "invalid_probability", "path": f"{base_path}.encounter_chance.probability", "message": f"Probability must be between 0 and 1, got {prob}" }) if 'pool' in encounter: pool = encounter['pool'] if not isinstance(pool, list) or len(pool) == 0: issues.append({ "type": "invalid_encounter_pool", "path": f"{base_path}.encounter_chance.pool", "message": "Encounter pool must be a non-empty list" }) return issues def get_logic_gates_summary(config_input): """ Get a summary of logic gates validation results. Returns formatted string for display. """ issues = validate_logic_gates(config_input) if not issues: return "**Logic Gates Validation:** No issues found" lines = ["**Logic Gates Validation Issues:**", ""] errors = [i for i in issues if i['type'] not in ('weight_warning', 'missing_default')] warnings = [i for i in issues if i['type'] in ('weight_warning', 'missing_default')] if errors: lines.append("### Errors") for issue in errors: lines.append(f"- [{issue['path']}] {issue['message']}") lines.append("") if warnings: lines.append("### Warnings") for issue in warnings: lines.append(f"- [{issue['path']}] {issue['message']}") lines.append("") return "\n".join(lines) # ==================== ACTIONABLE VALIDATION FUNCTIONS ==================== def get_validation_with_fixes(config_input): """ Enhanced validation that returns errors with suggested fixes. Returns dict with: - issues: List of issues with fix suggestions - quick_fixes: Dict of auto-fixable issues - stats: Config statistics """ data, error = parse_config(config_input) if error: return { "issues": [{"type": "parse_error", "message": error, "fixable": False}], "quick_fixes": {}, "stats": {} } data, is_nested = flatten_nested_config(data) issues = [] quick_fixes = {} all_states = set(data.keys()) for state_name, state_data in data.items(): if not isinstance(state_data, dict): continue choices = state_data.get('choices', []) transitions = state_data.get('transitions', {}) # Issue: Broken transitions for choice, target in transitions.items(): target_exists = False if target in all_states: target_exists = True elif '_' in target: # Check nested format for split_pos in range(len(target)): if target[split_pos] == '_': loc = target[:split_pos] st = target[split_pos+1:] if loc in all_states: target_exists = True break if not target_exists: # Suggest similar state names suggestions = find_similar_states(target, all_states) issues.append({ "type": "broken_transition", "state": state_name, "choice": choice, "target": target, "message": f"Transition '{choice}' points to non-existent state '{target}'", "suggestions": suggestions, "fixable": len(suggestions) > 0, "fix_type": "replace_target" }) if suggestions: quick_fixes[f"{state_name}|{choice}"] = { "action": "replace_transition_target", "old_target": target, "suggested_target": suggestions[0], "all_suggestions": suggestions } # Issue: Choice without transition choice_set = set(choices) if choices else set() transition_keys = set(transitions.keys()) missing_transitions = choice_set - transition_keys for missing in missing_transitions: # Suggest next logical state suggestions = suggest_next_states(state_name, all_states) issues.append({ "type": "missing_transition", "state": state_name, "choice": missing, "message": f"Choice '{missing}' has no transition defined", "suggestions": suggestions, "fixable": True, "fix_type": "add_transition" }) quick_fixes[f"{state_name}|{missing}|add"] = { "action": "add_transition", "choice": missing, "suggested_target": suggestions[0] if suggestions else "end" } # Issue: Dead end (not an ending state) if not transitions and not is_ending_state(state_name): issues.append({ "type": "dead_end", "state": state_name, "message": f"State '{state_name}' has no exits and doesn't appear to be an ending", "suggestions": ["Add transitions to continue the story", "Rename to indicate it's an ending (e.g., ending_X)"], "fixable": True, "fix_type": "add_ending_transition" }) quick_fixes[f"{state_name}|dead_end"] = { "action": "convert_to_ending", "add_choices": ["restart"], "add_transitions": {"restart": list(all_states)[0]} } # Issue: Orphaned state targeted_states = set() for s, sd in data.items(): if isinstance(sd, dict): for t in sd.get('transitions', {}).values(): targeted_states.add(t) if '_' in t: parts = t.split('_', 1) targeted_states.add(parts[0]) first_state = list(data.keys())[0] if data else None if state_name not in targeted_states and state_name != first_state and not is_ending_state(state_name): # Find states that could link here potential_sources = find_potential_source_states(state_name, data) issues.append({ "type": "orphaned_state", "state": state_name, "message": f"State '{state_name}' is not reachable from any other state", "suggestions": [f"Add a transition from '{s}' to '{state_name}'" for s in potential_sources[:3]], "fixable": len(potential_sources) > 0, "fix_type": "add_incoming_transition" }) # Calculate stats stats = { "total_states": len(all_states), "total_issues": len(issues), "fixable_issues": len([i for i in issues if i.get("fixable")]), "issue_types": {} } for issue in issues: issue_type = issue["type"] stats["issue_types"][issue_type] = stats["issue_types"].get(issue_type, 0) + 1 return { "issues": issues, "quick_fixes": quick_fixes, "stats": stats } def find_similar_states(target, all_states, threshold=0.6): """Find states with similar names to the target.""" suggestions = [] for state in all_states: # Simple similarity: common characters ratio common = set(target.lower()) & set(state.lower()) similarity = len(common) / max(len(target), len(state)) if similarity >= threshold: suggestions.append(state) # Also check if target is a substring or vice versa if target.lower() in state.lower() or state.lower() in target.lower(): if state not in suggestions: suggestions.append(state) return suggestions[:5] def suggest_next_states(current_state, all_states): """Suggest logical next states based on naming patterns.""" suggestions = [] # If current state has a number, suggest the next number import re match = re.search(r'(\d+)$', current_state) if match: num = int(match.group(1)) next_name = current_state[:match.start()] + str(num + 1) if next_name in all_states: suggestions.append(next_name) # Suggest 'end' if it exists if 'end' in all_states: suggestions.append('end') # Suggest states that start with similar prefix prefix = current_state.split('_')[0] if '_' in current_state else current_state[:3] for state in all_states: if state.startswith(prefix) and state != current_state and state not in suggestions: suggestions.append(state) return suggestions[:5] def find_potential_source_states(target_state, data): """Find states that could logically link to the target.""" potential = [] # States with similar names/prefixes target_prefix = target_state.split('_')[0] if '_' in target_state else target_state[:3] for state_name, state_data in data.items(): if state_name == target_state: continue if not isinstance(state_data, dict): continue # Check if this state has room for more choices choices = state_data.get('choices', []) if len(choices) < 4: # Can add more choices # Prefer states with similar prefix if state_name.startswith(target_prefix) or target_state.startswith(state_name.split('_')[0]): potential.insert(0, state_name) else: potential.append(state_name) return potential def is_ending_state(state_name): """Check if a state name indicates it's an ending.""" name_lower = state_name.lower() return ( name_lower == 'end' or name_lower.startswith('end_') or name_lower.startswith('ending_') or '_end' in name_lower or '_ending' in name_lower or 'finale' in name_lower or 'conclusion' in name_lower or 'victory' in name_lower or 'defeat' in name_lower or 'death' in name_lower ) def apply_quick_fix(config_input, fix_key, fix_data): """ Apply a quick fix to the config. Args: config_input: JSON config string fix_key: Key identifying the fix (e.g., "state|choice") fix_data: Fix data from quick_fixes dict Returns: Updated JSON config string """ data, error = parse_config(config_input) if error: return config_input data, is_nested = flatten_nested_config(data) action = fix_data.get("action") if action == "replace_transition_target": parts = fix_key.split("|") if len(parts) >= 2: state_name, choice = parts[0], parts[1] if state_name in data and "transitions" in data[state_name]: data[state_name]["transitions"][choice] = fix_data["suggested_target"] elif action == "add_transition": parts = fix_key.split("|") if len(parts) >= 2: state_name, choice = parts[0], parts[1] if state_name in data: if "transitions" not in data[state_name]: data[state_name]["transitions"] = {} data[state_name]["transitions"][choice] = fix_data["suggested_target"] elif action == "convert_to_ending": parts = fix_key.split("|") state_name = parts[0] if state_name in data: data[state_name]["choices"] = fix_data.get("add_choices", []) data[state_name]["transitions"] = fix_data.get("add_transitions", {}) return json.dumps(data, indent=2) def apply_all_quick_fixes(config_input): """ Apply all auto-fixable issues to the config. Returns: Tuple of (updated_config, fixes_applied_count, fixes_log) """ validation = get_validation_with_fixes(config_input) quick_fixes = validation["quick_fixes"] if not quick_fixes: return config_input, 0, ["No auto-fixable issues found"] current_config = config_input fixes_log = [] for fix_key, fix_data in quick_fixes.items(): try: current_config = apply_quick_fix(current_config, fix_key, fix_data) fixes_log.append(f"Applied: {fix_data['action']} for {fix_key}") except Exception as e: fixes_log.append(f"Failed: {fix_data['action']} for {fix_key} - {str(e)}") return current_config, len(quick_fixes), fixes_log def get_state_issues_map(config_input): """ Get a mapping of state names to their issues for inline highlighting. Returns: Dict mapping state_name -> list of issues """ validation = get_validation_with_fixes(config_input) issues = validation["issues"] state_issues = {} for issue in issues: state = issue.get("state", "global") if state not in state_issues: state_issues[state] = [] state_issues[state].append(issue) return state_issues def format_issues_for_display(issues): """ Format issues list as markdown for Gradio display. """ if not issues: return "No issues found!" lines = ["## Validation Issues\n"] # Group by type by_type = {} for issue in issues: t = issue["type"] if t not in by_type: by_type[t] = [] by_type[t].append(issue) type_icons = { "broken_transition": "🔴", "missing_transition": "🟠", "dead_end": "🟡", "orphaned_state": "⚪", "parse_error": "❌" } type_names = { "broken_transition": "Broken Transitions", "missing_transition": "Missing Transitions", "dead_end": "Dead Ends", "orphaned_state": "Orphaned States", "parse_error": "Parse Errors" } for issue_type, type_issues in by_type.items(): icon = type_icons.get(issue_type, "⚠️") name = type_names.get(issue_type, issue_type) lines.append(f"### {icon} {name} ({len(type_issues)})\n") for issue in type_issues: state = issue.get("state", "") message = issue.get("message", "") fixable = "✅ Auto-fixable" if issue.get("fixable") else "" lines.append(f"- **{state}**: {message} {fixable}") suggestions = issue.get("suggestions", []) if suggestions: lines.append(f" - Suggestions: {', '.join(suggestions[:3])}") lines.append("") return "\n".join(lines)