refs #701: modularize HMM console profile and scenarios
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Reusable presentation shell for MCP-backed Streamlit agent consoles."""
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Shared, intentionally small Streamlit presentation primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
from typing import Any
|
||||
|
||||
from .profile import AppProfile
|
||||
|
||||
|
||||
def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
||||
"""Apply one predictable light theme from the product profile."""
|
||||
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
:root {{ color-scheme: light !important; --console-primary: {escape(profile.primary_color)};
|
||||
--console-text: {escape(profile.text_color)}; --console-muted: {escape(profile.muted_color)};
|
||||
--console-border: {escape(profile.border_color)}; }}
|
||||
html, body, [data-testid="stAppViewContainer"], .stApp {{ background:#fff !important;
|
||||
color:var(--console-text) !important; color-scheme:light !important;
|
||||
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
|
||||
header[data-testid="stHeader"] {{ display:none !important; }}
|
||||
.block-container {{ max-width:1180px; padding:2rem 3rem 4rem; }}
|
||||
section[data-testid="stSidebar"], section[data-testid="stSidebar"] > div {{ background:#fff !important; }}
|
||||
section[data-testid="stSidebar"] {{ border-right:1px solid var(--console-border); }}
|
||||
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
|
||||
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
|
||||
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
|
||||
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
|
||||
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
|
||||
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||
border-radius:4px !important; box-shadow:none !important; }}
|
||||
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
||||
border-radius:4px !important; box-shadow:none !important; }}
|
||||
div[data-testid="stButton"] > button[kind="primary"],
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||
div[data-testid="stButton"] > button[kind="primary"] *,
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{
|
||||
color:#fff !important; -webkit-text-fill-color:#fff !important; }}
|
||||
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
||||
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
||||
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
||||
.console-muted {{ color:var(--console-muted) !important; }}
|
||||
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
|
||||
.console-login {{ text-align:left; }}
|
||||
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
|
||||
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_console_header(st: Any, profile: AppProfile) -> None:
|
||||
st.markdown(
|
||||
f"""<section class="console-header"><div class="console-wordmark">{escape(profile.short_name)}</div>
|
||||
<h1>{escape(profile.header_title)}</h1><p class="console-muted">{escape(profile.header_description)}</p></section>""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_login_brand(st: Any, profile: AppProfile) -> None:
|
||||
st.markdown(
|
||||
f"""<section class="console-login"><div class="console-wordmark">{escape(profile.short_name)}</div>
|
||||
<p class="console-muted">{escape(profile.login_kicker)}</p><h1>{escape(profile.login_title)}</h1>
|
||||
<p class="console-muted">{escape(profile.login_description)}</p></section>""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
118
poc4_active_source_20260714/src/agent_console/profile.py
Normal file
118
poc4_active_source_20260714/src/agent_console/profile.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""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 _apply_environment_overrides(profile: AppProfile) -> AppProfile:
|
||||
"""Apply deployment-specific presentation values without a code change."""
|
||||
|
||||
values = {
|
||||
field_name: os.environ.get(env_name, "").strip() 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) -> 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"),
|
||||
))
|
||||
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
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Small, self-contained Smilegate presentation layer for the PoC4 Streamlit app."""
|
||||
|
||||
HMM_CLEAN_THEME_CSS = """
|
||||
<style>
|
||||
:root {
|
||||
color-scheme:light !important;
|
||||
--hmm-navy:#e05a33; --hmm-ink:#252525; --hmm-muted:#6b6b6b; --hmm-line:#e7e2df;
|
||||
}
|
||||
html, body, [data-testid="stAppViewContainer"], .stApp {
|
||||
background:#fff !important; color:var(--hmm-ink) !important;
|
||||
color-scheme:light !important;
|
||||
font-family:"Noto Sans KR","Malgun Gothic",sans-serif;
|
||||
}
|
||||
[data-testid="stAppViewContainer"] p,
|
||||
[data-testid="stAppViewContainer"] span,
|
||||
[data-testid="stAppViewContainer"] label,
|
||||
[data-testid="stAppViewContainer"] h1,
|
||||
[data-testid="stAppViewContainer"] h2,
|
||||
[data-testid="stAppViewContainer"] h3,
|
||||
[data-testid="stAppViewContainer"] input,
|
||||
[data-testid="stAppViewContainer"] textarea {
|
||||
color:var(--hmm-ink) !important;
|
||||
-webkit-text-fill-color:var(--hmm-ink) !important;
|
||||
}
|
||||
header[data-testid="stHeader"] { display:none !important; }
|
||||
.block-container { max-width:1180px; padding:2rem 3rem 4rem; }
|
||||
section[data-testid="stSidebar"], section[data-testid="stSidebar"] > div {
|
||||
background:#fff !important;
|
||||
}
|
||||
section[data-testid="stSidebar"] { border-right:1px solid var(--hmm-line); }
|
||||
section[data-testid="stSidebar"] * {
|
||||
color:var(--hmm-ink) !important;
|
||||
-webkit-text-fill-color:var(--hmm-ink) !important;
|
||||
}
|
||||
input, textarea, [data-baseweb="select"] > div {
|
||||
background:#ffffff !important; color:var(--hmm-ink) !important;
|
||||
-webkit-text-fill-color:var(--hmm-ink) !important;
|
||||
border-radius:4px !important; border-color:#cfdbe4 !important; box-shadow:none !important;
|
||||
}
|
||||
[data-baseweb="select"] > div *,
|
||||
[data-testid="stSidebar"] button,
|
||||
[data-testid="stSidebar"] button * {
|
||||
color:var(--hmm-ink) !important;
|
||||
-webkit-text-fill-color:var(--hmm-ink) !important;
|
||||
}
|
||||
[data-baseweb="select"] > div,
|
||||
[data-testid="stSidebar"] button {
|
||||
background:#ffffff !important;
|
||||
border-color:#cfdbe4 !important;
|
||||
}
|
||||
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {
|
||||
border-radius:4px !important; box-shadow:none !important;
|
||||
}
|
||||
div[data-testid="stButton"] > button[kind="primary"],
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {
|
||||
background:var(--hmm-navy) !important; border-color:var(--hmm-navy) !important; color:#fff !important;
|
||||
}
|
||||
div[data-testid="stButton"] > button[kind="primary"] *,
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {
|
||||
color:#fff !important; -webkit-text-fill-color:#fff !important;
|
||||
}
|
||||
.hmm-app-header { margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--hmm-line); }
|
||||
.hmm-app-wordmark, .hmm-login-wordmark { color:var(--hmm-navy); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }
|
||||
.hmm-app-header h1 { margin:10px 0 8px; color:var(--hmm-ink); font-size:1.7rem; }
|
||||
.hmm-app-header p { margin:0; color:var(--hmm-muted); font-size:.92rem; }
|
||||
.st-key-kb_login_container { max-width:440px; margin:12vh auto 0; }
|
||||
.kb-login-brand { text-align:left; }
|
||||
.kb-login-kicker { margin-top:12px; color:var(--hmm-muted); font-size:.76rem; font-weight:700; }
|
||||
.kb-login-brand h1 { margin:12px 0 8px; color:var(--hmm-ink); font-size:1.7rem; }
|
||||
.kb-login-brand p { margin:0 0 22px; color:var(--hmm-muted); }
|
||||
.st-key-kb_login_container [data-testid="stForm"] { padding:0; border:0; background:transparent; box-shadow:none; }
|
||||
.st-key-kb_login_container [data-testid="stFormSubmitButton"] button {
|
||||
min-height:44px; background:var(--hmm-navy); border-color:var(--hmm-navy); color:#fff; box-shadow:none;
|
||||
}
|
||||
.kb-login-note { color:var(--hmm-muted); }
|
||||
@media (max-width:760px) {
|
||||
.block-container { padding:1.25rem 1.25rem 3rem; }
|
||||
.st-key-kb_login_container { margin-top:8vh; }
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def apply_hmm_theme(st: object) -> None:
|
||||
st.markdown(HMM_CLEAN_THEME_CSS, unsafe_allow_html=True)
|
||||
|
||||
|
||||
def render_hmm_login_brand(st: object) -> None:
|
||||
st.markdown(
|
||||
"""
|
||||
<section class="kb-login-brand">
|
||||
<div class="hmm-login-wordmark" aria-label="Smilegate">SMILEGATE</div>
|
||||
<div class="kb-login-kicker">SMILEGATE DATA & AI POC</div>
|
||||
<h1>스마일게이트 게임 데이터 AI 에이전트</h1>
|
||||
<p>게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.</p>
|
||||
</section>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_hmm_header(st: object) -> None:
|
||||
st.markdown(
|
||||
"""
|
||||
<section class="hmm-app-header">
|
||||
<div class="hmm-app-wordmark">SMILEGATE</div>
|
||||
<h1>게임 데이터 AI 에이전트</h1>
|
||||
<p>게임 로그와 서비스 데이터를 AI로 질의·분석하고, 권한 기반 데이터 접근을 검증합니다.</p>
|
||||
</section>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
71
poc4_active_source_20260714/src/poc4/scenarios.py
Normal file
71
poc4_active_source_20260714/src/poc4/scenarios.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""File-backed demo scenarios used by the PoC4 Streamlit screen.
|
||||
|
||||
Scenario content is deliberately configuration, not executable routing policy.
|
||||
Changing this JSON changes only the menu shown to a demo user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class ScenarioConfigError(RuntimeError):
|
||||
"""A safe, user-facing error for invalid scenario configuration."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoScenario:
|
||||
scenario_id: str
|
||||
category: str
|
||||
title: str
|
||||
question: str
|
||||
|
||||
# Compatibility aliases keep the Streamlit rendering independent from the
|
||||
# storage field names and make a future scenario source interchangeable.
|
||||
@property
|
||||
def question_id(self) -> str:
|
||||
return self.scenario_id
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.question
|
||||
|
||||
|
||||
def load_demo_scenarios(path: Path) -> tuple[DemoScenario, ...]:
|
||||
"""Read enabled scenarios and reject malformed or duplicated entries."""
|
||||
|
||||
try:
|
||||
payload: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
raise ScenarioConfigError(f"질문 시나리오 설정을 읽지 못했습니다: {path}") from None
|
||||
|
||||
raw_scenarios = payload.get("scenarios") if isinstance(payload, Mapping) else None
|
||||
if not isinstance(raw_scenarios, list):
|
||||
raise ScenarioConfigError("질문 시나리오 설정에 scenarios 배열이 필요합니다.")
|
||||
|
||||
scenarios: list[DemoScenario] = []
|
||||
seen_ids: set[str] = set()
|
||||
for raw in raw_scenarios:
|
||||
if not isinstance(raw, Mapping) or raw.get("enabled", True) is not True:
|
||||
continue
|
||||
scenario_id = str(raw.get("id") or "").strip().upper()
|
||||
category = str(raw.get("category") or "일반").strip()
|
||||
title = str(raw.get("title") or "").strip()
|
||||
question = str(raw.get("question") or "").strip()
|
||||
if not scenario_id or not title or not question:
|
||||
raise ScenarioConfigError("각 질문 시나리오에는 id, title, question이 필요합니다.")
|
||||
if scenario_id in seen_ids:
|
||||
raise ScenarioConfigError(f"중복된 질문 시나리오 ID입니다: {scenario_id}")
|
||||
scenarios.append(
|
||||
DemoScenario(
|
||||
scenario_id=scenario_id,
|
||||
category=category,
|
||||
title=title,
|
||||
question=question,
|
||||
)
|
||||
)
|
||||
seen_ids.add(scenario_id)
|
||||
return tuple(scenarios)
|
||||
Reference in New Issue
Block a user