Mastodon Politics, Power, and Science: Implementation Plan for configuring multiple ai services and allowing model and service overrides at any level

Thursday, December 11, 2025

Implementation Plan for configuring multiple ai services and allowing model and service overrides at any level

Phase 1: The Database - The Source of Truth

We'll add a new table to store all settings as key-value pairs with a defined scope.

File: CodexProject/codex_engine/core/db_manager.py

  1. Add the settings table to _initialize_tables:

    Python
    """CREATE TABLE IF NOT EXISTS settings (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        scope TEXT NOT NULL,       -- e.g., 'global', 'campaign'
        scope_id INTEGER,          -- e.g., campaign_id (NULL for global)
        key TEXT NOT NULL,
        value TEXT,
        UNIQUE(scope, scope_id, key)
    );""",
      

  2. Add new methods to manage these settings:

    Python
    # At the end of db_manager.py
    
    def get_setting(self, key: str, campaign_id: int = None) -> Optional[str]:
        """
        Gets a setting value, cascading from campaign-specific to global.
        """
        with self.get_connection() as conn:
            # 1. Try to get campaign-specific setting first
            if campaign_id:
                row = conn.execute(
                    "SELECT value FROM settings WHERE scope='campaign' AND scope_id=? AND key=?",
                    (campaign_id, key)
                ).fetchone()
                if row:
                    return json.loads(row['value'])
    
            # 2. Fallback to global setting
            row = conn.execute(
                "SELECT value FROM settings WHERE scope='global' AND key=?", (key,)
            ).fetchone()
            if row:
                return json.loads(row['value'])
        return None
    
    def set_setting(self, key: str, value: Any, scope: str = 'global', scope_id: int = None):
        """
        Saves a setting for a specific scope.
        """
        val_json = json.dumps(value)
        with self.get_connection() as conn:
            conn.execute(
                """INSERT INTO settings (scope, scope_id, key, value) VALUES (?, ?, ?, ?)
                   ON CONFLICT(scope, scope_id, key) DO UPDATE SET value=excluded.value""",
                (scope, scope_id, key, val_json)
            )
            conn.commit()
      


Phase 2: The Logic - The Cascade Manager

The ConfigManager will now use the database and understand the global/campaign hierarchy.

File: CodexProject/codex_engine/core/config_manager.py (Replace this file)

Python

import os
from .db_manager import DBManager

class ConfigManager:
    def __init__(self, db_manager: DBManager):
        self.db = db_manager
        self.defaults = {
            "ai_provider": "gemini",
            "api_key_env_var": "GEMINI_API_KEY",
            "base_url": None,
            "model_name": None # No default model, force user selection
        }

    def get(self, key: str, campaign_id: int = None):
        """
        Gets a setting, cascading from campaign to global to hardcoded defaults.
        """
        value = self.db.get_setting(key, campaign_id)
        if value is not None:
            return value
        return self.defaults.get(key)

    def set(self, key: str, value, campaign_id: int = None):
        """
        Saves a setting. If campaign_id is provided, saves as campaign-specific.
        Otherwise, saves as global.
        """
        scope = 'campaign' if campaign_id else 'global'
        self.db.set_setting(key, value, scope, campaign_id)
  

Phase 3: The UI - The Generic Settings Editor

This is the core of the new system. It's a powerful, data-driven modal that can build any settings panel you define.

File: CodexProject/codex_engine/ui/settings_editor.py (New File - Replaces ai_settings.py)

Python

import pygame
import os
from .widgets import Button, InputBox, Dropdown
from ..core.ai_manager import AIManager
from ..core.config_manager import ConfigManager

