refs #739: preserve Smilegate changes before repository layout migration

This commit is contained in:
devmrko
2026-08-03 11:12:19 +09:00
parent 022ae7f9d2
commit 4d2964b5c6
58 changed files with 3002 additions and 291 deletions

View File

@@ -183,7 +183,7 @@ def build_mcp_tool_arguments(
elif "limit" in properties:
args["limit"] = limit
return args
return {"prompt": question, "limit": limit}
return {}
__all__ = [

View File

@@ -46,6 +46,17 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
[data-testid="stJson"] code {{
background:transparent !important; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
/* Baseline answers and generated SQL use Streamlit's separate code
surface. Keep it readable when the browser prefers dark mode. */
[data-testid="stCode"], [data-testid="stCode"] pre,
[data-testid="stCode"] code, [data-testid="stCodeBlock"],
[data-testid="stCodeBlock"] pre, [data-testid="stCodeBlock"] code {{
background:#f8fafc !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; color-scheme:light !important;
-webkit-text-fill-color:var(--console-text) !important; }}
[data-testid="stCode"] *, [data-testid="stCodeBlock"] * {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
/* Chat responses are rendered in a separate Streamlit surface. Without
these rules a dark browser theme can leave the answer card dark while
its Markdown keeps the light-theme text color. */

View File

@@ -172,6 +172,7 @@ def evaluate_sql(
*,
execution_succeeded: bool,
error_text: str = "",
game_plan_status: str = "",
) -> QaJudgment:
"""Evaluate the generated SQL against the customer-approved benchmark rule."""
if question is None or not question.question_code:
@@ -215,75 +216,27 @@ def evaluate_sql(
support = question.support_level
if support == "UNSUPPORTED":
uses_alias_lookup = "COMN_GAME_ALIAS_BAS" in upper_sql
substitutes_sample_game = "STOVE_CHAOSZERO" in upper_sql
plan_status = _compact_text(game_plan_status).upper()
safe_empty_result = bool(
re.search(r"\bFROM\s+DUAL\b", upper_sql)
and re.search(r"\bWHERE\s+1\s*=\s*0\b", upper_sql)
)
if plan_status in {"UNAVAILABLE", "UNMATCHED"} and execution_succeeded and safe_empty_result:
return QaJudgment(
"PASS",
"게임 계획이 데이터 미지원 또는 미매칭으로 판정됐고, 임의 객체 선택 없이 빈 결과를 반환했습니다.",
)
if not sql and any(marker in error_text.lower() for marker in failure_markers):
return QaJudgment("PASS", "미지원 게임 질문이 실행 가능한 SQL로 변환되지 않았습니다. 기대한 안전 차단입니다.")
if uses_alias_lookup and not substitutes_sample_game and execution_succeeded:
return QaJudgment("PASS", "미지원 게임을 별칭 테이블로만 확인했고 샘플 게임 ID를 임의 대입하지 않았습니다.")
return QaJudgment("FAIL", "미지원 게임이 안전한 별칭 조회로 제한되지 않았거나 실행에 실패했습니다.")
return QaJudgment("FAIL", "미지원 게임이 게임 계획의 안전한 빈 결과로 처리되지 않았거나 실행에 실패했습니다.")
if not execution_succeeded or not sql or has_failure_text or missing_required:
return QaJudgment("FAIL", "\n".join(issues) or "필수 SQL 또는 실행 검증에 실패했습니다.")
_apply_case_specific_rules(question.question_code, sql, upper_sql, issues)
if any(issue.startswith("필수") or issue.startswith("월간") or issue.startswith("일별") or issue.startswith("주간") or issue.startswith("CZN-") for issue in issues):
if any(issue.startswith("필수") for issue in issues):
return QaJudgment("FAIL", "\n".join(issues))
if support == "PARTIAL":
issues.append("지원 범위가 일부인 질문이므로 결과 범위를 함께 검토해야 합니다.")
if issues:
return QaJudgment("WARN", "\n".join(issues))
return QaJudgment("PASS", "필수 테이블·컬럼·집계 조건과 실행 결과를 확인했습니다.")
def _apply_case_specific_rules(code: str, sql: str, upper_sql: str, issues: list[str]) -> None:
if code == "STD-26":
_append_issue(issues, "TRUNC(DATE" in upper_sql, "월간 NRU는 DATE 리터럴이 아니라 BASE_DT 기준 월 범위를 사용해야 합니다.")
_append_issue(issues, "MAX" not in upper_sql or "BASE_DT" not in upper_sql, "월간 NRU는 최신 MAX(BASE_DT) 스냅샷을 기준으로 해야 합니다.")
_append_issue(issues, "RAW_NRU_DT" in upper_sql and "TRUNC(" not in upper_sql, "월간 NRU의 RAW_NRU_DT 기간은 BASE_DT 기준으로 계산해야 합니다.")
elif code == "STD-27":
_append_issue(issues, "AU_FLAG" in upper_sql, "월간 AU는 AU_FLAG가 아니라 LAST_CONN_DT 전월 조건으로 계산해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("MAX", "BASE_DT", "ADD_MONTHS")), "월간 AU는 최신 BASE_DT의 전월을 기준으로 해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN", "LAST_CONN_DT")), "월간 AU에는 STD_USER_YN, EXPT_USER_YN, LAST_CONN_DT 조건이 필요합니다.")
elif code == "STD-22":
_append_issue(issues, "NRU_FLAG" not in upper_sql, "일별 NRU는 NRU_FLAG=1을 사용해야 합니다.")
elif code == "STD-25":
_append_issue(issues, "LAST_CONN_DT" not in upper_sql, "최근 7일 AU는 LAST_CONN_DT 기간 조건을 사용해야 합니다.")
_append_issue(issues, "AU_FLAG" in upper_sql, "최근 7일 AU는 AU_FLAG로 제한하면 안 됩니다.")
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN")), "최근 7일 AU에는 STD_USER_YN과 EXPT_USER_YN 조건이 필요합니다.")
_append_issue(issues, "GROUP BY" in upper_sql, "최근 7일 AU는 일자별 목록이 아니라 단일 집계여야 합니다.")
elif code == "STD-28":
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN")), "전체 유저 수에는 STD_USER_YN과 EXPT_USER_YN 조건이 필요합니다.")
_append_issue(issues, "RU_FLAG" in upper_sql, "이 기준의 전체 유저 수는 RU_FLAG가 아니라 STD_USER_YN으로 계산해야 합니다.")
elif code in {"CZN-07", "CZN-08", "CZN-13", "CZN-17"}:
_apply_goods_rules(code, sql, upper_sql, issues)
def _apply_goods_rules(code: str, sql: str, upper_sql: str, issues: list[str]) -> None:
dimension_name_terms = ("DIM_KR_NM", "DIM_EN_NM", "DIM_CTG", "CD_DESC", "CD_DTL_DESC")
has_dimension_name = any(term in upper_sql for term in dimension_name_terms)
if code in {"CZN-07", "CZN-08", "CZN-17"}:
_append_issue(issues, "CZN_COMN_SVC_DIM_BAS" not in upper_sql or not has_dimension_name, f"{code}은 CZN_COMN_SVC_DIM_BAS의 이름/분류 컬럼으로 재화명을 해석해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("DIM_TYPE_DIV_CD", "DIM_CTG")) or "크리스탈" not in sql, f"{code}은 GOODS_AGG와 크리스탈 분류 조건을 사용해야 합니다.")
_append_issue(issues, "HAVE_CNT" in upper_sql and "<>" not in upper_sql and "!=" not in upper_sql, f"{code}은 HAVE_CNT <> 0으로 0 보유량을 제외해야 합니다.")
if code == "CZN-07":
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_USER_MST", "EXPT_USER_YN")), "CZN-07은 유저 마스터를 조인하고 제외 유저를 필터링해야 합니다.")
_append_issue(issues, "GROUP BY" not in upper_sql or "BASE_DT" not in upper_sql, "CZN-07은 기간별 일자 집계를 위해 BASE_DT GROUP BY가 필요합니다.")
elif code == "CZN-08":
_append_issue(issues, any(term not in upper_sql for term in ("AU_FLAG", "EXPT_USER_YN")), "CZN-08은 표준 AU와 제외 유저 조건이 필요합니다.")
_append_issue(issues, "STD_USER_YN" in upper_sql, "CZN-08 기준에는 STD_USER_YN을 추가하면 모집단이 과도하게 좁아집니다.")
_append_issue(issues, "AVG(" in upper_sql, "CZN-08 1인당 평균은 AVG(HAVE_CNT)가 아니라 합계/고유 유저 수여야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("SUM", "COUNT", "DISTINCT", "/")), "CZN-08 평균은 SUM(HAVE_CNT)/COUNT(DISTINCT GUID)로 계산해야 합니다.")
elif code == "CZN-13":
_append_issue(issues, "CZN_CUSTOM_GOODS_HAVE_TXN" in upper_sql, "CZN-13은 보유 스냅샷이 아니라 재화 변동 테이블을 사용해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("CZN_CUSTOM_GOODS_CHANGE_TXN", "GOODS_CHANGE_CNT")), "CZN-13은 GOODS_CHANGE_CNT를 사용해야 합니다.")
_append_issue(issues, "CZN_COMN_SVC_DIM_BAS" not in upper_sql or not has_dimension_name, "CZN-13은 차원 테이블로 에테르를 해석해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("DIM_TYPE_DIV_CD", "DIM_CTG", "CHANGE_TYPE_CD", "'USE'")) or "에테르" not in sql, "CZN-13은 에테르 사용 분류와 CHANGE_TYPE_CD='USE' 조건이 필요합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_USER_MST", "EXPT_USER_YN", "COUNT", "DISTINCT", "GUID", "SUM")), "CZN-13은 대상 유저 조인, 고유 사용 유저 수, 사용량 합계가 필요합니다.")
elif code == "CZN-17":
_append_issue(issues, any(term not in upper_sql for term in ("CZN_CUSTOM_GOODS_HAVE_TXN", "HAVE_CNT")), "CZN-17은 재화 보유 스냅샷을 사용해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_CHARACTER_MST", "AU_FLAG", "EXPT_USER_YN")), "CZN-17은 캐릭터 AU와 제외 유저 조건이 필요합니다.")
_append_issue(issues, "AVG(" in upper_sql, "CZN-17 평균은 AVG(HAVE_CNT)가 아니라 합계/고유 캐릭터 수여야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("SUM", "COUNT", "DISTINCT", "/", "CUID")), "CZN-17 평균은 SUM(HAVE_CNT)/COUNT(DISTINCT CUID)로 계산해야 합니다.")
_append_issue(issues, "GROUP BY" in upper_sql, "CZN-17은 기준일 단일 집계여야 하므로 상세 GROUP BY를 사용하면 안 됩니다.")
_append_issue(issues, any(term in upper_sql for term in ("LEVEL_COL", "ACM_CONN_DCNT", "TDAY_PLAY_TIME")), "CZN-17에는 레벨·접속일·플레이타임 지표가 포함되면 안 됩니다.")
return QaJudgment("PASS", "고객 기준의 필수 SQL 요소와 실행 결과를 확인했습니다.")

View File

@@ -119,14 +119,17 @@ class QaHistoryStore:
username = (
_env_value("POC4_QA_DB_USERNAME", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_DB_USERNAME", self._env_file)
or _env_value("BACKOFFICE_DB_USERNAME", self._env_file)
)
password = (
_env_value("POC4_QA_DB_PASSWORD", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_DB_PASSWORD", self._env_file)
or _env_value("BACKOFFICE_DB_PASSWORD", self._env_file)
)
raw_dsn = (
_env_value("POC4_QA_DB_DSN", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_DB_URL", self._env_file)
or _env_value("BACKOFFICE_DB_URL", self._env_file)
)
dsn, wallet_from_dsn = _normalize_oracle_dsn(raw_dsn)
wallet_dir = (
@@ -161,7 +164,6 @@ class QaHistoryStore:
if not wallet_dir.is_dir():
raise QaHistoryStoreError("질답 이력 DB Wallet 경로를 확인해 주세요.")
kwargs["config_dir"] = str(wallet_dir)
kwargs["wallet_location"] = str(wallet_dir)
try:
self._pool = oracledb.create_pool(**kwargs)
return self._pool
@@ -225,6 +227,21 @@ class QaHistoryStore:
row = cursor.fetchone()
return question_from_record(_record_from_cursor(cursor, row)) if row else None
def get_question_by_code(self, question_code: str) -> QaQuestion | None:
sql = f"""
SELECT question_id, question_code, category, title, question_text,
source_document, source_sheet, source_row, source_scenario,
sample_sql, expected_focus, baseline_sql, baseline_answer,
support_level, evaluation_rule_json
FROM {QUESTION_TABLE}
WHERE question_code = :question_code AND active_yn = 'Y'
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, {"question_code": str(question_code).upper()})
row = cursor.fetchone()
return question_from_record(_record_from_cursor(cursor, row)) if row else None
def list_answers(self, question_id: int, *, limit: int = 30) -> list[dict[str, Any]]:
sql = f"""
SELECT answer_seq, question_id, answer_kind, run_key, conversation_id,