"""Configuration-backed product profile for a reusable agent console.""" from __future__ import annotations from dataclasses import dataclass import json import os from pathlib import Path from typing import Any, Mapping class AppProfileError(RuntimeError): """Safe error for an invalid product profile.""" @dataclass(frozen=True) class AppProfile: product_name: str short_name: str page_title: str page_icon: str header_title: str header_description: str login_kicker: str login_title: str login_description: str login_footer: str primary_color: str text_color: str muted_color: str border_color: str _ENV_FIELD_NAMES = { "product_name": "AGENT_CONSOLE_NAME", "short_name": "AGENT_CONSOLE_SHORT_NAME", "page_title": "AGENT_CONSOLE_PAGE_TITLE", "page_icon": "AGENT_CONSOLE_PAGE_ICON", "header_title": "AGENT_CONSOLE_HEADER_TITLE", "header_description": "AGENT_CONSOLE_HEADER_DESCRIPTION", "login_kicker": "AGENT_CONSOLE_LOGIN_KICKER", "login_title": "AGENT_CONSOLE_LOGIN_TITLE", "login_description": "AGENT_CONSOLE_LOGIN_DESCRIPTION", "login_footer": "AGENT_CONSOLE_LOGIN_FOOTER", "primary_color": "AGENT_CONSOLE_PRIMARY_COLOR", "text_color": "AGENT_CONSOLE_TEXT_COLOR", "muted_color": "AGENT_CONSOLE_MUTED_COLOR", "border_color": "AGENT_CONSOLE_BORDER_COLOR", } def _string(section: Mapping[str, Any], key: str, fallback: str = "") -> str: return str(section.get(key) or fallback).strip() def resolve_profile_path(default_path: Path) -> Path: configured = os.environ.get("AGENT_CONSOLE_PROFILE_PATH", "").strip() if not configured: return default_path path = Path(configured).expanduser() return path if path.is_absolute() else default_path.parent / path def _dotenv_value(name: str, path: Path | None) -> str: """Read one simple KEY=value runtime setting without importing a dotenv lib.""" if path is None: return "" try: lines = path.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeError): return "" prefix = f"{name}=" for line in lines: stripped = line.strip() if stripped.startswith(prefix): return stripped[len(prefix) :].strip().strip('"').strip("'") return "" def _apply_environment_overrides(profile: AppProfile, env_file: Path | None) -> AppProfile: """Apply deployment-specific presentation values without a code change.""" values = { field_name: ( os.environ.get(env_name, "").strip() or _dotenv_value(env_name, env_file) or getattr(profile, field_name) ) for field_name, env_name in _ENV_FIELD_NAMES.items() } return AppProfile(**values) def load_app_profile(default_path: Path, env_file: Path | None = None) -> AppProfile: """Load the selectable product skin without coupling it to a PoC name.""" path = resolve_profile_path(default_path) try: payload: Any = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, ValueError): raise AppProfileError(f"애플리케이션 프로필을 읽지 못했습니다: {path}") from None if not isinstance(payload, Mapping): raise AppProfileError("애플리케이션 프로필 형식이 올바르지 않습니다.") product = payload.get("product") theme = payload.get("theme") if not isinstance(product, Mapping) or not isinstance(theme, Mapping): raise AppProfileError("애플리케이션 프로필에 product와 theme 객체가 필요합니다.") profile = _apply_environment_overrides(AppProfile( product_name=_string(product, "name"), short_name=_string(product, "short_name"), page_title=_string(product, "page_title"), page_icon=_string(product, "page_icon", "🤖"), header_title=_string(product, "header_title"), header_description=_string(product, "header_description"), login_kicker=_string(product, "login_kicker"), login_title=_string(product, "login_title"), login_description=_string(product, "login_description"), login_footer=_string(product, "login_footer"), primary_color=_string(theme, "primary_color"), text_color=_string(theme, "text_color"), muted_color=_string(theme, "muted_color"), border_color=_string(theme, "border_color"), ), env_file) required = ( profile.product_name, profile.short_name, profile.page_title, profile.header_title, profile.login_title, profile.primary_color, profile.text_color, profile.muted_color, profile.border_color, ) if not all(required): raise AppProfileError("애플리케이션 프로필의 필수 표시값 또는 색상이 비어 있습니다.") return profile