codebuff-agent 2.1.74__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  2. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
  3. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  4. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
  5. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
  6. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
  7. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
  8. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
  9. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
  10. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
  11. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  12. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  13. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  14. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
  15. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
  16. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
  17. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
  18. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
  19. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  20. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  21. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
  22. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
  23. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  24. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
  25. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
  26. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
  27. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
  28. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  29. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  30. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  31. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  32. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
  33. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
  34. codebuff_agent-2.1.74.dist-info/METADATA +142 -0
  35. codebuff_agent-2.1.74.dist-info/RECORD +217 -0
  36. codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
  37. codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
  38. codebuff_core/__init__.py +153 -0
  39. codebuff_core/codebuff_config_defaults.json +64 -0
  40. codebuff_core/core/__init__.py +35 -0
  41. codebuff_core/core/code_validator.py +1238 -0
  42. codebuff_core/core/error_classifier.py +579 -0
  43. codebuff_core/core/exceptions.py +49 -0
  44. codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
  45. codebuff_core/directory_policy.py +32 -0
  46. codebuff_core/factory.py +155 -0
  47. codebuff_core/interfaces.py +197 -0
  48. codebuff_core/knowledge/__init__.py +31 -0
  49. codebuff_core/knowledge/chunking.py +375 -0
  50. codebuff_core/knowledge/loader.py +339 -0
  51. codebuff_core/knowledge/watchdog_service.py +354 -0
  52. codebuff_core/legacy_paths.py +69 -0
  53. codebuff_core/llm/__init__.py +13 -0
  54. codebuff_core/llm/service.py +407 -0
  55. codebuff_core/managers/__init__.py +22 -0
  56. codebuff_core/managers/config_manager.py +690 -0
  57. codebuff_core/managers/remote_config.py +673 -0
  58. codebuff_core/managers/remote_skills.py +539 -0
  59. codebuff_core/managers/session_manager.py +255 -0
  60. codebuff_core/middleware/__init__.py +3 -0
  61. codebuff_core/middleware/error_recovery.py +394 -0
  62. codebuff_core/middleware/logging_utils.py +33 -0
  63. codebuff_core/models/__init__.py +111 -0
  64. codebuff_core/models/agent.py +306 -0
  65. codebuff_core/models/chat.py +57 -0
  66. codebuff_core/models/common.py +82 -0
  67. codebuff_core/models/rag.py +300 -0
  68. codebuff_core/platform_rules.py +127 -0
  69. codebuff_core/prompts/__init__.py +44 -0
  70. codebuff_core/prompts/auto_agent_prompts.py +1278 -0
  71. codebuff_core/services/__init__.py +30 -0
  72. codebuff_core/services/agent_service.py +345 -0
  73. codebuff_core/services/analytics_aggregate.py +837 -0
  74. codebuff_core/services/chat_service.py +898 -0
  75. codebuff_core/services/context_condenser.py +306 -0
  76. codebuff_core/services/pii_guardrail.py +393 -0
  77. codebuff_core/services/rag_chat_agent.py +2993 -0
  78. codebuff_core/services/rag_collections.py +2740 -0
  79. codebuff_core/services/rag_manager.py +890 -0
  80. codebuff_core/services/rag_service.py +145 -0
  81. codebuff_core/services/retriever.py +260 -0
  82. codebuff_core/services/vllm_embedding_service.py +290 -0
  83. codebuff_core/tests/__init__.py +1 -0
  84. codebuff_core/tests/conftest.py +81 -0
  85. codebuff_core/tests/test_error_recovery.py +647 -0
  86. codebuff_core/tests/test_factory.py +135 -0
  87. codebuff_core/tests/test_services.py +150 -0
  88. jupyter_ext/__init__.py +266 -0
  89. jupyter_ext/_version.py +4 -0
  90. jupyter_ext/agent/__init__.py +49 -0
  91. jupyter_ext/agent/agent_factory.py +2270 -0
  92. jupyter_ext/agent/ast_tools.py +451 -0
  93. jupyter_ext/agent/autofix_middleware.py +481 -0
  94. jupyter_ext/agent/background_procs.py +372 -0
  95. jupyter_ext/agent/code_history_middleware.py +211 -0
  96. jupyter_ext/agent/codex_autofix.py +160 -0
  97. jupyter_ext/agent/codex_bridge.py +3339 -0
  98. jupyter_ext/agent/codex_hooks.py +320 -0
  99. jupyter_ext/agent/codex_slash.py +685 -0
  100. jupyter_ext/agent/codex_threads.py +114 -0
  101. jupyter_ext/agent/content_injection.py +377 -0
  102. jupyter_ext/agent/continuation_middleware.py +548 -0
  103. jupyter_ext/agent/custom_skills.py +335 -0
  104. jupyter_ext/agent/file_diffs.py +99 -0
  105. jupyter_ext/agent/git_history.py +188 -0
  106. jupyter_ext/agent/guardrail_middleware.py +421 -0
  107. jupyter_ext/agent/json_repair_utils.py +54 -0
  108. jupyter_ext/agent/langchain_tools.py +2673 -0
  109. jupyter_ext/agent/mermaid_fixer.py +289 -0
  110. jupyter_ext/agent/mermaid_middleware.py +135 -0
  111. jupyter_ext/agent/middleware.py +359 -0
  112. jupyter_ext/agent/middleware_prompts.py +53 -0
  113. jupyter_ext/agent/observability.py +334 -0
  114. jupyter_ext/agent/patch_tool_calls.py +57 -0
  115. jupyter_ext/agent/prompts.py +112 -0
  116. jupyter_ext/agent/skill_middleware.py +257 -0
  117. jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
  118. jupyter_ext/agent/skills/anomaly_detection.md +12 -0
  119. jupyter_ext/agent/skills/athena_sql.md +156 -0
  120. jupyter_ext/agent/skills/bdp_table_search.md +33 -0
  121. jupyter_ext/agent/skills/bigquery.md +65 -0
  122. jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
  123. jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
  124. jupyter_ext/agent/skills/classification_evaluation.md +11 -0
  125. jupyter_ext/agent/skills/clustering_analysis.md +12 -0
  126. jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
  127. jupyter_ext/agent/skills/data_analysis.md +236 -0
  128. jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
  129. jupyter_ext/agent/skills/data_loading.md +158 -0
  130. jupyter_ext/agent/skills/data_quality_report.md +13 -0
  131. jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
  132. jupyter_ext/agent/skills/emr_script_development.md +17 -0
  133. jupyter_ext/agent/skills/experiment_tracking.md +13 -0
  134. jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
  135. jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
  136. jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
  137. jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
  138. jupyter_ext/agent/skills/inference.md +392 -0
  139. jupyter_ext/agent/skills/lambda_function_development.md +19 -0
  140. jupyter_ext/agent/skills/model_comparison.md +11 -0
  141. jupyter_ext/agent/skills/model_explainability.md +12 -0
  142. jupyter_ext/agent/skills/model_training.md +318 -0
  143. jupyter_ext/agent/skills/pyspark.md +352 -0
  144. jupyter_ext/agent/skills/python_code_quality.md +35 -0
  145. jupyter_ext/agent/skills/regression_evaluation.md +12 -0
  146. jupyter_ext/agent/skills/spring_java_development.md +35 -0
  147. jupyter_ext/agent/skills/text_preprocessing.md +12 -0
  148. jupyter_ext/agent/skills/time_series_analysis.md +13 -0
  149. jupyter_ext/agent/skills/unit_testing.md +203 -0
  150. jupyter_ext/agent/todo_prompts.py +57 -0
  151. jupyter_ext/agent/tool_report.py +45 -0
  152. jupyter_ext/analytics_ingest.py +599 -0
  153. jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  154. jupyter_ext/handlers/__init__.py +8 -0
  155. jupyter_ext/handlers/analytics_handler.py +170 -0
  156. jupyter_ext/handlers/benchmark_handler.py +794 -0
  157. jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
  158. jupyter_ext/handlers/rag_admin_handler.py +689 -0
  159. jupyter_ext/handlers/websocket.py +497 -0
  160. jupyter_ext/http_handlers.py +8744 -0
  161. jupyter_ext/idle_monitor.py +346 -0
  162. jupyter_ext/labextension/package.json +157 -0
  163. jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  164. jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
  165. jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
  166. jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
  167. jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
  168. jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
  169. jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
  170. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
  171. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  172. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  173. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  174. jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
  175. jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
  176. jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
  177. jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
  178. jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
  179. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  180. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  181. jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
  182. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
  183. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  184. jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
  185. jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
  186. jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
  187. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
  188. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  189. jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  190. jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  191. jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  192. jupyter_ext/labextension/static/style.js +4 -0
  193. jupyter_ext/labextension/static/third-party-licenses.json +724 -0
  194. jupyter_ext/log_paths.py +113 -0
  195. jupyter_ext/resource_usage.py +519 -0
  196. jupyter_ext/secrets_bootstrap.py +116 -0
  197. jupyter_ext/team_access.py +105 -0
  198. jupyter_ext/tests/__init__.py +1 -0
  199. jupyter_ext/tests/test_context.py +234 -0
  200. jupyter_ext/tests/test_factory.py +563 -0
  201. jupyter_ext/tests/test_file_finder.py +139 -0
  202. jupyter_ext/tests/test_handlers.py +58 -0
  203. jupyter_ext/tests/test_mcp_selftest.py +204 -0
  204. jupyter_ext/tests/test_mermaid_fixer.py +272 -0
  205. jupyter_ext/tests/test_resource_tiers.py +298 -0
  206. jupyter_ext/tools/__init__.py +15 -0
  207. jupyter_ext/tools/base.py +90 -0
  208. jupyter_ext/tools/context.py +268 -0
  209. jupyter_ext/tools/dir_complete.py +86 -0
  210. jupyter_ext/tools/file_finder.py +303 -0
  211. jupyter_ext/tools/file_tools.py +244 -0
  212. jupyter_ext/tools/jupyter_tools.py +296 -0
  213. jupyter_ext/tools/registry.py +159 -0
  214. jupyter_ext/tools/search_tools.py +230 -0
  215. jupyter_ext/tools/security_tools.py +657 -0
  216. jupyter_ext/tools/shell_tools.py +109 -0
  217. jupyter_ext/tools/user_tools.py +132 -0
