diff --git a/docs/design/701-hmm-ai-agent-modularization/README.md b/docs/design/701-hmm-ai-agent-modularization/README.md new file mode 100644 index 0000000..4bafd67 --- /dev/null +++ b/docs/design/701-hmm-ai-agent-modularization/README.md @@ -0,0 +1,73 @@ +# HMM AI 업무 에이전트 유지보수성 모듈화 설계서 (#701) + +## 프로젝트 개요 + +`poc4_active_source_20260714`는 레거시 스냅샷 경로이며, 정식 서비스명은 HMM AI 업무 에이전트다. HMM MCP를 통해 HR 데이터, 표준 용어, 규정 문서를 조회하고 대화 이력과 보안 관리 화면을 제공한다. + +## 목표 + +기능을 바꾸지 않고 대형 화면 파일의 책임을 분리한다. 설정 해석과 MCP Streamable HTTP 통신은 Streamlit 화면 코드에서 제거해 독립적으로 검증할 수 있게 한다. + +## 현재 문제 + +- `apps/poc4/mcp_discovery_ui.py`가 화면, 설정, 인증, SQLite 대화 이력, MCP JSON-RPC, Agent 실행을 함께 관리한다. +- MCP 설정과 인증 토큰 규칙을 수정할 때 화면 코드까지 함께 읽어야 한다. +- MCP 프로토콜 처리의 단위 검증 지점이 없다. + +## 모듈 경계 + +| 모듈 | 책임 | Streamlit 의존 | +| --- | --- | --- | +| `src/poc4/runtime_config.py` | `.env`, MCP 서버 JSON, VPD preset 로드와 검증 | 없음 | +| `src/poc4/mcp_client.py` | endpoint 검증, JSON-RPC, 세션 fallback, tool discovery/call | 없음 | +| `src/poc4/chat_store.py` | SQLite 대화 이력 CRUD | 없음 | +| `apps/poc4/mcp_discovery_ui.py` | 사용자 입력, 상태, 화면 렌더링, 업무 Agent orchestration | 있음 | + +### 재사용 UI Shell과 제품 프로필 + +공통 화면 shell은 `src/agent_console/`에서 제공하고, 특정 고객·PoC의 표현은 +`config/app_profile.json`에 둔다. 다른 프로젝트는 앱 코드를 복사·수정하지 않고 profile JSON을 +교체할 수 있다. 실제 배포에서는 `AGENT_CONSOLE_NAME`, `AGENT_CONSOLE_HEADER_DESCRIPTION`, +`AGENT_CONSOLE_PRIMARY_COLOR` 등 `AGENT_CONSOLE_*` 환경변수가 JSON 기본값보다 우선한다. +따라서 고객별 제품명·설명·색상은 같은 컨테이너 이미지와 코드로 운영할 수 있다. + +| 구분 | 공통화 대상 | 제품별 설정 | +| --- | --- | --- | +| 화면 shell | light theme, sidebar, 입력/버튼, 로그인/헤더 renderer | 제품명, 문구, 아이콘, 색상 | +| 데모 질문 | JSON loader와 중복·형식 검증 | `config/hmm_demo_scenarios.json`의 질문 목록 | +| MCP 연결 | JSON registry와 server token env 참조 | endpoint, allowlist, token env 이름 | + +`poc4` 접두어가 있는 app path, session key, DB 파일명은 배포 호환성을 위한 레거시 경계다. +새 코드에는 서비스/모듈 이름으로 사용하지 않으며, 별도 migration 작업에서만 제거한다. + +## 추적 및 현행화 규칙 + +이 설계서는 HMM AI 업무 에이전트의 UI shell·제품 profile·데모 시나리오 구조에 대한 기준 문서다. + +1. 구조나 JSON schema를 변경하면 이 문서의 모듈 경계와 호환성 원칙을 먼저 갱신한다. +2. 변경은 Redmine 이슈에 설계·검증 결과와 Git commit SHA를 함께 기록한다. +3. Git commit message에는 Redmine 번호를 `refs #<번호>:` 형식으로 포함한다. +4. 환경별 값과 비밀값은 profile JSON에 넣지 않고 `.env` 또는 secret store에만 둔다. +5. UI CSS는 `src/agent_console/presentation.py` 한 곳에서 관리한다. 제품별 색상과 문구는 Python/CSS를 수정하지 않고 `app_profile.json`으로 조정한다. + +도메인 로직의 공개 오류는 `PublicMcpError`로 통일한다. UI는 이 오류를 사람이 이해할 수 있는 메시지로 표시하되 토큰과 HTTP 원문을 출력하지 않는다. + +## 호환성 원칙 + +1. 기존 환경변수, JSON 키, SQLite 테이블과 세션 키를 유지한다. +2. HMM MCP의 서버 전용 `HMM_MCP_BEARER_TOKEN` 규칙을 유지한다. +3. HTTP 400이 발생하는 MCP에 대해 기존 initialize/session fallback을 유지한다. +4. 외부 호출은 read-only tool만 사용하며 UI의 요청·응답 형식을 변경하지 않는다. + +## 검증 기준 + +- `py_compile` 및 모듈 import가 성공한다. +- 설정 JSON을 읽어 HMM MCP 한 개와 allowlist 세 개가 확인된다. +- HTTP mocking으로 일반 JSON-RPC와 session fallback을 검증한다. +- 운영 서버에서 `tools/list`로 HMM MCP 세 도구가 발견되고 Streamlit health/UI가 정상 응답한다. + +## 단계 + +1. 설정·MCP transport·대화 저장소를 순수 Python 모듈로 추출한다. +2. 화면 파일은 새 공개 API만 사용하도록 교체한다. +3. 단위 검증과 운영 smoke test 후 Git/Gitea에 반영한다. diff --git a/poc4_active_source_20260714/.env.sample b/poc4_active_source_20260714/.env.sample index e3d87db..a89bc8e 100644 --- a/poc4_active_source_20260714/.env.sample +++ b/poc4_active_source_20260714/.env.sample @@ -4,6 +4,22 @@ # cp .env.sample .env # chmod 600 .env # 편집 후 scripts/poc4/start_*_nohup.sh 로 기동합니다. + +# 제품별 화면 값: 기본값은 config/app_profile.json, 아래 값이 있으면 환경변수가 우선합니다. +# AGENT_CONSOLE_NAME=HMM AI 업무 에이전트 +# AGENT_CONSOLE_SHORT_NAME=HMM +# AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트 +# AGENT_CONSOLE_PAGE_ICON=⛴️ +# AGENT_CONSOLE_HEADER_TITLE=AI 업무 에이전트 +# AGENT_CONSOLE_HEADER_DESCRIPTION=사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다. +# AGENT_CONSOLE_LOGIN_KICKER=HMM SHIPPING & LOGISTICS DEMO +# AGENT_CONSOLE_LOGIN_TITLE=HMM AI 업무 에이전트 +# AGENT_CONSOLE_LOGIN_DESCRIPTION=사용자 인증 후 해운·물류 AI 질의와 보안 관리 기능을 이용할 수 있습니다. +# AGENT_CONSOLE_LOGIN_FOOTER=인증된 DEMO 사용자만 접근할 수 있습니다. +# AGENT_CONSOLE_PRIMARY_COLOR=#003b70 +# AGENT_CONSOLE_TEXT_COLOR=#172b3a +# AGENT_CONSOLE_MUTED_COLOR=#667785 +# AGENT_CONSOLE_BORDER_COLOR=#dfe7ed # # 실제 token, password, OCID, wallet 경로는 이 샘플에 기록하지 않습니다. # 이 파일은 Bash에서 읽히므로 KEY=value 형식만 사용하고 명령 치환은 넣지 않습니다. diff --git a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md b/poc4_active_source_20260714/HMM_BRAND_REFRESH.md index fb38cc1..5e5aefd 100644 --- a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md +++ b/poc4_active_source_20260714/HMM_BRAND_REFRESH.md @@ -6,7 +6,8 @@ - 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다. - 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다. - 로그인 유지 기능은 서버 비밀키로 서명한 7일 만료 토큰을 사용하며, 로그아웃 시 즉시 폐기한다. -- 화면 수정의 단일 진입점은 `src/poc4/hmm_ui.py`다. 색상·레이아웃·로그인/헤더 브랜드는 이 모듈에서만 관리한다. +- 공통 화면 CSS와 로그인/헤더 renderer는 `src/agent_console/presentation.py`에서 관리한다. + HMM의 제품명·문구·색상은 `config/app_profile.json`에만 둔다. # HMM MCP runtime wiring The PoC4 MCP registry defaults to `hmm_hr_mcp` (`https://hmm-mcp.cloud-handson.com/mcp`). diff --git a/poc4_active_source_20260714/SOURCE_README.md b/poc4_active_source_20260714/SOURCE_README.md index e2bcec3..7cababa 100644 --- a/poc4_active_source_20260714/SOURCE_README.md +++ b/poc4_active_source_20260714/SOURCE_README.md @@ -1,8 +1,9 @@ -# PoC4 MCP AI Console source snapshot +# HMM AI 업무 에이전트 소스 생성일: 2026-07-14 GMT -이 폴더는 현재 PoC4 MCP AI Console 실행에 필요한 소스 파일을 원래 경로 구조로 추려 복사한 스냅샷입니다. +이 폴더는 HMM AI 업무 에이전트 실행 소스다. `poc4`라는 디렉터리·환경변수 접두어는 +기존 배포 호환성을 위한 레거시 경로이며, 서비스의 정식 이름은 **HMM AI 업무 에이전트**다. ## Entrypoint @@ -25,7 +26,8 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po ## Included - `apps/poc4/mcp_discovery_ui.py` -- `apps/poc4/ui_theme.py` +- `src/agent_console/presentation.py` +- `src/agent_console/profile.py` - `src/mcp_tool_router.py` - `src/oci_genai_sdk.py` - `src/poc3/model_registry.py` @@ -33,6 +35,8 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po - `config/mcp_servers.json` - `config/poc3_model_profiles.json` - `config/vpd_token_presets.json` +- `config/hmm_demo_scenarios.json` — 화면에 표시할 데모 질문. 코드 수정 없이 이 파일만 변경한다. +- `config/app_profile.json` — 제품명, 문구, 아이콘, 색상을 지정하는 제품 profile - `.env.sample` - `requirements.txt` - `requirements-langgraph.txt` @@ -44,3 +48,14 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po - 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요. - 실제 VPD 토큰 원문은 복사하지 않았습니다. `config/vpd_token_presets.json`의 `token` 값을 배포 환경에서 교체하세요. - 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다. + +## 구성 원칙 + +- `config/`: 운영자가 바꿀 수 있는 MCP, 모델, 사용자 preset, 데모 질문 JSON +- `src/poc4/`: Streamlit과 분리 가능한 화면 보조 모듈 및 도메인 로직 +- `apps/poc4/`: 레거시 호환 entrypoint. 화면 조립과 사용자 상호작용만 담당하도록 점진적으로 축소 +- `tests/`: 설정 파일과 순수 Python 모듈의 회귀 검증 + +다른 프로젝트에 적용할 때는 scenario/MCP JSON만 교체하고, 제품명·설명·색상은 +`AGENT_CONSOLE_*` 환경변수로 지정합니다. 환경변수가 없을 때만 `config/app_profile.json`의 +중립 기본값을 사용합니다. diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py index 7defe6a..b763891 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -51,12 +51,13 @@ from src.oci_genai_sdk import ( temperature_for_model_profile, ) from src.poc3.model_registry import load_model_registry, resolve_model_profile -from src.poc3.questions import COMMON_DEMO_QUESTIONS -from src.poc4.hmm_ui import ( - apply_hmm_theme, - render_hmm_header, - render_hmm_login_brand, +from src.agent_console.presentation import ( + apply_console_theme, + render_console_header, + render_login_brand, ) +from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile +from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios LOG = logging.getLogger(__name__) @@ -76,6 +77,8 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci" ENV_FILE = ROOT / ".env" MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json" VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json" +DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json" +APP_PROFILE_FILE = ROOT / "config" / "app_profile.json" CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3" DEFAULT_VPD_USER_ID = "FC00789" VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/" @@ -1884,12 +1887,12 @@ def new_conversation_id() -> str: def _question_label(question: object) -> str: question_id = str(getattr(question, "question_id")) category = str(getattr(question, "category")) - text = str(getattr(question, "text")) - return f"{question_id} · {category} · {text}" + title = str(getattr(question, "title", getattr(question, "text"))) + return f"{question_id} · {category} · {title}" -def _apply_hmm_theme() -> None: - apply_hmm_theme(st) +def _apply_console_theme(profile: AppProfile) -> None: + apply_console_theme(st, profile) def _portal_auth_value(name: str) -> str: @@ -1987,9 +1990,9 @@ def _clear_portal_remembered_session() -> None: del st.query_params[PORTAL_REMEMBER_TOKEN_PARAM] -def _render_portal_login() -> None: - with st.container(key="kb_login_container"): - render_hmm_login_brand(st) +def _render_portal_login(profile: AppProfile) -> None: + with st.container(key="console_login_container"): + render_login_brand(st, profile) if not _portal_auth_configured(): st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.") return @@ -2027,9 +2030,7 @@ def _render_portal_login() -> None: if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False): st.error("사용자 ID 또는 비밀번호를 확인해 주세요.") st.markdown( - '
' - '인증된 DEMO 사용자만 접근할 수 있습니다.' - '
', + f'