# Provider definitions remain useful
PROVIDER_DEFAULTS = {
    "gemini": {"api_key_env_var": "GEMINI_API_KEY", "base_url": ""},
    "openai": {"api_key_env_var": "OPENAI_API_KEY", "base_url": ""},
    "ollama": {"api_key_env_var": "", "base_url": "http://localhost:11434/v1"},
    "groq": {"api_key_env_var": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1"}
}

class GenericSettingsEditor:
    def __init__(self, screen, config_manager: ConfigManager, tab_definitions, scope_id=None):
        self.screen = screen
        self.config_manager = config_manager
        self.tab_definitions = tab_definitions
        self.scope_id = scope_id # e.g., campaign_id
        
        # --- UI State ---
        self.font_title = pygame.font.Font(None, 48)
        self.font_ui = pygame.font.Font(None, 24)
        self.active_tab_key = list(self.tab_definitions.keys())[0]
        self.widgets = {} # {key: widget_instance}
        self.tab_buttons = {}
        self.status_text = "Ready."
        self.status_color = (200, 200, 200)

        self._build_ui_layout()
        self.run_loop()

    def _build_ui_layout(self):
        # ... Main panel, save/cancel buttons ...
        self.panel_rect = pygame.Rect(0, 0, 700, 550)
        self.panel_rect.center = self.screen.get_rect().center
        x, y = self.panel_rect.topleft

        # Build Tab Buttons
        num_tabs = len(self.tab_definitions)
        tab_w = (self.panel_rect.width - 40) / num_tabs
        for i, key in enumerate(self.tab_definitions.keys()):
            self.tab_buttons[key] = Button(
                x + 20 + (i * tab_w), y + 60, tab_w, 30, key, self.font_ui,
                (60,60,70), (80,80,90), (255,255,255), lambda k=key: self._set_active_tab(k)
            )

        self.btn_save = Button(x + 20, self.panel_rect.bottom - 60, 100, 40, "Save", self.font_ui, (50, 150, 50), (80, 200, 80), (255,255,255), self.save_settings)
        self.btn_cancel = Button(x + 140, self.panel_rect.bottom - 60, 100, 40, "Cancel", self.font_ui, (150, 50, 50), (200, 80, 80), (255,255,255), None)
        
        self._build_widgets_for_active_tab()
    
    def _set_active_tab(self, key):
        self.active_tab_key = key
        self._build_widgets_for_active_tab()

    def _build_widgets_for_active_tab(self):
        self.widgets = {}
        widget_defs = self.tab_definitions[self.active_tab_key]
        
        y_offset = 110
        for w_def in widget_defs:
            key = w_def['key']
            label = w_def['label']
            w_type = w_def['type']
            x, y = self.panel_rect.x + 20, self.panel_rect.y + y_offset
            
            # Draw label for the widget
            w_def['label_surface'] = self.font_ui.render(label, True, (200,200,200))
            w_def['label_pos'] = (x, y)
            y_offset += 25 # Space for the widget itself

            current_value = self.config_manager.get(key, self.scope_id)

            if w_type == 'input':
                self.widgets[key] = InputBox(x, y, 400, 30, self.font_ui, current_value or "")
            elif w_type == 'dropdown':
                opts = w_def.get('options', [])
                self.widgets[key] = Dropdown(x, y, 400, 30, self.font_ui, opts, initial_id=current_value)
            elif w_type == 'button':
                action = getattr(self, w_def['action'], None) # Find method by name
                self.widgets[key] = Button(x, y, 250, 40, label, self.font_ui, (100,100,150), (120,120,180), (255,255,255), action)
            
            y_offset += w_def.get('height', 40) + 20 # Spacing

    def save_settings(self):
        for key, widget in self.widgets.items():
            value = None
            if isinstance(widget, InputBox):
                value = widget.text
            elif isinstance(widget, Dropdown):
                value = widget.get_selected_id()
            
            if value is not None:
                self.config_manager.set(key, value, self.scope_id)
        
        print("Settings Saved!")
        return "close" # Signal to the run_loop

    def run_loop(self):
        # ... (similar to AISettingsEditor's loop, handling events for self.widgets)
        running = True
        while running:
            # Event Handling
            for event in pygame.event.get():
                if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
                    running = False; return
                
                # Check for Save/Cancel button clicks
                if self.btn_save.handle_event(event) == "close":
                    running = False; return
                if event.type == pygame.MOUSEBUTTONDOWN and self.btn_cancel.rect.collidepoint(event.pos):
                    running = False; return

                # Handle dynamic widgets
                for btn in self.tab_buttons.values(): btn.handle_event(event)
                for key, widget in self.widgets.items():
                    res = widget.handle_event(event)
                    if key == "ai_provider" and res: self._on_provider_change()
            
            # Drawing
            self.draw()
            pygame.display.flip()
            pygame.time.Clock().tick(30)

    # --- ACTION METHODS FOR BUTTONS ---
    def test_ai_connection(self):
        provider = self.widgets['ai_provider'].get_selected_id()
        key_var = self.widgets['api_key_env_var'].text
        base_url = self.widgets['base_url'].text or None
        
        self.status_text = f"Testing {provider}..."
        self.status_color = (200, 200, 100)
        self.draw(); pygame.display.flip() # Force redraw

        success, data = AIManager.test_and_get_models(provider, key_var, base_url)

        if success:
            self.status_text = f"Success! Found {len(data)} models."
            self.status_color = (100, 200, 100)
            self.widgets['model_name'].options = [{"id": m, "name": m} for m in data]
        else:
            self.status_text = f"Error: {data}"
            self.status_color = (200, 100, 100)
            self.widgets['model_name'].options = []
        
        self.widgets['model_name'].selected_idx = -1

    def _on_provider_change(self):
        """Auto-fill defaults when provider dropdown changes."""
        provider = self.widgets['ai_provider'].get_selected_id()
        defaults = PROVIDER_DEFAULTS.get(provider, {})
        self.widgets['api_key_env_var'].text = defaults.get('api_key_env_var', "")
        self.widgets['base_url'].text = defaults.get('base_url', "") or ""
        self.widgets['model_name'].options = []
        self.widgets['model_name'].selected_idx = -1
        self.status_text = "Provider changed. Please test connection."
        self.status_color = (200, 200, 200)

    def draw(self):
        # Overlay and panel
        overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 150))
        self.screen.blit(overlay, (0,0))
        pygame.draw.rect(self.screen, (40, 40, 50), self.panel_rect, border_radius=10)
        pygame.draw.rect(self.screen, (100, 100, 120), self.panel_rect, 2, border_radius=10)

        x, y = self.panel_rect.topleft
        
        # Title
        title_text = "Global Settings" if not self.scope_id else f"Campaign Settings"
        title_surf = self.font_title.render(title_text, True, (255, 255, 255))
        self.screen.blit(title_surf, (x + 20, y + 15))

        # Tabs
        for key, btn in self.tab_buttons.items():
            btn.base_color = (80, 80, 90) if key == self.active_tab_key else (60, 60, 70)
            btn.draw(self.screen)
        
        # Widgets and labels
        widget_defs = self.tab_definitions[self.active_tab_key]
        for w_def in widget_defs:
            self.screen.blit(w_def['label_surface'], w_def['label_pos'])
            widget = self.widgets.get(w_def['key'])
            if widget:
                widget.draw(self.screen)

        # Status text for AI tab
        if self.active_tab_key == "AI":
            status_surf = self.font_ui.render(self.status_text, True, self.status_color)
            self.screen.blit(status_surf, (self.panel_rect.x + 280, self.panel_rect.bottom - 100))

        # Save/Cancel
        self.btn_save.draw(self.screen)
        self.btn_cancel.draw(self.screen)
  

