Juhaha commited on
Commit
126c3a8
·
1 Parent(s): dd17284

MCP 응답을 v1 봉투로 통일 (primaryMatch 제거)

Browse files

search_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>

Files changed (2) hide show
  1. mcp_server.py +45 -38
  2. 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", "version": AGENT_VERSION,
140
- "dataCount": stats.get("total_menus", 0),
141
- "engines": [ENGINE_VERSION]}
142
  except Exception as e:
143
  logger.exception("health 실패")
144
- return {"status": "error", "version": AGENT_VERSION,
145
- "dataCount": 0, "engines": [], "error": str(e)}
 
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 {"version": ENGINE_VERSION, "query": query, "results": [],
177
- "primaryMatch": False, "error": "query가 비어있음"}
 
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, use_hyde=True)
 
183
  hits = result.get("search_results", [])
184
 
185
  results = []
186
- for r in hits:
187
  item = {
188
- "id": _strip_scr(r.get("menu_id", "")),
189
- "menuName": r.get("menu_name", ""),
190
- "path": r.get("menu_path", ""),
 
 
191
  }
192
  if verbose:
193
- item["score"] = round(r.get("similarity", 0.0), 4)
194
- item["matchType"] = ENGINE_VERSION
195
  item["keywords"] = r.get("keywords", [])
196
  results.append(item)
197
 
198
- out = {
199
- "version": ENGINE_VERSION,
200
- "query": query,
201
- "results": results,
202
- "primaryMatch": result.get("is_satisfactory", False),
203
- }
204
  if debug:
205
- out["debug"] = {
206
- "keywords": [query.strip()],
207
- "traces": [{
208
- "toolName": "hydeSearch",
209
- "input": {"query": query,
210
- "refinedQuery": result.get("refined_query", query)},
211
- "output": [{"id": _strip_scr(r.get("menu_id", "")),
212
- "path": r.get("menu_path", "")} for r in hits],
213
- }],
214
  }
215
- return out
216
  except Exception as e:
217
  logger.exception("search_menu 실패")
218
- return {"version": ENGINE_VERSION, "query": query, "results": [],
219
- "primaryMatch": False, "error": str(e)}
 
220
 
221
 
222
  # ════════════════════════════════════════════════════════════════════════════
@@ -229,15 +234,17 @@ def fetch_menu_raw() -> dict:
229
  외부에서 참조 데이터 전체를 받아 검색 결과를 대조 검증하는 용도.
230
 
231
  Returns:
232
- {version, count, menus:[원본 메뉴 dict, ...]}.
233
  """
 
234
  try:
235
  menus = _get_raw_menus()
236
- return {"version": ENGINE_VERSION, "count": len(menus),
237
- "menus": list(menus.values())}
238
  except Exception as e:
239
  logger.exception("fetch_menu_raw 실패")
240
- return {"version": ENGINE_VERSION, "count": 0, "menus": [], "error": str(e)}
 
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}] {json.dumps(obj, ensure_ascii=False)[:120]}")
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" - id={it['id']} {it['menuName']} | {it['path']} score={it.get('score')}")
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')})")