{html.escape(profile.login_footer)}

', unsafe_allow_html=True, ) @@ -2042,8 +2043,8 @@ def _logout_portal() -> None: st.rerun() -def _render_hmm_header() -> None: - render_hmm_header(st) +def _render_app_header(profile: AppProfile) -> None: + render_console_header(st, profile) def _render_vpd_user_card(preset: VpdTokenPreset) -> None: @@ -3994,11 +3995,12 @@ def discover_enabled_server_tools( def _mcp_server_cache_rows( servers: list[McpServer], -) -> tuple[tuple[str, str, str, tuple[str, ...], str, str], ...]: +) -> tuple[tuple[str, str, str, str, tuple[str, ...], str, str], ...]: return tuple( ( server.server_id, server.endpoint_url, + server.auth_token_env, server.default_tool, server.tool_allowlist, server.router_model_profile, @@ -4009,16 +4011,17 @@ def _mcp_server_cache_rows( def _mcp_servers_from_cache_rows( - rows: tuple[tuple[str, str, str, tuple[str, ...], str, str], ...], + rows: tuple[tuple[str, str, str, str, tuple[str, ...], str, str], ...], ) -> list[McpServer]: return [ McpServer( server_id=row[0], endpoint_url=row[1], - default_tool=row[2], - tool_allowlist=tuple(row[3]), - router_model_profile=row[4], - description=row[5], + auth_token_env=row[2], + default_tool=row[3], + tool_allowlist=tuple(row[4]), + router_model_profile=row[5], + description=row[6], ) for row in rows ] @@ -4026,7 +4029,7 @@ def _mcp_servers_from_cache_rows( @st.cache_data(show_spinner=False) def cached_discover_enabled_server_tools( - server_rows: tuple[tuple[str, str, str, tuple[str, ...], str, str], ...], + server_rows: tuple[tuple[str, str, str, str, tuple[str, ...], str, str], ...], token_fingerprint: str, cache_generation: int, _bearer_token: str, @@ -6917,19 +6920,25 @@ def _process_submitted_question( def main() -> None: + try: + profile = load_app_profile(APP_PROFILE_FILE) + except AppProfileError as exc: + st.set_page_config(page_title="AI 업무 에이전트", layout="wide") + st.error(str(exc)) + return st.set_page_config( - page_title="스마일게이트 게임 데이터 AI 콘솔", page_icon="🎮", layout="wide" + page_title=profile.page_title, page_icon=profile.page_icon, layout="wide" ) - _apply_hmm_theme() + _apply_console_theme(profile) _restore_portal_remembered_session() if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): - _render_portal_login() + _render_portal_login(profile) + return + try: + questions = load_demo_scenarios(DEMO_SCENARIOS_FILE) + except ScenarioConfigError as exc: + st.error(str(exc)) return - questions = tuple( - question - for question in COMMON_DEMO_QUESTIONS - if str(question.question_id).strip().upper() != "S5" - ) scenario_key = "poc4_mcp_discovery_scenario" question_text_key = "poc4_mcp_discovery_question_text" loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id" @@ -6938,9 +6947,10 @@ def main() -> None: query_progress_notice_key = "poc4_query_progress_notice" mcp_cache_generation_key = "poc4_mcp_tools_cache_generation" selected_scenario_state = st.session_state.get(scenario_key) - if ( - str(getattr(selected_scenario_state, "question_id", "")).strip().upper() - == "S5" + scenario_ids = {question.question_id for question in questions} + if str(getattr(selected_scenario_state, "question_id", "")).strip().upper() not in ( + "", + *scenario_ids, ): st.session_state.pop(scenario_key, None) st.session_state.pop(loaded_scenario_key, None) @@ -6987,7 +6997,7 @@ def main() -> None: ) if st.session_state.get(query_model_profile_key) not in model_profile_keys: st.session_state[query_model_profile_key] = default_query_model_profile - _render_hmm_header() + _render_app_header(profile) with st.sidebar: st.caption( f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}" @@ -7233,6 +7243,7 @@ def main() -> None: except (OSError, UnicodeError, ValueError): st.warning("MCP 설정 JSON을 읽지 못했습니다.") st.caption(f"config: {MCP_SERVERS_FILE}") + st.caption(f"scenario config: {DEMO_SCENARIOS_FILE}") st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}") st.caption(f"selected LLM model: {selected_query_model_profile}") st.caption(f"default model: {default_query_model_profile}") diff --git a/poc4_active_source_20260714/apps/poc4/ui_theme.py b/poc4_active_source_20260714/apps/poc4/ui_theme.py deleted file mode 100644 index b75056d..0000000 --- a/poc4_active_source_20260714/apps/poc4/ui_theme.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Cross-browser light theme primitives shared by the PoC_4 Streamlit UIs. - -This module is presentation-only. It does not import or call runtime adapters, -MCP clients, databases, retrieval code, or model providers. -""" - -from __future__ import annotations - - -POC4_LIGHT_THEME_CSS = """ - -""" - - -def apply_poc4_light_theme(st: object, *, additional_css: str = "") -> None: - """Inject optional layout CSS followed by the authoritative light theme.""" - - st.markdown( - additional_css + POC4_LIGHT_THEME_CSS, - unsafe_allow_html=True, - ) - - -__all__ = ["POC4_LIGHT_THEME_CSS", "apply_poc4_light_theme"] diff --git a/poc4_active_source_20260714/config/app_profile.json b/poc4_active_source_20260714/config/app_profile.json new file mode 100644 index 0000000..eea68d3 --- /dev/null +++ b/poc4_active_source_20260714/config/app_profile.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "product": { + "name": "AI 업무 에이전트", + "short_name": "AGENT", + "page_title": "AI 업무 에이전트", + "page_icon": "🤖", + "header_title": "AI 업무 에이전트", + "header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.", + "login_kicker": "DATA & AI DEMO", + "login_title": "AI 업무 에이전트", + "login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.", + "login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다." + }, + "theme": { + "primary_color": "#003b70", + "text_color": "#172b3a", + "muted_color": "#667785", + "border_color": "#dfe7ed" + } +} diff --git a/poc4_active_source_20260714/config/hmm_demo_scenarios.json b/poc4_active_source_20260714/config/hmm_demo_scenarios.json new file mode 100644 index 0000000..f50a9b4 --- /dev/null +++ b/poc4_active_source_20260714/config/hmm_demo_scenarios.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "description": "HMM AI 업무 에이전트 화면에 표시할 데모 질문 목록입니다. 이 파일을 수정하면 재배포 후 질문 메뉴에 반영됩니다.", + "scenarios": [ + { + "id": "HR-01", + "enabled": true, + "category": "휴가 규정", + "title": "연차 이월 기준", + "question": "연차 휴가 이월 기준과 제한을 알려줘" + }, + { + "id": "HR-02", + "enabled": true, + "category": "휴가 규정", + "title": "입사일 기준 연차", + "question": "입사일 기준으로 연차가 언제 발생하는지 알려줘" + }, + { + "id": "HR-03", + "enabled": true, + "category": "근태 용어", + "title": "휴가 명칭 표준화", + "question": "반차와 반일 휴가의 표준 근태 용어를 알려줘" + }, + { + "id": "HR-04", + "enabled": true, + "category": "조직·인력", + "title": "팀 구성 조회", + "question": "팀별 인원과 매니저를 보여줘" + }, + { + "id": "HR-05", + "enabled": true, + "category": "근태 현황", + "title": "휴가 현황 조회", + "question": "이번 달 팀원별 휴가 사용 현황을 보여줘" + } + ] +} diff --git a/poc4_active_source_20260714/src/agent_console/__init__.py b/poc4_active_source_20260714/src/agent_console/__init__.py new file mode 100644 index 0000000..ccd509b --- /dev/null +++ b/poc4_active_source_20260714/src/agent_console/__init__.py @@ -0,0 +1 @@ +"""Reusable presentation shell for MCP-backed Streamlit agent consoles.""" diff --git a/poc4_active_source_20260714/src/agent_console/presentation.py b/poc4_active_source_20260714/src/agent_console/presentation.py new file mode 100644 index 0000000..2ea8b72 --- /dev/null +++ b/poc4_active_source_20260714/src/agent_console/presentation.py @@ -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""" + + """, + unsafe_allow_html=True, + ) + + +def render_console_header(st: Any, profile: AppProfile) -> None: + st.markdown( + f"""
{escape(profile.short_name)}
+

