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,898 @@
1
+ """
2
+ 채팅 서비스 구현
3
+
4
+ IChatService의 Embedded 구현.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import re
10
+ from datetime import datetime
11
+ from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
12
+
13
+ from codebuff_core.interfaces import IChatService
14
+ from codebuff_core.llm import LLMService
15
+ from codebuff_core.llm.service import DEFAULT_MAX_OUTPUT_TOKENS
16
+ from codebuff_core.managers import get_config_manager, get_session_manager
17
+ from codebuff_core.managers.session_manager import ChatMessage
18
+ from codebuff_core.models.chat import ChatRequest, ChatResponse
19
+ from codebuff_core.services.pii_guardrail import mask_pii
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _chat_system_prompt() -> str:
25
+ """채팅 호출의 system — 도구 없는 Chat 과 같은 `prompts.chat_system_prompt` (이슈 #218).
26
+
27
+ 요약·테이블 후보 질문처럼 채팅이 아닌 호출은 system 을 넘기지 않는다.
28
+ """
29
+ from codebuff_core.services.rag_chat_agent import build_chat_mode_system_prompt
30
+
31
+ return build_chat_mode_system_prompt("simple")
32
+
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════════
35
+ # @<경로> Context Injection (이슈 #259 — codex CLI 처럼 `@` 는 파일 지정 하나)
36
+ # ═══════════════════════════════════════════════════════════════════════════
37
+ FILE_CONTEXT_TEMPLATE = """The following is the content of the file `{filepath}`:
38
+
39
+ ```
40
+ {content}
41
+ ```"""
42
+
43
+ # `@경로` · `@"공백 경로"` · 옛 `@file:경로` (손으로 친 옛 형식).
44
+ # 작은따옴표는 경로 구분자로 쓰지 않는다 — `@'s value ... it's` 같은 영어 문장을 잡는다.
45
+ _FILE_CMD_PATTERN = re.compile(
46
+ r"(?<!\S)@(?P<legacy>file:)?"
47
+ r"(?:\"(?P<dq>[^\"\n]+)\"|(?P<bare>[^\s\"'`\\]+(?:\\ [^\s\\]*)*))"
48
+ )
49
+
50
+ # 경로 뒤에 붙은 구두점·조사(`@a.py,` · `@a.py를`)만 떼어 본다. 영문·숫자는 떼지 않는다 —
51
+ # `@property` 가 `prop` 파일로, `@a.pyc` 가 `a.py` 로 풀리면 안 된다.
52
+ _TRAILING_TRIM = re.compile(r"[.,;:!?)\]}가-힣]{1,4}$")
53
+ _MAX_FILE_CHARS = 100_000
54
+ # 노트북은 JSON 전체를 파싱해야 셀을 합칠 수 있어 읽기 전에 크기로 막는다 (출력 이미지가 든 수백 MB 파일)
55
+ _MAX_NOTEBOOK_BYTES = 20 * 1024 * 1024
56
+
57
+
58
+ def _resolve_mention(arg: str, base_dir: str) -> str:
59
+ filepath = arg if os.path.isabs(arg) else os.path.join(base_dir, arg)
60
+ return os.path.normpath(filepath)
61
+
62
+
63
+ def _inside_root(filepath: str, base_dir: str) -> bool:
64
+ """멘션은 기준 디렉터리 안의 파일만 읽는다 — `..`·절대경로·심볼릭 링크로 밖을 못 읽는다.
65
+
66
+ 파서는 사람이 친 글만이 아니라 붙여 넣은 코드·노트북 본문도 훑는다. 그 안의
67
+ `@../../.aws/credentials` 가 LLM 으로 새면 안 된다 (read-file API 와 같은 경계).
68
+ """
69
+ root = os.path.realpath(base_dir)
70
+ return os.path.commonpath([root, os.path.realpath(filepath)]) == root
71
+
72
+
73
+ def _read_mention_file(filepath: str) -> str:
74
+ if filepath.endswith(".ipynb") and os.path.getsize(filepath) > _MAX_NOTEBOOK_BYTES:
75
+ raise ValueError(f"notebook too large (> {_MAX_NOTEBOOK_BYTES} bytes)")
76
+ with open(filepath, "r", encoding="utf-8") as f:
77
+ if not filepath.endswith(".ipynb"):
78
+ # 전체를 읽고 자르지 않는다 — 수 GB 파일이 메모리에 통째로 올라온다
79
+ content = f.read(_MAX_FILE_CHARS + 1)
80
+ if len(content) > _MAX_FILE_CHARS:
81
+ content = content[:_MAX_FILE_CHARS] + "\n\n... [truncated]"
82
+ return content
83
+ content = f.read()
84
+ try:
85
+ import nbformat
86
+
87
+ nb = nbformat.reads(content, as_version=4)
88
+ content = "\n\n".join(
89
+ f"# Cell {i + 1} ({c.cell_type}):\n{c.source}"
90
+ for i, c in enumerate(nb.cells)
91
+ )
92
+ except Exception:
93
+ pass # Use raw content if parsing fails
94
+ if len(content) > _MAX_FILE_CHARS:
95
+ content = (
96
+ content[:_MAX_FILE_CHARS]
97
+ + f"\n\n... [truncated, {len(content) - _MAX_FILE_CHARS} more characters]"
98
+ )
99
+ return content
100
+
101
+
102
+ def _process_file_commands(
103
+ message: str, base_dir: str = "."
104
+ ) -> Tuple[str, str, List[str]]:
105
+ """Process `@<경로>` file mentions in a user message.
106
+
107
+ 기준 디렉터리 안의 실제 파일로 풀리는 멘션만 내용을 싣고 `'경로'` 로 바꾼다. `@notebook`·`@property`·
108
+ 이메일·없는 경로·디렉터리는 원문 그대로 두고 오류도 내지 않는다 — 코드에 흔한
109
+ 데코레이터가 파일 조회 오류로 번지지 않게 한다. 파일이라고 **명시한** 옛 `@file:`
110
+ 과 따옴표 경로만 없을 때 오류를 낸다.
111
+
112
+ Args:
113
+ message: User message potentially containing @<경로> mentions
114
+ base_dir: Base directory for resolving relative paths
115
+
116
+ Returns:
117
+ Tuple of (file_context, cleaned_message, errors)
118
+ """
119
+ context_parts: List[str] = []
120
+ errors: List[str] = []
121
+ pieces: List[str] = []
122
+ pos = 0
123
+
124
+ for match in _FILE_CMD_PATTERN.finditer(message):
125
+ explicit = bool(match.group("legacy")) or match.group("bare") is None
126
+ raw = match.group("dq") or match.group("bare") or ""
127
+ arg, suffix = raw.replace("\\ ", " "), ""
128
+ filepath = _resolve_mention(arg, base_dir)
129
+
130
+ trail = _TRAILING_TRIM.search(arg)
131
+ if match.group("bare") is not None and not os.path.isfile(filepath) and trail:
132
+ for n in range(1, min(len(trail.group()), len(arg) - 1) + 1):
133
+ candidate = _resolve_mention(arg[:-n], base_dir)
134
+ if os.path.isfile(candidate):
135
+ arg, suffix, filepath = arg[:-n], arg[-n:], candidate
136
+ break
137
+
138
+ if not _inside_root(filepath, base_dir):
139
+ if explicit:
140
+ errors.append(f"Path outside workspace: {arg}")
141
+ continue
142
+
143
+ if not os.path.isfile(filepath):
144
+ if explicit:
145
+ if os.path.isdir(filepath):
146
+ errors.append(f"Cannot read directory: {filepath}")
147
+ else:
148
+ errors.append(f"File not found: {filepath}")
149
+ continue
150
+
151
+ try:
152
+ content = _read_mention_file(filepath)
153
+ except UnicodeDecodeError:
154
+ errors.append(f"Cannot read file (encoding error): {filepath}")
155
+ continue
156
+ except PermissionError:
157
+ errors.append(f"Permission denied: {filepath}")
158
+ continue
159
+ except Exception as e:
160
+ errors.append(f"Error reading {filepath}: {e}")
161
+ continue
162
+
163
+ context_parts.append(
164
+ FILE_CONTEXT_TEMPLATE.format(filepath=filepath, content=content)
165
+ )
166
+ pieces.append(message[pos : match.start()])
167
+ pieces.append(f"'{arg}'{suffix}")
168
+ pos = match.end()
169
+ logger.info(f"Processed file mention: {match.group()}")
170
+
171
+ pieces.append(message[pos:])
172
+ combined_context = "\n\n".join(context_parts) if context_parts else ""
173
+ return combined_context, "".join(pieces), errors
174
+
175
+
176
+ # ═══════════════════════════════════════════════════════════════════════════
177
+ # Summarization Prompt (Claude Code Benchmark)
178
+ # ═══════════════════════════════════════════════════════════════════════════
179
+ SUMMARIZATION_PROMPT = """다음 대화 내용을 요약하여 향후 컨텍스트 윈도우에서 작업을 효율적으로 재개할 수 있도록 해주세요.
180
+ 이 요약은 대화 히스토리를 대체하므로, 구조화되고 간결하며 실행 가능해야 합니다.
181
+
182
+ 요약에 반드시 포함할 내용:
183
+
184
+ ## 완료된 작업
185
+ - 완료된 태스크와 주요 결과물
186
+ - 생성/수정된 파일 목록
187
+
188
+ ## 현재 상태
189
+ - 진행 중인 작업
190
+ - 마지막으로 논의된 주제
191
+
192
+ ## 다음 단계
193
+ - 명확한 후속 액션 항목
194
+ - 보류 중인 결정 사항
195
+
196
+ ## 핵심 맥락
197
+ - 사용자 선호사항 및 제약조건
198
+ - 중요한 기술적 결정사항
199
+ - 작업 재개에 필수적인 정보
200
+
201
+ 작성 지침:
202
+ - 간결하되, 작업이 끊김 없이 계속될 수 있을 정도의 세부사항은 보존
203
+ - 불필요한 인사말, 확인 메시지, 중복 내용은 제외
204
+ - 코드 스니펫은 핵심적인 경우에만 포함
205
+ - 한국어로 작성
206
+
207
+ 대화 내용:
208
+ {conversation}
209
+
210
+ 요약:"""
211
+
212
+ # ═══════════════════════════════════════════════════════════════════════════
213
+ # Auto-Compact Configuration (Aligned with Agent mode SummarizationMiddleware)
214
+ # ═══════════════════════════════════════════════════════════════════════════
215
+ AUTO_COMPACT_TOKEN_THRESHOLD = 30000 # Trigger when tokens exceed this threshold
216
+ AUTO_COMPACT_KEEP_MESSAGES = 15 # Keep last N messages after compaction
217
+
218
+ # Token estimation
219
+ CHARS_PER_TOKEN = 3.5
220
+
221
+
222
+ def _request_file_context(request: ChatRequest) -> Tuple[str, str, List[str]]:
223
+ """요청의 `@<경로>` 멘션을 푼다. 앱이 만든 프롬프트(`fileMentions=False`)는 그대로 둔다."""
224
+ if not request.fileMentions:
225
+ return "", request.message, []
226
+ return _process_file_commands(
227
+ request.message, base_dir=request.workspaceRoot or os.getcwd()
228
+ )
229
+
230
+
231
+ def _estimate_tokens(text: str) -> int:
232
+ """Estimate token count from text length."""
233
+ return int(len(text) / CHARS_PER_TOKEN)
234
+
235
+
236
+ def _estimate_session_tokens(messages) -> int:
237
+ """Estimate total tokens in session messages."""
238
+ total_chars = sum(len(m.content) for m in messages)
239
+ return int(total_chars / CHARS_PER_TOKEN)
240
+
241
+
242
+ class EmbeddedChatService(IChatService):
243
+ """
244
+ 채팅 서비스 구현.
245
+
246
+ 인프로세스에서 직접 채팅 로직을 실행합니다.
247
+ """
248
+
249
+ def __init__(self):
250
+ """Embedded 채팅 서비스 초기화"""
251
+ self._config_manager = get_config_manager()
252
+ self._session_manager = get_session_manager()
253
+ # RAG agent checkpointer: ToolMessage(스킬 내용 등)를 turn 간 유지
254
+ self._rag_checkpointer = None # lazy init
255
+ self._rag_initialized_threads: set = set()
256
+ # RAG agent 캐시: conversation별로 같은 graph 인스턴스 재사용
257
+ self._rag_agents: Dict[str, Any] = {} # conv_id → RAGChatAgent
258
+ logger.info("EmbeddedChatService 초기화됨")
259
+
260
+ def _get_config(self) -> Dict[str, Any]:
261
+ """현재 LLM 설정 반환"""
262
+ return self._config_manager.get_config()
263
+
264
+ def _build_llm_config(self, llm_config) -> Dict[str, Any]:
265
+ """LLM 설정 — 서버가 해석한 값만 쓴다 (이슈 #113).
266
+
267
+ 접속 설정은 centri > 시크릿 env > pod 파일이고, 브라우저가 보낸 `llmConfig` 의
268
+ 접속 정보는 받지 않는다. 인자는 호출부 호환을 위해 남겼다.
269
+ """
270
+ return self._get_config()
271
+
272
+ def _get_or_create_conversation(self, conversation_id: Optional[str]) -> str:
273
+ """기존 대화 가져오기 또는 새로 생성"""
274
+ session = self._session_manager.get_or_create_session(conversation_id)
275
+ return session.id
276
+
277
+ def _build_context(
278
+ self, conversation_id: str, max_messages: int = 5
279
+ ) -> Optional[str]:
280
+ """이력에서 대화 컨텍스트 구성"""
281
+ return self._session_manager.build_context(conversation_id, max_messages)
282
+
283
+ def _store_messages(
284
+ self, conversation_id: str, user_message: str, assistant_response: str
285
+ ) -> None:
286
+ """대화 이력에 사용자 및 어시스턴트 메시지 저장"""
287
+ self._session_manager.store_messages(
288
+ conversation_id, user_message, assistant_response
289
+ )
290
+
291
+ async def _auto_compact_if_needed(
292
+ self, conversation_id: str, llm_config: Dict[str, Any]
293
+ ) -> Optional[str]:
294
+ """토큰 임계값 초과 시 자동 압축.
295
+
296
+ Agent 모드 SummarizationMiddleware와 동일 전략:
297
+ - 트리거: 토큰 수 > AUTO_COMPACT_TOKEN_THRESHOLD
298
+ - 유지: 최근 AUTO_COMPACT_KEEP_MESSAGES개 메시지
299
+ - 결과: [요약] + [최근 메시지]
300
+
301
+ Returns:
302
+ 압축된 경우 요약 문자열, 아니면 None
303
+ """
304
+ session = self._session_manager.get_session(conversation_id)
305
+ if not session or not session.messages:
306
+ return None
307
+
308
+ estimated_tokens = _estimate_session_tokens(session.messages)
309
+
310
+ if estimated_tokens <= AUTO_COMPACT_TOKEN_THRESHOLD:
311
+ return None
312
+
313
+ if len(session.messages) <= AUTO_COMPACT_KEEP_MESSAGES:
314
+ return None
315
+
316
+ logger.info(
317
+ f"[Chat] Auto-compact triggered for {conversation_id}: "
318
+ f"~{estimated_tokens} tokens > {AUTO_COMPACT_TOKEN_THRESHOLD} threshold, "
319
+ f"{len(session.messages)} messages"
320
+ )
321
+
322
+ try:
323
+ messages_to_summarize = session.messages[:-AUTO_COMPACT_KEEP_MESSAGES]
324
+ recent_messages = session.messages[-AUTO_COMPACT_KEEP_MESSAGES:]
325
+
326
+ conversation_text = "\n".join(
327
+ [
328
+ f"{'사용자' if m.role == 'user' else '어시스턴트'}: {m.content}"
329
+ for m in messages_to_summarize
330
+ ]
331
+ )
332
+
333
+ llm_service = LLMService(llm_config)
334
+ prompt = SUMMARIZATION_PROMPT.format(conversation=conversation_text)
335
+ summary = await llm_service.generate_response(prompt)
336
+
337
+ summary_message = ChatMessage(
338
+ role="assistant",
339
+ content=f"[이전 대화 요약]\n\n{summary}",
340
+ timestamp=datetime.now().timestamp(),
341
+ )
342
+
343
+ original_count = len(session.messages)
344
+ session.messages = [summary_message] + list(recent_messages)
345
+ session.updated_at = datetime.now().timestamp()
346
+ self._session_manager._save_sessions()
347
+
348
+ compressed_count = len(session.messages)
349
+ logger.info(
350
+ f"[Chat] Auto-compacted {conversation_id}: "
351
+ f"{original_count} -> {compressed_count} messages"
352
+ )
353
+
354
+ return summary
355
+
356
+ except Exception as e:
357
+ logger.error(f"[Chat] Auto-compact failed: {e}", exc_info=True)
358
+ return None
359
+
360
+ async def send_message(self, request: ChatRequest) -> ChatResponse:
361
+ """채팅 메시지를 보내고 응답 받기"""
362
+ logger.info(f"[Embedded] 채팅 메시지: {request.message[:100]}...")
363
+
364
+ config = self._build_llm_config(request.llmConfig)
365
+
366
+ if not config or not config.get("provider"):
367
+ raise ValueError(
368
+ "LLM이 설정되지 않음. API 키가 포함된 llmConfig를 제공하세요."
369
+ )
370
+
371
+ # 대화 가져오기 또는 생성
372
+ conversation_id = self._get_or_create_conversation(request.conversationId)
373
+
374
+ # @<경로> 컨텍스트 처리 — 핸들러가 채운 워크스페이스 루트 기준 (이슈 #259)
375
+ file_context, cleaned_message, context_errors = _request_file_context(request)
376
+ if context_errors:
377
+ logger.warning(f"Context processing errors: {context_errors}")
378
+
379
+ # 이력에서 컨텍스트 구성
380
+ history_context = self._build_context(conversation_id)
381
+
382
+ # 파일 컨텍스트 + 이력 컨텍스트 결합
383
+ all_context_parts = []
384
+ if file_context:
385
+ all_context_parts.append(file_context)
386
+ if history_context:
387
+ all_context_parts.append(history_context)
388
+ combined_context = "\n\n".join(all_context_parts) if all_context_parts else None
389
+
390
+ # LLM 호출
391
+ llm_service = LLMService(config)
392
+ response = await llm_service.generate_response(
393
+ cleaned_message,
394
+ context=combined_context,
395
+ system_prompt=_chat_system_prompt(),
396
+ )
397
+
398
+ # 메시지 저장 (cleaned_message 사용)
399
+ self._store_messages(conversation_id, cleaned_message, response)
400
+
401
+ # 자동 압축 (토큰 임계값 초과 시)
402
+ await self._auto_compact_if_needed(conversation_id, config)
403
+
404
+ # 모델 정보 가져오기
405
+ provider = config.get("provider", "unknown")
406
+ model = config.get(provider, {}).get("model", "unknown")
407
+
408
+ return ChatResponse(
409
+ response=response,
410
+ conversationId=conversation_id,
411
+ model=f"{provider}/{model}",
412
+ )
413
+
414
+ async def compact(self, body: Dict[str, Any]) -> Dict[str, Any]:
415
+ """Compact conversation history by summarizing older messages.
416
+
417
+ Strategy (Claude Code benchmark):
418
+ - Keep the last 3 messages intact
419
+ - Summarize all older messages using LLM
420
+ - Replace history with [summary] + [last 3 messages]
421
+ """
422
+ conversation_id = body.get("conversationId")
423
+ if not conversation_id:
424
+ return {
425
+ "success": False,
426
+ "message": "conversationId가 필요합니다.",
427
+ "originalMessages": 0,
428
+ "compressedMessages": 0,
429
+ }
430
+
431
+ session = self._session_manager.get_session(conversation_id)
432
+ if not session:
433
+ return {
434
+ "success": False,
435
+ "message": "세션을 찾을 수 없습니다.",
436
+ "originalMessages": 0,
437
+ "compressedMessages": 0,
438
+ }
439
+
440
+ original_count = len(session.messages)
441
+
442
+ # Already minimal - no compaction needed
443
+ if original_count <= 3:
444
+ return {
445
+ "success": True,
446
+ "message": "이미 최소 상태입니다. 압축이 필요하지 않습니다.",
447
+ "originalMessages": original_count,
448
+ "compressedMessages": original_count,
449
+ }
450
+
451
+ try:
452
+ # Split messages: older (to summarize) vs recent (to keep)
453
+ messages_to_summarize = session.messages[:-3]
454
+ recent_messages = session.messages[-3:]
455
+
456
+ # Build conversation text for summarization
457
+ conversation_text = "\n".join(
458
+ [
459
+ f"{'사용자' if m.role == 'user' else '어시스턴트'}: {m.content}"
460
+ for m in messages_to_summarize
461
+ ]
462
+ )
463
+
464
+ # Get LLM config from body or fallback to server config
465
+ llm_config = body.get("llmConfig") or self._get_config()
466
+ if isinstance(llm_config, dict) and "provider" not in llm_config:
467
+ llm_config = self._get_config()
468
+
469
+ # Generate summary using LLMService
470
+ llm_service = LLMService(llm_config)
471
+ prompt = SUMMARIZATION_PROMPT.format(conversation=conversation_text)
472
+ summary = await llm_service.generate_response(prompt)
473
+
474
+ # Create summary message
475
+ summary_message = ChatMessage(
476
+ role="assistant",
477
+ content=f"[이전 대화 요약]\n\n{summary}",
478
+ timestamp=datetime.now().timestamp(),
479
+ )
480
+
481
+ # Replace session messages: [summary] + [last 3]
482
+ session.messages = [summary_message] + list(recent_messages)
483
+ session.updated_at = datetime.now().timestamp()
484
+
485
+ # Persist changes
486
+ self._session_manager._save_sessions()
487
+
488
+ compressed_count = len(session.messages)
489
+ logger.info(
490
+ f"Compacted conversation {conversation_id}: "
491
+ f"{original_count} -> {compressed_count} messages"
492
+ )
493
+
494
+ return {
495
+ "success": True,
496
+ "message": f"대화가 압축되었습니다. ({original_count}개 → {compressed_count}개 메시지)",
497
+ "originalMessages": original_count,
498
+ "compressedMessages": compressed_count,
499
+ "summary": summary,
500
+ }
501
+
502
+ except Exception as e:
503
+ logger.error(f"Compact failed: {e}", exc_info=True)
504
+ return {
505
+ "success": False,
506
+ "message": f"압축 중 오류가 발생했습니다: {str(e)}",
507
+ "originalMessages": original_count,
508
+ "compressedMessages": original_count,
509
+ }
510
+
511
+ async def send_message_stream(
512
+ self, request: ChatRequest
513
+ ) -> AsyncGenerator[Dict[str, Any], None]:
514
+ """채팅 메시지를 보내고 스트리밍 응답 받기"""
515
+ logger.info(f"[Embedded] 채팅 스트림: {request.message[:100]}...")
516
+
517
+ # AWS Secrets Manager 연결정보 로드 실패 시 여기서 사용자에게 알림
518
+ # (기동은 정상 진행되고, 실제 채팅 시도 시점에만 노출)
519
+ _secret_err = os.environ.get("CODEBUFF_SECRET_LOAD_ERROR")
520
+ if _secret_err:
521
+ yield {
522
+ "error": (
523
+ "연결 설정을 불러오지 못했습니다 (AWS Secrets Manager). "
524
+ "관리자에게 문의하세요. "
525
+ f"[{_secret_err}]"
526
+ )
527
+ }
528
+ return
529
+
530
+ config = self._build_llm_config(request.llmConfig)
531
+
532
+ if not config or not config.get("provider"):
533
+ yield {
534
+ "error": "LLM이 설정되지 않음. API 키가 포함된 llmConfig를 제공하세요."
535
+ }
536
+ return
537
+
538
+ # 대화 가져오기 또는 생성
539
+ conversation_id = self._get_or_create_conversation(request.conversationId)
540
+
541
+ # /reset 명령: agent 캐시 + checkpointer state 초기화
542
+ if request.message.strip() == "/reset":
543
+ self._reset_rag_conversation(conversation_id)
544
+ yield {
545
+ "content": "대화가 초기화되었습니다.",
546
+ "done": True,
547
+ "conversationId": conversation_id,
548
+ "reset": True,
549
+ }
550
+ return
551
+
552
+ # @<경로> 컨텍스트 처리 — 핸들러가 채운 워크스페이스 루트 기준 (이슈 #259)
553
+ file_context, cleaned_message, context_errors = _request_file_context(request)
554
+
555
+ # 컨텍스트 에러를 경고로 전송
556
+ if context_errors:
557
+ yield {
558
+ "warning": "Context errors: " + "; ".join(context_errors),
559
+ "done": False,
560
+ }
561
+
562
+ # === PII Guardrail ===
563
+ # 사용자 메시지에서 PII 14종 검출 → 동일 길이 '*' 마스킹.
564
+ # 마스킹된 cleaned_message가 이후 모든 경로(RAG agent, 일반 LLM, 저장)로 흐름.
565
+ mask = mask_pii(cleaned_message)
566
+ if mask.has_pii:
567
+ logger.info(
568
+ "[PII] masked types=%s count=%d",
569
+ mask.detected_types,
570
+ len(mask.matches),
571
+ )
572
+ cleaned_message = mask.masked_text
573
+ yield {
574
+ "type": "input_masked",
575
+ "masked_text": mask.masked_text,
576
+ "detected_types": mask.detected_types,
577
+ "done": False,
578
+ }
579
+
580
+ # RAG agent 라우팅 — simple/knowledge 모두 RAG agent 경로 사용 (컨텍스트 공유)
581
+ # simple 모드: no_tools=True + prefix 주입으로 도구 호출 차단
582
+ # checkpointer가 대화 state를 관리하므로 history_context는 주입하지 않음
583
+ chat_mode = request.chatMode
584
+ if self._should_use_rag_agent():
585
+ async for chunk in self._stream_via_rag_agent(
586
+ config,
587
+ conversation_id,
588
+ cleaned_message,
589
+ file_context or None,
590
+ chat_mode=chat_mode,
591
+ ):
592
+ yield chunk
593
+ return
594
+
595
+ # 이력 컨텍스트 구성 (기존 LLM 경로에서만 사용)
596
+ history_context = self._build_context(conversation_id)
597
+
598
+ # 파일 컨텍스트 + 이력 컨텍스트 결합
599
+ all_context_parts = []
600
+ if file_context:
601
+ all_context_parts.append(file_context)
602
+ if history_context:
603
+ all_context_parts.append(history_context)
604
+ combined_context = "\n\n".join(all_context_parts) if all_context_parts else None
605
+
606
+ # LLM 응답 스트리밍 (기존 경로)
607
+ llm_service = LLMService(config)
608
+ full_response = ""
609
+
610
+ max_tokens = request.maxTokens or self._config_manager.get(
611
+ "maxOutputTokens", DEFAULT_MAX_OUTPUT_TOKENS
612
+ )
613
+
614
+ try:
615
+ async for chunk in llm_service.generate_response_stream(
616
+ cleaned_message,
617
+ context=combined_context,
618
+ max_tokens=max_tokens,
619
+ system_prompt=_chat_system_prompt(),
620
+ ):
621
+ full_response += chunk
622
+ yield {"content": chunk, "done": False}
623
+
624
+ # 스트리밍 완료 후 메시지 저장 (cleaned_message 사용)
625
+ self._store_messages(conversation_id, cleaned_message, full_response)
626
+
627
+ # 자동 압축 체크 (토큰 임계값 초과 시)
628
+ session = self._session_manager.get_session(conversation_id)
629
+ if session and session.messages:
630
+ estimated_tokens = _estimate_session_tokens(session.messages)
631
+ if (
632
+ estimated_tokens > AUTO_COMPACT_TOKEN_THRESHOLD
633
+ and len(session.messages) > AUTO_COMPACT_KEEP_MESSAGES
634
+ ):
635
+ yield {
636
+ "status": "대화 컨텍스트 요약 중...",
637
+ "icon": "thinking",
638
+ "done": False,
639
+ }
640
+ summary = await self._auto_compact_if_needed(
641
+ conversation_id, config
642
+ )
643
+ if summary:
644
+ yield {
645
+ "status": "대화가 자동으로 압축되었습니다.",
646
+ "icon": "check",
647
+ "done": False,
648
+ }
649
+
650
+ # 마지막 청크 전송
651
+ yield {"content": "", "done": True, "conversationId": conversation_id}
652
+
653
+ except Exception as e:
654
+ logger.error(f"스트림 채팅 실패: {e}", exc_info=True)
655
+ yield {"error": str(e)}
656
+
657
+ # ═══════════════════════════════════════════════════════════════════════
658
+ # RAG Agent 라우팅
659
+ # ═══════════════════════════════════════════════════════════════════════
660
+
661
+ @staticmethod
662
+ def _should_use_rag_agent() -> bool:
663
+ """RAG가 가용한지 확인. 매니저가 미등록이면 초기화 시도."""
664
+ from codebuff_core.services.rag_collections import is_rag_available
665
+
666
+ if is_rag_available():
667
+ return True
668
+
669
+ # 서버 재시작 후 RAG Admin 페이지를 안 열었을 때를 위한 자동 초기화
670
+ try:
671
+ from jupyter_ext.handlers.rag_admin_handler import _ensure_manager
672
+
673
+ _ensure_manager()
674
+ return is_rag_available()
675
+ except Exception as e:
676
+ logger.warning("RAG 자동 초기화 실패 (기존 LLM 경로 사용): %s", e)
677
+ return False
678
+
679
+ def _reset_rag_conversation(self, conversation_id: str) -> None:
680
+ """대화 초기화: agent 캐시 + checkpointer state + initialized_threads 정리."""
681
+ # agent 캐시에서 해당 conversation 제거
682
+ self._rag_agents.pop(conversation_id, None)
683
+ # initialized_threads에서 제거 (다음 턴에서 first_turn으로 처리)
684
+ self._rag_initialized_threads.discard(conversation_id)
685
+ # checkpointer에서 해당 thread state 제거
686
+ if self._rag_checkpointer is not None:
687
+ try:
688
+ storage = getattr(self._rag_checkpointer, "storage", None)
689
+ if storage and isinstance(storage, dict):
690
+ storage.pop(conversation_id, None)
691
+ except Exception:
692
+ pass
693
+ logger.info(f"[RAG-Chat] conversation reset: {conversation_id[:12]}...")
694
+
695
+ async def _generate_table_clarification(
696
+ self,
697
+ config: Dict[str, Any],
698
+ candidates: dict,
699
+ confirmed_tables: Optional[list] = None,
700
+ ) -> str:
701
+ """테이블 후보를 LLM에 전달하여 사용자에게 되묻는 자연스러운 질문 생성."""
702
+ try:
703
+ # 후보 정보를 간결하게 정리
704
+ candidate_info = []
705
+ for query, tables in candidates.items():
706
+ group = {"검색어": query, "후보": []}
707
+ for t in tables[:5]:
708
+ group["후보"].append(
709
+ {
710
+ "테이블명": t.get("table_name", ""),
711
+ "한글명": t.get("korean_name", ""),
712
+ "설명": t.get("description", "")[:100],
713
+ "score": t.get("score", 0),
714
+ }
715
+ )
716
+ candidate_info.append(group)
717
+
718
+ confirmed_info = []
719
+ if confirmed_tables:
720
+ for t in confirmed_tables:
721
+ confirmed_info.append(
722
+ {
723
+ "테이블명": t.get("table_name", ""),
724
+ "한글명": t.get("korean_name", ""),
725
+ }
726
+ )
727
+
728
+ import json as _json
729
+
730
+ prompt = (
731
+ "사용자가 테이블을 검색했는데 여러 후보가 나왔습니다. "
732
+ "사용자에게 어떤 테이블을 의미하는지 자연스럽게 되물어주세요.\n\n"
733
+ "규칙:\n"
734
+ "- 각 후보 테이블의 테이블명, 한글명, 설명을 간단히 소개하세요.\n"
735
+ "- 사용자가 쉽게 선택할 수 있도록 번호나 테이블명으로 안내하세요.\n"
736
+ "- 자동 확인된 테이블이 있으면 별도로 안내하세요.\n"
737
+ "- 마크다운 형식으로 작성하세요.\n\n"
738
+ f"[후보 테이블]\n{_json.dumps(candidate_info, ensure_ascii=False)}\n\n"
739
+ )
740
+ if confirmed_info:
741
+ prompt += (
742
+ f"[자동 확인된 테이블]\n"
743
+ f"{_json.dumps(confirmed_info, ensure_ascii=False)}\n\n"
744
+ )
745
+
746
+ llm_service = LLMService(config)
747
+ response = await llm_service.generate_response(prompt)
748
+ return response.strip()
749
+ except Exception as e:
750
+ logger.warning(f"테이블 후보 질문 생성 실패: {e}")
751
+ # fallback: 간단한 텍스트
752
+ lines = [
753
+ "검색 결과 여러 테이블이 발견되었습니다. 어떤 테이블을 의미하셨나요?\n"
754
+ ]
755
+ for query, tables in candidates.items():
756
+ lines.append(f"**{query}**")
757
+ for t in tables[:5]:
758
+ tname = t.get("table_name", "")
759
+ kname = t.get("korean_name", "")
760
+ lines.append(f"- `{tname}` ({kname})")
761
+ lines.append("")
762
+ if confirmed_tables:
763
+ lines.append("자동 확인된 테이블:")
764
+ for t in confirmed_tables:
765
+ lines.append(
766
+ f"- `{t.get('table_name', '')}` ({t.get('korean_name', '')})"
767
+ )
768
+ lines.append("\n원하시는 테이블명을 알려주세요.")
769
+ return "\n".join(lines)
770
+
771
+ def _build_history_messages(
772
+ self, conversation_id: str, limit: int = 10
773
+ ) -> Optional[List[Dict[str, str]]]:
774
+ """SessionManager 이력을 RAGChatAgent용 메시지 리스트로 변환."""
775
+ session = self._session_manager.get_session(conversation_id)
776
+ if not session or not session.messages:
777
+ return None
778
+ recent = session.messages[-limit:]
779
+ return [{"role": m.role, "content": m.content} for m in recent]
780
+
781
+ def _get_rag_checkpointer(self):
782
+ """RAG agent용 MemorySaver를 lazy 초기화."""
783
+ if self._rag_checkpointer is None:
784
+ from langgraph.checkpoint.memory import MemorySaver
785
+
786
+ self._rag_checkpointer = MemorySaver()
787
+ return self._rag_checkpointer
788
+
789
+ async def _stream_via_rag_agent(
790
+ self,
791
+ config: Dict[str, Any],
792
+ conversation_id: str,
793
+ cleaned_message: str,
794
+ combined_context: Optional[str],
795
+ chat_mode: Optional[str] = None,
796
+ ) -> AsyncGenerator[Dict[str, Any], None]:
797
+ """RAGChatAgent를 통해 스트리밍 응답.
798
+
799
+ MemorySaver checkpointer로 turn 간 LangGraph state 유지.
800
+ 첫 turn에만 SessionManager 이력을 전달 (서버 재시작 후 연속성).
801
+ 이후 turn에서는 checkpointer가 ToolMessage 포함 전체 state 유지.
802
+ """
803
+ from codebuff_core.services.rag_chat_agent import (
804
+ RAGChatAgent,
805
+ )
806
+
807
+ try:
808
+ checkpointer = self._get_rag_checkpointer()
809
+ is_first_turn = conversation_id not in self._rag_initialized_threads
810
+ is_simple = chat_mode == "simple"
811
+
812
+ logger.info(
813
+ f"[RAG-Chat] conv={conversation_id[:12]}... "
814
+ f"first_turn={is_first_turn} mode={chat_mode} "
815
+ f"initialized_threads={len(self._rag_initialized_threads)}"
816
+ )
817
+
818
+ # agent graph 캐시: 같은 conversation에서는 같은 graph 재사용
819
+ # (graph namespace가 달라지면 checkpointer에서 이전 state를 못 찾음)
820
+ # chat_mode를 키에 포함하지 않음 — 사용자가 모드를 전환해도 대화 연속성 유지
821
+ agent = self._rag_agents.get(conversation_id)
822
+ if agent is None:
823
+ agent = RAGChatAgent(
824
+ config,
825
+ checkpointer=checkpointer,
826
+ table_score_threshold=0.7,
827
+ table_confirm_mode="chat",
828
+ )
829
+ # 캐시 크기 제한 (가장 오래된 것 제거)
830
+ if len(self._rag_agents) > 50:
831
+ oldest = next(iter(self._rag_agents))
832
+ del self._rag_agents[oldest]
833
+ self._rag_agents[conversation_id] = agent
834
+
835
+ effective_message = cleaned_message
836
+
837
+ # 첫 turn: SessionManager 이력 전달 (서버 재시작 후 연속성)
838
+ # 이후: checkpointer가 state 유지하므로 history 불필요
839
+ history = (
840
+ self._build_history_messages(conversation_id) if is_first_turn else None
841
+ )
842
+
843
+ # chat 모드(simple): simple 프롬프트를 phase로 직접 주입 — codex 와 같은 빌더
844
+ # (centri `prompts.chat_system_prompt` 반영, 이슈 #178)
845
+ # RAG enable 모드(knowledge): None → agent가 _build_system_prompt() 사용
846
+ _phase_prompt = None
847
+ if is_simple:
848
+ from codebuff_core.services.rag_chat_agent import (
849
+ build_chat_mode_system_prompt,
850
+ )
851
+
852
+ _phase_prompt = build_chat_mode_system_prompt("simple")
853
+
854
+ full_response = ""
855
+ async for chunk in agent.stream_response(
856
+ effective_message,
857
+ history,
858
+ combined_context,
859
+ thread_id=conversation_id,
860
+ phase_system_prompt=_phase_prompt,
861
+ block_tools=is_simple,
862
+ ):
863
+ if "content" in chunk:
864
+ full_response += chunk.get("content", "")
865
+ yield chunk
866
+
867
+ self._rag_initialized_threads.add(conversation_id)
868
+
869
+ # 텍스트만 SessionManager에 저장 (서버 재시작 시 복원용)
870
+ self._store_messages(conversation_id, cleaned_message, full_response)
871
+
872
+ # 완료 청크
873
+ yield {"content": "", "done": True, "conversationId": conversation_id}
874
+
875
+ except Exception as e:
876
+ logger.error(f"RAG agent 스트림 실패: {e}", exc_info=True)
877
+ # RAG agent 실패 시 기존 LLM 경로로 폴백
878
+ logger.info("RAG agent 실패, 기존 LLM 스트리밍으로 폴백")
879
+ llm_service = LLMService(config)
880
+ full_response = ""
881
+ try:
882
+ async for chunk in llm_service.generate_response_stream(
883
+ cleaned_message,
884
+ context=combined_context,
885
+ system_prompt=_chat_system_prompt(),
886
+ ):
887
+ full_response += chunk
888
+ yield {"content": chunk, "done": False}
889
+
890
+ self._store_messages(conversation_id, cleaned_message, full_response)
891
+ yield {
892
+ "content": "",
893
+ "done": True,
894
+ "conversationId": conversation_id,
895
+ }
896
+ except Exception as fallback_err:
897
+ logger.error(f"폴백 스트림도 실패: {fallback_err}", exc_info=True)
898
+ yield {"error": str(fallback_err)}