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,673 @@
|
|
|
1
|
+
"""centri 중앙 설정 동기화 (이슈 #113).
|
|
2
|
+
|
|
3
|
+
설정의 정본은 centri RDS(`codebuff.config_value`)이고, pod 는 agent server 의
|
|
4
|
+
`GET /api/v1/codebuff/config` 로 받아 쓴다. 계약은 `docs/설정RDS/계약.md` 가 정본이다.
|
|
5
|
+
|
|
6
|
+
- 조직 설정(프롬프트·가드레일·LLM·임베딩·codex 주소 등)은 centri 포털에서 전역 < 팀 <
|
|
7
|
+
프로젝트 < 사용자 단위로 정하고, centri 가 이 pod 의 팀·프로젝트·사용자에 맞게 해석해
|
|
8
|
+
내려준다(사용자 층은 이슈 #181).
|
|
9
|
+
- 개인설정은 codebuff 설정 패널에서 고치고, 저장은 centri 의 사용자 행(팀·프로젝트·사용자)
|
|
10
|
+
이다(`save_user`). 사용자는 centri 가 `KEYCLOAK_ID_TOKEN` 으로 판정한다 — pod 가 보낸
|
|
11
|
+
사용자 이름을 믿지 않는다. API 키는 어디에도 싣지 않는다(시크릿 env 전용).
|
|
12
|
+
|
|
13
|
+
- `CODEBUFF_CONFIG_URL` 이 없으면 완전한 no-op 이다 — 오늘과 같다.
|
|
14
|
+
- 데몬 스레드 1개가 주기적으로 받아 **스냅샷**만 교체한다. `ConfigManager` 는 스냅샷을
|
|
15
|
+
읽을 뿐이라 요청 경로에서 네트워크 I/O 가 없다 — centri 장애가 모든 요청의 타임아웃이
|
|
16
|
+
되지 않게 하려는 것이다.
|
|
17
|
+
- 실패(연결·타임아웃·4xx/5xx·비JSON·`schema_version` 불일치)는 마지막 성공 스냅샷을
|
|
18
|
+
유지한다. 성공 이력이 없으면 스냅샷이 없고, pod 파일 값을 그대로 쓴다 — 켜는 스위치는
|
|
19
|
+
시크릿 키가 아니라 centri 의 첫 200 이다.
|
|
20
|
+
- 인증은 codebuff↔centri 공유 키(`CODEX_SERVER_API_KEY`) 하나를 Bearer 로 보낸다.
|
|
21
|
+
- 검증은 필드 단위다. 타입·범위·허용 목록을 벗어난 필드만 버리고 나머지는 적용한다.
|
|
22
|
+
- 어떤 경우에도 예외를 밖으로 내지 않는다.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import threading
|
|
31
|
+
from datetime import datetime, timedelta, timezone
|
|
32
|
+
from functools import lru_cache
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Callable, Dict, Optional, Sequence, Tuple
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
SCHEMA_VERSION = 1
|
|
39
|
+
REFRESH_INTERVAL_SEC = 60.0
|
|
40
|
+
# 갱신 주기를 줄이는 env — centri 연동 스모크용 (이슈 #237). 1초 밑·숫자 아님은 기본값이다
|
|
41
|
+
REFRESH_ENV = "CODEBUFF_CONFIG_REFRESH_SEC"
|
|
42
|
+
TIMEOUT_SEC = 3.0
|
|
43
|
+
URL_ENV = "CODEBUFF_CONFIG_URL"
|
|
44
|
+
# codebuff↔centri 통신은 공유 키 하나로 인증한다 — codex 게이트웨이와 같은 `codex_api_key`
|
|
45
|
+
# (시크릿 `WDD/WDP-BDP-SCM-CODEBUFF`). centri 도 같은 시크릿에서 읽어 대조한다.
|
|
46
|
+
TOKEN_ENV = "CODEX_SERVER_API_KEY"
|
|
47
|
+
# 사용자 판정용 — centri 가 서명을 검증하고 `preferred_username` 을 사용자로 쓴다.
|
|
48
|
+
USER_TOKEN_ENV = "KEYCLOAK_ID_TOKEN"
|
|
49
|
+
USER_TOKEN_HEADER = "X-Codebuff-User-Token"
|
|
50
|
+
|
|
51
|
+
# 추론 엔진 — 조직 키 `features.inference_engine` 의 허용값 (이슈 #155).
|
|
52
|
+
# config_manager 가 이 모듈을 import 하므로 순환을 피하려고 여기 둔다.
|
|
53
|
+
INFERENCE_ENGINES = ("codex", "langchain")
|
|
54
|
+
|
|
55
|
+
_BOTH = INFERENCE_ENGINES
|
|
56
|
+
_LANGCHAIN = ("langchain",)
|
|
57
|
+
_CODEX = ("codex",)
|
|
58
|
+
|
|
59
|
+
# 프롬프트 키의 정본 — 키마다 (그 키를 읽는 엔진, 어디에 들어가나) (이슈 #208, #218).
|
|
60
|
+
# 기본 설정(`_default_config().prompts`)·서버 허용 목록·설정 화면의 "적용" 표시가 이것에서
|
|
61
|
+
# 나온다. 어느 엔진·자리에서도 안 읽는 키는 여기 둘 수 없다 — 그것이 폐기 기준이다.
|
|
62
|
+
# 키는 기능 단위다: 두 엔진이 같은 기능을 쓰면 같은 키를 읽는다. codex 전용 키는 codex 만의
|
|
63
|
+
# 층(스레드 base·Agent 지시문)뿐이고, langchain 전용 키는 기본 엔진(codex)에서 효과가 없다.
|
|
64
|
+
# 키와 내장 스킬을 가르는 기준 (이슈 #228): 스킬은 두 엔진 모두 요청할 때 읽힌다. 그래서 엔진
|
|
65
|
+
# 고유 규칙·파싱 표식·`{{RESOURCE_CONTEXT}}` 같은 자리표시자·매 턴 규칙·스킬 라우팅 안내는 키에
|
|
66
|
+
# 남기고, 분야에서만 필요하고 엔진 이름 없이 쓸 수 있는 지식은 스킬로 옮긴다. 옮긴 곳은 아래
|
|
67
|
+
# 설명(설정 화면 "적용:")에 스킬 이름으로 적는다.
|
|
68
|
+
PROMPT_KEY_SCOPES: Dict[str, Tuple[Tuple[str, ...], str]] = {
|
|
69
|
+
"chat_system_prompt": (_BOTH, "채팅 시스템 (사내 자료 검색 끔) — 시스템 프롬프트"),
|
|
70
|
+
"chat_rag_prompt": (
|
|
71
|
+
_BOTH,
|
|
72
|
+
"채팅 시스템 (사내 자료 검색 켬) — 검색 결과 뒤 근거 지시",
|
|
73
|
+
),
|
|
74
|
+
"planner": (_LANGCHAIN, "Agent — 메인 에이전트"),
|
|
75
|
+
"python_expert": (
|
|
76
|
+
_LANGCHAIN,
|
|
77
|
+
"Agent — python_expert 서브에이전트 (품질 원칙은 스킬 python_code_quality)",
|
|
78
|
+
),
|
|
79
|
+
"researcher": (_LANGCHAIN, "Agent — researcher 서브에이전트"),
|
|
80
|
+
"athena_query": (_LANGCHAIN, "Agent — athena_query 서브에이전트"),
|
|
81
|
+
"athena_query_prompt": (_BOTH, "Athena 쿼리 패널 — 쿼리 생성(1단계)"),
|
|
82
|
+
"airflow_expert": (
|
|
83
|
+
_LANGCHAIN,
|
|
84
|
+
"Agent — airflow_expert 서브에이전트 (작성 가이드는 스킬 airflow_dag_development)",
|
|
85
|
+
),
|
|
86
|
+
"emr_expert": (_LANGCHAIN, "Agent — emr_expert 서브에이전트"),
|
|
87
|
+
"lambda_expert": (_LANGCHAIN, "Agent — lambda_expert 서브에이전트"),
|
|
88
|
+
"spring_expert": (_LANGCHAIN, "Agent — spring_expert 서브에이전트"),
|
|
89
|
+
"bq_agent": (
|
|
90
|
+
_LANGCHAIN,
|
|
91
|
+
"Agent — bq_agent 서브에이전트 (응답 구조는 스킬 bigquery·bigquery_oracle)",
|
|
92
|
+
),
|
|
93
|
+
# 이슈 #178: codex Agent 밖 base 지시문, 사내 플랫폼 규칙(두 엔진 공용)
|
|
94
|
+
"codex_base_instructions": (_CODEX, "채팅 시스템 — codex 스레드 base 지시문"),
|
|
95
|
+
"bdp_platform_rules": (_BOTH, "사내 플랫폼 규칙 — 사내 환경에서만 붙는다"),
|
|
96
|
+
# 이슈 #208: BDP Q&A 본문, 셀 버튼·코드리뷰 템플릿(프론트가 호출마다 받아 쓴다)
|
|
97
|
+
"chat_qa_system_prompt": (
|
|
98
|
+
_BOTH,
|
|
99
|
+
"채팅 시스템 (사내 자료 검색 켬) — 시스템 프롬프트",
|
|
100
|
+
),
|
|
101
|
+
"cell_explain_prompt": (_BOTH, "셀 버튼 설명"),
|
|
102
|
+
"cell_fix_prompt": (_BOTH, "셀 버튼 수정 — 에러가 있을 때"),
|
|
103
|
+
"cell_improve_prompt": (_BOTH, "셀 버튼 수정 — 에러가 없을 때(개선 제안)"),
|
|
104
|
+
"cell_question_prompt": (_BOTH, "셀 버튼 질문"),
|
|
105
|
+
"review_rules_prompt": (_BOTH, "코드리뷰 — 공통 규칙 (codex `/review` 도)"),
|
|
106
|
+
"review_security_prompt": (_BOTH, "코드리뷰 — 보안 관점 (codex `/review` 도)"),
|
|
107
|
+
"review_performance_prompt": (_BOTH, "코드리뷰 — 성능 관점 (codex `/review` 도)"),
|
|
108
|
+
"review_quality_prompt": (_BOTH, "코드리뷰 — 코드 품질 관점 (codex `/review` 도)"),
|
|
109
|
+
"review_summary_prompt": (_BOTH, "코드리뷰 — 요약"),
|
|
110
|
+
# 이슈 #218: 코드 상수였던 프롬프트
|
|
111
|
+
"answer_format_rules": (_BOTH, "채팅 시스템 — 응답 서식 규칙"),
|
|
112
|
+
"todo_rules": (_BOTH, "Agent — 할 일 목록(write_todos) 규칙"),
|
|
113
|
+
"notebook_generation_prompt": (
|
|
114
|
+
_BOTH,
|
|
115
|
+
"노트북 생성(/create-notebook) (품질 원칙은 스킬 python_code_quality)",
|
|
116
|
+
),
|
|
117
|
+
"athena_explain_fix_prompt": (_BOTH, "Athena 쿼리 패널 — EXPLAIN 오류 수정(2단계)"),
|
|
118
|
+
"athena_plan_tuning_prompt": (_BOTH, "Athena 쿼리 패널 — 쿼리 플랜 튜닝(3단계)"),
|
|
119
|
+
"athena_response_contract": (_BOTH, "Athena 쿼리 패널 — 검색 결과 뒤 응답 형식"),
|
|
120
|
+
"guide_ingest_markdown_prompt": (_BOTH, "가이드 적재 — 마크다운 구조화"),
|
|
121
|
+
"guide_chunk_summary_prompt": (_BOTH, "가이드 적재 — 청크 요약"),
|
|
122
|
+
"codex_agent_instructions": (_CODEX, "Agent — codex developer 지시문"),
|
|
123
|
+
"codex_agent_base_instructions": (_CODEX, "Agent — codex 스레드 base 지시문"),
|
|
124
|
+
}
|
|
125
|
+
PROMPT_KEYS = tuple(PROMPT_KEY_SCOPES)
|
|
126
|
+
PROMPT_MAX_CHARS = 10_000
|
|
127
|
+
|
|
128
|
+
# 바꾼 값이 언제 반영되나 (이슈 #218). centri 값은 폴링(60초)으로 들어오고, 그 뒤 이 시점에
|
|
129
|
+
# 읽힌다. 회신 표(`scripts/prompt_key_table.py`)가 이것에서 나오고, 키 집합이
|
|
130
|
+
# `PROMPT_KEYS` 와 같은지는 `backend/tests/test_prompt_keys_218.py` 가 잰다.
|
|
131
|
+
_NEXT_REQUEST = "다음 요청"
|
|
132
|
+
_NEW_SESSION = "새 세션(codex) · 다음 요청(langchain)"
|
|
133
|
+
PROMPT_KEY_APPLY: Dict[str, str] = {
|
|
134
|
+
"chat_system_prompt": _NEW_SESSION,
|
|
135
|
+
"chat_rag_prompt": _NEXT_REQUEST,
|
|
136
|
+
"planner": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
137
|
+
"python_expert": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
138
|
+
"researcher": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
139
|
+
"athena_query": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
140
|
+
"athena_query_prompt": _NEXT_REQUEST,
|
|
141
|
+
"airflow_expert": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
142
|
+
"emr_expert": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
143
|
+
"lambda_expert": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
144
|
+
"spring_expert": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
145
|
+
"bq_agent": "다음 요청 (Agent 캐시가 프롬프트를 키로 쓴다)",
|
|
146
|
+
"codex_base_instructions": "새 스레드·재개 (codex)",
|
|
147
|
+
"bdp_platform_rules": _NEW_SESSION,
|
|
148
|
+
"chat_qa_system_prompt": "새 세션(codex) · 새 대화(langchain)",
|
|
149
|
+
"cell_explain_prompt": "다음 호출 (프론트가 설정을 다시 받은 뒤)",
|
|
150
|
+
"cell_fix_prompt": "다음 호출 (프론트가 설정을 다시 받은 뒤)",
|
|
151
|
+
"cell_improve_prompt": "다음 호출 (프론트가 설정을 다시 받은 뒤)",
|
|
152
|
+
"cell_question_prompt": "다음 호출 (프론트가 설정을 다시 받은 뒤)",
|
|
153
|
+
"review_rules_prompt": "다음 호출 (셀 리뷰는 프론트가 설정을 다시 받은 뒤, `/review` 는 바로)",
|
|
154
|
+
"review_security_prompt": "다음 호출 (셀 리뷰는 프론트가 설정을 다시 받은 뒤, `/review` 는 바로)",
|
|
155
|
+
"review_performance_prompt": "다음 호출 (셀 리뷰는 프론트가 설정을 다시 받은 뒤, `/review` 는 바로)",
|
|
156
|
+
"review_quality_prompt": "다음 호출 (셀 리뷰는 프론트가 설정을 다시 받은 뒤, `/review` 는 바로)",
|
|
157
|
+
"review_summary_prompt": "다음 호출 (프론트가 설정을 다시 받은 뒤)",
|
|
158
|
+
"answer_format_rules": "새 세션(codex) · 다음 요청(langchain, 사내 자료 검색 끔) · 새 대화(langchain, 사내 자료 검색 켬)",
|
|
159
|
+
"todo_rules": "새 세션(codex) · 다음 요청(langchain)",
|
|
160
|
+
"notebook_generation_prompt": _NEXT_REQUEST,
|
|
161
|
+
"athena_explain_fix_prompt": _NEXT_REQUEST,
|
|
162
|
+
"athena_plan_tuning_prompt": _NEXT_REQUEST,
|
|
163
|
+
"athena_response_contract": _NEXT_REQUEST,
|
|
164
|
+
"guide_ingest_markdown_prompt": "다음 적재 작업",
|
|
165
|
+
"guide_chunk_summary_prompt": "다음 적재 작업",
|
|
166
|
+
"codex_agent_instructions": "새 세션 (codex)",
|
|
167
|
+
"codex_agent_base_instructions": "새 스레드·재개 (codex)",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# 옛 이름 → 새 이름 (이슈 #218). centri 가 옛 이름으로 보내거나 pod 파일에 옛 이름이 남아
|
|
171
|
+
# 있으면 새 이름이 비었을 때만 옮긴다 — 새 이름이 있으면 새 이름이 이긴다. 파일은 다시 쓰지 않는다.
|
|
172
|
+
RENAMED_PROMPT_KEYS: Dict[str, str] = {
|
|
173
|
+
"athena_tuning_prompt": "athena_query_prompt",
|
|
174
|
+
"python_developer": "python_expert",
|
|
175
|
+
"mwaa_expert": "airflow_expert",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def migrate_renamed_prompts(prompts: Any) -> Any:
|
|
180
|
+
"""`RENAMED_PROMPT_KEYS` 의 옛 이름 값을 새 이름으로 옮긴 사본. dict 가 아니면 그대로."""
|
|
181
|
+
if not isinstance(prompts, dict):
|
|
182
|
+
return prompts
|
|
183
|
+
out = dict(prompts)
|
|
184
|
+
for old, new in RENAMED_PROMPT_KEYS.items():
|
|
185
|
+
value = out.pop(old, None)
|
|
186
|
+
current = out.get(new)
|
|
187
|
+
if (
|
|
188
|
+
isinstance(value, str)
|
|
189
|
+
and value.strip()
|
|
190
|
+
and not (isinstance(current, str) and current.strip())
|
|
191
|
+
):
|
|
192
|
+
out[new] = value
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# 기본값 원문을 코드 상수로 두지 않는다 (이슈 #208, #218). 정본은 centri 기본값이고, codebuff 의
|
|
197
|
+
# 폴백은 그것을 옮긴 스냅샷 `schemas/config/codebuff_config_defaults.json` 하나뿐이다.
|
|
198
|
+
# 문구를 고치려면 스냅샷을 고친다 — `scripts/dump_config_defaults.py` 는 키를 스냅샷에서
|
|
199
|
+
# 그대로 옮기고, 프론트 폴백(`frontend/utils/featurePromptDefaults.json`)도 거기서 만든다.
|
|
200
|
+
SNAPSHOT_PROMPT_KEYS = PROMPT_KEYS
|
|
201
|
+
# 패키지 안의 이 파일은 스냅샷을 가리키는 심볼릭 링크다 — wheel 에는 내용이 실린다.
|
|
202
|
+
SNAPSHOT_FILE = Path(__file__).resolve().parents[1] / "codebuff_config_defaults.json"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@lru_cache(maxsize=1)
|
|
206
|
+
def _snapshot_prompts() -> Dict[str, str]:
|
|
207
|
+
with open(SNAPSHOT_FILE, encoding="utf-8") as f:
|
|
208
|
+
return json.load(f)["values"]["prompts"]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def snapshot_prompt(key: str) -> str:
|
|
212
|
+
"""스냅샷의 `prompts.<key>` 기본값.
|
|
213
|
+
|
|
214
|
+
파일이나 키가 없으면 예외를 그대로 낸다 — 배포물이 깨진 것이라 빈 프롬프트로 조용히
|
|
215
|
+
도는 것보다 낫다. `backend/tests/test_feature_prompt_defaults.py` 가 있음을 지킨다.
|
|
216
|
+
"""
|
|
217
|
+
if key not in SNAPSHOT_PROMPT_KEYS:
|
|
218
|
+
raise KeyError(f"스냅샷이 정본인 프롬프트 키가 아니다: {key}")
|
|
219
|
+
return _snapshot_prompts()[key]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# 사용자가 고르는 값 — 서버는 조직 기본값만 준다. 사용자가 고른 값이 이긴다.
|
|
223
|
+
# `agentEngine` 은 이슈 #138, `temperature` 는 이슈 #171(0.0 고정)에서 폐기했다.
|
|
224
|
+
# 서버가 보내도 모르는 키로 무시한다.
|
|
225
|
+
USER_DEFAULT_KEYS = ("codexPermissionMode", "autoApprove")
|
|
226
|
+
# 개인설정 패널이 저장하는 키 — centri 사용자 행(`user.<키>`)으로 간다.
|
|
227
|
+
USER_KEYS = USER_DEFAULT_KEYS + ("workspaceRoot",)
|
|
228
|
+
|
|
229
|
+
# 접속 설정 — 서버 > 시크릿 env > pod 파일 > 코드 기본값. 받은 적이 있어도 pod 파일을
|
|
230
|
+
# 버리지 않는다: 로컬 개발 서버는 centri·시크릿 없이 파일로 LLM 에 붙는다.
|
|
231
|
+
# 값은 해당 env 이름이다. API 키는 이 표에 없다 — 키는 env(없으면 pod 파일)만 쓴다.
|
|
232
|
+
CONNECTION_ENV = {
|
|
233
|
+
"vllm.endpoint": "VLLM_ENDPOINT",
|
|
234
|
+
"vllm.model": "VLLM_MODEL",
|
|
235
|
+
"rag.vllmEndpoint": "EMBEDDING_ENDPOINT",
|
|
236
|
+
"rag.vllmModel": "EMBEDDING_MODEL",
|
|
237
|
+
"rag.vllmDimension": "EMBEDDING_DIMENSION",
|
|
238
|
+
"codex.endpoint": "CODEX_SERVER_URL",
|
|
239
|
+
}
|
|
240
|
+
API_KEY_ENV = {
|
|
241
|
+
"vllm.apiKey": "VLLM_API_KEY",
|
|
242
|
+
"rag.vllmApiKey": "EMBEDDING_API_KEY",
|
|
243
|
+
"codex.apiKey": "CODEX_SERVER_API_KEY",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_KST = timezone(timedelta(hours=9))
|
|
247
|
+
_INVALID = object()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _is_int(value: Any) -> bool:
|
|
251
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _bool(value: Any) -> Any:
|
|
255
|
+
return value if isinstance(value, bool) else _INVALID
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _int_range(lo: int, hi: int) -> Callable[[Any], Any]:
|
|
259
|
+
return lambda v: v if _is_int(v) and lo <= v <= hi else _INVALID
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _text(max_chars: int) -> Callable[[Any], Any]:
|
|
263
|
+
return lambda v: (
|
|
264
|
+
v if isinstance(v, str) and v.strip() and len(v) <= max_chars else _INVALID
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _choice(choices: tuple) -> Callable[[Any], Any]:
|
|
269
|
+
return lambda v: v if v in choices else _INVALID
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _url(schemes: tuple) -> Callable[[Any], Any]:
|
|
273
|
+
return lambda v: (
|
|
274
|
+
v.strip()
|
|
275
|
+
if isinstance(v, str) and len(v) <= 500 and v.strip().startswith(schemes)
|
|
276
|
+
else _INVALID
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _connection_schema() -> Dict[str, Callable[[Any], Any]]:
|
|
281
|
+
http = ("http://", "https://")
|
|
282
|
+
return {
|
|
283
|
+
"vllm.endpoint": _url(http),
|
|
284
|
+
"vllm.model": _text(200),
|
|
285
|
+
"rag.vllmEndpoint": _url(http),
|
|
286
|
+
"rag.vllmModel": _text(200),
|
|
287
|
+
"rag.vllmDimension": _int_range(1, 65_536),
|
|
288
|
+
"codex.endpoint": _url(("ws://", "wss://")),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _org_schema() -> Dict[str, Callable[[Any], Any]]:
|
|
293
|
+
schema: Dict[str, Callable[[Any], Any]] = {
|
|
294
|
+
f"prompts.{k}": _text(PROMPT_MAX_CHARS) for k in PROMPT_KEYS
|
|
295
|
+
}
|
|
296
|
+
schema.update(
|
|
297
|
+
{
|
|
298
|
+
"guardrails.enableInput": _bool,
|
|
299
|
+
"guardrails.enableOutput": _bool,
|
|
300
|
+
"guardrails.maxInputLength": _int_range(1, 1_000_000),
|
|
301
|
+
"guardrails.maxCodeLength": _int_range(1, 1_000_000),
|
|
302
|
+
"idleTimeout": _int_range(60, 86_400),
|
|
303
|
+
"maxOutputTokens": _int_range(1, 131_072),
|
|
304
|
+
"rawLlmLogging": _bool,
|
|
305
|
+
"features.eda_enabled": _bool,
|
|
306
|
+
"features.bdp_qa_enabled": _bool,
|
|
307
|
+
# agent 모드 게이트 — env 관리자 판정 대신 이 키를 본다 (이슈 #179)
|
|
308
|
+
"features.agent_enabled": _bool,
|
|
309
|
+
"features.inference_engine": _choice(INFERENCE_ENGINES),
|
|
310
|
+
"rag.enabled": _bool,
|
|
311
|
+
"rag.collectionName": _text(100),
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
return schema
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _user_schema() -> Dict[str, Callable[[Any], Any]]:
|
|
318
|
+
from codebuff_core.managers.config_manager import CODEX_PERMISSION_MODES
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"codexPermissionMode": _choice(CODEX_PERMISSION_MODES),
|
|
322
|
+
"autoApprove": _bool,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _user_value_schema() -> Dict[str, Callable[[Any], Any]]:
|
|
327
|
+
"""사용자 행 — 조직 기본값 키에 작업 폴더가 더해진다."""
|
|
328
|
+
schema = _user_schema()
|
|
329
|
+
schema["workspaceRoot"] = lambda v: (
|
|
330
|
+
v if isinstance(v, str) and len(v) <= 1000 else _INVALID
|
|
331
|
+
)
|
|
332
|
+
return schema
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# 조직 키 — 한 번이라도 받았으면 pod 파일 값을 쓰지 않는다 (서버 > 코드 기본값).
|
|
336
|
+
ORG_SCHEMA = _org_schema()
|
|
337
|
+
CONNECTION_SCHEMA = _connection_schema()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _flatten(values: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
|
|
341
|
+
flat: Dict[str, Any] = {}
|
|
342
|
+
for key, value in values.items():
|
|
343
|
+
path = f"{prefix}{key}"
|
|
344
|
+
if isinstance(value, dict):
|
|
345
|
+
flat.update(_flatten(value, f"{path}."))
|
|
346
|
+
else:
|
|
347
|
+
flat[path] = value
|
|
348
|
+
return flat
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _check(
|
|
352
|
+
values: Dict[str, Any], schema: Dict[str, Callable[[Any], Any]], prefix: str
|
|
353
|
+
):
|
|
354
|
+
clean: Dict[str, Any] = {}
|
|
355
|
+
for path, raw in values.items():
|
|
356
|
+
check = schema.get(path)
|
|
357
|
+
if check is None:
|
|
358
|
+
logger.debug("remote config: 모르는 키 무시 %s%s", prefix, path)
|
|
359
|
+
continue
|
|
360
|
+
value = check(raw)
|
|
361
|
+
if value is _INVALID:
|
|
362
|
+
logger.warning("remote config: 규격 밖 값 무시 %s%s", prefix, path)
|
|
363
|
+
continue
|
|
364
|
+
clean[f"{prefix}{path}"] = value
|
|
365
|
+
return clean
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def sanitize(values: Any, user: Any = None) -> Dict[str, Any]:
|
|
369
|
+
"""서버 응답을 검증해 통과한 필드만 `{점 경로: 값}` 으로 돌려준다.
|
|
370
|
+
|
|
371
|
+
`values` 는 조직 값(팀·프로젝트까지 해석된 것), `user` 는 이 사용자가 저장한 개인설정이다.
|
|
372
|
+
개인설정은 `user.<키>` 경로로 담긴다.
|
|
373
|
+
"""
|
|
374
|
+
clean: Dict[str, Any] = {}
|
|
375
|
+
if isinstance(values, dict):
|
|
376
|
+
if "prompts" in values:
|
|
377
|
+
values = {**values, "prompts": migrate_renamed_prompts(values["prompts"])}
|
|
378
|
+
schema = dict(ORG_SCHEMA)
|
|
379
|
+
schema.update(CONNECTION_SCHEMA)
|
|
380
|
+
schema.update({f"userDefaults.{k}": v for k, v in _user_schema().items()})
|
|
381
|
+
clean.update(_check(_flatten(values), schema, ""))
|
|
382
|
+
if isinstance(user, dict):
|
|
383
|
+
clean.update(_check(user, _user_value_schema(), "user."))
|
|
384
|
+
return clean
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def sanitize_user(values: Dict[str, Any]) -> Dict[str, Any]:
|
|
388
|
+
"""centri 로 보낼 개인설정 — 허용 키·규격 안의 값만 남긴다."""
|
|
389
|
+
return {
|
|
390
|
+
k[len("user.") :]: v
|
|
391
|
+
for k, v in _check(values, _user_value_schema(), "user.").items()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class RemoteConfigClient:
|
|
396
|
+
"""centri 에서 설정을 받아 스냅샷으로 들고 있는다."""
|
|
397
|
+
|
|
398
|
+
def __init__(
|
|
399
|
+
self,
|
|
400
|
+
url: str = "",
|
|
401
|
+
token: str = "",
|
|
402
|
+
transport: Any = None,
|
|
403
|
+
refresh_interval: float = REFRESH_INTERVAL_SEC,
|
|
404
|
+
timeout: float = TIMEOUT_SEC,
|
|
405
|
+
team: str = "",
|
|
406
|
+
project: str = "",
|
|
407
|
+
user_token: str = "",
|
|
408
|
+
user: str = "",
|
|
409
|
+
) -> None:
|
|
410
|
+
self._url = (url or "").strip()
|
|
411
|
+
self._token = token or ""
|
|
412
|
+
# centri 가 조직 값을 전역 < 팀 < 프로젝트로 해석하는 데 쓴다 (이슈 #113)
|
|
413
|
+
self._scope = {"team": team or "", "project": project or ""}
|
|
414
|
+
self._user_token = user_token or ""
|
|
415
|
+
# GET 에만 싣는 로그 대조용 사번 (이슈 #181). 사용자 층은 centri 가 토큰으로 고른다
|
|
416
|
+
self._user = user or ""
|
|
417
|
+
self._transport = transport
|
|
418
|
+
self._interval = refresh_interval
|
|
419
|
+
self._timeout = timeout
|
|
420
|
+
self._lock = threading.Lock()
|
|
421
|
+
self._stop = threading.Event()
|
|
422
|
+
# 첫 시도(성공·실패 무관)가 끝나면 선다 — 기동이 이것을 상한 있게 기다린다
|
|
423
|
+
self._first_attempt = threading.Event()
|
|
424
|
+
self._thread: Optional[threading.Thread] = None
|
|
425
|
+
self._snapshot: Optional[Dict[str, Any]] = None
|
|
426
|
+
self._updated_at: Optional[str] = None
|
|
427
|
+
self._last_ok_at: Optional[str] = None
|
|
428
|
+
self._last_error: Optional[str] = None
|
|
429
|
+
# 공유 키 인증이 막힌 사유 — 키를 통과한 뒤의 실패(500·스키마)는 None 이다 (이슈 #233)
|
|
430
|
+
self._connection_error: Optional[str] = None
|
|
431
|
+
self._attempted = False
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def enabled(self) -> bool:
|
|
435
|
+
return bool(self._url)
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def started(self) -> bool:
|
|
439
|
+
return self._thread is not None
|
|
440
|
+
|
|
441
|
+
def start(self) -> None:
|
|
442
|
+
if not self.enabled or self._thread is not None:
|
|
443
|
+
return
|
|
444
|
+
self._thread = threading.Thread(
|
|
445
|
+
target=self._run, name="codebuff-remote-config", daemon=True
|
|
446
|
+
)
|
|
447
|
+
self._thread.start()
|
|
448
|
+
|
|
449
|
+
def _run(self) -> None:
|
|
450
|
+
while not self._stop.is_set():
|
|
451
|
+
self.refresh_now()
|
|
452
|
+
self._first_attempt.set()
|
|
453
|
+
self._stop.wait(self._interval)
|
|
454
|
+
|
|
455
|
+
def wait_first_attempt(self, timeout: float) -> bool:
|
|
456
|
+
"""스레드의 첫 시도가 끝날 때까지 최대 `timeout` 초 기다린다. 끝났으면 True."""
|
|
457
|
+
return self._first_attempt.wait(timeout)
|
|
458
|
+
|
|
459
|
+
def stop(self) -> None:
|
|
460
|
+
self._stop.set()
|
|
461
|
+
|
|
462
|
+
def refresh_now(self) -> bool:
|
|
463
|
+
"""한 번 받아 스냅샷을 교체한다. 성공 여부를 돌려준다. 예외를 내지 않는다."""
|
|
464
|
+
if not self.enabled:
|
|
465
|
+
return False
|
|
466
|
+
try:
|
|
467
|
+
values, user, updated_at = self._fetch()
|
|
468
|
+
clean = sanitize(values, user)
|
|
469
|
+
with self._lock:
|
|
470
|
+
self._snapshot = clean
|
|
471
|
+
self._updated_at = updated_at
|
|
472
|
+
self._last_ok_at = datetime.now(_KST).isoformat(timespec="seconds")
|
|
473
|
+
self._last_error = None
|
|
474
|
+
self._connection_error = None
|
|
475
|
+
self._attempted = True
|
|
476
|
+
return True
|
|
477
|
+
except Exception as e: # noqa: BLE001 — 어떤 실패도 기본값으로 떨어뜨린다
|
|
478
|
+
with self._lock:
|
|
479
|
+
self._last_error = f"{type(e).__name__}: {e}"[:300]
|
|
480
|
+
self._connection_error = _connection_error(e)
|
|
481
|
+
self._attempted = True
|
|
482
|
+
logger.warning("remote config 받기 실패 — 마지막 값 유지: %s", e)
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
def _headers(self) -> Dict[str, str]:
|
|
486
|
+
headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
|
|
487
|
+
if self._user_token:
|
|
488
|
+
headers[USER_TOKEN_HEADER] = self._user_token
|
|
489
|
+
return headers
|
|
490
|
+
|
|
491
|
+
def _fetch(self):
|
|
492
|
+
import httpx
|
|
493
|
+
|
|
494
|
+
with httpx.Client(
|
|
495
|
+
transport=self._transport, timeout=httpx.Timeout(self._timeout)
|
|
496
|
+
) as client:
|
|
497
|
+
resp = client.get(
|
|
498
|
+
self._url,
|
|
499
|
+
headers=self._headers(),
|
|
500
|
+
params={**self._scope, "user": self._user},
|
|
501
|
+
)
|
|
502
|
+
resp.raise_for_status()
|
|
503
|
+
body = resp.json()
|
|
504
|
+
if not isinstance(body, dict):
|
|
505
|
+
raise ValueError("응답이 JSON 객체가 아님")
|
|
506
|
+
version = body.get("schema_version")
|
|
507
|
+
if version != SCHEMA_VERSION:
|
|
508
|
+
raise ValueError(f"schema_version 불일치: {version!r}")
|
|
509
|
+
values = body.get("values")
|
|
510
|
+
# 빠진 `values` 를 "적용 키 0개" 로 받으면 모든 pod 가 조용히 기본값으로 돌아간다
|
|
511
|
+
if not isinstance(values, dict):
|
|
512
|
+
raise ValueError("values 가 JSON 객체가 아님")
|
|
513
|
+
updated_at = body.get("updated_at")
|
|
514
|
+
# `user` 는 사용자 토큰이 없거나 저장한 개인설정이 없으면 빠질 수 있다
|
|
515
|
+
user = body.get("user")
|
|
516
|
+
return (
|
|
517
|
+
values,
|
|
518
|
+
user if isinstance(user, dict) else {},
|
|
519
|
+
updated_at if isinstance(updated_at, str) else None,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
@property
|
|
523
|
+
def can_save_user(self) -> bool:
|
|
524
|
+
"""개인설정을 centri 에 저장할 수 있는가 — 한 번 받았고 사용자 토큰이 있을 때."""
|
|
525
|
+
return bool(self._user_token) and self.snapshot() is not None
|
|
526
|
+
|
|
527
|
+
def save_user(self, set_values: Dict[str, Any], unset: list) -> None:
|
|
528
|
+
"""개인설정을 centri 사용자 행에 쓴다. 실패하면 예외를 낸다 — 저장 실패를 숨기지 않는다.
|
|
529
|
+
|
|
530
|
+
사용자는 centri 가 `X-Codebuff-User-Token` 으로 판정한다. 성공하면 스냅샷을 다시 받는다.
|
|
531
|
+
"""
|
|
532
|
+
import httpx
|
|
533
|
+
|
|
534
|
+
body = {**self._scope, "set": set_values, "unset": list(unset)}
|
|
535
|
+
with httpx.Client(
|
|
536
|
+
transport=self._transport, timeout=httpx.Timeout(self._timeout)
|
|
537
|
+
) as client:
|
|
538
|
+
resp = client.put(f"{self._url}/user", headers=self._headers(), json=body)
|
|
539
|
+
resp.raise_for_status()
|
|
540
|
+
self.refresh_now()
|
|
541
|
+
|
|
542
|
+
def snapshot(self) -> Optional[Dict[str, Any]]:
|
|
543
|
+
with self._lock:
|
|
544
|
+
return dict(self._snapshot) if self._snapshot is not None else None
|
|
545
|
+
|
|
546
|
+
def connection(self) -> Dict[str, Any]:
|
|
547
|
+
"""센트리 연결 한 줄 — 공유 키로 한 인증이 통했는가 (이슈 #233).
|
|
548
|
+
|
|
549
|
+
설정 수신이 그 인증 확인을 겸한다. 키를 통과한 뒤의 실패는 연결이 아니라 설정 수신의
|
|
550
|
+
오류라 `status()` 에만 남는다.
|
|
551
|
+
"""
|
|
552
|
+
with self._lock:
|
|
553
|
+
if not self.enabled:
|
|
554
|
+
return {"state": "error", "reason": "no_url"}
|
|
555
|
+
if not self._attempted:
|
|
556
|
+
return {"state": "pending", "reason": None}
|
|
557
|
+
if self._connection_error is not None:
|
|
558
|
+
return {"state": "error", "reason": self._connection_error}
|
|
559
|
+
return {"state": "ok", "reason": None}
|
|
560
|
+
|
|
561
|
+
def status(self) -> Dict[str, Any]:
|
|
562
|
+
with self._lock:
|
|
563
|
+
if not self.enabled:
|
|
564
|
+
state = "disabled"
|
|
565
|
+
elif not self._attempted:
|
|
566
|
+
state = "pending"
|
|
567
|
+
elif self._last_error is not None:
|
|
568
|
+
state = "error"
|
|
569
|
+
else:
|
|
570
|
+
state = "ok"
|
|
571
|
+
return {
|
|
572
|
+
"state": state,
|
|
573
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
574
|
+
# 조직 키만 센다 — 개인설정 행(`user.*`)까지 세면 "적용 키" 가 부풀어 보인다
|
|
575
|
+
"serverKeys": sum(
|
|
576
|
+
1 for k in (self._snapshot or {}) if not k.startswith("user.")
|
|
577
|
+
),
|
|
578
|
+
"updatedAt": self._updated_at,
|
|
579
|
+
"lastOkAt": self._last_ok_at,
|
|
580
|
+
"lastError": self._last_error,
|
|
581
|
+
# centri 가 층을 고르는 신원 — 팀이 비면 team·project 층 값이 안 닿는다 (이슈 #237).
|
|
582
|
+
# 토큰은 값이 아니라 있는지만 싣는다
|
|
583
|
+
"scope": {
|
|
584
|
+
"team": self._scope["team"],
|
|
585
|
+
"project": self._scope["project"],
|
|
586
|
+
"userToken": bool(self._user_token),
|
|
587
|
+
},
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _connection_error(e: Exception) -> Optional[str]:
|
|
592
|
+
"""받기 실패를 연결 사유로 가른다. 키를 통과한 뒤의 실패면 None."""
|
|
593
|
+
import httpx
|
|
594
|
+
|
|
595
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
596
|
+
code = e.response.status_code
|
|
597
|
+
if code in (401, 403):
|
|
598
|
+
return "key_mismatch"
|
|
599
|
+
# centri 에 공유 키가 설정되지 않았을 때의 응답
|
|
600
|
+
if code == 503:
|
|
601
|
+
return "no_key"
|
|
602
|
+
return None
|
|
603
|
+
if isinstance(e, httpx.TransportError):
|
|
604
|
+
return "unreachable"
|
|
605
|
+
return None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
_client: Optional[RemoteConfigClient] = None
|
|
609
|
+
_client_lock = threading.Lock()
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def get_client() -> RemoteConfigClient:
|
|
613
|
+
"""현재 클라이언트. 아직 없으면 env 로 만든다 (스레드는 띄우지 않는다)."""
|
|
614
|
+
global _client
|
|
615
|
+
with _client_lock:
|
|
616
|
+
if _client is None:
|
|
617
|
+
_client = _client_from_env()
|
|
618
|
+
return _client
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _client_from_env(
|
|
622
|
+
team: Optional[str] = None, user_ids: Sequence[str] = ()
|
|
623
|
+
) -> RemoteConfigClient:
|
|
624
|
+
# 팀·프로젝트는 앞뒤 공백을 뗀다 — centri 포털은 공백을 떼고 층 행을 저장하므로(`_layer`)
|
|
625
|
+
# " 팀A" 로 물으면 그 팀·프로젝트·사용자 층이 아무것도 안 닿는다 (이슈 #237)
|
|
626
|
+
return RemoteConfigClient(
|
|
627
|
+
url=os.environ.get(URL_ENV, ""),
|
|
628
|
+
token=os.environ.get(TOKEN_ENV, ""),
|
|
629
|
+
team=(os.environ.get("HDSP_USER_TEAM", "") if team is None else team).strip(),
|
|
630
|
+
project=os.environ.get("HDSP_PRJ_ID", "").strip(),
|
|
631
|
+
user_token=os.environ.get(USER_TOKEN_ENV, ""),
|
|
632
|
+
user=next((u for u in user_ids if u), ""),
|
|
633
|
+
refresh_interval=_refresh_from_env(),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _refresh_from_env() -> float:
|
|
638
|
+
try:
|
|
639
|
+
value = float(os.environ.get(REFRESH_ENV, ""))
|
|
640
|
+
except ValueError:
|
|
641
|
+
return REFRESH_INTERVAL_SEC
|
|
642
|
+
# 너무 큰 값(inf·1e20)은 `Event.wait` 의 OverflowError 로 갱신 스레드를 죽인다. NaN 은 비교가 거짓이다
|
|
643
|
+
return value if 1 <= value <= 86400 else REFRESH_INTERVAL_SEC
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def start_from_env(
|
|
647
|
+
team: Optional[str] = None, user_ids: Sequence[str] = ()
|
|
648
|
+
) -> RemoteConfigClient:
|
|
649
|
+
"""서버 기동 시 시크릿 주입 뒤에 부른다. env 로 클라이언트를 다시 만들고 띄운다.
|
|
650
|
+
|
|
651
|
+
첫 시도를 최대 `TIMEOUT_SEC` 만큼 기다렸다 돌아온다. `ServerIdleMonitor` 가 생성 시
|
|
652
|
+
`idleTimeout` 을 한 번만 읽으므로, 기다리지 않으면 그 기동 내내 centri 값이 빠진다.
|
|
653
|
+
`team` 을 주면 env `HDSP_USER_TEAM` 대신 쓴다(JWT 팀 클레임 폴백은 jupyter_ext 가 안다).
|
|
654
|
+
`user_ids` 는 사번 후보(`team_access.user_ids()`)이고 첫 값을 `user` 로 싣는다.
|
|
655
|
+
요청 대신 스레드를 기다리는 것은 httpx 타임아웃이 DNS 조회를 덮지 않아서다 — 상한이
|
|
656
|
+
없으면 DNS 가 매달리는 동안 확장 로드 전체가 멈춘다.
|
|
657
|
+
"""
|
|
658
|
+
global _client
|
|
659
|
+
with _client_lock:
|
|
660
|
+
if _client is None or not _client.started:
|
|
661
|
+
_client = _client_from_env(team, user_ids)
|
|
662
|
+
_client.start()
|
|
663
|
+
client = _client
|
|
664
|
+
if client.enabled:
|
|
665
|
+
client.wait_first_attempt(TIMEOUT_SEC)
|
|
666
|
+
return client
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def set_client(client: Optional[RemoteConfigClient]) -> None:
|
|
670
|
+
"""테스트용 — 클라이언트를 갈아 끼운다. None 이면 다음 조회 때 env 로 다시 만든다."""
|
|
671
|
+
global _client
|
|
672
|
+
with _client_lock:
|
|
673
|
+
_client = client
|