Source code for core.ai.context_builder

"""Builds context about the user's project and current editor state for AI chat."""
from __future__ import annotations

import os
import json
from typing import Any, Dict, List, Optional

from core.logger import get_logger

_logger = get_logger("ai.context")


[docs] class ContextBuilder: """Collects project structure, scene info, and user scripts to provide rich context to the AI assistant.""" def __init__(self): self.project_path: str = "" self._scene_getter = None # callable that returns current Scene self._selected_getter = None # callable that returns selected entities
[docs] def set_project_path(self, path: str): self.project_path = path or ""
[docs] def set_scene_getter(self, getter): """Set a callable that returns the current Scene object.""" self._scene_getter = getter
[docs] def set_selected_entities_getter(self, getter): """Set a callable that returns the list of selected entities.""" self._selected_getter = getter
[docs] def build_context(self) -> str: """Build a context string describing the user's project and current state.""" parts = [] # Project structure structure = self._scan_project_structure() if structure: parts.append("## Current Project Structure\n" + structure) # Current scene info scene_info = self._get_scene_context() if scene_info: parts.append("## Current Scene\n" + scene_info) # Selected entity details selected_info = self._get_selected_entities_context() if selected_info: parts.append("## Currently Selected Entities\n" + selected_info) # Project config config_info = self._get_project_config() if config_info: parts.append("## Project Configuration\n" + config_info) return "\n\n".join(parts)
# ------------------------------------------------------------------ # Project scanning # ------------------------------------------------------------------ def _scan_project_structure(self) -> str: if not self.project_path or not os.path.isdir(self.project_path): return "" lines = [] scenes = [] scripts = [] prefabs = [] assets = [] for root, dirs, files in os.walk(self.project_path): # Skip hidden dirs and __pycache__ dirs[:] = [d for d in dirs if not d.startswith('.') and d != '__pycache__'] rel_root = os.path.relpath(root, self.project_path) for f in files: rel_path = os.path.join(rel_root, f) if rel_root != "." else f rel_path = rel_path.replace("\\", "/") if f.endswith(".scene"): scenes.append(rel_path) elif f.endswith(".py") and not f.startswith("__"): scripts.append(rel_path) elif f.endswith(".entity"): prefabs.append(rel_path) elif f.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".wav", ".ogg", ".mp3")): assets.append(rel_path) if scenes: lines.append("**Scenes:** " + ", ".join(scenes)) if scripts: lines.append("**Scripts:** " + ", ".join(scripts[:30])) if len(scripts) > 30: lines.append(f" ... and {len(scripts) - 30} more scripts") if prefabs: lines.append("**Prefabs:** " + ", ".join(prefabs[:20])) if assets: lines.append(f"**Assets:** {len(assets)} files (images, audio, etc.)") return "\n".join(lines) def _get_scene_context(self) -> str: if not self._scene_getter: return "" try: scene = self._scene_getter() if not scene or not scene.world: return "" world = scene.world entities = world.entities # Build systems info systems_lines = [] if hasattr(world, 'systems'): sys_count = len(world.systems) systems_lines.append(f"**Active Systems ({sys_count}):**") sys_names = [type(sys).__name__ for sys in world.systems] systems_lines.append(", ".join(sys_names)) lines = [] if systems_lines: lines.extend(systems_lines) lines.append("") lines.append(f"**Entity count:** {len(entities)}") for entity in entities[:50]: comp_names = ", ".join(type(c).__name__ for c in entity.components.values()) parent_info = f" (parent: {entity.parent.name})" if entity.parent else "" groups_info = f" [groups: {', '.join(entity.groups)}]" if entity.groups else "" tags_info = f" [tags: {', '.join(entity.tags)}]" if entity.tags else "" lines.append(f"- **{entity.name}**{parent_info}: {comp_names}{groups_info}{tags_info}") if len(entities) > 50: lines.append(f"... and {len(entities) - 50} more entities") return "\n".join(lines) except Exception as e: _logger.warning("Error getting scene context", error=str(e)) return "" def _get_selected_entities_context(self) -> str: if not self._selected_getter: return "" try: selected = self._selected_getter() if not selected: return "No entities selected." lines = [] for entity in selected: lines.append(f"### {entity.name}") lines.append(f"- Layer: {entity.layer}") if entity.groups: lines.append(f"- Groups: {', '.join(entity.groups)}") if entity.tags: lines.append(f"- Tags: {', '.join(entity.tags)}") for comp_type, comp in entity.components.items(): comp_name = comp_type.__name__ props = self._get_component_properties(comp) if props: lines.append(f"- **{comp_name}**: {props}") else: lines.append(f"- **{comp_name}**") return "\n".join(lines) except Exception as e: _logger.warning("Error getting selected entities", error=str(e)) return "" def _get_component_properties(self, comp) -> str: """Extract key properties from a component for display.""" skip = {"entity", "instance", "started", "image", "_module_key", "_loaded_script_path", "_loaded_class_name", "_loaded_mtime", "_coroutine_manager", "_tween_manager", "_events", "_previous_transform_state", "_current_transform_state"} props = {} for key in dir(comp): if key.startswith("_") or key in skip: continue if callable(getattr(type(comp), key, None)) and isinstance(getattr(type(comp), key, None), property): try: props[key] = getattr(comp, key) except Exception: pass elif not callable(getattr(comp, key, None)): try: val = getattr(comp, key) if not callable(val): props[key] = val except Exception: pass if not props: return "" parts = [] for k, v in list(props.items())[:10]: parts.append(f"{k}={v!r}") return ", ".join(parts) def _get_project_config(self) -> str: if not self.project_path: return "" config_path = os.path.join(self.project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r") as f: config = json.load(f) # Only include relevant keys relevant = {} for key in ("game_name", "resolution", "display", "layers", "groups", "input_actions", "lighting"): if key in config: relevant[key] = config[key] return json.dumps(relevant, indent=2) except Exception: return ""
[docs] def get_user_script_content(self, script_path: str) -> str: """Read the content of a user script file.""" if not script_path: return "" # Try absolute path first if os.path.isabs(script_path) and os.path.exists(script_path): try: with open(script_path, "r", encoding="utf-8") as f: return f.read() except Exception: return "" # Try relative to project path if self.project_path: full_path = os.path.join(self.project_path, script_path) if os.path.exists(full_path): try: with open(full_path, "r", encoding="utf-8") as f: return f.read() except Exception: pass return ""