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,2673 @@
1
+ """
2
+ Jupyter 노트북 조작을 위한 LangChain 호환 도구 모음.
3
+
4
+ NotebookContextManager를 래핑하여 LangChain Agent에서 사용 가능한
5
+ LangChain BaseTool 인터페이스를 제공합니다.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import fnmatch
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ from contextvars import ContextVar
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from codebuff_core.directory_policy import SEARCH_EXCLUDED_DIRS
21
+ from langchain_core.tools import BaseTool, tool
22
+
23
+ from ..tools.context import NotebookContextManager
24
+ from .codex_autofix import RUFF_FIX, ruff_command
25
+
26
+ # enterprise_tables 접근 제한 키워드 (모듈 레벨 1회 컴파일)
27
+ _ENTERPRISE_KEYWORDS = re.compile(
28
+ r"전사|처리계|정보계|채널계|엔터프라이즈|enterprise|레거시|idc"
29
+ r"|adw|bcv|cms|dpweb|m포인트몰|빅쿼리|bq|bigquery"
30
+ r"|원천|dw|메타샵|meta샵|meta#",
31
+ re.IGNORECASE,
32
+ )
33
+
34
+ # 사용자 원본 메시지와 EDA 제한은 요청마다 달라진다. 모듈 전역 문자열이면 동시에
35
+ # 실행되는 Chat/Q&A가 서로의 검색 범위를 바꾸므로 비동기 컨텍스트에 격리한다.
36
+ _last_user_message: ContextVar[str] = ContextVar("last_user_message", default="")
37
+ _eda_only_bdp: ContextVar[bool] = ContextVar("eda_only_bdp", default=False)
38
+
39
+
40
+ def set_last_user_message(msg: str) -> None:
41
+ """사용자 원본 메시지 저장 (rag_chat_agent에서 호출)."""
42
+ _last_user_message.set(msg)
43
+
44
+
45
+ def set_eda_only_bdp(enabled: bool) -> None:
46
+ """EDA 모드: enterprise_tables → bdp_tables 강제 치환."""
47
+ _eda_only_bdp.set(enabled)
48
+
49
+
50
+ def create_jupyter_cell_tool(context: NotebookContextManager) -> BaseTool:
51
+ """
52
+ Jupyter 셀 조작용 LangChain 도구를 생성합니다.
53
+
54
+ Args:
55
+ context: 노트북 상태 접근을 위한 NotebookContextManager
56
+
57
+ Returns:
58
+ 셀 조작용 LangChain BaseTool
59
+ """
60
+
61
+ @tool
62
+ def jupyter_cell(
63
+ code: Optional[str] = None,
64
+ description: Optional[str] = None,
65
+ execution_result: Optional[dict] = None,
66
+ ) -> str:
67
+ """Jupyter 노트북에 코드 셀을 생성하고 실행합니다.
68
+
69
+ Args:
70
+ code: 실행할 Python 코드
71
+ description: 코드 설명 (선택)
72
+ execution_result: 프론트엔드에서 전달된 실행 결과 (HITL edit decision 경유)
73
+
74
+ Returns:
75
+ 연산 결과가 담긴 JSON 문자열
76
+ """
77
+ # 마크다운 펜스 정리
78
+ cleaned_code = (code or "").strip()
79
+ if cleaned_code.startswith("```python"):
80
+ cleaned_code = cleaned_code[9:]
81
+ elif cleaned_code.startswith("```"):
82
+ cleaned_code = cleaned_code[3:]
83
+ if cleaned_code.endswith("```"):
84
+ cleaned_code = cleaned_code[:-3]
85
+ cleaned_code = cleaned_code.strip()
86
+
87
+ response = {
88
+ "tool": "jupyter_cell",
89
+ "parameters": {"code": cleaned_code, "description": description},
90
+ "status": "pending_execution",
91
+ "message": "Code cell queued for execution",
92
+ }
93
+ if execution_result is not None:
94
+ response["execution_result"] = execution_result
95
+ response["status"] = "complete"
96
+ response["message"] = "Code cell executed with client-reported results"
97
+ return json.dumps(response)
98
+
99
+ return jupyter_cell
100
+
101
+
102
+ def create_jupyter_markdown_tool(context: NotebookContextManager) -> BaseTool:
103
+ """
104
+ Jupyter 마크다운 셀 조작용 LangChain 도구를 생성합니다.
105
+
106
+ Args:
107
+ context: 노트북 상태 접근을 위한 NotebookContextManager
108
+
109
+ Returns:
110
+ 마크다운 조작용 LangChain BaseTool
111
+ """
112
+
113
+ @tool
114
+ def jupyter_markdown(
115
+ operation: str,
116
+ content: str,
117
+ cell_index: Optional[int] = None,
118
+ insert_after: Optional[int] = None,
119
+ ) -> str:
120
+ """
121
+ Jupyter 노트북에서 마크다운 셀을 생성하거나 수정합니다.
122
+
123
+ 문서화, 설명, 헤더 추가에 사용합니다.
124
+
125
+ Args:
126
+ operation: 수행할 연산 (CREATE 또는 UPDATE)
127
+ content: 마크다운 내용
128
+ cell_index: 셀 인덱스 (UPDATE용)
129
+ insert_after: 이 인덱스 뒤에 새 셀 삽입 (CREATE용)
130
+
131
+ Returns:
132
+ 연산 결과가 담긴 JSON 문자열
133
+ """
134
+ # HITL 모드: 실제 마크다운 셀 추가는 프론트엔드에서 처리됨.
135
+ return json.dumps(
136
+ {
137
+ "success": True,
138
+ "message": "마크다운 셀이 노트북에 추가되었습니다.",
139
+ }
140
+ )
141
+
142
+ return jupyter_markdown
143
+
144
+
145
+ def create_notebook_info_tool(context: NotebookContextManager) -> BaseTool:
146
+ """
147
+ 노트북 정보 조회용 LangChain 도구를 생성합니다.
148
+
149
+ Args:
150
+ context: 노트북 상태 접근을 위한 NotebookContextManager
151
+
152
+ Returns:
153
+ 노트북 정보 조회용 LangChain BaseTool
154
+ """
155
+
156
+ @tool
157
+ def notebook_info() -> str:
158
+ """
159
+ 현재 Jupyter 노트북의 정보를 가져옵니다.
160
+
161
+ 경로, 셀 개수, 커널 정보를 포함한 요약을 반환합니다.
162
+
163
+ Returns:
164
+ 노트북 요약이 담긴 JSON 문자열
165
+ """
166
+ # NotebookContextManager가 설정되어 있으면 요약 반환, 아니면 기본값
167
+ if context.current_notebook:
168
+ try:
169
+ summary = context.get_summary()
170
+ return json.dumps({"success": True, "data": summary})
171
+ except Exception as e:
172
+ return json.dumps({"success": False, "error": str(e)})
173
+ return json.dumps(
174
+ {
175
+ "success": True,
176
+ "data": {
177
+ "path": "unknown",
178
+ "cell_count": 0,
179
+ "kernel_name": "python3",
180
+ },
181
+ }
182
+ )
183
+
184
+ return notebook_info
185
+
186
+
187
+ # =============================================================================
188
+ # 파일 도구
189
+ # =============================================================================
190
+
191
+
192
+ def _walk(root: Path, skip_hidden: bool):
193
+ """root 아래 (경로, 디렉토리 여부) 를 낸다. 제외 디렉토리는 **보이되 들어가지 않는다**.
194
+
195
+ 판정은 root 기준 상대 이름으로 한다 — 워크트리처럼 root 경로 자체에 점
196
+ 디렉토리가 끼어 있어도 전부 걸러지지 않게 (이슈 #100). rglob 뒤에 거르면
197
+ node_modules 를 끝까지 순회하므로 os.walk 가지치기로 막는다.
198
+ """
199
+ for dirpath, dirnames, filenames in os.walk(root):
200
+ base = Path(dirpath)
201
+ for d in dirnames:
202
+ if not (skip_hidden and d.startswith(".")):
203
+ yield base / d, True
204
+ dirnames[:] = [
205
+ d
206
+ for d in dirnames
207
+ if d not in SEARCH_EXCLUDED_DIRS and not (skip_hidden and d.startswith("."))
208
+ ]
209
+ for name in filenames:
210
+ if not (skip_hidden and name.startswith(".")):
211
+ yield base / name, False
212
+
213
+
214
+ def _glob_regex(pattern: str) -> "re.Pattern[str]":
215
+ """rglob(pattern) 과 같은 뜻의 정규식 — `**` 는 0단 이상, 앞은 어느 깊이든.
216
+
217
+ `PurePath.match` 는 `**` 를 한 구성요소로 읽어 `**/*.py` 가 루트 파일을
218
+ 놓친다 (이슈 #100 셀프리뷰).
219
+ """
220
+ out, i = [], 0
221
+ while i < len(pattern):
222
+ if pattern.startswith("**/", i):
223
+ out.append("(?:.*/)?")
224
+ i += 3
225
+ elif pattern.startswith("**", i):
226
+ out.append(".*")
227
+ i += 2
228
+ elif pattern[i] == "*":
229
+ out.append("[^/]*")
230
+ i += 1
231
+ elif pattern[i] == "?":
232
+ out.append("[^/]")
233
+ i += 1
234
+ elif pattern[i] == "[" and pattern.find("]", i + 2) > i:
235
+ j = pattern.find("]", i + 2)
236
+ body = pattern[i + 1 : j]
237
+ if body.startswith("!"):
238
+ body = "^" + body[1:]
239
+ out.append("[" + body.replace("\\", "\\\\") + "]")
240
+ i = j + 1
241
+ else:
242
+ out.append(re.escape(pattern[i]))
243
+ i += 1
244
+ return re.compile("(?:.*/)?" + "".join(out) + r"\Z")
245
+
246
+
247
+ def _matches(path: Path, root: Path, pattern: str) -> bool:
248
+ if "/" in pattern:
249
+ rel = path.relative_to(root).as_posix()
250
+ return _glob_regex(pattern).match(rel) is not None
251
+ return fnmatch.fnmatch(path.name, pattern)
252
+
253
+
254
+ def create_file_read_tool(workspace_root: str) -> BaseTool:
255
+ """
256
+ 파일 내용 읽기용 LangChain 도구를 생성합니다.
257
+
258
+ Args:
259
+ workspace_root: 파일 작업의 루트 디렉토리
260
+
261
+ Returns:
262
+ 파일 읽기용 LangChain BaseTool
263
+ """
264
+ base_path = Path(workspace_root)
265
+
266
+ @tool
267
+ def file_read(path: str, encoding: str = "utf-8") -> str:
268
+ """
269
+ 파일 내용을 읽습니다.
270
+
271
+ 파일 내용을 텍스트로 반환합니다. 바이너리 파일은 오류를 반환합니다.
272
+
273
+ Args:
274
+ path: 읽을 파일 경로 (워크스페이스 기준 상대 경로)
275
+ encoding: 파일 인코딩 (기본값: utf-8)
276
+
277
+ Returns:
278
+ 파일 내용 또는 오류가 담긴 JSON 문자열
279
+ """
280
+ if not path:
281
+ return json.dumps({"success": False, "error": "경로가 필요합니다"})
282
+
283
+ try:
284
+ file_path = base_path / path
285
+ if not file_path.exists():
286
+ return json.dumps(
287
+ {"success": False, "error": f"파일을 찾을 수 없음: {path}"}
288
+ )
289
+
290
+ if not file_path.is_file():
291
+ return json.dumps({"success": False, "error": f"파일이 아님: {path}"})
292
+
293
+ content = file_path.read_text(encoding=encoding)
294
+ return json.dumps(
295
+ {
296
+ "success": True,
297
+ "data": {"content": content, "path": str(path)},
298
+ "metadata": {"size": len(content), "encoding": encoding},
299
+ }
300
+ )
301
+
302
+ except UnicodeDecodeError:
303
+ return json.dumps(
304
+ {"success": False, "error": f"텍스트로 읽을 수 없는 파일: {path}"}
305
+ )
306
+ except Exception as e:
307
+ return json.dumps({"success": False, "error": f"파일 읽기 오류: {e}"})
308
+
309
+ return file_read
310
+
311
+
312
+ def create_file_write_tool(workspace_root: str) -> BaseTool:
313
+ """
314
+ 파일 내용 쓰기용 LangChain 도구를 생성합니다.
315
+
316
+ Args:
317
+ workspace_root: 파일 작업의 루트 디렉토리
318
+
319
+ Returns:
320
+ 파일 쓰기용 LangChain BaseTool
321
+ """
322
+ base_path = Path(workspace_root)
323
+
324
+ @tool
325
+ def file_write(path: str, content: str, append: bool = False) -> str:
326
+ """
327
+ 파일에 내용을 씁니다.
328
+
329
+ 파일이 없으면 생성합니다. 필요시 상위 디렉토리도 생성합니다.
330
+ Python 파일(.py)은 자동으로 ruff check --fix(안 쓰인 import 는 남김)와 ruff format을 실행합니다.
331
+
332
+ 경고: 이 도구는 파일을 수정할 수 있습니다. HITL 승인이 필요합니다.
333
+
334
+ Args:
335
+ path: 쓸 파일 경로 (워크스페이스 기준 상대 경로)
336
+ content: 쓸 내용
337
+ append: 덮어쓰기 대신 추가 (기본값: False)
338
+
339
+ Returns:
340
+ 연산 결과가 담긴 JSON 문자열
341
+ """
342
+ if not path:
343
+ return json.dumps({"success": False, "error": "경로가 필요합니다"})
344
+
345
+ try:
346
+ file_path = base_path / path
347
+
348
+ # 필요시 상위 디렉토리 생성
349
+ file_path.parent.mkdir(parents=True, exist_ok=True)
350
+
351
+ mode = "a" if append else "w"
352
+ with open(file_path, mode, encoding="utf-8") as f:
353
+ f.write(content)
354
+
355
+ result = {
356
+ "success": True,
357
+ "data": {
358
+ "path": str(path),
359
+ "operation": "append" if append else "write",
360
+ },
361
+ "metadata": {"size": len(content)},
362
+ }
363
+
364
+ # Python 파일은 자동으로 ruff 실행
365
+ if path.endswith(".py"):
366
+ # PATH → wheel 이 깐 ruff 순 (이슈 #145). 둘 다 없으면 아래 FileNotFoundError
367
+ ruff = ruff_command() or ["ruff"]
368
+ try:
369
+ ruff_fix = subprocess.run(
370
+ [*ruff, *RUFF_FIX, str(file_path)],
371
+ capture_output=True,
372
+ timeout=30,
373
+ )
374
+ ruff_fmt = subprocess.run(
375
+ [*ruff, "format", str(file_path)],
376
+ capture_output=True,
377
+ timeout=30,
378
+ )
379
+ result["metadata"]["ruff_applied"] = True
380
+ result["metadata"]["ruff_fix_returncode"] = ruff_fix.returncode
381
+ result["metadata"]["ruff_format_returncode"] = ruff_fmt.returncode
382
+ except FileNotFoundError:
383
+ result["metadata"]["ruff_applied"] = False
384
+ result["metadata"]["ruff_note"] = "ruff가 설치되지 않음"
385
+ except subprocess.TimeoutExpired:
386
+ result["metadata"]["ruff_applied"] = False
387
+ result["metadata"]["ruff_note"] = "ruff 시간 초과"
388
+
389
+ return json.dumps(result)
390
+
391
+ except Exception as e:
392
+ return json.dumps({"success": False, "error": f"파일 쓰기 오류: {e}"})
393
+
394
+ return file_write
395
+
396
+
397
+ def create_file_list_tool(workspace_root: str) -> BaseTool:
398
+ """
399
+ 디렉토리 목록 조회용 LangChain 도구를 생성합니다.
400
+
401
+ Args:
402
+ workspace_root: 파일 작업의 루트 디렉토리
403
+
404
+ Returns:
405
+ 디렉토리 목록 조회용 LangChain BaseTool
406
+ """
407
+ base_path = Path(workspace_root)
408
+
409
+ @tool
410
+ def file_list(
411
+ path: str = ".", recursive: bool = False, pattern: Optional[str] = None
412
+ ) -> str:
413
+ """
414
+ 경로의 파일과 디렉토리를 나열합니다.
415
+
416
+ 파일/디렉토리 이름과 타입 목록을 반환합니다.
417
+
418
+ Args:
419
+ path: 나열할 디렉토리 경로 (워크스페이스 기준 상대 경로, 기본값: ".")
420
+ recursive: 재귀적으로 나열 (기본값: False)
421
+ pattern: 파일 필터링용 글롭 패턴 (예: '*.py')
422
+
423
+ Returns:
424
+ 항목 목록이 담긴 JSON 문자열
425
+ """
426
+ try:
427
+ dir_path = base_path / path
428
+ if not dir_path.exists():
429
+ return json.dumps(
430
+ {"success": False, "error": f"경로를 찾을 수 없음: {path}"}
431
+ )
432
+
433
+ if not dir_path.is_dir():
434
+ return json.dumps(
435
+ {"success": False, "error": f"디렉토리가 아님: {path}"}
436
+ )
437
+
438
+ entries = []
439
+
440
+ if recursive:
441
+ files = (
442
+ p
443
+ for p, _ in _walk(dir_path, skip_hidden=False)
444
+ if not pattern or _matches(p, dir_path, pattern)
445
+ )
446
+ else:
447
+ files = dir_path.glob(pattern) if pattern else dir_path.iterdir()
448
+
449
+ for entry in files:
450
+ try:
451
+ rel_path = entry.relative_to(base_path)
452
+ entries.append(
453
+ {
454
+ "name": entry.name,
455
+ "path": str(rel_path),
456
+ "type": "directory" if entry.is_dir() else "file",
457
+ "size": entry.stat().st_size if entry.is_file() else None,
458
+ }
459
+ )
460
+ except ValueError:
461
+ # base_path 외부 항목은 건너뜀
462
+ pass
463
+
464
+ return json.dumps(
465
+ {
466
+ "success": True,
467
+ "data": {"entries": entries, "path": str(path)},
468
+ "metadata": {"count": len(entries)},
469
+ }
470
+ )
471
+
472
+ except Exception as e:
473
+ return json.dumps(
474
+ {"success": False, "error": f"디렉토리 목록 조회 오류: {e}"}
475
+ )
476
+
477
+ return file_list
478
+
479
+
480
+ # =============================================================================
481
+ # 검색 도구
482
+ # =============================================================================
483
+
484
+
485
+ def create_search_code_tool(workspace_root: str) -> BaseTool:
486
+ """
487
+ 코드 검색용 LangChain 도구를 생성합니다.
488
+
489
+ Args:
490
+ workspace_root: 검색 작업의 루트 디렉토리
491
+
492
+ Returns:
493
+ 코드 검색용 LangChain BaseTool
494
+ """
495
+ base_path = Path(workspace_root)
496
+
497
+ @tool
498
+ def search_code(
499
+ pattern: str,
500
+ path: str = ".",
501
+ file_pattern: str = "*",
502
+ max_results: int = 50,
503
+ case_sensitive: bool = True,
504
+ ) -> str:
505
+ """
506
+ 코드 파일에서 패턴을 검색합니다.
507
+
508
+ 정규식 패턴을 사용하여 파일들에서 일치 항목을 찾습니다.
509
+ 파일 경로와 줄 번호가 포함된 일치하는 줄을 반환합니다.
510
+
511
+ Args:
512
+ pattern: 검색할 정규식 패턴
513
+ path: 검색할 디렉토리 (워크스페이스 기준 상대 경로, 기본값: ".")
514
+ file_pattern: 검색할 파일의 글롭 패턴 (예: '*.py')
515
+ max_results: 반환할 최대 결과 수 (기본값: 50)
516
+ case_sensitive: 대소문자 구분 검색 (기본값: True)
517
+
518
+ Returns:
519
+ 일치하는 줄이 담긴 JSON 문자열
520
+ """
521
+ if not pattern:
522
+ return json.dumps({"success": False, "error": "패턴이 필요합니다"})
523
+
524
+ try:
525
+ flags = 0 if case_sensitive else re.IGNORECASE
526
+ regex = re.compile(pattern, flags)
527
+ except re.error as e:
528
+ return json.dumps({"success": False, "error": f"잘못된 정규식 패턴: {e}"})
529
+
530
+ search_path = base_path / path
531
+ if not search_path.exists():
532
+ return json.dumps(
533
+ {"success": False, "error": f"경로를 찾을 수 없음: {path}"}
534
+ )
535
+
536
+ results = []
537
+ try:
538
+ for file_path, is_dir in _walk(search_path, skip_hidden=True):
539
+ if is_dir or not _matches(file_path, search_path, file_pattern):
540
+ continue
541
+ # FIFO·소켓을 read_text 하면 쓰는 쪽이 올 때까지 막힌다
542
+ if not file_path.is_file():
543
+ continue
544
+
545
+ try:
546
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
547
+ for line_num, line in enumerate(content.splitlines(), 1):
548
+ if regex.search(line):
549
+ rel_path = file_path.relative_to(base_path)
550
+ results.append(
551
+ {
552
+ "file": str(rel_path),
553
+ "line": line_num,
554
+ "content": line.strip()[:200], # 긴 줄은 잘라냄
555
+ }
556
+ )
557
+
558
+ if len(results) >= max_results:
559
+ break
560
+ except Exception:
561
+ # 읽을 수 없는 파일은 건너뜀
562
+ continue
563
+
564
+ if len(results) >= max_results:
565
+ break
566
+
567
+ return json.dumps(
568
+ {
569
+ "success": True,
570
+ "data": {"matches": results, "pattern": pattern},
571
+ "metadata": {
572
+ "count": len(results),
573
+ "truncated": len(results) >= max_results,
574
+ },
575
+ }
576
+ )
577
+
578
+ except Exception as e:
579
+ return json.dumps({"success": False, "error": f"검색 오류: {e}"})
580
+
581
+ return search_code
582
+
583
+
584
+ # =============================================================================
585
+ # LSP 도구 (Grep 기반 대체 구현)
586
+ # =============================================================================
587
+
588
+
589
+ def create_diagnostics_tool(workspace_root: str) -> BaseTool:
590
+ """
591
+ 코드 진단용 LangChain 도구를 생성합니다.
592
+
593
+ 정적 분석 도구(ruff, mypy)가 있으면 사용하고,
594
+ 없으면 기본 패턴 기반 진단을 제공하는 Python 기반 구현입니다.
595
+
596
+ Args:
597
+ workspace_root: 분석의 루트 디렉토리
598
+
599
+ Returns:
600
+ 진단용 LangChain BaseTool
601
+ """
602
+ base_path = Path(workspace_root)
603
+
604
+ @tool
605
+ def diagnostics(
606
+ path: Optional[str] = None, severity_filter: Optional[str] = None
607
+ ) -> str:
608
+ """
609
+ 파일 또는 전체 프로젝트의 코드 진단(오류, 경고)을 가져옵니다.
610
+
611
+ Python 파일의 문제를 탐지하기 위해 ruff를 실행합니다.
612
+ ruff가 없으면 기본 패턴 기반 검사로 대체합니다.
613
+
614
+ 이 도구의 용도:
615
+ - 코드 실행 전 문법 오류 확인
616
+ - Python 파일의 타입 오류 및 린트 문제 찾기
617
+ - 사용하지 않는 import나 변수 식별
618
+
619
+ Args:
620
+ path: 선택적 파일 경로. 미지정시 프로젝트 루트 검사.
621
+ severity_filter: 선택적 필터 ('error', 'warning')
622
+
623
+ Returns:
624
+ 진단 결과가 담긴 JSON 문자열
625
+ """
626
+ target = base_path / path if path else base_path
627
+ results = []
628
+
629
+ try:
630
+ # Python 진단을 위해 ruff 실행 시도
631
+ if target.is_file() and target.suffix == ".py":
632
+ ruff_target = str(target)
633
+ elif target.is_dir():
634
+ ruff_target = str(target)
635
+ else:
636
+ return json.dumps(
637
+ {
638
+ "success": True,
639
+ "data": {
640
+ "diagnostics": [],
641
+ "message": "분석할 Python 파일 없음",
642
+ },
643
+ }
644
+ )
645
+
646
+ try:
647
+ ruff_result = subprocess.run(
648
+ ["ruff", "check", "--output-format=json", ruff_target],
649
+ capture_output=True,
650
+ timeout=60,
651
+ cwd=str(base_path),
652
+ )
653
+
654
+ if ruff_result.stdout:
655
+ ruff_diagnostics = json.loads(ruff_result.stdout.decode("utf-8"))
656
+ for diag in ruff_diagnostics:
657
+ severity = (
658
+ "error"
659
+ if diag.get("code", "").startswith("E")
660
+ else "warning"
661
+ )
662
+ if severity_filter and severity != severity_filter:
663
+ continue
664
+
665
+ results.append(
666
+ {
667
+ "file": diag.get("filename", ""),
668
+ "line": diag.get("location", {}).get("row", 0),
669
+ "character": diag.get("location", {}).get("column", 0),
670
+ "severity": severity,
671
+ "code": diag.get("code", ""),
672
+ "message": diag.get("message", ""),
673
+ "source": "ruff",
674
+ }
675
+ )
676
+
677
+ except FileNotFoundError:
678
+ # Ruff 미설치 - 기본 피드백 제공
679
+ return json.dumps(
680
+ {
681
+ "success": True,
682
+ "data": {
683
+ "diagnostics": [],
684
+ "message": "ruff가 설치되지 않음. 설치: pip install ruff",
685
+ },
686
+ "metadata": {"tool_available": False},
687
+ }
688
+ )
689
+ except subprocess.TimeoutExpired:
690
+ return json.dumps({"success": False, "error": "진단 검사 시간 초과"})
691
+
692
+ # 심각도순 정렬 (오류 우선)
693
+ results.sort(
694
+ key=lambda x: (
695
+ 0 if x["severity"] == "error" else 1,
696
+ x["file"],
697
+ x["line"],
698
+ )
699
+ )
700
+
701
+ # 개수 계산
702
+ errors = sum(1 for r in results if r["severity"] == "error")
703
+ warnings = sum(1 for r in results if r["severity"] == "warning")
704
+
705
+ return json.dumps(
706
+ {
707
+ "success": True,
708
+ "data": {
709
+ "diagnostics": results[:50], # 50개로 제한
710
+ "summary": {
711
+ "errors": errors,
712
+ "warnings": warnings,
713
+ "total": len(results),
714
+ },
715
+ },
716
+ "metadata": {
717
+ "truncated": len(results) > 50,
718
+ "tool_available": True,
719
+ },
720
+ }
721
+ )
722
+
723
+ except Exception as e:
724
+ return json.dumps({"success": False, "error": f"진단 조회 오류: {e}"})
725
+
726
+ return diagnostics
727
+
728
+
729
+ def _find_ast_references(source: str, symbol: str) -> list[tuple[int, int, str]]:
730
+ """AST를 사용해 Python 소스에서 심볼의 실제 코드 참조를 찾습니다.
731
+
732
+ 문자열/주석 내 false positive를 제거하고 실제 코드 참조만 반환합니다.
733
+
734
+ Returns:
735
+ (line, col, preview) 튜플 리스트
736
+ """
737
+ try:
738
+ tree = ast.parse(source)
739
+ except SyntaxError:
740
+ return []
741
+
742
+ lines = source.splitlines()
743
+ refs: list[tuple[int, int, str]] = []
744
+
745
+ for node in ast.walk(tree):
746
+ matched = False
747
+ line = 0
748
+ col = 0
749
+
750
+ if isinstance(node, ast.Name) and node.id == symbol:
751
+ matched, line, col = True, node.lineno, node.col_offset
752
+ elif isinstance(node, ast.Attribute) and node.attr == symbol:
753
+ matched, line, col = True, node.lineno, node.col_offset
754
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
755
+ if node.name == symbol:
756
+ matched, line, col = True, node.lineno, node.col_offset
757
+ elif isinstance(node, ast.ClassDef) and node.name == symbol:
758
+ matched, line, col = True, node.lineno, node.col_offset
759
+ elif isinstance(node, ast.Import):
760
+ for alias in node.names:
761
+ if alias.name == symbol or (alias.asname and alias.asname == symbol):
762
+ matched, line, col = True, node.lineno, node.col_offset
763
+ break
764
+ elif isinstance(node, ast.ImportFrom):
765
+ for alias in node.names:
766
+ if alias.name == symbol or (alias.asname and alias.asname == symbol):
767
+ matched, line, col = True, node.lineno, node.col_offset
768
+ break
769
+
770
+ if matched and line > 0:
771
+ preview = lines[line - 1].strip()[:80] if line <= len(lines) else ""
772
+ refs.append((line, col, preview))
773
+
774
+ return refs
775
+
776
+
777
+ _REFERENCES_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
778
+ _REFERENCES_SKIP_DIRS = frozenset(
779
+ {"__pycache__", ".git", "node_modules", ".venv", "venv", ".tox", ".mypy_cache"}
780
+ )
781
+
782
+
783
+ def create_references_tool(workspace_root: str) -> BaseTool:
784
+ """
785
+ 심볼 참조 찾기용 LangChain 도구를 생성합니다.
786
+
787
+ Python 파일은 AST 기반으로 실제 코드 참조만 찾고,
788
+ 비-Python 파일은 grep fallback을 사용합니다.
789
+
790
+ Args:
791
+ workspace_root: 검색 작업의 루트 디렉토리
792
+
793
+ Returns:
794
+ 참조 찾기용 LangChain BaseTool
795
+ """
796
+ base_path = Path(workspace_root)
797
+
798
+ @tool
799
+ def references(
800
+ symbol: str,
801
+ path: Optional[str] = None,
802
+ file_pattern: str = "*.py",
803
+ ) -> str:
804
+ """
805
+ 코드베이스 전체에서 심볼의 모든 참조를 찾습니다.
806
+
807
+ Python 파일은 AST 분석으로 실제 코드 참조만 정확하게 찾습니다.
808
+ (문자열/주석 내 false positive 제거)
809
+ 비-Python 파일은 grep 기반 검색을 사용합니다.
810
+
811
+ 다음 용도로 유용합니다:
812
+ - 이름 변경/삭제 전 함수/클래스 사용 여부 확인
813
+ - 코드 전체에서 변수가 어떻게 사용되는지 이해
814
+ - 리팩토링 전 모든 사용 위치 찾기
815
+
816
+ Args:
817
+ symbol: 찾을 심볼 이름 (함수, 클래스, 변수)
818
+ path: 검색할 선택적 하위 디렉토리 (기본값: 전체 워크스페이스)
819
+ file_pattern: 검색할 파일의 글롭 패턴 (기본값: '*.py')
820
+
821
+ Returns:
822
+ 참조 위치 목록이 담긴 JSON 문자열
823
+ """
824
+ if not symbol:
825
+ return json.dumps({"success": False, "error": "심볼 이름이 필요합니다"})
826
+
827
+ search_path = base_path / path if path else base_path
828
+ if not search_path.exists():
829
+ return json.dumps(
830
+ {
831
+ "success": False,
832
+ "error": f"경로를 찾을 수 없음: {path or workspace_root}",
833
+ }
834
+ )
835
+
836
+ results: list[dict] = []
837
+ by_file: dict[str, list[dict]] = {}
838
+ use_ast = file_pattern == "*.py"
839
+
840
+ try:
841
+ for file_path in search_path.rglob(file_pattern):
842
+ if not file_path.is_file():
843
+ continue
844
+
845
+ # 숨김 디렉토리와 일반적인 비코드 디렉토리 건너뛰기
846
+ if any(
847
+ part.startswith(".") or part in _REFERENCES_SKIP_DIRS
848
+ for part in file_path.parts
849
+ ):
850
+ continue
851
+
852
+ # 대용량 파일 건너뛰기
853
+ try:
854
+ if file_path.stat().st_size > _REFERENCES_MAX_FILE_SIZE:
855
+ continue
856
+ except OSError:
857
+ continue
858
+
859
+ try:
860
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
861
+ rel_path = str(file_path.relative_to(base_path))
862
+
863
+ if use_ast:
864
+ # AST 기반: 실제 코드 참조만 매칭
865
+ for line_num, col, preview in _find_ast_references(
866
+ content, symbol
867
+ ):
868
+ ref = {
869
+ "file": rel_path,
870
+ "line": line_num,
871
+ "character": col,
872
+ "preview": preview,
873
+ }
874
+ results.append(ref)
875
+ by_file.setdefault(rel_path, []).append(ref)
876
+ else:
877
+ # grep fallback: 비-Python 파일
878
+ escaped_symbol = re.escape(symbol)
879
+ pattern = re.compile(rf"\b{escaped_symbol}\b")
880
+ for line_num, line in enumerate(content.splitlines(), 1):
881
+ for match in pattern.finditer(line):
882
+ ref = {
883
+ "file": rel_path,
884
+ "line": line_num,
885
+ "character": match.start(),
886
+ "preview": line.strip()[:80],
887
+ }
888
+ results.append(ref)
889
+ by_file.setdefault(rel_path, []).append(ref)
890
+
891
+ except Exception:
892
+ continue
893
+
894
+ # 파일과 줄 기준 정렬
895
+ results.sort(key=lambda x: (x["file"], x["line"]))
896
+
897
+ return json.dumps(
898
+ {
899
+ "success": True,
900
+ "data": {
901
+ "references": results[:100], # 100개로 제한
902
+ "by_file": {f: len(refs) for f, refs in by_file.items()},
903
+ "symbol": symbol,
904
+ },
905
+ "metadata": {
906
+ "count": len(results),
907
+ "file_count": len(by_file),
908
+ "truncated": len(results) > 100,
909
+ "method": "ast" if use_ast else "grep",
910
+ },
911
+ }
912
+ )
913
+
914
+ except Exception as e:
915
+ return json.dumps({"success": False, "error": f"참조 찾기 오류: {e}"})
916
+
917
+ return references
918
+
919
+
920
+ # =============================================================================
921
+ # 보안 스캔 도구
922
+ # =============================================================================
923
+
924
+
925
+ def create_security_scan_tool(workspace_root: str) -> BaseTool:
926
+ """
927
+ 보안 취약점 스캔용 LangChain 도구를 생성합니다.
928
+
929
+ bandit, semgrep, pip-audit, detect-secrets, 커스텀 우회 패턴 스캐너를
930
+ 통합하여 코드 레벨 보안 취약점을 검사합니다.
931
+
932
+ Args:
933
+ workspace_root: 스캔의 루트 디렉토리
934
+
935
+ Returns:
936
+ 보안 스캔용 LangChain BaseTool
937
+ """
938
+ base_path = Path(workspace_root)
939
+
940
+ @tool
941
+ def security_scan(
942
+ target_path: Optional[str] = None,
943
+ scan_type: str = "all",
944
+ severity_filter: str = "medium",
945
+ semgrep_ruleset: str = "p/owasp-top-ten",
946
+ ) -> str:
947
+ """
948
+ 보안 취약점 스캔을 실행합니다.
949
+
950
+ 여러 보안 스캐너를 통합하여 코드의 보안 취약점을 검사합니다:
951
+ - bandit: Python SAST (정적 분석)
952
+ - semgrep: 패턴 기반 SAST
953
+ - pip-audit: 의존성 취약점
954
+ - detect-secrets: 시크릿/크리덴셜 탐지
955
+ - custom evasion: 우회 패턴 탐지 (base64 인코딩, 변수명 난독화 등)
956
+
957
+ 이 도구의 용도:
958
+ - 코드 보안 취약점 검사 (SQL 인젝션, 하드코딩된 크리덴셜 등)
959
+ - 의존성 패키지 CVE 취약점 확인
960
+ - 시크릿/API 키 노출 탐지
961
+ - 보안 도구 우회 패턴 탐지
962
+
963
+ Args:
964
+ target_path: 스캔 대상 파일/디렉토리 경로 (기본: 워크스페이스 전체)
965
+ scan_type: 스캔 유형 - "all", "sast", "bandit", "semgrep",
966
+ "dependencies", "secrets", "evasion", "pii"
967
+ severity_filter: 최소 심각도 - "low", "medium" (기본), "high", "critical"
968
+ semgrep_ruleset: semgrep 규칙셋 (기본: "p/owasp-top-ten")
969
+
970
+ Returns:
971
+ 보안 스캔 결과가 담긴 JSON 문자열
972
+ """
973
+ from ..tools.security_tools import (
974
+ finding_to_dict,
975
+ run_bandit,
976
+ run_custom_pattern_scan,
977
+ run_detect_secrets,
978
+ run_pii_scan,
979
+ run_pip_audit,
980
+ run_semgrep,
981
+ )
982
+
983
+ target = str(base_path / target_path) if target_path else str(base_path)
984
+
985
+ all_findings = []
986
+ scanners_used = []
987
+ scanners_unavailable = []
988
+
989
+ try:
990
+ # --- SAST 스캐너 ---
991
+ if scan_type in ("all", "sast", "bandit"):
992
+ results = run_bandit(target, str(base_path), severity_filter)
993
+ if results:
994
+ all_findings.extend(results)
995
+ scanners_used.append("bandit")
996
+ elif scan_type == "bandit":
997
+ scanners_unavailable.append("bandit")
998
+ else:
999
+ # bandit이 결과 없으면 미설치 가능성 체크
1000
+ try:
1001
+ subprocess.run(
1002
+ ["bandit", "--version"],
1003
+ capture_output=True,
1004
+ timeout=10,
1005
+ )
1006
+ scanners_used.append("bandit")
1007
+ except FileNotFoundError:
1008
+ scanners_unavailable.append("bandit")
1009
+
1010
+ if scan_type in ("all", "sast", "semgrep"):
1011
+ results = run_semgrep(target, str(base_path), semgrep_ruleset)
1012
+ if results:
1013
+ all_findings.extend(results)
1014
+ scanners_used.append("semgrep")
1015
+ else:
1016
+ try:
1017
+ subprocess.run(
1018
+ ["semgrep", "--version"],
1019
+ capture_output=True,
1020
+ timeout=10,
1021
+ )
1022
+ scanners_used.append("semgrep")
1023
+ except FileNotFoundError:
1024
+ scanners_unavailable.append("semgrep")
1025
+
1026
+ # --- 의존성 스캐너 ---
1027
+ if scan_type in ("all", "dependencies"):
1028
+ results = run_pip_audit(str(base_path))
1029
+ if results:
1030
+ all_findings.extend(results)
1031
+ scanners_used.append("pip-audit")
1032
+ else:
1033
+ try:
1034
+ subprocess.run(
1035
+ ["pip-audit", "--version"],
1036
+ capture_output=True,
1037
+ timeout=10,
1038
+ )
1039
+ scanners_used.append("pip-audit")
1040
+ except FileNotFoundError:
1041
+ scanners_unavailable.append("pip-audit")
1042
+
1043
+ # --- 시크릿 스캐너 ---
1044
+ if scan_type in ("all", "secrets"):
1045
+ results = run_detect_secrets(target, str(base_path))
1046
+ if results:
1047
+ all_findings.extend(results)
1048
+ scanners_used.append("detect-secrets")
1049
+ else:
1050
+ try:
1051
+ subprocess.run(
1052
+ ["detect-secrets", "--version"],
1053
+ capture_output=True,
1054
+ timeout=10,
1055
+ )
1056
+ scanners_used.append("detect-secrets")
1057
+ except FileNotFoundError:
1058
+ scanners_unavailable.append("detect-secrets")
1059
+
1060
+ partial_scans = []
1061
+
1062
+ # --- 커스텀 우회 패턴 스캐너 (항상 실행 가능) ---
1063
+ if scan_type in ("all", "sast", "evasion"):
1064
+ results = run_custom_pattern_scan(target, str(base_path))
1065
+ if results:
1066
+ all_findings.extend(results)
1067
+ scanners_used.append("custom-evasion")
1068
+ if results.truncated:
1069
+ partial_scans.append(
1070
+ {
1071
+ "scanner": "custom-evasion",
1072
+ "reason": results.truncation_reason,
1073
+ }
1074
+ )
1075
+
1076
+ # --- 개인정보(PII) 패턴 스캐너 (항상 실행 가능) ---
1077
+ if scan_type in ("all", "pii"):
1078
+ results = run_pii_scan(target, str(base_path))
1079
+ if results:
1080
+ all_findings.extend(results)
1081
+ scanners_used.append("custom-pii")
1082
+ if results.truncated:
1083
+ partial_scans.append(
1084
+ {"scanner": "custom-pii", "reason": results.truncation_reason}
1085
+ )
1086
+
1087
+ # --- 심각도 필터링 ---
1088
+ severity_order = {
1089
+ "critical": 0,
1090
+ "high": 1,
1091
+ "medium": 2,
1092
+ "low": 3,
1093
+ "info": 4,
1094
+ }
1095
+ min_sev = severity_order.get(severity_filter, 2)
1096
+ filtered = [
1097
+ f for f in all_findings if severity_order.get(f.severity, 4) <= min_sev
1098
+ ]
1099
+
1100
+ # 심각도 내림차순 정렬
1101
+ filtered.sort(key=lambda f: severity_order.get(f.severity, 4))
1102
+
1103
+ # 최대 100개로 제한
1104
+ truncated = len(filtered) > 100
1105
+ filtered = filtered[:100]
1106
+
1107
+ # --- 요약 생성 ---
1108
+ by_severity = {}
1109
+ for sev in ("critical", "high", "medium", "low", "info"):
1110
+ count = sum(1 for f in filtered if f.severity == sev)
1111
+ if count > 0:
1112
+ by_severity[sev] = count
1113
+
1114
+ by_scanner: dict[str, int] = {}
1115
+ for f in filtered:
1116
+ by_scanner[f.scanner] = by_scanner.get(f.scanner, 0) + 1
1117
+
1118
+ owasp_ids = sorted({f.owasp_id for f in filtered if f.owasp_id})
1119
+
1120
+ return json.dumps(
1121
+ {
1122
+ "success": True,
1123
+ "data": {
1124
+ "findings": [finding_to_dict(f) for f in filtered],
1125
+ "summary": {
1126
+ "total": len(filtered),
1127
+ **by_severity,
1128
+ "by_scanner": by_scanner,
1129
+ "owasp_coverage": owasp_ids,
1130
+ },
1131
+ "scanners_used": scanners_used,
1132
+ "scanners_unavailable": scanners_unavailable,
1133
+ },
1134
+ "metadata": {
1135
+ "truncated": truncated,
1136
+ "scan_complete": not partial_scans,
1137
+ "partial_scans": partial_scans,
1138
+ },
1139
+ }
1140
+ )
1141
+
1142
+ except Exception as e:
1143
+ return json.dumps({"success": False, "error": f"보안 스캔 오류: {e}"})
1144
+
1145
+ return security_scan
1146
+
1147
+
1148
+ # =============================================================================
1149
+ # 파일 검색 도구 (fuzzy)
1150
+ # =============================================================================
1151
+
1152
+
1153
+ def create_file_find_tool(workspace_root: str) -> BaseTool:
1154
+ """
1155
+ Fuzzy 파일 검색용 LangChain 도구를 생성합니다.
1156
+
1157
+ Args:
1158
+ workspace_root: 검색의 루트 디렉토리
1159
+
1160
+ Returns:
1161
+ 파일 검색용 LangChain BaseTool
1162
+ """
1163
+ from ..tools.file_finder import FileIndex
1164
+
1165
+ _index = FileIndex(workspace_root)
1166
+
1167
+ @tool
1168
+ def file_find(
1169
+ query: str,
1170
+ path: str = ".",
1171
+ file_pattern: Optional[str] = None,
1172
+ max_results: int = 20,
1173
+ ) -> str:
1174
+ """Find files by name using fuzzy matching.
1175
+
1176
+ Use when you need to locate files by partial or approximate name.
1177
+ Unlike glob_search (exact glob patterns), this finds files even with
1178
+ typos or partial names.
1179
+
1180
+ Examples:
1181
+ file_find(query="auth") → finds auth.py, authentication.ts, ...
1182
+ file_find(query="main.py") → finds main.py, main_app.py, ...
1183
+ file_find(query="config", file_pattern="*.yaml") → YAML configs only
1184
+ file_find(query="src/hand") → files matching "hand" under src/
1185
+
1186
+ Args:
1187
+ query: Filename or partial name to search for (e.g., "auth", "main.py")
1188
+ path: Directory to search in (relative to workspace, default ".")
1189
+ file_pattern: Optional extension filter (e.g., "*.py", "*.ts")
1190
+ max_results: Maximum number of results (default 20)
1191
+
1192
+ Returns:
1193
+ JSON with ranked matches [{path, score, type}, ...]
1194
+ """
1195
+ if not query:
1196
+ return json.dumps({"success": False, "error": "query is required"})
1197
+
1198
+ try:
1199
+ # Determine supported extensions from file_pattern
1200
+ supported_exts: set[str] | None = None
1201
+ if file_pattern:
1202
+ # Extract extension from glob pattern like "*.py"
1203
+ if file_pattern.startswith("*."):
1204
+ supported_exts = {"." + file_pattern[2:]}
1205
+
1206
+ # If path is not ".", create a sub-index for that directory
1207
+ search_index = _index
1208
+ if path and path != ".":
1209
+ base = Path(workspace_root) / path
1210
+ if not base.is_dir():
1211
+ return json.dumps(
1212
+ {"success": False, "error": f"Directory not found: {path}"}
1213
+ )
1214
+ search_index = FileIndex(str(base))
1215
+
1216
+ results = search_index.fuzzy_search(
1217
+ query=query,
1218
+ max_results=max_results,
1219
+ supported_exts=supported_exts,
1220
+ )
1221
+
1222
+ # Prepend path prefix for sub-directory searches
1223
+ if path and path != ".":
1224
+ for r in results:
1225
+ r["path"] = str(Path(path) / r["path"])
1226
+
1227
+ return json.dumps(
1228
+ {
1229
+ "success": True,
1230
+ "data": results,
1231
+ "metadata": {
1232
+ "query": query,
1233
+ "total": len(results),
1234
+ },
1235
+ }
1236
+ )
1237
+ except Exception as e:
1238
+ return json.dumps({"success": False, "error": f"파일 검색 오류: {e}"})
1239
+
1240
+ return file_find
1241
+
1242
+
1243
+ def create_rag_search_tool() -> BaseTool:
1244
+ """
1245
+ RAG 검색용 LangChain 도구를 생성합니다.
1246
+
1247
+ 4개 컬렉션(enterprise_tables, bdp_tables, guide_docs, ds_guide_docs)에서 벡터 검색.
1248
+
1249
+ Returns:
1250
+ RAG 검색용 LangChain BaseTool
1251
+ """
1252
+
1253
+ @tool
1254
+ def rag_search(
1255
+ query: str,
1256
+ collection: Optional[str] = None,
1257
+ top_k: int = 5,
1258
+ ) -> str:
1259
+ """Search the RAG knowledge base for relevant information.
1260
+
1261
+ Searches across enterprise tables, BDP tables, guide documents, and DS guide documents.
1262
+ Use this tool to find table metadata, column definitions, or guide content.
1263
+
1264
+ Args:
1265
+ query: 검색 쿼리 (예: "카드 거래 테이블", "BDP 데이터 조회 방법")
1266
+ collection: 특정 컬렉션만 검색 ("enterprise_tables", "bdp_tables", "guide_docs", "ds_guide_docs"). None이면 전체.
1267
+ top_k: 반환할 최대 결과 수 (기본 5)
1268
+
1269
+ Returns:
1270
+ JSON with search results [{collection, score, payload}, ...]
1271
+ """
1272
+ import asyncio
1273
+
1274
+ try:
1275
+ from ..handlers.rag_admin_handler import _ensure_manager
1276
+
1277
+ mgr = _ensure_manager()
1278
+ # sync wrapper for async method
1279
+ loop = asyncio.get_event_loop()
1280
+ if loop.is_running():
1281
+ import concurrent.futures
1282
+
1283
+ with concurrent.futures.ThreadPoolExecutor() as pool:
1284
+ results = pool.submit(
1285
+ asyncio.run,
1286
+ mgr.search_test(
1287
+ query=query, collection=collection, top_k=top_k
1288
+ ),
1289
+ ).result()
1290
+ else:
1291
+ results = asyncio.run(
1292
+ mgr.search_test(query=query, collection=collection, top_k=top_k)
1293
+ )
1294
+
1295
+ if not results:
1296
+ return json.dumps(
1297
+ {"success": True, "results": [], "message": "검색 결과 없음"}
1298
+ )
1299
+
1300
+ # payload에서 _embed_text 제거 (검색용으로만 사용, LLM에겐 구조화된 payload 제공)
1301
+ for r in results:
1302
+ r.get("payload", {}).pop("_embed_text", None)
1303
+
1304
+ return json.dumps(
1305
+ {"success": True, "results": results, "total": len(results)},
1306
+ ensure_ascii=False,
1307
+ )
1308
+ except ImportError:
1309
+ return json.dumps(
1310
+ {
1311
+ "success": False,
1312
+ "error": "RAG 시스템이 초기화되지 않았습니다. qdrant-client를 설치하세요.",
1313
+ }
1314
+ )
1315
+ except Exception as e:
1316
+ return json.dumps({"success": False, "error": f"RAG 검색 오류: {e}"})
1317
+
1318
+ return rag_search
1319
+
1320
+
1321
+ def create_enhanced_rag_search_tool() -> BaseTool:
1322
+ """배치 쿼리 + Qdrant pre-filtering 지원 RAG 검색 도구 (chat mode 전용).
1323
+
1324
+ Returns:
1325
+ RAG 검색용 LangChain BaseTool
1326
+ """
1327
+
1328
+ @tool
1329
+ async def rag_search(queries: Any, top_k: int = 5) -> str: # noqa: C901
1330
+ """RAG 지식 베이스에서 정보를 검색합니다.
1331
+
1332
+ ## 사용 가능한 컬렉션
1333
+
1334
+ 1. **enterprise_tables** — 전사 엔터프라이즈 테이블 메타데이터
1335
+ - payload: table_name, korean_name, db_name, columns[{col_name, col_korean_name, col_type, pk}]
1336
+ - 내용: 테이블명, 한글명, DB명, 컬럼 정보 등
1337
+ - 필터 가능: table_name, korean_name, db_name
1338
+
1339
+ 2. **bdp_tables** — BDP 빅데이터플랫폼 테이블 메타데이터
1340
+ - payload: table_id, table_name, korean_name, storage_format, update_cycle, domain, owner_team, columns[...]
1341
+ - 예시: 카드/여신/수신 도메인 테이블, 일/월 배치 적재
1342
+ - 필터 가능: table_name, korean_name, domain, owner_team, storage_format, update_cycle
1343
+
1344
+ 3. **guide_docs** — BDP/플랫폼 업무 가이드 + bdpengine API 레퍼런스
1345
+ - payload: content, summary, source(파일명), title, section
1346
+ - 포함: BDP 아키텍처, S3/Athena/EMR/SageMaker 가이드, bdpengine 함수(S3, MinIO, Spark, Athena)
1347
+ - bdpengine: BDP 핵심 Python 패키지 (from bdpengine.functions import *). S3 파일관리, MinIO, Spark Job, Athena 쿼리 통합 인터페이스
1348
+ - 필터 가능: source(파일명), title, section
1349
+
1350
+ 4. **ds_guide_docs** — 데이터사이언스 기술 가이드
1351
+ - payload: content, summary, source(파일명), title, section
1352
+ - 포함: Pandas, PySpark, Dask, LightGBM 사용법/최적화
1353
+ - 필터 가능: source(파일명), title, section
1354
+
1355
+ ## queries 형식
1356
+
1357
+ JSON 배열 문자열. 여러 쿼리를 보내면 병렬 처리.
1358
+
1359
+ 각 요소: {"query": "...", "collection": "...", "match_text": ["검색어1", "검색어2"], "type": "table"|"columns"}
1360
+ - match_text (선택): 텍스트 인덱스 보조 검색어 리스트. 인덱스된 전체 필드에 OR 조건으로 자동 매칭.
1361
+ 벡터 검색과 병합하여 매칭 결과를 우선 포함합니다.
1362
+ - type (선택, 테이블 컬렉션 전용): 청크 타입 필터
1363
+ - "table": 사용자가 **테이블명/한글명**으로 검색할 때 (예: "dtcn0003 테이블 조회", "거래내역 테이블")
1364
+ → 테이블 메타가 강조된 청크만 검색 → 컬럼 dilution 없이 정확
1365
+ - "columns": 사용자가 **컬럼명**으로 테이블을 찾을 때 (예: "tran_amt 컬럼이 있는 테이블", "거래금액 컬럼 가진 테이블")
1366
+ → 컬럼 리스트가 강조된 청크만 검색
1367
+ - 미지정(생략): 의도가 애매하면 **양쪽 모두** 검색 (TableConfirmation 후보)
1368
+ - **헷갈리면 type을 지정하지 마세요** — 안전한 기본값.
1369
+ - payload 결과는 type과 무관하게 100% 동일 (테이블 메타 + 전체 컬럼).
1370
+ 즉, type="table"로 검색해도 컬럼 정보를 그대로 받습니다.
1371
+ - exclude (선택, 테이블 컬렉션 전용): 결과에서 제외할 테이블명 리스트. 후보를
1372
+ 보여준 뒤 사용자가 그 후보들 말고 다른/추가 테이블을 원하는 의도를 보이면(표현은
1373
+ 다양함), 이번 대화에서 지금까지 보여준 모든 테이블명(직전 배치만이 아니라 누적
1374
+ 전부)을 exclude에 넣고 같은 query로 재검색하면 중복 없이 다음 후보가 나옵니다
1375
+ (벡터검색은 offset 페이징이 불가하므로 이 방식).
1376
+
1377
+ 예시:
1378
+ [{"query": "카드 거래 테이블", "collection": "enterprise_tables"}]
1379
+ [{"query": "S3 파일 업로드", "collection": "guide_docs"}, {"query": "고객 테이블", "collection": "bdp_tables"}]
1380
+ [{"query": "dtcn0003 테이블 조회", "collection": "bdp_tables", "type": "table", "match_text": ["dtcn0003"]}]
1381
+ [{"query": "거래금액 컬럼", "collection": "bdp_tables", "type": "columns"}]
1382
+ [{"query": "카드매출 스키마", "collection": "bdp_tables", "match_text": ["card_pchs", "카드매출"]}]
1383
+ [{"query": "고객 테이블", "collection": "bdp_tables", "type": "table", "exclude": ["bdp_cust_snap", "bdp_cust_daily"]}]
1384
+
1385
+ Args:
1386
+ queries: JSON 배열 문자열. 각 요소: {query: str, collection?: str, match_text?: list, type?: "table"|"columns", exclude?: list}
1387
+ top_k: 쿼리당 최대 결과 수 (기본 5)
1388
+
1389
+ Returns:
1390
+ JSON. "테이블 검색 결과"와 "가이드 검색 결과"로 그룹핑.
1391
+ """
1392
+ import asyncio
1393
+
1394
+ parsed = _parse_batch_input(queries)
1395
+ if parsed is None:
1396
+ return json.dumps(
1397
+ {
1398
+ "success": False,
1399
+ "error": f"queries 파싱 실패 (list 변환 불가): {str(queries)[:200]}",
1400
+ },
1401
+ ensure_ascii=False,
1402
+ )
1403
+
1404
+ try:
1405
+ from codebuff_core.services.rag_collections import get_manager
1406
+
1407
+ mgr = get_manager()
1408
+ if mgr is None:
1409
+ return json.dumps(
1410
+ {"success": False, "error": "RAG 매니저가 초기화되지 않았습니다."},
1411
+ ensure_ascii=False,
1412
+ )
1413
+
1414
+ # async tool이므로 직접 await 가능 (데드락 없음)
1415
+ tasks = []
1416
+ regular_queries = []
1417
+ task_collections = [] # tasks 와 같은 순서 — 실패 보고에 컬렉션을 싣는다
1418
+
1419
+ _seen_queries: set = set() # (query, collection) 중복 제거
1420
+
1421
+ for i, q in enumerate(parsed):
1422
+ query_text = q.get("query", "") if isinstance(q, dict) else str(q)
1423
+ collection = q.get("collection") if isinstance(q, dict) else None
1424
+ # EDA 모드: enterprise_tables → bdp_tables 강제 치환
1425
+ if _eda_only_bdp.get() and collection == "enterprise_tables":
1426
+ collection = "bdp_tables"
1427
+ raw_mt = q.get("match_text") if isinstance(q, dict) else None
1428
+ match_text = (
1429
+ raw_mt
1430
+ if isinstance(raw_mt, list)
1431
+ else [raw_mt]
1432
+ if isinstance(raw_mt, str)
1433
+ else None
1434
+ )
1435
+
1436
+ # enterprise_tables → 사용자 원본 메시지에 키워드 없으면 bdp_tables로 변환
1437
+ if collection == "enterprise_tables":
1438
+ _check_text = f"{_last_user_message.get()} {query_text}"
1439
+ if not _ENTERPRISE_KEYWORDS.search(_check_text):
1440
+ print(
1441
+ f"[RAG] enterprise_tables → bdp_tables (no keyword): {query_text[:80]}",
1442
+ flush=True,
1443
+ )
1444
+ collection = "bdp_tables"
1445
+
1446
+ # 동일 (query, collection) 중복 스킵
1447
+ dedup_key = (query_text.lower().strip(), collection)
1448
+ if dedup_key in _seen_queries:
1449
+ print(
1450
+ f"[RAG] skipping duplicate query: {query_text[:80]} → {collection}",
1451
+ flush=True,
1452
+ )
1453
+ continue
1454
+ _seen_queries.add(dedup_key)
1455
+
1456
+ # faq_docs 직접 호출은 차단 — guide_docs 검색 시 자동으로
1457
+ # 병렬 검색되도록 redirect (LLM은 faq_docs를 모름)
1458
+ if collection == "faq_docs":
1459
+ print(
1460
+ f"[RAG] redirecting faq_docs → guide_docs: {query_text[:80]}",
1461
+ flush=True,
1462
+ )
1463
+ collection = "guide_docs"
1464
+
1465
+ # 테이블 컬렉션은 top_k=3 (컬럼 정보가 커서 토큰 절약)
1466
+ effective_top_k = (
1467
+ 3 if collection in ("enterprise_tables", "bdp_tables") else top_k
1468
+ )
1469
+ # match_text는 테이블 컬렉션에서만 사용
1470
+ effective_mt = (
1471
+ match_text
1472
+ if collection in ("enterprise_tables", "bdp_tables")
1473
+ else None
1474
+ )
1475
+ # type 필터는 테이블 컬렉션에서만 사용 (table | columns | None)
1476
+ raw_type = q.get("type") if isinstance(q, dict) else None
1477
+ effective_type = (
1478
+ raw_type
1479
+ if (
1480
+ isinstance(raw_type, str)
1481
+ and raw_type in ("table", "columns")
1482
+ and collection in ("enterprise_tables", "bdp_tables")
1483
+ )
1484
+ else None
1485
+ )
1486
+ # exclude: "다른 후보 보여줘"용 — 이미 뽑은 테이블명을 제외하고 재쿼리.
1487
+ # 테이블 컬렉션에서만 사용.
1488
+ raw_exclude = q.get("exclude") if isinstance(q, dict) else None
1489
+ effective_exclude = (
1490
+ raw_exclude
1491
+ if (
1492
+ isinstance(raw_exclude, list)
1493
+ and collection in ("enterprise_tables", "bdp_tables")
1494
+ )
1495
+ else [raw_exclude]
1496
+ if (
1497
+ isinstance(raw_exclude, str)
1498
+ and raw_exclude
1499
+ and collection in ("enterprise_tables", "bdp_tables")
1500
+ )
1501
+ else None
1502
+ )
1503
+ regular_queries.append(i)
1504
+ task_collections.append(collection)
1505
+ tasks.append(
1506
+ mgr.search_with_filter(
1507
+ query=query_text,
1508
+ collection=collection,
1509
+ top_k=effective_top_k,
1510
+ match_text=effective_mt,
1511
+ chunk_type=effective_type,
1512
+ exclude_table_names=effective_exclude,
1513
+ )
1514
+ )
1515
+
1516
+ # guide_docs 호출 시 같은 query로 faq_docs도 자동 병렬 검색
1517
+ # (LLM은 faq_docs 존재를 모름. 결과는 "FAQ 검색 결과"로 그룹핑)
1518
+ if collection == "guide_docs":
1519
+ regular_queries.append(i)
1520
+ task_collections.append("faq_docs")
1521
+ tasks.append(
1522
+ mgr.search_with_filter(
1523
+ query=query_text,
1524
+ collection="faq_docs",
1525
+ top_k=3,
1526
+ score_threshold=0.55,
1527
+ )
1528
+ )
1529
+
1530
+ all_results = await asyncio.gather(*tasks, return_exceptions=True)
1531
+
1532
+ # 결과 그룹핑
1533
+ table_results = []
1534
+ guide_results = []
1535
+ faq_results = []
1536
+
1537
+ # 실패는 "결과 없음" 과 구별해 싣는다 — 섞이면 장애가 "정보 없음" 답으로 바뀐다
1538
+ errors = []
1539
+
1540
+ for ri, result in enumerate(all_results):
1541
+ orig_idx = regular_queries[ri]
1542
+ query_info = parsed[orig_idx] if orig_idx < len(parsed) else {}
1543
+ query_text = (
1544
+ query_info.get("query", "") if isinstance(query_info, dict) else ""
1545
+ )
1546
+ if isinstance(result, Exception):
1547
+ print(f"[RAG] search error for query {ri}: {result}", flush=True)
1548
+ errors.append(
1549
+ {
1550
+ "query": query_text,
1551
+ "collection": task_collections[ri],
1552
+ "error": str(result),
1553
+ }
1554
+ )
1555
+ continue
1556
+ for r in result:
1557
+ r["_query"] = query_text
1558
+ col = r.get("collection", "")
1559
+ if col in ("enterprise_tables", "bdp_tables"):
1560
+ table_results.append(r)
1561
+ elif col == "faq_docs":
1562
+ faq_results.append(r)
1563
+ else:
1564
+ guide_results.append(r)
1565
+
1566
+ # LLM에 불필요한 키 제거 + columns를 header+rows로 압축 (토큰 절약)
1567
+ _STRIP_KEYS = {"id", "_group_key", "_chunk"}
1568
+ for r in table_results + guide_results + faq_results:
1569
+ for k in _STRIP_KEYS:
1570
+ r.pop(k, None)
1571
+ p = r.get("payload")
1572
+ if isinstance(p, dict):
1573
+ for k in _STRIP_KEYS:
1574
+ p.pop(k, None)
1575
+ # 컬럼 개수 캡: 후보 테이블 전체 합쳐 최대 300개 (후보 수로 균등 분배)
1576
+ _truncate_table_columns(
1577
+ [
1578
+ r["payload"]
1579
+ for r in table_results
1580
+ if isinstance(r.get("payload"), dict)
1581
+ ]
1582
+ )
1583
+
1584
+ # _warnings 수집 (match_text fallback 등)
1585
+ warnings: list = []
1586
+ for r in table_results + guide_results + faq_results:
1587
+ for w in r.pop("_warnings", []):
1588
+ if w not in warnings:
1589
+ warnings.append(w)
1590
+
1591
+ if tasks and len(errors) == len(tasks):
1592
+ return json.dumps(
1593
+ {
1594
+ "success": False,
1595
+ "error": "RAG 검색 오류: 모든 검색이 실패했습니다",
1596
+ "errors": errors,
1597
+ },
1598
+ ensure_ascii=False,
1599
+ )
1600
+
1601
+ output = {"success": True}
1602
+ if errors:
1603
+ output["errors"] = errors
1604
+ if warnings:
1605
+ output["warnings"] = warnings
1606
+ if table_results:
1607
+ output["테이블 검색 결과"] = table_results
1608
+ if guide_results:
1609
+ output["가이드 검색 결과"] = guide_results
1610
+ if faq_results:
1611
+ output["FAQ 검색 결과"] = faq_results
1612
+ if not table_results and not guide_results and not faq_results:
1613
+ output["message"] = "검색 결과 없음"
1614
+ output["results"] = []
1615
+ output["total"] = len(table_results) + len(guide_results) + len(faq_results)
1616
+
1617
+ return json.dumps(output, ensure_ascii=False, default=str)
1618
+
1619
+ except Exception as e:
1620
+ return json.dumps(
1621
+ {"success": False, "error": f"RAG 검색 오류: {e}"},
1622
+ ensure_ascii=False,
1623
+ )
1624
+
1625
+ return rag_search
1626
+
1627
+
1628
+ def create_get_table_columns_tool() -> BaseTool:
1629
+ """테이블 컬럼 목록 조회 도구.
1630
+
1631
+ Returns:
1632
+ LangChain BaseTool
1633
+ """
1634
+
1635
+ @tool
1636
+ async def get_table_columns(
1637
+ table_name: str,
1638
+ collection: str = "bdp_tables",
1639
+ start_index: int = 0,
1640
+ ) -> str:
1641
+ """테이블의 컬럼 목록을 조회합니다.
1642
+
1643
+ rag_search 결과에서 컬럼이 축약된 경우 이 도구로 전체 컬럼을 확인합니다.
1644
+
1645
+ Args:
1646
+ table_name: 테이블 이름 (정확한 이름 또는 부분 매칭)
1647
+ collection: 컬렉션 이름 (enterprise_tables 또는 bdp_tables, 기본: bdp_tables)
1648
+ start_index: 컬럼 목록 시작 인덱스 (페이징용, 기본 0)
1649
+
1650
+ Returns:
1651
+ JSON. table_name, collection, total_columns, start_index, columns, next_index, has_more
1652
+ """
1653
+ _MAX_COLUMNS = 100
1654
+ _ALLOWED = ("enterprise_tables", "bdp_tables", "bdp_tables_l2")
1655
+
1656
+ if collection not in _ALLOWED:
1657
+ return json.dumps(
1658
+ {
1659
+ "success": False,
1660
+ "error": f"collection은 {_ALLOWED} 중 하나여야 합니다.",
1661
+ },
1662
+ ensure_ascii=False,
1663
+ )
1664
+
1665
+ # enterprise_tables 접근 제한: 사용자 원본 메시지에 키워드 없으면 bdp_tables로 변환
1666
+ if collection == "enterprise_tables":
1667
+ _check_text = f"{_last_user_message.get()} {table_name}"
1668
+ if not _ENTERPRISE_KEYWORDS.search(_check_text):
1669
+ print(
1670
+ f"[RAG] get_table_columns: enterprise → bdp (no keyword): {table_name}",
1671
+ flush=True,
1672
+ )
1673
+ collection = "bdp_tables"
1674
+
1675
+ try:
1676
+ from codebuff_core.services.rag_collections import get_manager
1677
+
1678
+ mgr = get_manager()
1679
+ if mgr is None:
1680
+ return json.dumps(
1681
+ {"success": False, "error": "RAG 매니저가 초기화되지 않았습니다."},
1682
+ ensure_ascii=False,
1683
+ )
1684
+
1685
+ client = mgr._client
1686
+
1687
+ # 테이블명 필드: table_name 또는 테이블명 (매핑 설정에 따라 다름)
1688
+ _TABLE_NAME_FIELDS = ["table_name", "테이블명"]
1689
+
1690
+ def _lookup(coll: str):
1691
+ """coll에서 MatchText → MatchValue fallback으로 테이블 포인트 검색."""
1692
+ from qdrant_client.models import (
1693
+ FieldCondition,
1694
+ Filter,
1695
+ MatchText,
1696
+ MatchValue,
1697
+ )
1698
+
1699
+ # 1) MatchText로 검색 (text index가 있는 필드에서)
1700
+ mt_conditions = [
1701
+ FieldCondition(key=f, match=MatchText(text=table_name.lower()))
1702
+ for f in _TABLE_NAME_FIELDS
1703
+ ]
1704
+ mt_filter = Filter(should=mt_conditions)
1705
+ scroll_result = client.scroll(
1706
+ collection_name=coll,
1707
+ scroll_filter=mt_filter,
1708
+ limit=100,
1709
+ with_payload=True,
1710
+ with_vectors=False,
1711
+ )
1712
+ points = scroll_result[0] if scroll_result else []
1713
+
1714
+ resolved = None
1715
+ if points:
1716
+ # exact match 테이블명 결정 (대소문자 무시)
1717
+ for pt in points:
1718
+ for f in _TABLE_NAME_FIELDS:
1719
+ pt_name = (pt.payload or {}).get(f, "")
1720
+ if pt_name and pt_name.lower() == table_name.lower():
1721
+ resolved = pt_name
1722
+ break
1723
+ if resolved:
1724
+ break
1725
+ if resolved is None:
1726
+ for f in _TABLE_NAME_FIELDS:
1727
+ v = (points[0].payload or {}).get(f, "")
1728
+ if v:
1729
+ resolved = v
1730
+ break
1731
+
1732
+ # 2) MatchText 실패 시 MatchValue fallback
1733
+ if not points:
1734
+ for f in _TABLE_NAME_FIELDS:
1735
+ for mv_val in (table_name, table_name.lower()):
1736
+ mv_filter = Filter(
1737
+ must=[
1738
+ FieldCondition(
1739
+ key=f,
1740
+ match=MatchValue(value=mv_val),
1741
+ )
1742
+ ]
1743
+ )
1744
+ scroll_result = client.scroll(
1745
+ collection_name=coll,
1746
+ scroll_filter=mv_filter,
1747
+ limit=100,
1748
+ with_payload=True,
1749
+ with_vectors=False,
1750
+ )
1751
+ points = scroll_result[0] if scroll_result else []
1752
+ if points:
1753
+ resolved = mv_val
1754
+ break
1755
+ if points:
1756
+ break
1757
+
1758
+ # 같은 테이블의 모든 청크 수집
1759
+ resolved_lower = (resolved or "").lower()
1760
+ matched = [
1761
+ pt
1762
+ for pt in points
1763
+ if pt.payload
1764
+ and any(
1765
+ (pt.payload.get(f) or "").lower() == resolved_lower
1766
+ for f in _TABLE_NAME_FIELDS
1767
+ )
1768
+ ]
1769
+ return matched, resolved
1770
+
1771
+ # 명시적 collection 지정 시 그것만, 기본값(bdp_tables)이면 L2까지 순서대로 시도
1772
+ # (L1과 동일 UX — rag_search의 columns_truncated 안내가 이 도구로 이어지므로
1773
+ # L1에 없는 L2 전용 테이블도 별도 안내 없이 그대로 찾아져야 함)
1774
+ _try_collections = (
1775
+ [collection]
1776
+ if collection != "bdp_tables"
1777
+ else ["bdp_tables", "bdp_tables_l2"]
1778
+ )
1779
+ matched_points: list = []
1780
+ resolved_table_name = None
1781
+ resolved_collection = collection
1782
+ try:
1783
+ for _coll in _try_collections:
1784
+ matched_points, resolved_table_name = _lookup(_coll)
1785
+ if matched_points:
1786
+ resolved_collection = _coll
1787
+ break
1788
+ print(
1789
+ f"[RAG] get_table_columns: {table_name} → "
1790
+ f"resolved={resolved_table_name}, collection={resolved_collection}, "
1791
+ f"points={len(matched_points)}",
1792
+ flush=True,
1793
+ )
1794
+ except Exception as e:
1795
+ print(
1796
+ f"[RAG] get_table_columns search error: {e}",
1797
+ flush=True,
1798
+ )
1799
+
1800
+ collection = resolved_collection
1801
+
1802
+ if not matched_points:
1803
+ return json.dumps(
1804
+ {
1805
+ "success": False,
1806
+ "error": (
1807
+ f"테이블 '{table_name}'을(를) "
1808
+ f"{'/'.join(_try_collections)}에서 찾을 수 없습니다."
1809
+ ),
1810
+ },
1811
+ ensure_ascii=False,
1812
+ )
1813
+
1814
+ # 청크 순서대로 정렬 후 컬럼 합치기
1815
+ def _chunk_sort_key(pt):
1816
+ chunk = (pt.payload or {}).get("_chunk", "1/1")
1817
+ try:
1818
+ return int(str(chunk).split("/")[0])
1819
+ except (ValueError, IndexError):
1820
+ return 0
1821
+
1822
+ matched_points.sort(key=_chunk_sort_key)
1823
+ found_table_name = resolved_table_name or table_name
1824
+ columns: list = []
1825
+ for pt in matched_points:
1826
+ pt_cols = (pt.payload or {}).get("columns", [])
1827
+ if isinstance(pt_cols, list):
1828
+ columns.extend(pt_cols)
1829
+
1830
+ # columns 포맷팅
1831
+ total = len(columns) if isinstance(columns, list) else 0
1832
+ if start_index < 0:
1833
+ start_index = 0
1834
+
1835
+ sliced = (
1836
+ columns[start_index : start_index + _MAX_COLUMNS]
1837
+ if isinstance(columns, list)
1838
+ else []
1839
+ )
1840
+ end_index = start_index + len(sliced)
1841
+ has_more = end_index < total
1842
+
1843
+ # col_headers + rows 형태로 압축 (토큰 절약)
1844
+ col_headers: list = []
1845
+ formatted_rows: list = []
1846
+ for idx, col in enumerate(sliced):
1847
+ if isinstance(col, dict):
1848
+ if not col_headers:
1849
+ col_headers = list(col.keys())
1850
+ formatted_rows.append([col.get(h, "") for h in col_headers])
1851
+ elif isinstance(col, list):
1852
+ formatted_rows.append(col)
1853
+ else:
1854
+ formatted_rows.append([str(col)])
1855
+
1856
+ result: dict = {
1857
+ "success": True,
1858
+ "table_name": found_table_name,
1859
+ "collection": collection,
1860
+ "total_columns": total,
1861
+ "start_index": start_index,
1862
+ "next_index": end_index if has_more else None,
1863
+ "has_more": has_more,
1864
+ }
1865
+ if col_headers:
1866
+ result["col_headers"] = col_headers
1867
+ result["columns"] = formatted_rows
1868
+ return json.dumps(result, ensure_ascii=False, default=str)
1869
+
1870
+ except Exception as e:
1871
+ return json.dumps(
1872
+ {"success": False, "error": f"컬럼 조회 오류: {e}"},
1873
+ ensure_ascii=False,
1874
+ )
1875
+
1876
+ return get_table_columns
1877
+
1878
+
1879
+ def _parse_batch_input(raw: Any) -> Optional[list]:
1880
+ """list/dict(이미 파싱됨), str(JSON), str(repr), str(double-encoded) 모두 처리.
1881
+
1882
+ rag_search의 queries와 search_l2_table의 table_names가 공유하는 배치 입력
1883
+ 파서. 파싱 실패 시 None.
1884
+ """
1885
+ parsed = raw
1886
+ if isinstance(parsed, str):
1887
+ try:
1888
+ parsed = json.loads(parsed)
1889
+ except (json.JSONDecodeError, TypeError):
1890
+ # Python repr 형식 (single quotes) 처리
1891
+ try:
1892
+ import ast as _ast
1893
+
1894
+ parsed = _ast.literal_eval(parsed)
1895
+ except Exception:
1896
+ parsed = None
1897
+ if isinstance(parsed, str):
1898
+ # double-encoded: json.loads 결과가 또 string인 경우
1899
+ try:
1900
+ parsed = json.loads(parsed)
1901
+ except (json.JSONDecodeError, TypeError):
1902
+ parsed = None
1903
+ if isinstance(parsed, dict):
1904
+ parsed = [parsed]
1905
+
1906
+ if not isinstance(parsed, list) or not parsed:
1907
+ return None
1908
+ return parsed
1909
+
1910
+
1911
+ def _truncate_table_columns(payloads: list, total_budget: int = 300) -> None:
1912
+ """columns: [{key:val,...},...] → col_headers + columns([[val,...],...]) 압축 + 캡.
1913
+
1914
+ payloads 전체에 total_budget을 균등 분배(후보 1개면 전체, 3개면 각 1/3).
1915
+ 초과분은 잘라내고 payload에 columns_truncated 안내를 추가해 get_table_columns로
1916
+ 이어보게 함. payload를 in-place로 수정한다. rag_search와 search_l2_table이 공유.
1917
+ """
1918
+ n = max(1, len(payloads))
1919
+ per_cap = max(1, total_budget // n)
1920
+ for p in payloads:
1921
+ if not isinstance(p, dict):
1922
+ continue
1923
+ cols = p.get("columns")
1924
+ if not (cols and isinstance(cols, list) and isinstance(cols[0], dict)):
1925
+ continue
1926
+ headers = list(cols[0].keys())
1927
+ total_cols = len(cols)
1928
+ shown = cols[:per_cap]
1929
+ rows = [[c.get(h, "") for h in headers] for c in shown]
1930
+ del p["columns"]
1931
+ p["col_headers"] = headers
1932
+ p["columns"] = rows
1933
+ if total_cols > per_cap:
1934
+ tname = p.get("테이블명") or p.get("table_name") or p.get("table_id") or ""
1935
+ p["columns_truncated"] = (
1936
+ f"⚠️ 컬럼 축약됨: 전체 {total_cols}개 중 {per_cap}개만 표시. "
1937
+ "전체 컬럼이 필요하면 반드시 get_table_columns 도구로 조회하세요"
1938
+ + (f" (테이블명: {tname})" if tname else "")
1939
+ + "."
1940
+ )
1941
+
1942
+
1943
+ # L2(프로젝트) 테이블명 필드 후보. table 검색은 영문 테이블명(테이블명/table_name)
1944
+ # + 한글명(테이블한글명)까지 substring 매칭 — 영문/한글 어느 쪽으로 물어도 찾음.
1945
+ # 영문 필드를 앞에 둬서 결과 그룹핑/후보명 해석은 영문명을 우선한다.
1946
+ _L2_TABLE_NAME_FIELDS = ["테이블명", "table_name", "테이블한글명"]
1947
+ # columns[] 배열 내부 객체 필드의 dot-array 문법 — Qdrant는 인덱스 없는 필드에서
1948
+ # 배열 내부 특정 필드를 substring 매칭 지원 (columns[].컬럼명).
1949
+ _L2_COLUMN_NAME_FIELDS = [
1950
+ "columns[].컬럼명",
1951
+ "columns[].col_name",
1952
+ "columns[].column_name",
1953
+ ]
1954
+ # 한 번의 tool 호출에서 처리할 테이블명 상한. LLM 재호출 판단에 기대지 않고 tool
1955
+ # 내부에서 결정적으로 처리(L1+L2 혼합 SQL 튜닝 등 다건 요청의 조용한 누락 방지).
1956
+ _L2_MAX_TABLE_NAMES = 10
1957
+ # ambiguous 후보 노출 개수 — substring 매칭은 순위가 없어 "짧은 이름" 휴리스틱으로
1958
+ # 정렬 후 상위 N개만. 나머지는 개수만 안내하고 candidate_offset으로 이어본다.
1959
+ _L2_CANDIDATE_PAGE = 3
1960
+
1961
+
1962
+ def _l2_group_by_name(points: list) -> dict:
1963
+ """point 리스트를 테이블명(영문 우선) 기준으로 그룹핑. 반환 {테이블명: payload}."""
1964
+ by_name: dict = {}
1965
+ for pt in points:
1966
+ payload = pt.payload or {}
1967
+ tname = None
1968
+ for f in _L2_TABLE_NAME_FIELDS:
1969
+ v = payload.get(f)
1970
+ if v:
1971
+ tname = v
1972
+ break
1973
+ if tname and tname not in by_name:
1974
+ by_name[tname] = payload
1975
+ return by_name
1976
+
1977
+
1978
+ def create_search_l2_table_tool() -> BaseTool:
1979
+ """L2(프로젝트) 테이블 검색 도구 생성.
1980
+
1981
+ bdp_tables_l2는 벡터 없는(payload-only) 컬렉션 — 텍스트 인덱스도 없어
1982
+ MatchText가 진짜 substring 매칭으로 동작한다(토큰 매칭 아님). L2는 이름이
1983
+ 제각각이라 벡터 검색보다 정확한 substring 매칭이 더 적합.
1984
+ """
1985
+
1986
+ @tool
1987
+ async def search_l2_table(
1988
+ table_names: Any, type: str = "table", candidate_offset: int = 0
1989
+ ) -> str:
1990
+ """L2(프로젝트) 테이블을 이름 또는 컬럼명으로 검색합니다.
1991
+
1992
+ L1(bdp_tables, rag_search)과 별도 컬렉션(bdp_tables_l2)에서 substring
1993
+ 매칭으로 검색합니다. 한 번에 최대 10개까지 배치 처리됩니다(병렬 tool 호출은
1994
+ 지원되지 않지만, 이 도구가 내부에서 순차 처리하므로 한 번만 호출하면 됩니다).
1995
+
1996
+ Args:
1997
+ table_names: 테이블명(영문 또는 한글명) 또는 컬럼명 목록. JSON 배열
1998
+ 문자열 또는 리스트. type=table이면 영문 테이블명·한글명 어느 쪽으로도
1999
+ 검색됩니다(예: "mart_card_sales" 또는 "카드매출 일별"). "db.table"
2000
+ 형식이면 db명은 자동으로 제거됩니다.
2001
+ 예: ["mart_card_sales", "카드매출 일별", "some_l2_db.mart_user_stat"]
2002
+ type: "table"(기본, 테이블명/한글명 검색) 또는 "columns"(컬럼명으로 테이블 역추적)
2003
+ candidate_offset: ambiguous 후보 이어보기용 오프셋(기본 0). 직전 응답의
2004
+ next_candidate_offset 값을 넣어 같은 검색어로 재호출하면 다음 후보를 봅니다.
2005
+
2006
+ Returns:
2007
+ JSON {"results": [...]}. 각 결과:
2008
+ - 0건: {"query": str, "not_found": true}
2009
+ - type=table 1건: 테이블 전체 payload (컬럼 축약 시 안내)
2010
+ - type=table 2건+: {"query": str, "ambiguous": true, "candidates": [상위 3개],
2011
+ "candidate_offset": int, "more_candidates_count"?: int, "next_candidate_offset"?: int}
2012
+ - type=columns: {"query": str, "tables": [...]} (여러 건이 정상)
2013
+ """
2014
+ from codebuff_core.services.rag_collections import (
2015
+ COLLECTION_BDP_L2,
2016
+ get_manager,
2017
+ strip_fqn_to_table_name,
2018
+ )
2019
+
2020
+ parsed = _parse_batch_input(table_names)
2021
+ if parsed is None:
2022
+ return json.dumps(
2023
+ {
2024
+ "success": False,
2025
+ "error": f"table_names 파싱 실패 (list 변환 불가): {str(table_names)[:200]}",
2026
+ },
2027
+ ensure_ascii=False,
2028
+ )
2029
+
2030
+ warnings: list = []
2031
+ queries = [str(q) for q in parsed if str(q).strip()]
2032
+ if len(queries) > _L2_MAX_TABLE_NAMES:
2033
+ warnings.append(
2034
+ f"최대 {_L2_MAX_TABLE_NAMES}개까지 처리했습니다. "
2035
+ f"나머지 {len(queries) - _L2_MAX_TABLE_NAMES}개는 다시 호출하세요."
2036
+ )
2037
+ queries = queries[:_L2_MAX_TABLE_NAMES]
2038
+
2039
+ effective_type = type if type in ("table", "columns") else "table"
2040
+ try:
2041
+ _offset = max(0, int(candidate_offset))
2042
+ except (TypeError, ValueError):
2043
+ _offset = 0
2044
+
2045
+ try:
2046
+ from qdrant_client.models import FieldCondition, Filter, MatchText
2047
+
2048
+ mgr = get_manager()
2049
+ if mgr is None:
2050
+ return json.dumps(
2051
+ {"success": False, "error": "RAG 매니저가 초기화되지 않았습니다."},
2052
+ ensure_ascii=False,
2053
+ )
2054
+ client = mgr._client
2055
+
2056
+ results: list = []
2057
+ # 단일 매칭 payload는 나중에 한꺼번에 컬럼 예산(총 300개)을 나눠 축약 —
2058
+ # 다건 요청이 통째로 거대해지지 않도록(rag_search와 동일 방식).
2059
+ _single_payloads: list = []
2060
+ for raw_q in queries:
2061
+ q = (
2062
+ strip_fqn_to_table_name(raw_q)
2063
+ if effective_type == "table"
2064
+ else raw_q.strip()
2065
+ )
2066
+ fields = (
2067
+ _L2_TABLE_NAME_FIELDS
2068
+ if effective_type == "table"
2069
+ else _L2_COLUMN_NAME_FIELDS
2070
+ )
2071
+ scroll_filter = Filter(
2072
+ should=[
2073
+ FieldCondition(key=f, match=MatchText(text=q)) for f in fields
2074
+ ]
2075
+ )
2076
+ scroll_result = client.scroll(
2077
+ collection_name=COLLECTION_BDP_L2,
2078
+ scroll_filter=scroll_filter,
2079
+ limit=200,
2080
+ with_payload=True,
2081
+ with_vectors=False,
2082
+ )
2083
+ points = scroll_result[0] if scroll_result else []
2084
+ print(
2085
+ f"[RAG] search_l2_table: query={q!r} type={effective_type} "
2086
+ f"points={len(points)}",
2087
+ flush=True,
2088
+ )
2089
+
2090
+ by_name = _l2_group_by_name(points)
2091
+
2092
+ if effective_type == "columns":
2093
+ results.append({"query": raw_q, "tables": list(by_name.values())})
2094
+ continue
2095
+
2096
+ if not by_name:
2097
+ results.append({"query": raw_q, "not_found": True})
2098
+ elif len(by_name) == 1:
2099
+ (_, payload) = next(iter(by_name.items()))
2100
+ payload = dict(payload)
2101
+ _single_payloads.append(payload)
2102
+ # payload 참조를 유지 — truncate가 in-place 변형하므로 축약 후 병합.
2103
+ results.append({"query": raw_q, "_payload_ref": payload})
2104
+ else:
2105
+ # substring 매칭은 순위가 없음 → 매칭된 이름이 짧을수록 군더더기가
2106
+ # 적어 더 근접한 후보라는 휴리스틱. 동률은 알파벳순으로 고정해
2107
+ # candidate_offset 이어보기가 매 호출 동일 순서를 갖게 함.
2108
+ ranked = sorted(by_name.keys(), key=lambda n: (len(n), n))
2109
+ top = ranked[_offset : _offset + _L2_CANDIDATE_PAGE]
2110
+ more = len(ranked) - (_offset + len(top))
2111
+ res = {
2112
+ "query": raw_q,
2113
+ "ambiguous": True,
2114
+ "candidates": top,
2115
+ "candidate_offset": _offset,
2116
+ }
2117
+ if more > 0:
2118
+ res["more_candidates_count"] = more
2119
+ res["next_candidate_offset"] = _offset + len(top)
2120
+ results.append(res)
2121
+
2122
+ # 단일 매칭들에 총 300개 컬럼 예산을 균등 분배(초과분 축약+안내) 후 병합
2123
+ if _single_payloads:
2124
+ _truncate_table_columns(_single_payloads)
2125
+ for r in results:
2126
+ if "_payload_ref" in r:
2127
+ r.update(r.pop("_payload_ref"))
2128
+
2129
+ output: dict = {"success": True, "results": results}
2130
+ if warnings:
2131
+ output["warnings"] = warnings
2132
+ return json.dumps(output, ensure_ascii=False, default=str)
2133
+
2134
+ except Exception as e:
2135
+ return json.dumps(
2136
+ {"success": False, "error": f"L2 테이블 검색 오류: {e}"},
2137
+ ensure_ascii=False,
2138
+ )
2139
+
2140
+ return search_l2_table
2141
+
2142
+
2143
+ # ---------------------------------------------------------------------------
2144
+ # Data Skill — 동적 필터 가이드 + 확장 도구 디스커버리
2145
+ # ---------------------------------------------------------------------------
2146
+
2147
+ # 모듈 레벨 캐시: 최초 1회 Qdrant 조회 후 재사용
2148
+ _table_skill_cache: Optional[str] = None
2149
+
2150
+ _EXCLUDED_PAYLOAD_KEYS = {
2151
+ "content",
2152
+ "_embed_text",
2153
+ "summary",
2154
+ "columns",
2155
+ "embedding_text",
2156
+ }
2157
+ _MAX_SAMPLE_VALUES = 10
2158
+
2159
+
2160
+ def _build_filter_guide() -> str:
2161
+ """Qdrant에서 테이블 컬렉션의 payload 키와 샘플 값을 조회."""
2162
+ global _table_skill_cache
2163
+ if _table_skill_cache is not None:
2164
+ return _table_skill_cache
2165
+
2166
+ try:
2167
+ from codebuff_core.services.rag_collections import (
2168
+ COLLECTION_BDP,
2169
+ COLLECTION_ENTERPRISE,
2170
+ get_manager,
2171
+ )
2172
+
2173
+ mgr = get_manager()
2174
+ if mgr is None:
2175
+ return "(RAG 매니저 미초기화)"
2176
+
2177
+ client = mgr._client
2178
+ existing = {c.name for c in client.get_collections().collections}
2179
+
2180
+ # 테이블 컬렉션만 조회 (guide는 pre-filtering 불필요)
2181
+ table_collections = [COLLECTION_ENTERPRISE, COLLECTION_BDP]
2182
+ lines: list[str] = []
2183
+
2184
+ for col_name in table_collections:
2185
+ if col_name not in existing:
2186
+ continue
2187
+
2188
+ try:
2189
+ scroll_result = client.scroll(
2190
+ collection_name=col_name,
2191
+ limit=30,
2192
+ with_payload=True,
2193
+ with_vectors=False,
2194
+ )
2195
+ points = scroll_result[0] if scroll_result else []
2196
+ except Exception:
2197
+ continue
2198
+
2199
+ if not points:
2200
+ continue
2201
+
2202
+ # payload 키별 unique 값 수집
2203
+ key_values: dict[str, set] = {}
2204
+ for pt in points:
2205
+ if not pt.payload:
2206
+ continue
2207
+ for k, v in pt.payload.items():
2208
+ if k in _EXCLUDED_PAYLOAD_KEYS:
2209
+ continue
2210
+ if isinstance(v, (list, dict)):
2211
+ continue
2212
+ if k not in key_values:
2213
+ key_values[k] = set()
2214
+ key_values[k].add(str(v))
2215
+
2216
+ if not key_values:
2217
+ continue
2218
+
2219
+ lines.append(f"\n**{col_name}** 필터 가능 필드:")
2220
+ for key, values in sorted(key_values.items()):
2221
+ sample = sorted(values)[:_MAX_SAMPLE_VALUES]
2222
+ sample_str = ", ".join(f'"{v}"' for v in sample)
2223
+ suffix = " ..." if len(values) > _MAX_SAMPLE_VALUES else ""
2224
+ lines.append(f" - `{key}`: {sample_str}{suffix}")
2225
+
2226
+ # MatchText 검색 가능 필드 (text index가 있는 필드)
2227
+ try:
2228
+ col_info = client.get_collection(col_name)
2229
+ text_fields = [
2230
+ field_name
2231
+ for field_name, schema in (col_info.payload_schema or {}).items()
2232
+ if schema.data_type == "text"
2233
+ or getattr(schema, "lookup", None) == "text"
2234
+ or "text" in str(getattr(schema, "params", "")).lower()
2235
+ ]
2236
+ if text_fields:
2237
+ lines.append(f"\n**{col_name}** MatchText 검색 가능 필드:")
2238
+ for f in sorted(text_fields):
2239
+ lines.append(f" - `{f}`")
2240
+ except Exception:
2241
+ pass
2242
+
2243
+ result = "\n".join(lines) if lines else "(테이블 컬렉션 없음)"
2244
+ _table_skill_cache = result
2245
+ print(f"[RAG] data_skill cache built ({len(result)} chars)", flush=True)
2246
+ return result
2247
+
2248
+ except Exception as e:
2249
+ print(f"[RAG] data_skill build failed: {e}", flush=True)
2250
+ return f"(스키마 조회 실패: {e})"
2251
+
2252
+
2253
+ def _skill_key(name: str) -> str:
2254
+ """파일 stem(`data_loading`)과 frontmatter name(`data-loading`)을 같은 스킬로 본다."""
2255
+ from .custom_skills import skill_key
2256
+
2257
+ return skill_key(name)
2258
+
2259
+
2260
+ def create_load_skills_tool(workspace_root: Optional[str] = None) -> BaseTool:
2261
+ """스킬 로드 도구. skills/ 디렉토리의 md 파일을 이름으로 로드.
2262
+
2263
+ 워크스페이스를 주면 같은 이름의 커스텀(포털) 스킬이 내장 스킬을 이긴다 —
2264
+ `SkillMiddleware` 와 같은 계약이다 (이슈 #134, #202).
2265
+
2266
+ Returns:
2267
+ LangChain BaseTool
2268
+ """
2269
+
2270
+ # 사용 가능한 스킬 목록을 skills/ 디렉토리에서 동적 생성
2271
+ from .custom_skills import catalog, discover, read_body, with_builtin
2272
+
2273
+ _skill_dir = Path(__file__).parent / "skills"
2274
+ _available = sorted(p.stem for p in _skill_dir.glob("*.md"))
2275
+ _builtin_stems = {_skill_key(stem): stem for stem in _available}
2276
+ # 이름만으로는 무엇을 언제 불러야 할지 모른다 — 설명까지 싣는다 (이슈 #219)
2277
+ _skill_list = catalog(
2278
+ with_builtin(discover(workspace_root) if workspace_root else [])
2279
+ )
2280
+
2281
+ @tool
2282
+ def load_skills(names: str) -> str:
2283
+ """스킬을 로드합니다. 쉼표로 구분하여 여러 스킬을 한번에 로드 가능.
2284
+
2285
+ Args:
2286
+ names: 스킬 이름 (쉼표 구분). 예: "table_filter,athena_sql"
2287
+
2288
+ Returns:
2289
+ 로드된 스킬 내용.
2290
+ """
2291
+ skill_dir = _skill_dir
2292
+ skill_names = [n.strip() for n in names.split(",") if n.strip()]
2293
+ results: list[str] = []
2294
+ # 호출 때마다 다시 찾는다 — 대화 중에 사용자가 스킬을 더해도 보인다
2295
+ custom: dict[str, Path] = {}
2296
+ if workspace_root:
2297
+ for s in discover(workspace_root):
2298
+ custom.setdefault(_skill_key(s.name), s.path)
2299
+
2300
+ for name in skill_names:
2301
+ key = _skill_key(name)
2302
+ if key in custom:
2303
+ results.append(read_body(custom[key]))
2304
+ continue
2305
+ md_path = skill_dir / f"{_builtin_stems.get(key, name)}.md"
2306
+ if not md_path.is_file():
2307
+ results.append(f"[스킬 '{name}' 없음]")
2308
+ continue
2309
+ # 다른 두 경로(SkillMiddleware·codex)와 같은 본문 — frontmatter 제외·상한 (이슈 #228)
2310
+ template = read_body(md_path)
2311
+
2312
+ # 동적 플레이스홀더 치환
2313
+ if "{filter_guide}" in template:
2314
+ template = template.replace("{filter_guide}", _build_filter_guide())
2315
+
2316
+ results.append(template)
2317
+
2318
+ output = "\n\n---\n\n".join(results)
2319
+ print(f"[RAG] load_skills({names}): {len(results)} loaded", flush=True)
2320
+ return output
2321
+
2322
+ # tool description에 사용 가능한 스킬 목록 동적 추가
2323
+ load_skills.description = (
2324
+ f"스킬을 로드합니다. 쉼표로 구분하여 여러 스킬을 한번에 로드 가능. "
2325
+ f"사용 가능:\n{_skill_list or '(없음)'}"
2326
+ )
2327
+ return load_skills
2328
+
2329
+
2330
+ # ====================================================================
2331
+ # get_iceberg_snapshots — Athena 기반 Iceberg 스냅샷(적재 이력) 조회 도구
2332
+ # cd1-agent의 동명 도구 포팅. client는 k8s secret(jupyterlab-secret)의 AWS 키로
2333
+ # 직접 boto3.client("athena")를 생성하고, 실행은 직접 start_query_execution 호출
2334
+ # (WorkGroup은 HDSP_ENV 기준 하드코딩: dev=wdd-bdp-ath-wg-dlk / else wdp-…).
2335
+ # Mock/Real 분기는 별도 환경변수 없이 bdpengine 모듈 path로 자동 판별.
2336
+ # ====================================================================
2337
+
2338
+ # table_name 입력 검증 (영문/숫자/언더스코어만 허용 — SQL injection 차단)
2339
+ _ICEBERG_TABLE_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$")
2340
+
2341
+
2342
+ def _iceberg_is_bdpengine_mock() -> bool:
2343
+ """bdpengine이 mock_packages 경로의 mock 구현인지 자동 판별.
2344
+
2345
+ `bdpengine.__file__`은 namespace package라 None일 수 있어 `__path__`로 확인.
2346
+ codebuff는 EDA agent도 동일 bdpengine 추상화를 통해 mock/real을 분기 —
2347
+ 별도 ATHENA_PROVIDER 환경변수 없음.
2348
+ """
2349
+ try:
2350
+ import bdpengine
2351
+
2352
+ paths = list(getattr(bdpengine, "__path__", []) or [])
2353
+ if any("mock_packages" in str(p) for p in paths):
2354
+ return True
2355
+ # __file__ fallback (일부 환경에서 namespace package 아닌 경우)
2356
+ f = getattr(bdpengine, "__file__", "") or ""
2357
+ return "mock_packages" in f
2358
+ except Exception:
2359
+ # bdpengine 자체가 import 안 되면 mock으로 간주 (안전)
2360
+ return True
2361
+
2362
+
2363
+ def _iceberg_resolve_database(layer: str) -> str:
2364
+ """layer("l1"/"l2") + HDSP_ENV → Iceberg snapshot 조회용 database 이름.
2365
+
2366
+ 명명 규칙:
2367
+ - HDSP_ENV=dev → wdd_bdp_db_dlk_{layer}_org
2368
+ - 그 외 (운영/미설정): wdp_bdp_db_dlk_{layer}_org
2369
+ """
2370
+ import os as _os
2371
+
2372
+ env = _os.getenv("HDSP_ENV", "").lower()
2373
+ prefix = "wdd" if env == "dev" else "wdp"
2374
+ suffix = "l2" if str(layer).lower() == "l2" else "l1"
2375
+ return f"{prefix}_bdp_db_dlk_{suffix}_org"
2376
+
2377
+
2378
+ def _iceberg_build_snapshots_sql(database: str, table_name: str, limit: int) -> str:
2379
+ """Iceberg $snapshots 시스템 테이블 조회 SQL.
2380
+
2381
+ summary는 map<string,string> — Trino CAST AS JSON 으로 JSON 문자열화.
2382
+ SQL에 fully-qualified name(`"db"."table$snapshots"`)을 박아서 bdpengine.start_query가
2383
+ 별도 database 컨텍스트 없이도 실행 가능.
2384
+ """
2385
+ return (
2386
+ f"SELECT committed_at, snapshot_id, parent_id, operation, "
2387
+ f"CAST(summary AS JSON) AS summary "
2388
+ f'FROM "{database}"."{table_name}$snapshots" '
2389
+ f"WHERE operation <> 'replace' "
2390
+ f"ORDER BY committed_at DESC "
2391
+ f"LIMIT {limit}"
2392
+ )
2393
+
2394
+
2395
+ def _iceberg_is_table_not_found(err_msg: str) -> bool:
2396
+ """Athena 에러 메시지에서 테이블 없음 여부 판정."""
2397
+ if not err_msg:
2398
+ return False
2399
+ m = err_msg.lower()
2400
+ return (
2401
+ "table_not_found" in m
2402
+ or "does not exist" in m
2403
+ or "table not found" in m
2404
+ or "schema_not_found" in m
2405
+ )
2406
+
2407
+
2408
+ def _iceberg_mock_snapshots(table_name: str, limit: int) -> list:
2409
+ """로컬 테스트용 mock — 테이블명 seed 기반으로 재현 가능한 최근 10개 스냅샷.
2410
+
2411
+ bdpengine mock의 일반 _mock_results_for는 `$snapshots` 시스템 테이블 형식을
2412
+ 모르므로 이 도구 전용 mock 데이터를 별도 생성한다 (cd1-agent와 동일 패턴).
2413
+ """
2414
+ from datetime import datetime, timedelta, timezone
2415
+
2416
+ base = datetime(2026, 6, 4, 9, 0, 0, tzinfo=timezone.utc)
2417
+ seed = sum(ord(c) for c in table_name) % 1000
2418
+ rows: list = []
2419
+ n = min(int(limit), 10)
2420
+ for i in range(n):
2421
+ ts = base - timedelta(hours=i * 3 + (i // 3))
2422
+ is_overwrite = i == 5
2423
+ added = 0 if is_overwrite else 15234 + seed * 7 + i * 100
2424
+ deleted = 98000 + seed * 50 if is_overwrite else 0
2425
+ total = 98765 + seed * 10 + i * 15234 - (98000 + seed * 50 if i > 5 else 0)
2426
+ summary = {
2427
+ "added-records": str(added),
2428
+ "deleted-records": str(deleted),
2429
+ "total-records": str(total),
2430
+ "added-data-files": "0" if is_overwrite else str(3 + i % 2),
2431
+ "deleted-data-files": str(45 + seed % 10) if is_overwrite else "0",
2432
+ "total-data-files": str(120 + seed % 30 + i * 3),
2433
+ "added-files-size": str(added * 240),
2434
+ "removed-files-size": str(deleted * 240),
2435
+ "total-files-size": str(total * 240),
2436
+ "changed-partition-count": "1",
2437
+ }
2438
+ rows.append(
2439
+ {
2440
+ "committed_at": ts.isoformat(),
2441
+ "snapshot_id": str(7000000000000000 + seed * 1000 + i),
2442
+ "parent_id": (
2443
+ str(7000000000000000 + seed * 1000 + i + 1) if i < n - 1 else None
2444
+ ),
2445
+ "operation": "overwrite" if is_overwrite else "append",
2446
+ "summary": summary,
2447
+ }
2448
+ )
2449
+ return rows
2450
+
2451
+
2452
+ def _iceberg_load_aws_creds_from_k8s() -> tuple:
2453
+ """in-cluster k8s secret(jupyterlab-secret)에서 AWS 자격증명 로드.
2454
+
2455
+ Returns (access_key, secret_key, session_token|None).
2456
+ 키명: AWS_ACCESS_KEY / AWS_SECRET_KEY.
2457
+ session token은 있으면 사용(임시 자격증명), 없으면 None.
2458
+ """
2459
+ import base64
2460
+
2461
+ from kubernetes import client as _k8s_client
2462
+ from kubernetes import config as _kubeconfig
2463
+
2464
+ _kubeconfig.load_incluster_config()
2465
+ v1 = _k8s_client.CoreV1Api()
2466
+ secret = v1.read_namespaced_secret("jupyterlab-secret", namespace="jupyterhub-ns")
2467
+ decoded = {k: base64.b64decode(v).decode("utf-8") for k, v in secret.data.items()}
2468
+
2469
+ access_key = decoded.get("AWS_ACCESS_KEY")
2470
+ secret_key = decoded.get("AWS_SECRET_KEY")
2471
+ if not (access_key and secret_key):
2472
+ raise RuntimeError(
2473
+ "AWS 자격증명을 k8s secret(jupyterlab-secret)에서 찾지 못했습니다 "
2474
+ "(AWS_ACCESS_KEY / AWS_SECRET_KEY 확인)"
2475
+ )
2476
+ session_token = decoded.get("AWS_SESSION_TOKEN")
2477
+ return access_key, secret_key, session_token
2478
+
2479
+
2480
+ def _iceberg_run_athena(sql: str, timeout_s: int = 60) -> list:
2481
+ """직접 boto3 athena로 실행 (client 생성은 k8s secret AWS 키 사용).
2482
+
2483
+ client은 k8s secret(jupyterlab-secret)의 AWS 키로 직접 생성하고,
2484
+ start_query_execution을 호출한다. WorkGroup은 HDSP_ENV 기준 하드코딩.
2485
+ Polling은 1초 간격, 최대 timeout_s. 결과는 paginator로 수집.
2486
+ """
2487
+ import os as _os
2488
+ import time as _time
2489
+
2490
+ import boto3
2491
+
2492
+ access_key, secret_key, session_token = _iceberg_load_aws_creds_from_k8s()
2493
+ client_kwargs = {
2494
+ "region_name": "ap-northeast-2",
2495
+ "aws_access_key_id": access_key,
2496
+ "aws_secret_access_key": secret_key,
2497
+ }
2498
+ if session_token:
2499
+ client_kwargs["aws_session_token"] = session_token
2500
+ client = boto3.client("athena", **client_kwargs)
2501
+
2502
+ # WorkGroup은 HDSP_ENV 기준 하드코딩 (database와 동일한 wdd/wdp 분기).
2503
+ # 워크그룹에 output이 강제돼 있어 OutputLocation은 넘기지 않는다.
2504
+ env = _os.environ.get("HDSP_ENV", "").lower()
2505
+ prefix = "wdd" if env == "dev" else "wdp"
2506
+ workgroup = f"{prefix}-bdp-ath-wg-dlk"
2507
+
2508
+ result = client.start_query_execution(QueryString=sql, WorkGroup=workgroup)
2509
+ qid = result.get("QueryExecutionId")
2510
+ if not qid:
2511
+ raise RuntimeError("Athena: QueryExecutionId 없음")
2512
+
2513
+ state = "QUEUED"
2514
+ for _ in range(timeout_s):
2515
+ _time.sleep(1)
2516
+ resp = client.get_query_execution(QueryExecutionId=qid)
2517
+ state = resp["QueryExecution"]["Status"]["State"]
2518
+ if state == "SUCCEEDED":
2519
+ break
2520
+ if state in ("FAILED", "CANCELLED"):
2521
+ reason = resp["QueryExecution"]["Status"].get("StateChangeReason", state)
2522
+ raise RuntimeError(f"Athena query {state}: {reason}")
2523
+ else:
2524
+ raise RuntimeError(f"Athena query timeout after {timeout_s}s")
2525
+
2526
+ # 페이지네이션으로 결과 수집 (paginator 사용 가능 시 우선)
2527
+ rows: list = []
2528
+ header: Optional[list] = None
2529
+ try:
2530
+ paginator = client.get_paginator("get_query_results")
2531
+ for page in paginator.paginate(QueryExecutionId=qid):
2532
+ for row in page["ResultSet"]["Rows"]:
2533
+ values = [col.get("VarCharValue", "") for col in row.get("Data", [])]
2534
+ if header is None:
2535
+ header = values
2536
+ continue
2537
+ rows.append(dict(zip(header, values)))
2538
+ except (AttributeError, NotImplementedError):
2539
+ # mock client는 paginator 미지원 — 단일 호출로 처리
2540
+ result_set = client.get_query_results(QueryExecutionId=qid).get("ResultSet", {})
2541
+ for row in result_set.get("Rows", []):
2542
+ values = [col.get("VarCharValue", "") for col in row.get("Data", [])]
2543
+ if header is None:
2544
+ header = values
2545
+ continue
2546
+ rows.append(dict(zip(header, values)))
2547
+ return rows
2548
+
2549
+
2550
+ def create_iceberg_snapshots_tool() -> BaseTool:
2551
+ """get_iceberg_snapshots tool 생성. Iceberg 테이블 적재 이력 조회.
2552
+
2553
+ Athena의 `{db}.{table}$snapshots` 시스템 테이블을 조회하여 최근 적재 이력 반환.
2554
+ client은 k8s secret AWS 키로 직접 생성하고 실행은 직접 start_query_execution.
2555
+ mock_packages/bdpengine을 사용 중인 환경에서는 도구 자체 mock 데이터 반환.
2556
+ """
2557
+ import asyncio as _asyncio
2558
+
2559
+ @tool
2560
+ async def get_iceberg_snapshots(
2561
+ table_name: str,
2562
+ layer: str,
2563
+ limit: int = 20,
2564
+ ) -> str:
2565
+ """Iceberg 테이블의 최근 적재 이력(스냅샷)을 Athena로 조회합니다.
2566
+
2567
+ Args:
2568
+ table_name: 테이블 영문명(snake_case). 영문/숫자/언더스코어만 허용.
2569
+ layer: "l1"(일반 BDP 테이블) 또는 "l2"(프로젝트/L2). 사용자가
2570
+ 명확히 지정한 경우에만 호출하세요. 모호하면 사용자에게 되물어보세요.
2571
+ limit: 최근 N개 스냅샷 (기본 20, 최대 100). 빈도 분석 시에만 늘리세요.
2572
+
2573
+ Returns:
2574
+ JSON 문자열.
2575
+ - 성공: {database, table_name, layer, snapshot_count, snapshots: [...]}
2576
+ - 테이블 없음: {success: false, table_not_found: true, ...}
2577
+ - 기타 오류: {success: false, error: "..."}
2578
+
2579
+ 각 snapshot 항목: committed_at, snapshot_id, parent_id, operation, summary(JSON)
2580
+ """
2581
+ # 1) 입력 검증
2582
+ if not isinstance(table_name, str) or not _ICEBERG_TABLE_NAME_RE.match(
2583
+ table_name or ""
2584
+ ):
2585
+ return json.dumps(
2586
+ {
2587
+ "success": False,
2588
+ "error": (
2589
+ f"잘못된 table_name: {table_name!r}. "
2590
+ "영문/숫자/언더스코어만 허용됩니다."
2591
+ ),
2592
+ "table_name": table_name,
2593
+ },
2594
+ ensure_ascii=False,
2595
+ )
2596
+
2597
+ layer_norm = (layer or "").strip().lower()
2598
+ if layer_norm not in ("l1", "l2"):
2599
+ return json.dumps(
2600
+ {
2601
+ "success": False,
2602
+ "error": f"layer는 'l1' 또는 'l2'만 허용됩니다 (받은 값: {layer!r}).",
2603
+ "table_name": table_name,
2604
+ },
2605
+ ensure_ascii=False,
2606
+ )
2607
+
2608
+ try:
2609
+ clamped_limit = max(1, min(int(limit or 20), 100))
2610
+ except (TypeError, ValueError):
2611
+ clamped_limit = 20
2612
+
2613
+ database = _iceberg_resolve_database(layer_norm)
2614
+
2615
+ is_mock = _iceberg_is_bdpengine_mock()
2616
+ try:
2617
+ # 2) Mock vs Real 분기 (bdpengine 모듈 path 기반 자동 판별)
2618
+ if is_mock:
2619
+ # mock 모드에서 '_notfound' 포함 시 TABLE_NOT_FOUND 시뮬레이션
2620
+ if "_notfound" in table_name.lower():
2621
+ raise RuntimeError(
2622
+ f"TABLE_NOT_FOUND: Table '{database}.{table_name}' "
2623
+ "does not exist"
2624
+ )
2625
+ rows = _iceberg_mock_snapshots(table_name, clamped_limit)
2626
+ else:
2627
+ sql = _iceberg_build_snapshots_sql(database, table_name, clamped_limit)
2628
+ rows = await _asyncio.to_thread(_iceberg_run_athena, sql)
2629
+
2630
+ return json.dumps(
2631
+ {
2632
+ "database": database,
2633
+ "table_name": table_name,
2634
+ "layer": layer_norm,
2635
+ "snapshot_count": len(rows),
2636
+ "snapshots": rows,
2637
+ "mock": is_mock,
2638
+ },
2639
+ ensure_ascii=False,
2640
+ default=str,
2641
+ )
2642
+
2643
+ except Exception as e: # noqa: BLE001
2644
+ err_msg = str(e)
2645
+ print(
2646
+ f"[iceberg_snapshots] layer={layer_norm} table={table_name} "
2647
+ f"failed: {err_msg}",
2648
+ flush=True,
2649
+ )
2650
+ if _iceberg_is_table_not_found(err_msg):
2651
+ return json.dumps(
2652
+ {
2653
+ "success": False,
2654
+ "table_not_found": True,
2655
+ "table_name": table_name,
2656
+ "layer": layer_norm,
2657
+ "database": database,
2658
+ "error": f"테이블이 존재하지 않습니다: {database}.{table_name}",
2659
+ },
2660
+ ensure_ascii=False,
2661
+ )
2662
+ return json.dumps(
2663
+ {
2664
+ "success": False,
2665
+ "error": err_msg,
2666
+ "table_name": table_name,
2667
+ "layer": layer_norm,
2668
+ "database": database,
2669
+ },
2670
+ ensure_ascii=False,
2671
+ )
2672
+
2673
+ return get_iceberg_snapshots