codebuff-agent 2.1.74__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  2. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
  3. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  4. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
  5. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
  6. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
  7. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
  8. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
  9. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
  10. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
  11. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  12. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  13. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  14. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
  15. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
  16. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
  17. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
  18. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
  19. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  20. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  21. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
  22. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
  23. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  24. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
  25. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
  26. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
  27. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
  28. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  29. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  30. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  31. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  32. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
  33. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
  34. codebuff_agent-2.1.74.dist-info/METADATA +142 -0
  35. codebuff_agent-2.1.74.dist-info/RECORD +217 -0
  36. codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
  37. codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
  38. codebuff_core/__init__.py +153 -0
  39. codebuff_core/codebuff_config_defaults.json +64 -0
  40. codebuff_core/core/__init__.py +35 -0
  41. codebuff_core/core/code_validator.py +1238 -0
  42. codebuff_core/core/error_classifier.py +579 -0
  43. codebuff_core/core/exceptions.py +49 -0
  44. codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
  45. codebuff_core/directory_policy.py +32 -0
  46. codebuff_core/factory.py +155 -0
  47. codebuff_core/interfaces.py +197 -0
  48. codebuff_core/knowledge/__init__.py +31 -0
  49. codebuff_core/knowledge/chunking.py +375 -0
  50. codebuff_core/knowledge/loader.py +339 -0
  51. codebuff_core/knowledge/watchdog_service.py +354 -0
  52. codebuff_core/legacy_paths.py +69 -0
  53. codebuff_core/llm/__init__.py +13 -0
  54. codebuff_core/llm/service.py +407 -0
  55. codebuff_core/managers/__init__.py +22 -0
  56. codebuff_core/managers/config_manager.py +690 -0
  57. codebuff_core/managers/remote_config.py +673 -0
  58. codebuff_core/managers/remote_skills.py +539 -0
  59. codebuff_core/managers/session_manager.py +255 -0
  60. codebuff_core/middleware/__init__.py +3 -0
  61. codebuff_core/middleware/error_recovery.py +394 -0
  62. codebuff_core/middleware/logging_utils.py +33 -0
  63. codebuff_core/models/__init__.py +111 -0
  64. codebuff_core/models/agent.py +306 -0
  65. codebuff_core/models/chat.py +57 -0
  66. codebuff_core/models/common.py +82 -0
  67. codebuff_core/models/rag.py +300 -0
  68. codebuff_core/platform_rules.py +127 -0
  69. codebuff_core/prompts/__init__.py +44 -0
  70. codebuff_core/prompts/auto_agent_prompts.py +1278 -0
  71. codebuff_core/services/__init__.py +30 -0
  72. codebuff_core/services/agent_service.py +345 -0
  73. codebuff_core/services/analytics_aggregate.py +837 -0
  74. codebuff_core/services/chat_service.py +898 -0
  75. codebuff_core/services/context_condenser.py +306 -0
  76. codebuff_core/services/pii_guardrail.py +393 -0
  77. codebuff_core/services/rag_chat_agent.py +2993 -0
  78. codebuff_core/services/rag_collections.py +2740 -0
  79. codebuff_core/services/rag_manager.py +890 -0
  80. codebuff_core/services/rag_service.py +145 -0
  81. codebuff_core/services/retriever.py +260 -0
  82. codebuff_core/services/vllm_embedding_service.py +290 -0
  83. codebuff_core/tests/__init__.py +1 -0
  84. codebuff_core/tests/conftest.py +81 -0
  85. codebuff_core/tests/test_error_recovery.py +647 -0
  86. codebuff_core/tests/test_factory.py +135 -0
  87. codebuff_core/tests/test_services.py +150 -0
  88. jupyter_ext/__init__.py +266 -0
  89. jupyter_ext/_version.py +4 -0
  90. jupyter_ext/agent/__init__.py +49 -0
  91. jupyter_ext/agent/agent_factory.py +2270 -0
  92. jupyter_ext/agent/ast_tools.py +451 -0
  93. jupyter_ext/agent/autofix_middleware.py +481 -0
  94. jupyter_ext/agent/background_procs.py +372 -0
  95. jupyter_ext/agent/code_history_middleware.py +211 -0
  96. jupyter_ext/agent/codex_autofix.py +160 -0
  97. jupyter_ext/agent/codex_bridge.py +3339 -0
  98. jupyter_ext/agent/codex_hooks.py +320 -0
  99. jupyter_ext/agent/codex_slash.py +685 -0
  100. jupyter_ext/agent/codex_threads.py +114 -0
  101. jupyter_ext/agent/content_injection.py +377 -0
  102. jupyter_ext/agent/continuation_middleware.py +548 -0
  103. jupyter_ext/agent/custom_skills.py +335 -0
  104. jupyter_ext/agent/file_diffs.py +99 -0
  105. jupyter_ext/agent/git_history.py +188 -0
  106. jupyter_ext/agent/guardrail_middleware.py +421 -0
  107. jupyter_ext/agent/json_repair_utils.py +54 -0
  108. jupyter_ext/agent/langchain_tools.py +2673 -0
  109. jupyter_ext/agent/mermaid_fixer.py +289 -0
  110. jupyter_ext/agent/mermaid_middleware.py +135 -0
  111. jupyter_ext/agent/middleware.py +359 -0
  112. jupyter_ext/agent/middleware_prompts.py +53 -0
  113. jupyter_ext/agent/observability.py +334 -0
  114. jupyter_ext/agent/patch_tool_calls.py +57 -0
  115. jupyter_ext/agent/prompts.py +112 -0
  116. jupyter_ext/agent/skill_middleware.py +257 -0
  117. jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
  118. jupyter_ext/agent/skills/anomaly_detection.md +12 -0
  119. jupyter_ext/agent/skills/athena_sql.md +156 -0
  120. jupyter_ext/agent/skills/bdp_table_search.md +33 -0
  121. jupyter_ext/agent/skills/bigquery.md +65 -0
  122. jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
  123. jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
  124. jupyter_ext/agent/skills/classification_evaluation.md +11 -0
  125. jupyter_ext/agent/skills/clustering_analysis.md +12 -0
  126. jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
  127. jupyter_ext/agent/skills/data_analysis.md +236 -0
  128. jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
  129. jupyter_ext/agent/skills/data_loading.md +158 -0
  130. jupyter_ext/agent/skills/data_quality_report.md +13 -0
  131. jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
  132. jupyter_ext/agent/skills/emr_script_development.md +17 -0
  133. jupyter_ext/agent/skills/experiment_tracking.md +13 -0
  134. jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
  135. jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
  136. jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
  137. jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
  138. jupyter_ext/agent/skills/inference.md +392 -0
  139. jupyter_ext/agent/skills/lambda_function_development.md +19 -0
  140. jupyter_ext/agent/skills/model_comparison.md +11 -0
  141. jupyter_ext/agent/skills/model_explainability.md +12 -0
  142. jupyter_ext/agent/skills/model_training.md +318 -0
  143. jupyter_ext/agent/skills/pyspark.md +352 -0
  144. jupyter_ext/agent/skills/python_code_quality.md +35 -0
  145. jupyter_ext/agent/skills/regression_evaluation.md +12 -0
  146. jupyter_ext/agent/skills/spring_java_development.md +35 -0
  147. jupyter_ext/agent/skills/text_preprocessing.md +12 -0
  148. jupyter_ext/agent/skills/time_series_analysis.md +13 -0
  149. jupyter_ext/agent/skills/unit_testing.md +203 -0
  150. jupyter_ext/agent/todo_prompts.py +57 -0
  151. jupyter_ext/agent/tool_report.py +45 -0
  152. jupyter_ext/analytics_ingest.py +599 -0
  153. jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  154. jupyter_ext/handlers/__init__.py +8 -0
  155. jupyter_ext/handlers/analytics_handler.py +170 -0
  156. jupyter_ext/handlers/benchmark_handler.py +794 -0
  157. jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
  158. jupyter_ext/handlers/rag_admin_handler.py +689 -0
  159. jupyter_ext/handlers/websocket.py +497 -0
  160. jupyter_ext/http_handlers.py +8744 -0
  161. jupyter_ext/idle_monitor.py +346 -0
  162. jupyter_ext/labextension/package.json +157 -0
  163. jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  164. jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
  165. jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
  166. jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
  167. jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
  168. jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
  169. jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
  170. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
  171. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  172. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  173. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  174. jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
  175. jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
  176. jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
  177. jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
  178. jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
  179. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  180. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  181. jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
  182. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
  183. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  184. jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
  185. jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
  186. jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
  187. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
  188. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  189. jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  190. jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  191. jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  192. jupyter_ext/labextension/static/style.js +4 -0
  193. jupyter_ext/labextension/static/third-party-licenses.json +724 -0
  194. jupyter_ext/log_paths.py +113 -0
  195. jupyter_ext/resource_usage.py +519 -0
  196. jupyter_ext/secrets_bootstrap.py +116 -0
  197. jupyter_ext/team_access.py +105 -0
  198. jupyter_ext/tests/__init__.py +1 -0
  199. jupyter_ext/tests/test_context.py +234 -0
  200. jupyter_ext/tests/test_factory.py +563 -0
  201. jupyter_ext/tests/test_file_finder.py +139 -0
  202. jupyter_ext/tests/test_handlers.py +58 -0
  203. jupyter_ext/tests/test_mcp_selftest.py +204 -0
  204. jupyter_ext/tests/test_mermaid_fixer.py +272 -0
  205. jupyter_ext/tests/test_resource_tiers.py +298 -0
  206. jupyter_ext/tools/__init__.py +15 -0
  207. jupyter_ext/tools/base.py +90 -0
  208. jupyter_ext/tools/context.py +268 -0
  209. jupyter_ext/tools/dir_complete.py +86 -0
  210. jupyter_ext/tools/file_finder.py +303 -0
  211. jupyter_ext/tools/file_tools.py +244 -0
  212. jupyter_ext/tools/jupyter_tools.py +296 -0
  213. jupyter_ext/tools/registry.py +159 -0
  214. jupyter_ext/tools/search_tools.py +230 -0
  215. jupyter_ext/tools/security_tools.py +657 -0
  216. jupyter_ext/tools/shell_tools.py +109 -0
  217. jupyter_ext/tools/user_tools.py +132 -0
@@ -0,0 +1,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
+ }