김민경 Cursor commited on
Commit
7321c53
·
1 Parent(s): 106a83d

feat: ToolCard 버전 관리 + 퀵 테스트 + Admin UX 개선 + README 전면 재작성

Browse files

- ToolCardStore: JSON 영속화, 버전 이력, Diff, 롤백 지원
- Admin Dashboard 퀵 테스트 탭: 실시간 쿼리 검색, 배치 Recall 평가, LLM-as-Judge 분석
- Admin UX: 비개발자 가이드 패널, 안전/위험 동작 시각 구분, 마크다운 렌더링
- 검색 결과 가독성: 점수 바 시각화, Recall 카드 설명 추가, 스텝 넘버링
- README: 음/슴체 통일, 구조 재편(문제→전략→결과→상세→운영), 핵심 중심 압축

Co-authored-by: Cursor <cursoragent@cursor.com>

README.md CHANGED
@@ -8,33 +8,31 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # AI TMR Assistant — Tool Routing 고도화
12
 
13
- > **Intelligent Tool Routing**으로 정확도를 개선, **Scalable Tool Architecture**로 운영 효율과 장성을 동시에 확보
14
 
15
  ---
16
 
17
- ## 1. 무엇을 해결하려 했는가
18
 
19
- 12개 보험 상품 × 9개 기능(조회·산출·심사·보장·청구·컴플라이언스 등) = **54개 도구**를 운용하는 AI 챗봇.
20
- 도구가 많아지면서 세 가지 문제가 .
21
 
22
  | 문제 | 원인 | 영향 |
23
  |------|------|------|
24
  | 오호출 | 유사 도구 혼동 (premium_estimate ↔ plan_options) | 잘못된 답변 |
25
- | 비용 증가 | 매 요청마다 54개 스키마가 LLM 컨텍스트에 포함 | 토큰 낭비 |
26
- | 지연 증가 | 컨텍스트 길이에 비례 응답 시간 상승 | UX 저하 |
27
 
