refs #701: modularize HMM console profile and scenarios
This commit is contained in:
73
docs/design/701-hmm-ai-agent-modularization/README.md
Normal file
73
docs/design/701-hmm-ai-agent-modularization/README.md
Normal file
@@ -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에 반영한다.
|
||||||
@@ -4,6 +4,22 @@
|
|||||||
# cp .env.sample .env
|
# cp .env.sample .env
|
||||||
# chmod 600 .env
|
# chmod 600 .env
|
||||||
# 편집 후 scripts/poc4/start_*_nohup.sh 로 기동합니다.
|
# 편집 후 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 경로는 이 샘플에 기록하지 않습니다.
|
# 실제 token, password, OCID, wallet 경로는 이 샘플에 기록하지 않습니다.
|
||||||
# 이 파일은 Bash에서 읽히므로 KEY=value 형식만 사용하고 명령 치환은 넣지 않습니다.
|
# 이 파일은 Bash에서 읽히므로 KEY=value 형식만 사용하고 명령 치환은 넣지 않습니다.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
- 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다.
|
- 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다.
|
||||||
- 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다.
|
- 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다.
|
||||||
- 로그인 유지 기능은 서버 비밀키로 서명한 7일 만료 토큰을 사용하며, 로그아웃 시 즉시 폐기한다.
|
- 로그인 유지 기능은 서버 비밀키로 서명한 7일 만료 토큰을 사용하며, 로그아웃 시 즉시 폐기한다.
|
||||||
- 화면 수정의 단일 진입점은 `src/poc4/hmm_ui.py`다. 색상·레이아웃·로그인/헤더 브랜드는 이 모듈에서만 관리한다.
|
- 공통 화면 CSS와 로그인/헤더 renderer는 `src/agent_console/presentation.py`에서 관리한다.
|
||||||
|
HMM의 제품명·문구·색상은 `config/app_profile.json`에만 둔다.
|
||||||
# HMM MCP runtime wiring
|
# HMM MCP runtime wiring
|
||||||
|
|
||||||
The PoC4 MCP registry defaults to `hmm_hr_mcp` (`https://hmm-mcp.cloud-handson.com/mcp`).
|
The PoC4 MCP registry defaults to `hmm_hr_mcp` (`https://hmm-mcp.cloud-handson.com/mcp`).
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# PoC4 MCP AI Console source snapshot
|
# HMM AI 업무 에이전트 소스
|
||||||
|
|
||||||
생성일: 2026-07-14 GMT
|
생성일: 2026-07-14 GMT
|
||||||
|
|
||||||
이 폴더는 현재 PoC4 MCP AI Console 실행에 필요한 소스 파일을 원래 경로 구조로 추려 복사한 스냅샷입니다.
|
이 폴더는 HMM AI 업무 에이전트 실행 소스다. `poc4`라는 디렉터리·환경변수 접두어는
|
||||||
|
기존 배포 호환성을 위한 레거시 경로이며, 서비스의 정식 이름은 **HMM AI 업무 에이전트**다.
|
||||||
|
|
||||||
## Entrypoint
|
## Entrypoint
|
||||||
|
|
||||||
@@ -25,7 +26,8 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po
|
|||||||
## Included
|
## Included
|
||||||
|
|
||||||
- `apps/poc4/mcp_discovery_ui.py`
|
- `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/mcp_tool_router.py`
|
||||||
- `src/oci_genai_sdk.py`
|
- `src/oci_genai_sdk.py`
|
||||||
- `src/poc3/model_registry.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/mcp_servers.json`
|
||||||
- `config/poc3_model_profiles.json`
|
- `config/poc3_model_profiles.json`
|
||||||
- `config/vpd_token_presets.json`
|
- `config/vpd_token_presets.json`
|
||||||
|
- `config/hmm_demo_scenarios.json` — 화면에 표시할 데모 질문. 코드 수정 없이 이 파일만 변경한다.
|
||||||
|
- `config/app_profile.json` — 제품명, 문구, 아이콘, 색상을 지정하는 제품 profile
|
||||||
- `.env.sample`
|
- `.env.sample`
|
||||||
- `requirements.txt`
|
- `requirements.txt`
|
||||||
- `requirements-langgraph.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`을 기준으로 새로 만드세요.
|
- 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요.
|
||||||
- 실제 VPD 토큰 원문은 복사하지 않았습니다. `config/vpd_token_presets.json`의 `token` 값을 배포 환경에서 교체하세요.
|
- 실제 VPD 토큰 원문은 복사하지 않았습니다. `config/vpd_token_presets.json`의 `token` 값을 배포 환경에서 교체하세요.
|
||||||
- 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다.
|
- 대화 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`의
|
||||||
|
중립 기본값을 사용합니다.
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ from src.oci_genai_sdk import (
|
|||||||
temperature_for_model_profile,
|
temperature_for_model_profile,
|
||||||
)
|
)
|
||||||
from src.poc3.model_registry import load_model_registry, resolve_model_profile
|
from src.poc3.model_registry import load_model_registry, resolve_model_profile
|
||||||
from src.poc3.questions import COMMON_DEMO_QUESTIONS
|
from src.agent_console.presentation import (
|
||||||
from src.poc4.hmm_ui import (
|
apply_console_theme,
|
||||||
apply_hmm_theme,
|
render_console_header,
|
||||||
render_hmm_header,
|
render_login_brand,
|
||||||
render_hmm_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__)
|
LOG = logging.getLogger(__name__)
|
||||||
@@ -76,6 +77,8 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
|
|||||||
ENV_FILE = ROOT / ".env"
|
ENV_FILE = ROOT / ".env"
|
||||||
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
|
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
|
||||||
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.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"
|
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
||||||
DEFAULT_VPD_USER_ID = "FC00789"
|
DEFAULT_VPD_USER_ID = "FC00789"
|
||||||
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
|
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
|
||||||
@@ -1884,12 +1887,12 @@ def new_conversation_id() -> str:
|
|||||||
def _question_label(question: object) -> str:
|
def _question_label(question: object) -> str:
|
||||||
question_id = str(getattr(question, "question_id"))
|
question_id = str(getattr(question, "question_id"))
|
||||||
category = str(getattr(question, "category"))
|
category = str(getattr(question, "category"))
|
||||||
text = str(getattr(question, "text"))
|
title = str(getattr(question, "title", getattr(question, "text")))
|
||||||
return f"{question_id} · {category} · {text}"
|
return f"{question_id} · {category} · {title}"
|
||||||
|
|
||||||
|
|
||||||
def _apply_hmm_theme() -> None:
|
def _apply_console_theme(profile: AppProfile) -> None:
|
||||||
apply_hmm_theme(st)
|
apply_console_theme(st, profile)
|
||||||
|
|
||||||
|
|
||||||
def _portal_auth_value(name: str) -> str:
|
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]
|
del st.query_params[PORTAL_REMEMBER_TOKEN_PARAM]
|
||||||
|
|
||||||
|
|
||||||
def _render_portal_login() -> None:
|
def _render_portal_login(profile: AppProfile) -> None:
|
||||||
with st.container(key="kb_login_container"):
|
with st.container(key="console_login_container"):
|
||||||
render_hmm_login_brand(st)
|
render_login_brand(st, profile)
|
||||||
if not _portal_auth_configured():
|
if not _portal_auth_configured():
|
||||||
st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.")
|
st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.")
|
||||||
return
|
return
|
||||||
@@ -2027,9 +2030,7 @@ def _render_portal_login() -> None:
|
|||||||
if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False):
|
if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False):
|
||||||
st.error("사용자 ID 또는 비밀번호를 확인해 주세요.")
|
st.error("사용자 ID 또는 비밀번호를 확인해 주세요.")
|
||||||
st.markdown(
|
st.markdown(
|
||||||
'<div class="kb-login-note">'
|
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
|
||||||
'인증된 DEMO 사용자만 접근할 수 있습니다.'
|
|
||||||
'</div>',
|
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2042,8 +2043,8 @@ def _logout_portal() -> None:
|
|||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
def _render_hmm_header() -> None:
|
def _render_app_header(profile: AppProfile) -> None:
|
||||||
render_hmm_header(st)
|
render_console_header(st, profile)
|
||||||
|
|
||||||
|
|
||||||
def _render_vpd_user_card(preset: VpdTokenPreset) -> None:
|
def _render_vpd_user_card(preset: VpdTokenPreset) -> None:
|
||||||
@@ -3994,11 +3995,12 @@ def discover_enabled_server_tools(
|
|||||||
|
|
||||||
def _mcp_server_cache_rows(
|
def _mcp_server_cache_rows(
|
||||||
servers: list[McpServer],
|
servers: list[McpServer],
|
||||||
) -> tuple[tuple[str, str, str, tuple[str, ...], str, str], ...]:
|
) -> tuple[tuple[str, str, str, str, tuple[str, ...], str, str], ...]:
|
||||||
return tuple(
|
return tuple(
|
||||||
(
|
(
|
||||||
server.server_id,
|
server.server_id,
|
||||||
server.endpoint_url,
|
server.endpoint_url,
|
||||||
|
server.auth_token_env,
|
||||||
server.default_tool,
|
server.default_tool,
|
||||||
server.tool_allowlist,
|
server.tool_allowlist,
|
||||||
server.router_model_profile,
|
server.router_model_profile,
|
||||||
@@ -4009,16 +4011,17 @@ def _mcp_server_cache_rows(
|
|||||||
|
|
||||||
|
|
||||||
def _mcp_servers_from_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]:
|
) -> list[McpServer]:
|
||||||
return [
|
return [
|
||||||
McpServer(
|
McpServer(
|
||||||
server_id=row[0],
|
server_id=row[0],
|
||||||
endpoint_url=row[1],
|
endpoint_url=row[1],
|
||||||
default_tool=row[2],
|
auth_token_env=row[2],
|
||||||
tool_allowlist=tuple(row[3]),
|
default_tool=row[3],
|
||||||
router_model_profile=row[4],
|
tool_allowlist=tuple(row[4]),
|
||||||
description=row[5],
|
router_model_profile=row[5],
|
||||||
|
description=row[6],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -4026,7 +4029,7 @@ def _mcp_servers_from_cache_rows(
|
|||||||
|
|
||||||
@st.cache_data(show_spinner=False)
|
@st.cache_data(show_spinner=False)
|
||||||
def cached_discover_enabled_server_tools(
|
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,
|
token_fingerprint: str,
|
||||||
cache_generation: int,
|
cache_generation: int,
|
||||||
_bearer_token: str,
|
_bearer_token: str,
|
||||||
@@ -6917,19 +6920,25 @@ def _process_submitted_question(
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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(
|
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()
|
_restore_portal_remembered_session()
|
||||||
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
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
|
return
|
||||||
questions = tuple(
|
|
||||||
question
|
|
||||||
for question in COMMON_DEMO_QUESTIONS
|
|
||||||
if str(question.question_id).strip().upper() != "S5"
|
|
||||||
)
|
|
||||||
scenario_key = "poc4_mcp_discovery_scenario"
|
scenario_key = "poc4_mcp_discovery_scenario"
|
||||||
question_text_key = "poc4_mcp_discovery_question_text"
|
question_text_key = "poc4_mcp_discovery_question_text"
|
||||||
loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id"
|
loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id"
|
||||||
@@ -6938,9 +6947,10 @@ def main() -> None:
|
|||||||
query_progress_notice_key = "poc4_query_progress_notice"
|
query_progress_notice_key = "poc4_query_progress_notice"
|
||||||
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
|
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
|
||||||
selected_scenario_state = st.session_state.get(scenario_key)
|
selected_scenario_state = st.session_state.get(scenario_key)
|
||||||
if (
|
scenario_ids = {question.question_id for question in questions}
|
||||||
str(getattr(selected_scenario_state, "question_id", "")).strip().upper()
|
if str(getattr(selected_scenario_state, "question_id", "")).strip().upper() not in (
|
||||||
== "S5"
|
"",
|
||||||
|
*scenario_ids,
|
||||||
):
|
):
|
||||||
st.session_state.pop(scenario_key, None)
|
st.session_state.pop(scenario_key, None)
|
||||||
st.session_state.pop(loaded_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:
|
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
|
st.session_state[query_model_profile_key] = default_query_model_profile
|
||||||
_render_hmm_header()
|
_render_app_header(profile)
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.caption(
|
st.caption(
|
||||||
f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}"
|
f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}"
|
||||||
@@ -7233,6 +7243,7 @@ def main() -> None:
|
|||||||
except (OSError, UnicodeError, ValueError):
|
except (OSError, UnicodeError, ValueError):
|
||||||
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
|
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
|
||||||
st.caption(f"config: {MCP_SERVERS_FILE}")
|
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"token presets: {VPD_TOKEN_PRESETS_FILE}")
|
||||||
st.caption(f"selected LLM model: {selected_query_model_profile}")
|
st.caption(f"selected LLM model: {selected_query_model_profile}")
|
||||||
st.caption(f"default model: {default_query_model_profile}")
|
st.caption(f"default model: {default_query_model_profile}")
|
||||||
|
|||||||
@@ -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 = """
|
|
||||||
<style id="poc4-cross-browser-light-theme">
|
|
||||||
:root,
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root,
|
|
||||||
.stApp,
|
|
||||||
[data-testid="stAppViewContainer"],
|
|
||||||
[data-testid="stMain"] {
|
|
||||||
color-scheme: light !important;
|
|
||||||
background-color: #f7f8fa !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep native form controls independent of the browser/OS dark-mode palette. */
|
|
||||||
input,
|
|
||||||
textarea,
|
|
||||||
select,
|
|
||||||
[data-baseweb="input"] input,
|
|
||||||
[data-baseweb="textarea"] textarea {
|
|
||||||
color-scheme: light !important;
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
-webkit-text-fill-color: #172033 !important;
|
|
||||||
caret-color: #172033 !important;
|
|
||||||
border-color: #aab3c2 !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder,
|
|
||||||
textarea::placeholder,
|
|
||||||
[data-baseweb="input"] input::placeholder,
|
|
||||||
[data-baseweb="textarea"] textarea::placeholder {
|
|
||||||
color: #687386 !important;
|
|
||||||
-webkit-text-fill-color: #687386 !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:-webkit-autofill,
|
|
||||||
input:-webkit-autofill:hover,
|
|
||||||
input:-webkit-autofill:focus,
|
|
||||||
textarea:-webkit-autofill,
|
|
||||||
textarea:-webkit-autofill:hover,
|
|
||||||
textarea:-webkit-autofill:focus {
|
|
||||||
-webkit-text-fill-color: #172033 !important;
|
|
||||||
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
|
|
||||||
caret-color: #172033 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
select,
|
|
||||||
select option,
|
|
||||||
option {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
-webkit-text-fill-color: #172033 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Streamlit selectbox is rendered by BaseWeb; its menu is portaled to body. */
|
|
||||||
[data-baseweb="select"],
|
|
||||||
[data-baseweb="select"] > div {
|
|
||||||
color-scheme: light !important;
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
border-color: #aab3c2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-baseweb="select"] input,
|
|
||||||
[data-baseweb="select"] span,
|
|
||||||
[data-baseweb="select"] [aria-selected] {
|
|
||||||
color: #172033 !important;
|
|
||||||
-webkit-text-fill-color: #172033 !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-baseweb="select"] input::placeholder,
|
|
||||||
[data-baseweb="select"] [aria-placeholder="true"],
|
|
||||||
[data-baseweb="select"] [data-baseweb="placeholder"] {
|
|
||||||
color: #687386 !important;
|
|
||||||
-webkit-text-fill-color: #687386 !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-baseweb="select"] svg {
|
|
||||||
fill: #526075 !important;
|
|
||||||
color: #526075 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-baseweb="popover"],
|
|
||||||
[data-baseweb="menu"],
|
|
||||||
[role="listbox"] {
|
|
||||||
color-scheme: light !important;
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
border-color: #aab3c2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[role="option"],
|
|
||||||
[role="option"] *,
|
|
||||||
[data-baseweb="menu"] li,
|
|
||||||
[data-baseweb="menu"] li * {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
-webkit-text-fill-color: #172033 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[role="option"]:hover,
|
|
||||||
[role="option"][aria-selected="true"],
|
|
||||||
[role="option"]:hover *,
|
|
||||||
[role="option"][aria-selected="true"] * {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
-webkit-text-fill-color: #172033 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:disabled,
|
|
||||||
textarea:disabled,
|
|
||||||
select:disabled,
|
|
||||||
[data-baseweb="select"][aria-disabled="true"],
|
|
||||||
[data-baseweb="select"] > div[aria-disabled="true"],
|
|
||||||
[data-baseweb="select"] [aria-disabled="true"] {
|
|
||||||
background-color: #f1f3f6 !important;
|
|
||||||
color: #5f6b7a !important;
|
|
||||||
-webkit-text-fill-color: #5f6b7a !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-testid="stButton"] > button,
|
|
||||||
button[data-testid="stBaseButton-primary"],
|
|
||||||
button[kind="primary"] {
|
|
||||||
color-scheme: light !important;
|
|
||||||
background-color: #2256c7 !important;
|
|
||||||
border-color: #2256c7 !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
-webkit-text-fill-color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-testid="stButton"] > button *,
|
|
||||||
button[data-testid="stBaseButton-primary"] *,
|
|
||||||
button[kind="primary"] * {
|
|
||||||
color: #ffffff !important;
|
|
||||||
-webkit-text-fill-color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-testid="stButton"] > button:hover,
|
|
||||||
button[data-testid="stBaseButton-primary"]:hover,
|
|
||||||
button[kind="primary"]:hover {
|
|
||||||
background-color: #1746a2 !important;
|
|
||||||
border-color: #1746a2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-testid="stButton"] > button:disabled,
|
|
||||||
button[data-testid="stBaseButton-primary"]:disabled,
|
|
||||||
button[kind="primary"]:disabled {
|
|
||||||
background-color: #e4e8ee !important;
|
|
||||||
border-color: #c3cad4 !important;
|
|
||||||
color: #5f6b7a !important;
|
|
||||||
-webkit-text-fill-color: #5f6b7a !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-testid="stButton"] > button:disabled *,
|
|
||||||
button[data-testid="stBaseButton-primary"]:disabled *,
|
|
||||||
button[kind="primary"]:disabled * {
|
|
||||||
color: #5f6b7a !important;
|
|
||||||
-webkit-text-fill-color: #5f6b7a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outrank the legacy primary-label selector for non-WebKit engines too. */
|
|
||||||
div[data-testid="stButton"]
|
|
||||||
> button[data-testid="stBaseButton-primary"]:disabled
|
|
||||||
[data-testid="stMarkdownContainer"],
|
|
||||||
div[data-testid="stButton"]
|
|
||||||
> button[data-testid="stBaseButton-primary"]:disabled
|
|
||||||
[data-testid="stMarkdownContainer"] p,
|
|
||||||
div[data-testid="stButton"]
|
|
||||||
> button[data-testid="stBaseButton-primary"]:disabled
|
|
||||||
[data-testid="stMarkdownContainer"] span {
|
|
||||||
color: #5f6b7a !important;
|
|
||||||
-webkit-text-fill-color: #5f6b7a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
label,
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
p,
|
|
||||||
[data-testid="stMarkdownContainer"],
|
|
||||||
[data-testid="stWidgetLabel"],
|
|
||||||
[data-testid="stExpander"] summary,
|
|
||||||
[data-testid="stExpander"] summary *,
|
|
||||||
[data-testid="stExpanderDetails"],
|
|
||||||
[data-testid="stExpanderDetails"] * {
|
|
||||||
color: #172033;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-testid="stExpander"],
|
|
||||||
[data-testid="stVerticalBlockBorderWrapper"] {
|
|
||||||
color-scheme: light !important;
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #172033 !important;
|
|
||||||
border-color: #dce1e8 !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
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"]
|
|
||||||
21
poc4_active_source_20260714/config/app_profile.json
Normal file
21
poc4_active_source_20260714/config/app_profile.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
poc4_active_source_20260714/config/hmm_demo_scenarios.json
Normal file
41
poc4_active_source_20260714/config/hmm_demo_scenarios.json
Normal file
@@ -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": "이번 달 팀원별 휴가 사용 현황을 보여줘"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
58
poc4_active_source_20260714/tests/test_scenarios.py
Normal file
58
poc4_active_source_20260714/tests/test_scenarios.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user