refs #736: route Smilegate portal to few-shot MCP

This commit is contained in:
devmrko
2026-07-27 10:01:44 +09:00
parent caa7d55085
commit 0cbac8d23b
3 changed files with 110 additions and 39 deletions

View File

@@ -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(
"""
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: sans-serif; color: #15293a; }
form { display: grid; gap: 10px; }
label { font-size: 13px; font-weight: 700; color: #365064; }
input { width: 100%; border: 1px solid #afc4d3; border-radius: 7px;
padding: 11px 12px; font-size: 15px; color: #15293a; }
button { margin-top: 6px; width: 100%; border: 0; border-radius: 7px;
padding: 12px; background: #005c97; color: white; cursor: pointer;
font-size: 15px; font-weight: 700; }
button:hover { background: #004b7c; }
</style>
<form action="/_poc4/auth/login" method="post" target="_parent">
<label>사용자 ID
<input name="username" autocomplete="username" maxlength="80" required>
</label>
<label>비밀번호
<input name="password" type="password" autocomplete="current-password"
maxlength="200" required>
</label>
<button type="submit">로그인</button>
</form>
""",
height=220,
)
st.markdown(
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
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(
'<script>window.parent.location.assign("/_poc4/auth/logout");</script>',
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