28
- > 도구 10개 초과 정확도가 저하되고, 37개 기준 ~6,200 토큰 소비 [(참고)](https://achan2013.medium.com/how-many-tools-functions-can-an-ai-agent-has-21e0a82b7847).
29
- > "전부 넘기지 말고, 필요한 것만 검색해서 넘기자" — RAG-MCP 패턴 [(참고)](https://writer.com/engineering/rag-mcp/)
30
 
31
  ---
32
 
33
- ## 2. 어떻게 해결했는가
34
 
35
- ### 전략 A. Intelligent Tool Routing — 54개를 5개로 줄임
36
-
37
- Guardrail → Tool Search → LLM 선택, 3단계 필터링으로 정확한 도구만 LLM에 전달함.
38
 
39
  ```
40
  사용자 질문
@@ -49,32 +47,51 @@ Guardrail → Tool Search → LLM 선택, 3단계 필터링으로 정확한 도
49
 
50
  | 단계 | 모듈 | 기능 | 속도 |
51
  |------|------|------|------|
52
- | Guardrail | 정규식(L1) + 임베딩(L2) | 탈옥·비보험 질문 차단 | <5ms |
53
  | Tool Search | ChromaDB 멀티벡터 | 54개 → Top-K 후보 추출 | ~10ms |
54
- | LLM Selection | bind_tools() | 후보 중 실제 필요한 도구 호출 | 1~5s |
55
 
56
- **핵심:** Guardrail이 먼저 동작하므로 "오늘 날씨 어때?" 같은 질문은 벡터 검색·LLM 호출 없이 즉시 차단됨.
57
 
58
- ### 전략 B. Scalable Tool Architecture 추가가 운영 부담이 되지 않도록
59
 
60
- ```
61
- Tool 추가 Tool Card 작성 임베딩 자동 생성 → 즉시 검색 대상에 포
 
 
 
 
 
 
 
 
 
62
  ```
63
 
64
- | 방식 | 절차 | 서버 재시작 |
65
- |------|------|-------------|
66
- | 정적 등록 | Tool 함수 + ToolCard 서버 재시작 | 필요 |
67
- | 런타임 핫리로드 | Tool 함수 + API 호출 (`POST /api/tools/reload-module/{module}`) | **불필요** |
 
 
68
 
69
- ToolRegistry가 동적 관리고, 변경 ChromaDB 재인덱싱을 자동 트리거함.
 
70
 
71
- ### 전략 C. Validation 감이 아니라 숫자판단
 
72
 
73
- `scripts/eval_tool_recall.py`로 Recall@k, MRR, Hit@1을 정량 측정함.
74
 
75
- ```bash
76
- python -m scripts.eval_tool_recall --compare # k=1,3,5,7,10 비교표
77
- python -m scripts.eval_tool_recall --verbose # 오판 사례 상세
 
 
 
 
 
 
78
  ```
79
 
80
  ---
@@ -86,26 +103,22 @@ python -m scripts.eval_tool_recall --verbose # 오판 사례 상세
86
  | 지표 | k=1 | k=3 | **k=5 (운영)** | k=7 | k=10 |
87
  |------|-----|-----|----------------|-----|------|
88
  | **Recall@k** | 96.9% | 100% | **100%** | 100% | 100% |
89
- | **Hit@1** | 96.9% | 96.9% | **96.9%** | 96.9% | 96.9% |
90
- | **MRR** | 0.969 | 0.984 | **0.984** | 0.984 | 0.984 |
91
- | **No-Call Acc** | 80.0% | 80.0% | **80.0%** | 80.0% | 80.0% |
92
-
93
- | 점수 분포 (k=10) | min | avg | max |
94
- |-------------------|-----|-----|-----|
95
- | Tool-Call top-1 | 0.867 | 0.921 | 0.947 |
96
- | No-Call top-1 | 0.831 | 0.853 | 0.877 |
97
 
98
- - k=1 미탐 2건: 유사 도구 경계 사례 (coverage_detail ↔ benefit_amount, renewal_projection ↔ renewal_notice)
99
- - k=3부터 Recall 100%. 64개 tool-call 쿼리 전부 Top-3 안에 정답 도구 포함
100
- - no-call 오판 3건: 비보험 질문이지만 유사도 0.86~0.88로 경계에 걸림. Guardrail(L1+L2)에서 사전 차단되므로 실운영에서는 Tool Search 도달
101
 
102
- **결론:** 54개 → 5개로 90% 축소해도 **Recall@5 = 100%, MRR = 0.98**. 정확도를 유지하면서 비용과 지연을 동시에 줄임.
 
103
 
104
  ---
105
 
106
  ## 4. 구현 상세
107
 
108
- ### 4-1. 5노드 파이프라인 (LangGraph)
109
 
110
  ```
111
  START → [input_guardrail] → [query_rewriter] → [agent ↔ tools] → [output_guardrail] → END
@@ -114,78 +127,71 @@ START → [input_guardrail] → [query_rewriter] → [agent ↔ tools] → [outp
114
  | 노드 | 역할 | 소요 시간 |
115
  |------|------|-----------|
116
  | input_guardrail | 정규식(L1) + 임베딩(L2)으로 이상 요청 차단 | <5ms |
117
- | query_rewriter | 은 후속질문을 이전 맥락으로 재작성 | 0~1s |
118
- | agent | ChromaDB Top-K 필터링 → LLM 호출 | 1~5s |
119
- | tools | ToolRegistry 동적 디스패치 | 10~100ms |
120
  | output_guardrail | PII·금칙어 검사 + 면책 문구 자동 추가 | <2ms |
121
 
122
- ### 4-2. 쿼리 재작성 (Query Rewriter)
 
123
 
124
- "그거 얼마야?", "그건?" 같은 짧은 후속질문은 벡터 검색 정확도가 떨어짐. 이전 대화 맥락을 참조해 구체적 쿼리로 재작성하여 Tool Search 정확도를 보완함. Query Transformation은 Advanced RAG 핵심 기법 [(참고)](https://www.promptingguide.ai/research/rag).
125
 
126
- ### 4-3. 상품공시실 PDF 기반 RAG
 
 
127
 
128
- 보험 상품공시실에서 12개 상품요약서 PDF + 표준약관 + 회사 정보를 수집. PyMuPDF로 텍스트 추출 → 500자 청크 → ChromaDB 인제스트(~1,400 벡터). 도구 데이터에 없는 약관 조항·면책 규정을 RAG가 보완함.
129
 
130
- ### 4-4. Agentic 시스템 프롬프트
131
-
132
- 12개 상품 목록이 PRODUCTS 딕셔너리에서 동적 반영. 새 상품 추가 시 프롬프트가 자동 업데이트됨. 도구 체이닝 규칙("상품명만 알면 product_search → 해당 순서")도 포함하여 LLM이 자율적으로 연쇄 호출함.
133
-
134
- ### 4-5. LLM 사고과정터링
135
-
136
- Qwen3의 `<think>...</think>` 블록을 스트리밍 중 실시간 필터링. 사용자에게는 최종 답변만 노출, SSE 이벤트로 파이프라인 진행 상태를 표시하여 체감 지연을 줄임.
137
-
138
- ### 4-6. 도구 레벨 입력 가드
139
-
140
- 보험료 산출·가입 심사 등 나이/성별이 필수인 도구는, 사용자가 제공하지 않은 정보를 추측하지 않음. 도구가 `needs_user_input`을 반환하면 LLM이 해당 정보를 질문함.
141
-
142
- ### 4-7. 상품 카탈로그 UI
143
-
144
- 헤더의 "상품 목록" 버튼 또는 페이지 접속 시 자동으로 12개 상품 카탈로그 모달이 표시됨. 카테고리·갱신유형·간편심사 태그로 필터링하고, 상품 클릭 시 보장 내용 질문이 자동 세팅됨. 모바일 반응형 대응.
145
 
146
- ### 4-8. 서빙: 두 가지 인터페이스
147
 
148
  | 방식 | 설명 | 대상 |
149
  |------|------|------|
150
- | FastAPI (REST/SSE) | 웹 Chat UI + REST API | 일반 사용자 |
151
  | MCP Server (SSE/stdio) | 도구 54 + 리소스 17 + 프롬프트 8 노출 | Claude Desktop, Cursor 등 |
152
 
153
- MCP Inspector UI로 도구 입출력, 리소스 조회, 프롬프트 렌더링을 브라우저에서 직접 테스트 가능.
154
-
155
  ```bash
156
- python run_mcp.py --inspect
 
 
157
  ```
158
 
159
- ### 4-9. 도구 추가 체크리스트
160
 
161
- 도구를 추가할 때 아래 4단계를 순서대로 수행한다.
162
 
163
- **① 도구 함수 작성** — `app/tools/` 아래 해당 모듈에 `@tool` 함수를 추가한다. 함수의 `tool.name`이 이후 모든 연동의 키가 된다.
164
 
165
- **② ToolCard 등록** `app/tool_search/tool_cards.py`의 `_CARDS` 리스트에 카드추가한다.
166
 
167
- | 필드 | 규칙 | 예시 |
168
- |------|------|------|
169
- | `name` | tool.name과 **정확히** 일치 | `"premium_estimate"` |
170
- | `purpose` | 한 문장으로 명확하게 | `"예상 월 보험료를 산출한다."` |
171
- | `when_to_use` | **실제 사용자 발화** 패턴으로 작성 | `("보험료 얼마야?", "40세 남성 보험료")` |
172
- | `when_not_to_use` | 혼동 도구명을 `→ tool_name 사용` 형식으로 명시 | `("납입 플랜 → plan_options 사용",)` |
173
- | `tags` | 도메인 키워드 (필터링용) | `("보험료", "산출")` |
174
 
175
- > `when_to_use`가 다른 도구 카드와 **중복되면 임베딩이 충돌**한다. `validate_duplicate_when_to_use()` 자동 검출하므로 평가 크립 실행할 것.
176
 
177
- ToolCard 설계는 Tool Document Expansion [(Tool-DE, Lu et al. 2025)](https://arxiv.org/abs/2510.22670) 연구에 기반한다. purpose·when_to_use·tags로 임베딩 표면을 확장하고, when_not_to_use는 LLM description에만 주입하여 벡터 오염을 방지한다. Re-Invoke [(Google, EMNLP 2024)](https://arxiv.org/abs/2408.01875)의 합성 쿼리 전략과 동일 원리이며, ablation 결과 negative example을 임베딩에서 제외할 때 NDCG가 가장 높았다.
 
 
 
 
 
 
 
 
178
 
179
- **③ ��동 쌍 관리** — 기능이 유사한 도구가 있으면 양방향으로 처리한다.
180
 
181
  ```
182
- 1. 새 카드 when_not_to_use에 기존 유사 도구 언급
183
- 2. 기존 유사 도구 when_not_to_use에 새 도구 언급
184
  3. CONFUSION_PAIRS 리스트에 (기존, 신규) 쌍 등록
185
  ```
186
 
187
- `validate_confusion_pairs()`가 양방향 누락을 검출한다. 유사 도구 간 명시적 cross-reference는 ToolBench [(ICLR 2024)](https://arxiv.org/abs/2307.16789)에서 도구 수 증가 시 정확도 저하를 방지하는 핵심 전략으로 제시되었다.
188
-
189
  **④ 검증**
190
 
191
  ```bash
@@ -193,65 +199,44 @@ python -m scripts.eval_tool_recall --compare # Recall@k, MRR 확인
193
  python -m scripts.eval_tool_recall --verbose # 오판 사례 상세
194
  ```
195
 
196
- **연동 자동/수동 요약:**
197
-
198
- | 연동 지점 | 자동/수동 | 설명 |
199
  |-----------|:---------:|------|
200
- | ChromaDB 임베딩 | 자동 | 서버 시작 시 해시 비교 → 변경 감지되면 재인덱싱 |
201
- | LLM tool description | 자동 | `when_not_to_use`가 bind_tools() 시 description에 주입 |
202
- | 평가 스크립트 | 동 | 카드 정합성 검증이 평 실행 |
203
- | 도구 함수 (`app/tools/`) | **수동** | 카드만 있고 실제 함수가 없으면 동작하지 않음 |
204
- | `CONFUSION_PAIRS` | **수동** | 유사 도구가 있을 경우 반드시 등록 |
205
-
206
- > ToolCard가 없는 도구는 `tool.description` 단일 문서로 fallback 되어 동작은 하지만 검색 정확도가 낮다. 서버 로그에 `"ToolCard 없는 도구 N개"` 경고가 출력된다.
207
 
208
- ### 4-10. 런타임 도구 관리 API
209
 
210
- 서버 재시작 없이 도구를 추가·제거·확인할 수 있는 REST API를 제공한다. ToolRegistry [(동적 레지스트리 패턴)](https://python.langchain.com/docs/how_to/tools_runtime/)가 변경을 감지하고 ChromaDB 재인덱싱을 자동 트리거한다.
211
-
212
- ```bash
213
- # 전체 도구 목록 조회
214
- curl http://localhost:8080/api/tools
215
 
216
- # 특정 도구 런타임 해제 (ChromaDB 벡터도 자동 삭제)
217
- curl -X DELETE http://localhost:8080/api/tools/premium_estimate
218
 
219
- # 모듈 단위 핫리로드 (수정한 도구 코드를 서버 재시작 없이 반영)
220
- curl -X POST http://localhost:8080/api/tools/reload-module/premium
221
- ```
 
 
 
 
 
222
 
223
- | API | 메서드 | 기능 |
224
- |-----|--------|------|
225
- | `/api/tools` | GET | 전체 도구 목록 + 메타데이터 |
226
- | `/api/tools/{tool_name}` | DELETE | 도구 해제 + ChromaDB 벡터 삭제 |
227
- | `/api/tools/reload-module/{module}` | POST | 모듈 `importlib.reload()` → 도구 재등록 |
228
 
229
- MCP Inspector에서도 도구 입출력을 브라우저에서 직접 테스트할 수 있다.
230
 
231
  ```bash
232
- python run_mcp.py --inspect # Inspector UI → http://localhost:5173
 
 
233
  ```
234
 
235
- ### 4-11. Tool Admin Dashboard
236
-
237
- CLI 대신 브라우저에서 도구 레지스트리를 관리하는 웹 UI(`/admin/tools`)를 제공한다. 별도 서버·포트 없이 기존 FastAPI 앱에 내장되어 HF Spaces 등 배포 환경에서도 동작함.
238
-
239
- | 기능 | 설명 |
240
- |------|------|
241
- | 대시보드 | 등록 ��구 수, ChromaDB 벡터 수, Registry 버전, 서비스 상태 (10초 자동 갱신) |
242
- | 도구 테이블 | 이름, purpose, 태그, when_to_use 예시, ToolCard 유무 |
243
- | 도구 해제 | 확인 모달 → DELETE API → ChromaDB 벡터 즉시 삭제 |
244
- | 모듈 핫리로드 | 8개 모듈 선택 → register_many() → 재인덱싱 1회 |
245
- | 상세보기 | purpose, module, tags, when_to_use, when_not_to_use 전체 표시 |
246
- | 검색 | 이름·설명·태그 실시간 필터링 |
247
-
248
- 챗봇 UI 헤더의 **Tool Admin** 버튼 또는 `/admin/tools` 직접 접속으로 사용한다.
249
-
250
- > **MCP Inspector와의 차이:** MCP Inspector는 MCP 프로토콜 레벨의 디버깅 도구(도구 입출력 테스트)이고, Tool Admin은 애플리케이션 레벨의 운영 도구(도구 해제·복원, 임베딩 상태 모니터링)이다. 개발 시에는 Inspector, 운영 시에는 Admin Dashboard를 사용한다.
251
 
252
  ---
253
 
254
- ## 5. 기술 선택 근거
255
 
256
  ### ChromaDB
257
 
@@ -262,54 +247,35 @@ CLI 대신 브라우저에서 도구 레지스트리를 관리하는 웹 UI(`/ad
262
  | 실시간 upsert | rebuild 필요 | O | **O** |
263
  | 인프라 | 없음 | Docker 3개 | **pip 1줄** |
264
 
265
- 벡터 ~1,800 규모에서 Milvus는 오버엔지니어링, FAISS는 메타데이터 필터링 미지원. 10M 벡터 미만 프로젝트에서 ChromaDB 권장 [(Firecrawl)](https://www.firecrawl.dev/blog/best-vector-databases) [(DataCamp)](https://www.datacamp.com/blog/the-top-5-vector-databases).
 
266
 
267
  ### multilingual-e5-large
268
 
269
- [Kor-IR 벤치마크](https://github.com/Atipico1/Kor-IR)에서 오픈소스 최상위(NDCG@10 = 80.35). Mr. TyDi 한국어 MRR@10 = 61.6으로 e5-base(55.8) 대비 +10% [(모델 카드)](https://huggingface.co/intfloat/multilingual-e5-large). 비대칭 검색 시 "query: " / "passage: " 프리픽스 필수 [(E5 논문)](https://arxiv.org/abs/2402.05672). 로컬 추론(~10ms/쿼리)으로 외부 API 미의존.
270
-
271
- ### Multi-Vector 인덱싱
272
-
273
- 도구 하나를 단일 벡터로 임베딩하면 여러 사용 예시의 평균으로 벡터가 희석됨. purpose + when_to_use를 별도 문서로 인덱싱하고, 검색 시 tool별 max score로 집계하여 희석 없이 정확한 매칭을 달성. ColBERT 등 multi-vector 모델이 single-vector 대비 정확도가 높은 것과 동일 원리 [(Pinecone)](https://www.pinecone.io/blog/cascading-retrieval-with-multi-vector-representations/).
274
 
275
- ### Tool Card (Tool Document Expansion)
276
 
277
- LLM 도구 description은 보통 한두 줄. 이 짧은 텍스트만 임베딩하면 유사 도구 간 벡터가 거의 같아져 검색 정확도가 떨어짐.
278
-
279
- ```python
280
- ToolCard(
281
- name="premium_estimate",
282
- purpose="나·성별을 입력해 특정 상품의 예상 보험료를 산출한다.",
283
- when_to_use=("보험료 얼마야?", "40세 남성 보험료 계산해줘"),
284
- when_not_to_use=("납입 플랜이 궁금하다 → plan_options 사용",),
285
- tags=("보험료", "산출"),
286
- )
287
- ```
288
-
289
- | ToolCard 필드 | 학술 대응 | 임베딩 포함 | 역할 |
290
- |---------------|-----------|:-----------:|------|
291
- | `purpose` | Tool-DE의 function_description | O | 도구 핵심 기능 |
292
- | `when_to_use` | Re-Invoke의 synthetic queries | O | 검색 표면 확장 |
293
- | `tags` | Tool-DE의 tags | O | 도메인 클러스터링 |
294
- | `when_not_to_use` | Tool-DE의 limitations | X | LLM 최종 선택 시 혼동 방지 |
295
-
296
- when_not_to_use를 임베딩에서 제외한 이유: 타 도구 어휘("premium_estimate 사용")가 포함되어 벡터가 오염됨. Tool-DE ablation에서도 negative example 포함 시 성능 저하 확인.
297
-
298
- **학술 근거:**
299
- - **Tool-DE** (Lu et al., 2025) — 도구 문서 확장으로 NDCG@10 +6~7ppt, Recall@10 +10ppt 개선 [(논문)](https://arxiv.org/abs/2510.22670)
300
- - **Re-Invoke** (Google, EMNLP 2024) — 합성 쿼리 생성으로 nDCG@5 유의미 향상 [(논문)](https://arxiv.org/abs/2408.01875)
301
- - **RAG-MCP** (WRITER, 2025) — 메타데이터 기반 인덱싱 → 토큰 50%+ 절감 [(블로그)](https://writer.com/engineering/rag-mcp/)
302
 
303
  ---
304
 
305
- ## 6. 알려진 한계 및 고도화 방향
306
 
307
- | 한계 | 현상 | 고도화 방향 |
308
- |------|------|-------------|
309
- | product_search when_to_use 오버핏 | 타 도구 영역 발화 10개가 임베딩을 희석 | 순수 상품 검색 발화만 유지 |
310
- | 유사 도구 cross-reference 누락 | renewal_projection ↔ renewal_notice 양방향 가이드 부재 | when_not_to_use 양방향 보완 |
311
- | 수동 작성 한계 | 54개 × 7개 = ~380개 when_to_use 수동 관리 | Re-Invoke 방식 LLM 합성 쿼리 자동 생성 |
312
- | 정적 no-call 임계값 | Tool-Call min(0.867)과 No-Call max(0.877)이 겹침 | Reranker 2단계 도입 (Tool-Rank) |
313
 
314
  ---
315
 
@@ -355,7 +321,8 @@ app/
355
  │ └── data.py # 12개 상품 데이터 + 시스템 프롬프트
356
  ├── tool_search/ # ChromaDB 멀티벡터 라우팅
357
  │ ├── embedder.py # 임베딩 + Top-K 검색
358
- ── tool_cards.py # 54개 ToolCard
 
359
  ├── rag/ # 상품공시실 PDF RAG
360
  │ ├── retriever.py # 인제스트 + 검색
361
  │ └── splitter.py # 한국어 문장경계 청크 분할
 
8
  pinned: false
9
  ---
10
 
11
+ # AI TMR Assistant — Intelligent Tool Routing
12
 
13
+ > 54개운용는 보험 AI 챗봇에서, **필요한 도구만 골라 전달하는 검색 기반 라우팅**도·비용·지연을 동시에 개선
14
 
15
  ---
16
 
17
+ ## 1. 문제
18
 
19
+ 12개 보험 상품 × 9개 기능(조회·산출·심사·보장·청구 등) = **54개 도구**.
20
+ 도구가 많아지면서 세 가지 문제가 생.
21
 
22
  | 문제 | 원인 | 영향 |
23
  |------|------|------|
24
  | 오호출 | 유사 도구 혼동 (premium_estimate ↔ plan_options) | 잘못된 답변 |
25
+ | 비용 증가 | 매 요청마다 54개 스키마가 LLM 컨텍스트에 포함 | 토큰 낭비 |
26
+ | 지연 증가 | 컨텍스트 길이에 비례하여 응답 시간 상승 | UX 저하 |
27
 
28
+ 도구 10개 넘으면 정확도가 떨어지고, 37개 기준 ~6,200 토큰 소비 [(참고)](https://achan2013.medium.com/how-many-tools-functions-can-an-ai-agent-has-21e0a82b7847).
29
+ "전부 넘기지 말고 필요한 것만 검색해서 넘기자" — 이것이 RAG-MCP 패턴 핵심임 [(참고)](https://writer.com/engineering/rag-mcp/).
30
 
31
  ---
32
 
33
+ ## 2. 해결 전략
34
 
35
+ ### 2-1. 3단계 필터링으로 54개를 5개로 줄임
 
 
36
 
37
  ```
38
  사용자 질문
 
47
 
48
  | 단계 | 모듈 | 기능 | 속도 |
49
  |------|------|------|------|
50
+ | Guardrail | 정규식(L1) + 임베딩(L2) | 탈옥·비보험 질문 사전 차단 | <5ms |
51
  | Tool Search | ChromaDB 멀티벡터 | 54개 → Top-K 후보 추출 | ~10ms |
52
+ | LLM Selection | bind_tools() | 후보 중 최종 도구 선택·호출 | 1~5s |
53
 
54
+ Guardrail이 먼저 동작하므로 "오늘 날씨 어때?" 같은 질문은 벡터 검색이나 LLM 호출 없이 즉시 차단됨.
55
 
56
+ ### 2-2. Tool Card로 검색 정확 높임
57
 
58
+ LLM 도구 description은 보통 한두 줄이라, 유사 도구끼리 벡터가 거의 같아져서 구분이 어려움.
59
+ 이를 해결하기 위해 도구마다 **ToolCard**를 작성하여 임베딩 표면을 확장.
60
+
61
+ ```python
62
+ ToolCard(
63
+ name="premium_estimate",
64
+ purpose="나이·성별을 입력해 특정 상품의 예상 월 보험료를 산출한다.",
65
+ when_to_use=("보험료 얼마야?", "40세 남성 보험료 계산해줘"),
66
+ when_not_to_use=("납입 플랜이 궁금하다 → plan_options 사용",),
67
+ tags=("보험료", "산출"),
68
+ )
69
  ```
70
 
71
+ | 필드 | 임베딩 포함 | 역할 |
72
+ |------|:-----------:|------|
73
+ | `purpose` | O | 도구의 핵심 기능을 문장으로 설명 |
74
+ | `when_to_use` | O | 실제 사용자 발화 예시 검색 표면 확장 |
75
+ | `tags` | O | 도메인 키워드 → 클러스터링 보조 |
76
+ | `when_not_to_use` | **X** | 혼동 가능한 도구 안내 → LLM 최종 선택 시에만 사용 |
77
 
78
+ `when_not_to_use`를 임베딩에서 제외 이유: 도구 이름("premium_estimate 사용")이 되면 벡터가 오염됨.
79
+ Tool-DE [(Lu et al., 2025)](https://arxiv.org/abs/2510.22670) ablation에서도 negative example 포함 시 성능이 저하됨.
80
 
81
+ 필드는 **별도 문서**로 ChromaDB에 인덱싱하고, 검색 시 tool별 max score집계함.
82
+ 단일 벡터로 합치면 여러 예시의 평균으로 희석되지만, 이 방식은 ColBERT 등 multi-vector 모델과 동일한 원리로 희석 없이 정확한 매칭이 가능함 [(Pinecone)](https://www.pinecone.io/blog/cascading-retrieval-with-multi-vector-representations/).
83
 
84
+ ### 2-3. 코드 변경 없이 운영 중 튜닝
85
 
86
+ Admin Dashboard(`/admin/tools`)에서 ToolCard를 수정하면 즉시 챗봇에 반영됨.
87
+ 배치 Recall 평가와 LLM 분석을 내장하여, 수정 전후 성능 차이를 바로 확인할 수 있음.
88
+
89
+ ```
90
+ Admin UI에서 when_to_use 문장 수정
91
+ ↓ [저장 & 반영] 클릭
92
+ ① 메모리 REGISTRY 업데이트 + ChromaDB 재인덱싱
93
+ ② data/toolcard_overrides.json 저장 (서버 재시작 후에도 유지)
94
+ ③ 버전 이력 기록 → 문제 시 롤백 가능
95
  ```
96
 
97
  ---
 
103
  | 지표 | k=1 | k=3 | **k=5 (운영)** | k=7 | k=10 |
104
  |------|-----|-----|----------------|-----|------|
105
  | **Recall@k** | 96.9% | 100% | **100%** | 100% | 100% |
106
+ | **Hit@1** | 96.9% | | **96.9%** | | 96.9% |
107
+ | **MRR** | 0.969 | 0.984 | **0.984** | | 0.984 |
108
+ | **No-Call Acc** | | | **80.0%** | | 80.0% |
 
 
 
 
 
109
 
110
+ - k=1 미탐 2건: 유사 도구 경계 사례 (coverage_detail ↔ benefit_amount, renewal_projection ↔ renewal_notice)
111
+ - k=3부터 Recall 100% 64개 tool-call 쿼리 전부 Top-3 안에 포함
112
+ - no-call 오판 3건: 유사도 0.86~0.88로 경계에 걸리지만, Guardrail에서 사전 차단되므로 실운영에서는 Tool Search 도달하지 않음
113
 
114
+ **54개 → 5개로 90% 축소해도 Recall@5 = 100%, MRR = 0.98.
115
+ 정확도를 유지하면서 비용과 지연을 동시에 줄임.**
116
 
117
  ---
118
 
119
  ## 4. 구현 상세
120
 
121
+ ### 4-1. LangGraph 5노드 파이프라인
122
 
123
  ```
124
  START → [input_guardrail] → [query_rewriter] → [agent ↔ tools] → [output_guardrail] → END
 
127
  | 노드 | 역할 | 소요 시간 |
128
  |------|------|-----------|
129
  | input_guardrail | 정규식(L1) + 임베딩(L2)으로 이상 요청 차단 | <5ms |
130
+ | query_rewriter | "그거 얼마야?" 같은 후속질문을 이전 맥락으로 재작성 | 0~1s |
131
+ | agent | ChromaDB Top-K 검색 → LLM 도구 호출 | 1~5s |
132
+ | tools | ToolRegistry 동적 디스패치 → 도구 실행 | 10~100ms |
133
  | output_guardrail | PII·금칙어 검사 + 면책 문구 자동 추가 | <2ms |
134
 
135
+ 쿼리 재작성 Advanced RAG 핵심 기법인 Query Transformation에 해당함 [(참고)](https://www.promptingguide.ai/research/rag).
136
+ 짧은 후속질문의 벡터 검색 정확도를 보완하는 역할임.
137
 
138
+ ### 4-2. 상품공시실 PDF RAG
139
 
140
+ 보험 상품공시실에서 12개 상품요약서 + 표준약관 + 회사 정보를 수집함.
141
+ PyMuPDF로 텍스트 추출 → 500자 청크 → ChromaDB 인제스트(~1,400 벡터).
142
+ 도구 데이터에 없는 약관 조항·면책 규정을 RAG가 보완함.
143
 
144
+ ### 4-3. LLM 연동
145
 
146
+ | 항목 | 설명 |
147
+ |------|------|
148
+ | 시스템 프롬프트 | 12개 상품 목록이 PRODUCTS 딕셔너리에서 동적 반영됨. 도구 체이닝 규칙도 포함 |
149
+ | 사고과정 필터링 | Qwen3 `<think>` 블록을 스트리밍 중 실시간 필터링. 사용자에게 최종 답변만 노출 |
150
+ | 도구 레벨 가드 | 나이/성별 등 수값 미제공 시 `needs_user_input` 반환 → LLM이 되묻는 구조 |
 
 
 
 
 
 
 
 
 
 
151
 
152
+ ### 4-4. 서빙
153
 
154
  | 방식 | 설명 | 대상 |
155
  |------|------|------|
156
+ | FastAPI (REST/SSE) | 웹 Chat UI + REST API + Admin Dashboard | 일반 사용자·운영자 |
157
  | MCP Server (SSE/stdio) | 도구 54 + 리소스 17 + 프롬프트 8 노출 | Claude Desktop, Cursor 등 |
158
 
 
 
159
  ```bash
160
+ python run.py # FastAPI → http://localhost:8080
161
+ python run_mcp.py # MCP Server
162
+ python run_mcp.py --inspect # MCP Inspector UI → http://localhost:5173
163
  ```
164
 
165
+ ---
166
 
167
+ ## 5. 운영
168
 
169
+ ### 5-1. 도구 추가 체크리스트
170
 
171
+ 도구를 추가할 아래 순서따름.
172
 
173
+ **① 도구 함수 작성** `app/tools/` 아래 해당 모듈에 `@tool` 함수를 추가함. `tool.name`이 이후 모든 연동의 키가 됨.
 
 
 
 
 
 
174
 
175
+ ** ToolCard 등록** — `app/tool_search/tool_cards.py`의 `_CARDS` 스트 추가함.
176
 
177
+ | 필드 | 규칙 |
178
+ |------|------|
179
+ | `name` | tool.name과 정확히 일치해야 함 |
180
+ | `purpose` | 한 문장으로 도구 기능을 설명 |
181
+ | `when_to_use` | 실제 사용자 발화 패턴으로 작성 |
182
+ | `when_not_to_use` | 혼동 도구를 `→ tool_name 사용` 형식으로 명시 |
183
+ | `tags` | 도메인 키워드 (필터링용) |
184
+
185
+ > `when_to_use`가 다른 카드와 중복되면 임베딩이 충돌함. `validate_duplicate_when_to_use()`가 자동 검출하므로 평가 시 확인할 것.
186
 
187
+ **③ 동 쌍 관리** — 기능이 유사한 도구가 있으면 양방향으로 처리.
188
 
189
  ```
190
+ 1. 새 카드 when_not_to_use에 기존 유사 도구 언급
191
+ 2. 기존 유사 도구 when_not_to_use에 새 도구 언급
192
  3. CONFUSION_PAIRS 리스트에 (기존, 신규) 쌍 등록
193
  ```
194
 
 
 
195
  **④ 검증**
196
 
197
  ```bash
 
199
  python -m scripts.eval_tool_recall --verbose # 오판 사례 상세
200
  ```
201
 
202
+ | 연동 지점 | 자동/수동 | 비고 |
 
 
203
  |-----------|:---------:|------|
204
+ | ChromaDB 임베딩 | 자동 | 서버 시작 시 해시 비교 → 변경분만 재인덱싱 |
205
+ | LLM tool description | 자동 | `when_not_to_use`가 description에 자동 주입 |
206
+ | 도구 함수 | **수** | 카드 있고 함수없으면작하지 않음 |
207
+ | `CONFUSION_PAIRS` | **수동** | 유사 도구 존재 반드시 등록 |
 
 
 
208
 
209
+ > ToolCard가 없는 도구 `tool.description` 단일 문서로 fallback 됨. 동작은 하지만 검색 정확도가 낮음.
210
 
211
+ ### 5-2. Admin Dashboard
 
 
 
 
212
 
213
+ CLI 대신 브라우저(`/admin/tools`)에서 도구 레지스트리를 관리하는 UI임.
214
+ FastAPI 앱에 내장되어 별도 서버 없이 HF Spaces 등 배포 환경에서도 동작함.
215
 
216
+ | 기능 | 설명 |
217
+ |------|------|
218
+ | 대시보드 | 도구 수, ChromaDB 벡터 수, Registry 버전, 상태 모니터링 |
219
+ | ToolCard 편집 | purpose, when_to_use, when_not_to_use, tags 수정 → 즉시 반영 |
220
+ | 버전 이력 | 변경 이력 조회, Diff 비교, 특정 버전으로 롤백 |
221
+ | 퀵 테스트 | 실시간 쿼리 검색, 배치 Recall 평가, LLM 실패 분석 |
222
+ | 도구 해제 | 확인 모달 → DELETE → ChromaDB 벡터 즉시 삭제 |
223
+ | 모듈 핫리로드 | 8개 모듈 선택 → 코드 변경분 서버 재시작 없이 반영 |
224
 
225
+ 테스트 탭에 수정 전후 Recall@k를 비교하고, 실패 쿼리에 대해 LLM이 ToolCard 개선안을 제안하므로 **코변경 없이** 검색 정확도를 튜닝할 수 있음.
 
 
 
 
226
 
227
+ ### 5-3. 런타임 API
228
 
229
  ```bash
230
+ curl http://localhost:8080/api/tools # 전체 도구 목록
231
+ curl -X DELETE http://localhost:8080/api/tools/premium_estimate # 도구 해제
232
+ curl -X POST http://localhost:8080/api/tools/reload-module/premium # 모듈 핫리로드
233
  ```
234
 
235
+ ToolRegistry가 변경을 감지하고 ChromaDB 재인덱싱을 자동 트리거함.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  ---
238
 
239
+ ## 6. 기술 선택 근거
240
 
241
  ### ChromaDB
242
 
 
247
  | 실시간 upsert | rebuild 필요 | O | **O** |
248
  | 인프라 | 없음 | Docker 3개 | **pip 1줄** |
249
 
250
+ ~1,800 벡터 규모에서 Milvus는 오버엔지니어링, FAISS는 메타데이터 필터링 미지원.
251
+ 10M 벡터 미만에서 ChromaDB 권장 [(Firecrawl)](https://www.firecrawl.dev/blog/best-vector-databases) [(DataCamp)](https://www.datacamp.com/blog/the-top-5-vector-databases).
252
 
253
  ### multilingual-e5-large
254
 
255
+ [Kor-IR 벤치마크](https://github.com/Atipico1/Kor-IR) 오픈소스 최상위(NDCG@10 = 80.35).
256
+ Mr. TyDi 한국어 MRR@10 = 61.6으로 e5-base(55.8) 대비 +10% [(모델 카드)](https://huggingface.co/intfloat/multilingual-e5-large).
257
+ 비대칭 검색 시 `"query: "` / `"passage: "` 프리픽스 필수 [(논문)](https://arxiv.org/abs/2402.05672).
258
+ 로컬 추론(~10ms/쿼리)으로 외부 API에 의존하지 않음.
 
259
 
260
+ ### Tool Card 학술 근거
261
 
262
+ | 출처 | 핵심 기여 |
263
+ |------|-----------|
264
+ | [Tool-DE (Lu et al., 2025)](https://arxiv.org/abs/2510.22670) | 도구 문서 확장으로 NDCG@10 +6~7ppt, Recall@10 +10ppt |
265
+ | [Re-Invoke (Google, EMNLP 2024)](https://arxiv.org/abs/2408.01875) | 합성 쿼리 생성으로 nDCG@5 유의미 향상 |
266
+ | [ToolBench (ICLR 2024)](https://arxiv.org/abs/2307.16789) | 도구 수 증가 시 cross-reference가 정확도 저하를 방지 |
267
+ | [RAG-MCP (WRITER, 2025)](https://writer.com/engineering/rag-mcp/) | 메타데 기반 인덱싱 토큰 50%+ 절감 |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  ---
270
 
271
+ ## 7. 알려진 한계 및 고도화 방향
272
 
273
+ | 한계 | 현상 | 방향 |
274
+ |------|------|------|
275
+ | when_to_use 오버핏 | product_search에 타 도구 영역 발화가 임베딩을 희석 | 순수 상품 검색 발화만 유지 |
276
+ | cross-reference 누락 | renewal_projection ↔ renewal_notice 양방향 가이드 부재 | when_not_to_use 양방향 보완 |
277
+ | 수동 작성 한계 | 54개 × ~7개 = ~380개 when_to_use 수동 관리 | Re-Invoke 방식 LLM 합성 쿼리 자동 생성 |
278
+ | 정적 no-call 임계값 | tool-call min(0.867)과 no-call max(0.877)이 겹침 | Reranker 2단계 도입 |
279
 
280
  ---
281
 
 
321
  │ └── data.py # 12개 상품 데이터 + 시스템 프롬프트
322
  ├── tool_search/ # ChromaDB 멀티벡터 라우팅
323
  │ ├── embedder.py # 임베딩 + Top-K 검색
324
+ ── tool_cards.py # 54개 ToolCard
325
+ │ └── toolcard_store.py # ToolCard JSON 영속화 + 버전 이력
326
  ├── rag/ # 상품공시실 PDF RAG
327
  │ ├── retriever.py # 인제스트 + 검색
328
  │ └── splitter.py # 한국어 문장경계 청크 분할
app/main.py CHANGED
@@ -12,6 +12,9 @@ Endpoints:
12
  DELETE /api/tools/{tool_name} — 도구 런타임 해제
13
  POST /api/tools/reload-module/{mod} — 모듈 핫리로드
14
  GET /admin/tools — Tool Admin UI
 
 
 
15
  """
16
 
17
  from __future__ import annotations
@@ -80,7 +83,16 @@ async def lifespan(app: FastAPI):
80
  get_graph()
81
  logger.info("LangGraph compiled")
82
 
83
- # 4. Tool embeddings 초기 인덱싱
 
 
 
 
 
 
 
 
 
84
  try:
85
  from app.tool_search.embedder import get_tool_search
86
  searcher = get_tool_search()
@@ -521,6 +533,244 @@ async def admin_list_tools():
521
  return {"count": len(result), "registry_version": registry.version, "tools": result}
522
 
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  # ── UI ────────────────────────────────────────────────────────────────────────
525
 
526
  @app.get("/", response_class=HTMLResponse)
 
12
  DELETE /api/tools/{tool_name} — 도구 런타임 해제
13
  POST /api/tools/reload-module/{mod} — 모듈 핫리로드
14
  GET /admin/tools — Tool Admin UI
15
+ POST /api/admin/eval/search — 단일 쿼리 검색 테스트
16
+ POST /api/admin/eval/batch/{name} — ToolCard 배치 Recall 평가
17
+ POST /api/admin/eval/judge — LLM-as-Judge 실패 분석
18
  """
19
 
20
  from __future__ import annotations
 
83
  get_graph()
84
  logger.info("LangGraph compiled")
85
 
86
+ # 4. ToolCard JSON override 로딩
87
+ try:
88
+ from app.tool_search.tool_cards import apply_overrides
89
+ n = apply_overrides()
90
+ if n:
91
+ logger.info("Applied %d ToolCard overrides from JSON", n)
92
+ except Exception as e:
93
+ logger.warning("ToolCard override loading skipped: %s", e)
94
+
95
+ # 5. Tool embeddings 초기 인덱싱
96
  try:
97
  from app.tool_search.embedder import get_tool_search
98
  searcher = get_tool_search()
 
533
  return {"count": len(result), "registry_version": registry.version, "tools": result}
534
 
535
 
536
+ # ── ToolCard CRUD API ─────────────────────────────────────────────────────────
537
+
538
+ def _get_store():
539
+ from app.tool_search.toolcard_store import get_toolcard_store
540
+ return get_toolcard_store(on_publish=_on_card_publish)
541
+
542
+
543
+ def _on_card_publish(card) -> None:
544
+ """ToolCard publish 후 ChromaDB 재인덱싱 트리거."""
545
+ try:
546
+ from app.tool_search.embedder import get_tool_search
547
+ registry = get_tool_registry()
548
+ get_tool_search().index_tools(registry.get_all())
549
+ logger.info("ChromaDB re-indexed after ToolCard publish (%s)", card.name)
550
+ except Exception as e:
551
+ logger.warning("ChromaDB re-index after card publish failed: %s", e)
552
+
553
+
554
+ @app.get("/api/admin/toolcards/{name}")
555
+ async def get_toolcard(name: str):
556
+ """ToolCard 상세 — published/draft/code 원본 + 상태."""
557
+ from app.tool_search.tool_cards import CODE_REGISTRY, REGISTRY
558
+
559
+ store = _get_store()
560
+ status = store.get_status(name)
561
+ draft = store.get_draft(name)
562
+ effective = REGISTRY.get(name)
563
+ code_card = CODE_REGISTRY.get(name)
564
+
565
+ return {
566
+ **status,
567
+ "effective": {
568
+ "purpose": effective.purpose,
569
+ "when_to_use": list(effective.when_to_use),
570
+ "when_not_to_use": list(effective.when_not_to_use),
571
+ "tags": list(effective.tags),
572
+ } if effective else None,
573
+ "code_original": {
574
+ "purpose": code_card.purpose,
575
+ "when_to_use": list(code_card.when_to_use),
576
+ "when_not_to_use": list(code_card.when_not_to_use),
577
+ "tags": list(code_card.tags),
578
+ } if code_card else None,
579
+ "draft": draft,
580
+ }
581
+
582
+
583
+ @app.put("/api/admin/toolcards/{name}/draft")
584
+ async def save_toolcard_draft(name: str, request: Request):
585
+ """ToolCard draft 저장. 챗봇에는 미반영."""
586
+ body = await request.json()
587
+ store = _get_store()
588
+ draft = store.save_draft(name, body)
589
+ return {"status": "draft_saved", "name": name, "draft": draft}
590
+
591
+
592
+ @app.post("/api/admin/toolcards/{name}/publish")
593
+ async def publish_toolcard(name: str, request: Request):
594
+ """Draft → Published. 메모리 + ChromaDB 즉시 반영."""
595
+ body = await request.json()
596
+ note = body.get("note", "")
597
+ store = _get_store()
598
+
599
+ has_draft = store.get_draft(name)
600
+ if not has_draft:
601
+ data = body.get("data")
602
+ if not data:
603
+ raise HTTPException(400, "No draft exists and no data provided")
604
+ store.save_draft(name, data)
605
+
606
+ card = store.publish(name, note)
607
+ status = store.get_status(name)
608
+ return {"status": "published", "name": name, "version": status["version"]}
609
+
610
+
611
+ @app.post("/api/admin/toolcards/{name}/rollback")
612
+ async def rollback_toolcard(name: str, request: Request):
613
+ """특정 버전으로 롤백."""
614
+ body = await request.json()
615
+ target_version = body.get("version")
616
+ if not target_version:
617
+ raise HTTPException(400, "version is required")
618
+ store = _get_store()
619
+ card = store.rollback(name, int(target_version))
620
+ status = store.get_status(name)
621
+ return {"status": "rolled_back", "name": name, "version": status["version"]}
622
+
623
+
624
+ @app.get("/api/admin/toolcards/{name}/history")
625
+ async def toolcard_history(name: str):
626
+ """버전 이력 조회."""
627
+ store = _get_store()
628
+ history = store.get_history(name)
629
+ return {"name": name, "history": history}
630
+
631
+
632
+ @app.delete("/api/admin/toolcards/{name}/override")
633
+ async def reset_toolcard(name: str):
634
+ """Override 제거 → 코드 원본 카드로 복원."""
635
+ store = _get_store()
636
+ card = store.reset_to_code(name)
637
+ return {"status": "reset_to_code", "name": name, "has_code_card": card is not None}
638
+
639
+
640
+ @app.post("/api/admin/toolcards/{name}/discard-draft")
641
+ async def discard_toolcard_draft(name: str):
642
+ """Draft 폐기."""
643
+ store = _get_store()
644
+ store.discard_draft(name)
645
+ return {"status": "draft_discarded", "name": name}
646
+
647
+
648
+ # ── Quick Eval API ────────────────────────────────────────────────────────────
649
+
650
+ @app.post("/api/admin/eval/search")
651
+ async def eval_search(request: Request):
652
+ """단일 쿼리 → top-k 검색 결과 반환. 실시간 라우팅 확인용."""
653
+ body = await request.json()
654
+ query = body.get("query", "").strip()
655
+ top_k = body.get("top_k", 5)
656
+ if not query:
657
+ raise HTTPException(400, "query is required")
658
+
659
+ from app.tool_search.embedder import get_tool_search
660
+ candidates = get_tool_search().search(query, top_k=top_k)
661
+ return {
662
+ "query": query,
663
+ "results": [
664
+ {"name": c.name, "score": c.score, "description": c.description[:80]}
665
+ for c in candidates
666
+ ],
667
+ }
668
+
669
+
670
+ @app.post("/api/admin/eval/batch/{tool_name}")
671
+ async def eval_batch(tool_name: str):
672
+ """ToolCard의 when_to_use 전체를 검색하여 Recall@1/3/5 산출."""
673
+ from app.tool_search.tool_cards import REGISTRY
674
+ from app.tool_search.embedder import get_tool_search
675
+
676
+ card = REGISTRY.get(tool_name)
677
+ if not card or not card.when_to_use:
678
+ raise HTTPException(404, f"No ToolCard or when_to_use for '{tool_name}'")
679
+
680
+ searcher = get_tool_search()
681
+ details = []
682
+ for query in card.when_to_use:
683
+ hits = searcher.search(query, top_k=5)
684
+ rank = next(
685
+ (i + 1 for i, c in enumerate(hits) if c.name == tool_name), None
686
+ )
687
+ details.append({
688
+ "query": query,
689
+ "rank": rank,
690
+ "pass_at_3": rank is not None and rank <= 3,
691
+ "top_hits": [
692
+ {"name": c.name, "score": c.score} for c in hits[:5]
693
+ ],
694
+ })
695
+
696
+ n = len(details)
697
+ return {
698
+ "tool_name": tool_name,
699
+ "total": n,
700
+ "recall_at_1": round(sum(1 for d in details if d["rank"] == 1) / n, 4) if n else 0,
701
+ "recall_at_3": round(sum(1 for d in details if d["pass_at_3"]) / n, 4) if n else 0,
702
+ "recall_at_5": round(sum(1 for d in details if d["rank"] and d["rank"] <= 5) / n, 4) if n else 0,
703
+ "details": details,
704
+ }
705
+
706
+
707
+ @app.post("/api/admin/eval/judge")
708
+ async def eval_judge(request: Request):
709
+ """LLM-as-Judge: 실패 케이스를 분석하고 ToolCard 개선안을 제안."""
710
+ body = await request.json()
711
+ tool_name = body.get("tool_name", "")
712
+ failures = body.get("failures", [])
713
+
714
+ if not tool_name or not failures:
715
+ raise HTTPException(400, "tool_name and failures are required")
716
+
717
+ from app.tool_search.tool_cards import REGISTRY
718
+ from app.llm import get_llm
719
+
720
+ card = REGISTRY.get(tool_name)
721
+ card_info = ""
722
+ if card:
723
+ card_info = (
724
+ f"purpose: {card.purpose}\n"
725
+ f"when_to_use: {list(card.when_to_use)}\n"
726
+ f"when_not_to_use: {list(card.when_not_to_use)}\n"
727
+ f"tags: {list(card.tags)}"
728
+ )
729
+
730
+ failure_lines = []
731
+ for f in failures[:10]:
732
+ top_str = ", ".join(
733
+ f"{h['name']}({h['score']})" for h in f.get("top_hits", [])[:3]
734
+ )
735
+ failure_lines.append(
736
+ f" 쿼리: \"{f['query']}\" → 상위결과: [{top_str}] "
737
+ f"(expected: {tool_name}, rank: {f.get('rank', 'N/A')})"
738
+ )
739
+
740
+ prompt = f"""당신은 Tool Routing 전문가입니다. 아래 도구의 ToolCard 정보와, 해당 도구로 라우팅되어야 했지만 실패한 쿼리들을 분석해주세요.
741
+
742
+ ## 도구: {tool_name}
743
+ {card_info}
744
+
745
+ ## 실패 케이스 ({len(failure_lines)}건)
746
+ {chr(10).join(failure_lines)}
747
+
748
+ ## 요청사항
749
+ 1. 각 실패 쿼리가 왜 다른 도구로 라우팅되었는지 원인을 분석하세요.
750
+ 2. ToolCard를 어떻게 수정하면 이 쿼리들이 올바르게 라우팅될지 구체적으로 제안하세요.
751
+ - 추가할 when_to_use 예시
752
+ - 추가할 tags
753
+ - 수정할 purpose
754
+ 3. 주의: when_to_use에 이미 있는 쿼리와 너무 유사한 문장은 효과가 적습니다. 다양한 표현을 제안하세요.
755
+
756
+ 한국어로 간결하게 답변하세요."""
757
+
758
+ llm = get_llm()
759
+ try:
760
+ result = await llm.ainvoke(prompt)
761
+ analysis = result.content if hasattr(result, "content") else str(result)
762
+ analysis = _strip_think(analysis)
763
+ except Exception as e:
764
+ logger.warning("LLM Judge failed: %s", e)
765
+ analysis = f"LLM 분석 실패: {e}"
766
+
767
+ return {
768
+ "tool_name": tool_name,
769
+ "failure_count": len(failures),
770
+ "analysis": analysis,
771
+ }
772
+
773
+
774
  # ── UI ────────────────────────────────────────────────────────────────────────
775
 
776
  @app.get("/", response_class=HTMLResponse)
app/tool_search/tool_cards.py CHANGED
@@ -899,12 +899,29 @@ CONFUSION_PAIRS: list[tuple[str, str]] = [
899
  # ──────────────────────────────────────────────────────────────
900
  REGISTRY: dict[str, ToolCard] = {card.name: card for card in _CARDS}
901
 
 
 
 
902
 
903
  def get_card(tool_name: str) -> ToolCard | None:
904
  """도구 이름으로 ToolCard를 반환한다. 없으면 None."""
905
  return REGISTRY.get(tool_name)
906
 
907
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
908
  def missing_cards(tool_names: list[str]) -> list[str]:
909
  """카드가 없는 도구 이름 목록을 반환한다. 신규 tool 추가 시 경고용."""
910
  return [n for n in tool_names if n not in REGISTRY]
 
899
  # ──────────────────────────────────────────────────────────────
900
  REGISTRY: dict[str, ToolCard] = {card.name: card for card in _CARDS}
901
 
902
+ # 코드 정의 원본 (override 복원용)
903
+ CODE_REGISTRY: dict[str, ToolCard] = dict(REGISTRY)
904
+
905
 
906
  def get_card(tool_name: str) -> ToolCard | None:
907
  """도구 이름으로 ToolCard를 반환한다. 없으면 None."""
908
  return REGISTRY.get(tool_name)
909
 
910
 
911
+ def apply_overrides() -> int:
912
+ """JSON Store에서 published override를 REGISTRY에 적용. 서버 시작 시 호출."""
913
+ from app.tool_search.toolcard_store import get_toolcard_store
914
+
915
+ store = get_toolcard_store()
916
+ count = 0
917
+ for name in store.list_overrides():
918
+ card = store.get_published(name)
919
+ if card:
920
+ REGISTRY[name] = card
921
+ count += 1
922
+ return count
923
+
924
+
925
  def missing_cards(tool_names: list[str]) -> list[str]:
926
  """카드가 없는 도구 이름 목록을 반환한다. 신규 tool 추가 시 경고용."""
927
  return [n for n in tool_names if n not in REGISTRY]
app/tool_search/toolcard_store.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ToolCard JSON Store — 영속화 + 버전 이력 + Draft/Publish/Rollback.
2
+
3
+ 운영자가 Admin UI에서 ToolCard 메타데이터를 수정하면:
4
+ 1. draft로 임시 저장 (챗봇 미반영)
5
+ 2. publish 시 메모리 REGISTRY + ChromaDB 즉시 반영 + JSON 영속화
6
+ 3. 이전 버전은 history에 누적되어 롤백 가능
7
+
8
+ JSON 파일(data/toolcard_overrides.json)이 없으면 코드 정의 카드만 사용.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import json
15
+ import logging
16
+ import threading
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ from app.tool_search.tool_cards import ToolCard, REGISTRY as CODE_REGISTRY
22
+
23
+ logger = logging.getLogger("insurance.toolcard_store")
24
+
25
+ _DEFAULT_PATH = Path("data/toolcard_overrides.json")
26
+ _MAX_HISTORY = 30
27
+
28
+
29
+ def _card_to_dict(card: ToolCard) -> dict[str, Any]:
30
+ return {
31
+ "name": card.name,
32
+ "purpose": card.purpose,
33
+ "when_to_use": list(card.when_to_use),
34
+ "when_not_to_use": list(card.when_not_to_use),
35
+ "tags": list(card.tags),
36
+ }
37
+
38
+
39
+ def _dict_to_card(d: dict[str, Any]) -> ToolCard:
40
+ return ToolCard(
41
+ name=d["name"],
42
+ purpose=d.get("purpose", ""),
43
+ when_to_use=tuple(d.get("when_to_use", ())),
44
+ when_not_to_use=tuple(d.get("when_not_to_use", ())),
45
+ tags=tuple(d.get("tags", ())),
46
+ )
47
+
48
+
49
+ class ToolCardStore:
50
+ """ToolCard 영속 저장소. 스레드 안전."""
51
+
52
+ def __init__(
53
+ self,
54
+ path: Path = _DEFAULT_PATH,
55
+ on_publish: Callable[[ToolCard], None] | None = None,
56
+ ) -> None:
57
+ self._path = path
58
+ self._lock = threading.Lock()
59
+ self._on_publish = on_publish
60
+ self._store: dict[str, dict[str, Any]] = {}
61
+ self._load()
62
+
63
+ # ── 로드/저장 ─────────────────────────────────────────────────
64
+
65
+ def _load(self) -> None:
66
+ if not self._path.exists():
67
+ logger.info("ToolCard override file not found, starting fresh")
68
+ return
69
+ try:
70
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
71
+ self._store = raw.get("cards", {})
72
+ logger.info("Loaded %d ToolCard overrides from %s", len(self._store), self._path)
73
+ except Exception:
74
+ logger.exception("Failed to load ToolCard overrides")
75
+
76
+ def _save(self) -> None:
77
+ self._path.parent.mkdir(parents=True, exist_ok=True)
78
+ payload = {
79
+ "updated_at": datetime.now(timezone.utc).isoformat(),
80
+ "cards": self._store,
81
+ }
82
+ self._path.write_text(
83
+ json.dumps(payload, ensure_ascii=False, indent=2),
84
+ encoding="utf-8",
85
+ )
86
+
87
+ # ── 읽기 ─────────────────────────────────────────────────────
88
+
89
+ def get_published(self, name: str) -> ToolCard | None:
90
+ """Publish된 override 카드. 없으면 None(코드 카드 사용)."""
91
+ with self._lock:
92
+ entry = self._store.get(name)
93
+ if entry and entry.get("published"):
94
+ return _dict_to_card(entry["published"])
95
+ return None
96
+
97
+ def get_draft(self, name: str) -> dict[str, Any] | None:
98
+ with self._lock:
99
+ entry = self._store.get(name)
100
+ return copy.deepcopy(entry.get("draft")) if entry else None
101
+
102
+ def get_history(self, name: str) -> list[dict[str, Any]]:
103
+ with self._lock:
104
+ entry = self._store.get(name)
105
+ return copy.deepcopy(entry.get("history", [])) if entry else []
106
+
107
+ def get_effective_card(self, name: str) -> ToolCard | None:
108
+ """실제 적용 중인 카드: published override > 코드 정의."""
109
+ pub = self.get_published(name)
110
+ return pub if pub else CODE_REGISTRY.get(name)
111
+
112
+ def list_overrides(self) -> list[str]:
113
+ """Override가 있는 도구 이름 목록."""
114
+ with self._lock:
115
+ return list(self._store.keys())
116
+
117
+ def get_status(self, name: str) -> dict[str, Any]:
118
+ """도구의 현재 상태 요약."""
119
+ with self._lock:
120
+ entry = self._store.get(name, {})
121
+ has_code = name in CODE_REGISTRY
122
+ has_published = bool(entry.get("published"))
123
+ has_draft = bool(entry.get("draft"))
124
+ history = entry.get("history", [])
125
+ return {
126
+ "name": name,
127
+ "source": "override" if has_published else "code" if has_code else "none",
128
+ "has_draft": has_draft,
129
+ "version": history[-1]["version"] if history else 0,
130
+ "history_count": len(history),
131
+ }
132
+
133
+ # ── 쓰기 ─────────────────────────────────────────────────────
134
+
135
+ def save_draft(self, name: str, data: dict[str, Any]) -> dict[str, Any]:
136
+ """Draft 임시 저장. 챗봇에는 미반영."""
137
+ data["name"] = name
138
+ with self._lock:
139
+ if name not in self._store:
140
+ self._store[name] = {"published": None, "draft": None, "history": []}
141
+ self._store[name]["draft"] = data
142
+ self._save()
143
+ logger.info("Draft saved for '%s'", name)
144
+ return data
145
+
146
+ def publish(self, name: str, note: str = "") -> ToolCard:
147
+ """Draft → Published. 메모리 + ChromaDB 반영 + JSON 저장."""
148
+ with self._lock:
149
+ entry = self._store.get(name)
150
+ if not entry or not entry.get("draft"):
151
+ raise ValueError(f"No draft to publish for '{name}'")
152
+
153
+ draft_data = entry["draft"]
154
+ draft_data["name"] = name
155
+
156
+ prev_published = copy.deepcopy(entry.get("published"))
157
+ history = entry.get("history", [])
158
+ next_version = (history[-1]["version"] + 1) if history else 1
159
+
160
+ history.append({
161
+ "version": next_version,
162
+ "timestamp": datetime.now(timezone.utc).isoformat(),
163
+ "data": copy.deepcopy(draft_data),
164
+ "note": note or f"v{next_version} published",
165
+ "previous": prev_published,
166
+ })
167
+
168
+ if len(history) > _MAX_HISTORY:
169
+ history[:] = history[-_MAX_HISTORY:]
170
+
171
+ entry["published"] = draft_data
172
+ entry["draft"] = None
173
+ entry["history"] = history
174
+ self._save()
175
+
176
+ card = _dict_to_card(draft_data)
177
+
178
+ from app.tool_search.tool_cards import REGISTRY
179
+ REGISTRY[name] = card
180
+ logger.info("Published ToolCard '%s' (v%d)", name, next_version)
181
+
182
+ if self._on_publish:
183
+ self._on_publish(card)
184
+
185
+ return card
186
+
187
+ def publish_direct(self, name: str, data: dict[str, Any], note: str = "") -> ToolCard:
188
+ """Draft 없이 바로 Publish. 간편 수정용."""
189
+ self.save_draft(name, data)
190
+ return self.publish(name, note)
191
+
192
+ def rollback(self, name: str, target_version: int) -> ToolCard:
193
+ """특정 버전으로 롤백."""
194
+ with self._lock:
195
+ entry = self._store.get(name)
196
+ if not entry:
197
+ raise ValueError(f"No override history for '{name}'")
198
+
199
+ history = entry.get("history", [])
200
+ target = next((h for h in history if h["version"] == target_version), None)
201
+ if not target:
202
+ raise ValueError(f"Version {target_version} not found for '{name}'")
203
+
204
+ rollback_data = copy.deepcopy(target["data"])
205
+ prev_published = copy.deepcopy(entry.get("published"))
206
+
207
+ next_version = (history[-1]["version"] + 1) if history else 1
208
+ history.append({
209
+ "version": next_version,
210
+ "timestamp": datetime.now(timezone.utc).isoformat(),
211
+ "data": rollback_data,
212
+ "note": f"rollback to v{target_version}",
213
+ "previous": prev_published,
214
+ })
215
+
216
+ if len(history) > _MAX_HISTORY:
217
+ history[:] = history[-_MAX_HISTORY:]
218
+
219
+ entry["published"] = rollback_data
220
+ entry["draft"] = None
221
+ entry["history"] = history
222
+ self._save()
223
+
224
+ card = _dict_to_card(rollback_data)
225
+
226
+ from app.tool_search.tool_cards import REGISTRY
227
+ REGISTRY[name] = card
228
+ logger.info("Rolled back '%s' to v%d (new v%d)", name, target_version, next_version)
229
+
230
+ if self._on_publish:
231
+ self._on_publish(card)
232
+
233
+ return card
234
+
235
+ def discard_draft(self, name: str) -> None:
236
+ """Draft 폐기."""
237
+ with self._lock:
238
+ entry = self._store.get(name)
239
+ if entry:
240
+ entry["draft"] = None
241
+ self._save()
242
+
243
+ def reset_to_code(self, name: str) -> ToolCard | None:
244
+ """Override를 제거하고 코드 정의 카드로 복원."""
245
+ with self._lock:
246
+ if name in self._store:
247
+ del self._store[name]
248
+ self._save()
249
+
250
+ code_card = CODE_REGISTRY.get(name)
251
+ if code_card:
252
+ from app.tool_search.tool_cards import REGISTRY
253
+ REGISTRY[name] = code_card
254
+ if self._on_publish:
255
+ self._on_publish(code_card)
256
+ return code_card
257
+
258
+ @staticmethod
259
+ def diff(card_a: dict[str, Any], card_b: dict[str, Any]) -> dict[str, Any]:
260
+ """두 카드 데이터의 필드별 차이를 반환."""
261
+ changes: dict[str, Any] = {}
262
+ all_keys = {"purpose", "when_to_use", "when_not_to_use", "tags"}
263
+ for key in all_keys:
264
+ old_val = card_a.get(key)
265
+ new_val = card_b.get(key)
266
+ if old_val != new_val:
267
+ changes[key] = {"old": old_val, "new": new_val}
268
+ return changes
269
+
270
+
271
+ _store_instance: ToolCardStore | None = None
272
+ _store_lock = threading.Lock()
273
+
274
+
275
+ def get_toolcard_store(**kwargs) -> ToolCardStore:
276
+ global _store_instance
277
+ if _store_instance is None:
278
+ with _store_lock:
279
+ if _store_instance is None:
280
+ _store_instance = ToolCardStore(**kwargs)
281
+ return _store_instance
data/toolcard_overrides.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "updated_at": "2026-02-25T06:58:39.264096+00:00",
3
+ "cards": {}
4
+ }
templates/admin_tools.html CHANGED
@@ -105,17 +105,17 @@
105
  border-bottom:1px solid var(--border); position:sticky; top:58px; z-index:10;
106
  }
107
  .tool-table td {
108
- padding:10px 14px; font-size:13px; border-bottom:1px solid var(--border);
109
  vertical-align:top;
110
  }
111
  .tool-table tr:last-child td { border-bottom:none; }
112
- .tool-table tr:hover td { background:rgba(108,92,231,0.04); }
113
 
114
- .tool-name { font-weight:600; color:var(--accent-light); font-family:'SF Mono',monospace; font-size:12px; }
115
- .tool-purpose { color:var(--text-dim); font-size:12px; margin-top:2px; }
116
  .tag {
117
- display:inline-block; padding:2px 7px; border-radius:4px;
118
- font-size:10px; font-weight:600; margin:1px 2px;
119
  }
120
  .tag.card { background:var(--success-dim); color:var(--success); }
121
  .tag.no-card { background:var(--warning-dim); color:var(--warning); }
@@ -144,9 +144,9 @@
144
  .modal p { font-size:13px; color:var(--text-dim); margin-bottom:16px; line-height:1.5; }
145
  .modal .modal-actions { display:flex; gap:8px; justify-content:flex-end; }
146
 
147
- .modal label { display:block; font-size:12px; color:var(--text-dim); margin-bottom:6px; }
148
  .modal input, .modal select {
149
- width:100%; padding:8px 12px; border:1px solid var(--border);
150
  border-radius:8px; background:var(--surface2); color:var(--text);
151
  font-size:13px; margin-bottom:14px; outline:none;
152
  }
@@ -174,6 +174,204 @@
174
  .when-to-use.expanded { max-height:500px; }
175
  .when-to-use span { display:block; padding:1px 0; }
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  @media (max-width:768px) {
178
  .content { padding:12px; }
179
  .stats-row { grid-template-columns:1fr 1fr; }
@@ -181,6 +379,9 @@
181
  .tool-table th, .tool-table td { padding:8px 10px; }
182
  .col-tags, .col-when { display:none; }
183
  header { padding:10px 14px; }
 
 
 
184
  }
185
  </style>
186
  </head>
@@ -197,6 +398,39 @@
197
  </header>
198
 
199
  <div class="content">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  <div class="stats-row" id="stats-row">
201
  <div class="stat-card"><div class="label">등록 도구</div><div class="value" id="s-tools">—</div></div>
202
  <div class="stat-card"><div class="label">ChromaDB 벡터</div><div class="value" id="s-vectors">—</div></div>
@@ -235,11 +469,19 @@
235
  <!-- Delete Confirm Modal -->
236
  <div class="modal-overlay" id="delete-modal">
237
  <div class="modal">
238
- <h3>도구 해제 확인</h3>
239
- <p><strong id="del-name" style="color:var(--danger)"></strong> 도구를 런타임에서 해제합니다.<br>ChromaDB 벡터도 즉시 삭제됩니다. 모듈 리로드로 복원할 수 있습니다.</p>
 
 
 
 
 
 
 
 
240
  <div class="modal-actions">
241
- <button class="btn" onclick="closeModal('delete-modal')">취소</button>
242
- <button class="btn danger" id="del-confirm" onclick="confirmDelete()">해제</button>
243
  </div>
244
  </div>
245
  </div>
@@ -273,17 +515,143 @@
273
  <h3 id="detail-title"></h3>
274
  <div id="detail-body" style="font-size:13px;line-height:1.6;color:var(--text-dim);max-height:60vh;overflow-y:auto"></div>
275
  <div class="modal-actions" style="margin-top:16px">
 
276
  <button class="btn" onclick="closeModal('detail-modal')">닫기</button>
277
  </div>
278
  </div>
279
  </div>
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  <div class="toast" id="toast"></div>
282
 
283
  <script>
284
  let allTools = [];
285
  let toolCards = {};
286
  let deleteTarget = '';
 
 
 
 
 
 
 
 
 
 
 
287
 
288
  async function fetchHealth() {
289
  try {
@@ -354,10 +722,13 @@ function renderTable(tools) {
354
  </td>
355
  <td>
356
  <div class="action-btns">
 
 
 
357
  <button class="action-btn" onclick="showDetail('${t.name}')" title="상세보기">
358
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
359
  </button>
360
- <button class="action-btn del" onclick="openDeleteModal('${t.name}')" title="해제">
361
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
362
  </button>
363
  </div>
@@ -456,6 +827,457 @@ async function refreshAll() {
456
  await Promise.all([fetchHealth(), fetchTools()]);
457
  }
458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  document.getElementById('search').addEventListener('input', () => renderTable(allTools));
460
 
461
  document.querySelectorAll('.modal-overlay').forEach(el => {
 
105
  border-bottom:1px solid var(--border); position:sticky; top:58px; z-index:10;
106
  }
107
  .tool-table td {
108
+ padding:12px 14px; font-size:13px; border-bottom:1px solid var(--border);
109
  vertical-align:top;
110
  }
111
  .tool-table tr:last-child td { border-bottom:none; }
112
+ .tool-table tr:hover td { background:rgba(108,92,231,0.06); }
113
 
114
+ .tool-name { font-weight:600; color:var(--accent-light); font-family:'SF Mono',monospace; font-size:12.5px; }
115
+ .tool-purpose { color:var(--text-dim); font-size:12px; margin-top:3px; line-height:1.4; }
116
  .tag {
117
+ display:inline-block; padding:2px 8px; border-radius:4px;
118
+ font-size:10.5px; font-weight:600; margin:1px 2px;
119
  }
120
  .tag.card { background:var(--success-dim); color:var(--success); }
121
  .tag.no-card { background:var(--warning-dim); color:var(--warning); }
 
144
  .modal p { font-size:13px; color:var(--text-dim); margin-bottom:16px; line-height:1.5; }
145
  .modal .modal-actions { display:flex; gap:8px; justify-content:flex-end; }
146
 
147
+ .modal label { display:block; font-size:12px; color:var(--text-dim); margin-bottom:6px; font-weight:500; }
148
  .modal input, .modal select {
149
+ width:100%; padding:9px 12px; border:1px solid var(--border);
150
  border-radius:8px; background:var(--surface2); color:var(--text);
151
  font-size:13px; margin-bottom:14px; outline:none;
152
  }
 
174
  .when-to-use.expanded { max-height:500px; }
175
  .when-to-use span { display:block; padding:1px 0; }
176
 
177
+ .modal.wide { max-width:700px; max-height:90vh; display:flex; flex-direction:column; }
178
+ .modal.wide .modal-body { flex:1; overflow-y:auto; padding-right:4px; }
179
+ .edit-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; flex-wrap:wrap; gap:8px; }
180
+ .edit-header h3 { margin:0; }
181
+ .edit-status { display:flex; gap:6px; }
182
+
183
+ .tab-bar { display:flex; gap:0; margin-bottom:16px; border-bottom:1px solid var(--border); }
184
+ .tab-btn {
185
+ padding:8px 18px; font-size:12px; font-weight:600; cursor:pointer;
186
+ background:transparent; border:none; color:var(--text-muted);
187
+ border-bottom:2px solid transparent; transition:all 0.2s;
188
+ }
189
+ .tab-btn:hover { color:var(--text); }
190
+ .tab-btn.active { color:var(--accent-light); border-bottom-color:var(--accent); }
191
+
192
+ .list-editor { margin-bottom:8px; }
193
+ .list-item {
194
+ display:flex; align-items:center; gap:8px; padding:7px 10px;
195
+ background:var(--surface2); border:1px solid var(--border); border-radius:6px;
196
+ margin-bottom:4px; font-size:12px; line-height:1.4;
197
+ }
198
+ .list-item span { flex:1; word-break:break-word; }
199
+ .list-item .rm-btn {
200
+ width:20px; height:20px; border:none; background:transparent;
201
+ color:var(--danger); cursor:pointer; font-size:14px; flex-shrink:0;
202
+ display:flex; align-items:center; justify-content:center; border-radius:4px;
203
+ }
204
+ .list-item .rm-btn:hover { background:var(--danger-dim); }
205
+ .add-row { display:flex; gap:6px; margin-bottom:14px; }
206
+ .add-row input { flex:1; margin-bottom:0; }
207
+ .add-row .btn { padding:6px 14px; }
208
+
209
+ .tag-editor { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:8px; }
210
+ .tag-chip {
211
+ display:flex; align-items:center; gap:4px; padding:3px 8px;
212
+ background:var(--info-dim); color:var(--info); border-radius:4px;
213
+ font-size:11px; font-weight:600;
214
+ }
215
+ .tag-chip .rm-btn { color:var(--info); font-size:12px; cursor:pointer; background:transparent; border:none; }
216
+ .tag-chip .rm-btn:hover { color:var(--danger); }
217
+
218
+ .history-item {
219
+ display:flex; align-items:center; gap:12px; padding:12px 14px;
220
+ background:var(--surface2); border:1px solid var(--border); border-radius:8px;
221
+ margin-bottom:6px; font-size:12px; line-height:1.4;
222
+ }
223
+ .history-item .ver { font-weight:700; color:var(--accent-light); min-width:28px; }
224
+ .history-item .note { flex:1; color:var(--text-dim); }
225
+ .history-item .ts { color:var(--text-muted); font-size:11px; white-space:nowrap; }
226
+ .history-actions { display:flex; gap:4px; }
227
+
228
+ .diff-block { margin:8px 0 14px; padding:10px; background:var(--surface2); border:1px solid var(--border); border-radius:8px; font-size:12px; }
229
+ .diff-block .diff-field { margin-bottom:8px; }
230
+ .diff-block .diff-label { font-weight:600; color:var(--text); margin-bottom:4px; }
231
+ .diff-old { color:var(--danger); text-decoration:line-through; }
232
+ .diff-new { color:var(--success); }
233
+
234
+ .edit-note-input { margin-bottom:0 !important; }
235
+
236
+ /* Quick Test tab */
237
+ .eval-section {
238
+ margin-bottom:24px; padding-bottom:20px;
239
+ border-bottom:1px solid var(--border);
240
+ }
241
+ .eval-section:last-child { border-bottom:none; margin-bottom:0; }
242
+ .eval-section h4 {
243
+ font-size:14px; font-weight:700; margin-bottom:4px; color:var(--accent-light);
244
+ display:flex; align-items:center; gap:8px;
245
+ }
246
+ .eval-section h4 .step-num {
247
+ background:var(--accent); color:#fff; width:22px; height:22px; border-radius:50%;
248
+ display:inline-flex; align-items:center; justify-content:center;
249
+ font-size:11px; font-weight:700; flex-shrink:0;
250
+ }
251
+ .eval-section .section-desc {
252
+ font-size:12px; color:var(--text-muted); margin-bottom:12px; line-height:1.5;
253
+ }
254
+ .search-test-row { display:flex; gap:8px; margin-bottom:14px; }
255
+ .search-test-row input { flex:1; margin-bottom:0; }
256
+
257
+ .search-result {
258
+ display:flex; align-items:center; gap:10px; padding:8px 12px;
259
+ background:var(--surface2); border:1px solid var(--border); border-radius:8px;
260
+ margin-bottom:4px; font-size:12px; transition:border-color 0.15s;
261
+ }
262
+ .search-result .rank {
263
+ min-width:28px; height:28px; border-radius:50%; font-weight:700;
264
+ display:flex; align-items:center; justify-content:center;
265
+ background:var(--surface3); color:var(--text-muted); font-size:11px;
266
+ }
267
+ .search-result .sr-name { font-family:'SF Mono',monospace; color:var(--accent-light); min-width:150px; font-weight:600; }
268
+ .search-result .sr-score-wrap { min-width:100px; display:flex; flex-direction:column; gap:2px; }
269
+ .search-result .sr-score { color:var(--success); font-weight:700; font-size:12px; }
270
+ .search-result .sr-bar { height:3px; border-radius:2px; background:var(--surface3); }
271
+ .search-result .sr-bar-fill { height:100%; border-radius:2px; background:var(--success); }
272
+ .search-result .sr-desc { flex:1; color:var(--text-muted); font-size:11px; line-height:1.4; }
273
+ .search-result.is-target {
274
+ border-color:var(--success); background:var(--success-dim);
275
+ }
276
+ .search-result.is-target .rank { background:var(--success); color:#fff; }
277
+ .search-result.is-miss { border-color:var(--danger); background:var(--danger-dim); }
278
+
279
+ .recall-bar { display:flex; gap:12px; margin-bottom:16px; flex-wrap:wrap; }
280
+ .recall-card {
281
+ background:var(--surface2); border:1px solid var(--border); border-radius:10px;
282
+ padding:14px 20px; text-align:center; min-width:90px; flex:1;
283
+ }
284
+ .recall-card .rc-label { font-size:11px; color:var(--text-muted); font-weight:600; letter-spacing:0.3px; }
285
+ .recall-card .rc-value { font-size:24px; font-weight:800; margin-top:4px; }
286
+ .recall-card .rc-sub { font-size:10px; color:var(--text-muted); margin-top:2px; }
287
+ .recall-card .rc-value.good { color:var(--success); }
288
+ .recall-card .rc-value.ok { color:var(--warning); }
289
+ .recall-card .rc-value.bad { color:var(--danger); }
290
+
291
+ .batch-detail {
292
+ display:flex; align-items:center; gap:8px; padding:8px 12px;
293
+ background:var(--surface2); border:1px solid var(--border); border-radius:8px;
294
+ margin-bottom:4px; font-size:12px;
295
+ }
296
+ .batch-detail .bd-icon { font-size:14px; min-width:20px; text-align:center; }
297
+ .batch-detail .bd-query { flex:1; word-break:break-word; line-height:1.4; }
298
+ .batch-detail .bd-rank {
299
+ font-weight:700; min-width:32px; text-align:center;
300
+ padding:2px 6px; border-radius:4px; font-size:11px;
301
+ }
302
+ .batch-detail.pass .bd-rank { background:var(--success-dim); color:var(--success); }
303
+ .batch-detail.fail .bd-rank { background:var(--danger-dim); color:var(--danger); }
304
+ .batch-detail .bd-top { color:var(--text-muted); font-size:11px; max-width:200px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
305
+ .batch-detail.pass .bd-icon { color:var(--success); }
306
+ .batch-detail.fail .bd-icon { color:var(--danger); }
307
+ .batch-detail.fail { border-color:var(--danger); background:var(--danger-dim); }
308
+
309
+ .judge-box {
310
+ background:var(--surface2); border:1px solid var(--border); border-radius:10px;
311
+ padding:18px 20px; font-size:13px; line-height:1.8; color:var(--text-dim);
312
+ max-height:400px; overflow-y:auto;
313
+ }
314
+ .judge-box h3 { font-size:14px; color:var(--accent-light); margin:16px 0 8px; font-weight:700; }
315
+ .judge-box h3:first-child { margin-top:0; }
316
+ .judge-box h4 { font-size:13px; color:var(--text); margin:12px 0 6px; font-weight:600; }
317
+ .judge-box strong { color:var(--text); }
318
+ .judge-box em { color:var(--warning); font-style:normal; font-weight:600; }
319
+ .judge-box code { background:var(--surface3); color:var(--accent-light); padding:1px 5px; border-radius:3px; font-size:12px; }
320
+ .judge-box ul, .judge-box ol { margin:6px 0 6px 18px; }
321
+ .judge-box li { margin:3px 0; }
322
+ .judge-box hr { border:none; border-top:1px solid var(--border); margin:14px 0; }
323
+ .judge-box blockquote {
324
+ border-left:3px solid var(--accent); padding:6px 12px; margin:8px 0;
325
+ background:rgba(108,92,231,0.06); border-radius:0 6px 6px 0; font-size:12px;
326
+ }
327
+
328
+ .spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.6s linear infinite; }
329
+ @keyframes spin { to { transform:rotate(360deg); } }
330
+
331
+ /* Guide & Safety UX */
332
+ .guide-panel {
333
+ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);
334
+ padding:16px 20px; margin-bottom:20px; font-size:12px; line-height:1.7;
335
+ }
336
+ .guide-toggle {
337
+ display:flex; align-items:center; justify-content:space-between; cursor:pointer;
338
+ font-weight:700; font-size:13px; color:var(--accent-light);
339
+ }
340
+ .guide-toggle .arrow { transition:transform 0.2s; }
341
+ .guide-panel.collapsed .guide-body { display:none; }
342
+ .guide-panel.collapsed .arrow { transform:rotate(-90deg); }
343
+ .guide-body { margin-top:12px; }
344
+ .guide-row { display:flex; gap:10px; align-items:flex-start; margin-bottom:8px; }
345
+ .guide-row .gl { min-width:72px; font-weight:700; padding:3px 8px; border-radius:4px; text-align:center; font-size:10px; flex-shrink:0; margin-top:1px; }
346
+ .gl-safe { background:var(--success-dim); color:var(--success); }
347
+ .gl-live { background:var(--danger-dim); color:var(--danger); }
348
+ .gl-warn { background:var(--warning-dim); color:var(--warning); }
349
+
350
+ .tab-badge {
351
+ font-size:9px; padding:1px 5px; border-radius:3px; margin-left:4px;
352
+ font-weight:700; vertical-align:middle; letter-spacing:0.3px;
353
+ }
354
+ .tab-badge.safe { background:var(--success-dim); color:var(--success); }
355
+ .tab-badge.live { background:var(--danger-dim); color:var(--danger); }
356
+
357
+ .publish-warning {
358
+ background:var(--warning-dim); border:1px solid rgba(253,203,110,0.3); border-radius:8px;
359
+ padding:10px 14px; margin-bottom:14px; font-size:11px; line-height:1.6; color:var(--warning);
360
+ }
361
+ .publish-warning strong { color:var(--text); }
362
+
363
+ .btn.publish-btn {
364
+ background:#c0392b; color:#fff; border-color:#c0392b;
365
+ font-weight:700;
366
+ }
367
+ .btn.publish-btn:hover { background:#e74c3c; border-color:#e74c3c; }
368
+
369
+ .safe-banner {
370
+ background:var(--success-dim); border:1px solid rgba(0,184,148,0.2); border-radius:8px;
371
+ padding:8px 12px; margin-bottom:14px; font-size:11px; color:var(--success);
372
+ display:flex; align-items:center; gap:6px;
373
+ }
374
+
375
  @media (max-width:768px) {
376
  .content { padding:12px; }
377
  .stats-row { grid-template-columns:1fr 1fr; }
 
379
  .tool-table th, .tool-table td { padding:8px 10px; }
380
  .col-tags, .col-when { display:none; }
381
  header { padding:10px 14px; }
382
+ .modal.wide { max-width:100%; width:100%; margin:8px; max-height:calc(100vh - 16px); }
383
+ .tab-btn { padding:8px 12px; font-size:11px; }
384
+ .edit-header { flex-direction:column; align-items:flex-start; }
385
  }
386
  </style>
387
  </head>
 
398
  </header>
399
 
400
  <div class="content">
401
+ <!-- 온보딩 가이드 -->
402
+ <div class="guide-panel" id="guide-panel">
403
+ <div class="guide-toggle" onclick="toggleGuide()">
404
+ <span>이 화면 사용법</span>
405
+ <span class="arrow">▼</span>
406
+ </div>
407
+ <div class="guide-body">
408
+ <div class="guide-row">
409
+ <span class="gl gl-safe">안전</span>
410
+ <span>검색, 상세보기, 퀵 테스트 — 챗봇에 아무 영향 없음. 마음껏 써도 됨</span>
411
+ </div>
412
+ <div class="guide-row">
413
+ <span class="gl gl-warn">주의</span>
414
+ <span>편집 탭에서 내용을 고치는 것 자체는 안전. 아직 반영 안 됨</span>
415
+ </div>
416
+ <div class="guide-row">
417
+ <span class="gl gl-live">즉시 반영</span>
418
+ <span><strong>「저장 & 즉시 반영」</strong> 클릭 → 지금 돌아가는 챗봇에 바로 적용됨</span>
419
+ </div>
420
+ <div class="guide-row">
421
+ <span class="gl gl-live">즉시 반영</span>
422
+ <span><strong>「도구 해제」</strong> 클릭 → 챗봇이 해당 도구를 바로 못 씀 (모듈 리로드로 복원 가능)</span>
423
+ </div>
424
+ <div class="guide-row">
425
+ <span class="gl gl-live">즉시 반영</span>
426
+ <span><strong>「롤백」</strong> 클릭 → 과거 버전으로 교체. 지금 반영 중인 카드가 바뀜</span>
427
+ </div>
428
+ <div style="margin-top:8px;color:var(--text-muted);font-size:11px">
429
+ 잘못 반영했을 때: 「코드 원본 복원」 또는 「롤백」으로 되돌릴 수 있음
430
+ </div>
431
+ </div>
432
+ </div>
433
+
434
  <div class="stats-row" id="stats-row">
435
  <div class="stat-card"><div class="label">등록 도구</div><div class="value" id="s-tools">—</div></div>
436
  <div class="stat-card"><div class="label">ChromaDB 벡터</div><div class="value" id="s-vectors">—</div></div>
 
469
  <!-- Delete Confirm Modal -->
470
  <div class="modal-overlay" id="delete-modal">
471
  <div class="modal">
472
+ <h3 style="color:var(--danger)">도구 해제 — 즉시 반영됨</h3>
473
+ <div class="publish-warning" style="border-color:rgba(225,112,85,0.3)">
474
+ <strong style="color:var(--danger)">이 작업은 지금 돌아가는 챗봇에 바로 적용됩니다.</strong>
475
+ </div>
476
+ <p>
477
+ <strong id="del-name" style="color:var(--danger)"></strong> 도구를 해제하면:<br>
478
+ • 챗봇이 이 도구를 <strong>더 이상 사용할 수 없게</strong> 됩니다<br>
479
+ • ChromaDB에서 벡터가 즉시 삭제됩니다<br><br>
480
+ <span style="color:var(--success)">되돌리는 방법:</span> 상단의 「모듈 리로드」 버튼으로 복원할 수 있습니다.
481
+ </p>
482
  <div class="modal-actions">
483
+ <button class="btn" onclick="closeModal('delete-modal')">취소 (해제 안 함)</button>
484
+ <button class="btn danger" id="del-confirm" onclick="confirmDelete()">해제 실행</button>
485
  </div>
486
  </div>
487
  </div>
 
515
  <h3 id="detail-title"></h3>
516
  <div id="detail-body" style="font-size:13px;line-height:1.6;color:var(--text-dim);max-height:60vh;overflow-y:auto"></div>
517
  <div class="modal-actions" style="margin-top:16px">
518
+ <button class="btn primary" onclick="closeModal('detail-modal');openEdit(document.getElementById('detail-title').textContent)">편집</button>
519
  <button class="btn" onclick="closeModal('detail-modal')">닫기</button>
520
  </div>
521
  </div>
522
  </div>
523
 
524
+ <!-- Edit ToolCard Modal -->
525
+ <div class="modal-overlay" id="edit-modal">
526
+ <div class="modal wide">
527
+ <div class="edit-header">
528
+ <h3 id="edit-title">ToolCard 편집</h3>
529
+ <div class="edit-status">
530
+ <span class="tag card" id="edit-source"></span>
531
+ <span class="tag module" id="edit-version"></span>
532
+ </div>
533
+ </div>
534
+
535
+ <div class="tab-bar">
536
+ <button class="tab-btn active" data-tab="edit" onclick="switchEditTab('edit')">편집 <span class="tab-badge live">수정 가능</span></button>
537
+ <button class="tab-btn" data-tab="history" onclick="switchEditTab('history')">버전 이력 <span class="tab-badge safe">안전</span></button>
538
+ <button class="tab-btn" data-tab="eval" onclick="switchEditTab('eval')">퀵 테스트 <span class="tab-badge safe">안전</span></button>
539
+ </div>
540
+
541
+ <div class="modal-body">
542
+ <!-- 편집 탭 -->
543
+ <div id="tab-edit">
544
+ <label>Purpose (도구 목적)</label>
545
+ <input id="edit-purpose" type="text" placeholder="이 도구가 하는 일을 한 문장으로...">
546
+
547
+ <label>when_to_use (사용자 발화 예시)</label>
548
+ <div class="list-editor" id="edit-wtu"></div>
549
+ <div class="add-row">
550
+ <input id="new-wtu" type="text" placeholder='예: "보험료 얼마야?"'>
551
+ <button class="btn" onclick="addListItem('wtu')">추가</button>
552
+ </div>
553
+
554
+ <label>when_not_to_use (혼동 방지)</label>
555
+ <div class="list-editor" id="edit-wntu"></div>
556
+ <div class="add-row">
557
+ <input id="new-wntu" type="text" placeholder='예: "납입 플랜 → plan_options 사용"'>
558
+ <button class="btn" onclick="addListItem('wntu')">추가</button>
559
+ </div>
560
+
561
+ <label>Tags (도메인 태그)</label>
562
+ <div class="tag-editor" id="edit-tags"></div>
563
+ <div class="add-row">
564
+ <input id="new-tag" type="text" placeholder="태그 추가..." style="max-width:200px">
565
+ <button class="btn" onclick="addTagItem()">추가</button>
566
+ </div>
567
+
568
+ <label>변경 메모 (이력에 기록됨)</label>
569
+ <input class="edit-note-input" id="edit-note" type="text" placeholder="무엇을 변경했는지 간단히...">
570
+
571
+ <div class="publish-warning">
572
+ <strong>아래 버튼을 누르면 지금 돌아가는 챗봇에 바로 적용됩니다.</strong><br>
573
+ 먼저 「퀵 테스트」 탭에서 확인한 뒤 반영하는 것을 권장합니다.<br>
574
+ 잘못 반영했으면 「버전 이력」 탭에서 롤백하거나, 「코드 원본 복원」으로 되돌릴 수 있습니다.
575
+ </div>
576
+
577
+ <div class="modal-actions" style="margin-top:8px">
578
+ <button class="btn" onclick="closeModal('edit-modal')">취소 (변경 안 함)</button>
579
+ <button class="btn danger" id="btn-reset" onclick="resetToCode()" style="display:none" title="이 도구의 모든 수정을 지우고 원래 코드로 돌아갑니다">코드 원본 복원</button>
580
+ <button class="btn publish-btn" id="btn-publish" onclick="publishCard()">저장 & 즉시 반영</button>
581
+ </div>
582
+ </div>
583
+
584
+ <!-- 버전 이력 탭 -->
585
+ <div id="tab-history" style="display:none">
586
+ <div class="safe-banner">
587
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
588
+ 이력 조회와 Diff는 안전합니다. 단, <strong style="color:var(--warning)">「롤백」 버튼은 챗봇에 즉시 반영</strong>되니 주의하세요.
589
+ </div>
590
+ <div id="history-list"><div class="empty-state">이력이 없습니다.</div></div>
591
+ <div id="diff-view"></div>
592
+ </div>
593
+
594
+ <!-- 퀵 테스트 탭 -->
595
+ <div id="tab-eval" style="display:none">
596
+ <div class="safe-banner">
597
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
598
+ 이 탭은 읽기 전용입니다. 여기서 뭘 해도 챗봇에 영향 없습니다.
599
+ </div>
600
+ <!-- 1. 실시간 쿼리 검색 -->
601
+ <div class="eval-section">
602
+ <h4><span class="step-num">1</span> 실시간 쿼리 테스트</h4>
603
+ <div class="section-desc">
604
+ 아무 질문이나 입력하면, 챗봇이 어떤 도구를 선택할지 미리 확인합니다. 현재 도구가 초록색으로 표시됩니다.
605
+ </div>
606
+ <div class="search-test-row">
607
+ <input id="eval-query" type="text" placeholder="예: 보험료 좀 알아봐줘">
608
+ <button class="btn primary" onclick="runSearchTest()">검색</button>
609
+ </div>
610
+ <div id="eval-search-results"></div>
611
+ </div>
612
+
613
+ <!-- 2. 배치 Recall 평가 -->
614
+ <div class="eval-section">
615
+ <h4><span class="step-num">2</span> 자가 성능 평가</h4>
616
+ <div class="section-desc">
617
+ 이 도구에 등록된 발화 예시를 전부 검색해봅니다. "100%"면 모든 예시가 정확히 이 도구로 연결된다는 뜻입니다.
618
+ </div>
619
+ <button class="btn" id="btn-batch" onclick="runBatchEval()">배치 평가 실행</button>
620
+ <div id="eval-recall-bar" style="margin-top:12px"></div>
621
+ <div id="eval-batch-details" style="margin-top:8px"></div>
622
+ </div>
623
+
624
+ <!-- 3. LLM Judge -->
625
+ <div class="eval-section">
626
+ <h4><span class="step-num">3</span> AI 개선 제안</h4>
627
+ <div class="section-desc">
628
+ 2단계에서 실패한 쿼리가 있으면, AI가 원인을 분석하고 어떻게 고치면 되는지 구체적으로 제안합니다.
629
+ </div>
630
+ <button class="btn" id="btn-judge" onclick="runLlmJudge()" disabled>LLM 분석 실행</button>
631
+ <div id="eval-judge-result" style="margin-top:12px"></div>
632
+ </div>
633
+ </div>
634
+ </div>
635
+ </div>
636
+ </div>
637
+
638
  <div class="toast" id="toast"></div>
639
 
640
  <script>
641
  let allTools = [];
642
  let toolCards = {};
643
  let deleteTarget = '';
644
+ let editName = '';
645
+ let editData = { when_to_use: [], when_not_to_use: [], tags: [] };
646
+
647
+ function toggleGuide() {
648
+ const panel = document.getElementById('guide-panel');
649
+ panel.classList.toggle('collapsed');
650
+ localStorage.setItem('guide-collapsed', panel.classList.contains('collapsed'));
651
+ }
652
+ if (localStorage.getItem('guide-collapsed') === 'true') {
653
+ document.getElementById('guide-panel').classList.add('collapsed');
654
+ }
655
 
656
  async function fetchHealth() {
657
  try {
 
722
  </td>
723
  <td>
724
  <div class="action-btns">
725
+ <button class="action-btn" onclick="openEdit('${t.name}')" title="편집" style="color:var(--accent-light)">
726
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
727
+ </button>
728
  <button class="action-btn" onclick="showDetail('${t.name}')" title="상세보기">
729
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
730
  </button>
731
+ <button class="action-btn del" onclick="openDeleteModal('${t.name}')" title="이 도구를 해제합니다 (챗봇에 즉시 반영됨)">
732
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
733
  </button>
734
  </div>
 
827
  await Promise.all([fetchHealth(), fetchTools()]);
828
  }
829
 
830
+ // ── Edit ToolCard ──────────────────────────────────────────
831
+
832
+ async function openEdit(name) {
833
+ editName = name;
834
+ document.getElementById('edit-title').textContent = name;
835
+ document.getElementById('edit-note').value = '';
836
+ switchEditTab('edit');
837
+
838
+ try {
839
+ const r = await fetch(`/api/admin/toolcards/${name}`);
840
+ const d = await r.json();
841
+
842
+ const src = d.source === 'override' ? 'Override' : d.source === 'code' ? 'Code' : '—';
843
+ document.getElementById('edit-source').textContent = src;
844
+ document.getElementById('edit-source').className = 'tag ' + (d.source === 'override' ? 'card' : 'module');
845
+ document.getElementById('edit-version').textContent = d.version ? `v${d.version}` : 'v0';
846
+ document.getElementById('btn-reset').style.display = d.source === 'override' ? '' : 'none';
847
+
848
+ const card = d.draft || d.effective || {};
849
+ editData = {
850
+ when_to_use: [...(card.when_to_use || [])],
851
+ when_not_to_use: [...(card.when_not_to_use || [])],
852
+ tags: [...(card.tags || [])],
853
+ };
854
+ document.getElementById('edit-purpose').value = card.purpose || '';
855
+ renderListEditor('wtu', editData.when_to_use);
856
+ renderListEditor('wntu', editData.when_not_to_use);
857
+ renderTagEditor(editData.tags);
858
+
859
+ } catch(e) {
860
+ const t = toolCards[name];
861
+ if (t) {
862
+ editData = {
863
+ when_to_use: [...(t.when_to_use || [])],
864
+ when_not_to_use: [...(t.when_not_to_use || [])],
865
+ tags: [...(t.tags || [])],
866
+ };
867
+ document.getElementById('edit-purpose').value = t.purpose || '';
868
+ document.getElementById('edit-source').textContent = 'Code';
869
+ document.getElementById('edit-version').textContent = 'v0';
870
+ document.getElementById('btn-reset').style.display = 'none';
871
+ renderListEditor('wtu', editData.when_to_use);
872
+ renderListEditor('wntu', editData.when_not_to_use);
873
+ renderTagEditor(editData.tags);
874
+ }
875
+ }
876
+
877
+ document.getElementById('edit-modal').classList.add('show');
878
+ }
879
+
880
+ function renderListEditor(key, items) {
881
+ const container = document.getElementById('edit-' + key);
882
+ container.innerHTML = items.map((item, i) => `
883
+ <div class="list-item">
884
+ <span>${escHtml(item)}</span>
885
+ <button class="rm-btn" onclick="removeListItem('${key}',${i})">&times;</button>
886
+ </div>
887
+ `).join('');
888
+ }
889
+
890
+ function renderTagEditor(tags) {
891
+ const container = document.getElementById('edit-tags');
892
+ container.innerHTML = tags.map((t, i) => `
893
+ <div class="tag-chip">
894
+ ${escHtml(t)}
895
+ <button class="rm-btn" onclick="removeTag(${i})">&times;</button>
896
+ </div>
897
+ `).join('');
898
+ }
899
+
900
+ function addListItem(key) {
901
+ const input = document.getElementById('new-' + key);
902
+ const val = input.value.trim();
903
+ if (!val) return;
904
+ const dataKey = key === 'wtu' ? 'when_to_use' : 'when_not_to_use';
905
+ editData[dataKey].push(val);
906
+ renderListEditor(key, editData[dataKey]);
907
+ input.value = '';
908
+ input.focus();
909
+ }
910
+
911
+ function removeListItem(key, idx) {
912
+ const dataKey = key === 'wtu' ? 'when_to_use' : 'when_not_to_use';
913
+ editData[dataKey].splice(idx, 1);
914
+ renderListEditor(key, editData[dataKey]);
915
+ }
916
+
917
+ function addTagItem() {
918
+ const input = document.getElementById('new-tag');
919
+ const val = input.value.trim();
920
+ if (!val) return;
921
+ editData.tags.push(val);
922
+ renderTagEditor(editData.tags);
923
+ input.value = '';
924
+ input.focus();
925
+ }
926
+
927
+ function removeTag(idx) {
928
+ editData.tags.splice(idx, 1);
929
+ renderTagEditor(editData.tags);
930
+ }
931
+
932
+ async function publishCard() {
933
+ const ok = confirm(
934
+ `[즉시 반영] ${editName}\n\n` +
935
+ `이 변경이 지금 돌아가는 챗봇에 바로 적용됩니다.\n` +
936
+ `(잘못되면 \"버전 이력\" 탭에서 롤백 가능)\n\n` +
937
+ `계속 진행할까요?`
938
+ );
939
+ if (!ok) return;
940
+
941
+ const btn = document.getElementById('btn-publish');
942
+ btn.disabled = true; btn.textContent = '반영 중...';
943
+
944
+ const data = {
945
+ purpose: document.getElementById('edit-purpose').value.trim(),
946
+ when_to_use: editData.when_to_use,
947
+ when_not_to_use: editData.when_not_to_use,
948
+ tags: editData.tags,
949
+ };
950
+
951
+ if (!data.purpose) {
952
+ showToast('Purpose는 필수입니다', 'error');
953
+ btn.disabled = false; btn.textContent = '저장 & 즉시 반영';
954
+ return;
955
+ }
956
+
957
+ try {
958
+ const r = await fetch(`/api/admin/toolcards/${editName}/publish`, {
959
+ method: 'POST',
960
+ headers: {'Content-Type':'application/json'},
961
+ body: JSON.stringify({ data, note: document.getElementById('edit-note').value.trim() || undefined }),
962
+ });
963
+ const d = await r.json();
964
+ if (r.ok) {
965
+ showToast(`${editName} v${d.version} 반영 완료 — 챗봇에 적용됨`, 'success');
966
+ closeModal('edit-modal');
967
+ refreshAll();
968
+ } else {
969
+ showToast(d.detail || '반영 실패', 'error');
970
+ }
971
+ } catch(e) {
972
+ showToast('요청 실패: ' + e.message, 'error');
973
+ } finally {
974
+ btn.disabled = false; btn.textContent = '저장 & 즉시 반영';
975
+ }
976
+ }
977
+
978
+ async function resetToCode() {
979
+ if (!confirm(
980
+ `[즉시 반영] 코드 원본 복원\n\n` +
981
+ `${editName}의 모든 수정 내역을 지우고,\n` +
982
+ `개발자가 작성한 원래 상태로 되돌립니다.\n\n` +
983
+ `→ 챗봇에 바로 적용됩니다.\n\n계속 진행할까요?`
984
+ )) return;
985
+ try {
986
+ const r = await fetch(`/api/admin/toolcards/${editName}/override`, {method:'DELETE'});
987
+ if (r.ok) {
988
+ showToast(`${editName} 코드 원본 복원 완료`, 'success');
989
+ closeModal('edit-modal');
990
+ refreshAll();
991
+ }
992
+ } catch(e) {
993
+ showToast('복원 실패: ' + e.message, 'error');
994
+ }
995
+ }
996
+
997
+ // ── History / Diff ─────────────────────────────────────────
998
+
999
+ function switchEditTab(tab) {
1000
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
1001
+ document.getElementById('tab-edit').style.display = tab === 'edit' ? '' : 'none';
1002
+ document.getElementById('tab-history').style.display = tab === 'history' ? '' : 'none';
1003
+ document.getElementById('tab-eval').style.display = tab === 'eval' ? '' : 'none';
1004
+ if (tab === 'history') loadHistory();
1005
+ }
1006
+
1007
+ async function loadHistory() {
1008
+ const container = document.getElementById('history-list');
1009
+ const diffView = document.getElementById('diff-view');
1010
+ diffView.innerHTML = '';
1011
+
1012
+ try {
1013
+ const r = await fetch(`/api/admin/toolcards/${editName}/history`);
1014
+ const d = await r.json();
1015
+ const history = d.history || [];
1016
+
1017
+ if (!history.length) {
1018
+ container.innerHTML = '<div class="empty-state">아직 변경 이력이 없습니다.<br>편집 탭에서 저장 & 반영하면 이력이 생성됩니다.</div>';
1019
+ return;
1020
+ }
1021
+
1022
+ container.innerHTML = history.slice().reverse().map(h => {
1023
+ const ts = new Date(h.timestamp).toLocaleString('ko-KR', {dateStyle:'short',timeStyle:'short'});
1024
+ return `
1025
+ <div class="history-item">
1026
+ <div class="ver">v${h.version}</div>
1027
+ <div class="note">${escHtml(h.note)}</div>
1028
+ <div class="ts">${ts}</div>
1029
+ <div class="history-actions">
1030
+ ${h.previous ? `<button class="action-btn" onclick="showDiff(${h.version})" title="Diff">Diff</button>` : ''}
1031
+ <button class="action-btn" onclick="rollbackTo(${h.version})" title="이 버전으로 롤백" style="color:var(--warning)">롤백</button>
1032
+ </div>
1033
+ </div>`;
1034
+ }).join('');
1035
+ } catch(e) {
1036
+ container.innerHTML = '<div class="empty-state">이력 로드 실패</div>';
1037
+ }
1038
+ }
1039
+
1040
+ async function showDiff(version) {
1041
+ const diffView = document.getElementById('diff-view');
1042
+
1043
+ try {
1044
+ const r = await fetch(`/api/admin/toolcards/${editName}/history`);
1045
+ const d = await r.json();
1046
+ const entry = (d.history || []).find(h => h.version === version);
1047
+ if (!entry || !entry.previous) { diffView.innerHTML = ''; return; }
1048
+
1049
+ const prev = entry.previous;
1050
+ const curr = entry.data;
1051
+ const fields = ['purpose', 'when_to_use', 'when_not_to_use', 'tags'];
1052
+
1053
+ let html = `<div class="diff-block"><strong>v${version - 1} → v${version} 변경사항</strong>`;
1054
+
1055
+ for (const f of fields) {
1056
+ const oldVal = prev[f]; const newVal = curr[f];
1057
+ if (JSON.stringify(oldVal) === JSON.stringify(newVal)) continue;
1058
+
1059
+ html += `<div class="diff-field"><div class="diff-label">${f}</div>`;
1060
+
1061
+ if (Array.isArray(oldVal) && Array.isArray(newVal)) {
1062
+ const removed = oldVal.filter(x => !newVal.includes(x));
1063
+ const added = newVal.filter(x => !oldVal.includes(x));
1064
+ removed.forEach(x => html += `<div class="diff-old">- ${escHtml(x)}</div>`);
1065
+ added.forEach(x => html += `<div class="diff-new">+ ${escHtml(x)}</div>`);
1066
+ } else {
1067
+ html += `<div class="diff-old">- ${escHtml(String(oldVal || ''))}</div>`;
1068
+ html += `<div class="diff-new">+ ${escHtml(String(newVal || ''))}</div>`;
1069
+ }
1070
+ html += `</div>`;
1071
+ }
1072
+
1073
+ html += `</div>`;
1074
+ diffView.innerHTML = html;
1075
+ } catch(e) {
1076
+ diffView.innerHTML = '<div class="empty-state">Diff 로드 실패</div>';
1077
+ }
1078
+ }
1079
+
1080
+ async function rollbackTo(version) {
1081
+ if (!confirm(
1082
+ `[즉시 반영] v${version} 롤백\n\n` +
1083
+ `현재 적용 중인 카드를 v${version} 내용으로 교체합니다.\n` +
1084
+ `→ 챗봇에 바로 적용됩니다.\n\n계속 진행할까요?`
1085
+ )) return;
1086
+ try {
1087
+ const r = await fetch(`/api/admin/toolcards/${editName}/rollback`, {
1088
+ method:'POST',
1089
+ headers:{'Content-Type':'application/json'},
1090
+ body: JSON.stringify({version}),
1091
+ });
1092
+ const d = await r.json();
1093
+ if (r.ok) {
1094
+ showToast(`${editName} → v${version} 롤백 완료 (새 v${d.version})`, 'success');
1095
+ closeModal('edit-modal');
1096
+ refreshAll();
1097
+ } else {
1098
+ showToast(d.detail || '롤백 실패', 'error');
1099
+ }
1100
+ } catch(e) {
1101
+ showToast('롤백 실패: ' + e.message, 'error');
1102
+ }
1103
+ }
1104
+
1105
+ function escHtml(s) {
1106
+ const d = document.createElement('div');
1107
+ d.textContent = s;
1108
+ return d.innerHTML;
1109
+ }
1110
+
1111
+ function renderMd(text) {
1112
+ if (!text) return '';
1113
+ let h = escHtml(text);
1114
+ h = h.replace(/^### (.+)$/gm, '<h3>$1</h3>');
1115
+ h = h.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
1116
+ h = h.replace(/^---$/gm, '<hr>');
1117
+ h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1118
+ h = h.replace(/\*(.+?)\*/g, '<em>$1</em>');
1119
+ h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
1120
+ h = h.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
1121
+ h = h.replace(/^- (.+)$/gm, '<li>$1</li>');
1122
+ h = h.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
1123
+ h = h.replace(/\n{2,}/g, '<br><br>');
1124
+ h = h.replace(/\n/g, '<br>');
1125
+ h = h.replace(/<br>(<h[34]>)/g, '$1');
1126
+ h = h.replace(/(<\/h[34]>)<br>/g, '$1');
1127
+ h = h.replace(/<br>(<hr>)/g, '$1');
1128
+ h = h.replace(/(<hr>)<br>/g, '$1');
1129
+ h = h.replace(/<br>(<ul>)/g, '$1');
1130
+ h = h.replace(/(<\/ul>)<br>/g, '$1');
1131
+ return h;
1132
+ }
1133
+
1134
+ // ── Quick Eval ────────────────────────────────────────────
1135
+
1136
+ let lastBatchFailures = [];
1137
+
1138
+ async function runSearchTest() {
1139
+ const query = document.getElementById('eval-query').value.trim();
1140
+ if (!query) return;
1141
+
1142
+ const container = document.getElementById('eval-search-results');
1143
+ container.innerHTML = '<div class="spinner"></div> 검색 중...';
1144
+
1145
+ try {
1146
+ const r = await fetch('/api/admin/eval/search', {
1147
+ method: 'POST',
1148
+ headers: {'Content-Type':'application/json'},
1149
+ body: JSON.stringify({ query, top_k: 5 }),
1150
+ });
1151
+ const d = await r.json();
1152
+
1153
+ container.innerHTML = d.results.map((hit, i) => {
1154
+ const isTarget = hit.name === editName;
1155
+ const cls = isTarget ? 'is-target' : '';
1156
+ const pct = Math.round(hit.score * 100);
1157
+ return `<div class="search-result ${cls}">
1158
+ <div class="rank">${i + 1}</div>
1159
+ <div class="sr-name">${hit.name}</div>
1160
+ <div class="sr-score-wrap">
1161
+ <div class="sr-score">${(hit.score * 100).toFixed(1)}%</div>
1162
+ <div class="sr-bar"><div class="sr-bar-fill" style="width:${pct}%"></div></div>
1163
+ </div>
1164
+ <div class="sr-desc">${escHtml(hit.description)}</div>
1165
+ ${isTarget ? '<span style="color:var(--success);font-weight:700;white-space:nowrap">← 현재 도구</span>' : ''}
1166
+ </div>`;
1167
+ }).join('') || '<div style="color:var(--text-muted);font-size:12px">결과 없음</div>';
1168
+ } catch(e) {
1169
+ container.innerHTML = `<div style="color:var(--danger);font-size:12px">검색 실패: ${e.message}</div>`;
1170
+ }
1171
+ }
1172
+
1173
+ async function runBatchEval() {
1174
+ const btn = document.getElementById('btn-batch');
1175
+ const recallBar = document.getElementById('eval-recall-bar');
1176
+ const details = document.getElementById('eval-batch-details');
1177
+ btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> 평가 중...';
1178
+ recallBar.innerHTML = ''; details.innerHTML = '';
1179
+ lastBatchFailures = [];
1180
+
1181
+ try {
1182
+ const r = await fetch(`/api/admin/eval/batch/${editName}`, { method: 'POST' });
1183
+ const d = await r.json();
1184
+
1185
+ if (!r.ok) { showToast(d.detail || '평가 실패', 'error'); return; }
1186
+
1187
+ const rc = (v) => v >= 0.9 ? 'good' : v >= 0.7 ? 'ok' : 'bad';
1188
+ recallBar.innerHTML = `
1189
+ <div class="recall-bar">
1190
+ <div class="recall-card">
1191
+ <div class="rc-label">1위 정확도</div>
1192
+ <div class="rc-value ${rc(d.recall_at_1)}">${(d.recall_at_1 * 100).toFixed(0)}%</div>
1193
+ <div class="rc-sub">검색 1위에 나오는 비율</div>
1194
+ </div>
1195
+ <div class="recall-card">
1196
+ <div class="rc-label">Top-3 포함</div>
1197
+ <div class="rc-value ${rc(d.recall_at_3)}">${(d.recall_at_3 * 100).toFixed(0)}%</div>
1198
+ <div class="rc-sub">상위 3개 안에 드는 비율</div>
1199
+ </div>
1200
+ <div class="recall-card">
1201
+ <div class="rc-label">Top-5 포함</div>
1202
+ <div class="rc-value ${rc(d.recall_at_5)}">${(d.recall_at_5 * 100).toFixed(0)}%</div>
1203
+ <div class="rc-sub">상위 5개 안에 드는 비율</div>
1204
+ </div>
1205
+ <div class="recall-card">
1206
+ <div class="rc-label">테스트 수</div>
1207
+ <div class="rc-value" style="color:var(--text)">${d.total}건</div>
1208
+ <div class="rc-sub">등록된 발화 예시 수</div>
1209
+ </div>
1210
+ </div>`;
1211
+
1212
+ lastBatchFailures = d.details.filter(x => !x.pass_at_3);
1213
+
1214
+ details.innerHTML = d.details.map(item => {
1215
+ const pass = item.pass_at_3;
1216
+ const topNames = (item.top_hits || []).slice(0, 3).map(h => h.name).join(', ');
1217
+ return `<div class="batch-detail ${pass ? 'pass' : 'fail'}">
1218
+ <div class="bd-icon">${pass ? '✓' : '✗'}</div>
1219
+ <div class="bd-query">${escHtml(item.query)}</div>
1220
+ <div class="bd-rank">${item.rank ? item.rank + '위' : '—'}</div>
1221
+ <div class="bd-top" title="${topNames}">${topNames}</div>
1222
+ </div>`;
1223
+ }).join('');
1224
+
1225
+ document.getElementById('btn-judge').disabled = lastBatchFailures.length === 0;
1226
+ if (lastBatchFailures.length === 0) {
1227
+ document.getElementById('eval-judge-result').innerHTML =
1228
+ '<div style="color:var(--success);font-size:12px;padding:8px">모든 쿼리가 Top-3에 포함됩니다. LLM 분석이 필요 없습니다.</div>';
1229
+ } else {
1230
+ document.getElementById('eval-judge-result').innerHTML =
1231
+ `<div style="color:var(--warning);font-size:12px;padding:8px">${lastBatchFailures.length}건 실패 — LLM 분석을 실행하세요.</div>`;
1232
+ }
1233
+ } catch(e) {
1234
+ recallBar.innerHTML = `<div style="color:var(--danger);font-size:12px">평가 실패: ${e.message}</div>`;
1235
+ } finally {
1236
+ btn.disabled = false; btn.textContent = '배치 평가 실행';
1237
+ }
1238
+ }
1239
+
1240
+ async function runLlmJudge() {
1241
+ if (!lastBatchFailures.length) return;
1242
+
1243
+ const btn = document.getElementById('btn-judge');
1244
+ const container = document.getElementById('eval-judge-result');
1245
+ btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> LLM 분석 중...';
1246
+ container.innerHTML = '<div style="font-size:12px;color:var(--text-muted);padding:8px"><span class="spinner"></span> LLM이 실패 원인을 분석하고 있습니다... (10~30초)</div>';
1247
+
1248
+ try {
1249
+ const r = await fetch('/api/admin/eval/judge', {
1250
+ method: 'POST',
1251
+ headers: {'Content-Type':'application/json'},
1252
+ body: JSON.stringify({
1253
+ tool_name: editName,
1254
+ failures: lastBatchFailures,
1255
+ }),
1256
+ });
1257
+ const d = await r.json();
1258
+ container.innerHTML = `<div class="judge-box">${renderMd(d.analysis)}</div>`;
1259
+ } catch(e) {
1260
+ container.innerHTML = `<div style="color:var(--danger);font-size:12px">LLM 분석 실패: ${e.message}</div>`;
1261
+ } finally {
1262
+ btn.disabled = false; btn.textContent = 'LLM 분석 실행';
1263
+ }
1264
+ }
1265
+
1266
+ // ── Enter key support ─────────────────────────────────────
1267
+ ['new-wtu','new-wntu','new-tag'].forEach(id => {
1268
+ document.getElementById(id).addEventListener('keydown', e => {
1269
+ if (e.key === 'Enter') {
1270
+ e.preventDefault();
1271
+ if (id === 'new-tag') addTagItem();
1272
+ else addListItem(id.replace('new-',''));
1273
+ }
1274
+ });
1275
+ });
1276
+
1277
+ document.getElementById('eval-query').addEventListener('keydown', e => {
1278
+ if (e.key === 'Enter') { e.preventDefault(); runSearchTest(); }
1279
+ });
1280
+
1281
  document.getElementById('search').addEventListener('input', () => renderTable(allTools));
1282
 
1283
  document.querySelectorAll('.modal-overlay').forEach(el => {