From 8d0a8bdb5cdbd1ed6c7144e7361c114d9a5a4736 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 18 Aug 2026 09:26:35 +0900 Subject: [PATCH] Deploy: exdev sync 2026-08-18 09:26:35 --- chatbotApi-chatbot2.0-agent | 2 +- chatbotApp-chatbot2.0-agent | 2 +- exAiChatBot-chatbot2.0-agent | 2 +- kakaoChatbotSkill-chatbot2.0-agent | 2 +- .../quick_inquiry_branch_test.cpython-314.pyc | Bin 0 -> 26675 bytes scripts/quick_inquiry_branch_test.py | 526 +++++++++++++++ scripts/quick_inquiry_nlu_test.py | 626 ++++++++++++++++++ server-dev/caddy/Caddyfile.agent.exdev | 308 ++++----- 8 files changed, 1310 insertions(+), 158 deletions(-) create mode 100644 scripts/__pycache__/quick_inquiry_branch_test.cpython-314.pyc create mode 100644 scripts/quick_inquiry_branch_test.py create mode 100644 scripts/quick_inquiry_nlu_test.py diff --git a/chatbotApi-chatbot2.0-agent b/chatbotApi-chatbot2.0-agent index 67b573d..0b05813 160000 --- a/chatbotApi-chatbot2.0-agent +++ b/chatbotApi-chatbot2.0-agent @@ -1 +1 @@ -Subproject commit 67b573d118770f3bc0d54a71b578f7a499cf87df +Subproject commit 0b0581313287f70bbc6a58d6332620017a2922e0 diff --git a/chatbotApp-chatbot2.0-agent b/chatbotApp-chatbot2.0-agent index 44bbe25..719fa64 160000 --- a/chatbotApp-chatbot2.0-agent +++ b/chatbotApp-chatbot2.0-agent @@ -1 +1 @@ -Subproject commit 44bbe2573231b32622c8c98844dad91dada57181 +Subproject commit 719fa64a823bfabfa8931ecca13199847439ea2c diff --git a/exAiChatBot-chatbot2.0-agent b/exAiChatBot-chatbot2.0-agent index 74d37bc..23c7e05 160000 --- a/exAiChatBot-chatbot2.0-agent +++ b/exAiChatBot-chatbot2.0-agent @@ -1 +1 @@ -Subproject commit 74d37bc5e5c8ab116cb5fa68ed583c5e9defcc08 +Subproject commit 23c7e05baed3e24ceb3f4f1ff1974f031ddac0f6 diff --git a/kakaoChatbotSkill-chatbot2.0-agent b/kakaoChatbotSkill-chatbot2.0-agent index 0aa07b8..3410cae 160000 --- a/kakaoChatbotSkill-chatbot2.0-agent +++ b/kakaoChatbotSkill-chatbot2.0-agent @@ -1 +1 @@ -Subproject commit 0aa07b8dfb6860b4491641d2f7a98f8bf65e4fbf +Subproject commit 3410cae72a66c14bfe03b751867e1f429d7e2013 diff --git a/scripts/__pycache__/quick_inquiry_branch_test.cpython-314.pyc b/scripts/__pycache__/quick_inquiry_branch_test.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c006c30f2ccc25e97ff8f561edab2a55b01db180 GIT binary patch literal 26675 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 str: + t = re.sub(r"\s+", " ", (text or "").strip()) + return (t[: limit - 1] + "…") if len(t) > limit else t + + +def post_json(url: str, body: Dict[str, Any], timeout: float) -> Tuple[Optional[int], Any, float]: + t0 = time.time() + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json; charset=utf-8"} + ) + try: + with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp: + raw = resp.read().decode("utf-8", errors="replace") + try: + return resp.status, json.loads(raw), time.time() - t0 + except json.JSONDecodeError: + return resp.status, raw, time.time() - t0 + except urllib.error.HTTPError as e: + try: + payload = e.read().decode("utf-8", errors="replace") + try: + return e.code, json.loads(payload), time.time() - t0 + except Exception: + return e.code, payload, time.time() - t0 + except Exception: + return e.code, str(e), time.time() - t0 + except Exception as e: + return None, str(e), time.time() - t0 + + +def extract_answer(body: Any) -> str: + if not isinstance(body, dict): + return one_line(str(body)) + for key in ("answer", "message", "text", "statusMsg", "llmSummary"): + val = body.get(key) + if isinstance(val, str) and val.strip(): + return one_line(val) + dd = body.get("domainData") + if isinstance(dd, dict): + for key in ("llmSummary", "statusMsg", "answer"): + val = dd.get(key) + if isinstance(val, str) and val.strip(): + return one_line(val) + return one_line(json.dumps(body, ensure_ascii=False)) + + +def form_flags(body: Any) -> str: + flags = [] + if not isinstance(body, dict): + return "" + dd = body.get("domainData") if isinstance(body.get("domainData"), dict) else {} + raw = body.get("rawData") if isinstance(body.get("rawData"), dict) else {} + raw_dd = raw.get("domainData") if isinstance(raw.get("domainData"), dict) else {} + for src in (body, dd, raw, raw_dd): + for k, v in src.items(): + if str(k).startswith("needs") and v: + flags.append(f"{k}={v}") + if src.get("needsClarification"): + flags.append("needsClarification=True") + if src.get("listMode"): + flags.append(f"listMode={src.get('listMode')}") + # dedupe + seen = set() + out = [] + for f in flags: + if f not in seen: + seen.add(f) + out.append(f) + return ";".join(out) + + +def detect_mode(body: Any, answer: str) -> str: + """응답을 form/detail/clarify/list/location 으로 분류.""" + if not isinstance(body, dict): + return "unknown" + flags = form_flags(body) + route = str(body.get("routeType") or "") + dd = body.get("domainData") if isinstance(body.get("domainData"), dict) else {} + ans = answer or "" + + # 구체 데이터(전화·요금 등)가 있으면 form 플래그보다 detail 우선 + if re.search(r"0\d{1,2}-\d{3,4}-\d{4}", ans) or "연락처입니다" in ans: + return "detail" + if route == "clarify" or "needsClarification=True" in flags: + return "clarify" + if "위치정보" in ans or ("동의" in ans and "위치" in ans): + return "location" + if any(p in ans for p in ("선택하면", "선택해 주세요", "알려주세요", "어느 노선", "어느 방향", "차량번호가 필요")): + if any(x in flags for x in ("needsRestAreaForm=True", "needsIcTelForm=True")): + return "form" + return "clarify" + if any(x in flags for x in ("needsRestAreaForm=True", "needsIcTelForm=True")): + return "form" + lst = dd.get("list") or dd.get("items") + if isinstance(lst, list) and len(lst) > 0: + return "list" + if any( + k in ans + for k in ("전화", "원", "km/h", "메뉴", "유가", "충전", "구조", "공사", "정체", "서행", "주소") + ): + return "detail" + if body.get("status") is True and len(ans) > 15: + return "detail" + return "unknown" + + +def scenarios() -> List[Dict[str, Any]]: + """기능 × 파라미터 단계 × 구어체/별칭/위치.""" + rows: List[Dict[str, Any]] = [] + + def ask( + feature: str, + tier: str, + expected_mode: str, + intent: str, + question: str, + note: str = "", + ) -> None: + rows.append( + { + "mode": "ask", + "feature": feature, + "param_tier": tier, + "expected_mode": expected_mode, + "expected_intent": intent, + "question": question, + "params": {}, + "note": note, + } + ) + + def domain( + feature: str, + tier: str, + expected_mode: str, + intent: str, + question: str, + params: Dict[str, str], + note: str = "", + ) -> None: + rows.append( + { + "mode": "domain-query", + "feature": feature, + "param_tier": tier, + "expected_mode": expected_mode, + "expected_intent": intent, + "question": question, + "params": params, + "note": note, + } + ) + + # ---- 정체/혼잡 ---- + ask("노선혼잡", "none", "clarify", "TROAD_CONGESTION", "고속도로 정체구간 알려줘") + ask("노선혼잡", "none", "clarify", "TROAD_CONGESTION", "지금 어디가 막혀?") + ask("노선혼잡", "partial", "detail", "TROAD_CONGESTION", "경부선에 정체구간 있어?") + ask("노선혼잡", "partial", "detail", "TROAD_CONGESTION", "영동선 지금 막혀?") + ask("노선혼잡", "alias", "detail", "TROAD_CONGESTION", "경부고속도로 정체 어때?") + ask( + "노선혼잡", + "location", + "location", + "TROAD_CONGESTION", + "이 근처에 정체되는 구간 있어?", + "GPS 동의/근처 영업소 파생 가능", + ) + domain( + "노선혼잡", + "full", + "detail", + "TROAD_CONGESTION", + "경부선 정체 상세", + {"routeName": "경부선"}, + ) + + # ---- 영업소 정보 ---- + ask("영업소정보", "none", "form", "IC_TEL", "영업소 전화번호 좀 알려줘") + ask("영업소정보", "full", "detail", "IC_TEL", "판교 톨게이트 전화번호 좀") + ask("영업소정보", "alias", "detail", "IC_TEL", "동김천 톨게이트 번호 뭐야?") + ask("영업소정보", "alias", "detail", "IC_TEL", "동김천ic 연락처 알려줘") + ask("영업소정보", "alias", "detail", "IC_TEL", "동감천 톨게이트 전화 있어?") # 오표기 + ask("영업소정보", "alias", "detail", "IC_TEL", "판교영업소 대표번호") + ask( + "영업소정보", + "location", + "location", + "IC_TEL", + "여기 근처 영업소 전화번호 알려줘", + "위치기반 폼/상세", + ) + domain("영업소정보", "none", "form", "IC_TEL", "영업소 조회 폼", {}) + domain("영업소정보", "full", "detail", "IC_TEL", "동김천 상세", {"icName": "동김천"}) + + # ---- 통행요금 ---- + ask("구간통행요금", "none", "clarify", "FARE_SEARCH", "톨비 얼마야?") + ask("구간통행요금", "partial", "clarify", "FARE_SEARCH", "서울에서 출발하면 요금 얼마야?") + ask("구간통행요금", "full", "detail", "FARE_SEARCH", "서울에서 부산까지 톨비 얼마 나와?") + ask("구간통행요금", "alias", "detail", "FARE_SEARCH", "판교IC에서 신갈 톨게이트까지 요금 어때?") + ask("구간통행요금", "alias", "detail", "FARE_SEARCH", "판교 영업소에서 수원까지 하이패스 얼마야?") + + # ---- 통행시간 ---- + ask("구간통행시간", "none", "clarify", "TROAD_TRAVEL_TIME", "지금 고속도로 얼마나 걸려?") + ask("구간통행시간", "full", "detail", "TROAD_TRAVEL_TIME", "서울에서 부산까지 지금 가면 얼마나 걸려?") + ask("구간통행시간", "alias", "detail", "TROAD_TRAVEL_TIME", "판교에서 신갈 지금 막혀? 몇 분 걸려?") + + # ---- 교통속보 ---- + ask("노선별교통속보", "none", "clarify", "TROAD_BREAKING_NEWS", "고속도로에 사고 났다는데 어디야?") + ask("노선별교통속보", "partial", "detail", "TROAD_BREAKING_NEWS", "경부선에 사고 났어?") + ask("노선별교통속보", "alias", "detail", "TROAD_BREAKING_NEWS", "영동고속도로 교통속보 있어?") + + # ---- 차단계획 ---- + ask("차단계획", "none", "clarify", "CONSTRUCTION_PLAN", "고속도로 공사하는 데 있어?") + ask("차단계획", "partial", "detail", "CONSTRUCTION_PLAN", "경부선 오늘 공사해서 막히는 데 있어?") + ask("차단계획", "alias", "detail", "CONSTRUCTION_PLAN", "경부고속도로 차로 막는 일정 있나?") + domain( + "차단계획", + "partial", + "detail", + "CONSTRUCTION_PLAN", + "영동선 차단계획", + {"routeName": "영동선"}, + ) + + # ---- 긴급구조 ---- + ask("긴급구조기관", "none", "form", "EMERGENCY_HELP_ORG", "고속도로에서 사고 나면 어디로 연락해?") + ask("긴급구조기관", "partial", "form", "EMERGENCY_HELP_ORG", "경부선 긴급구조기관 알려줘") + ask("긴급구조기관", "alias", "form", "EMERGENCY_HELP_ORG", "경부고속도로 긴급구조대 어디야?") + domain("긴급구조기관", "none", "form", "EMERGENCY_HELP_ORG", "긴급구조 폼", {}) + domain( + "긴급구조기관", + "partial", + "form", + "EMERGENCY_HELP_ORG", + "경부선 선택 폼", + {"routeName": "경부선"}, + ) + + # ---- 휴게소 음식/브랜드/시설 ---- + ask("휴게소음식", "none", "clarify", "REST_AREA_FOOD_LIST", "휴게소에서 뭐 먹지?") + ask("휴게소음식", "full", "detail", "REST_AREA_FOOD_LIST", "죽전휴게소에서 뭐 먹을 거 있어?") + ask("휴게소음식", "alias", "detail", "REST_AREA_FOOD_LIST", "죽전 휴게소 밥 뭐 파나?") + + ask("휴게소브랜드매장", "none", "clarify", "BRAND_SHOP", "휴게소에 카페 있어?") + ask("휴게소브랜드매장", "full", "detail", "BRAND_SHOP", "김천휴게소에 브랜드 매장 뭐 있어?") + ask("휴게소브랜드매장", "alias", "detail", "BRAND_SHOP", "안성 휴게소에 편의점 있나?") + + ask("휴게소시설", "none", "clarify", "REST_AREA_FACILITY", "휴게소에 수유실 있어?") + ask("휴게소시설", "full", "detail", "REST_AREA_FACILITY", "죽전휴게소에 수유실 있나?") + ask("휴게소시설", "alias", "detail", "REST_AREA_FACILITY", "안성SA 샤워실 쓸 수 있어?") + + # ---- 주유 ---- + ask("휴게소주유", "none", "form", "GASSTATION", "고속도로 기름값 얼마야?") + ask("휴게소주유", "full", "detail", "GASSTATION", "죽전휴게소 기름값 얼마야?") + ask("휴게소주유", "partial", "detail", "GASSTATION", "경부선에서 기름 제일 싼 데 어디야?") + ask("휴게소주유", "alias", "detail", "GASSTATION", "안성 휴게소 휘발유값 어때?") + domain("휴게소주유", "none", "form", "GASSTATION", "주유 폼", {}) + + # ---- 충전 ---- + ask("휴게소충전소", "none", "form", "REST_AREA_CHARGING", "고속도로에 전기차 충전소 있어?") + ask("휴게소충전소", "full", "detail", "REST_AREA_CHARGING", "죽전에서 전기차 충전돼?") + ask("휴게소충전소", "partial", "form", "REST_AREA_CHARGING", "경부선에 충전되는 휴게소 어디 있어?") + ask("휴게소충전소", "alias", "detail", "REST_AREA_CHARGING", "강릉휴게소충전소 있어?") + ask("휴게소충전소", "alias", "detail", "REST_AREA_CHARGING", "김천 휴게소에서 수소차 충전해?") + domain("휴게소충전소", "none", "form", "REST_AREA_CHARGING", "충전 폼", {}) + domain( + "휴게소충전소", + "partial", + "form", + "REST_AREA_CHARGING", + "경부선 충전 폼", + {"routeName": "경부선"}, + ) + + # ---- 청년창업 ---- + ask("휴게소청년창업매장", "none", "form", "REST_AREA_YOUTH_SHOP", "고속도로에 청년창업 매장 있어?") + ask("휴게소청년창업매장", "full", "detail", "REST_AREA_YOUTH_SHOP", "죽전휴게소에 청년들 장사하는 가게 있어?") + ask( + "휴게소청년창업매장", + "alias", + "detail", + "REST_AREA_YOUTH_SHOP", + "안성 휴게소 청년몰 있나?", + ) + + # ---- 가까운 휴게소 ---- + ask("가까운휴게소", "partial", "detail", "REST_AREA_NEAREST", "판교 근처 휴게소 어디 있어?") + ask( + "가까운휴게소", + "location", + "location", + "REST_AREA_NEAREST", + "여기 제일 가까운 휴게소 어디야?", + "GPS 경로", + ) + ask("가까운휴게소", "alias", "detail", "REST_AREA_NEAREST", "기흥IC 근처에 쉴 데 있어?") + + # ---- 미납/환불 (파라미터=차량번호) ---- + ask("미납조회", "none", "clarify", "FARE_UNPAID", "미납요금 조회해줘") + ask("미납조회", "full", "detail", "FARE_UNPAID", "야 내 차 12가3456인데 미납 있어?") + ask("환불조회", "none", "clarify", "FARE_REFUND", "톨비 환불받을 거 있어?") + ask("환불조회", "full", "detail", "FARE_REFUND", "하이패스 환불금 들어왔나? 서울33하1122") + + return rows + + +def mode_match(expected: str, actual: str) -> bool: + if expected == actual: + return True + # form↔clarify 는 둘 다 '추가 선택 유도'로 허용 + if {expected, actual} <= {"form", "clarify"}: + return True + # location 질문은 form/clarify/detail 도 허용(클라이언트 GPS 없을 때) + if expected == "location" and actual in {"location", "form", "clarify", "detail"}: + return True + # list 는 detail 상위 개념으로 허용 + if expected == "detail" and actual in {"detail", "list"}: + return True + if expected == "list" and actual in {"list", "form", "detail"}: + return True + return False + + +def intent_ok(expected: str, actual: str) -> bool: + if not actual: + return False + if actual == expected: + return True + aliases = { + "TROAD_BREAKING_NEWS": {"TROAD_BREAKING_NEWS", "TRAFFIC_NEWS"}, + "TROAD_CONGESTION": {"TROAD_CONGESTION"}, + "BRAND_SHOP": {"BRAND_SHOP", "REST_AREA_FACILITY"}, # 편의점 질문은 시설로도 갈 수 있음 + } + return actual in aliases.get(expected, {expected}) + + +def judge( + expected_intent: str, + actual_intent: str, + expected_mode: str, + actual_mode: str, + http_st: Optional[int], + answer: str, +) -> Tuple[str, str]: + if http_st != 200: + return "FAIL", f"HTTP {http_st}" + natural = len(answer or "") >= 6 + i_ok = intent_ok(expected_intent, actual_intent) + m_ok = mode_match(expected_mode, actual_mode) + if i_ok and m_ok and natural: + return "PASS", "의도·응답모드 적합" + if i_ok and natural: + return "PARTIAL", f"모드상이 expected={expected_mode} actual={actual_mode}" + if m_ok and natural: + return "PARTIAL", f"의도상이 expected={expected_intent} actual={actual_intent or '-'}" + if natural: + return "PARTIAL", "응답만 존재" + return "FAIL", "응답 불량" + + +def append_row(path: str, row: Dict[str, str], write_header: bool) -> None: + with open(path, "a", newline="", encoding="utf-8-sig") as f: + w = csv.DictWriter(f, fieldnames=FIELDNAMES) + if write_header: + w.writeheader() + w.writerow(row) + f.flush() + + +def main() -> int: + global _SSL_CTX + ap = argparse.ArgumentParser() + ap.add_argument("--base", default="https://chatbot.ex.co.kr/chatbot") + ap.add_argument("--out", default="test.csv") + ap.add_argument("--timeout-ask", type=float, default=90.0) + ap.add_argument("--timeout-domain", type=float, default=45.0) + ap.add_argument("--sleep-ask", type=float, default=2.0) + ap.add_argument("--sleep-domain", type=float, default=1.2) + ap.add_argument("--insecure", action="store_true") + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--skip-domain", action="store_true") + ap.add_argument("--skip-ask", action="store_true") + args = ap.parse_args() + if args.insecure: + _SSL_CTX = ssl._create_unverified_context() + + base = args.base.rstrip("/") + ask_url = base + "/ask" + domain_url = base + "/domain-query" + started = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + open(args.out, "w", encoding="utf-8-sig").close() + header = True + results: List[str] = [] + + suite = scenarios() + if args.skip_ask: + suite = [c for c in suite if c["mode"] != "ask"] + if args.skip_domain: + suite = [c for c in suite if c["mode"] != "domain-query"] + if args.limit > 0: + suite = suite[: args.limit] + + total = len(suite) + for i, case in enumerate(suite, 1): + print(f"({i}/{total}) {case['mode']} {case['feature']} [{case['param_tier']}] {case['question'][:40]}", flush=True) + if case["mode"] == "ask": + http_st, body, elapsed = post_json( + ask_url, + {"query": case["question"], "botId": f"branch-ask-{i}"}, + args.timeout_ask, + ) + sleep = args.sleep_ask + else: + http_st, body, elapsed = post_json( + domain_url, + { + "intentType": case["expected_intent"], + "params": case["params"], + "botId": f"branch-dom-{i}", + }, + args.timeout_domain, + ) + sleep = args.sleep_domain + + answer = extract_answer(body) + actual_intent = "" + api_status = "" + route_type = "" + if isinstance(body, dict): + actual_intent = str(body.get("intentType") or "") + api_status = str(body.get("status")) + route_type = str(body.get("routeType") or "") + if case["mode"] == "domain-query" and not actual_intent: + actual_intent = case["expected_intent"] + actual_mode = detect_mode(body, answer) + result, reason = judge( + case["expected_intent"], + actual_intent, + case["expected_mode"], + actual_mode, + http_st, + answer, + ) + row = { + "tested_at": started, + "feature": case["feature"], + "param_tier": case["param_tier"], + "expected_mode": case["expected_mode"], + "expected_intent": case["expected_intent"], + "mode": case["mode"], + "question": case["question"], + "params": json.dumps(case.get("params") or {}, ensure_ascii=False), + "http_status": str(http_st or ""), + "elapsed_sec": f"{elapsed:.2f}", + "actual_intent": actual_intent, + "actual_mode": actual_mode, + "api_status": api_status, + "route_type": route_type, + "form_flags": form_flags(body), + "answer": answer, + "result": result, + "reason": reason, + "note": case.get("note") or "", + } + append_row(args.out, row, header) + header = False + results.append(result) + print(f" → {result:7} intent={actual_intent or '-':22} mode={actual_mode:8} | {answer[:70]}", flush=True) + time.sleep(sleep) + + print("---", flush=True) + print(f"saved={args.out} total={len(results)} {dict(Counter(results))}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/quick_inquiry_nlu_test.py b/scripts/quick_inquiry_nlu_test.py new file mode 100644 index 0000000..a5568ba --- /dev/null +++ b/scripts/quick_inquiry_nlu_test.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +"""간편조회 기능 다양 질문 테스트 → test.csv + +- domain-query: 간편조회 UI와 동일 경로 (다수 케이스, 빠름) +- /ask: 실제 고객 자연어 질문 (기능당 소수) + +사용: + PYTHONUNBUFFERED=1 python3 scripts/quick_inquiry_nlu_test.py \\ + --base http://127.0.0.1:8083/web --out test.csv +""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import ssl +import time +import urllib.error +import urllib.request +from collections import Counter +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +# 운영 HTTPS 등에서 로컬 CA 이슈 시 사용 +_SSL_CTX = ssl.create_default_context() + + +def one_line(text: str, limit: int = 280) -> str: + t = re.sub(r"\s+", " ", (text or "").strip()) + return (t[: limit - 1] + "…") if len(t) > limit else t + + +def post_json(url: str, body: Dict[str, Any], timeout: float) -> Tuple[Optional[int], Any, float]: + t0 = time.time() + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json; charset=utf-8"} + ) + try: + with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp: + raw = resp.read().decode("utf-8", errors="replace") + try: + return resp.status, json.loads(raw), time.time() - t0 + except json.JSONDecodeError: + return resp.status, raw, time.time() - t0 + except urllib.error.HTTPError as e: + try: + payload = e.read().decode("utf-8", errors="replace") + try: + return e.code, json.loads(payload), time.time() - t0 + except Exception: + return e.code, payload, time.time() - t0 + except Exception: + return e.code, str(e), time.time() - t0 + except Exception as e: + return None, str(e), time.time() - t0 + + +def get_json(url: str, timeout: float) -> Tuple[Optional[int], Any, float]: + t0 = time.time() + try: + with urllib.request.urlopen(url, timeout=timeout, context=_SSL_CTX) as resp: + return resp.status, json.loads(resp.read().decode("utf-8", errors="replace")), time.time() - t0 + except Exception as e: + return None, str(e), time.time() - t0 + + +def domain_cases_prod_light() -> List[Dict[str, Any]]: + """운영용 — 기능당 1~2건만 (부하 최소화).""" + light: List[Tuple[str, str, str, Dict[str, str]]] = [ + ("미납조회", "FARE_UNPAID", "야 내 차 12가3456인데 미납 있어?", {"carNo": "12가3456"}), + ("환불조회", "FARE_REFUND", "잘못 낸 톨비 환불받을 수 있어? 12가3456", {"carNo": "12가3456"}), + ("구간통행요금", "FARE_SEARCH", "서울에서 부산까지 톨비 얼마 나와?", {"fromIc": "서울", "toIc": "부산"}), + ("구간통행요금", "FARE_SEARCH", "판교에서 신갈 가면 요금이 어떻게 돼?", {"fromIc": "판교", "toIc": "신갈"}), + ("구간통행시간", "TROAD_TRAVEL_TIME", "서울에서 부산까지 지금 가면 얼마나 걸려?", + {"fromIc": "서울", "toIc": "부산", "carType": "1"}), + ("구간통행시간", "TROAD_TRAVEL_TIME", "판교에서 신갈 지금 막혀? 몇 분 걸려?", + {"fromIc": "판교", "toIc": "신갈", "carType": "1"}), + ("차단계획", "CONSTRUCTION_PLAN", "경부선 오늘 공사해서 막히는 데 있어?", {"routeName": "경부선"}), + ("차단계획", "CONSTRUCTION_PLAN", "영동선 차로 막는 일정 있나?", {"routeName": "영동선"}), + ("영업소정보", "IC_TEL", "판교 톨게이트 전화번호 좀", {"icName": "판교"}), + ("영업소정보", "IC_TEL", "기흥 톨게이트 주소랑 전화 좀", {"icName": "기흥"}), + ("긴급구조기관", "EMERGENCY_HELP_ORG", "경부선에서 사고 나면 어디로 연락해?", + {"listMode": "true", "routeName": "경부선"}), + ("휴게소음식", "REST_AREA_FOOD_LIST", "죽전휴게소에서 뭐 먹을 거 있어?", {"restAreaName": "죽전"}), + ("휴게소브랜드매장", "BRAND_SHOP", "죽전에 편의점이나 카페 있어?", {"restAreaName": "죽전"}), + ("휴게소시설", "REST_AREA_FACILITY", "죽전휴게소에 수유실 있나?", {"restAreaName": "죽전"}), + ("휴게소주유", "GASSTATION", "죽전휴게소 기름값 얼마야?", {"restAreaName": "죽전"}), + ("휴게소주유", "GASSTATION", "경부선에서 기름 제일 싼 데 어디야?", + {"compareMode": "cheapest", "fuelType": "휘발유", "routeName": "경부선"}), + ("휴게소충전소", "REST_AREA_CHARGING", "죽전에서 전기차 충전돼?", + {"restAreaName": "죽전", "chargingType": "all"}), + ("휴게소충전소", "REST_AREA_CHARGING", "강릉휴게소에 충전소 있어?", + {"restAreaName": "강릉", "chargingType": "all"}), + ("휴게소청년창업매장", "REST_AREA_YOUTH_SHOP", "죽전휴게소에 청년들 장사하는 가게 있어?", + {"restAreaName": "죽전"}), + ("가까운휴게소", "REST_AREA_NEAREST", "판교 근처 휴게소 어디 있어?", {"icName": "판교"}), + ("노선혼잡", "TROAD_CONGESTION", "경부선 지금 막히는 구간 있어?", {"routeName": "경부선"}), + ] + return [ + { + "mode": "domain-query", + "feature": f, + "expected_intent": intent, + "question": q, + "params": params, + "note": "운영 경량 domain-query", + } + for f, intent, q, params in light + ] + + +def extract_answer(body: Any) -> str: + if not isinstance(body, dict): + return one_line(str(body)) + for key in ("answer", "message", "text", "statusMsg", "llmSummary", "formattedAnswer"): + val = body.get(key) + if isinstance(val, str) and val.strip(): + return one_line(val) + for nest_key in ("domainData", "data", "rawData"): + nest = body.get(nest_key) + if isinstance(nest, dict): + for key in ("llmSummary", "statusMsg", "answer", "message"): + val = nest.get(key) + if isinstance(val, str) and val.strip(): + return one_line(val) + # list summary + lst = nest.get("list") or nest.get("items") + if isinstance(lst, list) and lst: + return one_line(f"건수={len(lst)} 예시={json.dumps(lst[0], ensure_ascii=False)}") + if body.get("needsRestAreaForm") or body.get("needsIcTelForm"): + return one_line(str(body.get("statusMsg") or "폼 유도 응답")) + return one_line(json.dumps(body, ensure_ascii=False)) + + +def intent_of(body: Any, fallback: str = "") -> str: + if not isinstance(body, dict): + return fallback + return str(body.get("intentType") or fallback) + + +def status_of(body: Any) -> str: + if not isinstance(body, dict): + return "" + if "status" in body: + return str(body.get("status")) + dd = body.get("domainData") + if isinstance(dd, dict) and "status" in dd: + return str(dd.get("status")) + return "" + + +INTENT_OK = { + "FARE_UNPAID": {"FARE_UNPAID"}, + "FARE_REFUND": {"FARE_REFUND"}, + "FARE_SEARCH": {"FARE_SEARCH"}, + "TROAD_TRAVEL_TIME": {"TROAD_TRAVEL_TIME"}, + "TROAD_BREAKING_NEWS": {"TROAD_BREAKING_NEWS", "TRAFFIC_NEWS"}, + "TRAFFIC_NEWS": {"TRAFFIC_NEWS", "TROAD_BREAKING_NEWS"}, + "CONSTRUCTION_PLAN": {"CONSTRUCTION_PLAN"}, + "IC_TEL": {"IC_TEL"}, + "EMERGENCY_HELP_ORG": {"EMERGENCY_HELP_ORG"}, + "REST_AREA_FOOD_LIST": {"REST_AREA_FOOD_LIST"}, + "BRAND_SHOP": {"BRAND_SHOP"}, + "REST_AREA_FACILITY": {"REST_AREA_FACILITY"}, + "GASSTATION": {"GASSTATION"}, + "REST_AREA_CHARGING": {"REST_AREA_CHARGING"}, + "REST_AREA_YOUTH_SHOP": {"REST_AREA_YOUTH_SHOP"}, + "REST_AREA_NEAREST": {"REST_AREA_NEAREST"}, + "TROAD_CONGESTION": {"TROAD_CONGESTION"}, +} + + +def judge(expected: str, actual: str, status: str, answer: str, http_st: Optional[int]) -> Tuple[str, str]: + if http_st != 200: + return "FAIL", f"HTTP {http_st}" + natural = len(answer or "") >= 6 and "Traceback" not in (answer or "") + intent_ok = (actual or "") in INTENT_OK.get(expected, {expected}) or actual == expected + st_true = status in ("True", "true", "1") + infra = any( + h in (answer or "") + for h in ("일시적인 장애", "Failed to obtain", "Listener refused", "연결") + ) + if intent_ok and st_true and natural: + return "PASS", "의도·응답 정상" + if intent_ok and natural and infra: + return "PARTIAL", "의도 일치, 인프라/DB 제약 안내" + if intent_ok and natural: + return "PARTIAL", "의도 일치, 데이터없음/안내성 응답" + if natural and actual: + return "PARTIAL", f"의도상이 expected={expected} actual={actual}" + if natural: + return "PARTIAL", "응답만 존재" + return "FAIL", f"의도/응답 불량 expected={expected} actual={actual or '-'}" + + +# ---- case builders ---- + +def domain_cases() -> List[Dict[str, Any]]: + """간편조회 domain-query — 질문은 구어체, 호출은 폼/직조회 params.""" + rows: List[Dict[str, Any]] = [] + + def add(feature: str, intent: str, question: str, params: Dict[str, str], note: str = "") -> None: + rows.append( + { + "mode": "domain-query", + "feature": feature, + "expected_intent": intent, + "question": question, + "params": params, + "note": note, + } + ) + + # 미납/환불 + add("미납조회", "FARE_UNPAID", "야 내 차 12가3456인데 미납 있어?", {"carNo": "12가3456"}, "Oracle 미연결 시 PARTIAL 가능") + add("미납조회", "FARE_UNPAID", "하이패스 미납금 좀 봐줘 서울12가3456", {"carNo": "서울12가3456"}, "Oracle 미연결 시 PARTIAL 가능") + add("미납조회", "FARE_UNPAID", "톨비 밀린 거 있어요? 차량번호 123가4567요", {"carNo": "123가4567"}, "Oracle 미연결 시 PARTIAL 가능") + add("미납조회", "FARE_UNPAID", "98나1234 미납통행료 나와요?", {"carNo": "98나1234"}, "Oracle 미연결 시 PARTIAL 가능") + add("환불조회", "FARE_REFUND", "12가3456으로 환불받을 돈 있어요?", {"carNo": "12가3456"}, "Oracle 미연결 시 PARTIAL 가능") + add("환불조회", "FARE_REFUND", "잘못 낸 톨비 환불 내역 좀 확인해줘 서울12가3456", {"carNo": "서울12가3456"}, "Oracle 미연결 시 PARTIAL 가능") + add("환불조회", "FARE_REFUND", "과오납 환불금 나왔나? 123가4567", {"carNo": "123가4567"}, "Oracle 미연결 시 PARTIAL 가능") + add("환불조회", "FARE_REFUND", "하이패스 환불받을 거 있는지 봐줘 98나1234", {"carNo": "98나1234"}, "Oracle 미연결 시 PARTIAL 가능") + + # 통행요금 + fare_qs = [ + ("서울에서 부산까지 톨비 얼마 나와?", "서울", "부산"), + ("판교에서 신갈 가면 요금이 어떻게 돼?", "판교", "신갈"), + ("판교IC에서 신갈IC까지 하이패스 얼마야?", "판교IC", "신갈IC"), + ("판교 영업소에서 수원까지 통행료 좀", "판교영업소", "수원"), + ("대전에서 대구 가는 데 톨비 얼마쯤 해?", "대전", "대구"), + ("천안에서 남이IC까지 요금 알려줄래?", "천안", "남이"), + ("서울 톨게이트에서 기흥까지 얼마야?", "서울영업소", "기흥"), + ("김포에서 인천 쪽 고속도로 요금 궁금해", "김포", "인천"), + ] + for q, a, b in fare_qs: + add("구간통행요금", "FARE_SEARCH", q, {"fromIc": a, "toIc": b}) + + # 통행시간 + time_qs = [ + ("서울에서 부산까지 지금 가면 얼마나 걸려?", "서울", "부산"), + ("판교에서 신갈 지금 막혀? 몇 분 걸려?", "판교", "신갈"), + ("대전에서 대구까지 지금 소요시간 어때?", "대전", "대구"), + ("서울IC에서 부산IC 지금 가면 몇 시간이야?", "서울IC", "부산IC"), + ("기흥에서 천안까지 지금 얼마나 걸릴까?", "기흥", "천안"), + ] + for q, a, b in time_qs: + add("구간통행시간", "TROAD_TRAVEL_TIME", q, {"fromIc": a, "toIc": b, "carType": "1"}) + + # 차단계획 + for q, route in [ + ("경부선 오늘 공사해서 막히는 데 있어?", "경부선"), + ("영동선 차로 막는 일정 있나?", "영동선"), + ("서해안선 공사구간 좀 알려줘", "서해안선"), + ("호남선 이번 주 차단계획 있어?", "호남선"), + ("경부고속도로 공사한다던데 어디야?", "경부고속도로"), + ("중부내륙선 차로통제 있어?", "중부내륙선"), + ]: + add("차단계획", "CONSTRUCTION_PLAN", q, {"routeName": route}) + + # 영업소 + for q, ic in [ + ("판교 톨게이트 전화번호 좀", "판교"), + ("판교IC 전화 어떻게 걸어?", "판교IC"), + ("판교 영업소 연락처 알려줘", "판교영업소"), + ("서울 영업소 번호 뭐야?", "서울"), + ("기흥 톨게이트 주소랑 전화 좀", "기흥"), + ("부산IC 영업소 정보 있어?", "부산IC"), + ("신갈 영업소 전화 받나?", "신갈"), + ("경부선 판교 쪽 영업소 번호 알려줘", "경부선판교"), + ]: + add("영업소정보", "IC_TEL", q, {"icName": ic}) + + # 긴급구조 + for q, route in [ + ("경부선에서 사고 나면 어디로 연락해?", "경부선"), + ("영동선 긴급구조 가능한 데 어디야?", "영동선"), + ("서해안선 긴급구조기관 좀 알려줘", "서해안선"), + ]: + add("긴급구조기관", "EMERGENCY_HELP_ORG", q, {"listMode": "true", "routeName": route}) + add("긴급구조기관", "EMERGENCY_HELP_ORG", "판교 근처 고속도로 긴급구조대 있어?", {"icName": "판교"}) + + # 휴게소 + rest_sets = [ + ("죽전", "죽전휴게소에서 뭐 먹을 거 있어?", "죽전에 편의점이나 카페 있어?", + "죽전휴게소에 수유실 있나?", "죽전휴게소 기름값 얼마야?", + "죽전에서 전기차 충전돼?", "죽전휴게소에 청년들 장사하는 가게 있어?"), + ("안성", "안성휴게소 밥 뭐 파나?", "안성휴게소에 브랜드 매장 뭐 있어?", + "안성휴게소 샤워실 쓸 수 있어?", "안성 휘발유 얼마야?", + "안성휴게소 전기차 충전소 있어?", "안성에 청년창업 매장 있어?"), + ("김천", "김천휴게소에서 뭐 먹지?", "김천휴게소 입점 매장 뭐 있어?", + "김천휴게소 편의시설 뭐 있어?", "김천 경유값 어때?", + "김천휴게소에 수소차 충전해?", "김천에 청년몰 있어?"), + ("금강", "금강휴게소 메뉴 추천해줘", "금강휴게소에 카페 있어?", + "금강휴게소 시설 뭐 있어?", "금강휴게소 주유소 있어?", + "금강에서 전기차 충전 가능해?", "금강휴게소 청년창업매장 있나?"), + ("죽전휴게소", "죽전 휴게소 식당 뭐 있어?", "죽전 휴게소에 쇼핑할 데 있어?", + "죽전 휴게소에 애완동물 시설 있어?", "죽전 휴게소 기름 넣어?", + "죽전 휴게소 EV 충전돼?", "죽전 휴게소 청년가게 있어?"), + ("강릉", "강릉휴게소에서 뭐 먹을까?", "강릉휴게소 매장 뭐 있어?", + "강릉휴게소 편의시설 알려줘", "강릉휴게소 주유소 유가 궁금해", + "강릉휴게소 충전소 있어?", "강릉에 청년창업 매장 있어?"), + ] + for name, q_food, q_brand, q_fac, q_gas, q_chg, q_youth in rest_sets: + add("휴게소음식", "REST_AREA_FOOD_LIST", q_food, {"restAreaName": name}) + add("휴게소브랜드매장", "BRAND_SHOP", q_brand, {"restAreaName": name}) + add("휴게소시설", "REST_AREA_FACILITY", q_fac, {"restAreaName": name}) + add("휴게소주유", "GASSTATION", q_gas, {"restAreaName": name}) + add("휴게소충전소", "REST_AREA_CHARGING", q_chg, {"restAreaName": name, "chargingType": "all"}) + add("휴게소청년창업매장", "REST_AREA_YOUTH_SHOP", q_youth, {"restAreaName": name}) + + add("휴게소주유", "GASSTATION", "경부선에서 기름 제일 싼 데 어디야?", + {"compareMode": "cheapest", "fuelType": "휘발유", "routeName": "경부선"}) + add("휴게소주유", "GASSTATION", "고속도로에서 경유 제일 싼 곳 알려줘", + {"compareMode": "cheapest", "fuelType": "경유"}) + add("휴게소충전소", "REST_AREA_CHARGING", "경부선에 전기차 충전되는 휴게소 어디 있어?", + {"listMode": "true", "routeName": "경부선", "chargingType": "electric"}) + add("휴게소충전소", "REST_AREA_CHARGING", "강릉휴게소충전소 있어?", + {"restAreaName": "강릉휴게소충전소", "chargingType": "all"}) + + for q, ic in [ + ("판교 근처 휴게소 어디 있어?", "판교"), + ("서울 톨게이트 지나서 바로 나오는 휴게소 어디야?", "서울"), + ("기흥IC 근처에 쉴 데 있어?", "기흥IC"), + ]: + add("가까운휴게소", "REST_AREA_NEAREST", q, {"icName": ic}) + + for q, route in [ + ("경부선 지금 막히는 구간 있어?", "경부선"), + ("영동선 어디가 막혀?", "영동선"), + ("서해안선 정체 심한 데 알려줘", "서해안선"), + ]: + add("노선혼잡", "TROAD_CONGESTION", q, {"routeName": route}) + + return rows + + +def ask_cases() -> List[Dict[str, Any]]: + """자연어 /ask — 일반 이용자 구어체 질문.""" + pairs = [ + ("미납조회", "FARE_UNPAID", [ + "야 내 차 12가3456인데 미납 있어?", + "하이패스 미납금 나왔나 봐줘 서울12가3456", + "톨비 밀린 거 있어요? 45다6789요", + ]), + ("환불조회", "FARE_REFUND", [ + "잘못 낸 톨비 환불받을 수 있어? 12가3456", + "하이패스 환불금 들어왔나? 서울33하1122", + ]), + ("구간통행요금", "FARE_SEARCH", [ + "서울에서 부산까지 톨비 얼마 나와?", + "판교에서 신갈 가면 요금이 어떻게 돼?", + "판교IC에서 신갈IC까지 하이패스 얼마야?", + "대전에서 대구 가는 데 톨비 얼마쯤 해?", + ]), + ("구간통행시간", "TROAD_TRAVEL_TIME", [ + "서울에서 부산까지 지금 가면 얼마나 걸려?", + "판교에서 신갈 지금 막혀? 몇 분 걸려?", + "지금 대전에서 대구까지 가면 몇 시간이야?", + ]), + ("노선별교통속보", "TROAD_BREAKING_NEWS", [ + "고속도로에 사고 났다는데 어디야?", + "경부선에 사고 났어?", + "영동선 지금 교통상황 어때?", + ]), + ("차단계획", "CONSTRUCTION_PLAN", [ + "경부선 오늘 공사해서 막히는 데 있어?", + "영동선 차로 막는 일정 있나?", + "서해안선 공사구간 좀 알려줘", + ]), + ("영업소정보", "IC_TEL", [ + "판교 톨게이트 전화번호 좀", + "기흥 톨게이트 주소랑 전화 좀", + "판교 영업소 연락처 알려줘", + ]), + ("긴급구조기관", "EMERGENCY_HELP_ORG", [ + "경부선에서 사고 나면 어디로 연락해?", + "고속도로에서 사고 났는데 가까운 구조대 어디야?", + ]), + ("휴게소음식", "REST_AREA_FOOD_LIST", [ + "죽전휴게소에서 뭐 먹을 거 있어?", + "안성휴게소 밥 뭐 파나?", + ]), + ("휴게소브랜드매장", "BRAND_SHOP", [ + "죽전에 편의점이나 카페 있어?", + "김천휴게소에 브랜드 매장 뭐 있어?", + ]), + ("휴게소시설", "REST_AREA_FACILITY", [ + "죽전휴게소에 수유실 있나?", + "안성휴게소 샤워실 쓸 수 있어?", + ]), + ("휴게소주유", "GASSTATION", [ + "죽전휴게소 기름값 얼마야?", + "경부선에서 기름 제일 싼 데 어디야?", + "고속도로에서 휘발유 제일 싼 곳 알려줘", + ]), + ("휴게소충전소", "REST_AREA_CHARGING", [ + "죽전에서 전기차 충전돼?", + "강릉휴게소에 충전소 있어?", + "김천휴게소에서 수소차 충전해?", + ]), + ("휴게소청년창업매장", "REST_AREA_YOUTH_SHOP", [ + "죽전휴게소에 청년들 장사하는 가게 있어?", + "고속도로에 청년창업 매장 있는 휴게소 어디야?", + ]), + ("가까운휴게소", "REST_AREA_NEAREST", [ + "판교 근처 휴게소 어디 있어?", + "서울 톨게이트 지나서 바로 나오는 휴게소 어디야?", + ]), + ("노선혼잡", "TROAD_CONGESTION", [ + "경부선 지금 막히는 구간 있어?", + "영동선 어디가 막혀?", + ]), + ] + rows: List[Dict[str, Any]] = [] + for feature, intent, qs in pairs: + for q in qs: + rows.append( + { + "mode": "ask", + "feature": feature, + "expected_intent": intent, + "question": q, + "params": {}, + "note": "구어체 자연어 채팅", + } + ) + return rows + + +FIELDNAMES = [ + "tested_at", + "mode", + "feature", + "expected_intent", + "question", + "params", + "http_status", + "elapsed_sec", + "actual_intent", + "api_status", + "answer", + "result", + "reason", + "route_type", + "note", +] + + +def append_row(path: str, row: Dict[str, str], write_header: bool) -> None: + with open(path, "a", newline="", encoding="utf-8-sig") as f: + w = csv.DictWriter(f, fieldnames=FIELDNAMES) + if write_header: + w.writeheader() + w.writerow(row) + f.flush() + + +def main() -> int: + global _SSL_CTX + ap = argparse.ArgumentParser() + ap.add_argument("--base", default="http://127.0.0.1:8083/web") + ap.add_argument("--out", default="test.csv") + ap.add_argument("--timeout-domain", type=float, default=45.0) + ap.add_argument("--timeout-ask", type=float, default=90.0) + ap.add_argument("--sleep", type=float, default=0.15) + ap.add_argument("--sleep-ask", type=float, default=0.0, help="ask 전용 sleep(0이면 --sleep)") + ap.add_argument("--skip-ask", action="store_true") + ap.add_argument("--skip-domain", action="store_true") + ap.add_argument("--limit", type=int, default=0) + ap.add_argument( + "--profile", + choices=["full", "prod"], + default="full", + help="prod=기능당 소수 + 구어체(운영 부하 최소화)", + ) + ap.add_argument("--insecure", action="store_true", help="SSL 검증 비활성화") + args = ap.parse_args() + + if args.insecure: + _SSL_CTX = ssl._create_unverified_context() + + base = args.base.rstrip("/") + domain_url = base + "/domain-query" + ask_url = base + "/ask" + traffic_url = base + "/traffic-news" + started = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + sleep_ask = args.sleep_ask if args.sleep_ask > 0 else max(args.sleep, 1.5) + + # reset out + open(args.out, "w", encoding="utf-8-sig").close() + header_needed = True + results: List[str] = [] + + def emit(row: Dict[str, str]) -> None: + nonlocal header_needed + append_row(args.out, row, header_needed) + header_needed = False + results.append(row["result"]) + print( + f"{row['result']:7} [{row['mode']:12}] {row['feature']:14} " + f"{row['expected_intent']:22} → {row['actual_intent'] or '-':22} | {row['question'][:42]}", + flush=True, + ) + + # traffic-news GET + http_st, body, elapsed = get_json(traffic_url, args.timeout_domain) + ans = extract_answer(body) if isinstance(body, (dict, list)) else one_line(str(body)) + if isinstance(body, list): + ans = one_line(f"list={len(body)}") + ok = http_st == 200 + else: + ok = http_st == 200 and ( + ( + isinstance(body, dict) + and ( + body.get("status") is True + or "list" in body + or "items" in body + or "routes" in body + ) + ) + or isinstance(body, list) + ) + emit( + { + "tested_at": started, + "mode": "domain-api", + "feature": "노선별교통속보", + "expected_intent": "TRAFFIC_NEWS", + "question": "오늘 고속도로에 사고나 통제 소식 있어?", + "params": "", + "http_status": str(http_st or ""), + "elapsed_sec": f"{elapsed:.2f}", + "actual_intent": "TRAFFIC_NEWS", + "api_status": "True" if ok else "False", + "answer": ans, + "result": "PASS" if ok else "FAIL", + "reason": "GET /traffic-news" if ok else "GET 실패", + "route_type": "GET", + "note": "간편조회 UI 전용", + } + ) + + suite: List[Dict[str, Any]] = [] + if not args.skip_domain: + suite.extend(domain_cases_prod_light() if args.profile == "prod" else domain_cases()) + if not args.skip_ask: + # prod: 기능당 질문 최대 2개로 축소 + ask = ask_cases() + if args.profile == "prod": + by_feat: Dict[str, int] = {} + trimmed: List[Dict[str, Any]] = [] + for c in ask: + n = by_feat.get(c["feature"], 0) + if n < 2: + trimmed.append(c) + by_feat[c["feature"]] = n + 1 + ask = trimmed + suite.extend(ask) + if args.limit > 0: + suite = suite[: args.limit] + + total = len(suite) + for i, case in enumerate(suite, 1): + mode = case["mode"] + print(f"({i}/{total}) …", flush=True) + if mode == "domain-query": + http_st, body, elapsed = post_json( + domain_url, + { + "intentType": case["expected_intent"], + "params": case["params"], + "botId": f"qi-dom-{i}", + }, + args.timeout_domain, + ) + actual = intent_of(body, case["expected_intent"]) + # domain-query often echoes intent in request; keep expected if missing + if not actual: + actual = case["expected_intent"] + else: + http_st, body, elapsed = post_json( + ask_url, + {"query": case["question"], "botId": f"qi-ask-{i}"}, + args.timeout_ask, + ) + actual = intent_of(body) + + status = status_of(body) + answer = extract_answer(body) + route_type = "" + if isinstance(body, dict): + route_type = str(body.get("routeType") or mode) + result, reason = judge(case["expected_intent"], actual, status, answer, http_st) + emit( + { + "tested_at": started, + "mode": mode, + "feature": case["feature"], + "expected_intent": case["expected_intent"], + "question": case["question"], + "params": json.dumps(case.get("params") or {}, ensure_ascii=False), + "http_status": str(http_st or ""), + "elapsed_sec": f"{elapsed:.2f}", + "actual_intent": actual, + "api_status": status, + "answer": answer, + "result": result, + "reason": reason, + "route_type": route_type, + "note": case.get("note") or "", + } + ) + time.sleep(sleep_ask if mode == "ask" else args.sleep) + + c = Counter(results) + print("---", flush=True) + print(f"saved={args.out} total={len(results)} {dict(c)}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server-dev/caddy/Caddyfile.agent.exdev b/server-dev/caddy/Caddyfile.agent.exdev index e986066..bb11e17 100755 --- a/server-dev/caddy/Caddyfile.agent.exdev +++ b/server-dev/caddy/Caddyfile.agent.exdev @@ -1,154 +1,154 @@ -# exdev 통합 HTTPS (443) — stock / chatbot(Agent) / bizcare / nationcompany / github / apex -# Agent 배포 시 chatbot-only Caddyfile 로 덮어써 다른 서브도메인이 깨진 것을 복구 -{ - email {$CADDY_ACME_EMAIL:admin@exdev.co.kr} - auto_https disable_redirects -} - -(tls_certs) { - tls /etc/caddy/certs/fullchain.pem /etc/caddy/certs/privkey.pem -} - -(proxy_std) { - flush_interval -1 - transport http { - read_timeout 120s - write_timeout 120s - versions 1.1 2 - } - header_up Host {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} -} - -# ── GoldenChart ─────────────────────────────────────────────────────────────── -https://{$STOCK_DOMAIN:stock.exdev.co.kr} { - import tls_certs - handle { - reverse_proxy host.docker.internal:{$GOLDENCHART_HOST_PORT:80} { - import proxy_std - } - } -} - -# ── Chatbot Agent 2.0 ───────────────────────────────────────────────────────── -https://{$CHATBOT_DOMAIN:chatbot.exdev.co.kr} { - import tls_certs - - # 업스트림 nginx 가 HTTP 로 absolute redirect 해서 http:// 로 떨어지지 않게 Caddy 에서 처리 - redir / /chatbot/ai/ 302 - - handle /agent/* { - reverse_proxy host.docker.internal:{$RAG_API_PORT:28012} { - transport http { - read_timeout 120s - write_timeout 120s - } - } - } - - handle /chatbot/admin* { - uri strip_prefix /chatbot/admin - reverse_proxy host.docker.internal:{$ADMIN_PORT:28013} { - transport http { - read_timeout 120s - write_timeout 120s - } - } - } - - handle /web/* { - reverse_proxy host.docker.internal:{$SKILL_PORT:8083} { - transport http { - read_timeout 120s - write_timeout 120s - } - } - } - - # 카카오 오픈빌더 Skill 웹훅 (/kakao/skill, /oakak/skill) - handle /kakao/* { - reverse_proxy host.docker.internal:{$SKILL_PORT:8083} { - transport http { - read_timeout 120s - write_timeout 120s - } - } - } - - handle /oakak/* { - reverse_proxy host.docker.internal:{$SKILL_PORT:8083} { - transport http { - read_timeout 120s - write_timeout 120s - } - } - } - - # iOS 홈화면 Web Clip — MIME 를 Caddy에서 강제 (인앱/프록시에서 text 로 깨지는 것 방지) - handle /chatbot/ai/downloads/*.mobileconfig { - header Content-Type application/x-apple-aspen-config - header Content-Disposition inline - header Cache-Control "no-cache, no-store, must-revalidate" - reverse_proxy host.docker.internal:{$CHATBOT_APP_PORT:8092} { - import proxy_std - } - } - - handle { - reverse_proxy host.docker.internal:{$CHATBOT_APP_PORT:8092} { - import proxy_std - } - } -} - -# ── BizCare ─────────────────────────────────────────────────────────────────── -https://{$BIZCARE_DOMAIN:bizcare.exdev.co.kr} { - tls /etc/caddy/certs/bizcare/fullchain.pem /etc/caddy/certs/bizcare/privkey.pem - handle { - reverse_proxy host.docker.internal:{$BIZCARE_HOST_PORT:83} { - import proxy_std - } - } -} - -# ── ChannelONE (nationcompany) — TLS-ALPN ACME ──────────────────────────────── -https://{$NATIONCOMPANY_DOMAIN:nationcompany.exdev.co.kr} { - handle { - reverse_proxy host.docker.internal:{$NATIONCOMPANY_HOST_PORT:84} { - import proxy_std - } - } -} - -# ── Gitea (github.exdev.co.kr) ──────────────────────────────────────────────── -https://{$GITHUB_DOMAIN:github.exdev.co.kr} { - tls /etc/caddy/certs/github/fullchain.pem /etc/caddy/certs/github/privkey.pem - handle { - reverse_proxy host.docker.internal:{$GITEA_HOST_PORT:3000} { - flush_interval -1 - transport http { - read_timeout 3600s - write_timeout 3600s - versions 1.1 2 - } - header_up Host {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - } - } -} - -# ── Legacy apex ─────────────────────────────────────────────────────────────── -https://{$HTTPS_DOMAIN:exdev.co.kr} { - import tls_certs - @legacy_chatbot path /chatbot /chatbot/* - handle @legacy_chatbot { - redir https://{$CHATBOT_DOMAIN:chatbot.exdev.co.kr}{uri} permanent - } - handle { - redir https://{$STOCK_DOMAIN:stock.exdev.co.kr}{uri} permanent - } -} +# exdev 통합 HTTPS (443) — stock / chatbot(Agent) / bizcare / nationcompany / github / apex +# Agent 배포 시 chatbot-only Caddyfile 로 덮어써 다른 서브도메인이 깨진 것을 복구 +{ + email {$CADDY_ACME_EMAIL:admin@exdev.co.kr} + auto_https disable_redirects +} + +(tls_certs) { + tls /etc/caddy/certs/fullchain.pem /etc/caddy/certs/privkey.pem +} + +(proxy_std) { + flush_interval -1 + transport http { + read_timeout 120s + write_timeout 120s + versions 1.1 2 + } + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} +} + +# ── GoldenChart ─────────────────────────────────────────────────────────────── +https://{$STOCK_DOMAIN:stock.exdev.co.kr} { + import tls_certs + handle { + reverse_proxy host.docker.internal:{$GOLDENCHART_HOST_PORT:80} { + import proxy_std + } + } +} + +# ── Chatbot Agent 2.0 ───────────────────────────────────────────────────────── +https://{$CHATBOT_DOMAIN:chatbot.exdev.co.kr} { + import tls_certs + + # 업스트림 nginx 가 HTTP 로 absolute redirect 해서 http:// 로 떨어지지 않게 Caddy 에서 처리 + redir / /chatbot/ai/ 302 + + handle /agent/* { + reverse_proxy host.docker.internal:{$RAG_API_PORT:28012} { + transport http { + read_timeout 120s + write_timeout 120s + } + } + } + + handle /chatbot/admin* { + uri strip_prefix /chatbot/admin + reverse_proxy host.docker.internal:{$ADMIN_PORT:28013} { + transport http { + read_timeout 120s + write_timeout 120s + } + } + } + + handle /web/* { + reverse_proxy host.docker.internal:{$SKILL_PORT:8083} { + transport http { + read_timeout 120s + write_timeout 120s + } + } + } + + # 카카오 오픈빌더 Skill 웹훅 (/kakao/skill, /oakak/skill) + handle /kakao/* { + reverse_proxy host.docker.internal:{$SKILL_PORT:8083} { + transport http { + read_timeout 120s + write_timeout 120s + } + } + } + + handle /oakak/* { + reverse_proxy host.docker.internal:{$SKILL_PORT:8083} { + transport http { + read_timeout 120s + write_timeout 120s + } + } + } + + # iOS 홈화면 Web Clip — MIME 를 Caddy에서 강제 (인앱/프록시에서 text 로 깨지는 것 방지) + handle /chatbot/ai/downloads/*.mobileconfig { + header Content-Type application/x-apple-aspen-config + header Content-Disposition inline + header Cache-Control "no-cache, no-store, must-revalidate" + reverse_proxy host.docker.internal:{$CHATBOT_APP_PORT:8092} { + import proxy_std + } + } + + handle { + reverse_proxy host.docker.internal:{$CHATBOT_APP_PORT:8092} { + import proxy_std + } + } +} + +# ── BizCare ─────────────────────────────────────────────────────────────────── +https://{$BIZCARE_DOMAIN:bizcare.exdev.co.kr} { + tls /etc/caddy/certs/bizcare/fullchain.pem /etc/caddy/certs/bizcare/privkey.pem + handle { + reverse_proxy host.docker.internal:{$BIZCARE_HOST_PORT:83} { + import proxy_std + } + } +} + +# ── ChannelONE (nationcompany) — TLS-ALPN ACME ──────────────────────────────── +https://{$NATIONCOMPANY_DOMAIN:nationcompany.exdev.co.kr} { + handle { + reverse_proxy host.docker.internal:{$NATIONCOMPANY_HOST_PORT:84} { + import proxy_std + } + } +} + +# ── Gitea (github.exdev.co.kr) ──────────────────────────────────────────────── +https://{$GITHUB_DOMAIN:github.exdev.co.kr} { + tls /etc/caddy/certs/github/fullchain.pem /etc/caddy/certs/github/privkey.pem + handle { + reverse_proxy host.docker.internal:{$GITEA_HOST_PORT:3000} { + flush_interval -1 + transport http { + read_timeout 3600s + write_timeout 3600s + versions 1.1 2 + } + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + } +} + +# ── Legacy apex ─────────────────────────────────────────────────────────────── +https://{$HTTPS_DOMAIN:exdev.co.kr} { + import tls_certs + @legacy_chatbot path /chatbot /chatbot/* + handle @legacy_chatbot { + redir https://{$CHATBOT_DOMAIN:chatbot.exdev.co.kr}{uri} permanent + } + handle { + redir https://{$STOCK_DOMAIN:stock.exdev.co.kr}{uri} permanent + } +}