Spaces:
Running
Running
Juhaha commited on
Commit ·
126c3a8
1
Parent(s): dd17284
MCP 응답을 v1 봉투로 통일 (primaryMatch 제거)
Browse filessearch_menu/fetch_menu_raw/health 응답을 우리 v1 봉투(agent/trace_id/ok/elapsed_ms/error)로 통일. results는 {rank,menu_id,menu_name,menu_path,similarity}. 담당자 rule-v1의 primaryMatch는 의미 불일치(우리는 임베딩 quality, 담당자는 확정매칭)로 제거. tool 이름(search_menu/fetch_menu_raw/health)은 게이트웨이 호출용 유지. agentHint는 유효 카테고리면 필터 적용. 로컬 e2e 검증 완료. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mcp_server.py +45 -38
- test_mcp_http.py +4 -4
mcp_server.py
CHANGED
|
@@ -128,21 +128,23 @@ def _strip_scr(menu_id) -> str:
|
|
| 128 |
|
| 129 |
|
| 130 |
# ════════════════════════════════════════════════════════════════════════════
|
| 131 |
-
# Tool) health — 상태 / 버전 / 데이터 건수
|
| 132 |
# ════════════════════════════════════════════════════════════════════════════
|
| 133 |
@mcp.tool()
|
| 134 |
def health() -> dict:
|
| 135 |
-
"""서버 상태
|
|
|
|
| 136 |
try:
|
| 137 |
engine = _get_engine()
|
| 138 |
stats = engine.vectorstore.get_stats()
|
| 139 |
-
return {"status": "ok", "
|
| 140 |
-
|
| 141 |
-
|
| 142 |
except Exception as e:
|
| 143 |
logger.exception("health 실패")
|
| 144 |
-
return {"status": "error", "
|
| 145 |
-
|
|
|
|
| 146 |
|
| 147 |
|
| 148 |
# ════════════════════════════════════════════════════════════════════════════
|
|
@@ -172,51 +174,54 @@ def search_menu(
|
|
| 172 |
Returns:
|
| 173 |
{version, query, results:[{id, menuName, path}], primaryMatch, [debug]}.
|
| 174 |
"""
|
|
|
|
| 175 |
if not query or not query.strip():
|
| 176 |
-
return {"
|
| 177 |
-
|
|
|
|
| 178 |
try:
|
| 179 |
from core.agent import run_agent
|
| 180 |
limit = max(1, min(int(limit), 30))
|
|
|
|
|
|
|
| 181 |
with _quiet_stdout(): # run_agent 내부 print → stderr (stdout 오염 방지)
|
| 182 |
-
result = run_agent(query=query, top_n=limit,
|
|
|
|
| 183 |
hits = result.get("search_results", [])
|
| 184 |
|
| 185 |
results = []
|
| 186 |
-
for r in hits:
|
| 187 |
item = {
|
| 188 |
-
"
|
| 189 |
-
"
|
| 190 |
-
"
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
if verbose:
|
| 193 |
-
item["
|
| 194 |
-
item["matchType"] = ENGINE_VERSION
|
| 195 |
item["keywords"] = r.get("keywords", [])
|
| 196 |
results.append(item)
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
"
|
| 201 |
-
"
|
| 202 |
-
"
|
| 203 |
-
}
|
| 204 |
if debug:
|
| 205 |
-
|
| 206 |
-
"
|
| 207 |
-
"
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
"output": [{"id": _strip_scr(r.get("menu_id", "")),
|
| 212 |
-
"path": r.get("menu_path", "")} for r in hits],
|
| 213 |
-
}],
|
| 214 |
}
|
| 215 |
-
return
|
| 216 |
except Exception as e:
|
| 217 |
logger.exception("search_menu 실패")
|
| 218 |
-
return {"
|
| 219 |
-
|
|
|
|
| 220 |
|
| 221 |
|
| 222 |
# ════════════════════════════════════════════════════════════════════════════
|
|
@@ -229,15 +234,17 @@ def fetch_menu_raw() -> dict:
|
|
| 229 |
외부에서 참조 데이터 전체를 받아 검색 결과를 대조 검증하는 용도.
|
| 230 |
|
| 231 |
Returns:
|
| 232 |
-
|
| 233 |
"""
|
|
|
|
| 234 |
try:
|
| 235 |
menus = _get_raw_menus()
|
| 236 |
-
return {"
|
| 237 |
-
|
| 238 |
except Exception as e:
|
| 239 |
logger.exception("fetch_menu_raw 실패")
|
| 240 |
-
return {"
|
|
|
|
| 241 |
|
| 242 |
|
| 243 |
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
# ════════════════════════════════════════════════════════════════════════════
|
| 131 |
+
# Tool) health — 상태(liveness/readiness) / 버전 / 데이터 건수 (v1 봉투)
|
| 132 |
# ════════════════════════════════════════════════════════════════════════════
|
| 133 |
@mcp.tool()
|
| 134 |
def health() -> dict:
|
| 135 |
+
"""서버 상태(liveness/readiness)·버전·참조 데이터 건수·엔진을 v1 봉투로 반환한다."""
|
| 136 |
+
started = time.time()
|
| 137 |
try:
|
| 138 |
engine = _get_engine()
|
| 139 |
stats = engine.vectorstore.get_stats()
|
| 140 |
+
return _envelope({"status": "ok", "live": True, "ready": True,
|
| 141 |
+
"menu_count": stats.get("total_menus", 0),
|
| 142 |
+
"engine": ENGINE_VERSION}, None, started)
|
| 143 |
except Exception as e:
|
| 144 |
logger.exception("health 실패")
|
| 145 |
+
return _envelope({"status": "error", "live": True, "ready": False},
|
| 146 |
+
None, started, ok=False,
|
| 147 |
+
error=_err("ENGINE_NOT_READY", "검색엔진 미준비", str(e)))
|
| 148 |
|
| 149 |
|
| 150 |
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
| 174 |
Returns:
|
| 175 |
{version, query, results:[{id, menuName, path}], primaryMatch, [debug]}.
|
| 176 |
"""
|
| 177 |
+
started = time.time()
|
| 178 |
if not query or not query.strip():
|
| 179 |
+
return _envelope({"query": query, "result_count": 0, "results": []},
|
| 180 |
+
None, started, ok=False,
|
| 181 |
+
error=_err("BAD_REQUEST", "query가 비어있음"))
|
| 182 |
try:
|
| 183 |
from core.agent import run_agent
|
| 184 |
limit = max(1, min(int(limit), 30))
|
| 185 |
+
# agentHint가 유효 카테고리면 필터로, 아니면 자체 카테고리 감지 사용
|
| 186 |
+
cat = agentHint if agentHint in CATEGORIES else None
|
| 187 |
with _quiet_stdout(): # run_agent 내부 print → stderr (stdout 오염 방지)
|
| 188 |
+
result = run_agent(query=query, top_n=limit,
|
| 189 |
+
category_filter=cat, use_hyde=True)
|
| 190 |
hits = result.get("search_results", [])
|
| 191 |
|
| 192 |
results = []
|
| 193 |
+
for i, r in enumerate(hits, 1):
|
| 194 |
item = {
|
| 195 |
+
"rank": i,
|
| 196 |
+
"menu_id": r.get("menu_id", ""),
|
| 197 |
+
"menu_name": r.get("menu_name", ""),
|
| 198 |
+
"menu_path": r.get("menu_path", ""),
|
| 199 |
+
"similarity": round(r.get("similarity", 0.0), 4),
|
| 200 |
}
|
| 201 |
if verbose:
|
| 202 |
+
item["category"] = r.get("category")
|
|
|
|
| 203 |
item["keywords"] = r.get("keywords", [])
|
| 204 |
results.append(item)
|
| 205 |
|
| 206 |
+
payload = {"query": query, "result_count": len(results), "results": results}
|
| 207 |
+
if verbose:
|
| 208 |
+
payload["detected_category"] = result.get("detected_category")
|
| 209 |
+
payload["intent"] = result.get("intent")
|
| 210 |
+
payload["quality_score"] = round(result.get("quality_score", 0.0), 4)
|
|
|
|
| 211 |
if debug:
|
| 212 |
+
payload["debug"] = {
|
| 213 |
+
"refined_query": result.get("refined_query", query),
|
| 214 |
+
"retry_count": result.get("retry_count", 0),
|
| 215 |
+
"rewrite_history": result.get("rewrite_history", []),
|
| 216 |
+
"detected_category": result.get("detected_category"),
|
| 217 |
+
"intent": result.get("intent"),
|
|
|
|
|
|
|
|
|
|
| 218 |
}
|
| 219 |
+
return _envelope(payload, None, started)
|
| 220 |
except Exception as e:
|
| 221 |
logger.exception("search_menu 실패")
|
| 222 |
+
return _envelope({"query": query, "result_count": 0, "results": []},
|
| 223 |
+
None, started, ok=False,
|
| 224 |
+
error=_err("INTERNAL", "검색 중 오류", str(e)))
|
| 225 |
|
| 226 |
|
| 227 |
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
| 234 |
외부에서 참조 데이터 전체를 받아 검색 결과를 대조 검증하는 용도.
|
| 235 |
|
| 236 |
Returns:
|
| 237 |
+
v1 봉투 + count + menus:[원본 메뉴 dict, ...].
|
| 238 |
"""
|
| 239 |
+
started = time.time()
|
| 240 |
try:
|
| 241 |
menus = _get_raw_menus()
|
| 242 |
+
return _envelope({"count": len(menus), "menus": list(menus.values())},
|
| 243 |
+
None, started)
|
| 244 |
except Exception as e:
|
| 245 |
logger.exception("fetch_menu_raw 실패")
|
| 246 |
+
return _envelope({"count": 0, "menus": []}, None, started, ok=False,
|
| 247 |
+
error=_err("INTERNAL", "조회 오류", str(e)))
|
| 248 |
|
| 249 |
|
| 250 |
# ════════════════════════════════════════════════════════════════════════════
|
test_mcp_http.py
CHANGED
|
@@ -16,7 +16,7 @@ def _show(label, result):
|
|
| 16 |
txt = result.content[0].text if result.content else "{}"
|
| 17 |
try:
|
| 18 |
obj = json.loads(txt)
|
| 19 |
-
print(f"\n[{label}] {
|
| 20 |
return obj
|
| 21 |
except Exception:
|
| 22 |
print(f"\n[{label}] {txt[:150]}")
|
|
@@ -32,13 +32,13 @@ async def main():
|
|
| 32 |
print("=== tools/list ===")
|
| 33 |
for t in tools.tools:
|
| 34 |
print(f" - {t.name}")
|
| 35 |
-
_show("health", await session.call_tool("health", {}, read_timeout_seconds=TO))
|
|
|
|
| 36 |
r = _show("search_menu(SOXL 주가)", await session.call_tool(
|
| 37 |
"search_menu", {"query": "SOXL 주가", "limit": 3, "verbose": True},
|
| 38 |
read_timeout_seconds=TO))
|
| 39 |
-
print(f" version={r.get('version')} primaryMatch={r.get('primaryMatch')}")
|
| 40 |
for it in r.get("results", []):
|
| 41 |
-
print(f"
|
| 42 |
r = _show("fetch_menu_raw()", await session.call_tool(
|
| 43 |
"fetch_menu_raw", {}, read_timeout_seconds=TO))
|
| 44 |
print(f" count={r.get('count')} (첫 메뉴: {(r.get('menus') or [{}])[0].get('menu_path')})")
|
|
|
|
| 16 |
txt = result.content[0].text if result.content else "{}"
|
| 17 |
try:
|
| 18 |
obj = json.loads(txt)
|
| 19 |
+
print(f"\n[{label}] ok={obj.get('ok')} elapsed={obj.get('elapsed_ms')}ms trace={obj.get('trace_id','')[:8]}")
|
| 20 |
return obj
|
| 21 |
except Exception:
|
| 22 |
print(f"\n[{label}] {txt[:150]}")
|
|
|
|
| 32 |
print("=== tools/list ===")
|
| 33 |
for t in tools.tools:
|
| 34 |
print(f" - {t.name}")
|
| 35 |
+
r = _show("health", await session.call_tool("health", {}, read_timeout_seconds=TO))
|
| 36 |
+
print(f" status={r.get('status')} menu_count={r.get('menu_count')} engine={r.get('engine')}")
|
| 37 |
r = _show("search_menu(SOXL 주가)", await session.call_tool(
|
| 38 |
"search_menu", {"query": "SOXL 주가", "limit": 3, "verbose": True},
|
| 39 |
read_timeout_seconds=TO))
|
|
|
|
| 40 |
for it in r.get("results", []):
|
| 41 |
+
print(f" {it['rank']}. {it['menu_name']} | {it['menu_path']} sim={it.get('similarity')}")
|
| 42 |
r = _show("fetch_menu_raw()", await session.call_tool(
|
| 43 |
"fetch_menu_raw", {}, read_timeout_seconds=TO))
|
| 44 |
print(f" count={r.get('count')} (첫 메뉴: {(r.get('menus') or [{}])[0].get('menu_path')})")
|