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,690 @@
1
+ """
2
+ Configuration Manager - Handle extension settings persistence
3
+
4
+ Singleton manager for configuration persistence across sessions.
5
+ Includes both admin (LLM/server) and user (workspace/preferences) settings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import os
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from codebuff_core.legacy_paths import resolve
18
+ from codebuff_core.managers import remote_config
19
+
20
+
21
+ def _is_masked_api_key(value: str) -> bool:
22
+ """Check if a value looks like a masked API key (e.g., 'sk-o...b459').
23
+
24
+ Masked keys are produced by _mask_api_key(): key[:4] + '...' + key[-4:]
25
+ resulting in 11-char strings, or '***' for short keys.
26
+ Real API keys are typically 30+ chars and don't contain '...'.
27
+ """
28
+ if not isinstance(value, str) or not value:
29
+ return False
30
+ if value == "***":
31
+ return True
32
+ return "..." in value and len(value) < 20
33
+
34
+
35
+ def _strip_masked_keys(incoming: dict, existing: dict) -> dict:
36
+ """Replace masked API key values in incoming config with existing real values.
37
+
38
+ When the frontend sends back masked keys (e.g., 'sk-o...b459'), this function
39
+ restores the real values from the existing config to prevent data corruption.
40
+ """
41
+ import copy
42
+
43
+ cleaned = copy.deepcopy(incoming)
44
+ for key, value in list(cleaned.items()):
45
+ if isinstance(value, dict):
46
+ existing_section = existing.get(key, {})
47
+ if isinstance(existing_section, dict):
48
+ # Restore real apiKey if incoming is masked
49
+ # (rag.vllmApiKey 처럼 `...ApiKey` 로 끝나는 키도 가려서 나간다 — 이슈 #113)
50
+ for field in [
51
+ f for f in value if f == "apiKey" or f.endswith("ApiKey")
52
+ ]:
53
+ if not _is_masked_api_key(value[field]):
54
+ continue
55
+ if field in existing_section and not _is_masked_api_key(
56
+ existing_section[field]
57
+ ):
58
+ value[field] = existing_section[field]
59
+ else:
60
+ del value[field]
61
+ # Restore real keys in keys array (e.g., keys[].key)
62
+ if "keys" in value and isinstance(value["keys"], list):
63
+ existing_keys = existing_section.get("keys", [])
64
+ existing_keys_by_id = {
65
+ k.get("id"): k
66
+ for k in existing_keys
67
+ if isinstance(k, dict) and "id" in k
68
+ }
69
+ for item in value["keys"]:
70
+ if (
71
+ isinstance(item, dict)
72
+ and "key" in item
73
+ and _is_masked_api_key(item["key"])
74
+ ):
75
+ existing_item = existing_keys_by_id.get(item.get("id"))
76
+ if (
77
+ existing_item
78
+ and "key" in existing_item
79
+ and not _is_masked_api_key(existing_item["key"])
80
+ ):
81
+ item["key"] = existing_item["key"]
82
+ elif key == "apiKey" and _is_masked_api_key(value):
83
+ if key in existing and not _is_masked_api_key(existing[key]):
84
+ cleaned[key] = existing[key]
85
+ else:
86
+ del cleaned[key]
87
+ return cleaned
88
+
89
+
90
+ # codex 권한 모드의 허용값 (이슈 #88). 에이전트 엔진 선택은 이슈 #138 에서 지웠다 —
91
+ # 어느 엔진으로 보낼지는 패널의 모드 드롭다운이 정한다.
92
+ CODEX_PERMISSION_MODES = ("ask", "bypass")
93
+ # 아무것도 안 고른 사용자의 권한 모드 — ask 였다가 이슈 #180 에서 bypass 로 바꿨다.
94
+ # 사용자가 ask 를 골라 저장했으면 그 값이 이긴다. centri 사본은
95
+ # `schemas/config/codebuff_config_defaults.json`(scripts/dump_config_defaults.py)로 따라간다.
96
+ DEFAULT_CODEX_PERMISSION_MODE = "bypass"
97
+
98
+ # 추론 엔진 — centri 전역 설정 `features.inference_engine` (이슈 #155).
99
+ # Chat(사내 검색 포함, #183)·Agent·노트북 자동생성·EDA Agent 가 이 값 하나를 따른다.
100
+ # langchain 이면 Agent 는 deepagents, 나머지는 RAGChatAgent 로 돈다.
101
+ INFERENCE_ENGINES = remote_config.INFERENCE_ENGINES
102
+ DEFAULT_INFERENCE_ENGINE = "codex"
103
+
104
+ # agent 모드 게이트 — centri 조직 키 `features.agent_enabled` (이슈 #179).
105
+ # 값이 없으면 켜짐이다 (이슈 #236) — Agent 는 기본으로 보이고, centri 가 명시적으로
106
+ # false 를 보낼 때만 닫힌다(조직 끄기 스위치).
107
+ DEFAULT_AGENT_ENABLED = True
108
+
109
+
110
+ def _coerce_choice(value: Any, choices: tuple, default: str) -> str:
111
+ """열거형 설정을 읽을 때 허용값 밖이면 기본값으로 떨어뜨린다."""
112
+ return value if value in choices else default
113
+
114
+
115
+ class ConfigManager:
116
+ """Manage configuration persistence"""
117
+
118
+ _instance = None
119
+ _config_file = None
120
+
121
+ def __init__(self):
122
+ try:
123
+ # 1순위: 환경변수
124
+ jupyter_config_dir = os.environ.get("JUPYTER_CONFIG_DIR")
125
+
126
+ # 2순위: 홈 디렉토리 ~/.jupyter
127
+ if not jupyter_config_dir:
128
+ # expanduser가 실패할 경우를 대비
129
+ try:
130
+ jupyter_config_dir = os.path.expanduser("~/.jupyter")
131
+ except Exception:
132
+ jupyter_config_dir = None
133
+
134
+ # 경로가 유효한지 체크하고, 없으면 생성 시도
135
+ if jupyter_config_dir:
136
+ config_path = Path(jupyter_config_dir)
137
+ try:
138
+ config_path.mkdir(parents=True, exist_ok=True)
139
+ self._config_file = resolve(
140
+ config_path / "codebuff_config.json",
141
+ config_path / "hdsp_agent_config.json",
142
+ )
143
+ except Exception as e:
144
+ print(f"Warning: Cannot write to {jupyter_config_dir}: {e}")
145
+ self._config_file = None
146
+
147
+ # 3순위 (비상용): 쓰기 실패했거나 경로가 없으면 /tmp 사용
148
+ if not self._config_file:
149
+ tmp_dir = Path(tempfile.gettempdir()) / "jupyter_codebuff"
150
+ tmp_dir.mkdir(parents=True, exist_ok=True)
151
+ self._config_file = tmp_dir / "codebuff_config.json"
152
+ print(f"Using temporary config path: {self._config_file}")
153
+
154
+ except Exception as e:
155
+ # 최악의 경우: 메모리에서만 동작하도록 가짜 경로 설정 (서버 다운 방지)
156
+ print(f"Critical Error in ConfigManager init: {e}")
157
+ self._config_file = Path("/tmp/codebuff_config_fallback.json")
158
+
159
+ self._config = self._load_config()
160
+
161
+ @classmethod
162
+ def get_instance(cls):
163
+ """Get singleton instance"""
164
+ if cls._instance is None:
165
+ cls._instance = ConfigManager()
166
+ return cls._instance
167
+
168
+ @classmethod
169
+ def reset_instance(cls):
170
+ """Reset singleton instance (for testing)"""
171
+ cls._instance = None
172
+
173
+ def _load_config(self) -> Dict[str, Any]:
174
+ """Load configuration from file"""
175
+ if not self._config_file.exists():
176
+ return self._default_config()
177
+
178
+ try:
179
+ with open(self._config_file, "r") as f:
180
+ loaded = json.load(f)
181
+ # Merge with defaults to ensure all keys exist
182
+ defaults = self._default_config()
183
+ defaults.update(loaded)
184
+ # 옛 프롬프트 키 이름은 새 이름으로 읽는다 (이슈 #218). 파일은 다시 쓰지 않는다
185
+ defaults["prompts"] = remote_config.migrate_renamed_prompts(
186
+ defaults.get("prompts")
187
+ )
188
+ self._migrate_legacy_providers(defaults)
189
+ self._sanitize_masked_keys(defaults)
190
+ return defaults
191
+ except (json.JSONDecodeError, IOError) as e:
192
+ print(f"Error loading config: {e}")
193
+ return self._default_config()
194
+
195
+ def _migrate_legacy_providers(self, config: Dict[str, Any]):
196
+ """Auto-migrate gemini/openai provider to vllm."""
197
+ changed = False
198
+ if config.get("provider") in ("gemini", "openai"):
199
+ config["provider"] = "vllm"
200
+ changed = True
201
+ for key in ("gemini", "openai"):
202
+ if key in config:
203
+ del config[key]
204
+ changed = True
205
+ summ = config.get("summarization")
206
+ if isinstance(summ, dict) and summ.get("provider") in ("gemini", "openai"):
207
+ summ["provider"] = "vllm"
208
+ changed = True
209
+ if changed:
210
+ self._persist(config)
211
+
212
+ def _sanitize_masked_keys(self, config: Dict[str, Any]):
213
+ """마스킹된 API 키를 빈 문자열로 교체하고 파일에 반영."""
214
+ changed = False
215
+ for section_key in ("vllm", "summarization", "embedding"):
216
+ section = config.get(section_key)
217
+ if isinstance(section, dict):
218
+ api_key = section.get("apiKey", "")
219
+ if _is_masked_api_key(api_key):
220
+ section["apiKey"] = ""
221
+ changed = True
222
+ if changed:
223
+ print(
224
+ "[ConfigManager] WARNING: 마스킹된 API 키가 감지되어 제거되었습니다. "
225
+ "설정에서 API 키를 다시 입력하세요."
226
+ )
227
+ self._persist(config)
228
+
229
+ def _persist(self, config: Dict[str, Any]):
230
+ """Write config dict to file."""
231
+ try:
232
+ self._config_file.parent.mkdir(parents=True, exist_ok=True)
233
+ with open(self._config_file, "w") as f:
234
+ json.dump(config, f, indent=2)
235
+ except IOError as e:
236
+ print(f"Warning: Failed to persist migrated config: {e}")
237
+
238
+ def _default_config(self) -> Dict[str, Any]:
239
+ """Get default configuration (merged admin + user schema)"""
240
+ return {
241
+ # LLM Provider Settings
242
+ "provider": "vllm",
243
+ "vllm": {
244
+ "endpoint": "https://openrouter.ai/api/v1",
245
+ "apiKey": "",
246
+ "model": "openai/gpt-oss-120b",
247
+ },
248
+ # Summarization LLM
249
+ "summarization": {
250
+ "enabled": False,
251
+ "provider": "vllm",
252
+ "model": None,
253
+ "endpoint": "",
254
+ "apiKey": "",
255
+ },
256
+ # Embedding
257
+ "embedding": {
258
+ "provider": "openai",
259
+ "model": "text-embedding-3-small",
260
+ "apiKey": "",
261
+ "endpoint": "",
262
+ },
263
+ # RAG
264
+ "rag": {
265
+ "enabled": True,
266
+ "knowledgeBasePath": None,
267
+ "qdrantMode": "local",
268
+ "qdrantUrl": "http://localhost:6333",
269
+ "qdrantPath": None,
270
+ "collectionName": "hdsp_docs",
271
+ "embeddingBackend": "vllm",
272
+ "vllmEndpoint": "http://localhost:8000",
273
+ "vllmModel": "qwen3-embedding-8b",
274
+ "vllmDimension": 4096,
275
+ "vllmApiKey": "",
276
+ },
277
+ # Server Settings
278
+ "idleTimeout": 3600,
279
+ "maxOutputTokens": 4096,
280
+ # Prompts (None = use defaults)
281
+ # 키 목록의 정본은 remote_config.PROMPT_KEYS (이슈 #113)
282
+ "prompts": {k: None for k in remote_config.PROMPT_KEYS},
283
+ # Guardrails
284
+ "guardrails": {
285
+ "enableInput": True,
286
+ "enableOutput": True,
287
+ "maxInputLength": 50000,
288
+ "maxCodeLength": 10000,
289
+ },
290
+ # Logging
291
+ "rawLlmLogging": False,
292
+ # User Settings
293
+ "workspaceRoot": "",
294
+ "autoApprove": False,
295
+ "codexPermissionMode": DEFAULT_CODEX_PERMISSION_MODE,
296
+ "codex": {
297
+ "endpoint": "",
298
+ "apiKey": "",
299
+ },
300
+ }
301
+
302
+ def get_config(self) -> Dict[str, Any]:
303
+ """Get current configuration"""
304
+ # Reload config from file to ensure we have the latest
305
+ self._config = self._load_config()
306
+ return self._effective(self._config)
307
+
308
+ def _effective(self, base: Dict[str, Any]) -> Dict[str, Any]:
309
+ """pod 파일 층(`base`)에 centri 중앙 설정을 얹은 사본 (이슈 #113).
310
+
311
+ `self._config` 는 파일 층 그대로 둔다 — 저장할 때 서버 값이 파일로 새지 않게.
312
+ URL 이 없거나 한 번도 받지 못했으면 오늘과 같다(pod 파일). 한 번이라도 받았으면
313
+ 조직 키는 서버 > 코드 기본값이다(파일 무시). 켜는 스위치가 시크릿 키가 아니라
314
+ centri 수신부의 첫 200 이라서, 수신부가 없는 동안 pod 파일 값이 사라지지 않는다.
315
+ """
316
+ cfg = copy.deepcopy(base)
317
+ user_set = cfg.pop("_userSet", None)
318
+ snap = remote_config.get_client().snapshot()
319
+ _apply_connection(cfg, snap)
320
+ if snap is None:
321
+ return cfg
322
+ defaults = self._default_config()
323
+ for path in remote_config.ORG_SCHEMA:
324
+ if path in snap:
325
+ _set_path(cfg, path, snap[path])
326
+ else:
327
+ found, value = _get_path(defaults, path)
328
+ if found:
329
+ _set_path(cfg, path, value)
330
+ else:
331
+ _del_path(cfg, path)
332
+ chosen = set(user_set) if isinstance(user_set, list) else set()
333
+ for key in remote_config.USER_KEYS:
334
+ # 개인설정(centri 사용자 행) > pod 파일에서 고른 값 > 조직 기본값
335
+ for path in (f"user.{key}", f"userDefaults.{key}"):
336
+ if path not in snap:
337
+ continue
338
+ if path.startswith("userDefaults.") and key in chosen:
339
+ break
340
+ cfg[key] = snap[path]
341
+ break
342
+ return cfg
343
+
344
+ def save_config(self, config: Dict[str, Any]):
345
+ """Save configuration to file - no validation"""
346
+ # Strip masked API keys to prevent overwriting real keys
347
+ config = _strip_masked_keys(config, self._config)
348
+ config = self._keep_file_org_values(config)
349
+ # Merge with existing config
350
+ self._config.update(config)
351
+
352
+ # Ensure config directory exists
353
+ self._config_file.parent.mkdir(parents=True, exist_ok=True)
354
+
355
+ # Write to file
356
+ try:
357
+ with open(self._config_file, "w") as f:
358
+ json.dump(self._config, f, indent=2)
359
+ except IOError as e:
360
+ raise RuntimeError(f"Failed to save config: {e}")
361
+
362
+ def _keep_file_org_values(self, config: Dict[str, Any]) -> Dict[str, Any]:
363
+ """서버·env 가 정하는 키는 파일 값으로 되돌려 저장한다 (이슈 #113).
364
+
365
+ `get_config()` 사본을 통째로 저장하는 경로(RAG 관리자 등)가 서버 값을 파일에
366
+ 쓰면, URL 을 지우고 재기동해도 옛 값으로 돌아가지 못한다. 사용자 기본값도
367
+ 같다 — 사용자가 고른 값은 `update_user_config` 가 따로 쓴다. 접속 설정은 URL 이
368
+ 없어도 env 가 얹히므로 늘 되돌린다 — 안 그러면 시크릿 API 키가 파일에 남는다.
369
+ """
370
+ if config is self._config:
371
+ return config
372
+ config = copy.deepcopy(config)
373
+ paths = list(remote_config.CONNECTION_ENV) + list(remote_config.API_KEY_ENV)
374
+ if remote_config.get_client().enabled:
375
+ paths += list(remote_config.ORG_SCHEMA) + list(remote_config.USER_KEYS)
376
+ for path in paths:
377
+ top = path.split(".", 1)[0]
378
+ if top not in config:
379
+ continue
380
+ found, value = _get_path(self._config, path)
381
+ if found:
382
+ _set_path(config, path, copy.deepcopy(value))
383
+ else:
384
+ _del_path(config, path)
385
+ if config.get(top) == {} and top not in self._config:
386
+ del config[top]
387
+ return config
388
+
389
+ def get(self, key: str, default=None):
390
+ """Get specific config value"""
391
+ return self._effective(self._config).get(key, default)
392
+
393
+ def set(self, key: str, value: Any):
394
+ """Set specific config value"""
395
+ self._config[key] = value
396
+ self.save_config(self._config)
397
+
398
+ def update_config(self, updates: Dict[str, Any]):
399
+ """Update configuration with partial updates (deep merge for nested dicts)"""
400
+ # Strip masked API keys to prevent overwriting real keys
401
+ updates = _strip_masked_keys(updates, self._config)
402
+ for key, value in updates.items():
403
+ if (
404
+ key in self._config
405
+ and isinstance(self._config[key], dict)
406
+ and isinstance(value, dict)
407
+ ):
408
+ self._config[key].update(value)
409
+ else:
410
+ self._config[key] = value
411
+ self.save_config(self._config)
412
+
413
+ def get_codex_settings(self) -> Dict[str, str]:
414
+ """codex 접속 설정 — 주소는 centri > env > 설정 파일, 키는 env > 설정 파일 (이슈 #88·#113).
415
+
416
+ endpoint 가 빈 문자열이면 codex 엔진을 쓸 수 없는 상태다.
417
+ """
418
+ codex = self.get_config().get("codex")
419
+ if not isinstance(codex, dict):
420
+ return {"endpoint": "", "apiKey": ""}
421
+ return {
422
+ "endpoint": codex.get("endpoint", "") or "",
423
+ "apiKey": codex.get("apiKey", "") or "",
424
+ }
425
+
426
+ def get_inference_engine(self) -> str:
427
+ """전역 추론 엔진 — 값이 없거나 허용값 밖이면 codex (이슈 #155)."""
428
+ features = self.get_config().get("features")
429
+ value = features.get("inference_engine") if isinstance(features, dict) else None
430
+ return _coerce_choice(value, INFERENCE_ENGINES, DEFAULT_INFERENCE_ENGINE)
431
+
432
+ def get_agent_enabled(self) -> bool:
433
+ """agent 모드 실효값 — centri > (못 받았으면) pod 파일 > 기본값 (이슈 #179)."""
434
+ features = self.get_config().get("features")
435
+ value = features.get("agent_enabled") if isinstance(features, dict) else None
436
+ return value if isinstance(value, bool) else DEFAULT_AGENT_ENABLED
437
+
438
+ def get_user_config(self) -> Dict[str, Any]:
439
+ """Get user configuration (workspace, autoApprove, codex 권한). temperature 는 0.0 고정 (이슈 #171)."""
440
+ config = self.get_config()
441
+ return {
442
+ "workspaceRoot": config.get("workspaceRoot", ""),
443
+ "autoApprove": config.get("autoApprove", False),
444
+ "codexPermissionMode": _coerce_choice(
445
+ config.get("codexPermissionMode"),
446
+ CODEX_PERMISSION_MODES,
447
+ DEFAULT_CODEX_PERMISSION_MODE,
448
+ ),
449
+ }
450
+
451
+ def update_user_config(self, updates: Dict[str, Any]):
452
+ """Update user configuration (only allowed fields)."""
453
+ allowed_fields = {
454
+ "workspaceRoot",
455
+ "autoApprove",
456
+ "codexPermissionMode",
457
+ }
458
+ # 열거형 필드는 값까지 본다 — 화이트리스트만 보면 오타가 그대로 저장돼
459
+ # 읽는 쪽이 매번 폴백을 해야 한다 (이슈 #88).
460
+ enum_fields = {
461
+ "codexPermissionMode": CODEX_PERMISSION_MODES,
462
+ }
463
+ filtered_updates = {
464
+ k: v
465
+ for k, v in updates.items()
466
+ if k in allowed_fields and (k not in enum_fields or v in enum_fields[k])
467
+ }
468
+ if not filtered_updates:
469
+ return
470
+ client = remote_config.get_client()
471
+ if client.can_save_user:
472
+ self._save_user_remote(client, filtered_updates)
473
+ return
474
+ self._track_user_choices(filtered_updates)
475
+ self.update_config(filtered_updates)
476
+
477
+ def _save_user_remote(self, client, updates: Dict[str, Any]):
478
+ """개인설정을 centri 사용자 행에 쓴다 (이슈 #113). 실패는 예외로 올린다.
479
+
480
+ 조직 기본값과 같은 값은 행을 지운다 — 패널은 모든 칸을 보내므로, 그대로 두면 한 번
481
+ 저장한 사용자는 조직 기본값이 바뀌어도 영영 못 받는다. 저장한 키는 pod 파일의
482
+ `_userSet` 에서 뺀다: 서버 행을 지웠을 때 파일의 옛 선택이 되살아나지 않게.
483
+ """
484
+ values = remote_config.sanitize_user(updates)
485
+ baseline = self._org_user_defaults()
486
+ set_values = {k: v for k, v in values.items() if v != baseline.get(k)}
487
+ unset = [k for k in values if k not in set_values]
488
+ client.save_user(set_values, unset)
489
+ # 저장한 키는 pod 파일 값도 코드 기본값으로 되돌린다. 조직 기본값과 같은 값을 골라
490
+ # 서버 행을 지웠는데 스냅샷에 `userDefaults.<키>` 가 없으면, 파일의 옛 값이 다시 보인다.
491
+ defaults = self._default_config()
492
+ changed = False
493
+ for key in values:
494
+ if key in defaults and self._config.get(key) != defaults[key]:
495
+ self._config[key] = defaults[key]
496
+ changed = True
497
+ elif key not in defaults and key in self._config:
498
+ del self._config[key]
499
+ changed = True
500
+ chosen = self._config.get("_userSet")
501
+ if isinstance(chosen, list) and set(chosen) & set(values):
502
+ self._config["_userSet"] = sorted(set(chosen) - set(values))
503
+ changed = True
504
+ if changed:
505
+ self.save_config(self._config)
506
+
507
+ def describe_sources(self) -> List[Dict[str, Any]]:
508
+ """설정 화면 "센트리에서 받은 설정" 의 행 — 허용 목록 키의 실효값과 출처 (이슈 #183).
509
+
510
+ 출처는 `portal`(centri) · `env` · `file`(pod 파일) · `default`(코드 기본값) 중 하나이고
511
+ `_effective` 의 우선순위를 그대로 따른다. API 키 경로는 허용 목록에 없다.
512
+ 프롬프트는 본문 대신 글자 수를 싣고, 정한 것만 싣는다 — 안 정한 것은 스냅샷 기본값
513
+ (`codebuff_config_defaults.json`)이다 (이슈 #218).
514
+ 프롬프트 행에는 그 키를 읽는 엔진·자리(`appliesTo`)를 붙인다 (이슈 #208).
515
+ """
516
+ base = self._load_config()
517
+ snap = remote_config.get_client().snapshot()
518
+ effective = self._effective(base)
519
+ defaults = self._default_config()
520
+ rows: List[Dict[str, Any]] = []
521
+ for path in list(remote_config.CONNECTION_ENV) + list(remote_config.ORG_SCHEMA):
522
+ connection = path in remote_config.CONNECTION_ENV
523
+ if snap is not None and path in snap:
524
+ source = "portal"
525
+ elif connection and _connection_env(path) is not None:
526
+ source = "env"
527
+ elif (snap is None or connection) and _in_file(base, defaults, path):
528
+ source = "file"
529
+ else:
530
+ source = "default"
531
+ _, value = _get_path(effective, path)
532
+ if path in _FEATURE_EFFECTIVE:
533
+ # 안 정한 기능 키는 부르는 쪽이 기본값을 채운다 — 표도 그 값을 보인다
534
+ value = _FEATURE_EFFECTIVE[path](self)
535
+ if path.startswith("prompts."):
536
+ if not isinstance(value, str) or not value.strip():
537
+ continue
538
+ # 옛 프론트가 파일에 남긴 로딩 문구는 값이 아니다 (configured_prompt 와 같은 판정)
539
+ if value == "(프롬프트를 불러오는 중...)":
540
+ continue
541
+ value = len(value)
542
+ engines, where = remote_config.PROMPT_KEY_SCOPES[
543
+ path[len("prompts.") :]
544
+ ]
545
+ rows.append(
546
+ {
547
+ "key": path,
548
+ "value": value,
549
+ "source": source,
550
+ # 이 키를 읽는 엔진·자리 — 화면이 현재 엔진과 대조한다 (이슈 #208)
551
+ "appliesTo": {"engines": list(engines), "where": where},
552
+ }
553
+ )
554
+ continue
555
+ rows.append({"key": path, "value": value, "source": source})
556
+ return rows
557
+
558
+ def _org_user_defaults(self) -> Dict[str, Any]:
559
+ """개인설정 칸마다 사용자가 아무것도 안 골랐을 때 보이는 값."""
560
+ snap = remote_config.get_client().snapshot() or {}
561
+ defaults = self._default_config()
562
+ return {
563
+ key: snap.get(f"userDefaults.{key}", defaults.get(key))
564
+ for key in remote_config.USER_KEYS
565
+ }
566
+
567
+ def _track_user_choices(self, updates: Dict[str, Any]):
568
+ """조직 기본값과 다른 값을 고른 키만 `_userSet` 에 남긴다 (이슈 #113).
569
+
570
+ 패널은 필드를 한꺼번에 저장하므로 "저장했다" 가 곧 "골랐다" 가 아니다. 손대지
571
+ 않은 값까지 고정하면 조직 기본값이 바뀌어도 그 사용자에게는 영영 안 닿는다.
572
+ """
573
+ snap = remote_config.get_client().snapshot() or {}
574
+ defaults = self._default_config()
575
+ chosen = self._config.get("_userSet")
576
+ chosen = set(chosen) if isinstance(chosen, list) else set()
577
+ for key in remote_config.USER_DEFAULT_KEYS:
578
+ if key not in updates:
579
+ continue
580
+ baseline = snap.get(f"userDefaults.{key}", defaults.get(key))
581
+ if updates[key] == baseline:
582
+ chosen.discard(key)
583
+ else:
584
+ chosen.add(key)
585
+ self._config["_userSet"] = sorted(chosen)
586
+
587
+
588
+ def _apply_connection(cfg: Dict[str, Any], snap: Optional[Dict[str, Any]]):
589
+ """접속 설정을 얹는다 — 주소·모델은 centri > env > pod 파일, API 키는 env > pod 파일.
590
+
591
+ API 키는 centri 로도 관리 화면으로도 오지 않는다. 시크릿 env 가 유일한 운영 경로다.
592
+ """
593
+ for path in remote_config.CONNECTION_ENV:
594
+ if snap is not None and path in snap:
595
+ _set_path(cfg, path, snap[path])
596
+ continue
597
+ value = _connection_env(path)
598
+ if value is not None:
599
+ _set_path(cfg, path, value)
600
+ for path, env in remote_config.API_KEY_ENV.items():
601
+ raw = os.environ.get(env, "")
602
+ if raw:
603
+ _set_path(cfg, path, raw)
604
+
605
+
606
+ def _connection_env(path: str) -> Any:
607
+ """접속 키의 env 값 — 비었거나 규격 밖(차원이 숫자가 아님)이면 None."""
608
+ raw = os.environ.get(remote_config.CONNECTION_ENV[path], "").strip()
609
+ if not raw:
610
+ return None
611
+ if path == "rag.vllmDimension":
612
+ return int(raw) if raw.isdigit() else None
613
+ return raw
614
+
615
+
616
+ def _in_file(base: Dict[str, Any], defaults: Dict[str, Any], path: str) -> bool:
617
+ """파일이 이 키를 기본값과 다르게 정했는가. 파일에 없는 키는 파일 출처가 아니다."""
618
+ found, value = _get_path(base, path)
619
+ return found and (found, value) != _get_path(defaults, path)
620
+
621
+
622
+ # 기본값이 `_default_config()` 가 아니라 게터에 있는 기능 키 (이슈 #183)
623
+ _FEATURE_EFFECTIVE = {
624
+ "features.eda_enabled": lambda cm: _feature(cm, "eda_enabled") is not False,
625
+ "features.bdp_qa_enabled": lambda cm: _feature(cm, "bdp_qa_enabled") is not False,
626
+ "features.agent_enabled": lambda cm: cm.get_agent_enabled(),
627
+ "features.inference_engine": lambda cm: cm.get_inference_engine(),
628
+ }
629
+
630
+
631
+ def _feature(cm: "ConfigManager", key: str) -> Any:
632
+ features = cm.get_config().get("features")
633
+ return features.get(key) if isinstance(features, dict) else None
634
+
635
+
636
+ def _get_path(cfg: Dict[str, Any], path: str):
637
+ node: Any = cfg
638
+ for part in path.split("."):
639
+ if not isinstance(node, dict) or part not in node:
640
+ return False, None
641
+ node = node[part]
642
+ return True, node
643
+
644
+
645
+ def _set_path(cfg: Dict[str, Any], path: str, value: Any):
646
+ *parents, leaf = path.split(".")
647
+ node = cfg
648
+ for part in parents:
649
+ if not isinstance(node.get(part), dict):
650
+ node[part] = {}
651
+ node = node[part]
652
+ node[leaf] = value
653
+
654
+
655
+ def _del_path(cfg: Dict[str, Any], path: str):
656
+ *parents, leaf = path.split(".")
657
+ node: Any = cfg
658
+ for part in parents:
659
+ node = node.get(part) if isinstance(node, dict) else None
660
+ if isinstance(node, dict):
661
+ node.pop(leaf, None)
662
+
663
+
664
+ def get_config_manager() -> ConfigManager:
665
+ """Get the singleton ConfigManager instance"""
666
+ return ConfigManager.get_instance()
667
+
668
+
669
+ def configured_prompt(key: str) -> str:
670
+ """관리자가 정한 `prompts.<key>` (centri > pod 파일). 없으면 빈 문자열 (이슈 #178).
671
+
672
+ 빈 문자열이면 부르는 쪽이 스냅샷 기본값을 쓴다(`prompt_value`). 프롬프트 조립을 설정 오류로 막지 않는다.
673
+ """
674
+ try:
675
+ value = get_config_manager().get_config().get("prompts", {}).get(key)
676
+ except Exception:
677
+ return ""
678
+ if not isinstance(value, str) or not value.strip():
679
+ return ""
680
+ # 옛 프론트가 파일에 남긴 로딩 문구는 값이 아니다 (agent_factory 와 같은 판정)
681
+ return "" if value == "(프롬프트를 불러오는 중...)" else value
682
+
683
+
684
+ def prompt_value(key: str) -> str:
685
+ """`prompts.<key>` 의 실효값 — 관리자 값(centri > pod 파일), 없으면 스냅샷 기본값 (이슈 #218).
686
+
687
+ 코드 상수 폴백은 없다. 기본값의 정본은 centri 이고 codebuff 는 그것을 옮긴
688
+ `codebuff_config_defaults.json` 만 들고 있다.
689
+ """
690
+ return configured_prompt(key) or remote_config.snapshot_prompt(key)