Phase 4: Application Integration

Now, we tie it all together in the main application.

File: CodexProject/main.py (Update this)

Python

# --- IMPORTS ---
from codex_engine.core.db_manager import DBManager
from codex_engine.core.config_manager import ConfigManager # <-- Add
from codex_engine.core.ai_manager import AIManager         # <-- Add
from codex_engine.ui.settings_editor import GenericSettingsEditor, PROVIDER_DEFAULTS # <-- Add
# ... other imports

class CodexApp:
    def __init__(self):
        # ... existing setup ...
        self.db = DBManager()
        self.config = ConfigManager(self.db) # <-- Create ConfigManager
        self.ai = AIManager(self.config)      # <-- Pass it to AIManager
        
        self.state = "MENU"
        self.current_campaign = None
        
        self.menu_screen = CampaignMenu(self.screen, self.db, self.config) # <-- Pass config
        self.map_viewer = None
        # ...

    def load_campaign(self, campaign_id, theme_id):
        # ...
        # --- RECONFIGURE AI FOR CAMPAIGN CONTEXT ---
        self.ai.reconfigure_for_campaign(campaign_id)
        # ---
        
        if not self.map_viewer:
            # Pass campaign-aware AI to MapViewer
            self.map_viewer = MapViewer(self.screen, self.theme_manager, self.ai)
        # ...
  

File: CodexProject/codex_engine/core/ai_manager.py (Update AIManager)
You need to add the reconfigure method and pass the config manager in __init__.

Python

class AIManager:
    def __init__(self, config_manager: ConfigManager): # <-- Change
        self.config_manager = config_manager
        self.provider = None
        self.reconfigure_for_campaign(None) # Load global config initially

    def reconfigure_for_campaign(self, campaign_id: int = None):
        provider_name = self.config_manager.get("ai_provider", campaign_id)
        # ... rest of the loading logic from Phase 4 ...
  

And pass the config_manager instance from CodexApp into your CampaignMenu.

File: CodexProject/codex_engine/ui/campaign_menu.py (Final UI Hook)

Python

# In CampaignMenu __init__
self.config = config_manager
self.btn_settings = Button(..., self.open_settings)

def open_settings(self):
    # This defines the tabs and widgets for the Global settings panel
    ai_tab_def = [
        {'type': 'dropdown', 'label': 'AI Provider:', 'key': 'ai_provider', 
         'options': [{"id": k, "name": k.title()} for k in PROVIDER_DEFAULTS.keys()]},
        {'type': 'input', 'label': 'API Key Environment Variable:', 'key': 'api_key_env_var'},
        {'type': 'input', 'label': 'Base URL:', 'key': 'base_url'},
        {'type': 'button', 'label': 'Test & Fetch Models', 'key': 'test_button', 'action': 'test_ai_connection'},
        {'type': 'dropdown', 'label': 'Selected Model:', 'key': 'model_name', 'options': []}
    ]
    
    tab_definitions = {
        "AI": ai_tab_def,
        "General": [ # Example of another tab
            {'type': 'input', 'label': 'GM Name:', 'key': 'gm_name'}
        ]
    }
    
    # Launch the generic editor with a GLOBAL scope (scope_id=None)
    GenericSettingsEditor(self.screen, self.config, tab_definitions)
  

Now you have a system where:

  1. The main menu configures Global settings.

  2. You can add a button inside the MapViewer to open the same editor but pass scope_id=self.current_campaign['id'].

  3. The AIManager will automatically use the campaign-specific model for your Noir game, and the global default for your D&D game.

No comments:

Post a Comment

h vs ℏ: A Proof That Planck's Constant Is a Coordinate Choice, Not Physics

J. Rogers, SE Ohio Abstract We prove that the choice between h (Planck's constant) and ℏ (reduced Planck's constant) represents a co...