Spaces:
Sleeping
Sleeping
김민경 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 +139 -172
- app/main.py +251 -1
- app/tool_search/tool_cards.py +17 -0
- app/tool_search/toolcard_store.py +281 -0
- data/toolcard_overrides.json +4 -0
- templates/admin_tools.html +835 -13
README.md
CHANGED
|
@@ -8,33 +8,31 @@ app_port: 7860
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
# AI TMR Assistant — Tool Routing
|
| 12 |
|
| 13 |
-
>
|
| 14 |
|
| 15 |
---
|
| 16 |
|
| 17 |
-
## 1.
|
| 18 |
|
| 19 |
-
12개 보험 상품 × 9개 기능(조회·산출·심사·보장·청구
|
| 20 |
-
도구가 많아지면서 세 가지 문제가
|
| 21 |
|
| 22 |
| 문제 | 원인 | 영향 |
|
| 23 |
|------|------|------|
|
| 24 |
| 오호출 | 유사 도구 혼동 (premium_estimate ↔ plan_options) | 잘못된 답변 |
|
| 25 |
-
| 비용 증가 | 매 요청마다 54개 스키마가 LLM 컨텍스트에 포함 | 토큰 낭비 |
|
| 26 |
-
| 지연 증가 | 컨텍스트 길이에 비례
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
---
|
| 32 |
|
| 33 |
-
## 2.
|
| 34 |
|
| 35 |
-
###
|
| 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() | 후보 중
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
-
###
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
```
|
| 63 |
|
| 64 |
-
|
|
| 65 |
-
|------|------
|
| 66 |
-
|
|
| 67 |
-
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
-
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
|
| 75 |
-
``
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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% |
|
| 90 |
-
| **MRR** | 0.969 | 0.984 | **0.984** |
|
| 91 |
-
| **No-Call Acc** |
|
| 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건: 유사 도구
|
| 99 |
-
- k=3부터 Recall 100%
|
| 100 |
-
- no-call 오판 3건:
|
| 101 |
|
| 102 |
-
**
|
|
|
|
| 103 |
|
| 104 |
---
|
| 105 |
|
| 106 |
## 4. 구현 상세
|
| 107 |
|
| 108 |
-
### 4-1. 5노드 파이프라인
|
| 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 |
|
| 118 |
-
| agent | ChromaDB Top-K
|
| 119 |
-
| tools | ToolRegistry 동적 디스패치 | 10~100ms |
|
| 120 |
| output_guardrail | PII·금칙어 검사 + 면책 문구 자동 추가 | <2ms |
|
| 121 |
|
| 122 |
-
|
|
|
|
| 123 |
|
| 124 |
-
|
| 125 |
|
| 126 |
-
|
|
|
|
|
|
|
| 127 |
|
| 128 |
-
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
12개 상품 목록이 PRODUCTS 딕셔너리에서 동적 반영
|
| 133 |
-
|
| 134 |
-
|
| 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-
|
| 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
|
|
|
|
|
|
|
| 157 |
```
|
| 158 |
|
| 159 |
-
|
| 160 |
|
| 161 |
-
|
| 162 |
|
| 163 |
-
|
| 164 |
|
| 165 |
-
|
| 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 |
-
|
| 176 |
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
**③
|
| 180 |
|
| 181 |
```
|
| 182 |
-
1. 새 카드
|
| 183 |
-
2. 기존 유사 도구
|
| 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`가
|
| 202 |
-
|
|
| 203 |
-
|
|
| 204 |
-
| `CONFUSION_PAIRS` | **수동** | 유사 도구가 있을 경우 반드시 등록 |
|
| 205 |
-
|
| 206 |
-
> ToolCard가 없는 도구는 `tool.description` 단일 문서로 fallback 되어 동작은 하지만 검색 정확도가 낮다. 서버 로그에 `"ToolCard 없는 도구 N개"` 경고가 출력된다.
|
| 207 |
|
| 208 |
-
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
```bash
|
| 213 |
-
# 전체 도구 목록 조회
|
| 214 |
-
curl http://localhost:8080/api/tools
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
-
|
| 224 |
-
|-----|--------|------|
|
| 225 |
-
| `/api/tools` | GET | 전체 도구 목록 + 메타데이터 |
|
| 226 |
-
| `/api/tools/{tool_name}` | DELETE | 도구 해제 + ChromaDB 벡터 삭제 |
|
| 227 |
-
| `/api/tools/reload-module/{module}` | POST | 모듈 `importlib.reload()` → 도구 재등록 |
|
| 228 |
|
| 229 |
-
|
| 230 |
|
| 231 |
```bash
|
| 232 |
-
|
|
|
|
|
|
|
| 233 |
```
|
| 234 |
|
| 235 |
-
|
| 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 |
-
##
|
| 255 |
|
| 256 |
### ChromaDB
|
| 257 |
|
|
@@ -262,54 +247,35 @@ CLI 대신 브라우저에서 도구 레지스트리를 관리하는 웹 UI(`/ad
|
|
| 262 |
| 실시간 upsert | rebuild 필요 | O | **O** |
|
| 263 |
| 인프라 | 없음 | Docker 3개 | **pip 1줄** |
|
| 264 |
|
| 265 |
-
|
|
|
|
| 266 |
|
| 267 |
### multilingual-e5-large
|
| 268 |
|
| 269 |
-
[Kor-IR 벤치마크](https://github.com/Atipico1/Kor-IR)
|
| 270 |
-
|
| 271 |
-
|
| 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
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 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 |
-
##
|
| 306 |
|
| 307 |
-
| 한계 | 현상 |
|
| 308 |
-
|------|------|------
|
| 309 |
-
|
|
| 310 |
-
|
|
| 311 |
-
| 수동 작성 한계 | 54개 × 7개 = ~380개 when_to_use 수동 관리 | Re-Invoke 방식 LLM 합성 쿼리 자동 생성 |
|
| 312 |
-
| 정적 no-call 임계값 |
|
| 313 |
|
| 314 |
---
|
| 315 |
|
|
@@ -355,7 +321,8 @@ app/
|
|
| 355 |
│ └── data.py # 12개 상품 데이터 + 시스템 프롬프트
|
| 356 |
├── tool_search/ # ChromaDB 멀티벡터 라우팅
|
| 357 |
│ ├── embedder.py # 임베딩 + Top-K 검색
|
| 358 |
-
│
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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.
|
| 113 |
|
| 114 |
-
.tool-name { font-weight:600; color:var(--accent-light); font-family:'SF Mono',monospace; font-size:
|
| 115 |
-
.tool-purpose { color:var(--text-dim); font-size:12px; margin-top:
|
| 116 |
.tag {
|
| 117 |
-
display:inline-block; padding:2px
|
| 118 |
-
font-size:
|
| 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:
|
| 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>도구 해제
|
| 239 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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})">×</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})">×</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(/^> (.+)$/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 => {
|