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.
- codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
- codebuff_agent-2.1.74.dist-info/METADATA +142 -0
- codebuff_agent-2.1.74.dist-info/RECORD +217 -0
- codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
- codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
- codebuff_core/__init__.py +153 -0
- codebuff_core/codebuff_config_defaults.json +64 -0
- codebuff_core/core/__init__.py +35 -0
- codebuff_core/core/code_validator.py +1238 -0
- codebuff_core/core/error_classifier.py +579 -0
- codebuff_core/core/exceptions.py +49 -0
- codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
- codebuff_core/directory_policy.py +32 -0
- codebuff_core/factory.py +155 -0
- codebuff_core/interfaces.py +197 -0
- codebuff_core/knowledge/__init__.py +31 -0
- codebuff_core/knowledge/chunking.py +375 -0
- codebuff_core/knowledge/loader.py +339 -0
- codebuff_core/knowledge/watchdog_service.py +354 -0
- codebuff_core/legacy_paths.py +69 -0
- codebuff_core/llm/__init__.py +13 -0
- codebuff_core/llm/service.py +407 -0
- codebuff_core/managers/__init__.py +22 -0
- codebuff_core/managers/config_manager.py +690 -0
- codebuff_core/managers/remote_config.py +673 -0
- codebuff_core/managers/remote_skills.py +539 -0
- codebuff_core/managers/session_manager.py +255 -0
- codebuff_core/middleware/__init__.py +3 -0
- codebuff_core/middleware/error_recovery.py +394 -0
- codebuff_core/middleware/logging_utils.py +33 -0
- codebuff_core/models/__init__.py +111 -0
- codebuff_core/models/agent.py +306 -0
- codebuff_core/models/chat.py +57 -0
- codebuff_core/models/common.py +82 -0
- codebuff_core/models/rag.py +300 -0
- codebuff_core/platform_rules.py +127 -0
- codebuff_core/prompts/__init__.py +44 -0
- codebuff_core/prompts/auto_agent_prompts.py +1278 -0
- codebuff_core/services/__init__.py +30 -0
- codebuff_core/services/agent_service.py +345 -0
- codebuff_core/services/analytics_aggregate.py +837 -0
- codebuff_core/services/chat_service.py +898 -0
- codebuff_core/services/context_condenser.py +306 -0
- codebuff_core/services/pii_guardrail.py +393 -0
- codebuff_core/services/rag_chat_agent.py +2993 -0
- codebuff_core/services/rag_collections.py +2740 -0
- codebuff_core/services/rag_manager.py +890 -0
- codebuff_core/services/rag_service.py +145 -0
- codebuff_core/services/retriever.py +260 -0
- codebuff_core/services/vllm_embedding_service.py +290 -0
- codebuff_core/tests/__init__.py +1 -0
- codebuff_core/tests/conftest.py +81 -0
- codebuff_core/tests/test_error_recovery.py +647 -0
- codebuff_core/tests/test_factory.py +135 -0
- codebuff_core/tests/test_services.py +150 -0
- jupyter_ext/__init__.py +266 -0
- jupyter_ext/_version.py +4 -0
- jupyter_ext/agent/__init__.py +49 -0
- jupyter_ext/agent/agent_factory.py +2270 -0
- jupyter_ext/agent/ast_tools.py +451 -0
- jupyter_ext/agent/autofix_middleware.py +481 -0
- jupyter_ext/agent/background_procs.py +372 -0
- jupyter_ext/agent/code_history_middleware.py +211 -0
- jupyter_ext/agent/codex_autofix.py +160 -0
- jupyter_ext/agent/codex_bridge.py +3339 -0
- jupyter_ext/agent/codex_hooks.py +320 -0
- jupyter_ext/agent/codex_slash.py +685 -0
- jupyter_ext/agent/codex_threads.py +114 -0
- jupyter_ext/agent/content_injection.py +377 -0
- jupyter_ext/agent/continuation_middleware.py +548 -0
- jupyter_ext/agent/custom_skills.py +335 -0
- jupyter_ext/agent/file_diffs.py +99 -0
- jupyter_ext/agent/git_history.py +188 -0
- jupyter_ext/agent/guardrail_middleware.py +421 -0
- jupyter_ext/agent/json_repair_utils.py +54 -0
- jupyter_ext/agent/langchain_tools.py +2673 -0
- jupyter_ext/agent/mermaid_fixer.py +289 -0
- jupyter_ext/agent/mermaid_middleware.py +135 -0
- jupyter_ext/agent/middleware.py +359 -0
- jupyter_ext/agent/middleware_prompts.py +53 -0
- jupyter_ext/agent/observability.py +334 -0
- jupyter_ext/agent/patch_tool_calls.py +57 -0
- jupyter_ext/agent/prompts.py +112 -0
- jupyter_ext/agent/skill_middleware.py +257 -0
- jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
- jupyter_ext/agent/skills/anomaly_detection.md +12 -0
- jupyter_ext/agent/skills/athena_sql.md +156 -0
- jupyter_ext/agent/skills/bdp_table_search.md +33 -0
- jupyter_ext/agent/skills/bigquery.md +65 -0
- jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
- jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
- jupyter_ext/agent/skills/classification_evaluation.md +11 -0
- jupyter_ext/agent/skills/clustering_analysis.md +12 -0
- jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
- jupyter_ext/agent/skills/data_analysis.md +236 -0
- jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
- jupyter_ext/agent/skills/data_loading.md +158 -0
- jupyter_ext/agent/skills/data_quality_report.md +13 -0
- jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
- jupyter_ext/agent/skills/emr_script_development.md +17 -0
- jupyter_ext/agent/skills/experiment_tracking.md +13 -0
- jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
- jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
- jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
- jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
- jupyter_ext/agent/skills/inference.md +392 -0
- jupyter_ext/agent/skills/lambda_function_development.md +19 -0
- jupyter_ext/agent/skills/model_comparison.md +11 -0
- jupyter_ext/agent/skills/model_explainability.md +12 -0
- jupyter_ext/agent/skills/model_training.md +318 -0
- jupyter_ext/agent/skills/pyspark.md +352 -0
- jupyter_ext/agent/skills/python_code_quality.md +35 -0
- jupyter_ext/agent/skills/regression_evaluation.md +12 -0
- jupyter_ext/agent/skills/spring_java_development.md +35 -0
- jupyter_ext/agent/skills/text_preprocessing.md +12 -0
- jupyter_ext/agent/skills/time_series_analysis.md +13 -0
- jupyter_ext/agent/skills/unit_testing.md +203 -0
- jupyter_ext/agent/todo_prompts.py +57 -0
- jupyter_ext/agent/tool_report.py +45 -0
- jupyter_ext/analytics_ingest.py +599 -0
- jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- jupyter_ext/handlers/__init__.py +8 -0
- jupyter_ext/handlers/analytics_handler.py +170 -0
- jupyter_ext/handlers/benchmark_handler.py +794 -0
- jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
- jupyter_ext/handlers/rag_admin_handler.py +689 -0
- jupyter_ext/handlers/websocket.py +497 -0
- jupyter_ext/http_handlers.py +8744 -0
- jupyter_ext/idle_monitor.py +346 -0
- jupyter_ext/labextension/package.json +157 -0
- jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
- jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
- jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
- jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
- jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
- jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
- jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
- jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
- jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
- jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
- jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
- jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- jupyter_ext/labextension/static/style.js +4 -0
- jupyter_ext/labextension/static/third-party-licenses.json +724 -0
- jupyter_ext/log_paths.py +113 -0
- jupyter_ext/resource_usage.py +519 -0
- jupyter_ext/secrets_bootstrap.py +116 -0
- jupyter_ext/team_access.py +105 -0
- jupyter_ext/tests/__init__.py +1 -0
- jupyter_ext/tests/test_context.py +234 -0
- jupyter_ext/tests/test_factory.py +563 -0
- jupyter_ext/tests/test_file_finder.py +139 -0
- jupyter_ext/tests/test_handlers.py +58 -0
- jupyter_ext/tests/test_mcp_selftest.py +204 -0
- jupyter_ext/tests/test_mermaid_fixer.py +272 -0
- jupyter_ext/tests/test_resource_tiers.py +298 -0
- jupyter_ext/tools/__init__.py +15 -0
- jupyter_ext/tools/base.py +90 -0
- jupyter_ext/tools/context.py +268 -0
- jupyter_ext/tools/dir_complete.py +86 -0
- jupyter_ext/tools/file_finder.py +303 -0
- jupyter_ext/tools/file_tools.py +244 -0
- jupyter_ext/tools/jupyter_tools.py +296 -0
- jupyter_ext/tools/registry.py +159 -0
- jupyter_ext/tools/search_tools.py +230 -0
- jupyter_ext/tools/security_tools.py +657 -0
- jupyter_ext/tools/shell_tools.py +109 -0
- 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)
|