From 0cbac8d23b890074184862dbfc1df529e60e1d07 Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 27 Jul 2026 10:01:44 +0900 Subject: [PATCH] refs #736: route Smilegate portal to few-shot MCP --- ai-web-agent-console/app.py | 132 +++++++++++++----- ai-web-agent-console/config/mcp_servers.json | 3 +- .../README.md | 14 ++ 3 files changed, 110 insertions(+), 39 deletions(-) create mode 100644 docs/design/736-smilegate-main-fewshot-route/README.md diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py index 5a6ccd5..be3a446 100644 --- a/ai-web-agent-console/app.py +++ b/ai-web-agent-console/app.py @@ -10,6 +10,7 @@ with the Bearer token entered on the screen. from __future__ import annotations +import base64 import hashlib import hmac import html @@ -67,7 +68,7 @@ from src.poc4.qa_history_store import QaHistoryStore, QaHistoryStoreError LOG = logging.getLogger(__name__) MCP_PROTOCOL_VERSION = "2025-11-25" -PREFERRED_TOOL = "oracle.select_ai.smilegate_game_text2sql" +PREFERRED_TOOL = "oracle.select_ai.smilegate_fewshot_nl2sql" DEFAULT_QUESTION = "" MAX_RESPONSE_BYTES = 1_000_000 MAX_CONVERSATION_MESSAGES = 8 @@ -93,6 +94,7 @@ VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" +PORTAL_AUTH_COOKIE_NAME = "poc4_portal_auth" AUDIT_SCHEMA = "SGMP_POC" REFERENCE_EVIDENCE_ENABLED = ( os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower() @@ -568,17 +570,9 @@ def _qa_history_store() -> QaHistoryStore: def _load_qa_questions() -> tuple[list[QaQuestion], QaHistoryStore | None, str]: try: - store = _qa_history_store() - questions = store.list_questions(limit=200) - if questions: - return questions, store, "" - return [], store, "질답 이력 DB에 아직 적재된 기준 질문이 없습니다." - except QaHistoryStoreError as exc: - try: - fallback = list(load_benchmark_questions(QA_BENCHMARK_FILE)) - except Exception: - fallback = [] - return fallback, None, str(exc) + return list(load_benchmark_questions(QA_BENCHMARK_FILE)), _qa_history_store(), "" + except Exception as exc: + return [], None, f"질답 기준 파일을 읽지 못했습니다: {exc}" def _judgment_label(status: str) -> str: @@ -637,8 +631,10 @@ def _render_qa_benchmark_panel( "제목": question.title, "질문": question.question_text, "기대 기준": _qa_answer_preview(question.expected_focus, 150), - "최근 판정": _judgment_label(question.last_judgment_status), - "최근 실행": question.last_evaluated_at or "과거 기준", + "기준 상태": _judgment_label(question.last_judgment_status) + if question.last_judgment_status + else "과거 기준", + "최근 실행": question.last_evaluated_at or "후보 선택 시 조회", } for question in visible_questions ], @@ -663,6 +659,16 @@ def _render_qa_benchmark_panel( st.info("자유 텍스트 질문도 실행하고 이력으로 남길 수 있습니다. 자유 질의는 정답 기준이 없어 수동 검토로 표시됩니다.") return None + if store is not None: + try: + stored_question = store.get_question_by_code(selected.question_code) + if stored_question is not None: + selected = stored_question + else: + st.warning("선택한 후보가 ADB 이력 테이블에 아직 적재되지 않았습니다.") + except QaHistoryStoreError as exc: + st.warning("선택한 후보의 ADB 이력을 불러오지 못했습니다. " + str(exc)) + with st.expander("선택한 질문의 정답 기준·원본·과거 답변", expanded=True): left, right = st.columns(2) with left: @@ -771,34 +777,78 @@ def _portal_credentials_are_valid(username: str, password: str) -> bool: return username_matches and password_matches +def _portal_cookie_user() -> str: + """Return the verified portal user stored in the HttpOnly auth cookie.""" + try: + token = str(st.context.cookies.get(PORTAL_AUTH_COOKIE_NAME, "")).strip() + encoded_claims, supplied_signature = token.rsplit(".", 1) + secret = _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET") + expected_signature = hmac.new( + secret.encode("utf-8"), encoded_claims.encode("ascii"), hashlib.sha256 + ).hexdigest() + if not secret or not hmac.compare_digest(supplied_signature, expected_signature): + return "" + padding = "=" * (-len(encoded_claims) % 4) + claims = json.loads( + base64.urlsafe_b64decode((encoded_claims + padding).encode("ascii")).decode("utf-8") + ) + username = str(claims.get("u", "")).strip() + expires_at = int(claims.get("e", 0)) + expected_username = _portal_auth_value("POC4_LOGIN_USER") + if claims.get("v") != 1 or expires_at <= int(datetime.now(timezone.utc).timestamp()): + return "" + if not username or not hmac.compare_digest(username, expected_username): + return "" + return username + except (AttributeError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError): + return "" + + +def _restore_portal_session_from_cookie() -> None: + if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): + return + username = _portal_cookie_user() + if username: + st.session_state[PORTAL_AUTHENTICATED_KEY] = True + st.session_state[PORTAL_AUTH_USER_KEY] = username + st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False + + 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 - with st.form("poc4_portal_login_form", clear_on_submit=True): - username = st.text_input( - "사용자 ID", - max_chars=80, - placeholder="사용자 ID를 입력하세요.", - ) - password = st.text_input( - "비밀번호", - type="password", - max_chars=200, - placeholder="비밀번호를 입력하세요.", - ) - submitted = st.form_submit_button("로그인", use_container_width=True) - if submitted: - if _portal_credentials_are_valid(username, password): - st.session_state[PORTAL_AUTHENTICATED_KEY] = True - st.session_state[PORTAL_AUTH_USER_KEY] = username.strip() - st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False - st.rerun() - st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True - if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False): + if st.query_params.get("login") == "failed": st.error("사용자 ID 또는 비밀번호를 확인해 주세요.") + components.html( + """ + +
+ + + +
+ """, + height=220, + ) st.markdown( f'

{html.escape(profile.login_footer)}

', unsafe_allow_html=True, @@ -809,7 +859,10 @@ def _logout_portal() -> None: st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None) st.session_state.pop(PORTAL_AUTH_USER_KEY, None) st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None) - st.rerun() + components.html( + '', + height=0, + ) def _render_app_header(profile: AppProfile) -> None: @@ -3907,15 +3960,17 @@ def _render_mcp_result_sections( st.json(step.get("result_summary", {})) if generated_sql: - with st.expander("생성 SQL"): + with st.expander("실행 SQL", expanded=True): st.code(generated_sql, language="sql") if items: - with st.expander(f"조회 결과 테이블 · {len(items)}건"): + with st.expander(f"조회 결과 · {len(items)}건", expanded=True): display_items = items[:100] st.dataframe(display_items, use_container_width=True) if len(items) > len(display_items): st.caption(f"화면에는 최초 {len(display_items)}건만 표시합니다.") + elif generated_sql: + st.info("실행 SQL은 생성되었지만 반환된 조회 결과 행이 없습니다.") with st.expander("MCP 호출 상세"): st.write(f"route: {details.get('server_id')} / {details.get('tool_name')}") @@ -5746,6 +5801,7 @@ def main() -> None: page_title=profile.page_title, page_icon=profile.page_icon, layout="wide" ) _apply_console_theme(profile) + _restore_portal_session_from_cookie() if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): _render_portal_login(profile) return diff --git a/ai-web-agent-console/config/mcp_servers.json b/ai-web-agent-console/config/mcp_servers.json index edc853e..ab2e4f6 100644 --- a/ai-web-agent-console/config/mcp_servers.json +++ b/ai-web-agent-console/config/mcp_servers.json @@ -9,9 +9,10 @@ "endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp", "auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN", "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", - "default_tool": "oracle.select_ai.smilegate_game_text2sql", + "default_tool": "oracle.select_ai.smilegate_fewshot_nl2sql", "router_model_profile": "gpt54_mini_oci", "tool_allowlist": [ + "oracle.select_ai.smilegate_fewshot_nl2sql", "oracle.select_ai.smilegate_game_text2sql", "oracle.select_ai.qa_vector_search", "oracle.select_ai.qa_vector_store" diff --git a/docs/design/736-smilegate-main-fewshot-route/README.md b/docs/design/736-smilegate-main-fewshot-route/README.md new file mode 100644 index 0000000..8e05690 --- /dev/null +++ b/docs/design/736-smilegate-main-fewshot-route/README.md @@ -0,0 +1,14 @@ +# Smilegate main MCP route: Few-shot NL2SQL (#736) + +## 변경 + +Smilegate 포털의 `smilegate_game_data_mcp` 기본 tool과 선호 route를 +`oracle.select_ai.smilegate_fewshot_nl2sql`로 전환한다. 기존 Text2SQL과 QA vector tool은 +allowlist에 유지해 호환성과 진단 경로를 보존한다. + +## 검증 + +- tools/list에서 새 tool이 발견되는지 확인 +- 포털의 기본 단일 호출 route가 새 tool인지 확인 +- 새 tool 응답에 `fewShotExamples`, `generatedSql`, `items`가 존재하는지 확인 +- 백오피스에 새 tool이 배포되지 않은 경우 포털에서 명확한 MCP 오류를 표시