@@ -0,0 +1,794 @@
1
+ """RAG Benchmark Handler — 테스트 케이스 관리, 벤치마크 실행, 결과 조회."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from datetime import datetime
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from jupyter_server.base.handlers import APIHandler
13
+
14
+ from codebuff_core.legacy_paths import resolve_home
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # 벤치마크 데이터 저장 경로
19
+ _BENCHMARK_DIR = resolve_home("benchmark")
20
+ _TEST_CASES_FILE = _BENCHMARK_DIR / "test_cases.json"
21
+ _RESULTS_DIR = _BENCHMARK_DIR / "results"
22
+
23
+
24
+ def _ensure_dirs():
25
+ _BENCHMARK_DIR.mkdir(parents=True, exist_ok=True)
26
+ _RESULTS_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+
29
+ def _load_test_cases() -> List[Dict[str, Any]]:
30
+ _ensure_dirs()
31
+ if _TEST_CASES_FILE.exists():
32
+ try:
33
+ return json.loads(_TEST_CASES_FILE.read_text(encoding="utf-8"))
34
+ except Exception:
35
+ return []
36
+ return []
37
+
38
+
39
+ def _save_test_cases(cases: List[Dict[str, Any]]):
40
+ _ensure_dirs()
41
+ _TEST_CASES_FILE.write_text(
42
+ json.dumps(cases, ensure_ascii=False, indent=2), encoding="utf-8"
43
+ )
44
+
45
+
46
+ def _load_results() -> List[Dict[str, Any]]:
47
+ """실행 결과 목록 (요약만)."""
48
+ _ensure_dirs()
49
+ results = []
50
+ for f in sorted(_RESULTS_DIR.glob("*.json"), reverse=True):
51
+ try:
52
+ data = json.loads(f.read_text(encoding="utf-8"))
53
+ results.append(
54
+ {
55
+ "id": f.stem,
56
+ "date": data.get("date", ""),
57
+ "memo": data.get("memo", ""),
58
+ "total_cases": data.get("total_cases", 0),
59
+ "summary": data.get("summary", {}),
60
+ }
61
+ )
62
+ except Exception:
63
+ continue
64
+ return results
65
+
66
+
67
+ def _load_result_detail(result_id: str) -> Optional[Dict[str, Any]]:
68
+ path = _RESULTS_DIR / f"{result_id}.json"
69
+ if path.exists():
70
+ return json.loads(path.read_text(encoding="utf-8"))
71
+ return None
72
+
73
+
74
+ def _save_result(result: Dict[str, Any]) -> str:
75
+ _ensure_dirs()
76
+ result_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
77
+ path = _RESULTS_DIR / f"{result_id}.json"
78
+ result["id"] = result_id
79
+ path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
80
+ return result_id
81
+
82
+
83
+ # ─── Test Cases CRUD ───
84
+
85
+
86
+ class BenchmarkTestCasesHandler(APIHandler):
87
+ """GET/POST /codebuff/benchmark/test-cases"""
88
+
89
+ async def get(self):
90
+ self.write(json.dumps(_load_test_cases(), ensure_ascii=False))
91
+
92
+ async def post(self):
93
+ body = (
94
+ json.loads(self.request.body.decode("utf-8")) if self.request.body else {}
95
+ )
96
+ action = body.get("action", "save_all")
97
+
98
+ if action == "save_all":
99
+ cases = body.get("test_cases", [])
100
+ _save_test_cases(cases)
101
+ self.write({"status": "ok", "count": len(cases)})
102
+
103
+ elif action == "add":
104
+ cases = _load_test_cases()
105
+ new_case = body.get("test_case", {})
106
+ if not new_case.get("id"):
107
+ new_case["id"] = f"tc_{uuid.uuid4().hex[:8]}"
108
+ cases.append(new_case)
109
+ _save_test_cases(cases)
110
+ self.write({"status": "ok", "id": new_case["id"]})
111
+
112
+ elif action == "delete":
113
+ tc_id = body.get("id")
114
+ cases = [c for c in _load_test_cases() if c.get("id") != tc_id]
115
+ _save_test_cases(cases)
116
+ self.write({"status": "ok"})
117
+
118
+ elif action == "update":
119
+ tc_id = body.get("id")
120
+ updated = body.get("test_case", {})
121
+ cases = _load_test_cases()
122
+ for i, c in enumerate(cases):
123
+ if c.get("id") == tc_id:
124
+ cases[i] = {**c, **updated}
125
+ break
126
+ _save_test_cases(cases)
127
+ self.write({"status": "ok"})
128
+
129
+
130
+ # ─── Auto-generate test cases ───
131
+
132
+
133
+ class BenchmarkGenerateHandler(APIHandler):
134
+ """POST /codebuff/benchmark/generate — Qdrant 문서에서 QA 쌍 자동 생성."""
135
+
136
+ async def post(self):
137
+ body = (
138
+ json.loads(self.request.body.decode("utf-8")) if self.request.body else {}
139
+ )
140
+ collection = body.get("collection", "guide_docs")
141
+ sources = body.get("sources", []) # 선택된 문서 목록
142
+ per_source = body.get("per_source", 1)
143
+
144
+ try:
145
+ from .rag_admin_handler import _ensure_manager
146
+
147
+ mgr = _ensure_manager()
148
+ if mgr is None:
149
+ self.set_status(500)
150
+ self.write({"error": "RAG 매니저 미초기화"})
151
+ return
152
+
153
+ client = mgr._client
154
+
155
+ # 선택된 문서의 chunk 가져오기
156
+ chunks_by_source: Dict[str, List[Dict]] = {}
157
+ scroll_offset = None
158
+ while True:
159
+ pts, scroll_offset = client.scroll(
160
+ collection_name=collection,
161
+ limit=100,
162
+ with_payload=True,
163
+ with_vectors=False,
164
+ offset=scroll_offset,
165
+ )
166
+ for pt in pts:
167
+ p = pt.payload or {}
168
+ src = p.get("source", p.get("title", "unknown"))
169
+ if sources and src not in sources:
170
+ continue
171
+ content = p.get("content", "")
172
+ if len(content) < 100:
173
+ continue
174
+ chunks_by_source.setdefault(src, []).append(
175
+ {
176
+ "source": src,
177
+ "title": p.get("title", ""),
178
+ "section": p.get("section", ""),
179
+ "content": content[:3000],
180
+ }
181
+ )
182
+ if scroll_offset is None:
183
+ break
184
+
185
+ if not chunks_by_source:
186
+ self.write(
187
+ json.dumps(
188
+ {"test_cases": [], "message": "해당 문서를 찾을 수 없습니다."}
189
+ )
190
+ )
191
+ return
192
+
193
+ # SSE 헤더
194
+ self.set_header("Content-Type", "text/event-stream")
195
+ self.set_header("Cache-Control", "no-cache")
196
+ self.set_header("Connection", "keep-alive")
197
+
198
+ # LLM으로 QA 생성
199
+ from codebuff_core.llm.service import LLMService
200
+ from codebuff_core.managers.config_manager import get_config_manager
201
+
202
+ cfg = get_config_manager().get_config()
203
+ llm_config = cfg.get("vllm", {})
204
+ if not llm_config:
205
+ self.write(
206
+ f"data: {json.dumps({'type': 'error', 'error': 'LLM 설정 없음'})}\n\n"
207
+ )
208
+ self.finish()
209
+ return
210
+
211
+ llm = LLMService({"provider": "vllm", "vllm": llm_config})
212
+ generated: List[Dict] = []
213
+
214
+ # 전체 chunk 목록 생성
215
+ all_tasks: List[Dict] = []
216
+ for src, chunks in chunks_by_source.items():
217
+ selected = sorted(
218
+ chunks, key=lambda c: len(c["content"]), reverse=True
219
+ )[:per_source]
220
+ for chunk in selected:
221
+ all_tasks.append(chunk)
222
+
223
+ total_tasks = len(all_tasks)
224
+ self.write(
225
+ f"data: {json.dumps({'type': 'progress', 'current': 0, 'total': total_tasks, 'source': '병렬 생성 시작'}, ensure_ascii=False)}\n\n"
226
+ )
227
+ await self.flush()
228
+
229
+ # 병렬 LLM 호출 (최대 5개 동시)
230
+ _PARALLEL = 5
231
+ _completed = 0
232
+
233
+ async def _generate_one(chunk: Dict) -> Optional[Dict]:
234
+ prompt = (
235
+ "아래 문서 내용을 읽고, 이 문서로만 답할 수 있는 질문과 기대 답변을 만들어주세요.\n\n"
236
+ "규칙:\n"
237
+ "- 실제 사용자가 할 법한 자연어 질문으로 만드세요.\n"
238
+ "- 기대 답변(ground_truth)은 문서 내용을 충실히 반영하여 **구체적이고 상세하게** 작성하세요.\n"
239
+ "- 핵심 함수명, 파라미터, 절차, 설정값 등을 포함하세요.\n"
240
+ "- must_not_contain에는 문서에 없지만 LLM이 만들어낼 수 있는 그럴듯한 키워드를 넣으세요.\n\n"
241
+ "반드시 아래 JSON 형식으로만 응답하세요:\n"
242
+ '{"question": "질문", "ground_truth": "상세한 기대 답변", '
243
+ '"must_not_contain": ["할루시네이션 가능 키워드"]}\n\n'
244
+ f"[문서: {chunk['source']}]\n"
245
+ f"[섹션: {chunk['section']}]\n\n"
246
+ f"{chunk['content']}"
247
+ )
248
+ try:
249
+ resp = await llm.generate_response(prompt)
250
+ resp = resp.strip()
251
+ if resp.startswith("```"):
252
+ resp = resp.split("```")[1]
253
+ if resp.startswith("json"):
254
+ resp = resp[4:]
255
+ parsed = json.loads(resp)
256
+ return {
257
+ "id": f"auto_{uuid.uuid4().hex[:8]}",
258
+ "category": collection,
259
+ "question": parsed.get("question", ""),
260
+ "ground_truth": parsed.get("ground_truth", ""),
261
+ "must_not_contain": parsed.get("must_not_contain", []),
262
+ "source": chunk["source"],
263
+ "difficulty": "medium",
264
+ "auto_generated": True,
265
+ }
266
+ except Exception as e:
267
+ logger.warning(f"QA generation failed for {chunk['source']}: {e}")
268
+ return None
269
+
270
+ import asyncio as _aio
271
+
272
+ sem = _aio.Semaphore(_PARALLEL)
273
+
274
+ async def _gen_with_sem(chunk: Dict):
275
+ nonlocal _completed
276
+ async with sem:
277
+ r = await _generate_one(chunk)
278
+ _completed += 1
279
+ if isinstance(r, dict) and r is not None:
280
+ generated.append(r)
281
+ self.write(
282
+ f"data: {json.dumps({'type': 'case', 'test_case': r}, ensure_ascii=False)}\n\n"
283
+ )
284
+ self.write(
285
+ f"data: {json.dumps({'type': 'progress', 'current': _completed, 'total': total_tasks, 'source': chunk['source']}, ensure_ascii=False)}\n\n"
286
+ )
287
+ await self.flush()
288
+ return r
289
+
290
+ await _aio.gather(*[_gen_with_sem(c) for c in all_tasks])
291
+
292
+ # 완료
293
+ self.write(
294
+ f"data: {json.dumps({'type': 'complete', 'total': len(generated)})}\n\n"
295
+ )
296
+ await self.flush()
297
+ self.finish()
298
+
299
+ except Exception as e:
300
+ logger.error(f"Benchmark generate failed: {e}", exc_info=True)
301
+ self.set_status(500)
302
+ self.write({"error": str(e)})
303
+
304
+
305
+ # ─── Available sources (for UI) ───
306
+
307
+
308
+ class BenchmarkSourcesHandler(APIHandler):
309
+ """GET /codebuff/benchmark/sources?collection=guide_docs"""
310
+
311
+ async def get(self):
312
+ collection = self.get_argument("collection", "guide_docs")
313
+ try:
314
+ from .rag_admin_handler import _ensure_manager
315
+
316
+ mgr = _ensure_manager()
317
+ if mgr is None:
318
+ self.write(json.dumps({"sources": []}))
319
+ return
320
+
321
+ client = mgr._client
322
+ sources: Dict[str, int] = {}
323
+ scroll_offset = None
324
+ while True:
325
+ pts, scroll_offset = client.scroll(
326
+ collection_name=collection,
327
+ limit=100,
328
+ with_payload=["source", "title"],
329
+ with_vectors=False,
330
+ offset=scroll_offset,
331
+ )
332
+ for pt in pts:
333
+ p = pt.payload or {}
334
+ src = p.get("source", p.get("title", "unknown"))
335
+ sources[src] = sources.get(src, 0) + 1
336
+ if scroll_offset is None:
337
+ break
338
+
339
+ result = [{"source": s, "chunks": c} for s, c in sorted(sources.items())]
340
+ self.write(json.dumps({"sources": result}, ensure_ascii=False))
341
+
342
+ except Exception as e:
343
+ self.write(json.dumps({"sources": [], "error": str(e)}))
344
+
345
+
346
+ # ─── Run benchmark ───
347
+
348
+
349
+ class BenchmarkRunHandler(APIHandler):
350
+ """POST /codebuff/benchmark/run — SSE stream으로 벤치마크 실행."""
351
+
352
+ async def post(self):
353
+ body = (
354
+ json.loads(self.request.body.decode("utf-8")) if self.request.body else {}
355
+ )
356
+ test_case_ids = body.get("test_case_ids") # None이면 전체
357
+ memo = body.get("memo", "")
358
+
359
+ all_cases = _load_test_cases()
360
+ if test_case_ids:
361
+ cases = [c for c in all_cases if c.get("id") in test_case_ids]
362
+ else:
363
+ cases = all_cases
364
+
365
+ if not cases:
366
+ self.set_status(400)
367
+ self.write({"error": "테스트 케이스가 없습니다."})
368
+ return
369
+
370
+ # SSE 헤더
371
+ self.set_header("Content-Type", "text/event-stream")
372
+ self.set_header("Cache-Control", "no-cache")
373
+ self.set_header("Connection", "keep-alive")
374
+
375
+ from codebuff_core.managers.config_manager import get_config_manager
376
+
377
+ cfg = get_config_manager().get_config()
378
+
379
+ results: List[Dict] = []
380
+ total = len(cases)
381
+ _completed = 0
382
+ _cancelled = False
383
+ _PARALLEL = 5
384
+
385
+ import asyncio as _aio
386
+
387
+ sem = _aio.Semaphore(_PARALLEL)
388
+
389
+ async def _run_one(tc):
390
+ nonlocal _completed, _cancelled
391
+ if _cancelled:
392
+ return None
393
+ async with sem:
394
+ if _cancelled:
395
+ return None
396
+ try:
397
+ r = await self._run_single_case(tc, cfg)
398
+ except Exception as e:
399
+ logger.error(f"Benchmark case {tc['id']} failed: {e}")
400
+ r = {"case_id": tc["id"], "error": str(e), "scores": {}}
401
+ _completed += 1
402
+ results.append(r)
403
+ try:
404
+ self.write(
405
+ f"data: {json.dumps({'type': 'case_result', 'result': r}, ensure_ascii=False, default=str)}\n\n"
406
+ )
407
+ self.write(
408
+ f"data: {json.dumps({'type': 'progress', 'current': _completed, 'total': total})}\n\n"
409
+ )
410
+ await self.flush()
411
+ except Exception:
412
+ _cancelled = True
413
+ return r
414
+
415
+ try:
416
+ await _aio.gather(*[_run_one(tc) for tc in cases])
417
+ except Exception:
418
+ _cancelled = True
419
+
420
+ # 완료된 결과만 저장 (취소 시에도)
421
+ valid_results = [r for r in results if r is not None]
422
+ summary = self._calc_summary(valid_results)
423
+
424
+ full_result = {
425
+ "date": datetime.now().isoformat(),
426
+ "memo": memo + (" (중단됨)" if _cancelled else ""),
427
+ "total_cases": len(valid_results),
428
+ "summary": summary,
429
+ "results": valid_results,
430
+ }
431
+ result_id = _save_result(full_result)
432
+
433
+ try:
434
+ self.write(
435
+ f"data: {json.dumps({'type': 'complete', 'result_id': result_id, 'summary': summary, 'cancelled': _cancelled}, ensure_ascii=False, default=str)}\n\n"
436
+ )
437
+ await self.flush()
438
+ except Exception:
439
+ pass
440
+ self.finish()
441
+
442
+ async def _run_single_case(
443
+ self, tc: Dict[str, Any], cfg: Dict[str, Any]
444
+ ) -> Dict[str, Any]:
445
+ """단일 테스트 케이스 실행 + 평가."""
446
+ from codebuff_core.services.rag_chat_agent import RAGChatAgent
447
+
448
+ llm_cfg = {"provider": "vllm", "vllm": cfg.get("vllm", {})}
449
+
450
+ # knowledge chat과 동일한 프롬프트 사용 — 에이전트가 knowledge 빌더와 centri
451
+ # `prompts.chat_rag_prompt`(grounding)를 직접 읽는다. `chat_system_prompt` 는
452
+ # simple 모드 키라 여기에 싣지 않는다 (이슈 #178)
453
+ # 1. Knowledge Chat으로 질문 실행
454
+ thread_id = f"benchmark-{uuid.uuid4().hex[:8]}"
455
+ from langgraph.checkpoint.memory import MemorySaver
456
+
457
+ agent = RAGChatAgent(
458
+ llm_cfg,
459
+ checkpointer=MemorySaver(),
460
+ )
461
+ response_text = ""
462
+ search_count = 0
463
+ start_time = time.time()
464
+
465
+ async for chunk in agent.stream_response(
466
+ user_message=tc["question"],
467
+ thread_id=thread_id,
468
+ ):
469
+ if isinstance(chunk, dict):
470
+ if chunk.get("content"):
471
+ response_text += chunk["content"]
472
+ if chunk.get("status") and "검색 완료" in chunk.get("status", ""):
473
+ search_count += 1
474
+
475
+ # agent state에서 ToolMessage 추출 (compression 적용 — LLM이 실제로 본 것)
476
+ rag_results_text = ""
477
+ _REFLECTION_MARKER = "[검색 결과 분석 지시]"
478
+ try:
479
+ from langchain_core.messages import ToolMessage as _TM
480
+ from codebuff_core.services.rag_chat_agent import (
481
+ _create_context_compression_middleware_class,
482
+ )
483
+
484
+ if agent._agent is not None:
485
+ _config = {"configurable": {"thread_id": thread_id}}
486
+ _state = await agent._agent.aget_state(_config)
487
+ _raw_msgs = list(_state.values.get("messages", []))
488
+
489
+ # compression 적용 (LLM이 실제로 본 compressed 버전)
490
+ _compressor = _create_context_compression_middleware_class()()
491
+ _compressed = _compressor.before_model({"messages": _raw_msgs})
492
+ _msgs = _compressed["messages"] if _compressed else _raw_msgs
493
+
494
+ for _msg in _msgs:
495
+ if isinstance(_msg, _TM) and _msg.content:
496
+ _content = _msg.content
497
+ if _REFLECTION_MARKER in _content:
498
+ _content = _content[: _content.index(_REFLECTION_MARKER)]
499
+ if _content.strip():
500
+ rag_results_text += _content[:8000] + "\n"
501
+ search_count += 1
502
+ except Exception as _state_err:
503
+ logger.warning(f"[Benchmark] {tc['id']}: aget_state failed: {_state_err}")
504
+
505
+ # fallback: 검색 결과를 못 가져왔으면 응답 자체를 근거로 사용 (평가 정확도 저하 방지)
506
+ if not rag_results_text and response_text:
507
+ rag_results_text = (
508
+ "[검색 결과를 직접 추출하지 못했습니다. "
509
+ "아래 응답 내용에서 출처 표기를 참고하여 faithfulness를 평가하세요.]\n"
510
+ + response_text[:4000]
511
+ )
512
+ logger.warning(
513
+ f"[Benchmark] {tc['id']}: using response as fallback evidence"
514
+ )
515
+
516
+ # search_count fallback: ToolMessage 수로 추정
517
+ if search_count == 0 and rag_results_text:
518
+ search_count = rag_results_text.count('"success"')
519
+
520
+ elapsed = round(time.time() - start_time, 1)
521
+ re_search_count = max(search_count - 1, 0)
522
+
523
+ # 2. LLM 기반 평가 (검색 결과 포함)
524
+ scores = await self._evaluate(tc, response_text, rag_results_text, cfg)
525
+ scores["response_time"] = elapsed
526
+ scores["search_count"] = search_count
527
+ scores["re_search_count"] = re_search_count
528
+
529
+ # effort bonus: 재검색 시도에 대한 가산 (max +5)
530
+ effort_bonus = min(re_search_count, 2) * 2.5
531
+ scores["effort_bonus"] = effort_bonus
532
+ scores["overall"] = min(round(scores.get("overall", 0) + effort_bonus, 1), 100)
533
+
534
+ # Bad test case auto-detection: 검색 5회 이상 + 점수 30 미만 → 데이터 불일치
535
+ data_mismatch = search_count >= 5 and scores.get("overall", 0) < 30
536
+ if data_mismatch:
537
+ logger.info(
538
+ "[Benchmark] %s: data_mismatch detected "
539
+ "(search_count=%d, overall=%.1f)",
540
+ tc["id"],
541
+ search_count,
542
+ scores.get("overall", 0),
543
+ )
544
+
545
+ result = {
546
+ "case_id": tc["id"],
547
+ "question": tc["question"],
548
+ "response": response_text,
549
+ "scores": scores,
550
+ }
551
+ if data_mismatch:
552
+ result["data_mismatch"] = True
553
+ result["data_mismatch_reason"] = (
554
+ f"검색 {search_count}회 시도했으나 점수 {scores.get('overall', 0):.1f}/100. "
555
+ "테스트 케이스의 질문이 현재 RAG 데이터와 맞지 않을 수 있습니다."
556
+ )
557
+ return result
558
+
559
+ async def _fetch_source_content(self, source: str, collection: str) -> str:
560
+ """테스트 케이스의 source 문서 원문을 Qdrant에서 직접 가져옴."""
561
+ try:
562
+ from .rag_admin_handler import _ensure_manager
563
+
564
+ mgr = _ensure_manager()
565
+ if mgr is None:
566
+ return ""
567
+
568
+ client = mgr._client
569
+ chunks: List[str] = []
570
+ scroll_offset = None
571
+ while True:
572
+ pts, scroll_offset = client.scroll(
573
+ collection_name=collection,
574
+ limit=100,
575
+ with_payload=True,
576
+ with_vectors=False,
577
+ offset=scroll_offset,
578
+ )
579
+ for pt in pts:
580
+ p = pt.payload or {}
581
+ src = p.get("source", p.get("title", ""))
582
+ # 정확 매칭 또는 부분 매칭 (source 필드에 파일명 포함)
583
+ if src == source or source in src or src in source:
584
+ content = p.get("content", "")
585
+ section = p.get("section", "")
586
+ if content:
587
+ header = f"[{section}] " if section else ""
588
+ chunks.append(f"{header}{content}")
589
+ if scroll_offset is None:
590
+ break
591
+
592
+ print(
593
+ f"[Benchmark] _fetch_source_content: "
594
+ f"source={source!r} collection={collection!r} "
595
+ f"chunks={len(chunks)}",
596
+ flush=True,
597
+ )
598
+ return "\n\n---\n\n".join(chunks)
599
+ except Exception as e:
600
+ print(
601
+ f"[Benchmark] _fetch_source_content FAILED: {e}",
602
+ flush=True,
603
+ )
604
+ return ""
605
+
606
+ async def _evaluate(
607
+ self,
608
+ tc: Dict[str, Any],
609
+ response: str,
610
+ rag_results: str,
611
+ cfg: Dict[str, Any],
612
+ ) -> Dict[str, Any]:
613
+ """LLM 기반 평가: Faithfulness + Quality.
614
+
615
+ 평가 근거로 에이전트 검색 결과 + 원본 문서 내용을 모두 사용.
616
+ 원본 문서를 직접 참조하여 faithfulness 오판을 방지.
617
+ """
618
+ from codebuff_core.llm.service import LLMService
619
+
620
+ llm_cfg = cfg.get("vllm", {})
621
+ llm = LLMService({"provider": "vllm", "vllm": llm_cfg})
622
+
623
+ # 원본 문서 내용 가져오기 (테스트 케이스의 source 필드 활용)
624
+ source_content = ""
625
+ tc_source = tc.get("source", "")
626
+ tc_collection = tc.get("category", "guide_docs")
627
+ if tc_source:
628
+ source_content = await self._fetch_source_content(tc_source, tc_collection)
629
+
630
+ # 평가 근거: 원본 문서 + 에이전트 검색 결과 결합
631
+ print(
632
+ f"[Benchmark] _evaluate: tc_source={tc_source!r} "
633
+ f"source_content_len={len(source_content)} "
634
+ f"rag_results_len={len(rag_results)}",
635
+ flush=True,
636
+ )
637
+ if source_content and rag_results:
638
+ evidence = (
639
+ f"[원본 문서 ({tc_source})]\n{source_content[:8000]}\n\n"
640
+ f"[에이전트 검색 결과]\n{rag_results[:12000]}"
641
+ )
642
+ elif source_content:
643
+ evidence = f"[원본 문서 ({tc_source})]\n{source_content[:12000]}"
644
+ else:
645
+ evidence = f"[에이전트 검색 결과]\n{rag_results[:16000]}"
646
+
647
+ eval_prompt = (
648
+ "아래 [질문], [근거 자료], [기대 답변], [실제 응답]을 비교 평가하세요.\n\n"
649
+ f"[질문]\n{tc['question']}\n\n"
650
+ f"[근거 자료]\n{evidence}\n\n"
651
+ f"[기대 답변]\n{tc.get('ground_truth', '없음')}\n\n"
652
+ f"[실제 응답]\n{response[:3000]}\n\n"
653
+ "평가 항목 (각 1~10점):\n"
654
+ "- correctness: 기대 답변과의 일치도\n"
655
+ "- completeness: 답변 완성도\n"
656
+ "- faithfulness: 실제 응답이 [근거 자료]에 있는 내용을 바탕으로 답변했는지. "
657
+ "[근거 자료]에 없는 내용을 만들어냈으면 감점\n"
658
+ "- conciseness: 핵심만 간결하게 답했는지\n\n"
659
+ "할루시네이션 판정: 실제 응답의 각 주장이 [근거 자료]에 근거가 있는지 확인하세요. "
660
+ "[근거 자료]에 근거 없는 주장을 hallucinated_claims에 나열하세요.\n\n"
661
+ "반드시 아래 JSON만 출력:\n"
662
+ '{"correctness": N, "completeness": N, "faithfulness": N, '
663
+ '"conciseness": N, "hallucinated_claims": ["근거 자료에 없는 주장"], '
664
+ '"comment": "평가 의견"}'
665
+ )
666
+
667
+ import re as _re
668
+
669
+ for attempt in range(2):
670
+ try:
671
+ resp = await llm.generate_response(eval_prompt)
672
+ resp = resp.strip()
673
+ # JSON 추출: 코드블록 또는 raw JSON
674
+ if resp.startswith("```"):
675
+ resp = resp.split("```")[1]
676
+ if resp.startswith("json"):
677
+ resp = resp[4:]
678
+ # 텍스트 내 JSON 객체 추출 (fallback)
679
+ json_match = _re.search(r"\{[^{}]*(?:\[[^\[\]]*\])?[^{}]*\}", resp)
680
+ if json_match:
681
+ resp = json_match.group(0)
682
+ parsed = json.loads(resp)
683
+ overall = round(
684
+ (
685
+ parsed.get("correctness", 5)
686
+ + parsed.get("completeness", 5)
687
+ + parsed.get("faithfulness", 5)
688
+ + parsed.get("conciseness", 5)
689
+ )
690
+ / 4
691
+ * 10,
692
+ 1,
693
+ )
694
+ parsed["overall"] = overall
695
+
696
+ # 전항목 0점 감지 → 평가 실패로 간주, 재시도
697
+ all_zero = all(
698
+ parsed.get(k, 0) == 0
699
+ for k in (
700
+ "correctness",
701
+ "completeness",
702
+ "faithfulness",
703
+ "conciseness",
704
+ )
705
+ )
706
+ if all_zero and attempt == 0:
707
+ logger.warning("Evaluation returned all zeros, retrying")
708
+ continue
709
+ if all_zero:
710
+ parsed["parse_error"] = True
711
+ parsed["comment"] = (
712
+ parsed.get("comment", "") + " [전항목 0점 — 평가 실패 가능성]"
713
+ )
714
+
715
+ return parsed
716
+ except Exception as e:
717
+ if attempt == 0:
718
+ logger.warning(f"Evaluation parse failed, retrying: {e}")
719
+ continue
720
+ logger.warning(f"Evaluation failed after retry: {e}")
721
+ return {
722
+ "correctness": 5,
723
+ "completeness": 5,
724
+ "faithfulness": 5,
725
+ "conciseness": 5,
726
+ "overall": 50.0,
727
+ "hallucinated_claims": [],
728
+ "comment": f"평가 파싱 실패 (기본값 적용): {e}",
729
+ "parse_error": True,
730
+ }
731
+
732
+ def _calc_summary(self, results: List[Dict]) -> Dict[str, Any]:
733
+ """전체 결과 요약."""
734
+ valid = [r for r in results if r.get("scores") and not r["scores"].get("error")]
735
+ if not valid:
736
+ return {}
737
+
738
+ def avg(key):
739
+ vals = [r["scores"].get(key, 0) for r in valid if r["scores"].get(key)]
740
+ return round(sum(vals) / len(vals), 2) if vals else 0
741
+
742
+ halluc_counts = []
743
+ for r in valid:
744
+ h = r["scores"].get("hallucinated_claims", [])
745
+ halluc_counts.append(len(h) if isinstance(h, list) else 0)
746
+
747
+ data_mismatch_cases = sum(1 for r in results if r.get("data_mismatch"))
748
+
749
+ return {
750
+ "overall": avg("overall"),
751
+ "correctness": avg("correctness"),
752
+ "completeness": avg("completeness"),
753
+ "faithfulness": avg("faithfulness"),
754
+ "conciseness": avg("conciseness"),
755
+ "avg_response_time": avg("response_time"),
756
+ "total_hallucinated_claims": sum(halluc_counts),
757
+ "cases_with_hallucination": sum(1 for c in halluc_counts if c > 0),
758
+ "total_evaluated": len(valid),
759
+ "data_mismatch_cases": data_mismatch_cases,
760
+ }
761
+
762
+
763
+ # ─── Results ───
764
+
765
+
766
+ class BenchmarkResultsHandler(APIHandler):
767
+ """GET/POST /codebuff/benchmark/results — 실행 결과 목록, 상세, 삭제."""
768
+
769
+ async def get(self):
770
+ result_id = self.get_argument("id", None)
771
+ if result_id:
772
+ detail = _load_result_detail(result_id)
773
+ if detail:
774
+ self.write(json.dumps(detail, ensure_ascii=False, default=str))
775
+ else:
776
+ self.set_status(404)
777
+ self.write({"error": "결과를 찾을 수 없습니다."})
778
+ else:
779
+ self.write(json.dumps(_load_results(), ensure_ascii=False, default=str))
780
+
781
+ async def post(self):
782
+ body = (
783
+ json.loads(self.request.body.decode("utf-8")) if self.request.body else {}
784
+ )
785
+ action = body.get("action")
786
+ if action == "delete":
787
+ ids = body.get("ids", [])
788
+ deleted = 0
789
+ for rid in ids:
790
+ path = _RESULTS_DIR / f"{rid}.json"
791
+ if path.exists():
792
+ path.unlink()
793
+ deleted += 1
794
+ self.write({"status": "ok", "deleted": deleted})