{escape(profile.header_title)}

{escape(profile.header_description)}

""", + unsafe_allow_html=True, + ) + + +def render_login_brand(st: Any, profile: AppProfile) -> None: + st.markdown( + f"""
{escape(profile.short_name)}
+

{escape(profile.login_kicker)}

{escape(profile.login_title)}

+

{escape(profile.login_description)}

""", + unsafe_allow_html=True, + ) diff --git a/poc4_active_source_20260714/src/agent_console/profile.py b/poc4_active_source_20260714/src/agent_console/profile.py new file mode 100644 index 0000000..7c422d4 --- /dev/null +++ b/poc4_active_source_20260714/src/agent_console/profile.py @@ -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 diff --git a/poc4_active_source_20260714/src/poc4/hmm_ui.py b/poc4_active_source_20260714/src/poc4/hmm_ui.py deleted file mode 100644 index cd50146..0000000 --- a/poc4_active_source_20260714/src/poc4/hmm_ui.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Small, self-contained Smilegate presentation layer for the PoC4 Streamlit app.""" - -HMM_CLEAN_THEME_CSS = """ - -""" - - -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( - """ -
-
SMILEGATE
-
SMILEGATE DATA & AI POC
-

스마일게이트 게임 데이터 AI 에이전트

-

게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.

-
- """, - unsafe_allow_html=True, - ) - - -def render_hmm_header(st: object) -> None: - st.markdown( - """ -
-
SMILEGATE
-

게임 데이터 AI 에이전트

-

게임 로그와 서비스 데이터를 AI로 질의·분석하고, 권한 기반 데이터 접근을 검증합니다.

-
- """, - unsafe_allow_html=True, - ) diff --git a/poc4_active_source_20260714/src/poc4/scenarios.py b/poc4_active_source_20260714/src/poc4/scenarios.py new file mode 100644 index 0000000..7b0fe8c --- /dev/null +++ b/poc4_active_source_20260714/src/poc4/scenarios.py @@ -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) diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py new file mode 100644 index 0000000..e7429b5 --- /dev/null +++ b/poc4_active_source_20260714/tests/test_scenarios.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios +from src.agent_console.profile import load_app_profile + + +class DemoScenarioConfigTest(unittest.TestCase): + def test_profile_environment_overrides_json_defaults(self) -> None: + path = Path(__file__).parents[1] / "config" / "app_profile.json" + with patch.dict( + "os.environ", + { + "AGENT_CONSOLE_SHORT_NAME": "HMM", + "AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트", + "AGENT_CONSOLE_PRIMARY_COLOR": "#003b70", + }, + clear=False, + ): + profile = load_app_profile(path) + + self.assertEqual(profile.short_name, "HMM") + self.assertEqual(profile.page_title, "HMM AI 업무 에이전트") + self.assertEqual(profile.primary_color, "#003b70") + + def test_hmm_scenarios_are_enabled_and_unique(self) -> None: + path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json" + scenarios = load_demo_scenarios(path) + + self.assertGreaterEqual(len(scenarios), 3) + self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios})) + self.assertTrue(all(item.question.strip() for item in scenarios)) + + def test_duplicate_id_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "scenarios.json" + path.write_text( + json.dumps( + { + "scenarios": [ + {"id": "HR-01", "title": "one", "question": "q1"}, + {"id": "HR-01", "title": "two", "question": "q2"}, + ] + } + ), + encoding="utf-8", + ) + with self.assertRaises(ScenarioConfigError): + load_demo_scenarios(path) + + +if __name__ == "__main__": + unittest.main()