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,2993 @@
|
|
|
1
|
+
"""RAG Chat Agent — LangChain create_agent 기반 ReAct 에이전트.
|
|
2
|
+
|
|
3
|
+
chat mode에서 RAG 검색이 필요할 때 LLM이 자동으로 rag_search tool을 호출.
|
|
4
|
+
최대 2회 tool call, 스트리밍 지원.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from codebuff_core.platform_rules import with_platform_rules
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Raw LLM 로깅 유틸리티
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_LOG_DIR = Path("agent_logs")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_raw_log_path() -> Path:
|
|
30
|
+
"""타임스탬프 기반 로그 파일 경로 반환."""
|
|
31
|
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
33
|
+
return _LOG_DIR / f"raw_llm_{ts}.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_raw_logging_enabled() -> bool:
|
|
37
|
+
"""config에서 rawLlmLogging 설정을 매번 확인 (핫리로드)."""
|
|
38
|
+
try:
|
|
39
|
+
from codebuff_core.managers.config_manager import get_config_manager
|
|
40
|
+
|
|
41
|
+
return bool(get_config_manager().get_config().get("rawLlmLogging", False))
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(f"[RAG] _is_raw_logging_enabled error: {e}", flush=True)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _append_log(log_path: Path, entry: Dict[str, Any]) -> None:
|
|
48
|
+
"""JSON 배열에 엔트리 추가 (보기 좋은 포맷)."""
|
|
49
|
+
try:
|
|
50
|
+
entries: list = []
|
|
51
|
+
if log_path.exists() and log_path.stat().st_size > 0:
|
|
52
|
+
with open(log_path, "r", encoding="utf-8") as f:
|
|
53
|
+
entries = json.load(f)
|
|
54
|
+
entries.append(entry)
|
|
55
|
+
with open(log_path, "w", encoding="utf-8") as f:
|
|
56
|
+
json.dump(entries, f, ensure_ascii=False, default=str, indent=2)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
print(f"[RAG] _append_log error: {e} path={log_path}", flush=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Harmony 토큰 필터 래퍼
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
# GPT-OSS harmony 특수 토큰 패턴
|
|
66
|
+
_HARMONY_TOKEN_RE = re.compile(
|
|
67
|
+
r"<\|(?:start|end|message|channel|call|system|user|assistant|"
|
|
68
|
+
r"analysis|commentary|final|functions?)\|>"
|
|
69
|
+
)
|
|
70
|
+
_HARMONY_BLOCK_RE = re.compile(r"<\|message\|>.*?<\|end\|>", re.DOTALL)
|
|
71
|
+
# GPT-OSS 인용 마커 (예: [9†L1-L7], [3†source]) — RAG에서 무의미
|
|
72
|
+
# GPT-OSS 인용 마커 (예: [9†L1-L7], [7‡source], [3✝L2])
|
|
73
|
+
# †(U+2020) ‡(U+2021) ✝(U+271D) ✙(U+2719) 등 십자가 계열 + 일반 특수문자
|
|
74
|
+
_CITATION_MARKER_RE = re.compile(r"\[\d+[†‡✝✙✚✛✜⁺⁑][^\]]*\]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _on_llm_retry_failure(exc: Exception) -> None:
|
|
78
|
+
"""ModelRetryMiddleware on_failure: 로그 후 예외 재발생."""
|
|
79
|
+
print(f"[RAG] LLM API ERROR (all retries failed): {exc}", flush=True)
|
|
80
|
+
raise exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _create_dynamic_system_prompt_middleware_class(prompt_holder: list):
|
|
84
|
+
"""호출별로 시스템 프롬프트를 동적으로 교체하는 미들웨어.
|
|
85
|
+
|
|
86
|
+
prompt_holder는 [None] 또는 [str] 형태의 리스트.
|
|
87
|
+
RAGChatAgent._dynamic_prompt_holder를 공유 참조로 사용하므로
|
|
88
|
+
stream_response() 호출 전에 holder[0]을 설정하면 해당 호출에만 적용된다.
|
|
89
|
+
"""
|
|
90
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
91
|
+
|
|
92
|
+
class _DynamicSystemPrompt(AgentMiddleware):
|
|
93
|
+
def before_model(self, state, runtime=None): # noqa: ARG002
|
|
94
|
+
override = prompt_holder[0]
|
|
95
|
+
if not override:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
from langchain_core.messages import SystemMessage
|
|
99
|
+
|
|
100
|
+
messages = state.get("messages", [])
|
|
101
|
+
new_msgs = []
|
|
102
|
+
replaced = False
|
|
103
|
+
for msg in messages:
|
|
104
|
+
if msg.type == "system" and not replaced:
|
|
105
|
+
new_msgs.append(SystemMessage(content=override))
|
|
106
|
+
replaced = True
|
|
107
|
+
else:
|
|
108
|
+
new_msgs.append(msg)
|
|
109
|
+
if not replaced:
|
|
110
|
+
new_msgs.insert(0, SystemMessage(content=override))
|
|
111
|
+
return {"messages": new_msgs}
|
|
112
|
+
|
|
113
|
+
async def abefore_model(self, state, runtime=None): # noqa: ARG002
|
|
114
|
+
return self.before_model(state, runtime)
|
|
115
|
+
|
|
116
|
+
return _DynamicSystemPrompt
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _create_developer_role_middleware_class():
|
|
120
|
+
"""SystemMessage → developer role 변환 미들웨어.
|
|
121
|
+
|
|
122
|
+
gpt-oss는 system role을 메타데이터로 취급하고,
|
|
123
|
+
developer role을 최우선 지시사항으로 따른다.
|
|
124
|
+
"""
|
|
125
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
126
|
+
|
|
127
|
+
class _DeveloperRole(AgentMiddleware):
|
|
128
|
+
def before_model(self, state, runtime=None): # noqa: ARG002
|
|
129
|
+
from langchain_core.messages import ChatMessage
|
|
130
|
+
|
|
131
|
+
messages = state.get("messages", [])
|
|
132
|
+
if not messages:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
converted = []
|
|
136
|
+
changed = False
|
|
137
|
+
for msg in messages:
|
|
138
|
+
if msg.type == "system":
|
|
139
|
+
converted.append(ChatMessage(role="developer", content=msg.content))
|
|
140
|
+
changed = True
|
|
141
|
+
else:
|
|
142
|
+
converted.append(msg)
|
|
143
|
+
|
|
144
|
+
return {"messages": converted} if changed else None
|
|
145
|
+
|
|
146
|
+
async def abefore_model(self, state, runtime=None): # noqa: ARG002
|
|
147
|
+
return self.before_model(state, runtime)
|
|
148
|
+
|
|
149
|
+
return _DeveloperRole
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _create_strip_content_on_toolcall_middleware_class():
|
|
153
|
+
"""tool_calls가 있는 AIMessage에서 content를 제거하는 미들웨어.
|
|
154
|
+
|
|
155
|
+
LLM이 content + tool_calls를 동시에 보내면 content는 할루시네이션.
|
|
156
|
+
UI에서만 폐기하면 context에 누적되어 토큰 초과 원인이 됨.
|
|
157
|
+
after_model에서 AIMessage.content를 비워 context 절약.
|
|
158
|
+
"""
|
|
159
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
160
|
+
|
|
161
|
+
class _StripContentOnToolCall(AgentMiddleware):
|
|
162
|
+
def after_model(self, state, runtime=None): # noqa: ARG002
|
|
163
|
+
from langchain_core.messages import AIMessage
|
|
164
|
+
|
|
165
|
+
messages = state.get("messages", [])
|
|
166
|
+
if not messages:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
last = messages[-1]
|
|
170
|
+
if (
|
|
171
|
+
not isinstance(last, AIMessage)
|
|
172
|
+
or not last.tool_calls
|
|
173
|
+
or not last.content
|
|
174
|
+
):
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
# tool_calls가 있으면 content 제거
|
|
178
|
+
stripped = last.model_copy(update={"content": ""})
|
|
179
|
+
new_messages = list(messages[:-1]) + [stripped]
|
|
180
|
+
logger.info(
|
|
181
|
+
"[RAG] Stripped %d chars content from AIMessage with tool_calls",
|
|
182
|
+
len(last.content),
|
|
183
|
+
)
|
|
184
|
+
return {"messages": new_messages}
|
|
185
|
+
|
|
186
|
+
async def aafter_model(self, state, runtime=None): # noqa: ARG002
|
|
187
|
+
return self.after_model(state, runtime)
|
|
188
|
+
|
|
189
|
+
return _StripContentOnToolCall
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _create_tool_call_dedupe_middleware_class():
|
|
193
|
+
"""LLM 응답의 tool_calls에서 (name, args) 중복 제거.
|
|
194
|
+
|
|
195
|
+
gpt-oss-120b의 harmony tool format이 vLLM에서 같은 호출을 여러 번
|
|
196
|
+
emit하는 케이스 방어. LangGraph는 dedupe 없이 tool_calls 배열 전체를
|
|
197
|
+
parallel dispatch하므로 Athena 등 외부 호출이 N배로 늘어남.
|
|
198
|
+
args가 다르면 별개로 취급되어 정상 실행됨.
|
|
199
|
+
"""
|
|
200
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
201
|
+
|
|
202
|
+
class _ToolCallDedupe(AgentMiddleware):
|
|
203
|
+
def after_model(self, state, runtime=None): # noqa: ARG002
|
|
204
|
+
from langchain_core.messages import AIMessage
|
|
205
|
+
|
|
206
|
+
messages = state.get("messages", [])
|
|
207
|
+
if not messages:
|
|
208
|
+
return None
|
|
209
|
+
last = messages[-1]
|
|
210
|
+
if not isinstance(last, AIMessage):
|
|
211
|
+
return None
|
|
212
|
+
tcs = last.tool_calls
|
|
213
|
+
if not tcs or len(tcs) <= 1:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
seen = set()
|
|
217
|
+
deduped = []
|
|
218
|
+
for tc in tcs:
|
|
219
|
+
key = (
|
|
220
|
+
tc.get("name"),
|
|
221
|
+
json.dumps(tc.get("args", {}), sort_keys=True, default=str),
|
|
222
|
+
)
|
|
223
|
+
if key in seen:
|
|
224
|
+
print(
|
|
225
|
+
f"[RAG] dedupe duplicate tool_call: "
|
|
226
|
+
f"{tc.get('name')} args={tc.get('args')}",
|
|
227
|
+
flush=True,
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
seen.add(key)
|
|
231
|
+
deduped.append(tc)
|
|
232
|
+
if len(deduped) == len(tcs):
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
stripped = last.model_copy(update={"tool_calls": deduped})
|
|
236
|
+
new_messages = list(messages[:-1]) + [stripped]
|
|
237
|
+
return {"messages": new_messages}
|
|
238
|
+
|
|
239
|
+
async def aafter_model(self, state, runtime=None): # noqa: ARG002
|
|
240
|
+
return self.after_model(state, runtime)
|
|
241
|
+
|
|
242
|
+
return _ToolCallDedupe
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _create_snapshot_answer_middleware_class():
|
|
246
|
+
"""get_iceberg_snapshots tool 결과 ToolMessage에 답변 가이드 지시 추가.
|
|
247
|
+
|
|
248
|
+
Athena의 committed_at이 UTC ISO8601이므로 KST 변환 + 사용자 질문
|
|
249
|
+
의도에 맞춘 답변을 LLM에 강제. rag_search의 evidence reflection과
|
|
250
|
+
동일하게 awrap_tool_call로 ToolMessage content를 후처리.
|
|
251
|
+
"""
|
|
252
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
253
|
+
|
|
254
|
+
class _SnapshotAnswer(AgentMiddleware):
|
|
255
|
+
async def awrap_tool_call(self, request, handler):
|
|
256
|
+
tc = getattr(request, "tool_call", {})
|
|
257
|
+
tool_name = tc.get("name", "") if isinstance(tc, dict) else ""
|
|
258
|
+
if "get_iceberg_snapshots" not in tool_name:
|
|
259
|
+
return await handler(request)
|
|
260
|
+
|
|
261
|
+
result = await handler(request)
|
|
262
|
+
|
|
263
|
+
from langchain_core.messages import ToolMessage
|
|
264
|
+
|
|
265
|
+
if not isinstance(result, ToolMessage):
|
|
266
|
+
return result
|
|
267
|
+
content = result.content
|
|
268
|
+
if not isinstance(content, str):
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
return ToolMessage(
|
|
272
|
+
content=content + _SNAPSHOT_ANSWER_INSTRUCTION,
|
|
273
|
+
tool_call_id=result.tool_call_id,
|
|
274
|
+
name=result.name,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return _SnapshotAnswer
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# 검색 결과 뒤 근거 지시는 `prompts.chat_rag_prompt` 다 (이슈 #178, #218 — 코드 상수 없음).
|
|
281
|
+
|
|
282
|
+
# rag_search 결과에 "FAQ 검색 결과" 그룹이 있을 때 추가되는 안내.
|
|
283
|
+
# LLM이 사내 누적 단답 FAQ를 참고용으로만 활용하도록 유도.
|
|
284
|
+
_FAQ_CONTEXT_NOTE = (
|
|
285
|
+
"\n\n---\n"
|
|
286
|
+
'[위 "FAQ 검색 결과"는 사내에 누적된 단답 질의응답입니다.]\n'
|
|
287
|
+
"- **사용자 질문과 직접 관련된 항목**이 있을 때만 답변에 활용하세요.\n"
|
|
288
|
+
'- 관련이 약하거나 모호하면 무시하고 "가이드 검색 결과"를 우선 사용하세요.\n'
|
|
289
|
+
"- FAQ 답변을 그대로 복붙하지 말고, 사용자 질문 맥락에 맞춰 다듬어 응답하세요."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# rag_search tool result에 추가되는 evidence 분석 지시
|
|
294
|
+
_EVIDENCE_REFLECTION_INSTRUCTION = (
|
|
295
|
+
"\n\n---\n"
|
|
296
|
+
"[검색 결과 분석 지시]\n"
|
|
297
|
+
"1. 검색 결과에서 **사용자 질문과 직접 관련된 부분**만 골라 답변에 사용하세요. "
|
|
298
|
+
"무관한 내용을 끌어와 나열하지 마세요. 검색 결과를 통째로 요약하지 마세요.\n"
|
|
299
|
+
"2. 정보가 부족하면 다른 검색어로 rag_search를 다시 호출하세요. "
|
|
300
|
+
"일반 지식이나 추측으로 메우지 마세요.\n"
|
|
301
|
+
'3. 검색 결과에 답이 없으면 솔직히 "검색 결과에 해당 정보가 없습니다"라고 알리고, '
|
|
302
|
+
"사용자에게 추가 정보를 요청하세요.\n"
|
|
303
|
+
"4. 검색 결과에 없는 함수명·파라미터명·설정값·경로·숫자를 임의로 추가하지 마세요. "
|
|
304
|
+
"검색 결과에 없으면 그 항목은 답변에서 빼세요.\n"
|
|
305
|
+
"5. **간결하게 답변하세요.** 핵심만 포함하고 불필요한 서론이나 중복 설명을 넣지 마세요.\n"
|
|
306
|
+
"6. 음슴체(~한다/~함) 결과여도 반드시 대화체(~합니다)로 변환해서 답변하세요."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# rag_search 가 모든 검색에 실패했을 때(success: False) 위 지시 대신 붙는다 (이슈 #156).
|
|
310
|
+
# 위 3번("답이 없으면 '해당 정보가 없습니다'")이 붙으면 장애가 틀린 답으로 바뀐다.
|
|
311
|
+
_SEARCH_FAILED_INSTRUCTION = (
|
|
312
|
+
"\n\n---\n"
|
|
313
|
+
"[검색 실패 안내]\n"
|
|
314
|
+
"검색 시스템 오류로 검색이 실행되지 않았습니다(위 errors 참고). "
|
|
315
|
+
"결과가 없는 것이 아닙니다.\n"
|
|
316
|
+
'1. "검색 결과에 해당 정보가 없습니다"라고 답하지 마세요.\n'
|
|
317
|
+
"2. 사용자에게 검색 중 오류가 발생했다고 알리고, 잠시 후 다시 시도하도록 안내하세요.\n"
|
|
318
|
+
"3. 일반 지식이나 추측으로 답을 만들지 마세요."
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _search_failed(content: str) -> bool:
|
|
323
|
+
"""rag_search 결과가 전체 실패(`success: False`)인지."""
|
|
324
|
+
try:
|
|
325
|
+
data = json.loads(content)
|
|
326
|
+
except (json.JSONDecodeError, TypeError):
|
|
327
|
+
return False
|
|
328
|
+
return isinstance(data, dict) and data.get("success") is False
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# get_iceberg_snapshots tool result에 추가되는 답변 가이드 지시.
|
|
332
|
+
# Athena가 committed_at을 UTC ISO8601로 반환하므로 KST 변환 강제.
|
|
333
|
+
_SNAPSHOT_ANSWER_INSTRUCTION = (
|
|
334
|
+
"\n\n---\n"
|
|
335
|
+
"[적재 이력 분석 지시]\n"
|
|
336
|
+
"1. **committed_at은 UTC 시각**입니다. 사용자에게 답변할 때 반드시 "
|
|
337
|
+
"**KST(UTC+9)로 변환**해서 표시하세요. "
|
|
338
|
+
'예: "2026-06-09T03:15:00.000Z" → "2026-06-09 12:15 (KST)".\n'
|
|
339
|
+
"2. **결과를 그대로 나열하지 마세요.** 사용자 질문 의도에 맞춰 답하세요.\n"
|
|
340
|
+
' - "최근 적재 시각" → 가장 최근 1건만 KST로 답변\n'
|
|
341
|
+
' - "적재 빈도/주기" → 적재 간격 또는 일·시간 단위 빈도 분석\n'
|
|
342
|
+
' - "최근 N건" → 명시된 N건만 KST 시각으로 정리\n'
|
|
343
|
+
"3. snapshot_id, parent_id 같은 내부 식별자는 사용자가 명시적으로 "
|
|
344
|
+
"물어보지 않는 한 답변에서 빼세요.\n"
|
|
345
|
+
"4. operation, added/deleted/total records는 사용자가 적재 변동·증가량을 "
|
|
346
|
+
"물어봤을 때만 활용하세요.\n"
|
|
347
|
+
"5. **간결하게 답변하세요.** 표/리스트는 필요한 경우만 사용하고 핵심을 먼저 제시하세요."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# CRAG: 검색 결과 불충분 판정 시 사용하는 지시
|
|
352
|
+
_EVIDENCE_INSUFFICIENT_INSTRUCTION = (
|
|
353
|
+
"\n\n---\n"
|
|
354
|
+
"[검색 결과 품질 평가: 불충분]\n"
|
|
355
|
+
"검색 결과가 사용자 질문에 답하기에 불충분합니다.\n"
|
|
356
|
+
"1. 다른 검색어나 다른 컬렉션으로 rag_search를 다시 호출하세요.\n"
|
|
357
|
+
"2. 재검색으로도 정보를 찾지 못하면, "
|
|
358
|
+
"'해당 정보를 찾을 수 없습니다. 관련 문서를 직접 참고하세요'라고 안내하세요.\n"
|
|
359
|
+
"3. **절대로 검색 결과에 없는 내용을 만들어내지 마세요.**"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# 반복 검색 감지 시 사용하는 지시 (이전 결과 활용 권장)
|
|
363
|
+
_EVIDENCE_STOP_SEARCH_INSTRUCTION = (
|
|
364
|
+
"\n\n---\n"
|
|
365
|
+
"[행동 지시]\n"
|
|
366
|
+
"위에 이전 검색에서 수집한 실제 데이터가 첨부되어 있습니다. "
|
|
367
|
+
"이 데이터를 정독하고 그것을 근거로 답변을 작성하세요.\n"
|
|
368
|
+
"\n"
|
|
369
|
+
"**필수**:\n"
|
|
370
|
+
"- '해당 정보를 찾을 수 없습니다' / '검색 결과에 내용이 없습니다' 같은 답변 금지. "
|
|
371
|
+
"위 데이터에 충분한 정보가 있습니다.\n"
|
|
372
|
+
"- 이전 턴의 ToolMessage들도 컨텍스트에 남아있으니 함께 활용하세요.\n"
|
|
373
|
+
"- 데이터가 정말 부족한 부분만 의미적으로 다른 키워드로 재검색 — 같은 키워드 재시도 금지."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _cosine_sim(a: list, b: list) -> float:
|
|
378
|
+
"""두 벡터의 cosine similarity (순수 Python, 패키지 불필요)."""
|
|
379
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
380
|
+
norm_a = sum(x * x for x in a) ** 0.5
|
|
381
|
+
norm_b = sum(x * x for x in b) ** 0.5
|
|
382
|
+
return dot / (norm_a * norm_b) if norm_a and norm_b else 0.0
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# 반복 검색 감지(cosine skip) 대상 컬렉션
|
|
386
|
+
_GUIDE_COLLECTIONS = {"guide_docs", "ds_guide_docs"}
|
|
387
|
+
|
|
388
|
+
_REPETITIVE_SEARCH_THRESHOLD = 0.85
|
|
389
|
+
_CHUNK_OVERLAP_THRESHOLD = 0.7 # 결과 chunk 70% 이상 중복이면 중단
|
|
390
|
+
|
|
391
|
+
# search_l2_table을 한 턴에 2번째로 호출하려 할 때 주입하는 차단 지시.
|
|
392
|
+
# 자동 페이지네이션 루프(offset을 계속 올려 후보를 소진하되 사용자에겐 안 보여줌) 방지.
|
|
393
|
+
_L2_PAGING_STOP_INSTRUCTION = (
|
|
394
|
+
"[중단: 이번 턴에 L2 검색은 이미 1회 수행했습니다]\n"
|
|
395
|
+
"추가로 search_l2_table을 호출하지 마세요. 직전 호출에서 받은 candidates(후보 "
|
|
396
|
+
"테이블명 목록)를 **지금 사용자에게 그대로 나열해서 보여주고** 어떤 테이블인지 "
|
|
397
|
+
"확인을 요청하세요. more_candidates_count가 있으면 '이 외에 N개가 더 있습니다, "
|
|
398
|
+
"더 구체적인 이름을 알려주시면 좁혀드릴게요'라고만 덧붙이세요. **다음 페이지는 "
|
|
399
|
+
"사용자가 다시 요청할 때만** 가져옵니다 — 지금 이어서 조회하지 마세요."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _extract_chunk_ids(content: str) -> set:
|
|
404
|
+
"""rag_search 결과 JSON에서 chunk 식별자 집합을 추출.
|
|
405
|
+
|
|
406
|
+
각 컬렉션의 result item 형식이 다르므로 우선순위로 ID 생성:
|
|
407
|
+
1. `id` 필드 (가이드/테이블 컬렉션)
|
|
408
|
+
2. `payload.id` (legacy)
|
|
409
|
+
"""
|
|
410
|
+
ids: set = set()
|
|
411
|
+
try:
|
|
412
|
+
parsed = json.loads(content) if isinstance(content, str) else content
|
|
413
|
+
if not isinstance(parsed, dict):
|
|
414
|
+
return ids
|
|
415
|
+
for key in ("테이블 검색 결과", "가이드 검색 결과"):
|
|
416
|
+
for item in parsed.get(key, []):
|
|
417
|
+
if not isinstance(item, dict):
|
|
418
|
+
continue
|
|
419
|
+
pid = item.get("id") or item.get("payload", {}).get("id")
|
|
420
|
+
if pid:
|
|
421
|
+
ids.add(str(pid))
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
return ids
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _create_evidence_reflection_middleware_class(
|
|
428
|
+
reflection_instruction: str = _EVIDENCE_REFLECTION_INSTRUCTION,
|
|
429
|
+
):
|
|
430
|
+
"""rag_search tool result에 evidence 분석 지시를 추가하는 미들웨어.
|
|
431
|
+
|
|
432
|
+
`reflection_instruction` 은 검색이 성공했을 때 붙는 지시다. 채팅 답을 전제로 한
|
|
433
|
+
기본값("대화체로", "추가 정보를 요청") 대신 노트북 자동생성은 자기 문구를 준다(이슈 #155).
|
|
434
|
+
|
|
435
|
+
두 가지 중복 감지 메커니즘:
|
|
436
|
+
1. 임베딩 cosine similarity: 쿼리가 의미적으로 동일한지 (입력 관점)
|
|
437
|
+
2. Chunk ID 중복률: 검색 결과가 이전과 겹치는지 (출력 관점)
|
|
438
|
+
어느 하나라도 중복으로 판단하면 재검색 중단 지시.
|
|
439
|
+
|
|
440
|
+
- guide_docs 계열만 추적, 테이블 검색은 통과
|
|
441
|
+
- 병렬 호출은 pending에 모아뒀다가 before_model에서 확정
|
|
442
|
+
"""
|
|
443
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
444
|
+
|
|
445
|
+
class _EvidenceReflection(AgentMiddleware):
|
|
446
|
+
def __init__(self):
|
|
447
|
+
super().__init__()
|
|
448
|
+
# 임베딩 벡터 추적 (쿼리 유사도)
|
|
449
|
+
self._searched_embeddings: list = []
|
|
450
|
+
self._pending_embeddings: list = []
|
|
451
|
+
# chunk ID 추적 (결과 중복률)
|
|
452
|
+
self._seen_chunk_ids: set = set()
|
|
453
|
+
# 검색 결과 캐시 — _searched_embeddings 와 인덱스 정렬
|
|
454
|
+
# skip 발생 시 매칭되는 이전 결과를 다시 첨부하기 위함
|
|
455
|
+
self._search_cache: list[str] = []
|
|
456
|
+
self._pending_cache: list[str] = []
|
|
457
|
+
# 동일 query 누적 반복 카운트 (LLM stuck 패턴 차단용)
|
|
458
|
+
# cosine ≥ _REPETITIVE_SEARCH_THRESHOLD 인 query를 normalize 후 카운트
|
|
459
|
+
self._query_repeat_counts: dict = {}
|
|
460
|
+
# search_l2_table 턴당 호출 카운터 (자동 페이지네이션 루프 차단용)
|
|
461
|
+
self._l2_call_count: int = 0
|
|
462
|
+
|
|
463
|
+
def reset(self):
|
|
464
|
+
"""턴 시작 시 호출하여 누적 상태 초기화."""
|
|
465
|
+
self._searched_embeddings.clear()
|
|
466
|
+
self._pending_embeddings.clear()
|
|
467
|
+
self._seen_chunk_ids.clear()
|
|
468
|
+
self._search_cache.clear()
|
|
469
|
+
self._pending_cache.clear()
|
|
470
|
+
self._query_repeat_counts.clear()
|
|
471
|
+
self._l2_call_count = 0
|
|
472
|
+
|
|
473
|
+
def before_model(self, state, runtime=None): # noqa: ARG002
|
|
474
|
+
"""LLM 호출 직전: pending 벡터/캐시를 확정 목록으로 이동 (라운드 경계)."""
|
|
475
|
+
if self._pending_embeddings:
|
|
476
|
+
self._searched_embeddings.extend(self._pending_embeddings)
|
|
477
|
+
self._pending_embeddings.clear()
|
|
478
|
+
if self._pending_cache:
|
|
479
|
+
self._search_cache.extend(self._pending_cache)
|
|
480
|
+
self._pending_cache.clear()
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
async def abefore_model(self, state, runtime=None): # noqa: ARG002
|
|
484
|
+
return self.before_model(state, runtime)
|
|
485
|
+
|
|
486
|
+
async def awrap_tool_call(self, request, handler):
|
|
487
|
+
tc = getattr(request, "tool_call", {})
|
|
488
|
+
tool_name = tc.get("name", "") if isinstance(tc, dict) else ""
|
|
489
|
+
|
|
490
|
+
# ── search_l2_table 자동 페이지네이션 루프 차단 ──
|
|
491
|
+
# gpt-oss가 tool 결과의 next_candidate_offset을 보고 한 턴에 offset을
|
|
492
|
+
# 계속 올리며 후보를 다 소진하되 사용자에겐 아무것도 안 보여주는 문제.
|
|
493
|
+
# 턴당 1회만 실제 실행하고, 2번째부터는 synthetic ToolMessage로 차단해
|
|
494
|
+
# "가진 후보를 사용자에게 나열해 보여주고 멈추라"고 지시.
|
|
495
|
+
if "search_l2_table" in tool_name:
|
|
496
|
+
self._l2_call_count += 1
|
|
497
|
+
if self._l2_call_count > 1:
|
|
498
|
+
print(
|
|
499
|
+
f"[RAG] EvidenceReflection: search_l2_table paging blocked "
|
|
500
|
+
f"(call #{self._l2_call_count} this turn) "
|
|
501
|
+
f"— returning synthetic ToolMessage",
|
|
502
|
+
flush=True,
|
|
503
|
+
)
|
|
504
|
+
from langchain_core.messages import ToolMessage
|
|
505
|
+
|
|
506
|
+
return ToolMessage(
|
|
507
|
+
content=_L2_PAGING_STOP_INSTRUCTION,
|
|
508
|
+
tool_call_id=tc.get("id", "") if isinstance(tc, dict) else "",
|
|
509
|
+
name="search_l2_table",
|
|
510
|
+
)
|
|
511
|
+
return await handler(request)
|
|
512
|
+
|
|
513
|
+
if "rag_search" not in tool_name:
|
|
514
|
+
return await handler(request)
|
|
515
|
+
|
|
516
|
+
# guide_docs 계열 쿼리 추출
|
|
517
|
+
guide_queries: list = []
|
|
518
|
+
try:
|
|
519
|
+
args = tc.get("args", {}) if isinstance(tc, dict) else {}
|
|
520
|
+
queries_str = args.get("queries", "") if isinstance(args, dict) else ""
|
|
521
|
+
if queries_str:
|
|
522
|
+
q_list = (
|
|
523
|
+
json.loads(queries_str)
|
|
524
|
+
if isinstance(queries_str, str)
|
|
525
|
+
else queries_str
|
|
526
|
+
)
|
|
527
|
+
for q in q_list if isinstance(q_list, list) else []:
|
|
528
|
+
if not isinstance(q, dict):
|
|
529
|
+
continue
|
|
530
|
+
col = q.get("collection", "")
|
|
531
|
+
if col in _GUIDE_COLLECTIONS:
|
|
532
|
+
guide_queries.append(q.get("query", ""))
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
# ── 검색 전 임베딩 유사도 체크 (쿼리 관점) ──
|
|
537
|
+
# 이전 라운드와 동일 주제이면 검색 자체를 스킵
|
|
538
|
+
if guide_queries:
|
|
539
|
+
combined_query = " ".join(guide_queries)
|
|
540
|
+
try:
|
|
541
|
+
from codebuff_core.services.vllm_embedding_service import (
|
|
542
|
+
get_vllm_embedding_service,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
emb_service = get_vllm_embedding_service()
|
|
546
|
+
current_vec = await emb_service.embed_query(combined_query)
|
|
547
|
+
|
|
548
|
+
max_sim = 0.0
|
|
549
|
+
best_idx = -1
|
|
550
|
+
for i, prev_vec in enumerate(self._searched_embeddings):
|
|
551
|
+
sim = _cosine_sim(current_vec, prev_vec)
|
|
552
|
+
if sim > max_sim:
|
|
553
|
+
max_sim = sim
|
|
554
|
+
best_idx = i
|
|
555
|
+
|
|
556
|
+
print(
|
|
557
|
+
f"[RAG] EvidenceReflection: PRE-CHECK "
|
|
558
|
+
f"query={combined_query[:80]!r} "
|
|
559
|
+
f"max_sim={max_sim:.3f} "
|
|
560
|
+
f"prev_count={len(self._searched_embeddings)}",
|
|
561
|
+
flush=True,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
if max_sim > _REPETITIVE_SEARCH_THRESHOLD:
|
|
565
|
+
# 동일 query 누적 카운트 (normalize 후 dict 키)
|
|
566
|
+
norm_query = " ".join(combined_query.lower().split())
|
|
567
|
+
repeat_count = self._query_repeat_counts.get(norm_query, 0) + 1
|
|
568
|
+
self._query_repeat_counts[norm_query] = repeat_count
|
|
569
|
+
print(
|
|
570
|
+
f"[RAG] EvidenceReflection: SKIP search "
|
|
571
|
+
f"(cosine={max_sim:.3f}, repeat#{repeat_count}) "
|
|
572
|
+
f"— idx={best_idx}",
|
|
573
|
+
flush=True,
|
|
574
|
+
)
|
|
575
|
+
self._pending_embeddings.append(current_vec)
|
|
576
|
+
self._pending_cache.append("") # skip은 캐시 추가 안 함
|
|
577
|
+
from langchain_core.messages import ToolMessage
|
|
578
|
+
|
|
579
|
+
cached_content = (
|
|
580
|
+
self._search_cache[best_idx]
|
|
581
|
+
if 0 <= best_idx < len(self._search_cache)
|
|
582
|
+
else ""
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# 동일 query 반복 시 — 항상 cached 데이터를 보여주되,
|
|
586
|
+
# 횟수에 따라 헤더 메시지 톤만 변화. "차단됨" 단어 회피 —
|
|
587
|
+
# LLM이 이를 "데이터 없음"으로 잘못 해석하는 문제 방지.
|
|
588
|
+
if repeat_count == 1:
|
|
589
|
+
note = (
|
|
590
|
+
f"[재검색 불필요 — 직전 결과 재제공] "
|
|
591
|
+
f"이미 유사한 쿼리(cosine={max_sim:.2f})로 검색한 "
|
|
592
|
+
f"기록이 있어 동일 결과가 반환됩니다. 아래 데이터를 "
|
|
593
|
+
f"활용해 답변하거나, 의미적으로 **다른 키워드**로 "
|
|
594
|
+
f"새 검색을 시도하세요."
|
|
595
|
+
)
|
|
596
|
+
elif repeat_count == 2:
|
|
597
|
+
note = (
|
|
598
|
+
f"[동일 검색 {repeat_count}회째 — 이미 받은 데이터로 답변 작성] "
|
|
599
|
+
f"같은 정보를 반복 조회하는 중입니다. **추가 검색 없이 "
|
|
600
|
+
f"아래 결과만으로 즉시 답변을 작성**하세요. 다른 측면이 "
|
|
601
|
+
f"필요하면 의미적으로 다른 키워드를 사용하세요."
|
|
602
|
+
)
|
|
603
|
+
else:
|
|
604
|
+
note = (
|
|
605
|
+
f"[동일 검색 {repeat_count}회째 — 즉시 답변 작성 필수] "
|
|
606
|
+
f"이미 충분히 조회한 정보입니다. 아래 데이터(또는 이전 "
|
|
607
|
+
f"턴의 ToolMessage)에 답이 있습니다. **지금 즉시 답변을 "
|
|
608
|
+
f"작성**하세요. 추가 검색은 동일 결과만 반환합니다."
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# 모든 경우에 cached_content 그대로 첨부 (있을 때만)
|
|
612
|
+
if cached_content:
|
|
613
|
+
body = note + "\n\n---\n[이전 검색 결과]\n" + cached_content
|
|
614
|
+
else:
|
|
615
|
+
body = note
|
|
616
|
+
body += _EVIDENCE_STOP_SEARCH_INSTRUCTION
|
|
617
|
+
|
|
618
|
+
return ToolMessage(
|
|
619
|
+
content=body,
|
|
620
|
+
tool_call_id=getattr(request, "tool_call", {}).get(
|
|
621
|
+
"id", ""
|
|
622
|
+
),
|
|
623
|
+
name="rag_search",
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
self._pending_embeddings.append(current_vec)
|
|
627
|
+
except Exception as e:
|
|
628
|
+
print(
|
|
629
|
+
f"[RAG] EvidenceReflection: embedding pre-check failed: {e}",
|
|
630
|
+
flush=True,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# ── 검색 실행 ──
|
|
634
|
+
result = await handler(request)
|
|
635
|
+
content = result.content if hasattr(result, "content") else str(result)
|
|
636
|
+
|
|
637
|
+
# guide_queries가 있을 때 검색 결과를 캐시 (skip 발생 시 재첨부용)
|
|
638
|
+
# _pending_embeddings에 방금 append된 벡터와 인덱스 일치하도록 같이 append
|
|
639
|
+
if guide_queries:
|
|
640
|
+
self._pending_cache.append(content)
|
|
641
|
+
|
|
642
|
+
# FAQ 검색 결과 존재 여부 (있으면 안내 prefix 부착)
|
|
643
|
+
_faq_note = ""
|
|
644
|
+
try:
|
|
645
|
+
_peek = json.loads(content)
|
|
646
|
+
if isinstance(_peek, dict) and _peek.get("FAQ 검색 결과"):
|
|
647
|
+
_faq_note = _FAQ_CONTEXT_NOTE
|
|
648
|
+
except (json.JSONDecodeError, TypeError):
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
failed = _search_failed(content)
|
|
652
|
+
|
|
653
|
+
# guide_docs 쿼리가 없으면 (테이블 검색만) → 일반 지시
|
|
654
|
+
if not guide_queries:
|
|
655
|
+
from langchain_core.messages import ToolMessage
|
|
656
|
+
|
|
657
|
+
instruction = (
|
|
658
|
+
_SEARCH_FAILED_INSTRUCTION if failed else reflection_instruction
|
|
659
|
+
)
|
|
660
|
+
return ToolMessage(
|
|
661
|
+
content=content + _faq_note + instruction,
|
|
662
|
+
tool_call_id=getattr(result, "tool_call_id", ""),
|
|
663
|
+
name=getattr(result, "name", None),
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
# ── 검색 후 chunk ID 중복률 체크 (결과 관점) ──
|
|
667
|
+
is_repetitive = False
|
|
668
|
+
current_ids = _extract_chunk_ids(content)
|
|
669
|
+
if current_ids and self._seen_chunk_ids:
|
|
670
|
+
overlap = current_ids & self._seen_chunk_ids
|
|
671
|
+
overlap_ratio = len(overlap) / len(current_ids) if current_ids else 0
|
|
672
|
+
if overlap_ratio >= _CHUNK_OVERLAP_THRESHOLD:
|
|
673
|
+
is_repetitive = True
|
|
674
|
+
print(
|
|
675
|
+
f"[RAG] EvidenceReflection: chunk_ids={len(current_ids)} "
|
|
676
|
+
f"overlap={overlap_ratio:.0%} repetitive={is_repetitive}",
|
|
677
|
+
flush=True,
|
|
678
|
+
)
|
|
679
|
+
self._seen_chunk_ids.update(current_ids)
|
|
680
|
+
|
|
681
|
+
from langchain_core.messages import ToolMessage
|
|
682
|
+
|
|
683
|
+
if failed:
|
|
684
|
+
instruction = _SEARCH_FAILED_INSTRUCTION
|
|
685
|
+
elif is_repetitive:
|
|
686
|
+
instruction = _EVIDENCE_STOP_SEARCH_INSTRUCTION
|
|
687
|
+
else:
|
|
688
|
+
instruction = reflection_instruction
|
|
689
|
+
|
|
690
|
+
return ToolMessage(
|
|
691
|
+
content=content + _faq_note + instruction,
|
|
692
|
+
tool_call_id=getattr(result, "tool_call_id", ""),
|
|
693
|
+
name=getattr(result, "name", None),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
return _EvidenceReflection
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
_ENTITY_PATTERNS = [
|
|
700
|
+
re.compile(r"`([^`]+)`"), # 백틱 내 용어
|
|
701
|
+
re.compile(
|
|
702
|
+
r"[a-z][a-z0-9]*(?:[_\-][a-z0-9]+){2,}"
|
|
703
|
+
), # snake/kebab 2+ (예: target-file-size-bytes)
|
|
704
|
+
re.compile(
|
|
705
|
+
r"[a-z][a-z0-9]*(?:\.[a-z_][a-z0-9_]*)+"
|
|
706
|
+
), # dot 표기 (예: system.rewrite_data_files)
|
|
707
|
+
re.compile(r"s3://[^\s\"',)]+"), # S3 경로
|
|
708
|
+
re.compile(r"\d+\s*(?:MB|GB|TB|KB|ms|초|분|시간|일|회|개)\b"), # 숫자+단위
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
# after_model 트리거 임계값: 응답에만 있는 미확인 개체 수
|
|
712
|
+
_UNGROUNDED_ENTITY_THRESHOLD = 5
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _extract_entities(text: str) -> set:
|
|
716
|
+
"""텍스트에서 구체적 개체(함수명, 경로, 숫자+단위 등)를 추출."""
|
|
717
|
+
entities: set = set()
|
|
718
|
+
for pat in _ENTITY_PATTERNS:
|
|
719
|
+
for m in pat.finditer(text):
|
|
720
|
+
val = m.group(1) if m.lastindex else m.group(0)
|
|
721
|
+
val = val.strip().lower()
|
|
722
|
+
if len(val) > 2:
|
|
723
|
+
entities.add(val)
|
|
724
|
+
return entities
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _create_rag_grounding_middleware_class(constraint_text: Optional[str] = None):
|
|
728
|
+
"""ToolMessage 직후 LLM 호출 전 grounding constraint를 주입하는 미들웨어.
|
|
729
|
+
|
|
730
|
+
핵심 동작:
|
|
731
|
+
1. 마지막 메시지가 ToolMessage일 때만 발동.
|
|
732
|
+
2. 이전 라운드에 주입했던 grounding HumanMessage들을 모두 제거(누적 방지).
|
|
733
|
+
3. 현재 턴의 사용자 원본 질문(가장 최근 grounding 아닌 HumanMessage)을
|
|
734
|
+
constraint 텍스트 상단에 echo하여 LLM이 항상 질문을 인식하도록 함.
|
|
735
|
+
4. 그 turn 안에서 이미 시도한 rag_search 쿼리 옵션을 constraint 끝에 부착하여
|
|
736
|
+
"이전과 다른 쿼리로 재검색" 지시가 구체적인 회피 키워드를 갖도록 함.
|
|
737
|
+
5. 새 grounding HumanMessage는 additional_kwargs에 _rag_grounding 플래그를
|
|
738
|
+
박아 다음 라운드에서 식별/제거 가능하게 한다.
|
|
739
|
+
"""
|
|
740
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
741
|
+
|
|
742
|
+
from codebuff_core.managers.config_manager import prompt_value
|
|
743
|
+
|
|
744
|
+
# 호출부 문구(노트북·EDA) > `prompts.chat_rag_prompt` (이슈 #178, #218)
|
|
745
|
+
_constraint = constraint_text or prompt_value("chat_rag_prompt")
|
|
746
|
+
_GROUNDING_FLAG = "_rag_grounding"
|
|
747
|
+
|
|
748
|
+
def _is_grounding(m: Any) -> bool:
|
|
749
|
+
return bool(
|
|
750
|
+
getattr(m, "additional_kwargs", None)
|
|
751
|
+
and m.additional_kwargs.get(_GROUNDING_FLAG)
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
def _format_query_entry(q: Any) -> str:
|
|
755
|
+
"""rag_search queries 한 항목 → 사람이 읽기 쉬운 한 줄."""
|
|
756
|
+
if not isinstance(q, dict):
|
|
757
|
+
return str(q)
|
|
758
|
+
qt = (q.get("query") or "").strip()
|
|
759
|
+
parts: List[str] = []
|
|
760
|
+
if qt:
|
|
761
|
+
parts.append(f'"{qt}"')
|
|
762
|
+
for key in ("collection", "type"):
|
|
763
|
+
v = q.get(key)
|
|
764
|
+
if v:
|
|
765
|
+
parts.append(f"{key}={v}")
|
|
766
|
+
mt = q.get("match_text")
|
|
767
|
+
if mt:
|
|
768
|
+
parts.append(f"match_text={mt}")
|
|
769
|
+
return " / ".join(parts) if parts else str(q)
|
|
770
|
+
|
|
771
|
+
def _extract_prior_queries(messages_after_user: List[Any]) -> List[str]:
|
|
772
|
+
"""이번 턴 안에서 호출된 rag_search 쿼리 목록 추출."""
|
|
773
|
+
import json as _json
|
|
774
|
+
from langchain_core.messages import AIMessage
|
|
775
|
+
|
|
776
|
+
out: List[str] = []
|
|
777
|
+
seen: set = set()
|
|
778
|
+
for m in messages_after_user:
|
|
779
|
+
if not isinstance(m, AIMessage):
|
|
780
|
+
continue
|
|
781
|
+
tcs = getattr(m, "tool_calls", None) or []
|
|
782
|
+
for tc in tcs:
|
|
783
|
+
if tc.get("name") != "rag_search":
|
|
784
|
+
continue
|
|
785
|
+
args = tc.get("args") or {}
|
|
786
|
+
raw = args.get("queries", "")
|
|
787
|
+
parsed = raw
|
|
788
|
+
if isinstance(raw, str):
|
|
789
|
+
try:
|
|
790
|
+
parsed = _json.loads(raw)
|
|
791
|
+
except Exception:
|
|
792
|
+
parsed = raw
|
|
793
|
+
if isinstance(parsed, dict):
|
|
794
|
+
parsed = [parsed]
|
|
795
|
+
if not isinstance(parsed, list):
|
|
796
|
+
continue
|
|
797
|
+
for q in parsed:
|
|
798
|
+
entry = _format_query_entry(q)
|
|
799
|
+
if entry and entry not in seen:
|
|
800
|
+
seen.add(entry)
|
|
801
|
+
out.append(entry)
|
|
802
|
+
return out
|
|
803
|
+
|
|
804
|
+
class _RagGrounding(AgentMiddleware):
|
|
805
|
+
def before_model(self, state, runtime=None): # noqa: ARG002
|
|
806
|
+
from langchain_core.messages import HumanMessage, ToolMessage
|
|
807
|
+
from langchain_core.messages import RemoveMessage
|
|
808
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
809
|
+
|
|
810
|
+
messages = state.get("messages", [])
|
|
811
|
+
if not messages:
|
|
812
|
+
return None
|
|
813
|
+
|
|
814
|
+
# 마지막 메시지가 ToolMessage인 경우에만 inject
|
|
815
|
+
last_msg = messages[-1]
|
|
816
|
+
if not isinstance(last_msg, ToolMessage):
|
|
817
|
+
return None
|
|
818
|
+
|
|
819
|
+
# 1) 이전 grounding HumanMessage 제거 (누적 방지)
|
|
820
|
+
filtered: List[Any] = [m for m in messages if not _is_grounding(m)]
|
|
821
|
+
|
|
822
|
+
# 2) 현재 턴 user 질문 = 가장 최근의 grounding 아닌 HumanMessage
|
|
823
|
+
user_question = ""
|
|
824
|
+
last_user_idx = -1
|
|
825
|
+
for idx, m in enumerate(filtered):
|
|
826
|
+
if isinstance(m, HumanMessage):
|
|
827
|
+
user_question = (
|
|
828
|
+
(m.content or "").strip()
|
|
829
|
+
if isinstance(m.content, str)
|
|
830
|
+
else str(m.content)
|
|
831
|
+
)
|
|
832
|
+
last_user_idx = idx
|
|
833
|
+
|
|
834
|
+
# 3) 사용자 메시지 이후의 rag_search 쿼리 누적
|
|
835
|
+
prior_queries = _extract_prior_queries(
|
|
836
|
+
filtered[last_user_idx + 1 :] if last_user_idx >= 0 else filtered
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
# 4) constraint 조립: 헤더(user 질문) + 본문 + (prior queries)
|
|
840
|
+
sections: List[str] = []
|
|
841
|
+
if user_question:
|
|
842
|
+
_uq = user_question[:1000] # 매우 긴 질문 대비 cap
|
|
843
|
+
sections.append(f"[사용자 원본 질문]\n{_uq}")
|
|
844
|
+
sections.append(_constraint)
|
|
845
|
+
if prior_queries:
|
|
846
|
+
_lines = "\n".join(f" - {q}" for q in prior_queries[:20])
|
|
847
|
+
sections.append(
|
|
848
|
+
"[이미 시도한 rag_search 쿼리 — 다시 검색할 경우 이 키워드/조합과 "
|
|
849
|
+
"구분되는 새 쿼리를 사용하세요]\n" + _lines
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
final_constraint = "\n\n".join(sections)
|
|
853
|
+
|
|
854
|
+
new_grounding = HumanMessage(
|
|
855
|
+
content=final_constraint,
|
|
856
|
+
additional_kwargs={_GROUNDING_FLAG: True},
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
"messages": [
|
|
861
|
+
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
|
862
|
+
*filtered,
|
|
863
|
+
new_grounding,
|
|
864
|
+
]
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async def abefore_model(self, state, runtime=None): # noqa: ARG002
|
|
868
|
+
return self.before_model(state, runtime)
|
|
869
|
+
|
|
870
|
+
return _RagGrounding
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# tiktoken 인코더 싱글톤 캐시
|
|
874
|
+
_tiktoken_enc = None
|
|
875
|
+
_tiktoken_available = None # None=미확인, True/False
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _get_tiktoken_encoder():
|
|
879
|
+
"""tiktoken 인코더를 한 번만 초기화. 패키지 내장 캐시를 사용하여 오프라인 지원."""
|
|
880
|
+
global _tiktoken_enc, _tiktoken_available # noqa: PLW0603
|
|
881
|
+
if _tiktoken_enc is not None:
|
|
882
|
+
return _tiktoken_enc
|
|
883
|
+
|
|
884
|
+
import os
|
|
885
|
+
|
|
886
|
+
import tiktoken
|
|
887
|
+
|
|
888
|
+
# 패키지 내장 캐시 디렉토리를 TIKTOKEN_CACHE_DIR로 설정
|
|
889
|
+
if "TIKTOKEN_CACHE_DIR" not in os.environ:
|
|
890
|
+
cache_dir = str(Path(__file__).parent.parent / "data" / "tiktoken_cache")
|
|
891
|
+
if Path(cache_dir).is_dir():
|
|
892
|
+
os.environ["TIKTOKEN_CACHE_DIR"] = cache_dir
|
|
893
|
+
|
|
894
|
+
_tiktoken_enc = tiktoken.get_encoding("cl100k_base")
|
|
895
|
+
_tiktoken_available = True
|
|
896
|
+
return _tiktoken_enc
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
CHUNK_SIZE = 10000 # 분할 인코딩 단위 (큰 텍스트 O(n²) 방지)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _count_tokens_text(enc, text: str) -> int:
|
|
903
|
+
"""큰 텍스트를 청크로 분할 후 batch 인코딩하여 토큰 수 반환."""
|
|
904
|
+
if len(text) <= CHUNK_SIZE:
|
|
905
|
+
return len(enc.encode(text))
|
|
906
|
+
chunks = [text[i : i + CHUNK_SIZE] for i in range(0, len(text), CHUNK_SIZE)]
|
|
907
|
+
results = enc.encode_ordinary_batch(chunks)
|
|
908
|
+
return sum(len(r) for r in results)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _estimate_tokens(messages) -> int:
|
|
912
|
+
"""tiktoken으로 메시지 토큰 수 추정. 실패 시 한국어 보수적 추정(2자/토큰) 폴백."""
|
|
913
|
+
global _tiktoken_available # noqa: PLW0603
|
|
914
|
+
|
|
915
|
+
if _tiktoken_available is False:
|
|
916
|
+
return _estimate_tokens_fallback(messages)
|
|
917
|
+
|
|
918
|
+
try:
|
|
919
|
+
enc = _get_tiktoken_encoder()
|
|
920
|
+
total = 0
|
|
921
|
+
for msg in messages:
|
|
922
|
+
content = msg.content if hasattr(msg, "content") else str(msg)
|
|
923
|
+
total += _count_tokens_text(enc, content) + 4
|
|
924
|
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
|
925
|
+
tc_str = str(msg.tool_calls)
|
|
926
|
+
total += _count_tokens_text(enc, tc_str)
|
|
927
|
+
print(
|
|
928
|
+
f"[RAG] tiktoken estimate: {total} tokens ({len(messages)} messages)",
|
|
929
|
+
flush=True,
|
|
930
|
+
)
|
|
931
|
+
return total
|
|
932
|
+
except Exception as e:
|
|
933
|
+
_tiktoken_available = False
|
|
934
|
+
print(f"[RAG] tiktoken unavailable, using fallback: {e}", flush=True)
|
|
935
|
+
return _estimate_tokens_fallback(messages)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _estimate_tokens_fallback(messages) -> int:
|
|
939
|
+
"""tiktoken 없을 때 폴백. 한국어 보수적 추정 (2자/토큰)."""
|
|
940
|
+
total = 0
|
|
941
|
+
for msg in messages:
|
|
942
|
+
content = msg.content if hasattr(msg, "content") else str(msg)
|
|
943
|
+
total += len(content) // 2 + 4
|
|
944
|
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
|
945
|
+
total += len(str(msg.tool_calls)) // 2
|
|
946
|
+
print(
|
|
947
|
+
f"[RAG] token estimate (fallback): {total} tokens ({len(messages)} messages)",
|
|
948
|
+
flush=True,
|
|
949
|
+
)
|
|
950
|
+
return total
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _create_context_compression_middleware_class(model_context_limit: int = 131072):
|
|
954
|
+
"""LLM 호출 전 토큰 사전 체크 + 점진적 ToolMessage 축소 미들웨어.
|
|
955
|
+
|
|
956
|
+
1. score 낮은 검색 결과부터 점진적 삭제
|
|
957
|
+
2. 마지막 1개 남으면 columns 제거 + content 2000자 컷
|
|
958
|
+
3. 87.5% 초과 시 더 공격적 축약
|
|
959
|
+
"""
|
|
960
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
961
|
+
|
|
962
|
+
# 시스템 프롬프트 + tools 스키마 오버헤드 감안 (메시지만 세므로 보수적)
|
|
963
|
+
SOFT_LIMIT = int(model_context_limit * 0.5)
|
|
964
|
+
HARD_LIMIT = int(model_context_limit * 0.65)
|
|
965
|
+
|
|
966
|
+
class _ContextCompression(AgentMiddleware):
|
|
967
|
+
def before_model(self, state, runtime=None): # noqa: ARG002
|
|
968
|
+
import json as _json
|
|
969
|
+
|
|
970
|
+
from langchain_core.messages import ToolMessage
|
|
971
|
+
|
|
972
|
+
messages = state.get("messages", [])
|
|
973
|
+
if not messages:
|
|
974
|
+
return None
|
|
975
|
+
|
|
976
|
+
estimated = _estimate_tokens(messages)
|
|
977
|
+
if estimated <= SOFT_LIMIT:
|
|
978
|
+
return None
|
|
979
|
+
|
|
980
|
+
# 메시지별 토큰 상세 로깅
|
|
981
|
+
for i, msg in enumerate(messages):
|
|
982
|
+
content = msg.content if hasattr(msg, "content") else ""
|
|
983
|
+
msg_tokens = len(content) // 2
|
|
984
|
+
logger.info(
|
|
985
|
+
"[RAG] Context msg[%d] type=%s tokens≈%d chars=%d",
|
|
986
|
+
i,
|
|
987
|
+
type(msg).__name__,
|
|
988
|
+
msg_tokens,
|
|
989
|
+
len(content),
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
logger.info(
|
|
993
|
+
"[RAG] Context compression: estimated=%d tokens, soft=%d, hard=%d",
|
|
994
|
+
estimated,
|
|
995
|
+
SOFT_LIMIT,
|
|
996
|
+
HARD_LIMIT,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
# ToolMessage 인덱스 수집
|
|
1000
|
+
new_messages = list(messages)
|
|
1001
|
+
changed = False
|
|
1002
|
+
|
|
1003
|
+
for msg_idx in range(len(new_messages) - 1, -1, -1):
|
|
1004
|
+
msg = new_messages[msg_idx]
|
|
1005
|
+
if not isinstance(msg, ToolMessage) or not msg.content:
|
|
1006
|
+
continue
|
|
1007
|
+
|
|
1008
|
+
# ToolMessage의 content를 JSON 파싱하여 결과 리스트 축소
|
|
1009
|
+
# EvidenceReflection이 append한 텍스트를 strip 후 파싱
|
|
1010
|
+
_raw_content = msg.content
|
|
1011
|
+
_marker_pos = _raw_content.find("\n\n---\n[검색 결과 분석 지시]")
|
|
1012
|
+
_json_part = (
|
|
1013
|
+
_raw_content[:_marker_pos] if _marker_pos > 0 else _raw_content
|
|
1014
|
+
)
|
|
1015
|
+
_suffix = _raw_content[_marker_pos:] if _marker_pos > 0 else ""
|
|
1016
|
+
try:
|
|
1017
|
+
parsed = _json.loads(_json_part)
|
|
1018
|
+
except (ValueError, TypeError):
|
|
1019
|
+
continue
|
|
1020
|
+
|
|
1021
|
+
if not isinstance(parsed, dict):
|
|
1022
|
+
continue
|
|
1023
|
+
|
|
1024
|
+
# 결과 리스트 키 찾기
|
|
1025
|
+
result_keys = [
|
|
1026
|
+
k
|
|
1027
|
+
for k in ("테이블 검색 결과", "가이드 검색 결과")
|
|
1028
|
+
if k in parsed
|
|
1029
|
+
and isinstance(parsed[k], list)
|
|
1030
|
+
and len(parsed[k]) > 0
|
|
1031
|
+
]
|
|
1032
|
+
|
|
1033
|
+
if not result_keys:
|
|
1034
|
+
continue
|
|
1035
|
+
|
|
1036
|
+
# Step 1: (결과 삭제 비활성화 — 컬럼 축소로 충분)
|
|
1037
|
+
|
|
1038
|
+
# Step 2: 모든 결과의 컬럼을 동시에 50%씩 삭감 + content 축약
|
|
1039
|
+
# 기존 방식: 결과 하나씩 컬럼 0까지 → 다음 결과 (비효율적)
|
|
1040
|
+
# 개선: 모든 결과 컬럼을 한 라운드에 50%씩 → estimate → 반복
|
|
1041
|
+
estimated = _estimate_tokens(new_messages)
|
|
1042
|
+
if estimated > SOFT_LIMIT:
|
|
1043
|
+
# 모든 결과의 payload 수집
|
|
1044
|
+
all_payloads = []
|
|
1045
|
+
for key in result_keys:
|
|
1046
|
+
for r in parsed.get(key, []):
|
|
1047
|
+
p = r.get("payload", r)
|
|
1048
|
+
if isinstance(p, dict):
|
|
1049
|
+
all_payloads.append(p)
|
|
1050
|
+
|
|
1051
|
+
# 라운드별: 모든 payload의 컬럼을 동시에 50% 삭감
|
|
1052
|
+
max_rounds = 12
|
|
1053
|
+
for round_i in range(max_rounds):
|
|
1054
|
+
any_cut = False
|
|
1055
|
+
for p in all_payloads:
|
|
1056
|
+
cols = p.get("columns")
|
|
1057
|
+
if not cols or not isinstance(cols, list):
|
|
1058
|
+
continue
|
|
1059
|
+
original = len(cols)
|
|
1060
|
+
keep = max(original // 2, 0)
|
|
1061
|
+
if keep == 0:
|
|
1062
|
+
del p["columns"]
|
|
1063
|
+
p["_notice"] = (
|
|
1064
|
+
"[토큰 제한으로 컬럼 상세 정보 전부 생략]"
|
|
1065
|
+
)
|
|
1066
|
+
else:
|
|
1067
|
+
p["columns"] = cols[:keep]
|
|
1068
|
+
p["_notice"] = (
|
|
1069
|
+
f"[토큰 제한으로 컬럼 {original} → {keep}개]"
|
|
1070
|
+
)
|
|
1071
|
+
any_cut = True
|
|
1072
|
+
|
|
1073
|
+
if not any_cut:
|
|
1074
|
+
break
|
|
1075
|
+
|
|
1076
|
+
changed = True
|
|
1077
|
+
new_content = (
|
|
1078
|
+
_json.dumps(parsed, ensure_ascii=False, default=str)
|
|
1079
|
+
+ _suffix
|
|
1080
|
+
)
|
|
1081
|
+
new_messages[msg_idx] = ToolMessage(
|
|
1082
|
+
content=new_content,
|
|
1083
|
+
tool_call_id=msg.tool_call_id,
|
|
1084
|
+
name=getattr(msg, "name", None),
|
|
1085
|
+
)
|
|
1086
|
+
estimated = _estimate_tokens(new_messages)
|
|
1087
|
+
print(
|
|
1088
|
+
f"[RAG] Compression round {round_i + 1}: "
|
|
1089
|
+
f"{estimated} tokens",
|
|
1090
|
+
flush=True,
|
|
1091
|
+
)
|
|
1092
|
+
if estimated <= SOFT_LIMIT:
|
|
1093
|
+
break
|
|
1094
|
+
|
|
1095
|
+
# 컬럼 전부 제거 후에도 초과 → content 50%씩 축약
|
|
1096
|
+
if estimated > SOFT_LIMIT:
|
|
1097
|
+
for p in all_payloads:
|
|
1098
|
+
ct = p.get("content", "")
|
|
1099
|
+
if not ct or not isinstance(ct, str):
|
|
1100
|
+
continue
|
|
1101
|
+
original_len = len(ct)
|
|
1102
|
+
while (
|
|
1103
|
+
estimated > SOFT_LIMIT
|
|
1104
|
+
and len(p.get("content", "")) > 200
|
|
1105
|
+
):
|
|
1106
|
+
cur = len(p["content"])
|
|
1107
|
+
keep = max(cur // 2, 200)
|
|
1108
|
+
p["content"] = p["content"][:keep]
|
|
1109
|
+
p["_content_notice"] = (
|
|
1110
|
+
f"[토큰 제한으로 원문 {original_len} → {keep}자]"
|
|
1111
|
+
)
|
|
1112
|
+
changed = True
|
|
1113
|
+
new_content = (
|
|
1114
|
+
_json.dumps(parsed, ensure_ascii=False, default=str)
|
|
1115
|
+
+ _suffix
|
|
1116
|
+
)
|
|
1117
|
+
new_messages[msg_idx] = ToolMessage(
|
|
1118
|
+
content=new_content,
|
|
1119
|
+
tool_call_id=msg.tool_call_id,
|
|
1120
|
+
name=getattr(msg, "name", None),
|
|
1121
|
+
)
|
|
1122
|
+
estimated = _estimate_tokens(new_messages)
|
|
1123
|
+
|
|
1124
|
+
# Step 3: 87.5% 초과 시 더 공격적 축약
|
|
1125
|
+
estimated = _estimate_tokens(new_messages)
|
|
1126
|
+
if estimated > HARD_LIMIT:
|
|
1127
|
+
for key in result_keys:
|
|
1128
|
+
results = parsed.get(key, [])
|
|
1129
|
+
for r in results:
|
|
1130
|
+
payload = r.get("payload", r)
|
|
1131
|
+
if isinstance(payload, dict) and "content" in payload:
|
|
1132
|
+
if len(payload["content"]) > 500:
|
|
1133
|
+
payload["content"] = payload["content"][:500]
|
|
1134
|
+
payload["_content_notice"] = (
|
|
1135
|
+
"[토큰 제한으로 원문이 500자로 축약되었습니다]"
|
|
1136
|
+
)
|
|
1137
|
+
changed = True
|
|
1138
|
+
|
|
1139
|
+
if changed:
|
|
1140
|
+
new_content = (
|
|
1141
|
+
_json.dumps(parsed, ensure_ascii=False, default=str)
|
|
1142
|
+
+ _suffix
|
|
1143
|
+
)
|
|
1144
|
+
new_messages[msg_idx] = ToolMessage(
|
|
1145
|
+
content=new_content,
|
|
1146
|
+
tool_call_id=msg.tool_call_id,
|
|
1147
|
+
name=getattr(msg, "name", None),
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
# 하나의 ToolMessage 처리 후 토큰 재확인
|
|
1151
|
+
estimated = _estimate_tokens(new_messages)
|
|
1152
|
+
if estimated <= SOFT_LIMIT:
|
|
1153
|
+
break
|
|
1154
|
+
|
|
1155
|
+
if changed:
|
|
1156
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
1157
|
+
from langchain_core.messages import RemoveMessage
|
|
1158
|
+
|
|
1159
|
+
final_tokens = _estimate_tokens(new_messages)
|
|
1160
|
+
print(
|
|
1161
|
+
f"[RAG] Context compressed: → {final_tokens} tokens",
|
|
1162
|
+
flush=True,
|
|
1163
|
+
)
|
|
1164
|
+
return {
|
|
1165
|
+
"messages": [
|
|
1166
|
+
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
|
1167
|
+
*new_messages,
|
|
1168
|
+
]
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return None
|
|
1172
|
+
|
|
1173
|
+
async def abefore_model(self, state, runtime=None): # noqa: ARG002
|
|
1174
|
+
return self.before_model(state, runtime)
|
|
1175
|
+
|
|
1176
|
+
return _ContextCompression
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _create_summary_to_system_middleware_class():
|
|
1180
|
+
"""SummarizationMiddleware가 생성한 요약 HumanMessage를 SystemMessage로 변환.
|
|
1181
|
+
|
|
1182
|
+
SummarizationMiddleware는 요약을 HumanMessage(lc_source=summarization)로 넣는데,
|
|
1183
|
+
LLM이 이를 사용자 메시지로 착각해 요약을 그대로 답변으로 출력하는 문제 방지.
|
|
1184
|
+
SystemMessage로 변환하여 "이전 세션 요약"임을 명시.
|
|
1185
|
+
"""
|
|
1186
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
1187
|
+
|
|
1188
|
+
class _SummaryToSystem(AgentMiddleware):
|
|
1189
|
+
def before_model(self, state, runtime=None): # noqa: ARG002
|
|
1190
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
1191
|
+
|
|
1192
|
+
messages = state.get("messages", [])
|
|
1193
|
+
if not messages:
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
new_messages = []
|
|
1197
|
+
changed = False
|
|
1198
|
+
for msg in messages:
|
|
1199
|
+
if (
|
|
1200
|
+
isinstance(msg, HumanMessage)
|
|
1201
|
+
and msg.additional_kwargs.get("lc_source") == "summarization"
|
|
1202
|
+
):
|
|
1203
|
+
# 요약 HumanMessage → SystemMessage로 변환
|
|
1204
|
+
summary_content = msg.content
|
|
1205
|
+
# "Here is a summary..." 접두사 제거
|
|
1206
|
+
if ":\n\n" in summary_content:
|
|
1207
|
+
summary_content = summary_content.split(":\n\n", 1)[1]
|
|
1208
|
+
new_messages.append(
|
|
1209
|
+
SystemMessage(
|
|
1210
|
+
content=(
|
|
1211
|
+
"[이전 세션 요약]\n"
|
|
1212
|
+
"다음은 이전 대화의 요약입니다. 참고만 하고, "
|
|
1213
|
+
"사용자의 현재 질문에 답변하세요.\n\n"
|
|
1214
|
+
f"{summary_content}"
|
|
1215
|
+
)
|
|
1216
|
+
)
|
|
1217
|
+
)
|
|
1218
|
+
changed = True
|
|
1219
|
+
logger.info(
|
|
1220
|
+
"[RAG] Converted summary HumanMessage → SystemMessage (%d chars)",
|
|
1221
|
+
len(summary_content),
|
|
1222
|
+
)
|
|
1223
|
+
else:
|
|
1224
|
+
new_messages.append(msg)
|
|
1225
|
+
|
|
1226
|
+
return {"messages": new_messages} if changed else None
|
|
1227
|
+
|
|
1228
|
+
async def abefore_model(self, state, runtime=None): # noqa: ARG002
|
|
1229
|
+
return self.before_model(state, runtime)
|
|
1230
|
+
|
|
1231
|
+
return _SummaryToSystem
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _strip_harmony_tokens(text: str) -> str:
|
|
1235
|
+
"""응답 텍스트에서 harmony 특수 토큰/블록 + GPT-OSS 인용 마커를 제거."""
|
|
1236
|
+
if not text:
|
|
1237
|
+
return text
|
|
1238
|
+
# 전체 harmony 블록 제거 (tool call이 content에 섞인 경우)
|
|
1239
|
+
cleaned = _HARMONY_BLOCK_RE.sub("", text)
|
|
1240
|
+
# 잔여 개별 토큰 제거
|
|
1241
|
+
cleaned = _HARMONY_TOKEN_RE.sub("", cleaned)
|
|
1242
|
+
# GPT-OSS 인용 마커 제거 (예: [9†L1-L7])
|
|
1243
|
+
cleaned = _CITATION_MARKER_RE.sub("", cleaned)
|
|
1244
|
+
return cleaned.strip()
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
class TableConfirmationNeeded(Exception):
|
|
1248
|
+
"""rag_search 결과가 불확실할 때 raise. candidates를 프론트에 전달."""
|
|
1249
|
+
|
|
1250
|
+
def __init__(self, candidates: dict, confirmed_tables: Optional[list] = None):
|
|
1251
|
+
self.candidates = candidates
|
|
1252
|
+
self.confirmed_tables = confirmed_tables or []
|
|
1253
|
+
super().__init__("Table confirmation needed")
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _create_table_confirmation_middleware_class(
|
|
1257
|
+
threshold: float = 0.7,
|
|
1258
|
+
excluded_table_names: Optional[list] = None,
|
|
1259
|
+
mode: str = "eda",
|
|
1260
|
+
):
|
|
1261
|
+
"""rag_search 결과를 다각적으로 검토하여, 불확실하면 후보 테이블 목록으로 교체.
|
|
1262
|
+
|
|
1263
|
+
확정 조건 (후보 안 보여줌):
|
|
1264
|
+
- 쿼리별 top score > 0.8 이고 #1-#2 gap > 0.05
|
|
1265
|
+
불확실 조건 (후보 보여줌):
|
|
1266
|
+
- _warnings 존재 (match_text fallback = 테이블명 미존재)
|
|
1267
|
+
- 쿼리별 모든 결과 score ≤ threshold
|
|
1268
|
+
- top 2 score 밀집 (gap < 0.03, 명확한 1등 없음)
|
|
1269
|
+
|
|
1270
|
+
mode:
|
|
1271
|
+
- "eda": 불확실 시 TableConfirmationNeeded 예외 raise (EDA Agent용)
|
|
1272
|
+
- "chat": 불확실 시 ToolMessage에 안내 추가 + 결과 유지 (Knowledge Chat용)
|
|
1273
|
+
"""
|
|
1274
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
1275
|
+
|
|
1276
|
+
class _TableConfirmation(AgentMiddleware):
|
|
1277
|
+
async def awrap_tool_call(self, request, handler):
|
|
1278
|
+
result = await handler(request)
|
|
1279
|
+
|
|
1280
|
+
# ToolCallRequest.tool_call['name'] からtool名を取得
|
|
1281
|
+
tc = getattr(request, "tool_call", {})
|
|
1282
|
+
tool_name = tc.get("name", "") if isinstance(tc, dict) else ""
|
|
1283
|
+
if "rag_search" not in tool_name:
|
|
1284
|
+
return result
|
|
1285
|
+
|
|
1286
|
+
content = result.content if hasattr(result, "content") else str(result)
|
|
1287
|
+
try:
|
|
1288
|
+
parsed = json.loads(content) if isinstance(content, str) else content
|
|
1289
|
+
except Exception:
|
|
1290
|
+
return result
|
|
1291
|
+
|
|
1292
|
+
if not isinstance(parsed, dict):
|
|
1293
|
+
return result
|
|
1294
|
+
|
|
1295
|
+
table_results = parsed.get("테이블 검색 결과", [])
|
|
1296
|
+
if not table_results:
|
|
1297
|
+
return result
|
|
1298
|
+
|
|
1299
|
+
# _warnings 존재 = match_text fallback 발생
|
|
1300
|
+
has_warnings = any(bool(r.get("_warnings")) for r in table_results)
|
|
1301
|
+
|
|
1302
|
+
# 쿼리별 그룹핑
|
|
1303
|
+
query_groups: dict = {}
|
|
1304
|
+
for r in table_results:
|
|
1305
|
+
q = r.get("_query", "default")
|
|
1306
|
+
query_groups.setdefault(q, []).append(r)
|
|
1307
|
+
|
|
1308
|
+
# excluded 테이블 이름 집합 (소문자 정규화)
|
|
1309
|
+
_excluded = {str(n).lower() for n in (excluded_table_names or []) if n}
|
|
1310
|
+
|
|
1311
|
+
def _get_field(p: dict, *keys: str) -> str:
|
|
1312
|
+
for k in keys:
|
|
1313
|
+
v = p.get(k)
|
|
1314
|
+
if v:
|
|
1315
|
+
return str(v)
|
|
1316
|
+
return ""
|
|
1317
|
+
|
|
1318
|
+
# 쿼리별 확정/불확실 판단
|
|
1319
|
+
uncertain_queries: dict = {}
|
|
1320
|
+
confirmed_tables: list = [] # 자동 확정된 테이블 (top-1)
|
|
1321
|
+
_assigned_tables: set = set() # cross-group dedup: 이미 할당된 테이블
|
|
1322
|
+
for q, results in query_groups.items():
|
|
1323
|
+
# excluded 테이블 제거
|
|
1324
|
+
if _excluded:
|
|
1325
|
+
results = [
|
|
1326
|
+
r
|
|
1327
|
+
for r in results
|
|
1328
|
+
if str(
|
|
1329
|
+
r.get("payload", {}).get("table_name")
|
|
1330
|
+
or r.get("payload", {}).get("테이블명")
|
|
1331
|
+
or ""
|
|
1332
|
+
).lower()
|
|
1333
|
+
not in _excluded
|
|
1334
|
+
]
|
|
1335
|
+
if not results:
|
|
1336
|
+
continue
|
|
1337
|
+
sorted_r = sorted(
|
|
1338
|
+
results, key=lambda x: x.get("score", 0), reverse=True
|
|
1339
|
+
)
|
|
1340
|
+
scores = [r.get("score", 0) for r in sorted_r]
|
|
1341
|
+
top = scores[0] if scores else 0
|
|
1342
|
+
second = scores[1] if len(scores) > 1 else 0
|
|
1343
|
+
gap = top - second
|
|
1344
|
+
|
|
1345
|
+
print(
|
|
1346
|
+
f"[RAG] TableConfirmation: query='{q}' "
|
|
1347
|
+
f"top={top:.3f} second={second:.3f} gap={gap:.3f} "
|
|
1348
|
+
f"warnings={has_warnings}",
|
|
1349
|
+
flush=True,
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
# 정확 이름 일치 판정: 사용자가 특정 테이블명(식별자)으로 물었는데
|
|
1353
|
+
# top-1 결과 이름이 정확히 일치하지 않으면, 점수가 높아도 자동 확정하지
|
|
1354
|
+
# 않고 L2 폴백 경로로 보낸다. L1은 벡터 검색이라 이름이 없어도 높은
|
|
1355
|
+
# 점수의 유사 결과를 반환하므로, 점수만으로 확정하면 L2를 못 탄다.
|
|
1356
|
+
# 자연어 쿼리(공백/한글 문구 등 식별자 아님)는 이 게이트에서 제외.
|
|
1357
|
+
def _norm_name(s: str) -> str:
|
|
1358
|
+
core = (s or "").split(":", 1)[-1].rsplit(".", 1)[-1]
|
|
1359
|
+
return core.strip().lower().replace(" ", "")
|
|
1360
|
+
|
|
1361
|
+
_q_core = (q or "").split(":", 1)[-1].rsplit(".", 1)[-1].strip()
|
|
1362
|
+
_q_is_identifier = bool(_q_core) and re.fullmatch(
|
|
1363
|
+
r"[A-Za-z0-9_]+", _q_core
|
|
1364
|
+
)
|
|
1365
|
+
_top_p0 = sorted_r[0].get("payload", {})
|
|
1366
|
+
_q_norm = _norm_name(q)
|
|
1367
|
+
_exact_match = _q_norm in (
|
|
1368
|
+
_norm_name(
|
|
1369
|
+
_get_field(_top_p0, "table_name", "테이블명", "table_id")
|
|
1370
|
+
),
|
|
1371
|
+
_norm_name(
|
|
1372
|
+
_get_field(_top_p0, "korean_name", "테이블한글명", "한글명")
|
|
1373
|
+
),
|
|
1374
|
+
)
|
|
1375
|
+
_force_fallback = bool(_q_is_identifier) and not _exact_match
|
|
1376
|
+
|
|
1377
|
+
# 확정: top > 0.8 이고 #2와 gap > 0.05 (단, 식별자 조회 정확일치 실패 시 보류)
|
|
1378
|
+
if top > 0.8 and gap > 0.05 and not _force_fallback:
|
|
1379
|
+
# 확정 테이블 정보 저장 (불확실 쿼리가 있을 때 함께 전달)
|
|
1380
|
+
_top_r = sorted_r[0]
|
|
1381
|
+
_top_p = _top_r.get("payload", {})
|
|
1382
|
+
confirmed_tables.append(
|
|
1383
|
+
{
|
|
1384
|
+
"table_name": _get_field(
|
|
1385
|
+
_top_p, "table_name", "테이블명", "table_id"
|
|
1386
|
+
),
|
|
1387
|
+
"korean_name": _get_field(
|
|
1388
|
+
_top_p, "korean_name", "테이블한글명", "한글명"
|
|
1389
|
+
),
|
|
1390
|
+
"score": _top_r.get("score", 0),
|
|
1391
|
+
"columns": _top_p.get("columns", []),
|
|
1392
|
+
"col_headers": _top_p.get("col_headers", []),
|
|
1393
|
+
"payload": {
|
|
1394
|
+
k: v
|
|
1395
|
+
for k, v in _top_p.items()
|
|
1396
|
+
if k
|
|
1397
|
+
not in (
|
|
1398
|
+
"columns",
|
|
1399
|
+
"col_headers",
|
|
1400
|
+
"_embed_text",
|
|
1401
|
+
"_content_hash",
|
|
1402
|
+
"_collection",
|
|
1403
|
+
)
|
|
1404
|
+
},
|
|
1405
|
+
"_auto_confirmed": True,
|
|
1406
|
+
}
|
|
1407
|
+
)
|
|
1408
|
+
_assigned_tables.add(
|
|
1409
|
+
_get_field(_top_p, "table_name", "테이블명", "table_id").lower()
|
|
1410
|
+
)
|
|
1411
|
+
continue
|
|
1412
|
+
|
|
1413
|
+
is_uncertain = False
|
|
1414
|
+
reason = ""
|
|
1415
|
+
|
|
1416
|
+
if _force_fallback:
|
|
1417
|
+
is_uncertain = True
|
|
1418
|
+
reason = "특정 이름 조회인데 정확 일치 없음 (L2 폴백 필요)"
|
|
1419
|
+
elif has_warnings:
|
|
1420
|
+
is_uncertain = True
|
|
1421
|
+
reason = "match_text 테이블명 미존재 (fallback)"
|
|
1422
|
+
elif top <= threshold:
|
|
1423
|
+
is_uncertain = True
|
|
1424
|
+
reason = f"최고 score {top:.3f} ≤ {threshold}"
|
|
1425
|
+
elif len(scores) >= 2 and gap < 0.03:
|
|
1426
|
+
is_uncertain = True
|
|
1427
|
+
reason = f"top 2 밀집 ({top:.3f} vs {second:.3f})"
|
|
1428
|
+
|
|
1429
|
+
if is_uncertain:
|
|
1430
|
+
# 중복 제거: 테이블명 기준 + cross-group dedup
|
|
1431
|
+
seen_table_names: dict = {}
|
|
1432
|
+
for r in sorted_r:
|
|
1433
|
+
p = r.get("payload", {})
|
|
1434
|
+
tname = (
|
|
1435
|
+
p.get("table_name")
|
|
1436
|
+
or p.get("테이블명")
|
|
1437
|
+
or p.get("table_id")
|
|
1438
|
+
or r.get("id", "")
|
|
1439
|
+
)
|
|
1440
|
+
tname_lower = str(tname).lower()
|
|
1441
|
+
if (
|
|
1442
|
+
tname not in seen_table_names
|
|
1443
|
+
and tname_lower not in _assigned_tables
|
|
1444
|
+
):
|
|
1445
|
+
seen_table_names[tname] = r
|
|
1446
|
+
deduped = list(seen_table_names.values())[:5]
|
|
1447
|
+
# 할당 기록
|
|
1448
|
+
for r in deduped:
|
|
1449
|
+
p = r.get("payload", {})
|
|
1450
|
+
tname = _get_field(p, "table_name", "테이블명", "table_id")
|
|
1451
|
+
if tname:
|
|
1452
|
+
_assigned_tables.add(tname.lower())
|
|
1453
|
+
|
|
1454
|
+
if not deduped:
|
|
1455
|
+
continue # cross-group dedup으로 후보 0건
|
|
1456
|
+
uncertain_queries[q] = {
|
|
1457
|
+
"reason": reason,
|
|
1458
|
+
"candidates": [
|
|
1459
|
+
{
|
|
1460
|
+
"table_name": _get_field(
|
|
1461
|
+
r.get("payload", {}),
|
|
1462
|
+
"table_name",
|
|
1463
|
+
"테이블명",
|
|
1464
|
+
"table_id",
|
|
1465
|
+
),
|
|
1466
|
+
"korean_name": _get_field(
|
|
1467
|
+
r.get("payload", {}),
|
|
1468
|
+
"korean_name",
|
|
1469
|
+
"테이블한글명",
|
|
1470
|
+
"한글명",
|
|
1471
|
+
),
|
|
1472
|
+
"score": r.get("score", 0),
|
|
1473
|
+
"description": _get_field(
|
|
1474
|
+
r.get("payload", {}),
|
|
1475
|
+
"description",
|
|
1476
|
+
"설명",
|
|
1477
|
+
),
|
|
1478
|
+
"domain": _get_field(
|
|
1479
|
+
r.get("payload", {}),
|
|
1480
|
+
"domain",
|
|
1481
|
+
"도메인",
|
|
1482
|
+
),
|
|
1483
|
+
"columns": r.get("payload", {}).get("columns", []),
|
|
1484
|
+
"col_headers": r.get("payload", {}).get(
|
|
1485
|
+
"col_headers", []
|
|
1486
|
+
),
|
|
1487
|
+
"payload": {
|
|
1488
|
+
k: v
|
|
1489
|
+
for k, v in r.get("payload", {}).items()
|
|
1490
|
+
if k
|
|
1491
|
+
not in (
|
|
1492
|
+
"columns",
|
|
1493
|
+
"col_headers",
|
|
1494
|
+
"_embed_text",
|
|
1495
|
+
"_content_hash",
|
|
1496
|
+
"_collection",
|
|
1497
|
+
)
|
|
1498
|
+
},
|
|
1499
|
+
}
|
|
1500
|
+
for r in deduped
|
|
1501
|
+
],
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if not uncertain_queries:
|
|
1505
|
+
return result
|
|
1506
|
+
|
|
1507
|
+
print(
|
|
1508
|
+
f"[RAG] Table confirmation: "
|
|
1509
|
+
f"{len(uncertain_queries)} uncertain queries "
|
|
1510
|
+
f"({[v['reason'] for v in uncertain_queries.values()]})",
|
|
1511
|
+
flush=True,
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
candidates = {q: v["candidates"] for q, v in uncertain_queries.items()}
|
|
1515
|
+
if confirmed_tables:
|
|
1516
|
+
print(
|
|
1517
|
+
f"[RAG] Auto-confirmed tables: "
|
|
1518
|
+
f"{[t['table_name'] for t in confirmed_tables]}",
|
|
1519
|
+
flush=True,
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
if mode == "chat":
|
|
1523
|
+
# Knowledge Chat: 검색 결과 유지 + 후보 안내를 ToolMessage에 추가.
|
|
1524
|
+
# 후보 중 쿼리와 정확히 일치하는 테이블명이 하나도 없으면(=L1에
|
|
1525
|
+
# 그 이름 자체가 없다는 뜻) LLM이 유사 후보를 정답으로 오인해
|
|
1526
|
+
# 바로 되묻지 않도록, 되묻기 전에 search_l2_table 폴백을 먼저
|
|
1527
|
+
# 시키는 지시를 주입한다. 정확 일치 후보가 있으면(통계적으로만
|
|
1528
|
+
# uncertain) 기존처럼 되묻기 안내를 유지한다.
|
|
1529
|
+
# 정확 일치는 영문 테이블명 + 한글명 둘 다로 판정 (사용자가 한글명으로
|
|
1530
|
+
# 검색했을 수도 있으므로). 공백은 제거해 "카드매출 일별"↔"카드매출일별"도 매칭.
|
|
1531
|
+
def _norm(s: str) -> str:
|
|
1532
|
+
return (s or "").lower().replace(" ", "")
|
|
1533
|
+
|
|
1534
|
+
_any_exact_match = any(
|
|
1535
|
+
_norm(c.get("table_name", "")) == _norm(q)
|
|
1536
|
+
or _norm(c.get("korean_name", "")) == _norm(q)
|
|
1537
|
+
for q, cands in candidates.items()
|
|
1538
|
+
for c in cands
|
|
1539
|
+
)
|
|
1540
|
+
if _any_exact_match:
|
|
1541
|
+
candidate_lines = [
|
|
1542
|
+
"\n\n---\n[테이블 확인 필요]\n"
|
|
1543
|
+
"검색 결과에 유사한 테이블이 여러 개 발견되었습니다. "
|
|
1544
|
+
"사용자에게 어떤 테이블을 의미하는지 자연스럽게 물어보세요.\n"
|
|
1545
|
+
]
|
|
1546
|
+
else:
|
|
1547
|
+
_q_for_l2 = ", ".join(f'"{q}"' for q in candidates.keys())
|
|
1548
|
+
candidate_lines = [
|
|
1549
|
+
"\n\n---\n[L1에 정확 일치 없음]\n"
|
|
1550
|
+
"L1 결과에 이름이 정확히 일치하는 테이블이 없습니다. 사용자가 "
|
|
1551
|
+
"L1/원본에서만 찾으라고 명시하지 않았다면, 이어서 "
|
|
1552
|
+
f"search_l2_table을 {_q_for_l2}(으)로 호출하세요(답변 보류). "
|
|
1553
|
+
"L2에 정확 일치가 있으면 그걸로 답하고, 없으면 L1·L2 후보를 합쳐 "
|
|
1554
|
+
"각 (L1)/(L2) 표시해 함께 보여주세요.\n"
|
|
1555
|
+
]
|
|
1556
|
+
for q, cands in candidates.items():
|
|
1557
|
+
candidate_lines.append(f"\n검색어 '{q}' 후보:")
|
|
1558
|
+
for c in cands:
|
|
1559
|
+
tname = c.get("table_name", "")
|
|
1560
|
+
kname = c.get("korean_name", "")
|
|
1561
|
+
desc = c.get("description", "")[:60]
|
|
1562
|
+
candidate_lines.append(f"- {tname} ({kname}): {desc}")
|
|
1563
|
+
if confirmed_tables:
|
|
1564
|
+
candidate_lines.append(
|
|
1565
|
+
"\n자동 확인된 테이블: "
|
|
1566
|
+
+ ", ".join(
|
|
1567
|
+
f"{t['table_name']}({t.get('korean_name', '')})"
|
|
1568
|
+
for t in confirmed_tables
|
|
1569
|
+
)
|
|
1570
|
+
)
|
|
1571
|
+
from langchain_core.messages import ToolMessage
|
|
1572
|
+
|
|
1573
|
+
return ToolMessage(
|
|
1574
|
+
content=content + "\n".join(candidate_lines),
|
|
1575
|
+
tool_call_id=getattr(result, "tool_call_id", ""),
|
|
1576
|
+
name=getattr(result, "name", None),
|
|
1577
|
+
)
|
|
1578
|
+
else:
|
|
1579
|
+
# EDA Agent: 기존 방식 — 예외 raise
|
|
1580
|
+
raise TableConfirmationNeeded(candidates, confirmed_tables)
|
|
1581
|
+
|
|
1582
|
+
return _TableConfirmation
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
# 기존 chat 시스템 프롬프트 + RAG 가이드
|
|
1586
|
+
# ============================================================
|
|
1587
|
+
# 모드별 시스템 프롬프트 (완전 독립, 각자 자기완결).
|
|
1588
|
+
# simple 모드는 centri `prompts.chat_system_prompt`(이슈 #178), knowledge 모드는
|
|
1589
|
+
# `prompts.chat_qa_system_prompt`(이슈 #208)로 바꿀 수 있다.
|
|
1590
|
+
# EDA agent는 http_handlers.py에서 별도 system_prompt_override 사용.
|
|
1591
|
+
# ============================================================
|
|
1592
|
+
|
|
1593
|
+
# 본문은 코드 상수로 두지 않는다 (이슈 #208, #218). 도구 없는 Chat(chatMode="simple")은
|
|
1594
|
+
# `prompts.chat_system_prompt` — Chat 과 BDP Q&A 를 합친 뒤로는 사내 검색이 꺼진 환경
|
|
1595
|
+
# (py<3.11 또는 features.bdp_qa_enabled=false)에서만 쓴다(#183). RAG enable 모드
|
|
1596
|
+
# (chatMode="knowledge")는 `prompts.chat_qa_system_prompt` 다. 정본은 centri 이고, 없으면
|
|
1597
|
+
# 스냅샷 기본값(`schemas/config/codebuff_config_defaults.json`)이다.
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def build_chat_mode_system_prompt(chat_mode: str) -> str:
|
|
1601
|
+
"""Chat/Q&A가 엔진과 무관하게 공유하는 최종 시스템 프롬프트.
|
|
1602
|
+
|
|
1603
|
+
simple 모드 본문은 `prompts.chat_system_prompt`(이슈 #178), knowledge 모드 본문은
|
|
1604
|
+
`prompts.chat_qa_system_prompt`(이슈 #208)다. 끝에 응답 서식 규칙
|
|
1605
|
+
`prompts.answer_format_rules` 를 붙인다 (이슈 #218).
|
|
1606
|
+
"""
|
|
1607
|
+
from codebuff_core.managers.config_manager import prompt_value
|
|
1608
|
+
|
|
1609
|
+
rules = "\n\n" + prompt_value("answer_format_rules")
|
|
1610
|
+
if chat_mode != "knowledge":
|
|
1611
|
+
return with_platform_rules(prompt_value("chat_system_prompt")) + rules
|
|
1612
|
+
|
|
1613
|
+
prompt = prompt_value("chat_qa_system_prompt")
|
|
1614
|
+
# EDA on 시 테이블 컬렉션의 match_text 인덱스 정보 동적 주입
|
|
1615
|
+
eda_enabled = False
|
|
1616
|
+
try:
|
|
1617
|
+
from codebuff_core.managers.config_manager import get_config_manager
|
|
1618
|
+
|
|
1619
|
+
_cfg = get_config_manager().get_config()
|
|
1620
|
+
# 없으면 켜짐, 저장된 false 만 끈다 (이슈 #229)
|
|
1621
|
+
eda_enabled = _cfg.get("features", {}).get("eda_enabled", True) is not False
|
|
1622
|
+
except Exception:
|
|
1623
|
+
pass
|
|
1624
|
+
if eda_enabled:
|
|
1625
|
+
match_text_info = _get_match_text_fields_info()
|
|
1626
|
+
if match_text_info:
|
|
1627
|
+
prompt = prompt + "\n\n" + match_text_info
|
|
1628
|
+
|
|
1629
|
+
# 사내 BDP: boto3 대신 bdpengine (이슈 #120)
|
|
1630
|
+
return with_platform_rules(prompt) + rules
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def _has_table_results(content: str) -> bool:
|
|
1634
|
+
"""EDA 가 다음 단계에 넘길 테이블 문맥인가 — LangChain 경로의 판정과 같은 키를 본다."""
|
|
1635
|
+
try:
|
|
1636
|
+
parsed = json.loads(content)
|
|
1637
|
+
except (json.JSONDecodeError, TypeError):
|
|
1638
|
+
return False
|
|
1639
|
+
return isinstance(parsed, dict) and (
|
|
1640
|
+
"테이블 검색 결과" in parsed or "table_results" in parsed
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
class CodexQAToolPostprocessor:
|
|
1645
|
+
"""기존 Knowledge Chat 도구 미들웨어를 Codex dynamic tool에 적용한다.
|
|
1646
|
+
|
|
1647
|
+
도구 구현만 재사용하면 검색 결과의 후보 안내·근거 지시·반복 제한·스냅샷
|
|
1648
|
+
KST 규칙이 사라진다. LangChain ToolMessage 경계를 얇게 흉내 내 동일 미들웨어를
|
|
1649
|
+
그대로 통과시키고, Codex에는 최종 content만 돌려준다.
|
|
1650
|
+
|
|
1651
|
+
grounding 은 구 경로에서 `before_model` 에 사람 메시지로 끼워 넣었다. codex 에는
|
|
1652
|
+
그 자리가 없으므로 같은 미들웨어가 만든 문구를 도구 결과 끝에 붙인다 — 모델이
|
|
1653
|
+
그 결과를 읽는 바로 그 시점이라 위치가 같다.
|
|
1654
|
+
"""
|
|
1655
|
+
|
|
1656
|
+
def __init__(
|
|
1657
|
+
self,
|
|
1658
|
+
table_confirm_mode: str = "chat",
|
|
1659
|
+
table_score_threshold: float = 0.7,
|
|
1660
|
+
excluded_table_names: Optional[list] = None,
|
|
1661
|
+
grounding_constraint: Optional[str] = None,
|
|
1662
|
+
evidence_instruction: Optional[str] = None,
|
|
1663
|
+
) -> None:
|
|
1664
|
+
self.table_confirm_mode = table_confirm_mode
|
|
1665
|
+
self.grounding_constraint = grounding_constraint
|
|
1666
|
+
self._snapshot = _create_snapshot_answer_middleware_class()()
|
|
1667
|
+
self._evidence = (
|
|
1668
|
+
_create_evidence_reflection_middleware_class()()
|
|
1669
|
+
if evidence_instruction is None
|
|
1670
|
+
else _create_evidence_reflection_middleware_class(evidence_instruction)()
|
|
1671
|
+
)
|
|
1672
|
+
# "off": 되물을 사용자가 없는 한 턴(노트북 자동생성)이라 후보 안내도 턴 중단도 안 한다
|
|
1673
|
+
self._table_confirmation = (
|
|
1674
|
+
None
|
|
1675
|
+
if table_confirm_mode == "off"
|
|
1676
|
+
else _create_table_confirmation_middleware_class(
|
|
1677
|
+
threshold=table_score_threshold,
|
|
1678
|
+
excluded_table_names=excluded_table_names,
|
|
1679
|
+
mode=table_confirm_mode,
|
|
1680
|
+
)()
|
|
1681
|
+
)
|
|
1682
|
+
self._grounding = _create_rag_grounding_middleware_class(grounding_constraint)()
|
|
1683
|
+
self._turn_messages: List[Any] = []
|
|
1684
|
+
# EDA(`table_confirm_mode="eda"`)는 불확실한 검색에서 턴을 끊고 후보를 사용자에게
|
|
1685
|
+
# 묻는다. LangChain 은 예외로 끊었지만 codex 턴은 도구 회신을 기다리므로 예외를
|
|
1686
|
+
# 여기서 받아 두고, 핸들러가 턴을 취소한 뒤 SSE 로 알린다 (이슈 #155)
|
|
1687
|
+
self.pending_confirmation: Optional[TableConfirmationNeeded] = None
|
|
1688
|
+
# 마지막 테이블 검색 원문 — LangChain 은 agent state 에서 꺼냈다 (이슈 #155).
|
|
1689
|
+
# 우선순위도 그쪽과 같다: 테이블 결과 키가 있는 도구 결과, 없으면 마지막 도구 결과
|
|
1690
|
+
self.last_table_result = ""
|
|
1691
|
+
self.last_tool_result = ""
|
|
1692
|
+
|
|
1693
|
+
def reset(self, question: str = "") -> None:
|
|
1694
|
+
"""새 사용자 턴: 검색·L2 호출 횟수와 grounding 이 볼 턴 기록을 다시 쌓는다."""
|
|
1695
|
+
from langchain_core.messages import HumanMessage
|
|
1696
|
+
|
|
1697
|
+
self._evidence.reset()
|
|
1698
|
+
self._turn_messages = [HumanMessage(content=question)] if question else []
|
|
1699
|
+
self.pending_confirmation = None
|
|
1700
|
+
self.last_table_result = ""
|
|
1701
|
+
self.last_tool_result = ""
|
|
1702
|
+
|
|
1703
|
+
def _grounding_text(
|
|
1704
|
+
self, tool_name: str, args: Dict[str, Any], call_id: str, content: str
|
|
1705
|
+
) -> str:
|
|
1706
|
+
from langchain_core.messages import AIMessage, ToolMessage
|
|
1707
|
+
|
|
1708
|
+
self._turn_messages.append(
|
|
1709
|
+
AIMessage(
|
|
1710
|
+
content="",
|
|
1711
|
+
tool_calls=[{"name": tool_name, "args": args, "id": call_id}],
|
|
1712
|
+
)
|
|
1713
|
+
)
|
|
1714
|
+
self._turn_messages.append(
|
|
1715
|
+
ToolMessage(content=content, tool_call_id=call_id, name=tool_name)
|
|
1716
|
+
)
|
|
1717
|
+
update = self._grounding.before_model({"messages": self._turn_messages})
|
|
1718
|
+
if not update:
|
|
1719
|
+
return ""
|
|
1720
|
+
return str(update["messages"][-1].content)
|
|
1721
|
+
|
|
1722
|
+
async def invoke(
|
|
1723
|
+
self,
|
|
1724
|
+
tool_name: str,
|
|
1725
|
+
args: Dict[str, Any],
|
|
1726
|
+
call_id: str,
|
|
1727
|
+
handler: Callable[[Dict[str, Any]], Awaitable[Any]],
|
|
1728
|
+
) -> Tuple[str, Any]:
|
|
1729
|
+
"""도구를 한 번 실행하고 (후처리 content, 원본 결과)를 반환한다."""
|
|
1730
|
+
from langchain_core.messages import ToolMessage
|
|
1731
|
+
|
|
1732
|
+
# 직전 검색의 pending evidence를 다음 모델 라운드 상태로 확정한다.
|
|
1733
|
+
self._evidence.before_model({})
|
|
1734
|
+
request = SimpleNamespace(
|
|
1735
|
+
tool_call={"name": tool_name, "args": args, "id": call_id}
|
|
1736
|
+
)
|
|
1737
|
+
raw_holder: List[Any] = [None]
|
|
1738
|
+
|
|
1739
|
+
async def run_tool(_request):
|
|
1740
|
+
raw = await handler(args)
|
|
1741
|
+
raw_holder[0] = raw
|
|
1742
|
+
content = (
|
|
1743
|
+
raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
|
|
1744
|
+
)
|
|
1745
|
+
self.last_tool_result = content
|
|
1746
|
+
if _has_table_results(content):
|
|
1747
|
+
self.last_table_result = content
|
|
1748
|
+
return ToolMessage(
|
|
1749
|
+
content=content,
|
|
1750
|
+
tool_call_id=call_id or "codex-qa-tool",
|
|
1751
|
+
name=tool_name,
|
|
1752
|
+
)
|
|
1753
|
+
|
|
1754
|
+
async def confirm_tables(_request):
|
|
1755
|
+
if self._table_confirmation is None:
|
|
1756
|
+
return await run_tool(_request)
|
|
1757
|
+
return await self._table_confirmation.awrap_tool_call(_request, run_tool)
|
|
1758
|
+
|
|
1759
|
+
async def reflect_evidence(_request):
|
|
1760
|
+
return await self._evidence.awrap_tool_call(_request, confirm_tables)
|
|
1761
|
+
|
|
1762
|
+
try:
|
|
1763
|
+
result = await self._snapshot.awrap_tool_call(request, reflect_evidence)
|
|
1764
|
+
except TableConfirmationNeeded as needed:
|
|
1765
|
+
self.pending_confirmation = needed
|
|
1766
|
+
return (
|
|
1767
|
+
"검색된 테이블이 불확실해 사용자 확인을 기다립니다. "
|
|
1768
|
+
"더 이상 도구를 부르거나 SQL 을 만들지 말고 이 턴을 끝내세요.",
|
|
1769
|
+
raw_holder[0],
|
|
1770
|
+
)
|
|
1771
|
+
content = str(result.content if hasattr(result, "content") else result)
|
|
1772
|
+
grounding = self._grounding_text(
|
|
1773
|
+
tool_name, args, call_id or "codex-qa-tool", content
|
|
1774
|
+
)
|
|
1775
|
+
if grounding:
|
|
1776
|
+
content = f"{content}\n\n---\n{grounding}"
|
|
1777
|
+
return content, raw_holder[0]
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def _get_match_text_fields_info() -> str:
|
|
1781
|
+
"""Qdrant에서 enterprise_tables/bdp_tables의 text index 필드를 ���회."""
|
|
1782
|
+
try:
|
|
1783
|
+
from codebuff_core.services.rag_collections import (
|
|
1784
|
+
COLLECTION_BDP,
|
|
1785
|
+
COLLECTION_ENTERPRISE,
|
|
1786
|
+
get_manager,
|
|
1787
|
+
)
|
|
1788
|
+
|
|
1789
|
+
mgr = get_manager()
|
|
1790
|
+
if mgr is None:
|
|
1791
|
+
return ""
|
|
1792
|
+
|
|
1793
|
+
client = mgr._client
|
|
1794
|
+
existing = {c.name for c in client.get_collections().collections}
|
|
1795
|
+
lines: list = []
|
|
1796
|
+
|
|
1797
|
+
for col_name in [COLLECTION_ENTERPRISE, COLLECTION_BDP]:
|
|
1798
|
+
if col_name not in existing:
|
|
1799
|
+
continue
|
|
1800
|
+
try:
|
|
1801
|
+
col_info = client.get_collection(col_name)
|
|
1802
|
+
text_fields = [
|
|
1803
|
+
fname
|
|
1804
|
+
for fname, schema in (col_info.payload_schema or {}).items()
|
|
1805
|
+
if "text" in str(getattr(schema, "params", "")).lower()
|
|
1806
|
+
or getattr(schema, "data_type", "") == "text"
|
|
1807
|
+
]
|
|
1808
|
+
if text_fields:
|
|
1809
|
+
lines.append(
|
|
1810
|
+
f"- **{col_name}**: {', '.join(f'`{f}`' for f in sorted(text_fields))}"
|
|
1811
|
+
)
|
|
1812
|
+
except Exception:
|
|
1813
|
+
continue
|
|
1814
|
+
|
|
1815
|
+
if not lines:
|
|
1816
|
+
return ""
|
|
1817
|
+
|
|
1818
|
+
return "### match_text 검색 가능 컬렉션 및 인덱스 필드\n" + "\n".join(lines)
|
|
1819
|
+
except Exception:
|
|
1820
|
+
return ""
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
_COLLECTION_LABEL = {
|
|
1824
|
+
"guide_docs": "가이드",
|
|
1825
|
+
"ds_guide_docs": "DS가이드",
|
|
1826
|
+
"bdp_tables": "L1테이블",
|
|
1827
|
+
"enterprise_tables": "전사테이블",
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def _format_tool_status(queries_str) -> str:
|
|
1832
|
+
"""tool_start 이벤트에서 사용자 친화적 상태 메시지 생성."""
|
|
1833
|
+
try:
|
|
1834
|
+
if isinstance(queries_str, list):
|
|
1835
|
+
q_list = queries_str
|
|
1836
|
+
elif isinstance(queries_str, str) and queries_str:
|
|
1837
|
+
q_list = json.loads(queries_str)
|
|
1838
|
+
else:
|
|
1839
|
+
q_list = []
|
|
1840
|
+
except Exception:
|
|
1841
|
+
return "📚 검색 중..."
|
|
1842
|
+
|
|
1843
|
+
# 컬렉션별 라벨 추출
|
|
1844
|
+
collections: set = set()
|
|
1845
|
+
for q in q_list:
|
|
1846
|
+
if isinstance(q, dict):
|
|
1847
|
+
col = q.get("collection", "")
|
|
1848
|
+
if col:
|
|
1849
|
+
collections.add(col)
|
|
1850
|
+
|
|
1851
|
+
col_label = ", ".join(_COLLECTION_LABEL.get(c, c) for c in sorted(collections))
|
|
1852
|
+
if not col_label:
|
|
1853
|
+
col_label = "Knowledge base"
|
|
1854
|
+
|
|
1855
|
+
# 쿼리 텍스트 추출 (중복 제거, 4단어까지, 쉼표 구분)
|
|
1856
|
+
seen: set = set()
|
|
1857
|
+
queries: list = []
|
|
1858
|
+
for q in q_list:
|
|
1859
|
+
if not isinstance(q, dict):
|
|
1860
|
+
continue
|
|
1861
|
+
txt = q.get("query", "").strip()
|
|
1862
|
+
if txt and txt.lower() not in seen:
|
|
1863
|
+
seen.add(txt.lower())
|
|
1864
|
+
words = txt.split()[:4]
|
|
1865
|
+
queries.append(" ".join(words) + ("..." if len(txt.split()) > 4 else ""))
|
|
1866
|
+
if queries:
|
|
1867
|
+
q_text = ", ".join(queries[:3])
|
|
1868
|
+
return f"📚 {col_label} 검색 중: {q_text}"
|
|
1869
|
+
|
|
1870
|
+
return f"📚 {col_label} 검색 중..."
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def qa_tool_start_status(tool_name: str, tool_input: Any) -> Optional[Dict[str, str]]:
|
|
1874
|
+
"""도구 호출이 시작될 때 패널에 띄우는 진행 줄. 없으면 None.
|
|
1875
|
+
|
|
1876
|
+
LangChain(`stream_response`)과 codex(`codex_bridge`) 두 엔진이 같은 문구를 쓴다 (이슈 #155).
|
|
1877
|
+
"""
|
|
1878
|
+
if "skill" in tool_name.lower():
|
|
1879
|
+
skill_names = ""
|
|
1880
|
+
if isinstance(tool_input, dict):
|
|
1881
|
+
skill_names = tool_input.get("names", "")
|
|
1882
|
+
elif isinstance(tool_input, str):
|
|
1883
|
+
skill_names = tool_input
|
|
1884
|
+
if not skill_names:
|
|
1885
|
+
return None
|
|
1886
|
+
return {"status": f"🔧 스킬 로딩: {skill_names}", "icon": "search"}
|
|
1887
|
+
if "get_table_columns" in tool_name:
|
|
1888
|
+
tbl = tool_input.get("table_name", "") if isinstance(tool_input, dict) else ""
|
|
1889
|
+
return {"status": f"📋 컬럼 목록 조회 중... {tbl}", "icon": "search"}
|
|
1890
|
+
if "get_iceberg_snapshots" in tool_name:
|
|
1891
|
+
tbl = ""
|
|
1892
|
+
layer = ""
|
|
1893
|
+
if isinstance(tool_input, dict):
|
|
1894
|
+
tbl = tool_input.get("table_name", "") or ""
|
|
1895
|
+
layer = tool_input.get("layer", "") or ""
|
|
1896
|
+
status = "🗄️ 적재 이력 조회 중"
|
|
1897
|
+
if tbl:
|
|
1898
|
+
suffix = f" ({layer.upper()})" if layer else ""
|
|
1899
|
+
status = f"🗄️ 적재 이력 조회 중: {tbl}{suffix}"
|
|
1900
|
+
return {"status": status, "icon": "database"}
|
|
1901
|
+
if "search_l2_table" in tool_name:
|
|
1902
|
+
names_raw = (
|
|
1903
|
+
tool_input.get("table_names", "")
|
|
1904
|
+
if isinstance(tool_input, dict)
|
|
1905
|
+
else tool_input
|
|
1906
|
+
)
|
|
1907
|
+
try:
|
|
1908
|
+
names_list = (
|
|
1909
|
+
json.loads(names_raw) if isinstance(names_raw, str) else names_raw
|
|
1910
|
+
)
|
|
1911
|
+
except Exception:
|
|
1912
|
+
names_list = []
|
|
1913
|
+
names_display = (
|
|
1914
|
+
", ".join(str(n) for n in names_list[:3])
|
|
1915
|
+
if isinstance(names_list, list) and names_list
|
|
1916
|
+
else ""
|
|
1917
|
+
)
|
|
1918
|
+
return {
|
|
1919
|
+
"status": (
|
|
1920
|
+
f"📚 L2 테이블 검색 중: {names_display}"
|
|
1921
|
+
if names_display
|
|
1922
|
+
else "📚 L2 테이블 검색 중..."
|
|
1923
|
+
),
|
|
1924
|
+
"icon": "search",
|
|
1925
|
+
}
|
|
1926
|
+
queries_str = ""
|
|
1927
|
+
if isinstance(tool_input, dict):
|
|
1928
|
+
queries_str = tool_input.get("queries", "")
|
|
1929
|
+
elif isinstance(tool_input, str):
|
|
1930
|
+
queries_str = tool_input
|
|
1931
|
+
return {"status": _format_tool_status(queries_str), "icon": "search"}
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _parse_tool_output(output: Any) -> Any:
|
|
1935
|
+
return json.loads(output) if isinstance(output, str) else output
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def qa_tool_end_status(
|
|
1939
|
+
tool_name: str, output: Any, tool_input: Any = None
|
|
1940
|
+
) -> Optional[Dict[str, Any]]:
|
|
1941
|
+
"""도구 호출이 끝났을 때 패널에 띄우는 결과 줄. 없으면 None (스킬 로드).
|
|
1942
|
+
|
|
1943
|
+
`qa_tool_start_status` 와 짝이다 — 두 엔진이 같은 문구를 쓴다 (이슈 #155).
|
|
1944
|
+
"""
|
|
1945
|
+
if "skill" in tool_name.lower():
|
|
1946
|
+
return None
|
|
1947
|
+
if "get_table_columns" in tool_name:
|
|
1948
|
+
total_cols = 0
|
|
1949
|
+
tbl_name = ""
|
|
1950
|
+
try:
|
|
1951
|
+
parsed = _parse_tool_output(output)
|
|
1952
|
+
if isinstance(parsed, dict):
|
|
1953
|
+
total_cols = parsed.get("total_columns", 0)
|
|
1954
|
+
tbl_name = parsed.get("table_name", "")
|
|
1955
|
+
except Exception:
|
|
1956
|
+
pass
|
|
1957
|
+
return {
|
|
1958
|
+
"status": f"📋 컬럼 조회 완료. {tbl_name} {total_cols}개",
|
|
1959
|
+
"icon": "search",
|
|
1960
|
+
}
|
|
1961
|
+
if "get_iceberg_snapshots" in tool_name:
|
|
1962
|
+
snap_count = 0
|
|
1963
|
+
tbl_name = ""
|
|
1964
|
+
layer = ""
|
|
1965
|
+
not_found = False
|
|
1966
|
+
err = ""
|
|
1967
|
+
try:
|
|
1968
|
+
parsed = _parse_tool_output(output)
|
|
1969
|
+
if isinstance(parsed, dict):
|
|
1970
|
+
tbl_name = parsed.get("table_name", "") or ""
|
|
1971
|
+
layer = parsed.get("layer", "") or ""
|
|
1972
|
+
if parsed.get("success") is False:
|
|
1973
|
+
not_found = bool(parsed.get("table_not_found"))
|
|
1974
|
+
err = str(parsed.get("error", "오류"))[:50]
|
|
1975
|
+
else:
|
|
1976
|
+
snap_count = int(parsed.get("snapshot_count", 0) or 0)
|
|
1977
|
+
except Exception:
|
|
1978
|
+
pass
|
|
1979
|
+
suffix = f" ({layer.upper()})" if layer else ""
|
|
1980
|
+
if not_found:
|
|
1981
|
+
msg = f"🗄️ {tbl_name} 테이블 없음{suffix}"
|
|
1982
|
+
elif err:
|
|
1983
|
+
msg = f"🗄️ {tbl_name} 적재 이력 조회 실패: {err}"
|
|
1984
|
+
elif tbl_name:
|
|
1985
|
+
msg = f"🗄️ {tbl_name} 스냅샷 {snap_count}건 조회 완료"
|
|
1986
|
+
else:
|
|
1987
|
+
msg = f"🗄️ 스냅샷 {snap_count}건 조회 완료"
|
|
1988
|
+
return {"status": msg, "icon": "database", "fade_previous": True}
|
|
1989
|
+
if "search_l2_table" in tool_name:
|
|
1990
|
+
matched = 0
|
|
1991
|
+
not_found_count = 0
|
|
1992
|
+
# ambiguous는 검색어 개수가 아니라 실제 후보 수(표시분+숨김분)로 집계 —
|
|
1993
|
+
# "후보 1건"이 검색어 1개가 ambiguous였다는 뜻으로 오인되지 않도록.
|
|
1994
|
+
cand_total = 0
|
|
1995
|
+
err = ""
|
|
1996
|
+
try:
|
|
1997
|
+
parsed = _parse_tool_output(output)
|
|
1998
|
+
if isinstance(parsed, dict):
|
|
1999
|
+
if parsed.get("success") is False:
|
|
2000
|
+
err = str(parsed.get("error", "오류"))[:50]
|
|
2001
|
+
else:
|
|
2002
|
+
for r in parsed.get("results", []):
|
|
2003
|
+
if not isinstance(r, dict):
|
|
2004
|
+
continue
|
|
2005
|
+
if r.get("not_found"):
|
|
2006
|
+
not_found_count += 1
|
|
2007
|
+
elif r.get("ambiguous"):
|
|
2008
|
+
cand_total += len(r.get("candidates", [])) + int(
|
|
2009
|
+
r.get("more_candidates_count", 0)
|
|
2010
|
+
)
|
|
2011
|
+
else:
|
|
2012
|
+
matched += 1
|
|
2013
|
+
except Exception:
|
|
2014
|
+
pass
|
|
2015
|
+
if err:
|
|
2016
|
+
msg = f"📚 L2 테이블 검색 실패: {err}"
|
|
2017
|
+
else:
|
|
2018
|
+
parts = []
|
|
2019
|
+
if matched:
|
|
2020
|
+
parts.append(f"{matched}건 매칭")
|
|
2021
|
+
if cand_total:
|
|
2022
|
+
parts.append(f"후보 {cand_total}건")
|
|
2023
|
+
if not_found_count:
|
|
2024
|
+
parts.append(f"{not_found_count}건 미발견")
|
|
2025
|
+
msg = f"📚 L2 검색 완료: {', '.join(parts)}" if parts else "📚 L2 검색 완료"
|
|
2026
|
+
return {"status": msg, "icon": "search", "fade_previous": True}
|
|
2027
|
+
# rag_search 결과
|
|
2028
|
+
total = 0
|
|
2029
|
+
parsed: Any = None
|
|
2030
|
+
try:
|
|
2031
|
+
parsed = _parse_tool_output(output)
|
|
2032
|
+
if isinstance(parsed, dict):
|
|
2033
|
+
total = parsed.get("total", 0)
|
|
2034
|
+
except Exception:
|
|
2035
|
+
pass
|
|
2036
|
+
# 검색 결과 내용 요약 (50글자 truncate) + 컬렉션 라벨
|
|
2037
|
+
result_preview = ""
|
|
2038
|
+
col_label = ""
|
|
2039
|
+
result_key_label = {"가이드 검색 결과": "가이드", "테이블 검색 결과": "L1테이블"}
|
|
2040
|
+
try:
|
|
2041
|
+
if isinstance(parsed, dict):
|
|
2042
|
+
found_labels: list = []
|
|
2043
|
+
for rk in ("가이드 검색 결과", "테이블 검색 결과"):
|
|
2044
|
+
items = parsed.get(rk, [])
|
|
2045
|
+
if items:
|
|
2046
|
+
found_labels.append(result_key_label.get(rk, rk))
|
|
2047
|
+
if rk == "테이블 검색 결과" and items:
|
|
2048
|
+
# 테이블: 각 후보의 한글명을 쉼표로
|
|
2049
|
+
tbl_names = []
|
|
2050
|
+
for ri in items:
|
|
2051
|
+
rp = ri.get("payload", {})
|
|
2052
|
+
kn = (
|
|
2053
|
+
rp.get("korean_name", "")
|
|
2054
|
+
or rp.get("테이블한글명", "")
|
|
2055
|
+
or rp.get("table_name", "")
|
|
2056
|
+
)
|
|
2057
|
+
if kn and kn not in tbl_names:
|
|
2058
|
+
tbl_names.append(kn)
|
|
2059
|
+
if tbl_names:
|
|
2060
|
+
result_preview = ", ".join(tbl_names)
|
|
2061
|
+
elif not result_preview:
|
|
2062
|
+
# 가이드/코드: 첫 번째 content 50글자
|
|
2063
|
+
for ri in items:
|
|
2064
|
+
rc = ri.get("payload", {}).get("content", "")
|
|
2065
|
+
if rc:
|
|
2066
|
+
result_preview = rc[:50].replace("\n", " ")
|
|
2067
|
+
if len(rc) > 50:
|
|
2068
|
+
result_preview += "..."
|
|
2069
|
+
break
|
|
2070
|
+
if found_labels:
|
|
2071
|
+
col_label = ", ".join(found_labels)
|
|
2072
|
+
except Exception:
|
|
2073
|
+
pass
|
|
2074
|
+
if not col_label:
|
|
2075
|
+
# 결과 0건 시 input의 컬렉션에서 라벨 추출
|
|
2076
|
+
try:
|
|
2077
|
+
q_str = (
|
|
2078
|
+
tool_input.get("queries", "") if isinstance(tool_input, dict) else ""
|
|
2079
|
+
)
|
|
2080
|
+
q_list = (
|
|
2081
|
+
json.loads(q_str)
|
|
2082
|
+
if isinstance(q_str, str) and q_str
|
|
2083
|
+
else q_str
|
|
2084
|
+
if isinstance(q_str, list)
|
|
2085
|
+
else []
|
|
2086
|
+
)
|
|
2087
|
+
input_cols: set = set()
|
|
2088
|
+
for q in q_list:
|
|
2089
|
+
if isinstance(q, dict) and q.get("collection"):
|
|
2090
|
+
input_cols.add(q["collection"])
|
|
2091
|
+
if input_cols:
|
|
2092
|
+
col_label = ", ".join(
|
|
2093
|
+
_COLLECTION_LABEL.get(c, c) for c in sorted(input_cols)
|
|
2094
|
+
)
|
|
2095
|
+
except Exception:
|
|
2096
|
+
pass
|
|
2097
|
+
if not col_label:
|
|
2098
|
+
col_label = "Knowledge base"
|
|
2099
|
+
msg = f"📚 {col_label} 검색 {total}건 완료"
|
|
2100
|
+
if result_preview:
|
|
2101
|
+
msg += f": {result_preview}"
|
|
2102
|
+
return {"status": msg, "icon": "search", "fade_previous": True}
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
class RAGChatAgent:
|
|
2106
|
+
"""LangChain create_agent 기반 RAG chat agent.
|
|
2107
|
+
|
|
2108
|
+
chat mode에서 RAG가 가용할 때 LLMService 대신 사용.
|
|
2109
|
+
rag_search 도구 1개로 ReAct loop (최대 2회 tool call).
|
|
2110
|
+
"""
|
|
2111
|
+
|
|
2112
|
+
def __init__(
|
|
2113
|
+
self,
|
|
2114
|
+
llm_config: Dict[str, Any],
|
|
2115
|
+
checkpointer=None,
|
|
2116
|
+
system_prompt_override: Optional[str] = None,
|
|
2117
|
+
table_score_threshold: Optional[float] = None,
|
|
2118
|
+
excluded_table_names: Optional[list] = None,
|
|
2119
|
+
no_tools: bool = False,
|
|
2120
|
+
table_confirm_mode: str = "eda",
|
|
2121
|
+
grounding_constraint: Optional[str] = None,
|
|
2122
|
+
workspace_root: Optional[str] = None,
|
|
2123
|
+
):
|
|
2124
|
+
self._llm_config = llm_config
|
|
2125
|
+
# load_skills 가 같은 이름의 포털 스킬을 보려면 워크스페이스가 필요하다 (이슈 #220)
|
|
2126
|
+
self._workspace_root = workspace_root
|
|
2127
|
+
self._checkpointer = checkpointer
|
|
2128
|
+
self._system_prompt_override = system_prompt_override
|
|
2129
|
+
self._table_score_threshold = table_score_threshold
|
|
2130
|
+
self._excluded_table_names = excluded_table_names or []
|
|
2131
|
+
self._no_tools = no_tools
|
|
2132
|
+
self._table_confirm_mode = table_confirm_mode # "eda" or "chat"
|
|
2133
|
+
self._grounding_constraint = grounding_constraint
|
|
2134
|
+
self._agent = None
|
|
2135
|
+
# 호출별 동적 시스템 프롬프트 홀더 (phase_system_prompt 지원)
|
|
2136
|
+
self._dynamic_prompt_holder: list = [None]
|
|
2137
|
+
# 미들웨어 인스턴스 참조 (턴별 리셋용)
|
|
2138
|
+
self._evidence_reflection = None
|
|
2139
|
+
self._rag_grounding = None
|
|
2140
|
+
self._table_confirmation = None
|
|
2141
|
+
|
|
2142
|
+
async def _cleanup_state_after_fallback(
|
|
2143
|
+
self,
|
|
2144
|
+
config: Dict[str, Any],
|
|
2145
|
+
fallback_response: str,
|
|
2146
|
+
compressed_messages: Optional[List[Any]] = None,
|
|
2147
|
+
):
|
|
2148
|
+
"""fallback 성공 후 그래프 state를 compressed context + fallback 응답으로 교체.
|
|
2149
|
+
|
|
2150
|
+
Args:
|
|
2151
|
+
fallback_response: fallback LLM이 생성한 응답 텍스트
|
|
2152
|
+
compressed_messages: fallback에서 사용한 compressed state messages
|
|
2153
|
+
"""
|
|
2154
|
+
try:
|
|
2155
|
+
from langchain_core.messages import AIMessage
|
|
2156
|
+
|
|
2157
|
+
if compressed_messages:
|
|
2158
|
+
messages = list(compressed_messages)
|
|
2159
|
+
else:
|
|
2160
|
+
state = await self._agent.aget_state(config)
|
|
2161
|
+
messages = list(state.values.get("messages", []))
|
|
2162
|
+
|
|
2163
|
+
# 빈 AIMessage(실패한 응답)를 fallback 응답으로 교체
|
|
2164
|
+
replaced = False
|
|
2165
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
2166
|
+
if isinstance(messages[i], AIMessage):
|
|
2167
|
+
if not messages[i].content and not messages[i].tool_calls:
|
|
2168
|
+
messages[i] = AIMessage(content=fallback_response)
|
|
2169
|
+
replaced = True
|
|
2170
|
+
break
|
|
2171
|
+
|
|
2172
|
+
if not replaced:
|
|
2173
|
+
messages.append(AIMessage(content=fallback_response))
|
|
2174
|
+
|
|
2175
|
+
await self._agent.aupdate_state(config, {"messages": messages})
|
|
2176
|
+
print(
|
|
2177
|
+
f"[RAG] State updated after fallback: {len(messages)} messages, "
|
|
2178
|
+
f"tokens≈{_estimate_tokens(messages)}",
|
|
2179
|
+
flush=True,
|
|
2180
|
+
)
|
|
2181
|
+
except Exception as e:
|
|
2182
|
+
print(f"[RAG] State update failed: {e}", flush=True)
|
|
2183
|
+
|
|
2184
|
+
def _create_model(self, temperature: float = 0.0):
|
|
2185
|
+
"""vLLM 설정으로 LangChain ChatModel 생성."""
|
|
2186
|
+
import os
|
|
2187
|
+
|
|
2188
|
+
from langchain.chat_models import init_chat_model
|
|
2189
|
+
|
|
2190
|
+
cfg = self._llm_config.get("vllm", {})
|
|
2191
|
+
# 주소·모델은 실효 config 우선 → env 폴백 (이슈 #200). 호출부가 넘기는 값은
|
|
2192
|
+
# `ConfigManager.get_config()` 라 이미 centri > env > pod 파일로 얹혀 있다 —
|
|
2193
|
+
# env 를 앞에 세우면 centri 조직 값(팀·프로젝트 오버라이드)이 pod env 에 밀린다.
|
|
2194
|
+
# 시크릿 env 가 파일을 이기는 것은 실효 config 가 이미 지킨다.
|
|
2195
|
+
# API 키는 centri 로 오지 않으므로 env > config(파일) 그대로다.
|
|
2196
|
+
endpoint = (
|
|
2197
|
+
cfg.get("endpoint", "") or os.environ.get("VLLM_ENDPOINT", "")
|
|
2198
|
+
).rstrip("/")
|
|
2199
|
+
if not endpoint.endswith("/v1"):
|
|
2200
|
+
endpoint += "/v1"
|
|
2201
|
+
model = cfg.get("model", "") or os.environ.get("VLLM_MODEL", "") or "default"
|
|
2202
|
+
api_key = os.environ.get("VLLM_API_KEY", "") or cfg.get("apiKey", "")
|
|
2203
|
+
|
|
2204
|
+
extra_body: Dict[str, Any] = {"include_reasoning": False}
|
|
2205
|
+
if "gpt-oss" in model.lower():
|
|
2206
|
+
# gpt-oss 토크나이저 고유 stop token — 다른 모델엔 적용하지 않음.
|
|
2207
|
+
extra_body["stop_token_ids"] = [200002, 199999, 200012]
|
|
2208
|
+
|
|
2209
|
+
return init_chat_model(
|
|
2210
|
+
f"openai:{model}",
|
|
2211
|
+
base_url=endpoint,
|
|
2212
|
+
api_key=api_key,
|
|
2213
|
+
temperature=temperature,
|
|
2214
|
+
streaming=True,
|
|
2215
|
+
# tool 호출 시 병렬 tool_call 비활성화. tools 없는 호출(예: 요약)에도
|
|
2216
|
+
# 같은 model 객체가 재사용되지만 parallel_tool_calls는 tools 미지정
|
|
2217
|
+
# 요청에서도 에러 없이 무시됨(tool_choice와 달리 안전).
|
|
2218
|
+
model_kwargs={"parallel_tool_calls": False},
|
|
2219
|
+
extra_body=extra_body,
|
|
2220
|
+
)
|
|
2221
|
+
|
|
2222
|
+
def _create_tools(self):
|
|
2223
|
+
"""rag_search + get_table_columns + get_iceberg_snapshots + search_l2_table + load_skills 도구 생성."""
|
|
2224
|
+
from jupyter_ext.agent.langchain_tools import (
|
|
2225
|
+
create_enhanced_rag_search_tool,
|
|
2226
|
+
create_get_table_columns_tool,
|
|
2227
|
+
create_iceberg_snapshots_tool,
|
|
2228
|
+
create_load_skills_tool,
|
|
2229
|
+
create_search_l2_table_tool,
|
|
2230
|
+
)
|
|
2231
|
+
|
|
2232
|
+
return [
|
|
2233
|
+
create_enhanced_rag_search_tool(),
|
|
2234
|
+
create_get_table_columns_tool(),
|
|
2235
|
+
create_iceberg_snapshots_tool(),
|
|
2236
|
+
create_search_l2_table_tool(),
|
|
2237
|
+
create_load_skills_tool(self._workspace_root),
|
|
2238
|
+
]
|
|
2239
|
+
|
|
2240
|
+
def _build_system_prompt(self) -> str:
|
|
2241
|
+
"""RAG enable 모드 시스템 프롬프트 = Q&A 본문(`chat_qa_system_prompt`) + 동적 주입.
|
|
2242
|
+
|
|
2243
|
+
- Chat/Q&A 의 실제 답변은 codex 가 한다(이슈 #155). codex 는 같은 문구를
|
|
2244
|
+
build_chat_mode_system_prompt 로 받으므로 두 경로의 지시문은 한 벌이다.
|
|
2245
|
+
- EDA agent는 system_prompt_override 별도 prompt 사용 (이 함수 우회).
|
|
2246
|
+
- centri 키는 빌더가 읽는다(simple 은 `chat_system_prompt` 이슈 #178,
|
|
2247
|
+
knowledge 는 `chat_qa_system_prompt` 이슈 #208).
|
|
2248
|
+
"""
|
|
2249
|
+
# simple 모드 fallback (호출 경로가 거의 없지만 안전망)
|
|
2250
|
+
if self._no_tools:
|
|
2251
|
+
return build_chat_mode_system_prompt("simple")
|
|
2252
|
+
|
|
2253
|
+
return build_chat_mode_system_prompt("knowledge")
|
|
2254
|
+
|
|
2255
|
+
async def _fallback_llm_call(self, config: Dict[str, Any]):
|
|
2256
|
+
"""그래프 실패 시 state에서 메시지를 가져와 compression 후 LLM 직접 호출."""
|
|
2257
|
+
from langchain_core.messages import (
|
|
2258
|
+
HumanMessage,
|
|
2259
|
+
SystemMessage,
|
|
2260
|
+
ToolMessage,
|
|
2261
|
+
)
|
|
2262
|
+
|
|
2263
|
+
# checkpointer에서 현재 state 가져오기
|
|
2264
|
+
thread_id = (config.get("configurable") or {}).get("thread_id")
|
|
2265
|
+
state_messages = []
|
|
2266
|
+
if self._checkpointer and thread_id:
|
|
2267
|
+
try:
|
|
2268
|
+
state = await self._agent.aget_state(config)
|
|
2269
|
+
state_messages = state.values.get("messages", [])
|
|
2270
|
+
except Exception:
|
|
2271
|
+
pass
|
|
2272
|
+
|
|
2273
|
+
if not state_messages:
|
|
2274
|
+
raise RuntimeError("No messages in state for fallback")
|
|
2275
|
+
|
|
2276
|
+
print(
|
|
2277
|
+
f"[RAG] Fallback: state has {len(state_messages)} messages",
|
|
2278
|
+
flush=True,
|
|
2279
|
+
)
|
|
2280
|
+
|
|
2281
|
+
# compression 미들웨어 로직 적용
|
|
2282
|
+
try:
|
|
2283
|
+
compressor = _create_context_compression_middleware_class()()
|
|
2284
|
+
compressed = compressor.before_model({"messages": state_messages})
|
|
2285
|
+
if compressed:
|
|
2286
|
+
state_messages = compressed["messages"]
|
|
2287
|
+
print("[RAG] Fallback: compression applied", flush=True)
|
|
2288
|
+
except Exception as comp_err:
|
|
2289
|
+
print(
|
|
2290
|
+
f"[RAG] Fallback: compression failed ({comp_err}), using raw messages",
|
|
2291
|
+
flush=True,
|
|
2292
|
+
)
|
|
2293
|
+
|
|
2294
|
+
# 사용자 질문(마지막) + tool 결과 추출
|
|
2295
|
+
user_question = ""
|
|
2296
|
+
tool_contents = []
|
|
2297
|
+
for msg in state_messages:
|
|
2298
|
+
if isinstance(msg, HumanMessage):
|
|
2299
|
+
user_question = msg.content # 마지막 HumanMessage를 사용
|
|
2300
|
+
elif isinstance(msg, ToolMessage) and msg.content:
|
|
2301
|
+
tool_contents.append(msg.content[:2000])
|
|
2302
|
+
|
|
2303
|
+
if not user_question:
|
|
2304
|
+
raise RuntimeError("No user question found in state")
|
|
2305
|
+
|
|
2306
|
+
combined_results = "\n\n---\n\n".join(tool_contents[-3:])
|
|
2307
|
+
model = self._create_model()
|
|
2308
|
+
messages = [
|
|
2309
|
+
SystemMessage(
|
|
2310
|
+
content="사용자 질문과 검색 결과를 바탕으로 답변하세요. "
|
|
2311
|
+
"추가 도구 호출 없이 바로 답변만 생성하세요."
|
|
2312
|
+
),
|
|
2313
|
+
HumanMessage(
|
|
2314
|
+
content=f"질문: {user_question}\n\n검색 결과:\n{combined_results}"
|
|
2315
|
+
),
|
|
2316
|
+
]
|
|
2317
|
+
|
|
2318
|
+
fallback_tokens = _estimate_tokens(messages)
|
|
2319
|
+
print(
|
|
2320
|
+
f"[RAG] Fallback LLM call: question={user_question[:80]}, "
|
|
2321
|
+
f"tool_results={len(tool_contents)}, tokens={fallback_tokens}",
|
|
2322
|
+
flush=True,
|
|
2323
|
+
)
|
|
2324
|
+
async for chunk in model.astream(messages):
|
|
2325
|
+
if chunk.content:
|
|
2326
|
+
yield chunk.content
|
|
2327
|
+
|
|
2328
|
+
def _get_or_create_evidence_reflection(self):
|
|
2329
|
+
"""EvidenceReflection 미들웨어 인스턴스를 생성하거나 기존 인스턴스 반환."""
|
|
2330
|
+
if self._evidence_reflection is None:
|
|
2331
|
+
self._evidence_reflection = _create_evidence_reflection_middleware_class()()
|
|
2332
|
+
return self._evidence_reflection
|
|
2333
|
+
|
|
2334
|
+
def _get_or_create_rag_grounding(self):
|
|
2335
|
+
"""RagGrounding 미들웨어 인스턴스를 생성하거나 기존 인스턴스 반환."""
|
|
2336
|
+
if self._rag_grounding is None:
|
|
2337
|
+
self._rag_grounding = _create_rag_grounding_middleware_class(
|
|
2338
|
+
constraint_text=self._grounding_constraint
|
|
2339
|
+
)()
|
|
2340
|
+
return self._rag_grounding
|
|
2341
|
+
|
|
2342
|
+
def _get_or_create_table_confirmation(self):
|
|
2343
|
+
"""TableConfirmation 미들웨어 인스턴스를 생성하거나 기존 인스턴스 반환."""
|
|
2344
|
+
if self._table_confirmation is None:
|
|
2345
|
+
self._table_confirmation = _create_table_confirmation_middleware_class(
|
|
2346
|
+
self._table_score_threshold,
|
|
2347
|
+
self._excluded_table_names,
|
|
2348
|
+
mode=self._table_confirm_mode,
|
|
2349
|
+
)()
|
|
2350
|
+
return self._table_confirmation
|
|
2351
|
+
|
|
2352
|
+
def _rebuild_agent(self, temperature: float = 0.0):
|
|
2353
|
+
"""temperature를 변경하여 agent를 재생성."""
|
|
2354
|
+
self._agent = None
|
|
2355
|
+
self._ensure_agent(temperature=temperature)
|
|
2356
|
+
|
|
2357
|
+
def _ensure_agent(self, temperature: float = 0.0):
|
|
2358
|
+
"""create_agent로 agent graph 생성 (lazy)."""
|
|
2359
|
+
if self._agent is not None:
|
|
2360
|
+
return
|
|
2361
|
+
|
|
2362
|
+
from langchain.agents import create_agent
|
|
2363
|
+
from langchain.agents.middleware import (
|
|
2364
|
+
ModelRetryMiddleware,
|
|
2365
|
+
SummarizationMiddleware,
|
|
2366
|
+
)
|
|
2367
|
+
|
|
2368
|
+
model = self._create_model(temperature=temperature)
|
|
2369
|
+
tools = self._create_tools()
|
|
2370
|
+
system_prompt = (
|
|
2371
|
+
self._system_prompt_override
|
|
2372
|
+
if self._system_prompt_override
|
|
2373
|
+
else self._build_system_prompt()
|
|
2374
|
+
)
|
|
2375
|
+
|
|
2376
|
+
create_kwargs: Dict[str, Any] = dict(
|
|
2377
|
+
model=model,
|
|
2378
|
+
tools=tools,
|
|
2379
|
+
system_prompt=system_prompt,
|
|
2380
|
+
middleware=[
|
|
2381
|
+
_create_dynamic_system_prompt_middleware_class(
|
|
2382
|
+
self._dynamic_prompt_holder
|
|
2383
|
+
)(),
|
|
2384
|
+
_create_developer_role_middleware_class()(),
|
|
2385
|
+
_create_strip_content_on_toolcall_middleware_class()(),
|
|
2386
|
+
_create_tool_call_dedupe_middleware_class()(),
|
|
2387
|
+
_create_snapshot_answer_middleware_class()(),
|
|
2388
|
+
self._get_or_create_evidence_reflection(),
|
|
2389
|
+
self._get_or_create_rag_grounding(),
|
|
2390
|
+
_create_context_compression_middleware_class()(),
|
|
2391
|
+
*(
|
|
2392
|
+
[self._get_or_create_table_confirmation()]
|
|
2393
|
+
if self._table_score_threshold
|
|
2394
|
+
else []
|
|
2395
|
+
),
|
|
2396
|
+
ModelRetryMiddleware(
|
|
2397
|
+
max_retries=3,
|
|
2398
|
+
backoff_factor=1.5,
|
|
2399
|
+
initial_delay=0.3,
|
|
2400
|
+
max_delay=30.0,
|
|
2401
|
+
on_failure=_on_llm_retry_failure,
|
|
2402
|
+
),
|
|
2403
|
+
SummarizationMiddleware(
|
|
2404
|
+
model=model,
|
|
2405
|
+
trigger=("tokens", 90000),
|
|
2406
|
+
keep=("messages", 15),
|
|
2407
|
+
summary_prompt=(
|
|
2408
|
+
"아래 대화 기록에서 가장 중요하고 관련성 높은 정보를 추출하세요.\n"
|
|
2409
|
+
"반드시 한국어로 작성하세요.\n\n"
|
|
2410
|
+
"다음 섹션별로 정리하세요:\n\n"
|
|
2411
|
+
"## 세션 목표\n"
|
|
2412
|
+
"사용자의 주요 목표나 요청은 무엇인가?\n\n"
|
|
2413
|
+
"## 핵심 요약\n"
|
|
2414
|
+
"대화에서 도출된 중요한 결론, 검색 결과, 결정 사항을 기록하세요.\n\n"
|
|
2415
|
+
"## 다음 단계\n"
|
|
2416
|
+
"세션 목표 달성을 위해 남은 작업은 무엇인가?\n\n"
|
|
2417
|
+
"추출한 요약만 출력하세요. 다른 텍스트를 추가하지 마세요.\n\n"
|
|
2418
|
+
"<messages>\n{messages}\n</messages>"
|
|
2419
|
+
),
|
|
2420
|
+
),
|
|
2421
|
+
_create_summary_to_system_middleware_class()(),
|
|
2422
|
+
],
|
|
2423
|
+
)
|
|
2424
|
+
if self._checkpointer is not None:
|
|
2425
|
+
create_kwargs["checkpointer"] = self._checkpointer
|
|
2426
|
+
self._agent = create_agent(**create_kwargs)
|
|
2427
|
+
|
|
2428
|
+
async def stream_response(
|
|
2429
|
+
self,
|
|
2430
|
+
user_message: str,
|
|
2431
|
+
history_messages: Optional[List[Dict[str, str]]] = None,
|
|
2432
|
+
file_context: Optional[str] = None,
|
|
2433
|
+
thread_id: Optional[str] = None,
|
|
2434
|
+
phase_system_prompt: Optional[str] = None,
|
|
2435
|
+
block_tools: bool = False,
|
|
2436
|
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
|
2437
|
+
"""Agent 응답을 스트리밍. SSE 호환 청크 yield.
|
|
2438
|
+
|
|
2439
|
+
Args:
|
|
2440
|
+
history_messages: 첫 turn에만 전달 (checkpointer 미사용 또는 서버 재시작 후).
|
|
2441
|
+
checkpointer가 state를 유지하면 None으로 전달해 중복 방지.
|
|
2442
|
+
thread_id: checkpointer용 thread ID. conversation_id 사용 권장.
|
|
2443
|
+
block_tools: True이면 tool call을 차단 (simple 모드).
|
|
2444
|
+
phase_system_prompt: 이 호출에만 적용할 시스템 프롬프트 오버라이드.
|
|
2445
|
+
None이면 생성 시 지정한 system_prompt_override 또는 기본값 사용.
|
|
2446
|
+
DynamicSystemPromptMiddleware가 before_model에서 SystemMessage를 교체.
|
|
2447
|
+
|
|
2448
|
+
Yields:
|
|
2449
|
+
{"content": str, "done": False} — LLM 토큰
|
|
2450
|
+
{"status": str, "icon": str, "done": False} — 도구 호출 상태
|
|
2451
|
+
"""
|
|
2452
|
+
self._dynamic_prompt_holder[0] = phase_system_prompt
|
|
2453
|
+
self._ensure_agent()
|
|
2454
|
+
|
|
2455
|
+
# 턴 시작: 미들웨어 상태 리셋
|
|
2456
|
+
if self._evidence_reflection is not None:
|
|
2457
|
+
self._evidence_reflection.reset()
|
|
2458
|
+
|
|
2459
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
2460
|
+
|
|
2461
|
+
# 메시지 구성
|
|
2462
|
+
messages = []
|
|
2463
|
+
if history_messages:
|
|
2464
|
+
for m in history_messages:
|
|
2465
|
+
if m["role"] == "user":
|
|
2466
|
+
messages.append(HumanMessage(content=m["content"]))
|
|
2467
|
+
else:
|
|
2468
|
+
messages.append(AIMessage(content=m["content"]))
|
|
2469
|
+
|
|
2470
|
+
if file_context:
|
|
2471
|
+
messages.append(HumanMessage(content=f"[참고 컨텍스트]\n{file_context}"))
|
|
2472
|
+
|
|
2473
|
+
messages.append(HumanMessage(content=user_message))
|
|
2474
|
+
|
|
2475
|
+
# 사용자 원본 메시지 저장 (enterprise 키워드 체크용)
|
|
2476
|
+
try:
|
|
2477
|
+
from jupyter_ext.agent.langchain_tools import set_last_user_message
|
|
2478
|
+
|
|
2479
|
+
set_last_user_message(user_message)
|
|
2480
|
+
except Exception:
|
|
2481
|
+
pass
|
|
2482
|
+
|
|
2483
|
+
config: Dict[str, Any] = {"recursion_limit": 100}
|
|
2484
|
+
if thread_id:
|
|
2485
|
+
config["configurable"] = {"thread_id": thread_id}
|
|
2486
|
+
|
|
2487
|
+
# ── simple 모드: agent 그래프 우회, 모델 직접 호출 ──
|
|
2488
|
+
if block_tools:
|
|
2489
|
+
try:
|
|
2490
|
+
# checkpointer에서 이전 대화 로드
|
|
2491
|
+
existing_msgs: list = []
|
|
2492
|
+
if self._checkpointer and thread_id:
|
|
2493
|
+
try:
|
|
2494
|
+
_state = await self._agent.aget_state(config)
|
|
2495
|
+
existing_msgs = list(_state.values.get("messages", []))
|
|
2496
|
+
except Exception:
|
|
2497
|
+
pass
|
|
2498
|
+
|
|
2499
|
+
# 시스템 프롬프트 + 기존 대화 + 새 메시지
|
|
2500
|
+
sys_prompt = (
|
|
2501
|
+
phase_system_prompt
|
|
2502
|
+
or self._system_prompt_override
|
|
2503
|
+
or self._build_system_prompt()
|
|
2504
|
+
)
|
|
2505
|
+
llm_messages = [SystemMessage(content=sys_prompt)]
|
|
2506
|
+
for m in existing_msgs:
|
|
2507
|
+
if m.type != "system":
|
|
2508
|
+
llm_messages.append(m)
|
|
2509
|
+
llm_messages.extend(messages)
|
|
2510
|
+
|
|
2511
|
+
model = self._create_model()
|
|
2512
|
+
response_text = ""
|
|
2513
|
+
async for chunk in model.astream(llm_messages):
|
|
2514
|
+
if chunk.content:
|
|
2515
|
+
response_text += chunk.content
|
|
2516
|
+
yield {"content": chunk.content, "done": False}
|
|
2517
|
+
|
|
2518
|
+
# checkpointer state 업데이트 (대화 연속성 유지)
|
|
2519
|
+
if self._checkpointer and thread_id and response_text:
|
|
2520
|
+
try:
|
|
2521
|
+
update_msgs = list(messages) + [
|
|
2522
|
+
AIMessage(content=response_text)
|
|
2523
|
+
]
|
|
2524
|
+
await self._agent.aupdate_state(
|
|
2525
|
+
config, {"messages": update_msgs}
|
|
2526
|
+
)
|
|
2527
|
+
except Exception as _upd_err:
|
|
2528
|
+
print(
|
|
2529
|
+
f"[RAG] simple mode state update failed: {_upd_err}",
|
|
2530
|
+
flush=True,
|
|
2531
|
+
)
|
|
2532
|
+
except Exception as e:
|
|
2533
|
+
print(f"[RAG] simple mode error: {e}", flush=True)
|
|
2534
|
+
yield {
|
|
2535
|
+
"content": f"응답 생성 중 오류가 발생했습니다: {e}",
|
|
2536
|
+
"done": False,
|
|
2537
|
+
}
|
|
2538
|
+
return
|
|
2539
|
+
|
|
2540
|
+
input_dict = {"messages": messages}
|
|
2541
|
+
|
|
2542
|
+
# 디버그: checkpointer state 확인
|
|
2543
|
+
if self._checkpointer and thread_id:
|
|
2544
|
+
try:
|
|
2545
|
+
_dbg_state = await self._agent.aget_state(config)
|
|
2546
|
+
_dbg_msgs = _dbg_state.values.get("messages", [])
|
|
2547
|
+
print(
|
|
2548
|
+
f"[RAG] stream_response: thread={thread_id[:16]}... "
|
|
2549
|
+
f"input_msgs={len(messages)} "
|
|
2550
|
+
f"checkpoint_msgs={len(_dbg_msgs)} "
|
|
2551
|
+
f"agent_id={id(self._agent)}",
|
|
2552
|
+
flush=True,
|
|
2553
|
+
)
|
|
2554
|
+
except Exception:
|
|
2555
|
+
pass
|
|
2556
|
+
|
|
2557
|
+
content_started = False
|
|
2558
|
+
tool_call_count = 0
|
|
2559
|
+
_rag_search_count = 0 # rag_search 호출 횟수
|
|
2560
|
+
_l2_search_count = 0 # search_l2_table 호출 횟수
|
|
2561
|
+
_MAX_RAG_SEARCH = 5
|
|
2562
|
+
_MAX_L2_SEARCH = 5
|
|
2563
|
+
_MAX_TOOL_CALLS = 30 # 전체 tool 호출 상한 (무한 루프 방어)
|
|
2564
|
+
_pending_tool = False # parallel tool call 방어: 첫 번째만 처리
|
|
2565
|
+
_current_call_id = "" # tool_call ↔ tool_result 매핑용
|
|
2566
|
+
_current_tool_name = "" # on_tool_start에서 저장, on_tool_end 로깅에 사용
|
|
2567
|
+
_current_run_id = "" # on_tool_start의 run_id (on_tool_end 매칭용)
|
|
2568
|
+
_logged_call_ids: set = set() # 이미 로깅한 call_id 추적 (중복 방지)
|
|
2569
|
+
_content_buffer: list[str] = [] # LLM 턴별 content 버퍼 (할루시네이션 방지)
|
|
2570
|
+
_tool_processing = False # tool call 실행 후 LLM 응답 대기 상태
|
|
2571
|
+
_stream_had_error = False # 스트리밍 중 에러 발생 여부 (temperature retry 조건)
|
|
2572
|
+
|
|
2573
|
+
# Raw LLM 로깅 (핫리로드: 매 요청마다 config 확인)
|
|
2574
|
+
_raw_logging = _is_raw_logging_enabled()
|
|
2575
|
+
_raw_log_path: Optional[Path] = None
|
|
2576
|
+
if _raw_logging:
|
|
2577
|
+
_raw_log_path = _get_raw_log_path()
|
|
2578
|
+
_append_log(
|
|
2579
|
+
_raw_log_path,
|
|
2580
|
+
{
|
|
2581
|
+
"type": "user_request",
|
|
2582
|
+
"timestamp": datetime.now().isoformat(),
|
|
2583
|
+
"thread_id": thread_id,
|
|
2584
|
+
"system_prompt": self._system_prompt_override
|
|
2585
|
+
or self._build_system_prompt(),
|
|
2586
|
+
"user_message": user_message,
|
|
2587
|
+
"history_count": len(history_messages) if history_messages else 0,
|
|
2588
|
+
"has_file_context": bool(file_context),
|
|
2589
|
+
"file_context": file_context[:500] if file_context else None,
|
|
2590
|
+
"input_messages": [
|
|
2591
|
+
{"role": m.type, "content": m.content} for m in messages
|
|
2592
|
+
],
|
|
2593
|
+
},
|
|
2594
|
+
)
|
|
2595
|
+
|
|
2596
|
+
print(f"[RAG] user_message: {user_message[:200]}", flush=True)
|
|
2597
|
+
|
|
2598
|
+
try:
|
|
2599
|
+
async for event in self._agent.astream_events(
|
|
2600
|
+
input_dict, version="v2", config=config
|
|
2601
|
+
):
|
|
2602
|
+
kind = event.get("event", "")
|
|
2603
|
+
|
|
2604
|
+
# 디버그: chain/model 시작/종료 이벤트 로깅
|
|
2605
|
+
if kind in (
|
|
2606
|
+
"on_chain_start",
|
|
2607
|
+
"on_chain_end",
|
|
2608
|
+
"on_chat_model_start",
|
|
2609
|
+
"on_chat_model_end",
|
|
2610
|
+
"on_llm_start",
|
|
2611
|
+
"on_llm_end",
|
|
2612
|
+
"on_tool_start",
|
|
2613
|
+
"on_tool_end",
|
|
2614
|
+
):
|
|
2615
|
+
evt_name = event.get("name", "")
|
|
2616
|
+
_evt_extra = ""
|
|
2617
|
+
# LangGraph 종료 시 content_started 상태 로깅
|
|
2618
|
+
if kind == "on_chain_end" and evt_name == "LangGraph":
|
|
2619
|
+
_evt_extra = (
|
|
2620
|
+
f" content_started={content_started}"
|
|
2621
|
+
f" tool_processing={_tool_processing}"
|
|
2622
|
+
f" tool_calls={tool_call_count}"
|
|
2623
|
+
f" buf={len(_content_buffer)}"
|
|
2624
|
+
)
|
|
2625
|
+
print(
|
|
2626
|
+
f"[RAG] EVENT: {kind} name={evt_name}{_evt_extra}",
|
|
2627
|
+
flush=True,
|
|
2628
|
+
)
|
|
2629
|
+
|
|
2630
|
+
if kind in ("on_chat_model_stream", "on_llm_stream"):
|
|
2631
|
+
chunk = event.get("data", {}).get("chunk")
|
|
2632
|
+
if chunk and hasattr(chunk, "content") and chunk.content:
|
|
2633
|
+
# GPT-OSS harmony 특수 토큰 + 인용 마커 필터링
|
|
2634
|
+
text = chunk.content
|
|
2635
|
+
if _HARMONY_TOKEN_RE.search(text) or _CITATION_MARKER_RE.search(
|
|
2636
|
+
text
|
|
2637
|
+
):
|
|
2638
|
+
text = _strip_harmony_tokens(text)
|
|
2639
|
+
if not text:
|
|
2640
|
+
continue
|
|
2641
|
+
|
|
2642
|
+
# 매 LLM 턴마다 버퍼링: tool call 오면 폐기, 안 오면 flush
|
|
2643
|
+
_content_buffer.append(text)
|
|
2644
|
+
|
|
2645
|
+
elif kind in ("on_chat_model_end", "on_llm_end"):
|
|
2646
|
+
output = event.get("data", {}).get("output")
|
|
2647
|
+
has_tc = bool(
|
|
2648
|
+
output and hasattr(output, "tool_calls") and output.tool_calls
|
|
2649
|
+
)
|
|
2650
|
+
buf_len = len(_content_buffer)
|
|
2651
|
+
buf_preview = (
|
|
2652
|
+
"".join(_content_buffer)[:80] if _content_buffer else ""
|
|
2653
|
+
)
|
|
2654
|
+
# LLM 응답 내용 로깅
|
|
2655
|
+
output_content = ""
|
|
2656
|
+
if output and hasattr(output, "content"):
|
|
2657
|
+
output_content = output.content or ""
|
|
2658
|
+
print(
|
|
2659
|
+
f"[RAG] chat_model_end: has_tool_calls={has_tc} "
|
|
2660
|
+
f"buf={buf_len} "
|
|
2661
|
+
f"output_len={len(output_content)} "
|
|
2662
|
+
f"preview={buf_preview!r}",
|
|
2663
|
+
flush=True,
|
|
2664
|
+
)
|
|
2665
|
+
# 빈 응답 감지 (buffer 0, tool_calls 0, output 0)
|
|
2666
|
+
if not _content_buffer and not has_tc and not output_content:
|
|
2667
|
+
print(
|
|
2668
|
+
"[RAG] WARNING: LLM returned empty response",
|
|
2669
|
+
flush=True,
|
|
2670
|
+
)
|
|
2671
|
+
# tool call이 있는 턴: 버퍼 폐기 (할루시네이션)
|
|
2672
|
+
# tool call이 없는 턴: 버퍼 flush (최종 답변)
|
|
2673
|
+
if _content_buffer and has_tc:
|
|
2674
|
+
print(
|
|
2675
|
+
f"[RAG] discarding content with tool_calls: "
|
|
2676
|
+
f"{buf_len} chunks",
|
|
2677
|
+
flush=True,
|
|
2678
|
+
)
|
|
2679
|
+
_content_buffer.clear()
|
|
2680
|
+
elif _content_buffer:
|
|
2681
|
+
# SummarizationMiddleware 출력 감지 → 폐기 + 상태 메시지
|
|
2682
|
+
joined = "".join(_content_buffer)
|
|
2683
|
+
_SUMMARY_PATTERNS = (
|
|
2684
|
+
"SESSION",
|
|
2685
|
+
"## Summary",
|
|
2686
|
+
"## Intent",
|
|
2687
|
+
"## Artifacts",
|
|
2688
|
+
"## Next",
|
|
2689
|
+
"## 세션 목표",
|
|
2690
|
+
"## 핵심 요약",
|
|
2691
|
+
"## 다음 단계",
|
|
2692
|
+
"Here is a summary",
|
|
2693
|
+
)
|
|
2694
|
+
if any(
|
|
2695
|
+
joined.lstrip().startswith(p) for p in _SUMMARY_PATTERNS
|
|
2696
|
+
):
|
|
2697
|
+
print(
|
|
2698
|
+
f"[RAG] discarding summarization output: {len(joined)} chars",
|
|
2699
|
+
flush=True,
|
|
2700
|
+
)
|
|
2701
|
+
_content_buffer.clear()
|
|
2702
|
+
if _tool_processing:
|
|
2703
|
+
_tool_processing = False
|
|
2704
|
+
yield {"tool_processing_end": True, "done": False}
|
|
2705
|
+
yield {"summarizing": True, "done": False}
|
|
2706
|
+
else:
|
|
2707
|
+
# tool call 후 LLM 응답 시작 → tool_processing 해제
|
|
2708
|
+
if _tool_processing:
|
|
2709
|
+
_tool_processing = False
|
|
2710
|
+
yield {"tool_processing_end": True, "done": False}
|
|
2711
|
+
# 버퍼 합쳐서 인용 마커 제거 후 flush
|
|
2712
|
+
_joined_buf = "".join(_content_buffer)
|
|
2713
|
+
if _CITATION_MARKER_RE.search(_joined_buf):
|
|
2714
|
+
_joined_buf = _CITATION_MARKER_RE.sub("", _joined_buf)
|
|
2715
|
+
if _joined_buf.strip():
|
|
2716
|
+
content_started = True
|
|
2717
|
+
yield {"content": _joined_buf, "done": False}
|
|
2718
|
+
_content_buffer.clear()
|
|
2719
|
+
|
|
2720
|
+
# Raw LLM 로깅: LLM turn 종료 시 전체 응답 기록
|
|
2721
|
+
if _raw_log_path:
|
|
2722
|
+
output = event.get("data", {}).get("output")
|
|
2723
|
+
log_entry: Dict[str, Any] = {
|
|
2724
|
+
"type": "llm_response",
|
|
2725
|
+
"timestamp": datetime.now().isoformat(),
|
|
2726
|
+
"thread_id": thread_id,
|
|
2727
|
+
}
|
|
2728
|
+
if output and hasattr(output, "content"):
|
|
2729
|
+
_llm_content = output.content or ""
|
|
2730
|
+
log_entry["content"] = (
|
|
2731
|
+
_llm_content[:3000] if _llm_content else None
|
|
2732
|
+
)
|
|
2733
|
+
log_entry["content_length"] = len(_llm_content)
|
|
2734
|
+
if output and hasattr(output, "tool_calls"):
|
|
2735
|
+
tc = output.tool_calls
|
|
2736
|
+
if tc:
|
|
2737
|
+
log_entry["tool_calls"] = [
|
|
2738
|
+
{"name": t.get("name"), "args": t.get("args")}
|
|
2739
|
+
for t in tc
|
|
2740
|
+
]
|
|
2741
|
+
else:
|
|
2742
|
+
log_entry["tool_calls"] = []
|
|
2743
|
+
_append_log(_raw_log_path, log_entry)
|
|
2744
|
+
|
|
2745
|
+
elif kind == "on_tool_start":
|
|
2746
|
+
# tool call 발생: 버퍼에 쌓인 할루시네이션 content 폐기
|
|
2747
|
+
if _content_buffer:
|
|
2748
|
+
print(
|
|
2749
|
+
f"[RAG] discarding buffered hallucination: "
|
|
2750
|
+
f"{len(_content_buffer)} chunks",
|
|
2751
|
+
flush=True,
|
|
2752
|
+
)
|
|
2753
|
+
_content_buffer.clear()
|
|
2754
|
+
content_started = False
|
|
2755
|
+
|
|
2756
|
+
# parallel tool call 방어: 이미 tool 실행 중이면 스킵
|
|
2757
|
+
if _pending_tool:
|
|
2758
|
+
print(
|
|
2759
|
+
f"[RAG] parallel tool call skipped: {event.get('name', '')}",
|
|
2760
|
+
flush=True,
|
|
2761
|
+
)
|
|
2762
|
+
continue
|
|
2763
|
+
_pending_tool = True
|
|
2764
|
+
tool_call_count += 1
|
|
2765
|
+
tool_name = event.get("name", "")
|
|
2766
|
+
tool_input = event.get("data", {}).get("input", {})
|
|
2767
|
+
|
|
2768
|
+
# 전체 tool 호출 상한 초과 시 강제 종료
|
|
2769
|
+
if tool_call_count > _MAX_TOOL_CALLS:
|
|
2770
|
+
print(
|
|
2771
|
+
f"[RAG] MAX_TOOL_CALLS ({_MAX_TOOL_CALLS}) reached, stopping",
|
|
2772
|
+
flush=True,
|
|
2773
|
+
)
|
|
2774
|
+
yield {
|
|
2775
|
+
"status": f"도구 호출 횟수 제한({_MAX_TOOL_CALLS}회)에 도달하여 중단합니다.",
|
|
2776
|
+
"done": True,
|
|
2777
|
+
}
|
|
2778
|
+
return
|
|
2779
|
+
|
|
2780
|
+
_current_call_id = f"call_{tool_call_count}"
|
|
2781
|
+
_current_tool_name = tool_name
|
|
2782
|
+
_current_run_id = event.get("run_id", "")
|
|
2783
|
+
if _raw_log_path:
|
|
2784
|
+
_append_log(
|
|
2785
|
+
_raw_log_path,
|
|
2786
|
+
{
|
|
2787
|
+
"type": "tool_call",
|
|
2788
|
+
"call_id": _current_call_id,
|
|
2789
|
+
"timestamp": datetime.now().isoformat(),
|
|
2790
|
+
"thread_id": thread_id,
|
|
2791
|
+
"tool_name": tool_name,
|
|
2792
|
+
"tool_input": tool_input
|
|
2793
|
+
if isinstance(tool_input, dict)
|
|
2794
|
+
else str(tool_input),
|
|
2795
|
+
"call_num": tool_call_count,
|
|
2796
|
+
},
|
|
2797
|
+
)
|
|
2798
|
+
|
|
2799
|
+
# rag_search 과도한 호출 방어
|
|
2800
|
+
if "rag_search" in tool_name:
|
|
2801
|
+
_rag_search_count += 1
|
|
2802
|
+
if _rag_search_count > _MAX_RAG_SEARCH:
|
|
2803
|
+
print(
|
|
2804
|
+
f"[RAG] rag_search call #{_rag_search_count} "
|
|
2805
|
+
f"blocked (max={_MAX_RAG_SEARCH})",
|
|
2806
|
+
flush=True,
|
|
2807
|
+
)
|
|
2808
|
+
_pending_tool = False
|
|
2809
|
+
continue
|
|
2810
|
+
|
|
2811
|
+
# search_l2_table 과도한 호출 방어
|
|
2812
|
+
elif "search_l2_table" in tool_name:
|
|
2813
|
+
_l2_search_count += 1
|
|
2814
|
+
if _l2_search_count > _MAX_L2_SEARCH:
|
|
2815
|
+
print(
|
|
2816
|
+
f"[RAG] search_l2_table call #{_l2_search_count} "
|
|
2817
|
+
f"blocked (max={_MAX_L2_SEARCH})",
|
|
2818
|
+
flush=True,
|
|
2819
|
+
)
|
|
2820
|
+
_pending_tool = False
|
|
2821
|
+
continue
|
|
2822
|
+
|
|
2823
|
+
print(
|
|
2824
|
+
f"[RAG] tool_start (call #{tool_call_count}): "
|
|
2825
|
+
f"{tool_name} {str(tool_input)[:200]}",
|
|
2826
|
+
flush=True,
|
|
2827
|
+
)
|
|
2828
|
+
_start_status = qa_tool_start_status(tool_name, tool_input)
|
|
2829
|
+
if _start_status:
|
|
2830
|
+
yield {**_start_status, "done": False}
|
|
2831
|
+
|
|
2832
|
+
elif kind == "on_tool_end":
|
|
2833
|
+
# run_id가 on_tool_start와 일치하지 않으면 무시 (이벤트 뒤섞임 방지)
|
|
2834
|
+
_end_run_id = event.get("run_id", "")
|
|
2835
|
+
if _current_run_id and _end_run_id != _current_run_id:
|
|
2836
|
+
continue
|
|
2837
|
+
_pending_tool = False # 다음 tool call 허용
|
|
2838
|
+
tool_name = event.get("name", "")
|
|
2839
|
+
output = event.get("data", {}).get("output", "")
|
|
2840
|
+
if hasattr(output, "content"):
|
|
2841
|
+
output = output.content
|
|
2842
|
+
|
|
2843
|
+
_end_status = qa_tool_end_status(
|
|
2844
|
+
tool_name, output, event.get("data", {}).get("input", {})
|
|
2845
|
+
)
|
|
2846
|
+
print(
|
|
2847
|
+
f"[RAG] tool_end: {tool_name} "
|
|
2848
|
+
f"{(_end_status or {}).get('status', '')}",
|
|
2849
|
+
flush=True,
|
|
2850
|
+
)
|
|
2851
|
+
if _end_status:
|
|
2852
|
+
yield {**_end_status, "done": False}
|
|
2853
|
+
# tool 실행 완료 → LLM이 결과 분석 중 상태
|
|
2854
|
+
if not _tool_processing:
|
|
2855
|
+
_tool_processing = True
|
|
2856
|
+
yield {"tool_processing": True, "done": False}
|
|
2857
|
+
|
|
2858
|
+
# Raw LLM 로깅: 모든 tool의 result 기록 (call_id로 매핑)
|
|
2859
|
+
# 이미 로깅한 call_id는 건너뜀 (중복 on_tool_end 방지)
|
|
2860
|
+
if (
|
|
2861
|
+
_raw_log_path
|
|
2862
|
+
and _current_call_id
|
|
2863
|
+
and _current_call_id not in _logged_call_ids
|
|
2864
|
+
):
|
|
2865
|
+
_output_parsed: Any = output
|
|
2866
|
+
if isinstance(output, str):
|
|
2867
|
+
try:
|
|
2868
|
+
_output_parsed = json.loads(output)
|
|
2869
|
+
except (json.JSONDecodeError, TypeError):
|
|
2870
|
+
_output_parsed = output
|
|
2871
|
+
_append_log(
|
|
2872
|
+
_raw_log_path,
|
|
2873
|
+
{
|
|
2874
|
+
"type": "tool_result",
|
|
2875
|
+
"call_id": _current_call_id,
|
|
2876
|
+
"timestamp": datetime.now().isoformat(),
|
|
2877
|
+
"thread_id": thread_id,
|
|
2878
|
+
"tool_name": _current_tool_name or tool_name,
|
|
2879
|
+
"output": _output_parsed,
|
|
2880
|
+
},
|
|
2881
|
+
)
|
|
2882
|
+
_logged_call_ids.add(_current_call_id)
|
|
2883
|
+
|
|
2884
|
+
except TableConfirmationNeeded:
|
|
2885
|
+
raise # Athena handler에서 catch하도록 전파
|
|
2886
|
+
except Exception as e:
|
|
2887
|
+
import traceback
|
|
2888
|
+
|
|
2889
|
+
_stream_had_error = True
|
|
2890
|
+
err_msg = str(e)
|
|
2891
|
+
print(f"[RAG] ERROR: {err_msg}", flush=True)
|
|
2892
|
+
traceback.print_exc()
|
|
2893
|
+
|
|
2894
|
+
if "recursion limit" in err_msg.lower():
|
|
2895
|
+
print("[RAG] Recursion limit — attempting final LLM call", flush=True)
|
|
2896
|
+
if not content_started:
|
|
2897
|
+
# 그래프 state에서 메시지를 가져와 LLM 직접 호출
|
|
2898
|
+
try:
|
|
2899
|
+
async for chunk in self._fallback_llm_call(config):
|
|
2900
|
+
content_started = True
|
|
2901
|
+
yield {"content": chunk, "done": False}
|
|
2902
|
+
except Exception as fallback_err:
|
|
2903
|
+
print(
|
|
2904
|
+
f"[RAG] Fallback LLM call failed: {fallback_err}",
|
|
2905
|
+
flush=True,
|
|
2906
|
+
)
|
|
2907
|
+
yield {
|
|
2908
|
+
"content": "검색 결과를 바탕으로 답변을 준비했으나 "
|
|
2909
|
+
"처리 한도에 도달했습니다. 질문을 더 구체적으로 "
|
|
2910
|
+
"해주시면 정확한 답변을 드릴 수 있습니다.",
|
|
2911
|
+
"done": False,
|
|
2912
|
+
}
|
|
2913
|
+
else:
|
|
2914
|
+
# content가 이미 일부 출력된 경우만 에러 안내 표시
|
|
2915
|
+
# (content_started=False이면 아래 retry 로직에서 처리)
|
|
2916
|
+
if content_started:
|
|
2917
|
+
yield {
|
|
2918
|
+
"content": f"\n\n> 문제가 발생했습니다. {err_msg}\n> 잠시 후 다시 시도해 주세요.",
|
|
2919
|
+
"done": False,
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
# 스트림 종료 후 content가 없으면 처리
|
|
2923
|
+
if not content_started:
|
|
2924
|
+
_reason = (
|
|
2925
|
+
"stream ended without content after tool_end"
|
|
2926
|
+
if _tool_processing
|
|
2927
|
+
else "empty response without tool calls"
|
|
2928
|
+
)
|
|
2929
|
+
|
|
2930
|
+
if _stream_had_error:
|
|
2931
|
+
# LLM/vLLM 에러 발생 → temperature 올려서 재시도
|
|
2932
|
+
print(
|
|
2933
|
+
f"[RAG] WARNING: {_reason} (had error) — retrying with higher temperature",
|
|
2934
|
+
flush=True,
|
|
2935
|
+
)
|
|
2936
|
+
_retry_temp = 0.1
|
|
2937
|
+
while not content_started and _retry_temp <= 0.9:
|
|
2938
|
+
print(
|
|
2939
|
+
f"[RAG] Retrying with temperature={_retry_temp:.1f}", flush=True
|
|
2940
|
+
)
|
|
2941
|
+
self._rebuild_agent(temperature=_retry_temp)
|
|
2942
|
+
try:
|
|
2943
|
+
async for chunk in self._agent.astream(
|
|
2944
|
+
{"messages": messages}, config, stream_mode="updates"
|
|
2945
|
+
):
|
|
2946
|
+
for _node_name, _node_val in chunk.items():
|
|
2947
|
+
if _node_name == "__interrupt__" or _node_val is None:
|
|
2948
|
+
continue
|
|
2949
|
+
_msgs = (
|
|
2950
|
+
_node_val.get("messages", [])
|
|
2951
|
+
if isinstance(_node_val, dict)
|
|
2952
|
+
else []
|
|
2953
|
+
)
|
|
2954
|
+
for _msg in _msgs:
|
|
2955
|
+
if (
|
|
2956
|
+
hasattr(_msg, "content")
|
|
2957
|
+
and _msg.content
|
|
2958
|
+
and _msg.type == "ai"
|
|
2959
|
+
and not getattr(_msg, "tool_calls", None)
|
|
2960
|
+
):
|
|
2961
|
+
content_started = True
|
|
2962
|
+
yield {"content": _msg.content, "done": False}
|
|
2963
|
+
except Exception as retry_err:
|
|
2964
|
+
print(
|
|
2965
|
+
f"[RAG] Retry failed (temp={_retry_temp:.1f}): {retry_err}",
|
|
2966
|
+
flush=True,
|
|
2967
|
+
)
|
|
2968
|
+
_retry_temp += 0.1
|
|
2969
|
+
|
|
2970
|
+
if not content_started:
|
|
2971
|
+
print("[RAG] All temperature retries failed", flush=True)
|
|
2972
|
+
yield {
|
|
2973
|
+
"content": "LLM 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
|
|
2974
|
+
"done": False,
|
|
2975
|
+
}
|
|
2976
|
+
# temperature를 0으로 복원
|
|
2977
|
+
self._rebuild_agent(temperature=0.0)
|
|
2978
|
+
else:
|
|
2979
|
+
# 에러 없이 빈 응답 → fallback LLM 호출
|
|
2980
|
+
print(
|
|
2981
|
+
f"[RAG] WARNING: {_reason} — attempting fallback LLM call",
|
|
2982
|
+
flush=True,
|
|
2983
|
+
)
|
|
2984
|
+
try:
|
|
2985
|
+
async for chunk in self._fallback_llm_call(config):
|
|
2986
|
+
content_started = True
|
|
2987
|
+
yield {"content": chunk, "done": False}
|
|
2988
|
+
except Exception as fallback_err:
|
|
2989
|
+
print(f"[RAG] Fallback LLM call failed: {fallback_err}", flush=True)
|
|
2990
|
+
yield {
|
|
2991
|
+
"content": "응답 생성에 실패했습니다. 다시 시도해주세요.",
|
|
2992
|
+
"done": False,
|
|
2993
|
+
}
|