synth-ai 0.2.13.dev2__py3-none-any.whl → 0.2.16__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.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

Files changed (293) hide show
  1. examples/README.md +1 -0
  2. examples/multi_step/SFT_README.md +147 -0
  3. examples/multi_step/configs/README_verilog_rl.md +77 -0
  4. examples/multi_step/configs/VERILOG_REWARDS.md +90 -0
  5. examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +183 -0
  6. examples/multi_step/configs/crafter_eval_synth_qwen4b.toml +35 -0
  7. examples/multi_step/configs/crafter_eval_text_only_groq_qwen32b.toml +36 -0
  8. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +12 -11
  9. examples/multi_step/configs/crafter_sft_qwen30b_lora.toml +62 -0
  10. examples/multi_step/configs/crafter_synth_backend.md +40 -0
  11. examples/multi_step/configs/verilog_eval_groq_qwen32b.toml +31 -0
  12. examples/multi_step/configs/verilog_eval_synth_qwen8b.toml +33 -0
  13. examples/multi_step/configs/verilog_rl_lora.toml +190 -0
  14. examples/multi_step/convert_traces_to_sft.py +84 -0
  15. examples/multi_step/judges/crafter_backend_judge.py +220 -0
  16. examples/multi_step/judges/verilog_backend_judge.py +234 -0
  17. examples/multi_step/readme.md +48 -0
  18. examples/multi_step/run_sft_qwen30b.sh +45 -0
  19. examples/multi_step/verilog_rl_lora.md +218 -0
  20. examples/qwen_coder/configs/coder_lora_30b.toml +3 -2
  21. examples/qwen_coder/configs/coder_lora_4b.toml +2 -1
  22. examples/qwen_coder/configs/coder_lora_small.toml +2 -1
  23. examples/qwen_vl/BUGS_AND_FIXES.md +232 -0
  24. examples/qwen_vl/IMAGE_VALIDATION_COMPLETE.md +271 -0
  25. examples/qwen_vl/IMAGE_VALIDATION_SUMMARY.md +260 -0
  26. examples/qwen_vl/INFERENCE_SFT_TESTS.md +412 -0
  27. examples/qwen_vl/NEXT_STEPS_2B.md +325 -0
  28. examples/qwen_vl/QUICKSTART.md +327 -0
  29. examples/qwen_vl/QUICKSTART_RL_VISION.md +110 -0
  30. examples/qwen_vl/README.md +154 -0
  31. examples/qwen_vl/RL_VISION_COMPLETE.md +475 -0
  32. examples/qwen_vl/RL_VISION_TESTING.md +333 -0
  33. examples/qwen_vl/SDK_VISION_INTEGRATION.md +328 -0
  34. examples/qwen_vl/SETUP_COMPLETE.md +275 -0
  35. examples/qwen_vl/VISION_TESTS_COMPLETE.md +490 -0
  36. examples/qwen_vl/VLM_PIPELINE_COMPLETE.md +242 -0
  37. examples/qwen_vl/__init__.py +2 -0
  38. examples/qwen_vl/collect_data_via_cli.md +423 -0
  39. examples/qwen_vl/collect_vision_traces.py +368 -0
  40. examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +127 -0
  41. examples/qwen_vl/configs/crafter_vlm_sft_example.toml +60 -0
  42. examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +43 -0
  43. examples/qwen_vl/configs/eval_gpt4o_vision_proper.toml +29 -0
  44. examples/qwen_vl/configs/eval_gpt5nano_vision.toml +45 -0
  45. examples/qwen_vl/configs/eval_qwen2vl_vision.toml +44 -0
  46. examples/qwen_vl/configs/filter_qwen2vl_sft.toml +50 -0
  47. examples/qwen_vl/configs/filter_vision_sft.toml +53 -0
  48. examples/qwen_vl/configs/filter_vision_test.toml +8 -0
  49. examples/qwen_vl/configs/sft_qwen3_vl_2b_test.toml +54 -0
  50. examples/qwen_vl/crafter_gpt5nano_agent.py +308 -0
  51. examples/qwen_vl/crafter_qwen_vl_agent.py +300 -0
  52. examples/qwen_vl/run_vision_comparison.sh +62 -0
  53. examples/qwen_vl/run_vision_sft_pipeline.sh +175 -0
  54. examples/qwen_vl/test_image_validation.py +201 -0
  55. examples/qwen_vl/test_sft_vision_data.py +110 -0
  56. examples/rl/README.md +1 -1
  57. examples/rl/configs/eval_base_qwen.toml +17 -0
  58. examples/rl/configs/eval_rl_qwen.toml +13 -0
  59. examples/rl/configs/rl_from_base_qwen.toml +37 -0
  60. examples/rl/configs/rl_from_base_qwen17.toml +76 -0
  61. examples/rl/configs/rl_from_ft_qwen.toml +37 -0
  62. examples/rl/run_eval.py +436 -0
  63. examples/rl/run_rl_and_save.py +111 -0
  64. examples/rl/task_app/README.md +22 -0
  65. examples/rl/task_app/math_single_step.py +990 -0
  66. examples/rl/task_app/math_task_app.py +111 -0
  67. examples/sft/README.md +5 -5
  68. examples/sft/configs/crafter_fft_qwen0p6b.toml +4 -2
  69. examples/sft/configs/crafter_lora_qwen0p6b.toml +4 -3
  70. examples/sft/evaluate.py +4 -4
  71. examples/sft/export_dataset.py +7 -4
  72. examples/sft/generate_traces.py +2 -0
  73. examples/swe/task_app/README.md +1 -1
  74. examples/swe/task_app/grpo_swe_mini.py +1 -1
  75. examples/swe/task_app/grpo_swe_mini_task_app.py +0 -12
  76. examples/swe/task_app/hosted/envs/mini_swe/environment.py +13 -13
  77. examples/swe/task_app/hosted/policy_routes.py +0 -2
  78. examples/swe/task_app/hosted/rollout.py +2 -8
  79. examples/task_apps/IMAGE_ONLY_EVAL_QUICKSTART.md +258 -0
  80. examples/task_apps/crafter/CREATE_SFT_DATASET.md +273 -0
  81. examples/task_apps/crafter/EVAL_IMAGE_ONLY_RESULTS.md +152 -0
  82. examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +174 -0
  83. examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +268 -0
  84. examples/task_apps/crafter/QUERY_EXAMPLES.md +203 -0
  85. examples/task_apps/crafter/README_IMAGE_ONLY_EVAL.md +316 -0
  86. examples/task_apps/crafter/eval_image_only_gpt4o.toml +28 -0
  87. examples/task_apps/crafter/eval_text_only_groq_llama.toml +36 -0
  88. examples/task_apps/crafter/filter_sft_dataset.toml +16 -0
  89. examples/task_apps/crafter/task_app/__init__.py +3 -0
  90. examples/task_apps/crafter/task_app/grpo_crafter.py +309 -14
  91. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/environment.py +10 -0
  92. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +75 -4
  93. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +17 -2
  94. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +55 -3
  95. examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +114 -32
  96. examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +127 -27
  97. examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +156 -0
  98. examples/task_apps/enron/__init__.py +1 -0
  99. examples/task_apps/enron/filter_sft.toml +5 -0
  100. examples/task_apps/enron/tests/__init__.py +2 -0
  101. examples/task_apps/enron/tests/integration/__init__.py +2 -0
  102. examples/task_apps/enron/tests/integration/test_enron_eval.py +2 -0
  103. examples/task_apps/enron/tests/unit/__init__.py +2 -0
  104. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_COMPLETE.md +283 -0
  105. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_STATUS.md +155 -0
  106. examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +415 -0
  107. examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +29 -0
  108. examples/task_apps/pokemon_red/pallet_town_rl_config.toml +2 -0
  109. examples/task_apps/pokemon_red/task_app.py +199 -6
  110. examples/task_apps/pokemon_red/test_pallet_town_rewards.py +2 -0
  111. examples/task_apps/sokoban/filter_sft.toml +5 -0
  112. examples/task_apps/sokoban/tests/__init__.py +2 -0
  113. examples/task_apps/sokoban/tests/integration/__init__.py +2 -0
  114. examples/task_apps/sokoban/tests/unit/__init__.py +2 -0
  115. examples/task_apps/verilog/eval_groq_qwen32b.toml +8 -4
  116. examples/task_apps/verilog/filter_sft.toml +5 -0
  117. examples/task_apps/verilog/task_app/grpo_verilog.py +258 -23
  118. examples/task_apps/verilog/tests/__init__.py +2 -0
  119. examples/task_apps/verilog/tests/integration/__init__.py +2 -0
  120. examples/task_apps/verilog/tests/integration/test_verilog_eval.py +2 -0
  121. examples/task_apps/verilog/tests/unit/__init__.py +2 -0
  122. examples/vlm/README.md +3 -3
  123. examples/vlm/configs/crafter_vlm_gpt4o.toml +2 -0
  124. examples/vlm/crafter_openai_vlm_agent.py +3 -5
  125. examples/vlm/filter_image_rows.py +1 -1
  126. examples/vlm/run_crafter_vlm_benchmark.py +2 -2
  127. examples/warming_up_to_rl/_utils.py +92 -0
  128. examples/warming_up_to_rl/analyze_trace_db.py +1 -1
  129. examples/warming_up_to_rl/configs/crafter_fft.toml +2 -0
  130. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +2 -0
  131. examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +2 -0
  132. examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +2 -0
  133. examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +2 -1
  134. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +2 -1
  135. examples/warming_up_to_rl/configs/rl_from_ft.toml +2 -0
  136. examples/warming_up_to_rl/export_trace_sft.py +174 -60
  137. examples/warming_up_to_rl/groq_test.py +2 -0
  138. examples/warming_up_to_rl/readme.md +63 -132
  139. examples/warming_up_to_rl/run_fft_and_save.py +1 -1
  140. examples/warming_up_to_rl/run_local_rollout.py +2 -0
  141. examples/warming_up_to_rl/run_local_rollout_modal.py +2 -0
  142. examples/warming_up_to_rl/run_local_rollout_parallel.py +2 -0
  143. examples/warming_up_to_rl/run_local_rollout_traced.py +2 -0
  144. examples/warming_up_to_rl/run_rl_and_save.py +1 -1
  145. examples/warming_up_to_rl/run_rollout_remote.py +2 -0
  146. examples/warming_up_to_rl/task_app/README.md +42 -0
  147. examples/warming_up_to_rl/task_app/grpo_crafter.py +696 -0
  148. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +135 -0
  149. examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +173 -0
  150. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +5 -0
  151. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +143 -0
  152. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +1226 -0
  153. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -0
  154. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
  155. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
  156. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +522 -0
  157. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +478 -0
  158. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +108 -0
  159. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +305 -0
  160. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
  161. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +204 -0
  162. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +5 -0
  163. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +618 -0
  164. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +100 -0
  165. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +1081 -0
  166. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +195 -0
  167. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +1861 -0
  168. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +5 -0
  169. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +211 -0
  170. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +161 -0
  171. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +137 -0
  172. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +62 -0
  173. synth_ai/__init__.py +44 -30
  174. synth_ai/_utils/__init__.py +47 -0
  175. synth_ai/_utils/base_url.py +10 -0
  176. synth_ai/_utils/http.py +10 -0
  177. synth_ai/_utils/prompts.py +10 -0
  178. synth_ai/_utils/task_app_state.py +12 -0
  179. synth_ai/_utils/user_config.py +10 -0
  180. synth_ai/api/models/supported.py +145 -7
  181. synth_ai/api/train/__init__.py +13 -1
  182. synth_ai/api/train/cli.py +30 -7
  183. synth_ai/api/train/config_finder.py +18 -11
  184. synth_ai/api/train/env_resolver.py +13 -10
  185. synth_ai/cli/__init__.py +66 -49
  186. synth_ai/cli/_modal_wrapper.py +9 -6
  187. synth_ai/cli/_typer_patch.py +0 -2
  188. synth_ai/cli/_validate_task_app.py +22 -4
  189. synth_ai/cli/legacy_root_backup.py +3 -1
  190. synth_ai/cli/lib/__init__.py +10 -0
  191. synth_ai/cli/lib/task_app_discovery.py +7 -0
  192. synth_ai/cli/lib/task_app_env.py +518 -0
  193. synth_ai/cli/recent.py +1 -0
  194. synth_ai/cli/setup.py +266 -0
  195. synth_ai/cli/task_app_deploy.py +16 -0
  196. synth_ai/cli/task_app_list.py +25 -0
  197. synth_ai/cli/task_app_modal_serve.py +16 -0
  198. synth_ai/cli/task_app_serve.py +18 -0
  199. synth_ai/cli/task_apps.py +392 -141
  200. synth_ai/cli/train.py +18 -0
  201. synth_ai/cli/tui.py +62 -0
  202. synth_ai/demos/__init__.py +10 -0
  203. synth_ai/demos/core/__init__.py +28 -1
  204. synth_ai/demos/crafter/__init__.py +1 -0
  205. synth_ai/demos/crafter/crafter_fft_4b.toml +55 -0
  206. synth_ai/demos/crafter/grpo_crafter_task_app.py +185 -0
  207. synth_ai/demos/crafter/rl_from_base_qwen4b.toml +74 -0
  208. synth_ai/demos/demo_registry.py +176 -0
  209. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
  210. synth_ai/demos/math/__init__.py +1 -0
  211. synth_ai/demos/math/_common.py +16 -0
  212. synth_ai/demos/math/app.py +38 -0
  213. synth_ai/demos/math/config.toml +76 -0
  214. synth_ai/demos/math/deploy_modal.py +54 -0
  215. synth_ai/demos/math/modal_task_app.py +702 -0
  216. synth_ai/demos/math/task_app_entry.py +51 -0
  217. synth_ai/environments/environment/core.py +7 -1
  218. synth_ai/environments/examples/bandit/engine.py +0 -1
  219. synth_ai/environments/examples/bandit/environment.py +0 -1
  220. synth_ai/environments/examples/crafter_classic/environment.py +1 -1
  221. synth_ai/environments/examples/verilog/engine.py +76 -10
  222. synth_ai/environments/examples/wordle/environment.py +0 -1
  223. synth_ai/evals/base.py +16 -5
  224. synth_ai/evals/client.py +1 -1
  225. synth_ai/inference/client.py +1 -1
  226. synth_ai/learning/client.py +1 -1
  227. synth_ai/learning/health.py +1 -1
  228. synth_ai/learning/jobs.py +1 -1
  229. synth_ai/learning/rl/client.py +1 -1
  230. synth_ai/learning/rl/env_keys.py +1 -1
  231. synth_ai/learning/rl/secrets.py +1 -1
  232. synth_ai/learning/sft/client.py +1 -1
  233. synth_ai/learning/sft/data.py +407 -4
  234. synth_ai/learning/validators.py +4 -1
  235. synth_ai/task/__init__.py +11 -1
  236. synth_ai/task/apps/__init__.py +5 -2
  237. synth_ai/task/config.py +259 -0
  238. synth_ai/task/contracts.py +15 -2
  239. synth_ai/task/rubrics/__init__.py +4 -2
  240. synth_ai/task/rubrics/loaders.py +27 -4
  241. synth_ai/task/rubrics/scoring.py +3 -0
  242. synth_ai/task/rubrics.py +219 -0
  243. synth_ai/task/trace_correlation_helpers.py +328 -0
  244. synth_ai/task/tracing_utils.py +14 -3
  245. synth_ai/task/validators.py +145 -2
  246. synth_ai/tracing_v3/config.py +15 -13
  247. synth_ai/tracing_v3/constants.py +21 -0
  248. synth_ai/tracing_v3/db_config.py +3 -1
  249. synth_ai/tracing_v3/decorators.py +10 -7
  250. synth_ai/tracing_v3/session_tracer.py +10 -0
  251. synth_ai/tracing_v3/turso/daemon.py +2 -2
  252. synth_ai/tracing_v3/turso/native_manager.py +108 -77
  253. synth_ai/tracing_v3/utils.py +1 -1
  254. synth_ai/tui/__init__.py +5 -0
  255. synth_ai/tui/__main__.py +13 -0
  256. synth_ai/tui/cli/__init__.py +1 -0
  257. synth_ai/tui/cli/query_experiments.py +164 -0
  258. synth_ai/tui/cli/query_experiments_v3.py +164 -0
  259. synth_ai/tui/dashboard.py +911 -0
  260. synth_ai/utils/__init__.py +101 -0
  261. synth_ai/utils/base_url.py +94 -0
  262. synth_ai/utils/cli.py +131 -0
  263. synth_ai/utils/env.py +287 -0
  264. synth_ai/utils/http.py +169 -0
  265. synth_ai/utils/modal.py +308 -0
  266. synth_ai/utils/process.py +212 -0
  267. synth_ai/utils/prompts.py +39 -0
  268. synth_ai/utils/sqld.py +122 -0
  269. synth_ai/utils/task_app_discovery.py +882 -0
  270. synth_ai/utils/task_app_env.py +186 -0
  271. synth_ai/utils/task_app_state.py +318 -0
  272. synth_ai/utils/user_config.py +137 -0
  273. synth_ai/v0/config/__init__.py +1 -5
  274. synth_ai/v0/config/base_url.py +1 -7
  275. synth_ai/v0/tracing/config.py +1 -1
  276. synth_ai/v0/tracing/decorators.py +1 -1
  277. synth_ai/v0/tracing/upload.py +1 -1
  278. synth_ai/v0/tracing_v1/config.py +1 -1
  279. synth_ai/v0/tracing_v1/decorators.py +1 -1
  280. synth_ai/v0/tracing_v1/upload.py +1 -1
  281. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/METADATA +85 -31
  282. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/RECORD +286 -135
  283. synth_ai/cli/man.py +0 -106
  284. synth_ai/compound/cais.py +0 -0
  285. synth_ai/core/experiment.py +0 -13
  286. synth_ai/core/system.py +0 -15
  287. synth_ai/demo_registry.py +0 -295
  288. synth_ai/handshake.py +0 -109
  289. synth_ai/http.py +0 -26
  290. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/WHEEL +0 -0
  291. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/entry_points.txt +0 -0
  292. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/licenses/LICENSE +0 -0
  293. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,911 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Interactive TUI Dashboard for Synth AI experiments.
