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