refs #701: modularize HMM console profile and scenarios

This commit is contained in:
devmrko
2026-07-22 12:52:31 +09:00
parent 97ed5d38ab
commit a2112e07dc
14 changed files with 538 additions and 378 deletions

View File

@@ -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 &amp; 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,
)

View 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)