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,2740 @@
1
+ """
2
+ RAG Collection Manager — 4개 컬렉션(enterprise_tables, bdp_tables, guide_docs, ds_guide_docs) 관리.
3
+
4
+ 핵심 기능:
5
+ - 동적 CSV 파싱 (관리자 매핑 기반)
6
+ - 마크다운 헤더 기반 시맨틱 청킹
7
+ - LLM 청크 요약 임베딩
8
+ - 비-md 파일 → 마크다운 변환 (draw.io XML 포함 시 Mermaid 자동 변환)
9
+ - 적재 프리뷰, 데이터 브라우저, 검색 테스트, 이력 관리
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import csv
16
+ import hashlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import time
22
+ import uuid
23
+
24
+ from codebuff_core.legacy_paths import resolve_jupyter_config
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+ from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
28
+
29
+ import httpx
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ HISTORY_PATH = str(
34
+ resolve_jupyter_config("codebuff_rag_history.json", "hdsp_rag_history.json")
35
+ )
36
+
37
+ # 컬렉션 이름
38
+ COLLECTION_ENTERPRISE = "enterprise_tables"
39
+ COLLECTION_BDP = "bdp_tables"
40
+ COLLECTION_GUIDE = "guide_docs"
41
+ COLLECTION_DS_GUIDE = "ds_guide_docs"
42
+ COLLECTION_FAQ = "faq_docs"
43
+ ALL_COLLECTIONS = [
44
+ COLLECTION_ENTERPRISE,
45
+ COLLECTION_BDP,
46
+ COLLECTION_GUIDE,
47
+ COLLECTION_DS_GUIDE,
48
+ COLLECTION_FAQ,
49
+ ]
50
+ # L2(프로젝트/마트) 테이블 컬렉션 — 벡터 없는 payload-only, 외부 파이프라인이 적재.
51
+ # ALL_COLLECTIONS에 넣지 않는다: ensure_collections(dimension 기반 벡터 생성)와
52
+ # get_collection_stats(admin UI)가 이 리스트를 구동하는데 L2는 벡터도 없고
53
+ # codebuff가 소유하지도 않는다.
54
+ COLLECTION_BDP_L2 = "bdp_tables_l2"
55
+ # 비-테이블 컬렉션 (검색 시 _group_key grouping을 사용하지 않는 그룹)
56
+ GUIDE_COLLECTIONS = {COLLECTION_GUIDE, COLLECTION_DS_GUIDE, COLLECTION_FAQ}
57
+
58
+ _FQN_RE = re.compile(r"[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+")
59
+
60
+
61
+ def strip_fqn_to_table_name(value: str) -> str:
62
+ """'db.table' 형태(순수 식별자.식별자)면 마지막 세그먼트(테이블명)만 반환.
63
+
64
+ 에이전트가 FQN으로 검색하면 db명이 검색 노이즈가 되므로 테이블명만 사용.
65
+ 예: wdp_bdp_db_dlk_l1_unmsk_view.card_pchs → card_pchs
66
+ """
67
+ v = value.strip()
68
+ if _FQN_RE.fullmatch(v):
69
+ return v.rsplit(".", 1)[-1]
70
+ return v
71
+
72
+
73
+ # 청킹 기본값
74
+ DEFAULT_CHUNKING = {
75
+ "target_size": 800,
76
+ "min_size": 150,
77
+ "max_size": 2000,
78
+ "overlap": 0,
79
+ }
80
+
81
+ # ------------------------------------------------------------------
82
+ # 매니저 레지스트리 (chat_service에서 RAG 가용성 체크용)
83
+ # ------------------------------------------------------------------
84
+ _manager_instance: Optional["RAGCollectionManager"] = None
85
+
86
+
87
+ def register_manager(mgr: "RAGCollectionManager") -> None:
88
+ """RAGCollectionManager 싱글톤 등록."""
89
+ global _manager_instance
90
+ _manager_instance = mgr
91
+
92
+
93
+ def get_manager() -> Optional["RAGCollectionManager"]:
94
+ """등록된 RAGCollectionManager 반환."""
95
+ return _manager_instance
96
+
97
+
98
+ def is_rag_available() -> bool:
99
+ """RAG가 사용 가능한지 빠르게 확인. 매니저 등록 여부만 체크."""
100
+ return _manager_instance is not None
101
+
102
+
103
+ class RAGCollectionManager:
104
+ """3개 RAG 컬렉션의 생성/적재/삭제/검색을 관리."""
105
+
106
+ def __init__(
107
+ self,
108
+ qdrant_client: Any,
109
+ embedding_service: Any,
110
+ llm_config: Optional[Dict[str, Any]] = None,
111
+ ):
112
+ self._client = qdrant_client
113
+ self._embedding = embedding_service
114
+ self._llm_config = llm_config or self._load_llm_config()
115
+ self._progress: Dict[str, Any] = {}
116
+ self._upsert_lock = asyncio.Lock()
117
+ self._upsert_cancelled = False
118
+
119
+ def get_progress(self) -> Dict[str, Any]:
120
+ """현재 진행 상황 반환."""
121
+ return dict(self._progress)
122
+
123
+ def cancel_upsert(self) -> bool:
124
+ """진행 중인 적재를 중지 요청."""
125
+ if self._upsert_lock.locked():
126
+ self._upsert_cancelled = True
127
+ return True
128
+ return False
129
+
130
+ # ------------------------------------------------------------------
131
+ # LLM 설정 로드
132
+ # ------------------------------------------------------------------
133
+
134
+ @staticmethod
135
+ def _load_llm_config() -> Dict[str, Any]:
136
+ """LLM endpoint 로드 — get_config() 가 centri > env > 파일로 해석한 값 (이슈 #113)."""
137
+ try:
138
+ from codebuff_core.managers.config_manager import get_config_manager
139
+
140
+ cfg = get_config_manager().get_config()
141
+ vllm = cfg.get("vllm", {})
142
+ return {
143
+ "endpoint": vllm.get("endpoint", ""),
144
+ "api_key": vllm.get("apiKey", ""),
145
+ "model": vllm.get("model", ""),
146
+ }
147
+ except Exception:
148
+ return {"endpoint": "", "api_key": "", "model": ""}
149
+
150
+ # ------------------------------------------------------------------
151
+ # 컬렉션 관리
152
+ # ------------------------------------------------------------------
153
+
154
+ async def ensure_collections(self, dimension: int) -> None:
155
+ """3개 컬렉션 생성 (없으면)."""
156
+ from qdrant_client.models import Distance, VectorParams
157
+
158
+ existing = {c.name for c in self._client.get_collections().collections}
159
+ for name in ALL_COLLECTIONS:
160
+ if name not in existing:
161
+ self._client.create_collection(
162
+ collection_name=name,
163
+ vectors_config=VectorParams(
164
+ size=dimension, distance=Distance.COSINE
165
+ ),
166
+ )
167
+ logger.info("Collection created: %s (dim=%d)", name, dimension)
168
+
169
+ async def delete_collection(self, collection_name: str) -> None:
170
+ """컬렉션 삭제 후 재생성."""
171
+ if collection_name not in ALL_COLLECTIONS:
172
+ raise ValueError(f"Unknown collection: {collection_name}")
173
+ self._client.delete_collection(collection_name)
174
+ await self.ensure_collections(self._embedding.dimension)
175
+ logger.info("Collection reset: %s", collection_name)
176
+
177
+ async def get_collection_stats(self) -> Dict[str, Any]:
178
+ """3개 컬렉션 통계."""
179
+ existing = set()
180
+ try:
181
+ existing = {c.name for c in self._client.get_collections().collections}
182
+ except Exception as e:
183
+ logger.warning("get_collections failed: %s", e)
184
+
185
+ stats = {}
186
+ for name in ALL_COLLECTIONS:
187
+ if name not in existing:
188
+ stats[name] = {"points": 0, "vectors": 0, "status": "not_found"}
189
+ continue
190
+ try:
191
+ info = self._client.get_collection(name)
192
+ stats[name] = {
193
+ "points": info.points_count or 0,
194
+ "vectors": getattr(info, "vectors_count", 0) or 0,
195
+ "status": str(getattr(info.status, "value", info.status)),
196
+ }
197
+ except Exception as e:
198
+ logger.warning("get_collection(%s) failed: %s", name, e)
199
+ # count로 포인트 수 직접 카운트
200
+ try:
201
+ count = self._client.count(name).count
202
+ stats[name] = {"points": count, "vectors": count, "status": "ok"}
203
+ except Exception:
204
+ stats[name] = {"points": 0, "vectors": 0, "status": "error"}
205
+ return stats
206
+
207
+ # ------------------------------------------------------------------
208
+ # 테이블 컬렉션 적재 (enterprise_tables / bdp_tables)
209
+ # ------------------------------------------------------------------
210
+
211
+ async def upsert_table_collection(
212
+ self,
213
+ collection_name: str,
214
+ file_path: str,
215
+ mapping: Dict[str, Any],
216
+ join_file_path: Optional[str] = None,
217
+ join_key: Optional[str] = None,
218
+ ) -> Dict[str, Any]:
219
+ """CSV → 동적 매핑 → 임베딩 → upsert.
220
+
221
+ Args:
222
+ collection_name: "enterprise_tables" 또는 "bdp_tables"
223
+ file_path: 메인 CSV 경로
224
+ mapping: {groupBy, embeddingFields, tableFields, columnFields}
225
+ join_file_path: 두 번째 CSV (bdp_tables)
226
+ join_key: 조인 키
227
+ """
228
+ if self._upsert_lock.locked():
229
+ return {
230
+ "total": 0,
231
+ "errors": ["적재가 이미 진행 중입니다. 완료 후 다시 시도하세요."],
232
+ }
233
+
234
+ async with self._upsert_lock:
235
+ return await self._upsert_table_impl(
236
+ collection_name, file_path, mapping, join_file_path, join_key
237
+ )
238
+
239
+ async def _upsert_table_impl(
240
+ self,
241
+ collection_name: str,
242
+ file_path: str,
243
+ mapping: Dict[str, Any],
244
+ join_file_path: Optional[str] = None,
245
+ join_key: Optional[str] = None,
246
+ ) -> Dict[str, Any]:
247
+ start = time.time()
248
+ errors: List[str] = []
249
+ self._progress = {
250
+ "status": "running",
251
+ "total": 0,
252
+ "done": 0,
253
+ "collection": collection_name,
254
+ }
255
+
256
+ # CSV 파싱
257
+ rows = self._read_csv(file_path)
258
+ if join_file_path and join_key:
259
+ column_rows = self._read_csv(join_file_path)
260
+ rows = self._join_csvs(rows, column_rows, join_key)
261
+
262
+ # 그룹핑 (str 또는 list)
263
+ group_by = mapping.get("groupBy", [])
264
+ if not group_by:
265
+ raise ValueError("mapping.groupBy is required")
266
+
267
+ groups = self._group_rows(rows, group_by)
268
+ logger.info(
269
+ "Parsed %d groups from %s (rows=%d)", len(groups), file_path, len(rows)
270
+ )
271
+
272
+ # 임베딩 텍스트 생성 + payload 구성
273
+ embedding_fields = mapping.get("embeddingFields", [])
274
+ table_fields = mapping.get("tableFields", [])
275
+ column_fields = mapping.get("columnFields", [])
276
+ # 임베딩 강조 필드 (헤더+라벨 자리에 박힘). 미지정 시 tableFields[:2]
277
+ header_fields = mapping.get("headerFields") or table_fields[:2]
278
+
279
+ # 컬렉션 확보 (없으면 생성, 있으면 유지)
280
+ await self.ensure_collections(self._embedding.dimension)
281
+
282
+ # indexFields가 있으면 text payload index 생성
283
+ index_fields = mapping.get("indexFields", [])
284
+ if index_fields:
285
+ self._ensure_text_indexes(collection_name, index_fields)
286
+
287
+ # type 필터링을 위한 keyword index
288
+ self._ensure_keyword_index(collection_name, "type")
289
+
290
+ # 기존 포인트의 content hash 조회 (변경 감지용)
291
+ existing_hashes: Dict[str, str] = {} # pid → _content_hash
292
+ try:
293
+ scroll_offset = None
294
+ while True:
295
+ pts, scroll_offset = self._client.scroll(
296
+ collection_name=collection_name,
297
+ offset=scroll_offset,
298
+ limit=100,
299
+ with_payload=["_content_hash"],
300
+ with_vectors=False,
301
+ )
302
+ for pt in pts:
303
+ h = (pt.payload or {}).get("_content_hash", "")
304
+ existing_hashes[str(pt.id)] = h
305
+ if scroll_offset is None:
306
+ break
307
+ except Exception:
308
+ pass # 컬렉션이 비었거나 에러 → 전체 적재
309
+
310
+ texts: List[str] = []
311
+ payloads: List[Dict[str, Any]] = []
312
+ point_ids: List[str] = []
313
+ skipped_unchanged = 0
314
+
315
+ MAX_COLUMNS_PER_CHUNK = 200
316
+
317
+ def _make_table_payload(
318
+ table_row: Dict[str, str],
319
+ chunk_rows: List[Dict[str, str]],
320
+ chunk_marker: str,
321
+ chunk_type: str,
322
+ embed_text: str,
323
+ content_hash: str,
324
+ ) -> Dict[str, Any]:
325
+ """type=table / type=columns 공통 payload 생성 (동일 결과 보장).
326
+
327
+ table_row: join 이전 원본 main row (table-level 메타). __main_row__
328
+ 보존 키에서 가져오거나, join이 없으면 group_rows[0] 그대로.
329
+ """
330
+ payload: Dict[str, Any] = {
331
+ "_collection": collection_name,
332
+ "_embed_text": embed_text,
333
+ "_group_key": group_key,
334
+ "_content_hash": content_hash,
335
+ "type": chunk_type,
336
+ }
337
+ if chunk_marker:
338
+ payload["_chunk"] = chunk_marker
339
+ for tf in table_fields:
340
+ if tf in table_row:
341
+ payload[tf] = table_row[tf]
342
+ payload["columns"] = [
343
+ {cf: r[cf] for cf in column_fields if cf in r} for r in chunk_rows
344
+ ]
345
+ return payload
346
+
347
+ def _enqueue(pid: str, embed_text: str, payload: Dict[str, Any]) -> bool:
348
+ """해시 비교 후 큐에 추가. skip 시 False."""
349
+ nonlocal skipped_unchanged
350
+ ch = payload["_content_hash"]
351
+ if pid in existing_hashes and existing_hashes[pid] == ch:
352
+ skipped_unchanged += 1
353
+ return False
354
+ texts.append(embed_text)
355
+ payloads.append(payload)
356
+ point_ids.append(pid)
357
+ return True
358
+
359
+ for group_key, group_rows in groups.items():
360
+ first_row = group_rows[0]
361
+ # join이 있었으면 __main_row__로 table-level 메타 복원
362
+ # (column row가 description/korean_name 등을 덮어쓴 경우 대비)
363
+ table_row = first_row.get(self._MAIN_ROW_KEY) or first_row
364
+ num_columns = len(group_rows)
365
+ total_col_chunks = (
366
+ num_columns + MAX_COLUMNS_PER_CHUNK - 1
367
+ ) // MAX_COLUMNS_PER_CHUNK
368
+ total_col_chunks = max(total_col_chunks, 1)
369
+
370
+ # ── 1) type=table 청크 (테이블당 1개, 모든 컬럼 payload 포함) ──
371
+ table_text = self._build_table_meta_embed_text(
372
+ table_row, group_rows, table_fields, embedding_fields, header_fields
373
+ )
374
+ if table_text.strip():
375
+ table_hash = hashlib.sha256(table_text.encode("utf-8")).hexdigest()[:16]
376
+ table_pid = str(
377
+ uuid.uuid5(
378
+ uuid.NAMESPACE_DNS,
379
+ f"{collection_name}:{group_key}:table",
380
+ )
381
+ )
382
+ # type=table은 컬럼 청크가 여러 개여도 payload에 전체 컬럼 보존
383
+ # 단, 너무 많으면 MAX*총_청크수까지만 (Qdrant payload 보호)
384
+ _table_payload_rows = group_rows[
385
+ : MAX_COLUMNS_PER_CHUNK * total_col_chunks
386
+ ]
387
+ _chunk_marker = f"1/{total_col_chunks}" if total_col_chunks > 1 else ""
388
+ table_payload = _make_table_payload(
389
+ table_row,
390
+ _table_payload_rows,
391
+ _chunk_marker,
392
+ "table",
393
+ table_text,
394
+ table_hash,
395
+ )
396
+ _enqueue(table_pid, table_text, table_payload)
397
+
398
+ # ── 2) type=columns 청크들 (컬럼 수에 따라 1+개) ──
399
+ for chunk_idx in range(0, num_columns, MAX_COLUMNS_PER_CHUNK):
400
+ chunk_rows = group_rows[chunk_idx : chunk_idx + MAX_COLUMNS_PER_CHUNK]
401
+ # 헤더용 테이블명은 table_row에서 (메타 보존), 컬럼 본문은
402
+ # chunk_rows에서 (column-level description 등 활용)
403
+ cols_text = self._build_columns_embed_text(
404
+ table_row, chunk_rows, table_fields, column_fields, header_fields
405
+ )
406
+ if not cols_text.strip():
407
+ continue
408
+ cols_hash = hashlib.sha256(cols_text.encode("utf-8")).hexdigest()[:16]
409
+ chunk_n = chunk_idx // MAX_COLUMNS_PER_CHUNK + 1
410
+ cols_pid = str(
411
+ uuid.uuid5(
412
+ uuid.NAMESPACE_DNS,
413
+ f"{collection_name}:{group_key}:columns:{chunk_idx}",
414
+ )
415
+ )
416
+ _chunk_marker = (
417
+ f"{chunk_n}/{total_col_chunks}" if total_col_chunks > 1 else ""
418
+ )
419
+ cols_payload = _make_table_payload(
420
+ table_row,
421
+ chunk_rows,
422
+ _chunk_marker,
423
+ "columns",
424
+ cols_text,
425
+ cols_hash,
426
+ )
427
+ _enqueue(cols_pid, cols_text, cols_payload)
428
+
429
+ if not texts:
430
+ return {
431
+ "total": 0,
432
+ "skipped_unchanged": skipped_unchanged,
433
+ "errors": [] if skipped_unchanged else ["No valid groups found"],
434
+ }
435
+
436
+ # 배치 임베딩 + upsert (deterministic ID 사용)
437
+ upserted = await self._batch_upsert(
438
+ collection_name, texts, payloads, point_ids=point_ids
439
+ )
440
+
441
+ elapsed = round(time.time() - start, 1)
442
+ self._record_history(collection_name, "upsert", upserted, errors, elapsed)
443
+ return {
444
+ "total": upserted,
445
+ "skipped_unchanged": skipped_unchanged,
446
+ "groups": len(groups),
447
+ "elapsed_s": elapsed,
448
+ "errors": errors,
449
+ }
450
+
451
+ # ------------------------------------------------------------------
452
+ # 가이드 3단계 파이프라인
453
+ # ------------------------------------------------------------------
454
+
455
+ MAX_FILE_CHARS = 100_000 # 10만자 초과 시 skip
456
+
457
+ _STEP1_CONCURRENCY = 3 # 1단계 LLM 동시 호출 수
458
+
459
+ async def guide_pipeline_step1(
460
+ self,
461
+ source_dir: str,
462
+ ) -> AsyncGenerator[Dict[str, Any], None]:
463
+ """1단계: 원본 파일 → 벡터 임베딩용 MD 재구성 (3개 병렬).
464
+
465
+ 모든 파일(.md 포함)을 LLM으로 재구성.
466
+ 출력: {source_dir}_mdversion/ 에 .md 파일 저장.
467
+
468
+ Yields:
469
+ {"status": "running", "current": N, "total": M, "file": "name"}
470
+ {"status": "error", "file": "name", "error": "reason"}
471
+ {"status": "complete", ...}
472
+ """
473
+ p = Path(source_dir)
474
+ out_dir = Path(f"{source_dir}_mdversion")
475
+ out_dir.mkdir(parents=True, exist_ok=True)
476
+
477
+ all_files = sorted(
478
+ f
479
+ for f in p.rglob("*")
480
+ if f.is_file()
481
+ and not f.name.startswith(".")
482
+ and not any(part.startswith(".") for part in f.relative_to(p).parts[:-1])
483
+ )
484
+
485
+ total = len(all_files)
486
+ sem = asyncio.Semaphore(self._STEP1_CONCURRENCY)
487
+ queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
488
+ done_count = 0
489
+ success_count = 0
490
+ error_count = 0
491
+
492
+ async def _process_one(fp: Path) -> None:
493
+ nonlocal done_count, success_count, error_count
494
+ async with sem:
495
+ await queue.put(
496
+ {
497
+ "status": "running",
498
+ "current": done_count + 1,
499
+ "total": total,
500
+ "file": fp.name,
501
+ }
502
+ )
503
+ try:
504
+ content = fp.read_text(encoding="utf-8")
505
+
506
+ if len(content) > self.MAX_FILE_CHARS:
507
+ await queue.put(
508
+ {
509
+ "status": "error",
510
+ "file": fp.name,
511
+ "error": f"파일이 너무 큽니다 ({len(content)}자). 수동 분할 필요.",
512
+ }
513
+ )
514
+ error_count += 1
515
+ return
516
+
517
+ # source URL 추출 후 LLM 변환 전에 제거
518
+ src_url = self._extract_confluence_url(content)
519
+ if src_url:
520
+ content = re.sub(
521
+ r"^\[source:\s*[^\]]*\]\s*\n?",
522
+ "",
523
+ content,
524
+ count=1,
525
+ ).lstrip()
526
+
527
+ result = await self._convert_to_markdown(content, fp.name)
528
+
529
+ if not result or not result.strip():
530
+ await queue.put(
531
+ {"status": "error", "file": fp.name, "error": "LLM 빈 응답"}
532
+ )
533
+ error_count += 1
534
+ return
535
+
536
+ if len(result.strip()) < len(content) * 0.5:
537
+ logger.warning(
538
+ "MD conversion may be truncated: %s (in=%d, out=%d)",
539
+ fp.name,
540
+ len(content),
541
+ len(result),
542
+ )
543
+
544
+ # source URL을 md 첫 줄에 보존
545
+ md_content = result.strip()
546
+ if src_url:
547
+ md_content = f"[source: {src_url}]\n\n{md_content}"
548
+
549
+ out_path = out_dir / f"{fp.stem}.md"
550
+ out_path.write_text(md_content, encoding="utf-8")
551
+ success_count += 1
552
+
553
+ except Exception as e:
554
+ await queue.put(
555
+ {"status": "error", "file": fp.name, "error": str(e)}
556
+ )
557
+ error_count += 1
558
+ finally:
559
+ done_count += 1
560
+
561
+ # 병렬 태스크 시작
562
+ tasks = [asyncio.create_task(_process_one(fp)) for fp in all_files]
563
+
564
+ # 큐에서 이벤트를 꺼내서 yield (모든 태스크 완료까지)
565
+ while done_count < total:
566
+ try:
567
+ event = await asyncio.wait_for(queue.get(), timeout=1.0)
568
+ yield event
569
+ except asyncio.TimeoutError:
570
+ continue
571
+
572
+ # 잔여 이벤트 drain
573
+ while not queue.empty():
574
+ yield queue.get_nowait()
575
+
576
+ # 모든 태스크 완료 대기
577
+ await asyncio.gather(*tasks, return_exceptions=True)
578
+
579
+ yield {
580
+ "status": "complete",
581
+ "total": total,
582
+ "success": success_count,
583
+ "errors": error_count,
584
+ "outputDir": str(out_dir),
585
+ }
586
+
587
+ async def guide_pipeline_step2(
588
+ self,
589
+ source_dir: str,
590
+ ) -> AsyncGenerator[Dict[str, Any], None]:
591
+ """2단계: _mdversion/ 파일 → 청킹 + 요약 → _chunked/ 에 개별 파일 저장.
592
+
593
+ Yields:
594
+ {"status": "running", "current": N, "total": M, "file": "name", "phase": "..."}
595
+ {"status": "complete", "total_files": N, "total_chunks": M, "outputDir": "path"}
596
+ """
597
+ md_dir = Path(f"{source_dir}_mdversion")
598
+ chunk_dir = Path(f"{source_dir}_chunked")
599
+ chunk_dir.mkdir(parents=True, exist_ok=True)
600
+
601
+ # 기존 청크 파일 삭제 (재실행 시 깨끗하게)
602
+ for old in chunk_dir.glob("*.md"):
603
+ old.unlink()
604
+
605
+ md_files = sorted(md_dir.glob("*.md"))
606
+ cfg = {**DEFAULT_CHUNKING}
607
+ total_chunks = 0
608
+
609
+ for i, fp in enumerate(md_files):
610
+ yield {
611
+ "status": "running",
612
+ "current": i + 1,
613
+ "total": len(md_files),
614
+ "file": fp.name,
615
+ "phase": "chunking",
616
+ }
617
+
618
+ content = fp.read_text(encoding="utf-8")
619
+ confluence_url = self._extract_confluence_url(content)
620
+ if confluence_url:
621
+ # 첫 줄 source URL을 content에서 제거
622
+ content = re.sub(
623
+ r"^\[source:\s*[^\]]*\]\s*\n?", "", content, count=1
624
+ ).lstrip()
625
+ title = self._extract_title(content, fp.name)
626
+ chunks = self._chunk_markdown(content, cfg)
627
+
628
+ if not chunks:
629
+ continue
630
+
631
+ yield {
632
+ "status": "running",
633
+ "current": i + 1,
634
+ "total": len(md_files),
635
+ "file": fp.name,
636
+ "phase": "summary",
637
+ }
638
+ summaries = await self._generate_chunk_summaries(chunks, title)
639
+
640
+ for j, (chunk, summary) in enumerate(zip(chunks, summaries)):
641
+ section = chunk.get("section", "")
642
+ # YAML frontmatter에서 따옴표 이스케이프
643
+ safe_section = section.replace('"', '\\"')
644
+ safe_summary = summary.replace('"', '\\"')
645
+
646
+ frontmatter = (
647
+ f"---\n"
648
+ f'source: "{fp.name}"\n'
649
+ f'section: "{safe_section}"\n'
650
+ f'summary: "{safe_summary}"\n'
651
+ f"chunk_index: {j + 1}\n"
652
+ f"total_chunks: {len(chunks)}\n"
653
+ + (
654
+ f'confluence_url: "{confluence_url}"\n'
655
+ if confluence_url
656
+ else ""
657
+ )
658
+ + "---\n\n"
659
+ )
660
+ chunk_path = chunk_dir / f"{fp.stem}_{j + 1:03d}.md"
661
+ chunk_path.write_text(frontmatter + chunk["content"], encoding="utf-8")
662
+ total_chunks += 1
663
+
664
+ yield {
665
+ "status": "complete",
666
+ "total_files": len(md_files),
667
+ "total_chunks": total_chunks,
668
+ "outputDir": str(chunk_dir),
669
+ }
670
+
671
+ async def guide_pipeline_step3(
672
+ self,
673
+ source_dir: str,
674
+ collection_name: str = COLLECTION_GUIDE,
675
+ ) -> AsyncGenerator[Dict[str, Any], None]:
676
+ """3단계: _chunked/ 파일 → 임베딩 + Qdrant upsert.
677
+
678
+ Yields:
679
+ {"status": "running", "current": N, "total": M, "file": "name"}
680
+ {"status": "complete", "total": N, "upserted": M}
681
+ """
682
+ chunk_dir = Path(f"{source_dir}_chunked")
683
+ chunk_files = sorted(chunk_dir.glob("*.md"))
684
+
685
+ # 컬렉션 초기화 후 재적재
686
+ try:
687
+ self._client.delete_collection(collection_name)
688
+ except Exception:
689
+ pass
690
+ await self.ensure_collections(self._embedding.dimension)
691
+
692
+ texts: List[str] = []
693
+ payloads: List[Dict[str, Any]] = []
694
+
695
+ for i, fp in enumerate(chunk_files):
696
+ yield {
697
+ "status": "running",
698
+ "current": i + 1,
699
+ "total": len(chunk_files),
700
+ "file": fp.name,
701
+ }
702
+
703
+ raw = fp.read_text(encoding="utf-8")
704
+ meta, content = self._parse_chunk_file(raw)
705
+
706
+ source = meta.get("source", fp.name)
707
+ section = meta.get("section", "")
708
+ summary = meta.get("summary", "")
709
+ title = self._extract_title(content, source)
710
+
711
+ prefix = f"[{title}"
712
+ if section:
713
+ prefix += f" > {section}"
714
+ prefix += "]"
715
+
716
+ embed_text = f"{prefix}\n{summary}\n\n{content}"
717
+ texts.append(embed_text)
718
+ confluence_url = meta.get("confluence_url", "") or meta.get(
719
+ "source_url", ""
720
+ )
721
+ payload_entry: Dict[str, Any] = {
722
+ "content": content,
723
+ "summary": summary,
724
+ "source": source,
725
+ "title": title,
726
+ "section": section,
727
+ "_collection": collection_name,
728
+ "_embed_text": embed_text,
729
+ }
730
+ if confluence_url:
731
+ payload_entry["confluence_url"] = confluence_url
732
+ payloads.append(payload_entry)
733
+
734
+ # 적재 직전 PII 마스킹 (bdp_guides=guide_docs 컬렉션 한정)
735
+ if collection_name == COLLECTION_GUIDE:
736
+ texts, payloads = self._mask_pii_for_ingestion(
737
+ texts, payloads, collection_name
738
+ )
739
+
740
+ upserted = await self._batch_upsert(collection_name, texts, payloads)
741
+
742
+ yield {
743
+ "status": "complete",
744
+ "total": len(chunk_files),
745
+ "upserted": upserted,
746
+ }
747
+
748
+ @staticmethod
749
+ def _parse_chunk_file(raw: str) -> Tuple[Dict[str, Any], str]:
750
+ """YAML frontmatter + content 파싱."""
751
+ if raw.startswith("---"):
752
+ parts = raw.split("---", 2)
753
+ if len(parts) >= 3:
754
+ import yaml
755
+
756
+ try:
757
+ meta = yaml.safe_load(parts[1]) or {}
758
+ except Exception:
759
+ meta = {}
760
+ content = parts[2].strip()
761
+ return meta, content
762
+ return {}, raw.strip()
763
+
764
+ def guide_pipeline_status(self, source_dir: str) -> Dict[str, Any]:
765
+ """각 단계별 산출물 존재 여부 확인."""
766
+ md_dir = Path(f"{source_dir}_mdversion")
767
+ chunk_dir = Path(f"{source_dir}_chunked")
768
+ return {
769
+ "mdversion": {
770
+ "exists": md_dir.is_dir(),
771
+ "files": len(list(md_dir.glob("*.md"))) if md_dir.is_dir() else 0,
772
+ },
773
+ "chunked": {
774
+ "exists": chunk_dir.is_dir(),
775
+ "files": len(list(chunk_dir.glob("*.md"))) if chunk_dir.is_dir() else 0,
776
+ },
777
+ }
778
+
779
+ # ------------------------------------------------------------------
780
+ # 가이드 문서 적재 (레거시 — 하위 호환)
781
+ # ------------------------------------------------------------------
782
+
783
+ async def upsert_guide_directory(
784
+ self,
785
+ dir_path: str,
786
+ chunking_config: Optional[Dict[str, int]] = None,
787
+ collection_name: str = COLLECTION_GUIDE,
788
+ ) -> Dict[str, Any]:
789
+ """디렉토리 내 모든 파일 순차 처리 → guide 컬렉션 적재."""
790
+ start = time.time()
791
+ cfg = {**DEFAULT_CHUNKING, **(chunking_config or {})}
792
+ p = Path(dir_path)
793
+ if not p.is_dir():
794
+ return {"error": f"Not a directory: {dir_path}"}
795
+
796
+ # 모든 파일 수집 (숨김 디렉토리 .ipynb_checkpoints 등 제외)
797
+ all_files = sorted(
798
+ f
799
+ for f in p.rglob("*")
800
+ if f.is_file()
801
+ and not f.name.startswith(".")
802
+ and not any(part.startswith(".") for part in f.relative_to(p).parts[:-1])
803
+ )
804
+ total_chunks = 0
805
+ converted = 0
806
+ errors: List[Dict[str, str]] = []
807
+
808
+ # 진행 상황 초기화
809
+ self._progress = {
810
+ "status": "running",
811
+ "total_files": len(all_files),
812
+ "processed_files": 0,
813
+ "current_file": "",
814
+ "total_chunks": 0,
815
+ "summary_total": 0,
816
+ "summary_done": 0,
817
+ }
818
+
819
+ # 기존 컬렉션 전체 삭제 후 재적재
820
+ self._client.delete_collection(collection_name)
821
+ await self.ensure_collections(self._embedding.dimension)
822
+
823
+ for fp in all_files:
824
+ self._progress["current_file"] = fp.name
825
+ try:
826
+ result = await self._process_guide_file(fp, cfg, collection_name)
827
+ total_chunks += result.get("chunks", 0)
828
+ self._progress["total_chunks"] = total_chunks
829
+ if result.get("converted"):
830
+ converted += 1
831
+ except Exception as e:
832
+ errors.append({"file": str(fp), "error": str(e)})
833
+ logger.error("Guide file error %s: %s", fp, e)
834
+ self._progress["processed_files"] += 1
835
+
836
+ elapsed = round(time.time() - start, 1)
837
+ self._progress = {
838
+ "status": "done",
839
+ "total_files": len(all_files),
840
+ "processed_files": len(all_files),
841
+ "total_chunks": total_chunks,
842
+ "elapsed_s": elapsed,
843
+ }
844
+ self._record_history(
845
+ collection_name,
846
+ "upsert_directory",
847
+ total_chunks,
848
+ [e["error"] for e in errors],
849
+ elapsed,
850
+ )
851
+ return {
852
+ "total_files": len(all_files),
853
+ "total_chunks": total_chunks,
854
+ "converted_files": converted,
855
+ "errors": errors,
856
+ "elapsed_s": elapsed,
857
+ }
858
+
859
+ async def upsert_guide_file(
860
+ self,
861
+ file_path: str,
862
+ chunking_config: Optional[Dict[str, int]] = None,
863
+ collection_name: str = COLLECTION_GUIDE,
864
+ ) -> Dict[str, Any]:
865
+ """guide 컬렉션 파일 1개만 추가/갱신 (해시 비교)."""
866
+ cfg = {**DEFAULT_CHUNKING, **(chunking_config or {})}
867
+ fp = Path(file_path)
868
+ if not fp.is_file():
869
+ return {"error": f"Not a file: {file_path}"}
870
+
871
+ source = fp.name
872
+ file_hash = self._file_hash(fp)
873
+
874
+ # 기존 동일 source 포인트 해시 비교
875
+ existing = self._scroll_by_source(collection_name, source, limit=1)
876
+ if existing and existing[0].payload.get("_file_hash") == file_hash:
877
+ return {"status": "unchanged", "source": source}
878
+
879
+ # 기존 포인트 삭제
880
+ self._delete_by_source(collection_name, source)
881
+
882
+ result = await self._process_guide_file(fp, cfg, collection_name)
883
+ return {"status": "updated", "source": source, **result}
884
+
885
+ async def delete_guide_file(
886
+ self, source: str, collection_name: str = COLLECTION_GUIDE
887
+ ) -> Dict[str, Any]:
888
+ """guide 컬렉션에서 특정 source 포인트 삭제."""
889
+ count = self._delete_by_source(collection_name, source)
890
+ return {"deleted": count, "source": source}
891
+
892
+ def _mask_pii_for_ingestion(
893
+ self,
894
+ texts: List[str],
895
+ payloads: List[Dict[str, Any]],
896
+ collection_name: str,
897
+ ) -> Tuple[List[str], List[Dict[str, Any]]]:
898
+ """RAG 적재 직전 PII 마스킹 후처리.
899
+
900
+ 임베딩 대상 텍스트와 payload의 텍스트 필드를 마스킹해 원본 PII가
901
+ 벡터/저장값 어디에도 남지 않도록 한다. guide_docs/faq_docs 적재에만 호출.
902
+ """
903
+ from codebuff_core.services.pii_guardrail import mask_pii
904
+
905
+ mask_fields = (
906
+ "content",
907
+ "summary",
908
+ "title",
909
+ "section",
910
+ "question",
911
+ "answer",
912
+ "tags",
913
+ )
914
+ masked_texts: List[str] = []
915
+ chunks_with_pii = 0
916
+ detected: set = set()
917
+
918
+ for text, payload in zip(texts, payloads):
919
+ res = mask_pii(text)
920
+ masked_texts.append(res.masked_text)
921
+ if res.has_pii:
922
+ chunks_with_pii += 1
923
+ detected.update(res.detected_types)
924
+ for key in mask_fields:
925
+ val = payload.get(key)
926
+ if isinstance(val, str) and val:
927
+ field_res = mask_pii(val)
928
+ if field_res.has_pii:
929
+ payload[key] = field_res.masked_text
930
+ detected.update(field_res.detected_types)
931
+ payload["_embed_text"] = res.masked_text
932
+
933
+ if chunks_with_pii:
934
+ logger.info(
935
+ "[PII] %s 적재 마스킹: %d/%d chunks, types=%s",
936
+ collection_name,
937
+ chunks_with_pii,
938
+ len(texts),
939
+ sorted(detected),
940
+ )
941
+ return masked_texts, payloads
942
+
943
+ async def _process_guide_file(
944
+ self, fp: Path, cfg: Dict[str, int], collection_name: str = COLLECTION_GUIDE
945
+ ) -> Dict[str, Any]:
946
+ """파일 1개 → md 변환 → 청킹 → 요약 → 임베딩 → upsert."""
947
+ content = fp.read_text(encoding="utf-8")
948
+ converted = False
949
+
950
+ # 비-md 파일 → 마크다운 변환
951
+ if fp.suffix.lower() != ".md":
952
+ content = await self._convert_to_markdown(content, fp.name)
953
+ # 변환 결과 저장
954
+ md_path = fp.with_suffix(".md")
955
+ md_path.write_text(content, encoding="utf-8")
956
+ converted = True
957
+
958
+ source = fp.name
959
+ file_hash = self._file_hash(fp)
960
+
961
+ # 제목 추출
962
+ title = self._extract_title(content, fp.name)
963
+
964
+ # 청킹
965
+ chunks = self._chunk_markdown(content, cfg)
966
+ if not chunks:
967
+ return {"chunks": 0, "converted": converted}
968
+
969
+ # 요약 생성
970
+ summaries = await self._generate_chunk_summaries(chunks, title)
971
+
972
+ # 임베딩 텍스트 + payload 구성
973
+ texts: List[str] = []
974
+ payloads: List[Dict[str, Any]] = []
975
+
976
+ for chunk, summary in zip(chunks, summaries):
977
+ section = chunk.get("section", "")
978
+ prefix = f"[{title}"
979
+ if section:
980
+ prefix += f" > {section}"
981
+ prefix += "]"
982
+
983
+ embed_text = f"{prefix}\n{summary}\n\n{chunk['content']}"
984
+ texts.append(embed_text)
985
+ payloads.append(
986
+ {
987
+ "content": chunk["content"],
988
+ "summary": summary,
989
+ "source": source,
990
+ "title": title,
991
+ "section": section,
992
+ "_collection": collection_name,
993
+ "_embed_text": embed_text,
994
+ "_file_hash": file_hash,
995
+ }
996
+ )
997
+
998
+ # 적재 직전 PII 마스킹 (bdp_guides=guide_docs 컬렉션 한정)
999
+ if collection_name == COLLECTION_GUIDE:
1000
+ texts, payloads = self._mask_pii_for_ingestion(
1001
+ texts, payloads, collection_name
1002
+ )
1003
+
1004
+ upserted = await self._batch_upsert(collection_name, texts, payloads)
1005
+ return {"chunks": upserted, "converted": converted}
1006
+
1007
+ # ------------------------------------------------------------------
1008
+ # FAQ 컬렉션 적재 (faq_docs)
1009
+ # ------------------------------------------------------------------
1010
+
1011
+ async def upsert_faq_collection(self, file_path: str) -> Dict[str, Any]:
1012
+ """CSV(question, answer, tags) → faq_docs 컬렉션 적재.
1013
+
1014
+ - 임베딩 텍스트: question만 사용 (Question-to-Question 정렬로 정확도 ↑)
1015
+ - payload: question, answer, tags, _embed_text, _file_hash
1016
+ - 기존 hash와 동일하면 skip (변경분만 재임베딩)
1017
+ """
1018
+ start = time.time()
1019
+ errors: List[str] = []
1020
+
1021
+ rows = self._read_csv(file_path)
1022
+ if not rows:
1023
+ return {"total": 0, "errors": ["CSV is empty"]}
1024
+
1025
+ # 필수 컬럼 확인
1026
+ first = rows[0]
1027
+ if "question" not in first or "answer" not in first:
1028
+ raise ValueError(
1029
+ "FAQ CSV must contain 'question' and 'answer' columns "
1030
+ f"(got: {list(first.keys())})"
1031
+ )
1032
+
1033
+ await self.ensure_collections(self._embedding.dimension)
1034
+
1035
+ # 기존 포인트의 content hash 조회 (변경 감지)
1036
+ existing_hashes: Dict[str, str] = {}
1037
+ try:
1038
+ scroll_offset = None
1039
+ while True:
1040
+ pts, scroll_offset = self._client.scroll(
1041
+ collection_name=COLLECTION_FAQ,
1042
+ offset=scroll_offset,
1043
+ limit=200,
1044
+ with_payload=["_content_hash"],
1045
+ with_vectors=False,
1046
+ )
1047
+ for pt in pts:
1048
+ h = (pt.payload or {}).get("_content_hash", "")
1049
+ existing_hashes[str(pt.id)] = h
1050
+ if scroll_offset is None:
1051
+ break
1052
+ except Exception:
1053
+ pass
1054
+
1055
+ self._progress = {
1056
+ "status": "running",
1057
+ "total": len(rows),
1058
+ "done": 0,
1059
+ "collection": COLLECTION_FAQ,
1060
+ }
1061
+
1062
+ texts: List[str] = []
1063
+ payloads: List[Dict[str, Any]] = []
1064
+ point_ids: List[str] = []
1065
+ skipped_unchanged = 0
1066
+
1067
+ for row in rows:
1068
+ q = str(row.get("question") or "").strip()
1069
+ a = str(row.get("answer") or "").strip()
1070
+ if not q or not a:
1071
+ continue
1072
+ tags = str(row.get("tags") or "").strip()
1073
+
1074
+ # point id는 질문 내용으로 deterministic
1075
+ pid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{COLLECTION_FAQ}:{q}"))
1076
+
1077
+ # 임베딩 텍스트 = question만 (Q↔Q 정렬)
1078
+ embed_text = q
1079
+ content_hash = hashlib.sha256(
1080
+ f"{q}\n{a}\n{tags}".encode("utf-8")
1081
+ ).hexdigest()[:16]
1082
+
1083
+ if pid in existing_hashes and existing_hashes[pid] == content_hash:
1084
+ skipped_unchanged += 1
1085
+ continue
1086
+
1087
+ payload: Dict[str, Any] = {
1088
+ "_collection": COLLECTION_FAQ,
1089
+ "_embed_text": embed_text,
1090
+ "_content_hash": content_hash,
1091
+ "question": q,
1092
+ "answer": a,
1093
+ }
1094
+ if tags:
1095
+ payload["tags"] = tags
1096
+
1097
+ texts.append(embed_text)
1098
+ payloads.append(payload)
1099
+ point_ids.append(pid)
1100
+
1101
+ if not texts:
1102
+ return {
1103
+ "total": 0,
1104
+ "skipped_unchanged": skipped_unchanged,
1105
+ "errors": [] if skipped_unchanged else ["No valid rows"],
1106
+ }
1107
+
1108
+ # 적재 직전 PII 마스킹 (faq_docs 컬렉션)
1109
+ texts, payloads = self._mask_pii_for_ingestion(texts, payloads, COLLECTION_FAQ)
1110
+
1111
+ upserted = await self._batch_upsert(
1112
+ COLLECTION_FAQ, texts, payloads, point_ids=point_ids
1113
+ )
1114
+
1115
+ elapsed = round(time.time() - start, 1)
1116
+ self._record_history(COLLECTION_FAQ, "upsert", upserted, errors, elapsed)
1117
+ return {
1118
+ "total": upserted,
1119
+ "skipped_unchanged": skipped_unchanged,
1120
+ "rows": len(rows),
1121
+ "elapsed_s": elapsed,
1122
+ "errors": errors,
1123
+ }
1124
+
1125
+ # ------------------------------------------------------------------
1126
+ # 마크다운 변환
1127
+ # ------------------------------------------------------------------
1128
+
1129
+ async def _convert_to_markdown(self, content: str, filename: str) -> str:
1130
+ """비-md 파일 → 마크다운 구조화 (내용 100% 보존, 포맷만 변경)."""
1131
+ from codebuff_core.managers.config_manager import prompt_value
1132
+
1133
+ # 지시부는 `prompts.guide_ingest_markdown_prompt`, 파일명·원본은 코드가 붙인다 (이슈 #218)
1134
+ prompt = (
1135
+ prompt_value("guide_ingest_markdown_prompt") + "\n\n"
1136
+ f"파일명: {filename}\n\n"
1137
+ f"원본:\n{content}"
1138
+ )
1139
+ result, reason = await self._call_llm(prompt, return_finish_reason=True)
1140
+ print(
1141
+ f"[RAG] MD convert: {filename} finish_reason={reason} "
1142
+ f"in={len(content)} out={len(result or '')}",
1143
+ flush=True,
1144
+ )
1145
+
1146
+ if not result or not result.strip():
1147
+ return content
1148
+
1149
+ # 출력이 잘린 경우 이어쓰기 (최대 3회)
1150
+ max_continuations = 3
1151
+ full_result = result
1152
+ for cont_i in range(max_continuations):
1153
+ if reason != "length":
1154
+ break
1155
+ logger.info(
1156
+ "MD conversion truncated for %s, continuing (%d/%d)",
1157
+ filename,
1158
+ cont_i + 1,
1159
+ max_continuations,
1160
+ )
1161
+ # 이미 변환된 부분에 해당하는 원본 위치를 추정하여 나머지만 전달
1162
+ # 변환 결과의 마지막 5줄에서 키워드를 추출하여 원본에서 위치 탐색
1163
+ last_lines = full_result.strip().splitlines()[-5:]
1164
+ remaining = content
1165
+ for line in last_lines:
1166
+ # 마크다운 포맷 제거 후 원본에서 검색
1167
+ clean = line.strip().lstrip("#").strip().strip("*").strip()
1168
+ if len(clean) > 15:
1169
+ idx = content.find(clean)
1170
+ if idx > 0:
1171
+ # 해당 위치 이후의 원본을 remaining으로
1172
+ remaining = content[idx + len(clean) :]
1173
+ break
1174
+
1175
+ last_output = "\n".join(last_lines)
1176
+ cont_prompt = (
1177
+ "이전 마크다운 변환이 출력 한도에 도달하여 중단되었습니다.\n"
1178
+ "아래 규칙을 반드시 지켜주세요:\n"
1179
+ "1. '이전 출력의 마지막 부분' 바로 다음부터 이어서 변환하세요\n"
1180
+ "2. 절대 요약/마무리/결론 섹션을 추가하지 마세요\n"
1181
+ "3. 원본 내용을 축소하지 말고 100% 그대로 마크다운으로 변환하세요\n"
1182
+ "4. 마크다운만 출력하세요 (설명이나 메타 텍스트 없이)\n\n"
1183
+ f"이전 출력의 마지막 부분:\n```\n{last_output}\n```\n\n"
1184
+ f"변환할 나머지 원본:\n{remaining}"
1185
+ )
1186
+ cont_result, reason = await self._call_llm(
1187
+ cont_prompt, return_finish_reason=True
1188
+ )
1189
+ if cont_result and cont_result.strip():
1190
+ full_result += "\n" + cont_result
1191
+
1192
+ return full_result.strip()
1193
+
1194
+ # ------------------------------------------------------------------
1195
+ # 시맨틱 청킹 (마크다운 헤더 기반)
1196
+ # ------------------------------------------------------------------
1197
+
1198
+ def _chunk_markdown(
1199
+ self, content: str, cfg: Optional[Dict[str, int]] = None
1200
+ ) -> List[Dict[str, Any]]:
1201
+ """마크다운 헤더 기반 시맨틱 청킹 + target_size 병합.
1202
+
1203
+ 1단계: ## → ### → 빈 줄 → 문장으로 시맨틱 분할
1204
+ 2단계: 인접 소형 청크를 target_size까지 병합
1205
+ """
1206
+ c = {**DEFAULT_CHUNKING, **(cfg or {})}
1207
+ target = c["target_size"]
1208
+ min_sz = c["min_size"]
1209
+ max_sz = c["max_size"]
1210
+
1211
+ # 1단계: 시맨틱 분할 (min_size 제한 없이 전부 수집)
1212
+ sections = self._split_by_header(content, level=2)
1213
+ raw_chunks: List[Dict[str, Any]] = []
1214
+
1215
+ for section_header, section_text in sections:
1216
+ if len(section_text) <= max_sz:
1217
+ raw_chunks.append({"content": section_text, "section": section_header})
1218
+ continue
1219
+
1220
+ # ### (h3)로 재분할
1221
+ subsections = self._split_by_header(section_text, level=3)
1222
+ for sub_header, sub_text in subsections:
1223
+ header = (
1224
+ f"{section_header} > {sub_header}" if sub_header else section_header
1225
+ )
1226
+ if len(sub_text) <= max_sz:
1227
+ raw_chunks.append({"content": sub_text, "section": header})
1228
+ continue
1229
+
1230
+ # 빈 줄(문단) 기준 분할
1231
+ para_chunks = self._split_by_paragraphs(sub_text, target, max_sz)
1232
+ for pc in para_chunks:
1233
+ raw_chunks.append({"content": pc, "section": header})
1234
+
1235
+ # 2단계: 인접 소형 청크를 target_size까지 병합
1236
+ # 제목 전용 청크(section 없고 짧은)는 무조건 다음 청크에 흡수
1237
+ merged: List[Dict[str, Any]] = []
1238
+ for chunk in raw_chunks:
1239
+ clen = len(chunk["content"])
1240
+ prev_is_title_only = (
1241
+ merged
1242
+ and not merged[-1]["section"]
1243
+ and len(merged[-1]["content"]) < min_sz
1244
+ )
1245
+ can_merge = merged and (
1246
+ len(merged[-1]["content"]) + clen + 2 <= target or prev_is_title_only
1247
+ )
1248
+ if can_merge:
1249
+ merged[-1]["content"] += "\n\n" + chunk["content"]
1250
+ if chunk["section"] and not merged[-1]["section"]:
1251
+ merged[-1]["section"] = chunk["section"]
1252
+ else:
1253
+ merged.append(dict(chunk))
1254
+
1255
+ # min_size 미만 잔여 청크 → 이전 또는 다음 청크에 흡수
1256
+ final: List[Dict[str, Any]] = []
1257
+ for chunk in merged:
1258
+ clen = len(chunk["content"].strip())
1259
+ if clen < min_sz:
1260
+ if final:
1261
+ # 이전 청크에 흡수
1262
+ final[-1]["content"] += "\n\n" + chunk["content"]
1263
+ else:
1264
+ # 첫 번째 청크가 작으면 일단 추가 (다음에 흡수될 수 있음)
1265
+ final.append(dict(chunk))
1266
+ else:
1267
+ # 이전 청크가 min_size 미만이면 현재에 흡수
1268
+ if final and len(final[-1]["content"].strip()) < min_sz:
1269
+ chunk["content"] = final[-1]["content"] + "\n\n" + chunk["content"]
1270
+ if not chunk["section"] and final[-1]["section"]:
1271
+ chunk["section"] = final[-1]["section"]
1272
+ final[-1] = chunk
1273
+ else:
1274
+ final.append(dict(chunk))
1275
+
1276
+ # 마지막 청크가 min_size 미만이면 이전에 흡수
1277
+ if len(final) >= 2 and len(final[-1]["content"].strip()) < min_sz:
1278
+ final[-2]["content"] += "\n\n" + final[-1]["content"]
1279
+ final.pop()
1280
+
1281
+ return final
1282
+
1283
+ @staticmethod
1284
+ def _split_by_header(text: str, level: int) -> List[Tuple[str, str]]:
1285
+ """마크다운 헤더 레벨로 분할. 반환: [(header_text, section_content), ...]"""
1286
+ pattern = r"^(#{" + str(level) + r"})\s+(.+)$"
1287
+ sections: List[Tuple[str, str]] = []
1288
+ current_header = ""
1289
+ current_lines: List[str] = []
1290
+
1291
+ for line in text.split("\n"):
1292
+ m = re.match(pattern, line, re.MULTILINE)
1293
+ if m:
1294
+ # 이전 섹션 저장
1295
+ if current_lines:
1296
+ sections.append((current_header, "\n".join(current_lines).strip()))
1297
+ current_header = m.group(2).strip()
1298
+ current_lines = [line]
1299
+ else:
1300
+ current_lines.append(line)
1301
+
1302
+ if current_lines:
1303
+ sections.append((current_header, "\n".join(current_lines).strip()))
1304
+
1305
+ return sections if sections else [("", text)]
1306
+
1307
+ @staticmethod
1308
+ def _split_by_paragraphs(text: str, target: int, max_sz: int) -> List[str]:
1309
+ """빈 줄 기준 문단 분할, target_size에 맞게 병합."""
1310
+ paragraphs = re.split(r"\n\s*\n", text)
1311
+ chunks: List[str] = []
1312
+ current = ""
1313
+
1314
+ for para in paragraphs:
1315
+ para = para.strip()
1316
+ if not para:
1317
+ continue
1318
+ if current and len(current) + len(para) + 2 > target:
1319
+ chunks.append(current)
1320
+ current = para
1321
+ else:
1322
+ current = current + "\n\n" + para if current else para
1323
+
1324
+ # max_size 초과 시 문장 단위 분할
1325
+ if len(current) > max_sz:
1326
+ sentences = re.split(r"(?<=[.!?。])\s+", current)
1327
+ buf = ""
1328
+ for s in sentences:
1329
+ if buf and len(buf) + len(s) + 1 > target:
1330
+ chunks.append(buf)
1331
+ buf = s
1332
+ else:
1333
+ buf = buf + " " + s if buf else s
1334
+ current = buf
1335
+
1336
+ if current:
1337
+ chunks.append(current)
1338
+ return chunks
1339
+
1340
+ # ------------------------------------------------------------------
1341
+ # LLM 청크 요약 생성
1342
+ # ------------------------------------------------------------------
1343
+
1344
+ async def _generate_chunk_summaries(
1345
+ self,
1346
+ chunks: List[Dict[str, Any]],
1347
+ doc_title: str,
1348
+ concurrency: int = 5,
1349
+ ) -> List[str]:
1350
+ """각 청크에 대해 LLM 요약을 병렬 생성 (semaphore로 동시 요청 제한)."""
1351
+ from codebuff_core.managers.config_manager import prompt_value
1352
+
1353
+ # 지시부는 `prompts.guide_chunk_summary_prompt` 다 (이슈 #218) — 문서 하나에 한 번 읽는다
1354
+ instruction = prompt_value("guide_chunk_summary_prompt")
1355
+ sem = asyncio.Semaphore(concurrency)
1356
+
1357
+ async def _summarize_one(idx: int, chunk: Dict[str, Any]) -> Tuple[int, str]:
1358
+ async with sem:
1359
+ section = chunk.get("section", "")
1360
+ content = chunk["content"]
1361
+ # 너무 짧은 청크는 요약 불필요
1362
+ if len(content.strip()) < 50:
1363
+ return idx, content.strip()
1364
+ prompt = (
1365
+ instruction + "\n\n"
1366
+ f"문서: {doc_title}\n"
1367
+ f"섹션: {section}\n\n"
1368
+ "---\n"
1369
+ f"{content[:3000]}\n"
1370
+ "---"
1371
+ )
1372
+ try:
1373
+ summary = await self._call_llm(prompt)
1374
+ return idx, summary.strip() if summary else ""
1375
+ except Exception as e:
1376
+ logger.warning("Chunk summary failed [%d]: %s", idx, e)
1377
+ return idx, ""
1378
+ finally:
1379
+ self._progress["summary_done"] = (
1380
+ self._progress.get("summary_done", 0) + 1
1381
+ )
1382
+
1383
+ self._progress["summary_total"] = self._progress.get("summary_total", 0) + len(
1384
+ chunks
1385
+ )
1386
+
1387
+ tasks = [_summarize_one(i, c) for i, c in enumerate(chunks)]
1388
+ results = await asyncio.gather(*tasks)
1389
+ results_sorted = sorted(results, key=lambda x: x[0])
1390
+ return [r[1] for r in results_sorted]
1391
+
1392
+ # ------------------------------------------------------------------
1393
+ # LLM 호출 (OpenAI 호환 API)
1394
+ # ------------------------------------------------------------------
1395
+
1396
+ async def _call_llm(
1397
+ self, prompt: str, max_retries: int = 5, return_finish_reason: bool = False
1398
+ ) -> "str | Tuple[str, str]":
1399
+ """vllm 엔드포인트로 LLM 호출 (rate limit 재시도 포함).
1400
+
1401
+ Args:
1402
+ return_finish_reason: True면 (content, finish_reason) 튜플 반환.
1403
+ """
1404
+ endpoint = self._llm_config.get("endpoint", "")
1405
+ if not endpoint:
1406
+ raise ValueError("LLM endpoint not configured (summarization or vllm)")
1407
+
1408
+ api_key = self._llm_config.get("api_key", "")
1409
+ model = self._llm_config.get("model", "")
1410
+
1411
+ headers = {"Content-Type": "application/json"}
1412
+ if api_key:
1413
+ headers["Authorization"] = f"Bearer {api_key}"
1414
+
1415
+ payload = {
1416
+ "model": model,
1417
+ "messages": [{"role": "user", "content": prompt}],
1418
+ "temperature": 0.1,
1419
+ "max_tokens": 16384,
1420
+ }
1421
+
1422
+ base = endpoint.rstrip("/")
1423
+ url = (
1424
+ f"{base}/v1/chat/completions"
1425
+ if "/v1" not in base
1426
+ else f"{base}/chat/completions"
1427
+ )
1428
+
1429
+ last_error: Optional[Exception] = None
1430
+ for attempt in range(max_retries):
1431
+ try:
1432
+ async with httpx.AsyncClient(timeout=120.0, verify=False) as client:
1433
+ resp = await client.post(url, json=payload, headers=headers)
1434
+ resp.raise_for_status()
1435
+ data = resp.json()
1436
+ content = data["choices"][0]["message"]["content"]
1437
+ if return_finish_reason:
1438
+ reason = data["choices"][0].get("finish_reason", "stop")
1439
+ return content, reason
1440
+ return content
1441
+ except httpx.HTTPStatusError as e:
1442
+ last_error = e
1443
+ if e.response.status_code == 429:
1444
+ # Rate limit: Retry-After 헤더 우선, 없으면 exponential backoff
1445
+ retry_after = e.response.headers.get("Retry-After")
1446
+ wait = (
1447
+ float(retry_after) if retry_after else min(2**attempt * 2, 60)
1448
+ )
1449
+ logger.warning(
1450
+ "Rate limited, waiting %.0fs (attempt %d/%d)",
1451
+ wait,
1452
+ attempt + 1,
1453
+ max_retries,
1454
+ )
1455
+ await asyncio.sleep(wait)
1456
+ elif e.response.status_code >= 500:
1457
+ wait = min(2**attempt, 30)
1458
+ logger.warning(
1459
+ "Server error %d, retrying in %ds: %s",
1460
+ e.response.status_code,
1461
+ wait,
1462
+ e,
1463
+ )
1464
+ await asyncio.sleep(wait)
1465
+ else:
1466
+ raise
1467
+ except httpx.RequestError as e:
1468
+ last_error = e
1469
+ wait = min(2**attempt, 30)
1470
+ logger.warning("LLM request error, retrying in %ds: %s", wait, e)
1471
+ await asyncio.sleep(wait)
1472
+
1473
+ raise Exception(
1474
+ f"LLM call failed after {max_retries} retries: {last_error} (url={url})"
1475
+ )
1476
+
1477
+ # ------------------------------------------------------------------
1478
+ # 프리뷰 / 검증
1479
+ # ------------------------------------------------------------------
1480
+
1481
+ async def dry_run_table(
1482
+ self,
1483
+ file_path: str,
1484
+ mapping: Dict[str, Any],
1485
+ join_file_path: Optional[str] = None,
1486
+ join_key: Optional[str] = None,
1487
+ sample_count: int = 3,
1488
+ ) -> Dict[str, Any]:
1489
+ """적재 전 프리뷰 — 파싱 샘플 + 임베딩 텍스트 미리보기."""
1490
+ rows = self._read_csv(file_path)
1491
+ if join_file_path and join_key:
1492
+ column_rows = self._read_csv(join_file_path)
1493
+ rows = self._join_csvs(rows, column_rows, join_key)
1494
+
1495
+ group_by = mapping.get("groupBy", [])
1496
+ groups = self._group_rows(rows, group_by)
1497
+ embedding_fields = mapping.get("embeddingFields", [])
1498
+ table_fields = mapping.get("tableFields", [])
1499
+ column_fields = mapping.get("columnFields", [])
1500
+ header_fields = mapping.get("headerFields") or table_fields[:2]
1501
+
1502
+ samples = []
1503
+ for i, (key, group_rows) in enumerate(groups.items()):
1504
+ if i >= sample_count:
1505
+ break
1506
+ first_row = group_rows[0]
1507
+ table_row = first_row.get(self._MAIN_ROW_KEY) or first_row
1508
+ embed_text = self._build_table_embed_text(
1509
+ table_row,
1510
+ group_rows,
1511
+ table_fields,
1512
+ column_fields,
1513
+ embedding_fields,
1514
+ header_fields,
1515
+ )
1516
+
1517
+ payload = {}
1518
+ for tf in table_fields:
1519
+ if tf in table_row: # 파일에 존재하는 필드만 포함
1520
+ payload[tf] = table_row[tf]
1521
+ cols = [{cf: r[cf] for cf in column_fields if cf in r} for r in group_rows]
1522
+
1523
+ samples.append(
1524
+ {
1525
+ "group_key": key,
1526
+ "embed_text": embed_text[:800],
1527
+ "payload_preview": payload,
1528
+ "columns_count": len(cols),
1529
+ "columns_sample": cols[:3],
1530
+ }
1531
+ )
1532
+
1533
+ return {
1534
+ "total_groups": len(groups),
1535
+ "total_rows": len(rows),
1536
+ "samples": samples,
1537
+ }
1538
+
1539
+ async def dry_run_guide(
1540
+ self, dir_path: str, chunking_config: Optional[Dict[str, int]] = None
1541
+ ) -> Dict[str, Any]:
1542
+ """guide 컬렉션 청킹 프리뷰."""
1543
+ cfg = {**DEFAULT_CHUNKING, **(chunking_config or {})}
1544
+ p = Path(dir_path)
1545
+ files = sorted(
1546
+ f
1547
+ for f in p.rglob("*")
1548
+ if f.is_file()
1549
+ and not f.name.startswith(".")
1550
+ and not any(part.startswith(".") for part in f.relative_to(p).parts[:-1])
1551
+ )
1552
+ previews = []
1553
+ for fp in files:
1554
+ try:
1555
+ content = fp.read_text(encoding="utf-8")
1556
+ title = self._extract_title(content, fp.name)
1557
+ chunks = self._chunk_markdown(content, cfg)
1558
+ previews.append(
1559
+ {
1560
+ "file": fp.name,
1561
+ "needs_conversion": fp.suffix.lower() != ".md",
1562
+ "title": title,
1563
+ "chunk_count": len(chunks),
1564
+ "chunks_preview": [
1565
+ {
1566
+ "section": c.get("section", ""),
1567
+ "first_100": c["content"][:100],
1568
+ }
1569
+ for c in chunks[:5]
1570
+ ],
1571
+ }
1572
+ )
1573
+ except Exception as e:
1574
+ previews.append({"file": fp.name, "error": str(e)})
1575
+ return {"total_files": len(files), "previews": previews}
1576
+
1577
+ def validate_csv_mapping(
1578
+ self, file_path: str, mapping: Dict[str, Any]
1579
+ ) -> Dict[str, Any]:
1580
+ """CSV 유효성 검증."""
1581
+ rows = self._read_csv(file_path)
1582
+ if not rows:
1583
+ return {"valid": False, "error": "Empty CSV"}
1584
+
1585
+ headers = set(rows[0].keys())
1586
+ group_by = mapping.get("groupBy", [])
1587
+ if isinstance(group_by, str):
1588
+ group_by = [group_by] if group_by else []
1589
+ all_fields = (
1590
+ group_by
1591
+ + mapping.get("embeddingFields", [])
1592
+ + mapping.get("tableFields", [])
1593
+ + mapping.get("columnFields", [])
1594
+ )
1595
+
1596
+ missing = [f for f in all_fields if f and f not in headers]
1597
+ warnings: List[str] = []
1598
+
1599
+ # 빈 값 비율 (각 그룹핑 키별)
1600
+ for gb in group_by:
1601
+ if gb in headers:
1602
+ null_count = sum(1 for r in rows if not r.get(gb))
1603
+ null_pct = round(null_count / len(rows) * 100, 1)
1604
+ if null_pct > 0:
1605
+ warnings.append(f"groupBy '{gb}': NULL {null_pct}%")
1606
+ if group_by:
1607
+ groups = self._group_rows(rows, group_by)
1608
+ warnings.append(f"Unique groups: {len(groups)}")
1609
+
1610
+ return {
1611
+ "valid": len(missing) == 0,
1612
+ "headers": sorted(headers),
1613
+ "missing_fields": missing,
1614
+ "total_rows": len(rows),
1615
+ "warnings": warnings,
1616
+ }
1617
+
1618
+ def parse_csv_headers(self, file_path: str) -> List[str]:
1619
+ """CSV 헤더 목록 반환."""
1620
+ rows = self._read_csv(file_path, limit=1)
1621
+ if rows:
1622
+ return sorted(rows[0].keys())
1623
+ return []
1624
+
1625
+ def list_directory_files(self, dir_path: str) -> List[Dict[str, Any]]:
1626
+ """디렉토리 내 파일 목록."""
1627
+ p = Path(dir_path)
1628
+ if not p.is_dir():
1629
+ return []
1630
+ files = []
1631
+ for f in sorted(p.rglob("*")):
1632
+ if (
1633
+ f.is_file()
1634
+ and not f.name.startswith(".")
1635
+ and not any(
1636
+ part.startswith(".") for part in f.relative_to(p).parts[:-1]
1637
+ )
1638
+ ):
1639
+ files.append(
1640
+ {
1641
+ "name": f.name,
1642
+ "path": str(f),
1643
+ "size": f.stat().st_size,
1644
+ "ext": f.suffix.lower(),
1645
+ }
1646
+ )
1647
+ return files
1648
+
1649
+ # ------------------------------------------------------------------
1650
+ # 데이터 브라우저
1651
+ # ------------------------------------------------------------------
1652
+
1653
+ async def browse_collection(
1654
+ self,
1655
+ collection: str,
1656
+ offset: Optional[str] = None,
1657
+ limit: int = 10,
1658
+ filters: Optional[List[Dict[str, str]]] = None,
1659
+ ) -> Dict[str, Any]:
1660
+ """컬렉션 포인트 페이징 조회. offset은 Qdrant scroll의 point ID.
1661
+
1662
+ filters: [{"field": "source", "value": "guide.md"}, ...] — AND 조건.
1663
+ nested 배열 필터는 field에 "columns[].col_name" 형태 사용.
1664
+ """
1665
+ from qdrant_client.models import FieldCondition, Filter, MatchValue
1666
+
1667
+ scroll_filter = None
1668
+ if filters:
1669
+ conditions = [
1670
+ FieldCondition(key=f["field"], match=MatchValue(value=f["value"]))
1671
+ for f in filters
1672
+ if f.get("field") and f.get("value")
1673
+ ]
1674
+ if conditions:
1675
+ scroll_filter = Filter(must=conditions)
1676
+
1677
+ points, next_offset = self._client.scroll(
1678
+ collection_name=collection,
1679
+ scroll_filter=scroll_filter,
1680
+ limit=limit,
1681
+ offset=offset,
1682
+ with_payload=True,
1683
+ with_vectors=False,
1684
+ )
1685
+
1686
+ items = []
1687
+ for pt in points:
1688
+ payload = dict(pt.payload) if pt.payload else {}
1689
+ # _embed_text 는 너무 크므로 축약
1690
+ if "_embed_text" in payload:
1691
+ payload["_embed_text"] = payload["_embed_text"][:200] + "..."
1692
+ items.append({"id": str(pt.id), "payload": payload})
1693
+
1694
+ return {"items": items, "next_offset": next_offset, "count": len(items)}
1695
+
1696
+ async def delete_point(self, collection: str, point_id: str) -> None:
1697
+ """포인트 1개 삭제."""
1698
+ from qdrant_client.models import PointIdsList
1699
+
1700
+ self._client.delete(
1701
+ collection_name=collection,
1702
+ points_selector=PointIdsList(points=[point_id]),
1703
+ )
1704
+
1705
+ # ------------------------------------------------------------------
1706
+ # 검색 테스트
1707
+ # ------------------------------------------------------------------
1708
+
1709
+ async def search_test(
1710
+ self, query: str, collection: Optional[str] = None, top_k: int = 5
1711
+ ) -> List[Dict[str, Any]]:
1712
+ """관리자용 검색 테스트."""
1713
+ query_vec = await self._embedding.embed_query(query)
1714
+ collections = [collection] if collection else ALL_COLLECTIONS
1715
+ results: List[Dict[str, Any]] = []
1716
+
1717
+ for col in collections:
1718
+ try:
1719
+ hits = self._client.query_points(
1720
+ collection_name=col,
1721
+ query=query_vec,
1722
+ limit=top_k,
1723
+ with_payload=True,
1724
+ )
1725
+ for hit in hits.points:
1726
+ payload = dict(hit.payload) if hit.payload else {}
1727
+ if "_embed_text" in payload:
1728
+ payload["_embed_text"] = payload["_embed_text"][:200] + "..."
1729
+ results.append(
1730
+ {
1731
+ "collection": col,
1732
+ "score": round(hit.score, 4),
1733
+ "id": str(hit.id),
1734
+ "payload": payload,
1735
+ }
1736
+ )
1737
+ except Exception as e:
1738
+ logger.warning("Search test failed for %s: %s", col, e)
1739
+
1740
+ results.sort(key=lambda x: x["score"], reverse=True)
1741
+ return results[:top_k]
1742
+
1743
+ async def search_with_filter(
1744
+ self,
1745
+ query: str,
1746
+ collection: Optional[str] = None,
1747
+ filters: Optional[List[Dict[str, str]]] = None,
1748
+ top_k: int = 10,
1749
+ score_threshold: float = 0.5,
1750
+ match_text: Optional[List[str]] = None,
1751
+ chunk_type: Optional[str] = None,
1752
+ exclude_table_names: Optional[List[str]] = None,
1753
+ ) -> List[Dict[str, Any]]:
1754
+ """Qdrant pre-filtering 검색. score_threshold 이상만 반환.
1755
+
1756
+ 필터 적용 결과가 0건이면, 필터 값을 쿼리에 합쳐서 필터 없이 자동 재검색.
1757
+ 예: filters=[{field: "db_name", value: "ADW"}], query="카드매출"
1758
+ → 0건 시 query="ADW 카드매출" 으로 필터 없이 재검색.
1759
+
1760
+ Args:
1761
+ query: 검색 쿼리 텍스트
1762
+ collection: 특정 컬렉션 또는 None(전체)
1763
+ filters: [{"field": "x", "value": "y"}] AND 조건 리스트
1764
+ top_k: 최대 결과 수 (상한)
1765
+ score_threshold: 최소 유사도 점수 (기본 0.3)
1766
+ match_text: 텍스트 매칭 검색어 리스트 (인덱스 필드 전체에 OR 검색)
1767
+ chunk_type: "table" | "columns" | None.
1768
+ - "table": 테이블명 검색에 최적화된 청크만 (테이블 메타 강조)
1769
+ - "columns": 컬럼 검색에 최적화된 청크만 (컬럼 리스트 강조)
1770
+ - None: 양쪽 모두 검색 후 _group_key로 dedupe (TableConfirmation
1771
+ 후보로 사용)
1772
+ """
1773
+ results = await self._search_qdrant(
1774
+ query=query,
1775
+ collections=[collection] if collection else None,
1776
+ filters=filters,
1777
+ top_k=top_k,
1778
+ score_threshold=score_threshold,
1779
+ match_text=match_text,
1780
+ chunk_type=chunk_type,
1781
+ exclude_table_names=exclude_table_names,
1782
+ )
1783
+
1784
+ # 필터 적용 결과가 0건이면 필터 값을 쿼리에 합쳐서 재검색
1785
+ if not results and filters:
1786
+ filter_values = [f["value"] for f in filters if f.get("value")]
1787
+ if filter_values:
1788
+ enriched_query = " ".join(filter_values) + " " + query
1789
+ logger.info(
1790
+ "[RAG] filter returned 0 results, retrying without filter: %s",
1791
+ enriched_query,
1792
+ )
1793
+ results = await self._search_qdrant(
1794
+ query=enriched_query,
1795
+ collections=[collection] if collection else None,
1796
+ filters=None,
1797
+ top_k=top_k,
1798
+ score_threshold=score_threshold,
1799
+ match_text=match_text,
1800
+ chunk_type=chunk_type,
1801
+ exclude_table_names=exclude_table_names,
1802
+ )
1803
+
1804
+ return results
1805
+
1806
+ async def _search_qdrant(
1807
+ self,
1808
+ query: str,
1809
+ collections: Optional[List[str]] = None,
1810
+ filters: Optional[List[Dict[str, str]]] = None,
1811
+ top_k: int = 10,
1812
+ score_threshold: float = 0.5,
1813
+ match_text: Optional[List[str]] = None,
1814
+ chunk_type: Optional[str] = None,
1815
+ exclude_table_names: Optional[List[str]] = None,
1816
+ ) -> List[Dict[str, Any]]:
1817
+ """Qdrant 벡터 검색 내부 구현."""
1818
+ from qdrant_client.models import FieldCondition, Filter, MatchText, MatchValue
1819
+
1820
+ # type=table 검색: 임베딩 대상 쿼리에 결정론적으로 라벨을 붙여 정확도 향상.
1821
+ # (테이블명/테이블한글명으로 검색 시 그냥 {값}만 넣으면 dilution으로 정확도 저하)
1822
+ # 한글이 있으면 테이블한글명, 아니면 테이블명. 이미 라벨(":")이 있으면 그대로.
1823
+ _embed_query = query
1824
+ if chunk_type == "table" and ":" not in query:
1825
+ _value = strip_fqn_to_table_name(query)
1826
+ _field = "테이블한글명" if re.search(r"[가-힣]", _value) else "테이블명"
1827
+ _embed_query = f"{_field}:{_value}"
1828
+ query_vec = await self._embedding.embed_query(_embed_query)
1829
+ target_collections = collections or ALL_COLLECTIONS
1830
+
1831
+ # 일반 필터 + type 필터 결합
1832
+ must_conditions: List[Any] = []
1833
+ if filters:
1834
+ must_conditions.extend(
1835
+ FieldCondition(key=f["field"], match=MatchValue(value=f["value"]))
1836
+ for f in filters
1837
+ if f.get("field") and f.get("value")
1838
+ )
1839
+ if chunk_type in ("table", "columns"):
1840
+ must_conditions.append(
1841
+ FieldCondition(key="type", match=MatchValue(value=chunk_type))
1842
+ )
1843
+ # 이미 뽑은 테이블 제외(must_not) — "다른 후보 보여줘"용. 벡터검색은 offset
1844
+ # 페이지네이션이 부적합해서, 이미 본 테이블명을 제외하고 재쿼리해 다음 후보를 얻음.
1845
+ must_not_conditions: List[Any] = []
1846
+ if exclude_table_names:
1847
+ for name in exclude_table_names:
1848
+ if not name or not str(name).strip():
1849
+ continue
1850
+ for f in ("테이블명", "table_name"):
1851
+ must_not_conditions.append(
1852
+ FieldCondition(key=f, match=MatchValue(value=str(name)))
1853
+ )
1854
+
1855
+ if must_conditions or must_not_conditions:
1856
+ query_filter = Filter(
1857
+ must=must_conditions or None,
1858
+ must_not=must_not_conditions or None,
1859
+ )
1860
+ else:
1861
+ query_filter = None
1862
+
1863
+ results: List[Dict[str, Any]] = []
1864
+ seen_groups: set = set()
1865
+ _match_text_fallback: List[str] = []
1866
+ _MATCH_TEXT_BOOST = 0.15 # match_text 매칭 결과에 score boost
1867
+
1868
+ for col in target_collections:
1869
+ # 테이블 컬렉션은 _group_key로 그룹핑, 가이드 컬렉션은 일반 검색
1870
+ _use_grouping = col not in GUIDE_COLLECTIONS
1871
+ if _use_grouping:
1872
+ self._ensure_keyword_index(col, "_group_key")
1873
+
1874
+ # match_text가 있으면: 1) 필터 검색 + 2) 일반 검색 → 병합
1875
+ mt_filter = None
1876
+ if match_text:
1877
+ try:
1878
+ col_info = self._client.get_collection(col)
1879
+ text_fields = [
1880
+ fname
1881
+ for fname, schema in (col_info.payload_schema or {}).items()
1882
+ if "text" in str(getattr(schema, "params", "")).lower()
1883
+ or getattr(schema, "data_type", "") == "text"
1884
+ ]
1885
+ if not text_fields:
1886
+ text_fields = ["table_name", "korean_name"]
1887
+ expanded_terms: set = set()
1888
+ for term in match_text:
1889
+ if not term or not term.strip():
1890
+ continue
1891
+ expanded_terms.add(term.strip())
1892
+ for sub in term.strip().split("_"):
1893
+ if sub:
1894
+ expanded_terms.add(sub)
1895
+ mt_conditions = [
1896
+ FieldCondition(key=f, match=MatchText(text=term))
1897
+ for term in expanded_terms
1898
+ for f in text_fields
1899
+ ]
1900
+ if mt_conditions:
1901
+ # type 필터를 must로 결합 (chunk_type가 지정된 경우)
1902
+ if chunk_type in ("table", "columns"):
1903
+ mt_filter = Filter(
1904
+ must=[
1905
+ FieldCondition(
1906
+ key="type",
1907
+ match=MatchValue(value=chunk_type),
1908
+ )
1909
+ ],
1910
+ should=mt_conditions,
1911
+ must_not=must_not_conditions or None,
1912
+ )
1913
+ else:
1914
+ mt_filter = Filter(
1915
+ should=mt_conditions,
1916
+ must_not=must_not_conditions or None,
1917
+ )
1918
+ except Exception as e:
1919
+ logger.warning("match_text filter build failed for %s: %s", col, e)
1920
+
1921
+ try:
1922
+ if _use_grouping:
1923
+ # ── 테이블 컬렉션: query_points_groups ──
1924
+ if mt_filter:
1925
+ mt_groups = self._client.query_points_groups(
1926
+ collection_name=col,
1927
+ group_by="_group_key",
1928
+ query=query_vec,
1929
+ query_filter=mt_filter,
1930
+ limit=top_k,
1931
+ group_size=1,
1932
+ score_threshold=max(score_threshold - 0.2, 0.1),
1933
+ with_payload=True,
1934
+ )
1935
+ if not mt_groups.groups:
1936
+ mt_terms = ", ".join(match_text or [])
1937
+ _match_text_fallback.append(
1938
+ f"[{col}] match_text {mt_terms}에 해당하는 "
1939
+ f"테이블이 없어 일반 검색 결과입니다. "
1940
+ f"사용자가 요청한 테이블이 맞는지 확인하세요."
1941
+ )
1942
+ for group in mt_groups.groups:
1943
+ group_id = str(group.id)
1944
+ if group_id in seen_groups:
1945
+ continue
1946
+ seen_groups.add(group_id)
1947
+ if not group.hits:
1948
+ continue
1949
+ hit = group.hits[0]
1950
+ payload = dict(hit.payload) if hit.payload else {}
1951
+ payload.pop("_embed_text", None)
1952
+ payload.pop("_content_hash", None)
1953
+ self._annotate_chunk_truncation(payload)
1954
+ results.append(
1955
+ {
1956
+ "collection": col,
1957
+ "score": round(
1958
+ min(hit.score + _MATCH_TEXT_BOOST, 1.0), 4
1959
+ ),
1960
+ "id": str(hit.id),
1961
+ "payload": payload,
1962
+ }
1963
+ )
1964
+
1965
+ normal_groups = self._client.query_points_groups(
1966
+ collection_name=col,
1967
+ group_by="_group_key",
1968
+ query=query_vec,
1969
+ query_filter=query_filter,
1970
+ limit=top_k,
1971
+ group_size=1,
1972
+ score_threshold=score_threshold,
1973
+ with_payload=True,
1974
+ )
1975
+ for group in normal_groups.groups:
1976
+ group_id = str(group.id)
1977
+ if group_id in seen_groups:
1978
+ continue
1979
+ seen_groups.add(group_id)
1980
+ if not group.hits:
1981
+ continue
1982
+ hit = group.hits[0]
1983
+ payload = dict(hit.payload) if hit.payload else {}
1984
+ payload.pop("_embed_text", None)
1985
+ payload.pop("_content_hash", None)
1986
+ self._annotate_chunk_truncation(payload)
1987
+ results.append(
1988
+ {
1989
+ "collection": col,
1990
+ "score": round(hit.score, 4),
1991
+ "id": str(hit.id),
1992
+ "payload": payload,
1993
+ }
1994
+ )
1995
+ else:
1996
+ # ── 가이드 컬렉션: query_points (그룹핑 없음) ──
1997
+ seen_ids: set = set()
1998
+ if mt_filter:
1999
+ mt_hits = self._client.query_points(
2000
+ collection_name=col,
2001
+ query=query_vec,
2002
+ query_filter=mt_filter,
2003
+ limit=top_k,
2004
+ score_threshold=max(score_threshold - 0.2, 0.1),
2005
+ with_payload=True,
2006
+ )
2007
+ for hit in mt_hits.points:
2008
+ pid = str(hit.id)
2009
+ if pid in seen_ids:
2010
+ continue
2011
+ seen_ids.add(pid)
2012
+ payload = dict(hit.payload) if hit.payload else {}
2013
+ payload.pop("_embed_text", None)
2014
+ payload.pop("_content_hash", None)
2015
+ results.append(
2016
+ {
2017
+ "collection": col,
2018
+ "score": round(
2019
+ min(hit.score + _MATCH_TEXT_BOOST, 1.0), 4
2020
+ ),
2021
+ "id": pid,
2022
+ "payload": payload,
2023
+ }
2024
+ )
2025
+
2026
+ normal_hits = self._client.query_points(
2027
+ collection_name=col,
2028
+ query=query_vec,
2029
+ query_filter=query_filter,
2030
+ limit=top_k,
2031
+ score_threshold=score_threshold,
2032
+ with_payload=True,
2033
+ )
2034
+ for hit in normal_hits.points:
2035
+ pid = str(hit.id)
2036
+ if pid in seen_ids:
2037
+ continue
2038
+ seen_ids.add(pid)
2039
+ payload = dict(hit.payload) if hit.payload else {}
2040
+ payload.pop("_embed_text", None)
2041
+ payload.pop("_content_hash", None)
2042
+ results.append(
2043
+ {
2044
+ "collection": col,
2045
+ "score": round(hit.score, 4),
2046
+ "id": pid,
2047
+ "payload": payload,
2048
+ }
2049
+ )
2050
+ except Exception as e:
2051
+ logger.warning("search failed for %s: %s", col, e)
2052
+
2053
+ # 테이블명 exact/partial match 리랭킹 (match_text 우선, query fallback)
2054
+ _mt_tokens = set()
2055
+ if match_text:
2056
+ for mt in match_text:
2057
+ if mt and mt.strip():
2058
+ _mt_tokens.add(mt.strip().lower())
2059
+
2060
+ _query_lower = query.lower()
2061
+
2062
+ for r in results:
2063
+ payload = r.get("payload", {})
2064
+ table_name = (
2065
+ payload.get("table_name")
2066
+ or payload.get("테이블명")
2067
+ or payload.get("table_id")
2068
+ or ""
2069
+ ).lower()
2070
+ if not table_name:
2071
+ continue
2072
+ # 1) match_text exact match (+0.5)
2073
+ if _mt_tokens and table_name in _mt_tokens:
2074
+ r["score"] += 0.5
2075
+ # 2) match_text partial match (+0.1)
2076
+ elif _mt_tokens and any(
2077
+ table_name in t or t in table_name for t in _mt_tokens
2078
+ ):
2079
+ r["score"] += 0.1
2080
+ # 3) query에 테이블명이 포함 (+0.2)
2081
+ elif table_name in _query_lower:
2082
+ r["score"] += 0.2
2083
+
2084
+ results.sort(key=lambda x: x["score"], reverse=True)
2085
+ final = results[:top_k]
2086
+
2087
+ # match_text fallback 메시지가 있으면 결과에 첨부
2088
+ if _match_text_fallback:
2089
+ for r in final:
2090
+ r.setdefault("_warnings", []).extend(_match_text_fallback)
2091
+
2092
+ return final
2093
+
2094
+ def _expand_guide_sections(
2095
+ self, results: List[Dict[str, Any]], collections: List[str]
2096
+ ) -> List[Dict[str, Any]]:
2097
+ """가이드 검색 결과의 chunk를 같은 source+section 전체로 확장.
2098
+
2099
+ 검색에서 section의 일부 chunk만 반환되면 LLM이 맥락 부족으로
2100
+ 학습 데이터로 보충(할루시네이션)하는 문제를 방지.
2101
+ """
2102
+ from qdrant_client.models import FieldCondition, Filter, MatchValue
2103
+
2104
+ # 가이드 결과만 필터 (테이블 검색은 건너뜀)
2105
+ guide_results = [
2106
+ r for r in results if r.get("collection", "") in GUIDE_COLLECTIONS
2107
+ ]
2108
+ if not guide_results:
2109
+ return results
2110
+
2111
+ # source+section 쌍 수집 (이미 있는 chunk ID 기록)
2112
+ existing_ids: set = set()
2113
+ section_keys: set = set() # (collection, source, section)
2114
+ for r in results:
2115
+ existing_ids.add(r.get("id", ""))
2116
+ p = r.get("payload", {})
2117
+ col = r.get("collection", "")
2118
+ if col in GUIDE_COLLECTIONS:
2119
+ src = p.get("source", "")
2120
+ sec = p.get("section", "")
2121
+ if src and sec:
2122
+ section_keys.add((col, src, sec))
2123
+
2124
+ if not section_keys:
2125
+ return results
2126
+
2127
+ # 각 source+section의 누락 chunk를 Qdrant에서 가져오기
2128
+ expanded: List[Dict[str, Any]] = []
2129
+ for col, src, sec in section_keys:
2130
+ try:
2131
+ scroll_filter = Filter(
2132
+ must=[
2133
+ FieldCondition(key="source", match=MatchValue(value=src)),
2134
+ FieldCondition(key="section", match=MatchValue(value=sec)),
2135
+ ]
2136
+ )
2137
+ pts, _ = self._client.scroll(
2138
+ collection_name=col,
2139
+ scroll_filter=scroll_filter,
2140
+ limit=10,
2141
+ with_payload=True,
2142
+ with_vectors=False,
2143
+ )
2144
+ for pt in pts:
2145
+ pid = str(pt.id)
2146
+ if pid in existing_ids:
2147
+ continue
2148
+ existing_ids.add(pid)
2149
+ payload = dict(pt.payload) if pt.payload else {}
2150
+ payload.pop("_embed_text", None)
2151
+ payload.pop("_content_hash", None)
2152
+ payload["_expanded"] = True # 확장된 chunk 표시
2153
+ expanded.append(
2154
+ {
2155
+ "collection": col,
2156
+ "score": 0.0, # 검색 매칭이 아닌 확장 chunk
2157
+ "id": pid,
2158
+ "payload": payload,
2159
+ }
2160
+ )
2161
+ except Exception as e:
2162
+ logger.warning(
2163
+ "section expand failed for %s/%s/%s: %s", col, src, sec, e
2164
+ )
2165
+
2166
+ if expanded:
2167
+ logger.info(
2168
+ "[RAG] Section expand: %d additional chunks for %d sections",
2169
+ len(expanded),
2170
+ len(section_keys),
2171
+ )
2172
+
2173
+ return results + expanded
2174
+
2175
+ # ------------------------------------------------------------------
2176
+ # 적재 이력
2177
+ # ------------------------------------------------------------------
2178
+
2179
+ def get_history(self) -> List[Dict[str, Any]]:
2180
+ """이력 로그 조회."""
2181
+ try:
2182
+ with open(HISTORY_PATH, encoding="utf-8") as f:
2183
+ return json.load(f)
2184
+ except (FileNotFoundError, json.JSONDecodeError):
2185
+ return []
2186
+
2187
+ def _record_history(
2188
+ self,
2189
+ collection: str,
2190
+ action: str,
2191
+ count: int,
2192
+ errors: List[str],
2193
+ elapsed_s: float = 0,
2194
+ ) -> None:
2195
+ """이력 기록."""
2196
+ history = self.get_history()
2197
+ history.append(
2198
+ {
2199
+ "timestamp": datetime.now().isoformat(),
2200
+ "collection": collection,
2201
+ "action": action,
2202
+ "count": count,
2203
+ "errors": errors[:10],
2204
+ "elapsed_s": elapsed_s,
2205
+ }
2206
+ )
2207
+ # 최근 200건만 유지
2208
+ history = history[-200:]
2209
+ os.makedirs(os.path.dirname(HISTORY_PATH), exist_ok=True)
2210
+ with open(HISTORY_PATH, "w", encoding="utf-8") as f:
2211
+ json.dump(history, f, ensure_ascii=False, indent=2)
2212
+
2213
+ # ------------------------------------------------------------------
2214
+ # 내부 유틸리티
2215
+ # ------------------------------------------------------------------
2216
+
2217
+ def _ensure_text_indexes(self, collection_name: str, fields: List[str]) -> None:
2218
+ """payload 필드에 text index가 없으면 생성. MatchText 검색용."""
2219
+ from qdrant_client.models import TextIndexParams, TokenizerType
2220
+
2221
+ try:
2222
+ info = self._client.get_collection(collection_name)
2223
+ existing = set(info.payload_schema.keys()) if info.payload_schema else set()
2224
+ except Exception:
2225
+ existing = set()
2226
+
2227
+ for field in fields:
2228
+ if field in existing:
2229
+ continue
2230
+ try:
2231
+ self._client.create_payload_index(
2232
+ collection_name=collection_name,
2233
+ field_name=field,
2234
+ field_schema=TextIndexParams(
2235
+ type="text",
2236
+ tokenizer=TokenizerType.WORD,
2237
+ min_token_len=1,
2238
+ max_token_len=60,
2239
+ lowercase=True,
2240
+ ),
2241
+ )
2242
+ logger.info("[RAG] Created text index: %s.%s", collection_name, field)
2243
+ except Exception as e:
2244
+ logger.warning(
2245
+ "[RAG] Failed to create text index %s.%s: %s",
2246
+ collection_name,
2247
+ field,
2248
+ e,
2249
+ )
2250
+
2251
+ # 이미 keyword index가 생성된 (collection, field) 추적
2252
+ _keyword_index_cache: set = set()
2253
+
2254
+ def _ensure_keyword_index(self, collection_name: str, field: str) -> None:
2255
+ """payload 필드에 keyword index가 없으면 생성. query_points_groups 필수."""
2256
+ cache_key = (collection_name, field)
2257
+ if cache_key in self._keyword_index_cache:
2258
+ return
2259
+ from qdrant_client.models import PayloadSchemaType
2260
+
2261
+ try:
2262
+ info = self._client.get_collection(collection_name)
2263
+ existing = info.payload_schema or {}
2264
+ if field in existing:
2265
+ self._keyword_index_cache.add(cache_key)
2266
+ return
2267
+ except Exception:
2268
+ pass
2269
+
2270
+ try:
2271
+ self._client.create_payload_index(
2272
+ collection_name=collection_name,
2273
+ field_name=field,
2274
+ field_schema=PayloadSchemaType.KEYWORD,
2275
+ )
2276
+ logger.info("[RAG] Created keyword index: %s.%s", collection_name, field)
2277
+ except Exception as e:
2278
+ logger.warning(
2279
+ "[RAG] Failed to create keyword index %s.%s: %s",
2280
+ collection_name,
2281
+ field,
2282
+ e,
2283
+ )
2284
+ self._keyword_index_cache.add(cache_key)
2285
+
2286
+ @staticmethod
2287
+ def _annotate_chunk_truncation(payload: Dict[str, Any]) -> None:
2288
+ """청크 분할된 결과에 컬럼 축약 안내를 추가."""
2289
+ chunk_info = payload.get("_chunk")
2290
+ if not chunk_info:
2291
+ return
2292
+ # _chunk 형식: "1/3" — 총 청크 수가 1보다 크면 축약된 것
2293
+ parts = str(chunk_info).split("/")
2294
+ if len(parts) == 2:
2295
+ try:
2296
+ total_chunks = int(parts[1])
2297
+ except ValueError:
2298
+ total_chunks = 1
2299
+ if total_chunks > 1:
2300
+ payload["_column_truncated"] = (
2301
+ f"이 테이블은 컬럼이 많아 {total_chunks}개 청크 중 "
2302
+ f"일부만 표시됩니다. 전체 컬럼이 필요하면 "
2303
+ f"get_table_columns 도구를 사용하세요."
2304
+ )
2305
+
2306
+ @staticmethod
2307
+ def _build_table_meta_embed_text(
2308
+ first_row: Dict[str, str],
2309
+ group_rows: List[Dict[str, str]],
2310
+ table_fields: List[str],
2311
+ embedding_fields: List[str],
2312
+ header_fields: Optional[List[str]] = None,
2313
+ ) -> str:
2314
+ """type=table 청크용 임베딩 텍스트. 강조 필드(테이블명/한글명) 중심.
2315
+
2316
+ 컬럼 이름은 의도적으로 제외 — 테이블명 검색 시 컬럼 토큰에 의한
2317
+ 희석(dilution)을 막기 위함. payload의 columns에는 전체 컬럼이
2318
+ 들어가므로 결과 셋은 영향 없음.
2319
+
2320
+ header_fields: 강조 자리(헤더+라벨)에 박을 필드 이름 최대 2개.
2321
+ None이면 table_fields[:2] fallback. 매핑의 tableFields 순서와
2322
+ 독립적으로 강조 필드를 지정할 수 있음.
2323
+
2324
+ 예시 출력 (header_fields=["테이블명","테이블한글명"]):
2325
+ ## dtcn0003 (거래내역)
2326
+ 테이블명: dtcn0003
2327
+ 테이블한글명: 거래내역
2328
+ - table_id: BDP0031
2329
+ - description: 거래내역 데이터를 daily 주기로 적재하는 BDP Iceberg 테이블
2330
+ - domain: card
2331
+ ...
2332
+ """
2333
+ lines: List[str] = []
2334
+ hf = header_fields if header_fields else table_fields[:2]
2335
+
2336
+ # 제목 + 강조 라벨 (header_fields 사용)
2337
+ title_parts: List[str] = []
2338
+ primary = first_row.get(hf[0], "") if len(hf) > 0 else ""
2339
+ secondary = first_row.get(hf[1], "") if len(hf) > 1 else ""
2340
+ if primary:
2341
+ title_parts.append(primary)
2342
+ if secondary:
2343
+ title_parts.append(f"({secondary})")
2344
+ if title_parts:
2345
+ lines.append(f"## {' '.join(title_parts)}")
2346
+ if primary:
2347
+ lines.append(f"테이블명: {primary}")
2348
+ if secondary:
2349
+ lines.append(f"테이블한글명: {secondary}")
2350
+
2351
+ # 나머지 테이블 메타 (header_fields에 들어간 필드는 제외 — 중복 방지)
2352
+ for tf in table_fields:
2353
+ if tf in hf:
2354
+ continue
2355
+ val = first_row.get(tf, "")
2356
+ if val:
2357
+ lines.append(f"- {tf}: {val}")
2358
+
2359
+ # embeddingFields 중 table fields에 없는 추가 필드
2360
+ extra_fields = [f for f in embedding_fields if f not in table_fields]
2361
+ if extra_fields:
2362
+ extra_vals: set = set()
2363
+ for r in group_rows:
2364
+ for f in extra_fields:
2365
+ v = r.get(f, "")
2366
+ if v:
2367
+ extra_vals.add(str(v))
2368
+ if extra_vals:
2369
+ lines.append("")
2370
+ lines.append(" ".join(extra_vals))
2371
+
2372
+ if group_rows:
2373
+ lines.append(f"- 컬럼 수: {len(group_rows)}")
2374
+
2375
+ return "\n".join(lines)
2376
+
2377
+ @staticmethod
2378
+ def _build_columns_embed_text(
2379
+ first_row: Dict[str, str],
2380
+ group_rows: List[Dict[str, str]],
2381
+ table_fields: List[str],
2382
+ column_fields: List[str],
2383
+ header_fields: Optional[List[str]] = None,
2384
+ ) -> str:
2385
+ """type=columns 청크용 임베딩 텍스트. 컬럼 이름/한글명/설명 위주.
2386
+
2387
+ 헤더에 테이블명을 짧게만 포함 — 컬럼 검색 시 컬럼 토큰 비중이
2388
+ 지배적이 되도록.
2389
+
2390
+ header_fields: 헤더 식별자에 박을 필드 이름 최대 2개.
2391
+ None이면 table_fields[:2] fallback.
2392
+
2393
+ 예시 출력:
2394
+ ## 컬럼 — dtcn0003 (거래내역)
2395
+ - col_name: TRAN_DT, col_korean_name: 거래일자, col_type: VARCHAR, pk: Y
2396
+ - col_name: TRAN_AMT, col_korean_name: 거래금액, col_type: NUMBER, pk: N
2397
+ """
2398
+ lines: List[str] = []
2399
+ hf = header_fields if header_fields else table_fields[:2]
2400
+
2401
+ # 헤더: 강조 X, 식별용으로만 짧게
2402
+ title_parts: List[str] = []
2403
+ primary = first_row.get(hf[0], "") if len(hf) > 0 else ""
2404
+ secondary = first_row.get(hf[1], "") if len(hf) > 1 else ""
2405
+ if primary:
2406
+ title_parts.append(primary)
2407
+ if secondary:
2408
+ title_parts.append(f"({secondary})")
2409
+ if title_parts:
2410
+ lines.append(f"## 컬럼 — {' '.join(title_parts)}")
2411
+
2412
+ # 컬럼 본문
2413
+ if column_fields and group_rows:
2414
+ for r in group_rows:
2415
+ parts: List[str] = []
2416
+ for cf in column_fields:
2417
+ v = r.get(cf, "")
2418
+ if v:
2419
+ parts.append(f"{cf}: {v}")
2420
+ if parts:
2421
+ lines.append(f"- {', '.join(parts)}")
2422
+
2423
+ return "\n".join(lines)
2424
+
2425
+ @staticmethod
2426
+ def _build_table_embed_text(
2427
+ first_row: Dict[str, str],
2428
+ group_rows: List[Dict[str, str]],
2429
+ table_fields: List[str],
2430
+ column_fields: List[str],
2431
+ embedding_fields: List[str],
2432
+ header_fields: Optional[List[str]] = None,
2433
+ ) -> str:
2434
+ """레거시 호환용 — meta+columns 합쳐서 한 번에 반환.
2435
+
2436
+ preview/dry-run 등 청크 분리가 필요 없는 경로에서 사용.
2437
+ 실제 인덱싱 경로에서는 _build_table_meta_embed_text와
2438
+ _build_columns_embed_text를 각각 호출.
2439
+ """
2440
+ meta = RAGCollectionManager._build_table_meta_embed_text(
2441
+ first_row, group_rows, table_fields, embedding_fields, header_fields
2442
+ )
2443
+ cols = RAGCollectionManager._build_columns_embed_text(
2444
+ first_row, group_rows, table_fields, column_fields, header_fields
2445
+ )
2446
+ return f"{meta}\n\n{cols}" if cols else meta
2447
+
2448
+ @staticmethod
2449
+ def _read_csv(file_path: str, limit: int = 0) -> List[Dict[str, str]]:
2450
+ """CSV 또는 Parquet 파일 → dict 리스트. 확장자 또는 magic bytes로 자동 판별."""
2451
+ ext = Path(file_path).suffix.lower()
2452
+ if ext in (".parquet", ".pq"):
2453
+ return RAGCollectionManager._read_parquet(file_path, limit)
2454
+ # 확장자 없거나 불명확 → magic bytes로 parquet 감지
2455
+ if not ext or ext not in (".csv", ".tsv", ".txt"):
2456
+ try:
2457
+ with open(file_path, "rb") as f:
2458
+ magic = f.read(4)
2459
+ if magic == b"PAR1":
2460
+ return RAGCollectionManager._read_parquet(file_path, limit)
2461
+ except Exception:
2462
+ pass
2463
+ rows = []
2464
+ with open(file_path, encoding="utf-8-sig") as f:
2465
+ reader = csv.DictReader(f)
2466
+ for i, row in enumerate(reader):
2467
+ rows.append(dict(row))
2468
+ if limit and i + 1 >= limit:
2469
+ break
2470
+ return rows
2471
+
2472
+ @staticmethod
2473
+ def _read_parquet(file_path: str, limit: int = 0) -> List[Dict[str, str]]:
2474
+ """Parquet 파일 → dict 리스트. 모든 값을 str로 변환."""
2475
+ import pandas as pd
2476
+
2477
+ df = pd.read_parquet(file_path)
2478
+ if limit:
2479
+ df = df.head(limit)
2480
+ # NaN → 빈 문자열, 모든 값 str 변환
2481
+ df = df.fillna("").astype(str)
2482
+ return df.to_dict(orient="records")
2483
+
2484
+ @staticmethod
2485
+ def _group_rows(
2486
+ rows: List[Dict[str, str]], group_by: Any
2487
+ ) -> Dict[str, List[Dict[str, str]]]:
2488
+ """groupBy 키(단일 str 또는 list)로 행 그룹핑."""
2489
+ if isinstance(group_by, str):
2490
+ group_by = [group_by]
2491
+ groups: Dict[str, List[Dict[str, str]]] = {}
2492
+ for row in rows:
2493
+ parts = [row.get(k, "") for k in group_by]
2494
+ if not all(parts):
2495
+ continue
2496
+ key = "||".join(parts)
2497
+ groups.setdefault(key, []).append(row)
2498
+ return groups
2499
+
2500
+ # join 결과 row에 원본 main row를 보존하는 hidden 키
2501
+ _MAIN_ROW_KEY = "__main_row__"
2502
+
2503
+ @staticmethod
2504
+ def _join_csvs(
2505
+ main_rows: List[Dict[str, str]],
2506
+ join_rows: List[Dict[str, str]],
2507
+ join_key: str,
2508
+ ) -> List[Dict[str, str]]:
2509
+ """두 CSV를 조인 키로 병합 (main에 join 정보 추가).
2510
+
2511
+ 주의: `{**r, **jr}`에서 column row(jr)가 main row(r)의 동일 필드를
2512
+ 덮어쓴다 (예: description, korean_name). column-level 데이터를
2513
+ 우선시하는 의도된 동작이지만, table-level 메타에 접근하려면
2514
+ 원본 main row가 필요하므로 `__main_row__` 키로 보존한다.
2515
+
2516
+ 이후 인덱싱 단계에서 `row.get(_MAIN_ROW_KEY) or row` 로 table-level
2517
+ 필드에 접근하고, column-level 필드는 row를 그대로 사용한다.
2518
+ """
2519
+ join_index: Dict[str, List[Dict[str, str]]] = {}
2520
+ for r in join_rows:
2521
+ k = r.get(join_key, "")
2522
+ if k:
2523
+ join_index.setdefault(k, []).append(r)
2524
+
2525
+ merged = []
2526
+ for r in main_rows:
2527
+ k = r.get(join_key, "")
2528
+ matching = join_index.get(k, [])
2529
+ if matching:
2530
+ for jr in matching:
2531
+ combined = {**r, **jr}
2532
+ combined[RAGCollectionManager._MAIN_ROW_KEY] = r
2533
+ merged.append(combined)
2534
+ else:
2535
+ copy = dict(r)
2536
+ copy[RAGCollectionManager._MAIN_ROW_KEY] = r
2537
+ merged.append(copy)
2538
+ return merged
2539
+
2540
+ @staticmethod
2541
+ def _extract_confluence_url(content: str) -> str:
2542
+ """첫 줄의 [source: URL] 패턴에서 URL 추출."""
2543
+ first_line = content.split("\n", 1)[0].strip()
2544
+ m = re.match(r"^\[source:\s*(.+?)\]", first_line, re.IGNORECASE)
2545
+ return m.group(1).strip() if m else ""
2546
+
2547
+ @staticmethod
2548
+ def _extract_title(content: str, fallback: str) -> str:
2549
+ """마크다운 첫 # 제목 추출."""
2550
+ for line in content.split("\n"):
2551
+ m = re.match(r"^#\s+(.+)$", line.strip())
2552
+ if m:
2553
+ return m.group(1).strip()
2554
+ return Path(fallback).stem
2555
+
2556
+ @staticmethod
2557
+ def _file_hash(fp: Path) -> str:
2558
+ """SHA256 해시 (앞 16자)."""
2559
+ return hashlib.sha256(fp.read_bytes()).hexdigest()[:16]
2560
+
2561
+ async def _embed_chunked(
2562
+ self, text: str, chunk_chars: int = 2000
2563
+ ) -> Optional[List[float]]:
2564
+ """긴 텍스트를 청크로 나눠 임베딩 후 평균 벡터 반환."""
2565
+ chunks = [text[i : i + chunk_chars] for i in range(0, len(text), chunk_chars)]
2566
+ try:
2567
+ vecs = await self._embedding.embed_texts(chunks)
2568
+ if not vecs:
2569
+ return None
2570
+ # 평균 벡터
2571
+ dim = len(vecs[0])
2572
+ avg = [0.0] * dim
2573
+ for v in vecs:
2574
+ for j in range(dim):
2575
+ avg[j] += v[j]
2576
+ n = len(vecs)
2577
+ return [x / n for x in avg]
2578
+ except Exception as e:
2579
+ logger.warning("Chunked embed also failed: %s", e)
2580
+ return None
2581
+
2582
+ async def _batch_upsert(
2583
+ self,
2584
+ collection: str,
2585
+ texts: List[str],
2586
+ payloads: List[Dict[str, Any]],
2587
+ point_ids: Optional[List[str]] = None,
2588
+ batch_size: int = 1,
2589
+ concurrency: int = 64,
2590
+ ) -> int:
2591
+ """배치 임베딩(병렬) + Qdrant upsert."""
2592
+ from qdrant_client.models import PointStruct
2593
+
2594
+ total_items = len(texts)
2595
+ self._progress = {
2596
+ "status": "running",
2597
+ "total": total_items,
2598
+ "done": 0,
2599
+ "collection": collection,
2600
+ }
2601
+
2602
+ # 배치 분할
2603
+ batches = []
2604
+ for i in range(0, total_items, batch_size):
2605
+ batch_ids = point_ids[i : i + batch_size] if point_ids else None
2606
+ batches.append(
2607
+ (texts[i : i + batch_size], payloads[i : i + batch_size], batch_ids)
2608
+ )
2609
+
2610
+ total = 0
2611
+ errors = 0
2612
+
2613
+ # concurrency개씩 병렬 임베딩 → 순차 upsert
2614
+ self._upsert_cancelled = False
2615
+ for group_start in range(0, len(batches), concurrency):
2616
+ if self._upsert_cancelled:
2617
+ self._progress = {
2618
+ "status": "cancelled",
2619
+ "done": total,
2620
+ "total": total_items,
2621
+ }
2622
+ logger.info("Upsert cancelled at %d/%d", total, total_items)
2623
+ break
2624
+
2625
+ group = batches[group_start : group_start + concurrency]
2626
+
2627
+ # 병렬 임베딩
2628
+ embed_tasks = [self._embedding.embed_texts(bt) for bt, _, _ in group]
2629
+ embed_results = await asyncio.gather(*embed_tasks, return_exceptions=True)
2630
+
2631
+ # 순차 upsert (Qdrant 로컬은 동기)
2632
+ for (batch_texts, batch_payloads, batch_ids), embeddings in zip(
2633
+ group, embed_results
2634
+ ):
2635
+ if isinstance(embeddings, Exception):
2636
+ # 임베딩 에러 → 청크 분할 / 테이블 정보만으로 재시도
2637
+ logger.warning(
2638
+ "Embed failed (will retry): %s | text_len=%d",
2639
+ str(embeddings)[:200],
2640
+ len(batch_texts[0]) if batch_texts else 0,
2641
+ )
2642
+ if batch_texts:
2643
+ retry_ok = True
2644
+ retry_embeddings = []
2645
+ for text in batch_texts:
2646
+ # 1차: 청크 분할 후 평균 벡터
2647
+ chunked = await self._embed_chunked(text)
2648
+ if chunked is not None:
2649
+ retry_embeddings.append(chunked)
2650
+ continue
2651
+ # 2차: 컬럼 제외, 테이블 정보만으로 재시도
2652
+ table_only = text.split("### 컬럼")[0].strip()
2653
+ try:
2654
+ vecs = await self._embedding.embed_texts([table_only])
2655
+ retry_embeddings.append(vecs[0])
2656
+ logger.info(
2657
+ "Table-only embed OK: %d→%d chars",
2658
+ len(text),
2659
+ len(table_only),
2660
+ )
2661
+ except Exception:
2662
+ retry_ok = False
2663
+ break
2664
+ if retry_ok:
2665
+ embeddings = retry_embeddings
2666
+ logger.info(
2667
+ "Embed retry OK: text_len=%d",
2668
+ len(batch_texts[0]),
2669
+ )
2670
+ else:
2671
+ embeddings = None # 아래 에러 처리로
2672
+
2673
+ if embeddings is None or isinstance(embeddings, Exception):
2674
+ errors += 1
2675
+ for pl in batch_payloads:
2676
+ table_info = {
2677
+ k: v
2678
+ for k, v in pl.items()
2679
+ if k not in ("columns", "_embed_text", "_collection")
2680
+ }
2681
+ logger.warning(
2682
+ "Embed error: %s | table=%s | text_len=%d",
2683
+ embeddings,
2684
+ table_info,
2685
+ len(batch_texts[0]) if batch_texts else 0,
2686
+ )
2687
+ self._progress["done"] += len(batch_texts)
2688
+ continue
2689
+ try:
2690
+ points = [
2691
+ PointStruct(
2692
+ id=pid if pid else str(uuid.uuid4()),
2693
+ vector=emb,
2694
+ payload=pl,
2695
+ )
2696
+ for pl, emb, pid in zip(
2697
+ batch_payloads,
2698
+ embeddings,
2699
+ batch_ids if batch_ids else [None] * len(batch_payloads),
2700
+ )
2701
+ ]
2702
+ self._client.upsert(collection_name=collection, points=points)
2703
+ total += len(points)
2704
+ except Exception as e:
2705
+ errors += 1
2706
+ logger.warning("Batch upsert error: %s", e)
2707
+ self._progress["done"] = total + (errors * batch_size)
2708
+
2709
+ self._progress = {"status": "done"}
2710
+ return total
2711
+
2712
+ def _scroll_by_source(self, collection: str, source: str, limit: int = 100) -> list:
2713
+ """source 필드로 포인트 검색."""
2714
+ from qdrant_client.models import FieldCondition, Filter, MatchValue
2715
+
2716
+ points, _ = self._client.scroll(
2717
+ collection_name=collection,
2718
+ scroll_filter=Filter(
2719
+ must=[FieldCondition(key="source", match=MatchValue(value=source))]
2720
+ ),
2721
+ limit=limit,
2722
+ with_payload=True,
2723
+ with_vectors=False,
2724
+ )
2725
+ return points
2726
+
2727
+ def _delete_by_source(self, collection: str, source: str) -> int:
2728
+ """source 기준 포인트 삭제."""
2729
+
2730
+ points = self._scroll_by_source(collection, source)
2731
+ if not points:
2732
+ return 0
2733
+ from qdrant_client.models import PointIdsList
2734
+
2735
+ ids = [pt.id for pt in points]
2736
+ self._client.delete(
2737
+ collection_name=collection,
2738
+ points_selector=PointIdsList(points=ids),
2739
+ )
2740
+ return len(ids)