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,599 @@
1
+ """코드버프 로그 RDS 적재 송신부 (이슈 #30).
2
+
3
+ 지금 로그는 pod 로컬 JSONL 파일로만 쌓인다. 이 모듈은 **파일 쓰기는 그대로 두고**
4
+ 같은 이벤트를 cd1-agent 수집 API 로 한 벌 더 보낸다. 파일이 내구 사본으로 남으므로
5
+ 전송이 실패해도 데이터를 잃지 않는다.
6
+
7
+ 세 가지가 이 모듈의 계약이다.
8
+
9
+ 1. **호출자를 절대 막지 않는다.** `enqueue`/`flush` 는 어떤 경우에도 예외를 밖으로
10
+ 내지 않고, 큐가 차면 버린다. 로깅 실패가 사용자 작업을 막으면 안 된다 —
11
+ 기존 `analytics_handler` 의 `except: pass` 성질을 그대로 잇는다.
12
+ 2. **`CODEBUFF_INGEST_URL` 이 비면 완전한 no-op 이다.** 큐에 쌓지도 않는다. 그래서
13
+ 수집 API·시크릿·네트워크가 준비되기 전에도 이 코드를 안전하게 머지할 수 있다.
14
+ 3. **`context` 는 원문 그대로 보낸다** (이슈 #30 판단, 2026-09-03). 화이트리스트로
15
+ 지우지 않는다. 대신 행 크기 상한 하나만 코드가 진다 — `MAX_CONTEXT_BYTES` 를
16
+ 넘으면 **행을 버리지 않고 잘라서** `context_truncated` 로 표시한다.
17
+ ⚠️ 원문에는 사용자 프롬프트와 셀 소스코드가 들어 있다. 접근 통제(GRANT)와
18
+ 보존기간(TTL)은 DB 쪽에서 정해야 하는 몫이다 (이슈 #30 `## 미해결`).
19
+
20
+ 재시도는 하지 않는다. 대신 `event_id` 에 UNIQUE 를 걸어(`schemas/rds/codebuff_mysql.sql`)
21
+ 수집 API 가 나중에 재시도를 넣더라도 중복이 쌓이지 않게 해 뒀다.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import atexit
27
+ import json
28
+ import logging
29
+ import os
30
+ import queue
31
+ import threading
32
+ import time
33
+ import uuid
34
+ from datetime import datetime, timedelta, timezone
35
+ from typing import Any, Optional
36
+
37
+ from .team_access import resolve_team
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ #: 사내 표준 시간대. 컨테이너 TZ 에 기대지 않고 오프셋을 명시한다 — 기존
42
+ #: `analytics_handler` 의 `datetime.now()` 는 naive 라 컨테이너가 UTC 면 9시간 밀린다.
43
+ KST = timezone(timedelta(hours=9))
44
+
45
+ #: `context` JSON 한 건의 상한. 원문 적재를 택했으므로 상한이 없으면 페이로드가
46
+ #: 무한정 커진다 (선례 `telemetry_ingest.py` 는 본문 4MB 를 넘기면 요청 전체를 버린다).
47
+ MAX_CONTEXT_BYTES = 64 * 1024
48
+
49
+ QUEUE_MAXSIZE = 5000
50
+
51
+ #: 절단 폴백에서 "작다" 의 기준. 집계가 쓰는 필드(cell_index, cell_attribution,
52
+ #: agent_code_ratio_pct)는 전부 이보다 훨씬 작다.
53
+ _SMALL_VALUE_BYTES = 512
54
+ FLUSH_BATCH = 200
55
+ #: 요청 1건의 바이트 상한 (이슈 #98). 행 수만으로 자르면 최악 200행 × 64KB = 12.8MB 가
56
+ #: 나가는데, 수신 선례(`telemetry_ingest.py`)의 본문 상한은 4MB 이고 413 이면 배치 전체를
57
+ #: 버린다. 헤더·키 여유를 두고 3MB 로 자른다.
58
+ MAX_BATCH_BYTES = 3 * 1024 * 1024
59
+ FLUSH_INTERVAL_SEC = 30.0
60
+ HTTP_TIMEOUT_SEC = 5.0
61
+
62
+ #: 세션 종료 경로의 예산. 이 경로는 **async 호출자 안에서 동기로** 돌기 때문에
63
+ #: (`idle_monitor._monitor_loop`, `IdleShutdownHandler.post` 둘 다 `async def`)
64
+ #: 이벤트루프를 잡는다. 그래서 요청 1건의 타임아웃이 아니라 **전체 예산**을 건다 —
65
+ #: 요청마다 2초씩 무한정 쓰는 일이 없도록 배치 루프가 이 예산을 보고 멈춘다.
66
+ SESSION_END_BUDGET_SEC = 2.0
67
+ SESSION_END_REQUEST_TIMEOUT_SEC = 1.0
68
+ SCHEMA_VERSION = 1
69
+ #: codebuff↔centri 통신의 공유 키 — codex 게이트웨이와 같은 `codex_api_key` 를 Bearer 로 보낸다.
70
+ #: 수신기 전용 토큰은 두지 않는다 (이슈 #113, 2026-09-25 사용자 결정).
71
+ TOKEN_ENV = "CODEX_SERVER_API_KEY"
72
+
73
+ TABLE_UX_EVENT = "ux_event_hst"
74
+ TABLE_AGENT_CALL = "agent_call_hst"
75
+ TABLE_SESSION_END = "session_end_hst"
76
+ _TABLES = (TABLE_UX_EVENT, TABLE_AGENT_CALL, TABLE_SESSION_END)
77
+
78
+ _UX_EVENT_TYPES = ("impression", "click", "error")
79
+ _AGENT_EVENT_KINDS = ("model_call", "tool_call", "error_classification", "turn")
80
+
81
+ _ENV_TEAM = resolve_team()
82
+ _ENV_PROJECT = os.environ.get("HDSP_PRJ_ID", "")
83
+ _ENV_USER = os.environ.get("JUPYTERHUB_USER", "") or os.environ.get("HDSP_BDP_ID", "")
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # 값 정규화
88
+ # ---------------------------------------------------------------------------
89
+ def _as_float(value: Any) -> Optional[float]:
90
+ try:
91
+ if value is None or isinstance(value, bool):
92
+ return None
93
+ return float(value)
94
+ except (TypeError, ValueError):
95
+ return None
96
+
97
+
98
+ def _as_int(value: Any) -> Optional[int]:
99
+ try:
100
+ if value is None or isinstance(value, bool):
101
+ return None
102
+ return int(value)
103
+ except (TypeError, ValueError):
104
+ return None
105
+
106
+
107
+ def _as_flag(value: Any) -> Optional[int]:
108
+ if value is None:
109
+ return None
110
+ return 1 if value else 0
111
+
112
+
113
+ def _as_text(value: Any) -> Optional[str]:
114
+ if value is None:
115
+ return None
116
+ return value if isinstance(value, str) else str(value)
117
+
118
+
119
+ def _as_clipped(value: Any, limit: int) -> Optional[str]:
120
+ """칼럼 폭까지 자른다. 넘겨 보내면 INSERT IGNORE 가 경고만 남기고 조용히 자른다."""
121
+ text = _as_text(value)
122
+ return None if text is None else text[:limit]
123
+
124
+
125
+ def _blob_len(obj: Any) -> int:
126
+ return len(json.dumps(obj, ensure_ascii=False, default=str).encode("utf-8"))
127
+
128
+
129
+ def _normalize_context(raw: Any) -> "tuple[dict, int]":
130
+ """`context` 를 원문 그대로 두되 타입 드리프트만 흡수하고 상한을 건다.
131
+
132
+ 반환: (context, context_truncated)
133
+ """
134
+ if not isinstance(raw, dict):
135
+ return {}, 0
136
+
137
+ ctx = dict(raw)
138
+
139
+ # 실측 드리프트: 같은 키가 int(43건)/float(26건) 으로 섞여 들어온다.
140
+ # JSON 타입이 섞이면 조회 쪽에서 캐스팅이 갈라지므로 여기서 하나로 만든다.
141
+ if "agent_code_ratio_pct" in ctx:
142
+ ctx["agent_code_ratio_pct"] = _as_float(ctx["agent_code_ratio_pct"])
143
+
144
+ if _blob_len(ctx) <= MAX_CONTEXT_BYTES:
145
+ return ctx, 0
146
+
147
+ # 긴 문자열부터 반씩 줄인다. 집계가 쓰는 필드는 짧아서 마지막까지 남는다.
148
+ for _ in range(64):
149
+ if _blob_len(ctx) <= MAX_CONTEXT_BYTES:
150
+ return ctx, 1
151
+ longest_key = None
152
+ longest_len = 0
153
+ for key, value in ctx.items():
154
+ if isinstance(value, str) and len(value) > longest_len:
155
+ longest_key, longest_len = key, len(value)
156
+ if longest_key is None:
157
+ break
158
+ ctx[longest_key] = (
159
+ ctx[longest_key][: longest_len // 2] if longest_len > 16 else ""
160
+ )
161
+
162
+ if _blob_len(ctx) > MAX_CONTEXT_BYTES:
163
+ # 문자열 halving 으로 못 줄인 경우(중첩 구조·키가 아주 많은 경우).
164
+ # 통째로 버리면 A5 가 쓰는 cell_attribution 같은 **작은 집계 필드까지** 사라진다.
165
+ # 그래서 작은 값만 남기고 큰 값을 버린다.
166
+ kept = {
167
+ key: value
168
+ for key, value in ctx.items()
169
+ if _blob_len(value) <= _SMALL_VALUE_BYTES
170
+ }
171
+ kept["_truncated"] = True
172
+ ctx = kept if _blob_len(kept) <= MAX_CONTEXT_BYTES else {"_truncated": True}
173
+ return ctx, 1
174
+
175
+
176
+ def _identity_of(event: dict, identity: Optional[dict]) -> "dict[str, str]":
177
+ src = identity or {}
178
+ return {
179
+ "team": event.get("team") or src.get("team") or _ENV_TEAM,
180
+ "project": event.get("project") or src.get("project") or _ENV_PROJECT,
181
+ "user_id": event.get("user_id") or src.get("user_id") or _ENV_USER,
182
+ }
183
+
184
+
185
+ def _from_epoch_ms(value: Any) -> Optional[datetime]:
186
+ """브라우저 `Date.now()` (epoch ms) → KST. 유일하게 모호하지 않은 시각원이다."""
187
+ ms = _as_int(value)
188
+ if ms is None or ms <= 0:
189
+ return None
190
+ try:
191
+ return datetime.fromtimestamp(ms / 1000.0, KST)
192
+ except (OverflowError, OSError, ValueError):
193
+ return None
194
+
195
+
196
+ def _from_naive_text(value: Any) -> Optional[datetime]:
197
+ """파일에 적힌 naive 시각 문자열 → KST 로 간주.
198
+
199
+ ⚠️ 원본이 `datetime.now()` (naive) 라 pod 컨테이너 TZ 가 UTC 면 실제로는 UTC 다.
200
+ 그 판정은 이슈 #30 `## 미해결` [측정] 이 추적한다. 여기서는 KST 로 간주하되,
201
+ epoch ms 가 있으면 그쪽을 먼저 쓴다.
202
+ """
203
+ text = _as_text(value)
204
+ if not text:
205
+ return None
206
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
207
+ try:
208
+ return datetime.strptime(text, fmt).replace(tzinfo=KST)
209
+ except ValueError:
210
+ continue
211
+ return None
212
+
213
+
214
+ def _stamp(*candidates: Any) -> "tuple[str, str]":
215
+ """이벤트 자신의 시각을 우선 쓰고, 없을 때만 현재 시각으로 떨어진다.
216
+
217
+ 빌드 시각을 찍으면 라이브 적재는 맞지만 **파일을 나중에 배치로 백필할 때
218
+ 전부 오늘 날짜가 된다.** 파일이 내구 사본이라는 이 설계의 전제가 깨지므로
219
+ 이벤트가 들고 온 시각을 먼저 본다.
220
+ """
221
+ moment = next((c for c in candidates if isinstance(c, datetime)), None)
222
+ if moment is None:
223
+ moment = datetime.now(KST)
224
+ return moment.strftime("%Y-%m-%d %H:%M:%S"), moment.strftime("%Y-%m-%d")
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # 행 빌더 — 순수 함수. 실패하면 예외 대신 None 을 돌려준다
229
+ # ---------------------------------------------------------------------------
230
+ def build_ux_event_row(
231
+ event: Any,
232
+ identity: Optional[dict] = None,
233
+ now: Optional[datetime] = None,
234
+ ) -> Optional[dict]:
235
+ """analytics 이벤트(impression/click/error) → `ux_event_hst` 행."""
236
+ try:
237
+ if not isinstance(event, dict):
238
+ return None
239
+ event_type = event.get("event_type")
240
+ if event_type not in _UX_EVENT_TYPES:
241
+ return None
242
+ # 레거시 키 `event_name` 을 현행 `feature` 로 접는다 (실측 2건).
243
+ feature = event.get("feature") or event.get("event_name")
244
+ if not feature:
245
+ return None
246
+
247
+ context, truncated = _normalize_context(event.get("context"))
248
+ event_at, event_dt = _stamp(
249
+ now,
250
+ _from_epoch_ms(event.get("timestamp")),
251
+ _from_naive_text(event.get("datetime")),
252
+ )
253
+ return {
254
+ "event_id": _as_text(event.get("event_id")) or str(uuid.uuid4()),
255
+ "session_id": _as_text(event.get("session_id")),
256
+ "event_type": event_type,
257
+ "feature": _as_text(feature),
258
+ "event_at": event_at,
259
+ "event_dt": event_dt,
260
+ "client_ts": _as_int(event.get("timestamp")),
261
+ "extension_version": _as_text(event.get("extension_version")),
262
+ "cpu_cores": _as_float(event.get("cpu_cores")),
263
+ "memory_total_gb": _as_float(event.get("memory_total_gb")),
264
+ "gpu_name": _as_text(event.get("gpu_name")),
265
+ "gpu_memory_total_mb": _as_int(event.get("gpu_memory_total_mb")),
266
+ "context": context,
267
+ "context_truncated": truncated,
268
+ **_identity_of(event, identity),
269
+ }
270
+ except Exception:
271
+ logger.debug("build_ux_event_row failed", exc_info=True)
272
+ return None
273
+
274
+
275
+ def build_agent_call_row(
276
+ event: Any,
277
+ identity: Optional[dict] = None,
278
+ now: Optional[datetime] = None,
279
+ ) -> Optional[dict]:
280
+ """구조화 이벤트(model_call/tool_call/error_classification/turn) → `agent_call_hst` 행."""
281
+ try:
282
+ if not isinstance(event, dict):
283
+ return None
284
+ kind = event.get("event")
285
+ if kind not in _AGENT_EVENT_KINDS:
286
+ return None
287
+
288
+ called_at, called_dt = _stamp(now, _from_naive_text(event.get("ts")))
289
+ return {
290
+ "event_id": str(uuid.uuid4()),
291
+ "event_kind": kind,
292
+ "agent_backend": _as_text(event.get("agent_backend")),
293
+ "called_at": called_at,
294
+ "called_dt": called_dt,
295
+ "session_id": _as_text(event.get("session_id")),
296
+ "ux_session_id": _as_text(event.get("ux_session_id")),
297
+ "call_num": _as_int(event.get("call_num")),
298
+ "elapsed_ms": _as_float(event.get("elapsed_ms")),
299
+ "status": _as_text(event.get("status")),
300
+ "tool_name": _as_text(event.get("tool_name")),
301
+ "error_message": _as_text(event.get("error")),
302
+ "input_msg_count": _as_int(event.get("input_msg_count")),
303
+ "output_tool_calls": _as_int(event.get("output_tool_calls")),
304
+ "model_name": _as_clipped(event.get("model_name"), 64),
305
+ "agent_name": _as_clipped(event.get("agent_name"), 64),
306
+ "input_tokens": _as_int(event.get("input_tokens")),
307
+ "output_tokens": _as_int(event.get("output_tokens")),
308
+ "cached_tokens": _as_int(event.get("cached_tokens")),
309
+ "finish_reason": _as_clipped(event.get("finish_reason"), 32),
310
+ "error_type": _as_text(event.get("error_type")),
311
+ "decision": _as_text(event.get("decision")),
312
+ "root_cause": _as_text(event.get("root_cause")),
313
+ "used_llm": _as_flag(event.get("used_llm")),
314
+ "confidence": _as_float(event.get("confidence")),
315
+ **_identity_of(event, identity),
316
+ }
317
+ except Exception:
318
+ logger.debug("build_agent_call_row failed", exc_info=True)
319
+ return None
320
+
321
+
322
+ def build_session_end_row(
323
+ event: Any,
324
+ identity: Optional[dict] = None,
325
+ now: Optional[datetime] = None,
326
+ ) -> Optional[dict]:
327
+ """idle_shutdown 이벤트 → `session_end_hst` 행.
328
+
329
+ 이 이벤트는 pod 가 죽기 직전에 한 번 나온다. 필드가 비어 있어도 "세션이 끝났다" 는
330
+ 사실 자체가 정보이므로 **행을 만들어 낸다** (다른 둘과 달리 None 을 돌려주지 않는다).
331
+ """
332
+ try:
333
+ if not isinstance(event, dict):
334
+ event = {}
335
+ ended_at, ended_dt = _stamp(now, _from_naive_text(event.get("ts")))
336
+ return {
337
+ "event_id": str(uuid.uuid4()),
338
+ "ended_at": ended_at,
339
+ "ended_dt": ended_dt,
340
+ "idle_seconds": _as_float(event.get("idle_seconds")),
341
+ "timeout_setting": _as_int(event.get("timeout_setting")),
342
+ "trigger_source": _as_text(event.get("trigger_source")),
343
+ **_identity_of(event, identity),
344
+ }
345
+ except Exception:
346
+ logger.debug("build_session_end_row failed", exc_info=True)
347
+ return None
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # 전송 클라이언트
352
+ # ---------------------------------------------------------------------------
353
+ class IngestClient:
354
+ """유계 큐 + 데몬 스레드 1개로 수집 API 에 배치 전송한다.
355
+
356
+ 스레드가 하나인 것은 의도다 — async 핸들러(`analytics_handler`)와 sync writer
357
+ (`observability`, `log_paths`) 세 경로가 같은 큐를 쓰므로 전송 코드가 한 벌로 남는다.
358
+ """
359
+
360
+ def __init__(
361
+ self,
362
+ url: str = "",
363
+ token: str = "",
364
+ queue_maxsize: int = QUEUE_MAXSIZE,
365
+ transport: Any = None,
366
+ start_worker: bool = True,
367
+ flush_interval: float = FLUSH_INTERVAL_SEC,
368
+ batch: int = FLUSH_BATCH,
369
+ ) -> None:
370
+ self._url = (url or "").strip()
371
+ self._token = token or ""
372
+ self._queue: "queue.Queue[tuple]" = queue.Queue(maxsize=queue_maxsize)
373
+ self._transport = transport
374
+ self._batch = batch
375
+ self._flush_interval = flush_interval
376
+ self._stop = threading.Event()
377
+ self._thread: Optional[threading.Thread] = None
378
+ # 이벤트루프 스레드·데몬·to_thread 워커가 동시에 올릴 수 있다.
379
+ self._counter_lock = threading.Lock()
380
+ # 바이트 상한에 걸려 다음 배치로 넘긴 행. 큐 앞에 되돌릴 수 없어서 따로 든다.
381
+ # drain 은 데몬과 세션 종료 경로가 동시에 부를 수 있어 잠근다.
382
+ self._carry: Optional[tuple] = None
383
+ self._drain_lock = threading.Lock()
384
+ self.dropped = 0
385
+ # 설정 화면의 "사용 로그 수신" 줄 (이슈 #183). 실패는 여전히 삼키고 기록만 한다
386
+ self._last_ok_at: Optional[str] = None
387
+ self._last_error: Optional[str] = None
388
+ if start_worker and self.enabled:
389
+ self._thread = threading.Thread(
390
+ target=self._run, name="codebuff-ingest", daemon=True
391
+ )
392
+ self._thread.start()
393
+
394
+ @property
395
+ def enabled(self) -> bool:
396
+ return bool(self._url)
397
+
398
+ @property
399
+ def queued(self) -> int:
400
+ return self._queue.qsize() + (1 if self._carry is not None else 0)
401
+
402
+ def status(self) -> "dict[str, Any]":
403
+ """송신 상태 — `disabled`·`pending`(아직 안 보냄)·`ok`·`error`. 마지막 전송이 정한다."""
404
+ with self._counter_lock:
405
+ if not self.enabled:
406
+ state = "disabled"
407
+ elif self._last_error is not None:
408
+ state = "error"
409
+ elif self._last_ok_at is not None:
410
+ state = "ok"
411
+ else:
412
+ state = "pending"
413
+ return {
414
+ "state": state,
415
+ "queued": self.queued,
416
+ "dropped": self.dropped,
417
+ "lastOkAt": self._last_ok_at,
418
+ "lastError": self._last_error,
419
+ }
420
+
421
+ def _record(self, error: Optional[str]) -> None:
422
+ with self._counter_lock:
423
+ if error is None:
424
+ self._last_ok_at = datetime.now(KST).isoformat(timespec="seconds")
425
+ self._last_error = error
426
+
427
+ def enqueue(self, table: str, row: Optional[dict]) -> None:
428
+ """행 하나를 큐에 넣는다. **어떤 경우에도 예외를 내지 않는다.**"""
429
+ try:
430
+ if not self.enabled or row is None or table not in _TABLES:
431
+ return
432
+ try:
433
+ self._queue.put_nowait((table, row))
434
+ except queue.Full:
435
+ # 버리는 것이 맞다. 여기서 블로킹하면 사용자 요청이 멈춘다.
436
+ with self._counter_lock:
437
+ self.dropped += 1
438
+ except Exception:
439
+ logger.debug("ingest enqueue failed", exc_info=True)
440
+
441
+ def drain_payload(self, limit: Optional[int] = None) -> dict:
442
+ """큐에서 최대 `limit` 행, `MAX_BATCH_BYTES` 이하를 꺼내 페이로드로 묶는다.
443
+
444
+ 상한을 넘기는 행은 버리지 않고 다음 배치의 첫 행으로 넘긴다. 행 1개가 상한보다
445
+ 크더라도 그 행 혼자 한 배치로 나간다 (`MAX_CONTEXT_BYTES` 가 행 크기를 막는다).
446
+ """
447
+ rows: "dict[str, list]" = {}
448
+ cap = limit or self._batch
449
+ with self._drain_lock:
450
+ used = 0
451
+ for _ in range(cap):
452
+ if self._carry is not None:
453
+ item, self._carry = self._carry, None
454
+ else:
455
+ try:
456
+ item = self._queue.get_nowait()
457
+ except queue.Empty:
458
+ break
459
+ size = _blob_len(item[1])
460
+ if rows and used + size > MAX_BATCH_BYTES:
461
+ self._carry = item
462
+ break
463
+ used += size
464
+ rows.setdefault(item[0], []).append(item[1])
465
+ return {"schema_version": SCHEMA_VERSION, "rows": rows}
466
+
467
+ def flush(
468
+ self,
469
+ timeout: float = HTTP_TIMEOUT_SEC,
470
+ budget: Optional[float] = None,
471
+ ) -> None:
472
+ """큐를 비운다. 전송 실패도 조용히 버린다 — 재시도하지 않는다.
473
+
474
+ `timeout` 은 **요청 1건**의 상한이고 `budget` 은 이 호출 **전체**의 상한이다.
475
+ 둘을 나눈 이유: 큐에 5,000행이 있으면 배치가 25개까지 나오므로 요청 상한만으로는
476
+ 전체가 25배로 늘어난다. 호출자를 막는 경로(세션 종료)는 반드시 `budget` 을 준다.
477
+ """
478
+ try:
479
+ if not self.enabled:
480
+ return
481
+ deadline = None if budget is None else time.monotonic() + budget
482
+ while True:
483
+ if deadline is not None and time.monotonic() >= deadline:
484
+ return
485
+ payload = self.drain_payload()
486
+ if not payload["rows"]:
487
+ return
488
+ self._send(payload, timeout)
489
+ except Exception:
490
+ logger.debug("ingest flush failed", exc_info=True)
491
+
492
+ def close(self, timeout: float = 2.0) -> None:
493
+ self._stop.set()
494
+ thread = self._thread
495
+ if thread is not None:
496
+ thread.join(timeout=timeout)
497
+ self.flush(timeout=timeout)
498
+
499
+ # -- 내부 ---------------------------------------------------------------
500
+ def _run(self) -> None:
501
+ while not self._stop.wait(self._flush_interval):
502
+ self.flush()
503
+
504
+ def _send(self, payload: dict, timeout: float) -> None:
505
+ try:
506
+ import httpx
507
+
508
+ body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
509
+ headers = {
510
+ "Content-Type": "application/json",
511
+ "Authorization": f"Bearer {self._token}",
512
+ }
513
+ # 단일 실수(float)는 connect/read/write/pool 각각에 적용된다. 전체 상한은
514
+ # flush(budget=...) 가 본다.
515
+ limit = httpx.Timeout(timeout)
516
+ with httpx.Client(transport=self._transport, timeout=limit) as client:
517
+ response = client.post(self._url, content=body, headers=headers)
518
+ if response.status_code >= 400:
519
+ self._record(f"HTTP {response.status_code}")
520
+ logger.debug(
521
+ "ingest rejected: status=%s rows=%s",
522
+ response.status_code,
523
+ {k: len(v) for k, v in payload["rows"].items()},
524
+ )
525
+ else:
526
+ self._record(None)
527
+ except Exception as e:
528
+ self._record(f"{type(e).__name__}: {e}"[:300])
529
+ logger.debug("ingest send failed", exc_info=True)
530
+
531
+
532
+ # ---------------------------------------------------------------------------
533
+ # 프로세스 단일 클라이언트 + 훅이 부르는 얇은 헬퍼
534
+ # ---------------------------------------------------------------------------
535
+ _CLIENT: Optional[IngestClient] = None
536
+ _CLIENT_LOCK = threading.Lock()
537
+
538
+
539
+ def get_client() -> IngestClient:
540
+ global _CLIENT
541
+ if _CLIENT is None:
542
+ with _CLIENT_LOCK:
543
+ if _CLIENT is None:
544
+ _CLIENT = IngestClient(
545
+ url=os.environ.get("CODEBUFF_INGEST_URL", ""),
546
+ token=os.environ.get(TOKEN_ENV, ""),
547
+ )
548
+ # 정상 종료(SIGTERM 이 아닌) 경로에서 큐 잔량을 흘려보낸다.
549
+ # 이게 없으면 close() 를 아무도 부르지 않아 죽은 코드가 된다.
550
+ atexit.register(_close_client_at_exit)
551
+ return _CLIENT
552
+
553
+
554
+ def _close_client_at_exit() -> None:
555
+ client = _CLIENT
556
+ if client is not None:
557
+ try:
558
+ client.close()
559
+ except Exception:
560
+ pass
561
+
562
+
563
+ def enqueue_ux_event(event: Any, identity: Optional[dict] = None) -> None:
564
+ try:
565
+ get_client().enqueue(TABLE_UX_EVENT, build_ux_event_row(event, identity))
566
+ except Exception:
567
+ logger.debug("enqueue_ux_event failed", exc_info=True)
568
+
569
+
570
+ def enqueue_agent_call(event: Any, identity: Optional[dict] = None) -> None:
571
+ try:
572
+ get_client().enqueue(TABLE_AGENT_CALL, build_agent_call_row(event, identity))
573
+ except Exception:
574
+ logger.debug("enqueue_agent_call failed", exc_info=True)
575
+
576
+
577
+ def enqueue_session_end(event: Any, identity: Optional[dict] = None) -> None:
578
+ """세션 종료는 pod 가 곧 죽으므로 넣고 바로 동기 플러시한다.
579
+
580
+ ⚠️ 호출자(`log_paths.write_idle_shutdown_log`)가 `async def` 안에서 불리므로 이
581
+ 함수는 **이벤트루프를 잡는다.** 그래서 전체 예산(`SESSION_END_BUDGET_SEC`)을 걸어
582
+ 상한을 실제로 만든다 — 예산이 없으면 배치 수만큼 곱해진다.
583
+ """
584
+ try:
585
+ client = get_client()
586
+ row = build_session_end_row(event, identity)
587
+ before = client.dropped
588
+ client.enqueue(TABLE_SESSION_END, row)
589
+ if client.dropped > before:
590
+ # 큐가 차 있었다. 이 경로가 존재하는 이유인 행이 제일 먼저 버려지면 안 된다.
591
+ client.flush(
592
+ timeout=SESSION_END_REQUEST_TIMEOUT_SEC, budget=SESSION_END_BUDGET_SEC
593
+ )
594
+ client.enqueue(TABLE_SESSION_END, row)
595
+ client.flush(
596
+ timeout=SESSION_END_REQUEST_TIMEOUT_SEC, budget=SESSION_END_BUDGET_SEC
597
+ )
598
+ except Exception:
599
+ logger.debug("enqueue_session_end failed", exc_info=True)
@@ -0,0 +1,7 @@
1
+ {
2
+ "ServerApp": {
3
+ "jpserver_extensions": {
4
+ "jupyter_ext": true
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,8 @@
1
+ """Handlers for Codebuff."""
2
+
3
+ try:
4
+ from .websocket import AgentWebSocketHandler
5
+ except ImportError:
6
+ AgentWebSocketHandler = None # type: ignore[assignment,misc]
7
+
8
+ __all__ = ["AgentWebSocketHandler"]