4
+
5
+ Launch with: python -m synth_ai.tui.dashboard
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from datetime import datetime
11
+ from urllib.parse import urlparse
12
+
13
+ # Import textual components with graceful fallback
14
+ try:
15
+ from textual import on
16
+ from textual.app import App, ComposeResult
17
+ from textual.binding import Binding
18
+ from textual.containers import Container
19
+ from textual.reactive import reactive
20
+ from textual.timer import Timer
21
+ from textual.widgets import (
22
+ DataTable,
23
+ Footer,
24
+ Header,
25
+ Static,
26
+ )
27
+ _TEXTUAL_AVAILABLE = True
28
+ except (ImportError, ModuleNotFoundError):
29
+ # Textual not available - provide dummy classes for type checking
30
+ on = None # type: ignore
31
+ App = object # type: ignore
32
+ ComposeResult = object # type: ignore
33
+ Binding = object # type: ignore
34
+ Container = object # type: ignore
35
+
36
+ def reactive(value, *_, **__):
37
+ return value
38
+
39
+ Timer = object # type: ignore
40
+ DataTable = object # type: ignore
41
+ Footer = object # type: ignore
42
+ Header = object # type: ignore
43
+ Static = object # type: ignore
44
+ _TEXTUAL_AVAILABLE = False
45
+
46
+ # Import database manager with graceful fallback
47
+ try:
48
+ from synth_ai.tracing_v3.turso.native_manager import (
49
+ NativeLibsqlTraceManager, # type: ignore[import-untyped]
50
+ )
51
+ _DB_AVAILABLE = True
52
+ except (ImportError, ModuleNotFoundError, TypeError):
53
+ # Database manager not available - provide dummy class
54
+ NativeLibsqlTraceManager = object # type: ignore
55
+ _DB_AVAILABLE = False
56
+
57
+ from datetime import timedelta
58
+
59
+ import requests
60
+
61
+
62
+ class ExperimentRow:
63
+ """Data structure for experiment display."""
64
+
65
+ def __init__(
66
+ self,
67
+ exp_id: str,
68
+ name: str,
69
+ description: str,
70
+ created_at: datetime,
71
+ sessions: int,
72
+ events: int,
73
+ messages: int,
74
+ cost: float,
75
+ tokens: int,
76
+ ):
77
+ self.exp_id = exp_id
78
+ self.name = name or "Unnamed"
79
+ self.description = description or ""
80
+ self.created_at = created_at
81
+ self.sessions = sessions
82
+ self.events = events
83
+ self.messages = messages
84
+ self.cost = cost
85
+ self.tokens = tokens
86
+
87
+ def to_row(self) -> list[str]:
88
+ """Convert to table row format."""
89
+ return [
90
+ self.exp_id[:8], # Shortened ID
91
+ self.name[:20], # Truncated name
92
+ str(self.sessions),
93
+ str(self.events),
94
+ str(self.messages),
95
+ f"${self.cost:.4f}",
96
+ f"{self.tokens:,}",
97
+ self.created_at.strftime("%H:%M"),
98
+ ]
99
+
100
+
101
+ class ExperimentTable(DataTable):
102
+ """Custom DataTable for experiments with refresh capability."""
103
+
104
+ def __init__(self, **kwargs):
105
+ super().__init__(**kwargs)
106
+ self.experiments: list[ExperimentRow] = []
107
+ self.selected_exp_id: str | None = None
108
+
109
+ def setup_table(self):
110
+ """Initialize table columns."""
111
+ self.add_columns("ID", "Name", "Sessions", "Events", "Messages", "Cost", "Tokens", "Time")
112
+
113
+ async def refresh_data(self, db_manager: NativeLibsqlTraceManager | None) -> None:
114
+ """Refresh experiment data from database."""
115
+ if not db_manager:
116
+ # Database not available, clear the table
117
+ self.experiments.clear()
118
+ self.clear()
119
+ return
120
+
121
+ try:
122
+ # Get experiment list with stats using raw query
123
+ df = await db_manager.query_traces("""
124
+ SELECT
125
+ e.experiment_id,
126
+ e.name,
127
+ e.description,
128
+ e.created_at,
129
+ COUNT(DISTINCT st.session_id) as num_sessions,
130
+ COUNT(DISTINCT ev.id) as num_events,
131
+ COUNT(DISTINCT m.id) as num_messages,
132
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.cost_usd ELSE 0 END) / 100.0 as total_cost,
133
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.total_tokens ELSE 0 END) as total_tokens
134
+ FROM experiments e
135
+ LEFT JOIN session_traces st ON e.experiment_id = st.experiment_id
136
+ LEFT JOIN events ev ON st.session_id = ev.session_id
137
+ LEFT JOIN messages m ON st.session_id = m.session_id
138
+ GROUP BY e.experiment_id, e.name, e.description, e.created_at
139
+ ORDER BY e.created_at DESC
140
+ """)
141
+
142
+ self.experiments.clear()
143
+ self.clear()
144
+
145
+ if not df.empty:
146
+ for _, row in df.iterrows():
147
+ exp_row = ExperimentRow(
148
+ exp_id=row["experiment_id"],
149
+ name=row["name"],
150
+ description=row["description"],
151
+ created_at=row["created_at"],
152
+ sessions=int(row["num_sessions"] or 0),
153
+ events=int(row["num_events"] or 0),
154
+ messages=int(row["num_messages"] or 0),
155
+ cost=float(row["total_cost"] or 0.0),
156
+ tokens=int(row["total_tokens"] or 0),
157
+ )
158
+ self.experiments.append(exp_row)
159
+ self.add_row(*exp_row.to_row(), key=exp_row.exp_id)
160
+
161
+ except Exception as e:
162
+ logging.error(f"Failed to refresh experiments: {e}")
163
+
164
+ def get_selected_experiment(self) -> ExperimentRow | None:
165
+ """Get currently selected experiment."""
166
+ if self.cursor_row >= 0 and self.cursor_row < len(self.experiments):
167
+ return self.experiments[self.cursor_row]
168
+ return None
169
+
170
+
171
+ class ExperimentDetail(Static):
172
+ """Detailed view of selected experiment."""
173
+
174
+ def __init__(self, **kwargs):
175
+ super().__init__(**kwargs)
176
+ self.current_experiment: ExperimentRow | None = None
177
+
178
+ def update_experiment(self, experiment: ExperimentRow | None):
179
+ """Update the displayed experiment details."""
180
+ self.current_experiment = experiment
181
+ if experiment:
182
+ details = f"""
183
+ 🔬 **{experiment.name}**
184
+ ID: {experiment.exp_id}
185
+ Description: {experiment.description or "No description"}
186
+
187
+ 📊 **Statistics**
188
+ Sessions: {experiment.sessions}
189
+ Events: {experiment.events}
190
+ Messages: {experiment.messages}
191
+ Cost: ${experiment.cost:.4f}
192
+ Tokens: {experiment.tokens:,}
193
+
194
+ 🕒 **Created**: {experiment.created_at.strftime("%Y-%m-%d %H:%M:%S")}
195
+ """.strip()
196
+ else:
197
+ details = "Select an experiment to view details"
198
+
199
+ self.update(details)
200
+
201
+
202
+ class DatabaseStatus(Static):
203
+ """Display database connection status."""
204
+
205
+ connection_status = reactive("🔴 Disconnected")
206
+ db_info = reactive("")
207
+
208
+ def __init__(self, **kwargs):
209
+ super().__init__(**kwargs)
210
+
211
+ def render(self) -> str:
212
+ status_line = f"Database: {self.connection_status}"
213
+ if self.db_info:
214
+ status_line += f" | {self.db_info}"
215
+ return status_line
216
+
217
+ def set_connected(self, url: str, db_name: str = ""):
218
+ parsed = urlparse(url)
219
+ if "sqlite" in url:
220
+ # Extract just the filename for cleaner display
221
+ from pathlib import Path
222
+ try:
223
+ path_part = url.split("///")[-1]
224
+ filename = Path(path_part).name
225
+ self.connection_status = f"🟢 {filename}"
226
+ except Exception:
227
+ self.connection_status = "🟢 Connected"
228
+ else:
229
+ host_info = f"{parsed.hostname}:{parsed.port}" if parsed.port else str(parsed.hostname)
230
+ self.connection_status = f"🟢 {host_info}"
231
+
232
+ if db_name:
233
+ self.db_info = f"[{db_name}]"
234
+
235
+ def set_disconnected(self, error: str = ""):
236
+ error_text = f" - {error}" if error else ""
237
+ self.connection_status = f"🔴 Disconnected{error_text}"
238
+ self.db_info = ""
239
+
240
+ def set_db_selector(self, current: int, total: int):
241
+ """Show database selector info."""
242
+ if total > 1:
243
+ self.db_info = f"DB {current + 1}/{total} (n/p to switch)"
244
+ else:
245
+ self.db_info = ""
246
+
247
+
248
+ class BalanceStatus(Static):
249
+ """Display balance and spending information (local + global)."""
250
+
251
+ # Global (backend API)
252
+ global_balance = reactive("$0.00")
253
+ global_spend_24h = reactive("$0.00")
254
+ global_spend_7d = reactive("$0.00")
255
+ global_status = reactive("⏳")
256
+
257
+ # Local (database)
258
+ local_traces = reactive(0)
259
+ local_cost = reactive("$0.00")
260
+ local_tokens = reactive(0)
261
+ local_tasks = reactive([]) # List of (task_name, count) tuples
262
+ local_status = reactive("⏳")
263
+
264
+ def __init__(self, **kwargs):
265
+ super().__init__(**kwargs)
266
+
267
+ def render(self) -> str:
268
+ # Format tokens safely
269
+ if isinstance(self.local_tokens, int) and self.local_tokens > 0:
270
+ if self.local_tokens >= 1_000_000:
271
+ tokens_str = f"{self.local_tokens / 1_000_000:.1f}M"
272
+ elif self.local_tokens >= 1_000:
273
+ tokens_str = f"{self.local_tokens / 1_000:.1f}K"
274
+ else:
275
+ tokens_str = f"{self.local_tokens}"
276
+ else:
277
+ tokens_str = str(self.local_tokens)
278
+
279
+ # Format tasks - show top 3 only
280
+ tasks_str = ""
281
+ if self.local_tasks and len(self.local_tasks) > 0:
282
+ top_tasks = self.local_tasks[:3]
283
+ task_lines = [f"{name} ({count})" for name, count in top_tasks]
284
+ tasks_str = " | " + ", ".join(task_lines)
285
+ if len(self.local_tasks) > 3:
286
+ tasks_str += f", +{len(self.local_tasks) - 3}"
287
+
288
+ # Compact single-line format
289
+ return f"""[b]Local[/b] {self.local_status} {self.local_traces} traces | {self.local_cost} | {tokens_str} tokens{tasks_str}
290
+
291
+ [b]Global[/b] {self.global_status} {self.global_balance} | 24h: {self.global_spend_24h} | 7d: {self.global_spend_7d}"""
292
+
293
+ def update_global(self, balance: float, spend_24h: float, spend_7d: float):
294
+ """Update global backend balance information."""
295
+ self.global_balance = f"${balance:.2f}"
296
+ self.global_spend_24h = f"${spend_24h:.2f}"
297
+ self.global_spend_7d = f"${spend_7d:.2f}"
298
+ self.global_status = "✅"
299
+
300
+ def update_local(self, traces: int, cost: float, tokens: int, tasks: list[tuple[str, int]] | None = None):
301
+ """Update local database statistics."""
302
+ self.local_traces = traces
303
+ self.local_cost = f"${cost:.4f}"
304
+ self.local_tokens = tokens
305
+ self.local_tasks = tasks or []
306
+ self.local_status = "✅"
307
+
308
+ def set_global_loading(self):
309
+ """Show loading state for global data."""
310
+ self.global_balance = "..."
311
+ self.global_spend_24h = "..."
312
+ self.global_spend_7d = "..."
313
+ self.global_status = "⏳"
314
+
315
+ def set_local_loading(self):
316
+ """Show loading state for local data."""
317
+ self.local_traces = 0
318
+ self.local_cost = "..."
319
+ self.local_tokens = 0
320
+ self.local_tasks = []
321
+ self.local_status = "⏳"
322
+
323
+ def set_global_error(self, error: str):
324
+ """Show error state for global data."""
325
+ self.global_balance = "Error"
326
+ self.global_spend_24h = "-"
327
+ self.global_spend_7d = "-"
328
+ self.global_status = "❌"
329
+
330
+ def set_local_error(self, error: str):
331
+ """Show error state for local data."""
332
+ self.local_traces = 0
333
+ self.local_cost = "Error"
334
+ self.local_tokens = 0
335
+ self.local_tasks = []
336
+ self.local_status = "❌"
337
+
338
+ def set_global_unavailable(self):
339
+ """Mark global data as unavailable (no API key)."""
340
+ self.global_balance = "N/A"
341
+ self.global_spend_24h = "N/A"
342
+ self.global_spend_7d = "N/A"
343
+ self.global_status = "⚪"
344
+
345
+
346
+ class ActiveRunsTable(DataTable):
347
+ """Display currently active/running sessions."""
348
+
349
+ def __init__(self, **kwargs):
350
+ super().__init__(**kwargs)
351
+ self.active_runs: list[dict] = []
352
+
353
+ def setup_table(self):
354
+ """Initialize table columns."""
355
+ self.add_columns("Session", "Experiment", "Started", "Duration", "Events", "Status")
356
+
357
+ async def refresh_data(self, db_manager: NativeLibsqlTraceManager | None) -> None:
358
+ """Refresh active runs data from database."""
359
+ if not db_manager:
360
+ # Database not available, clear the table
361
+ self.active_runs.clear()
362
+ self.clear()
363
+ return
364
+
365
+ try:
366
+ # Get active sessions (those with recent activity in last 5 minutes)
367
+ cutoff_time = datetime.now() - timedelta(minutes=5)
368
+
369
+ df = await db_manager.query_traces("""
370
+ WITH recent_sessions AS (
371
+ SELECT
372
+ st.session_id,
373
+ st.experiment_id,
374
+ st.created_at,
375
+ e.name as experiment_name,
376
+ COUNT(ev.id) as event_count,
377
+ MAX(ev.created_at) as last_event_time
378
+ FROM session_traces st
379
+ LEFT JOIN experiments e ON st.experiment_id = e.experiment_id
380
+ LEFT JOIN events ev ON st.session_id = ev.session_id
381
+ WHERE st.created_at >= :cutoff_time
382
+ GROUP BY st.session_id, st.experiment_id, st.created_at, e.name
383
+ )
384
+ SELECT
385
+ session_id,
386
+ experiment_id,
387
+ experiment_name,
388
+ created_at,
389
+ event_count,
390
+ last_event_time
391
+ FROM recent_sessions
392
+ ORDER BY last_event_time DESC
393
+ """, {"cutoff_time": cutoff_time})
394
+
395
+ self.active_runs.clear()
396
+ self.clear()
397
+
398
+ if not df.empty:
399
+ for _, row in df.iterrows():
400
+ session_id = str(row["session_id"])
401
+ experiment_name = row["experiment_name"] or "Unknown"
402
+
403
+ # Parse datetime strings
404
+ try:
405
+ if isinstance(row["created_at"], str):
406
+ from dateutil import parser as date_parser
407
+ started_at = date_parser.parse(row["created_at"])
408
+ else:
409
+ started_at = row["created_at"]
410
+
411
+ if isinstance(row["last_event_time"], str):
412
+ from dateutil import parser as date_parser
413
+ last_event_time = date_parser.parse(row["last_event_time"])
414
+ else:
415
+ last_event_time = row["last_event_time"]
416
+ except Exception as e:
417
+ logging.error(f"Failed to parse datetime: {e}")
418
+ continue
419
+
420
+ duration = datetime.now() - started_at
421
+
422
+ # Format duration
423
+ if duration.total_seconds() < 3600: # Less than 1 hour
424
+ duration_str = f"{int(duration.total_seconds() // 60)}m"
425
+ else:
426
+ hours = int(duration.total_seconds() // 3600)
427
+ minutes = int((duration.total_seconds() % 3600) // 60)
428
+ duration_str = f"{hours}h {minutes}m"
429
+
430
+ # Status based on recent activity
431
+ time_since_last = datetime.now() - last_event_time
432
+ if time_since_last.total_seconds() < 60: # Active in last minute
433
+ status = "🟢 Active"
434
+ elif time_since_last.total_seconds() < 300: # Active in last 5 minutes
435
+ status = "🟡 Recent"
436
+ else:
437
+ status = "🟠 Idle"
438
+
439
+ run_info = {
440
+ "session_id": session_id,
441
+ "experiment_name": experiment_name,
442
+ "started_at": started_at,
443
+ "duration": duration_str,
444
+ "events": int(row["event_count"]),
445
+ "status": status
446
+ }
447
+ self.active_runs.append(run_info)
448
+ self.add_row(
449
+ session_id[:8], # Shortened session ID
450
+ experiment_name[:20], # Truncated name
451
+ started_at.strftime("%H:%M:%S"),
452
+ duration_str,
453
+ str(run_info["events"]),
454
+ status,
455
+ key=session_id
456
+ )
457
+
458
+ except Exception as e:
459
+ logging.error(f"Failed to refresh active runs: {e}")
460
+
461
+
462
+ def find_databases() -> list[tuple[str, str]]:
463
+ """Find all available databases in common locations.
464
+
465
+ Returns:
466
+ List of (name, path) tuples
467
+ """
468
+ databases = []
469
+ search_paths = [
470
+ "traces/v3",
471
+ "traces",
472
+ ".",
473
+ ]
474
+
475
+ for search_path in search_paths:
476
+ try:
477
+ from pathlib import Path
478
+ search_dir = Path(search_path)
479
+ if not search_dir.exists():
480
+ continue
481
+
482
+ # Find all .db files
483
+ for db_file in search_dir.glob("**/*.db"):
484
+ if db_file.is_file():
485
+ # Use relative path from current directory
486
+ rel_path = str(db_file.relative_to(Path.cwd()))
487
+ # Create a friendly name
488
+ name = db_file.stem # filename without .db
489
+ if len(databases) == 0:
490
+ name = f"{name} (default)"
491
+ databases.append((name, rel_path))
492
+ except Exception as e:
493
+ logging.debug(f"Error scanning {search_path}: {e}")
494
+
495
+ # If no databases found, return default
496
+ if not databases:
497
+ databases.append(("synth_ai (default)", "traces/v3/synth_ai.db"))
498
+
499
+ return databases
500
+
501
+
502
+ class SynthDashboard(App if _TEXTUAL_AVAILABLE else object):
503
+ """Main Synth AI TUI Dashboard application."""
504
+
505
+ CSS = """
506
+ Screen {
507
+ layout: grid;
508
+ grid-columns: 1fr 1fr 1fr;
509
+ grid-rows: auto 1fr 1fr auto;
510
+ grid-gutter: 1;
511
+ }
512
+
513
+ #header {
514
+ column-span: 3;
515
+ height: 3;
516
+ }
517
+
518
+ #experiments-table {
519
+ row-span: 2;
520
+ }
521
+
522
+ #active-runs-panel {
523
+ column-span: 1;
524
+ }
525
+
526
+ #balance-status {
527
+ column-span: 1;
528
+ }
529
+
530
+ #experiment-detail {
531
+ column-span: 2;
532
+ height: 1fr;
533
+ }
534
+
535
+ #status-bar {
536
+ column-span: 3;
537
+ height: 3;
538
+ }
539
+
540
+ ExperimentTable {
541
+ height: 100%;
542
+ }
543
+
544
+ ActiveRunsTable {
545
+ height: 100%;
546
+ }
547
+
548
+ ExperimentDetail {
549
+ border: solid $primary;
550
+ padding: 1;
551
+ height: 100%;
552
+ }
553
+
554
+ BalanceStatus {
555
+ border: solid $primary;
556
+ padding: 1;
557
+ height: 100%;
558
+ }
559
+
560
+ DatabaseStatus {
561
+ height: 1;
562
+ padding: 0 1;
563
+ }
564
+
565
+ .section-title {
566
+ text-style: bold;
567
+ height: 1;
568
+ }
569
+ """
570
+
571
+ BINDINGS = [
572
+ Binding("q", "quit", "Quit"),
573
+ Binding("r", "refresh", "Refresh"),
574
+ Binding("n", "next_db", "Next DB"),
575
+ Binding("p", "prev_db", "Prev DB"),
576
+ Binding("d", "toggle_debug", "Debug"),
577
+ ("ctrl+c", "quit", "Quit"),
578
+ ]
579
+
580
+ def __init__(self, db_url: str = "sqlite+aiosqlite:///traces/v3/synth_ai.db"):
581
+ super().__init__()
582
+ self.db_url = db_url
583
+ self.db_manager: NativeLibsqlTraceManager | None = None
584
+ self.refresh_timer: Timer | None = None
585
+
586
+ # Database discovery and selection
587
+ self.available_dbs: list[tuple[str, str]] = find_databases()
588
+ self.current_db_index: int = 0
589
+
590
+ # Log discovered databases
591
+ logging.info(f"Found {len(self.available_dbs)} database(s):")
592
+ for idx, (name, path) in enumerate(self.available_dbs):
593
+ logging.info(f" [{idx+1}] {name}: {path}")
594
+
595
+ # Try to find the initial db_url in available_dbs
596
+ for idx, (name, path) in enumerate(self.available_dbs):
597
+ if path in db_url or db_url.endswith(path):
598
+ self.current_db_index = idx
599
+ logging.info(f"Using database: {name} ({path})")
600
+ break
601
+
602
+ def compose(self) -> ComposeResult:
603
+ """Create the UI layout."""
604
+ yield Header(show_clock=True)
605
+
606
+ with Container(id="experiments-table"):
607
+ yield Static("🧪 Experiments", classes="section-title")
608
+ yield ExperimentTable(id="experiments")
609
+
610
+ with Container(id="active-runs-panel"):
611
+ yield Static("⚡ Active Runs", classes="section-title")
612
+ yield ActiveRunsTable(id="active-runs")
613
+
614
+ with Container(id="balance-status"):
615
+ yield Static("💰 Balance & Stats", classes="section-title")
616
+ yield BalanceStatus(id="balance")
617
+
618
+ with Container(id="experiment-detail"):
619
+ yield Static("📋 Details", classes="section-title")
620
+ yield ExperimentDetail(id="detail")
621
+
622
+ with Container(id="status-bar"):
623
+ yield DatabaseStatus(id="db-status")
624
+ yield Footer()
625
+
626
+ async def on_mount(self) -> None:
627
+ """Initialize the app when mounted."""
628
+ # Setup database connection - make it optional
629
+ await self._connect_to_database()
630
+
631
+ # Setup tables
632
+ exp_table = self.query_one("#experiments", ExperimentTable)
633
+ exp_table.setup_table()
634
+
635
+ active_runs_table = self.query_one("#active-runs", ActiveRunsTable)
636
+ active_runs_table.setup_table()
637
+
638
+ # Set balance loading state
639
+ balance_widget = self.query_one("#balance", BalanceStatus)
640
+ balance_widget.set_global_loading()
641
+ balance_widget.set_local_loading()
642
+
643
+ # Initial data load
644
+ await self.action_refresh()
645
+
646
+ # Start auto-refresh timer (every 5 seconds)
647
+ self.refresh_timer = self.set_interval(5.0, self._auto_refresh_data)
648
+
649
+ async def _auto_refresh_data(self) -> None:
650
+ """Auto-refresh data periodically."""
651
+ exp_table = self.query_one("#experiments", ExperimentTable)
652
+ active_runs_table = self.query_one("#active-runs", ActiveRunsTable)
653
+ balance_widget = self.query_one("#balance", BalanceStatus)
654
+
655
+ if self.db_manager:
656
+ await exp_table.refresh_data(self.db_manager)
657
+ await active_runs_table.refresh_data(self.db_manager)
658
+ await self._refresh_local_stats(balance_widget)
659
+
660
+ # Always try to refresh global balance (independent of local DB)
661
+ await self._refresh_global_balance(balance_widget)
662
+
663
+ async def action_refresh(self) -> None:
664
+ """Manual refresh action."""
665
+ exp_table = self.query_one("#experiments", ExperimentTable)
666
+ active_runs_table = self.query_one("#active-runs", ActiveRunsTable)
667
+ balance_widget = self.query_one("#balance", BalanceStatus)
668
+
669
+ balance_widget.set_global_loading()
670
+ balance_widget.set_local_loading()
671
+
672
+ if self.db_manager:
673
+ await exp_table.refresh_data(self.db_manager)
674
+ await active_runs_table.refresh_data(self.db_manager)
675
+ await self._refresh_local_stats(balance_widget)
676
+
677
+ # Always try to refresh global balance (independent of local DB)
678
+ await self._refresh_global_balance(balance_widget)
679
+
680
+ async def _refresh_local_stats(self, balance_widget: BalanceStatus) -> None:
681
+ """Refresh local database statistics."""
682
+ if not self.db_manager:
683
+ logging.warning("No database manager available for local stats")
684
+ balance_widget.set_local_error("No database")
685
+ return
686
+
687
+ try:
688
+ logging.info("Fetching local stats from database...")
689
+ # Query local trace statistics
690
+ df = await self.db_manager.query_traces("""
691
+ SELECT
692
+ COUNT(DISTINCT st.session_id) as num_traces,
693
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.cost_usd ELSE 0 END) / 100.0 as total_cost,
694
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.total_tokens ELSE 0 END) as total_tokens
695
+ FROM session_traces st
696
+ LEFT JOIN events ev ON st.session_id = ev.session_id
697
+ """)
698
+
699
+ # Query task/environment breakdown from metadata
700
+ task_df = await self.db_manager.query_traces("""
701
+ SELECT
702
+ json_extract(metadata, '$.env_name') as task_name,
703
+ COUNT(DISTINCT session_id) as trace_count
704
+ FROM session_traces
705
+ WHERE json_extract(metadata, '$.env_name') IS NOT NULL
706
+ GROUP BY task_name
707
+ ORDER BY trace_count DESC
708
+ LIMIT 10
709
+ """)
710
+
711
+ if not df.empty:
712
+ row = df.iloc[0]
713
+ num_traces = int(row["num_traces"] or 0)
714
+ total_cost = float(row["total_cost"] or 0.0)
715
+ total_tokens = int(row["total_tokens"] or 0)
716
+
717
+ # Parse task data
718
+ tasks = []
719
+ if not task_df.empty:
720
+ for _, task_row in task_df.iterrows():
721
+ task_name = task_row["task_name"]
722
+ count = int(task_row["trace_count"])
723
+ if task_name:
724
+ tasks.append((str(task_name), count))
725
+
726
+ logging.info(f"Local stats: {num_traces} traces, ${total_cost:.4f}, {total_tokens} tokens, {len(tasks)} tasks")
727
+ balance_widget.update_local(num_traces, total_cost, total_tokens, tasks)
728
+ else:
729
+ logging.warning("Query returned empty dataframe")
730
+ balance_widget.update_local(0, 0.0, 0, [])
731
+
732
+ except Exception as e:
733
+ logging.error(f"Failed to refresh local stats: {e}", exc_info=True)
734
+ balance_widget.set_local_error(str(e)[:20])
735
+
736
+ async def _refresh_global_balance(self, balance_widget: BalanceStatus) -> None:
737
+ """Refresh balance information from backend API."""
738
+ try:
739
+ # Try to get balance from environment or API
740
+ api_key = os.getenv("SYNTH_API_KEY") or os.getenv("SYNTH_BACKEND_API_KEY")
741
+ if not api_key:
742
+ balance_widget.set_global_unavailable()
743
+ return
744
+
745
+ # Try to get backend URL from environment
746
+ backend_url = os.getenv("SYNTH_BACKEND_BASE_URL") or "https://agent-learning.onrender.com/api/v1"
747
+
748
+ # Fetch balance
749
+ response = requests.get(
750
+ f"{backend_url}/balance/current",
751
+ headers={"Authorization": f"Bearer {api_key}"},
752
+ timeout=5
753
+ )
754
+ response.raise_for_status()
755
+ data = response.json()
756
+
757
+ balance = float(data.get("balance_dollars", 0.0))
758
+
759
+ # Try to get usage data
760
+ try:
761
+ usage_response = requests.get(
762
+ f"{backend_url}/balance/usage/windows",
763
+ params={"hours": "24,168"},
764
+ headers={"Authorization": f"Bearer {api_key}"},
765
+ timeout=5
766
+ )
767
+ if usage_response.ok:
768
+ usage_data = usage_response.json()
769
+ windows = {
770
+ int(r.get("window_hours")): r
771
+ for r in usage_data.get("windows", [])
772
+ if isinstance(r.get("window_hours"), int)
773
+ }
774
+
775
+ spend_24h = 0.0
776
+ spend_7d = 0.0
777
+
778
+ if 24 in windows:
779
+ spend_24h = float(windows[24].get("total_spend_cents", 0)) / 100.0
780
+ if 168 in windows:
781
+ spend_7d = float(windows[168].get("total_spend_cents", 0)) / 100.0
782
+
783
+ balance_widget.update_global(balance, spend_24h, spend_7d)
784
+ else:
785
+ # Fallback to just balance
786
+ balance_widget.update_global(balance, 0.0, 0.0)
787
+ except Exception:
788
+ # Fallback to just balance
789
+ balance_widget.update_global(balance, 0.0, 0.0)
790
+
791
+ except Exception as e:
792
+ # Only show error if it's not just "endpoint not available"
793
+ error_msg = str(e)
794
+ if "500" in error_msg or "Internal Server Error" in error_msg:
795
+ # Backend endpoint not implemented yet
796
+ balance_widget.set_global_unavailable()
797
+ else:
798
+ balance_widget.set_global_error(error_msg[:30])
799
+
800
+ async def action_quit(self) -> None:
801
+ """Quit the application."""
802
+ if self.refresh_timer:
803
+ self.refresh_timer.stop()
804
+ if self.db_manager:
805
+ await self.db_manager.close()
806
+ self.exit()
807
+
808
+ async def _connect_to_database(self) -> None:
809
+ """Connect to the current database."""
810
+ db_status = self.query_one("#db-status", DatabaseStatus)
811
+ balance_widget = self.query_one("#balance", BalanceStatus)
812
+
813
+ try:
814
+ # Close existing connection if any
815
+ if self.db_manager:
816
+ await self.db_manager.close()
817
+ self.db_manager = None
818
+
819
+ # Get current database info
820
+ db_name, db_path = self.available_dbs[self.current_db_index]
821
+ self.db_url = f"sqlite+aiosqlite:///{db_path}"
822
+
823
+ logging.info(f"Connecting to database: {db_name} ({db_path})")
824
+
825
+ self.db_manager = NativeLibsqlTraceManager(self.db_url)
826
+ if self.db_manager:
827
+ await self.db_manager.initialize()
828
+ db_status.set_connected(self.db_url, db_name)
829
+ db_status.set_db_selector(self.current_db_index, len(self.available_dbs))
830
+
831
+ # Immediately refresh local stats after connecting
832
+ logging.info("Refreshing local stats after connection...")
833
+ await self._refresh_local_stats(balance_widget)
834
+ else:
835
+ db_status.set_disconnected("Database manager not available")
836
+ balance_widget.set_local_error("No manager")
837
+ except (ImportError, ModuleNotFoundError):
838
+ # Database dependencies not available
839
+ db_status.set_disconnected("Database dependencies missing (libsql)")
840
+ self.db_manager = None
841
+ balance_widget.set_local_error("No libsql")
842
+ except Exception as e:
843
+ logging.error(f"Failed to connect to database: {e}", exc_info=True)
844
+ db_status.set_disconnected(str(e))
845
+ self.db_manager = None
846
+ balance_widget.set_local_error(str(e)[:15])
847
+
848
+ async def action_next_db(self) -> None:
849
+ """Switch to next database."""
850
+ if len(self.available_dbs) <= 1:
851
+ return
852
+
853
+ self.current_db_index = (self.current_db_index + 1) % len(self.available_dbs)
854
+ await self._connect_to_database()
855
+ await self.action_refresh()
856
+
857
+ async def action_prev_db(self) -> None:
858
+ """Switch to previous database."""
859
+ if len(self.available_dbs) <= 1:
860
+ return
861
+
862
+ self.current_db_index = (self.current_db_index - 1) % len(self.available_dbs)
863
+ await self._connect_to_database()
864
+ await self.action_refresh()
865
+
866
+ def action_toggle_debug(self) -> None:
867
+ """Toggle debug mode."""
868
+ # Could add debug panel or logging level toggle
869
+ pass
870
+
871
+ @on(DataTable.RowHighlighted, "#experiments")
872
+ def on_experiment_selected(self, event: DataTable.RowHighlighted) -> None:
873
+ """Handle experiment selection."""
874
+ exp_table = self.query_one("#experiments", ExperimentTable)
875
+ selected_exp = exp_table.get_selected_experiment()
876
+
877
+ detail_panel = self.query_one("#detail", ExperimentDetail)
878
+ detail_panel.update_experiment(selected_exp)
879
+
880
+
881
+ def main(argv: list[str] | None = None):
882
+ """Main entry point for the dashboard."""
883
+ # Check if textual is available
884
+ if not _TEXTUAL_AVAILABLE:
885
+ print("❌ Textual library is not available. Please install it with: pip install textual")
886
+ return
887
+
888
+ import argparse
889
+ import os
890
+
891
+ parser = argparse.ArgumentParser(description="Synth AI Interactive Dashboard")
892
+ parser.add_argument(
893
+ "-u",
894
+ "--url",
895
+ default=os.getenv("TUI_DB_URL", "sqlite+aiosqlite:///traces/v3/synth_ai.db"),
896
+ help="Database URL (default: traces/v3/synth_ai.db)",
897
+ )
898
+ parser.add_argument("--debug", action="store_true", default=bool(os.getenv("TUI_DEBUG")), help="Enable debug logging")
899
+
900
+ args = parser.parse_args(argv)
901
+
902
+ if args.debug:
903
+ logging.basicConfig(level=logging.DEBUG)
904
+
905
+ # Run the dashboard
906
+ app = SynthDashboard(db_url=args.url)
907
+ app.run()
908
+
909
+
910
+ if __name__ == "__main__":
911
+ main()