insika 0.9.0 → 1.0.1

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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -0,0 +1,187 @@
1
+ <% totals = @report && @report["totals"] %>
2
+ <% requests = totals ? totals["requests"] : 0 %>
3
+ <div class="models-page">
4
+ <header class="page-head">
5
+ <div><%== crumbs_for("Models") %><h1>Models<span class="sub">Usage, cost & performance</span></h1></div>
6
+ <div class="model-updated"><span class="model-indicator"></span>Snapshot<% if @report && @report["to"] %> · <%= Time.iso8601(@report["to"]).utc.strftime("%H:%M UTC") %><% end %></div>
7
+ </header>
8
+
9
+ <form method="get" action="/studio/models" class="model-toolbar">
10
+ <div class="model-filter-group">
11
+ <label><span>Agent</span><select name="agent"><option value="">All agents</option>
12
+ <% (Array(@report && @report["agents"]) + [@agent]).compact.uniq.sort.each do |agent| %><option value="<%= agent %>"<%== ' selected' if @agent == agent %>><%= agent %></option><% end %>
13
+ </select></label>
14
+ <label><span>Provider</span><select name="provider"><option value="">All providers</option>
15
+ <% (Array(@report && @report["providers"]) + [@provider]).compact.uniq.sort.each do |provider| %><option value="<%= provider %>"<%== ' selected' if @provider == provider %>><%= provider %></option><% end %>
16
+ </select></label>
17
+ <label><span>Model</span><select name="model"><option value="">All models</option>
18
+ <% (Array(@report && @report["model_options"]) + [@model]).compact.uniq.sort.each do |model| %><option value="<%= model %>"<%== ' selected' if @model == model %>><%= model %></option><% end %>
19
+ </select></label>
20
+ </div>
21
+ <div class="model-filter-group" data-controller="model-filters">
22
+ <label class="sr-only" for="model-period">Period</label>
23
+ <select id="model-period" name="period" data-model-filters-target="period" data-action="change->model-filters#pickPeriod"><% { "24h" => "Last 24 hours", "7d" => "Last 7 days", "30d" => "Last 30 days", "custom" => "Custom range" }.each do |value, label| %><option value="<%= value %>"<%== ' selected' if @period == value %>><%= label %></option><% end %></select>
24
+ <label class="model-date"><span>From</span><input type="date" name="from" value="<%= @range_from %>" max="<%= Time.now.utc.strftime('%Y-%m-%d') %>" data-model-filters-target="date" data-action="change->model-filters#pickDate"></label>
25
+ <label class="model-date"><span>To</span><input type="date" name="to" value="<%= @range_to %>" max="<%= Time.now.utc.strftime('%Y-%m-%d') %>" data-model-filters-target="date" data-action="change->model-filters#pickDate"></label>
26
+ <button type="submit" class="primary">Apply filters <span aria-hidden="true">↗</span></button>
27
+ </div>
28
+ </form>
29
+
30
+ <section class="model-summary" aria-label="Model metrics summary">
31
+ <div class="model-stat"><span class="model-stat-label">Requests</span><strong><%= requests %></strong><span><%= @report ? @report["models"].size : 0 %> models in this period</span></div>
32
+ <div class="model-stat"><span class="model-stat-label">Reported cost <small>USD</small></span><strong><%= model_chart_value(totals && totals["cost"], "USD") %></strong><span><%= requests.positive? ? "#{totals['unknown_cost_requests']} of #{requests} unknown (#{format('%.1f', totals['unknown_cost_requests'] * 100.0 / requests)}%)" : "Awaiting cost data" %></span></div>
33
+ <div class="model-stat"><span class="model-stat-label">p95 latency</span><strong><%= model_chart_value(totals && totals["p95_ms"], "ms") %></strong><span>p50 <%= model_chart_value(totals && totals["p50_ms"], "ms") %> · p90 <%= model_chart_value(totals && totals["p90_ms"], "ms") %></span></div>
34
+ <div class="model-stat"><span class="model-stat-label">Failure rate</span><strong><%= requests.positive? ? format("%.1f%%", totals["failures"] * 100.0 / requests) : "—" %></strong><span><%= totals ? totals["failures"] : 0 %> failed requests · <%= totals ? totals["retries"] : 0 %> retries</span></div>
35
+ </section>
36
+
37
+ <% if requests.zero? %><div class="model-notice"><strong>No model requests yet for these filters.</strong><span>Choose another period or <a href="/studio/playground">make a request in Playground</a>.</span></div><% end %>
38
+
39
+ <div class="model-charts">
40
+ <section class="card model-panel">
41
+ <div class="model-panel-head"><div><h2>Requests over time</h2><p>Recorded volume across the selected models</p></div><span class="model-unit">REQUESTS</span></div>
42
+ <%== render("_model_chart", locals: { title: "Requests over time", fields: [["requests", "Requests"]], unit: "requests", empty: "No recorded requests" }) %>
43
+ </section>
44
+ <section class="card model-panel">
45
+ <div class="model-panel-head"><div><h2>Reported cost over time</h2><p>Known cost only · missing values remain gaps</p></div><span class="model-unit cost">USD</span></div>
46
+ <%== render("_model_chart", locals: { title: "Reported cost over time", fields: [["cost", "Reported cost"]], unit: "USD", empty: requests.zero? ? "No recorded costs" : "Cost unavailable" }) %>
47
+ </section>
48
+ <section class="card model-panel">
49
+ <div class="model-panel-head"><div><h2>Latency over time</h2><p>Request duration, including native retries</p></div></div>
50
+ <div class="model-legend"><span class="p50">p50</span><span class="p90">p90</span><span class="p95">p95</span></div>
51
+ <%== render("_model_chart", locals: { title: "Latency over time", fields: [["p95_ms", "p95"], ["p90_ms", "p90"], ["p50_ms", "p50"]], unit: "ms", empty: requests.zero? ? "No measured requests" : "Latency unavailable" }) %>
52
+ </section>
53
+ <section class="card model-panel">
54
+ <div class="model-panel-head"><div><h2>Cost by model</h2><p>Where your reported spend is going</p></div><span class="model-unit cost">USD</span></div>
55
+ <% ranked = Array(@report && @report["models"]).sort_by { |row| -(row["cost"] || -1) } %>
56
+ <% maximum = [ranked.filter_map { |r| r["cost"] }.max || 0, 0.000001].max %>
57
+ <% if ranked.empty? %><div class="model-ranking-empty"><strong>No model usage yet</strong><span>Your model mix will appear here.</span></div><% else %>
58
+ <div class="model-ranking">
59
+ <% ranked.first(6).each_with_index do |row, index| %>
60
+ <div class="model-rank">
61
+ <div class="model-rank-label"><span class="model-rank-number"><%= index + 1 %></span><div><strong><%= row["model"] || "Unknown model" %></strong><span><%= row["provider"] || "Unknown provider" %> · <%= row["requests"] %> requests<% if row["unknown_cost_requests"].positive? %> · partial coverage<% end %></span></div><b><%= model_chart_value(row["cost"], "USD") %></b></div>
62
+ <svg class="model-rank-bar" viewBox="0 0 100 4" preserveAspectRatio="none" role="img" aria-label="<%= row["model"] %>: <%= model_chart_value(row["cost"], 'USD') %> reported cost"><rect class="track" width="100" height="4" rx="2"/><% unless row["cost"].nil? %><rect class="fill" width="<%= row["cost"].to_f / maximum * 100 %>" height="4" rx="2"/><% end %></svg>
63
+ </div>
64
+ <% end %>
65
+ </div>
66
+ <p class="model-ranking-note">Top <%= [ranked.size, 6].min %> by reported cost. Full breakdown below.</p>
67
+ <% end %>
68
+ </section>
69
+ </div>
70
+
71
+ <% if requests.positive? %>
72
+ <section class="card model-panel model-detail">
73
+ <div class="section-head"><h2>Model performance</h2><span class="muted">Exact values for the selected period</span></div>
74
+ <div class="model-table-scroll"><table class="grid">
75
+ <thead><tr><th>provider</th><th>model</th><th class="num">req</th><th class="num">fail</th><th class="num">retry</th><th class="num">p50 (ms)</th><th class="num">p90 (ms)</th><th class="num">p95 (ms)</th></tr></thead>
76
+ <tbody>
77
+ <% @report["models"].each do |row| %>
78
+ <tr>
79
+ <td><span class="model-provider"><%= row["provider"] || "—" %></span></td><td class="model-name"><%= row["model"] || "—" %></td>
80
+ <td class="num"><%= row["requests"] %></td><td class="num"><%= row["failures"] %></td><td class="num"><%= row["retries"] %></td>
81
+ <% %w[p50 p90 p95].each do |pct| %><td class="num"><%= row["#{pct}_ms"].nil? ? "—" : format("%.2f", row["#{pct}_ms"]) %></td><% end %>
82
+ </tr>
83
+ <% end %>
84
+ </tbody>
85
+ </table></div>
86
+ </section>
87
+
88
+ <section class="card model-panel model-detail">
89
+ <div class="section-head"><h2>Token breakdown</h2><span class="muted">Reported subtotals · thinking tokens shown separately</span></div>
90
+ <div class="model-table-scroll"><table class="grid">
91
+ <thead><tr><th>model</th><th class="num">USD</th><th class="num">cost unknown</th><th class="num">input</th><th class="num">output</th><th class="num">cache read</th><th class="num">cache write</th><th class="num">thinking</th></tr></thead>
92
+ <tbody>
93
+ <% @report["models"].each do |row| %>
94
+ <tr>
95
+ <td><%= row["provider"] || "—" %> / <%= row["model"] || "—" %></td>
96
+ <td class="num"><%= row["cost"].nil? ? "—" : format("%.6f", row["cost"]) %></td>
97
+ <td class="num"><%= row["unknown_cost_requests"] %> of <%= row["requests"] %></td>
98
+ <% %w[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens].each do |field| %>
99
+ <% unknown = row.fetch("unknown_#{field}_requests", 0) %>
100
+ <% coverage = "#{unknown} of #{row['requests']} requests have unknown #{field.tr('_', ' ')}" %>
101
+ <td class="num"<% if unknown.positive? %> title="Reported subtotal; <%= coverage %>" aria-label="<%= row[field].nil? ? 'No' : row[field] %> reported <%= field.tr('_', ' ') %>; <%= coverage %>"<% end %>><%= row[field].nil? ? "—" : row[field] %></td>
102
+ <% end %>
103
+ </tr>
104
+ <% end %>
105
+ </tbody>
106
+ </table></div>
107
+ </section>
108
+
109
+ <section class="card model-panel model-detail">
110
+ <div class="section-head"><h2>By operation</h2><span class="muted">chat is the reply · knowledge_* runs after the turn</span></div>
111
+ <div class="model-table-scroll"><table class="grid">
112
+ <thead><tr><th>operation</th><th class="num">req</th><th class="num">USD</th><th class="num">input</th><th class="num">output</th><th class="num">cache read</th></tr></thead>
113
+ <tbody>
114
+ <% Array(@report["operations"]).each do |row| %>
115
+ <tr>
116
+ <td><%= row["operation"] %></td>
117
+ <td class="num"><%= row["requests"] %></td>
118
+ <td class="num"><%= row["cost"].nil? ? "—" : format("%.6f", row["cost"]) %></td>
119
+ <% %w[input_tokens output_tokens cache_read_tokens].each do |field| %>
120
+ <td class="num"><%= row[field].nil? ? "—" : row[field] %></td>
121
+ <% end %>
122
+ </tr>
123
+ <% end %>
124
+ </tbody>
125
+ </table></div>
126
+ </section>
127
+
128
+ <% end %>
129
+ <% turns = @report && @report["turns"] %>
130
+ <% if turns && turns["count"].positive? %>
131
+ <section class="card model-panel model-detail">
132
+ <div class="section-head"><h2>Turn latency</h2><span class="muted"><%= turns["count"] %> turns · <%= turns["failures"] %> failed (<%= format("%.1f", turns["failures"] * 100.0 / turns["count"]) %>%)<% unless turns["failure_stages"].empty? %>: <%= turns["failure_stages"].map { |k, v| "#{k} #{v}" }.join(", ") %><% end %> · ttft is the provider's first token, first balloon what the customer waits</span></div>
133
+ <div class="model-table-scroll"><table class="grid">
134
+ <thead><tr><th>window</th><th class="num">turns</th><th class="num">p50 (ms)</th><th class="num">p95 (ms)</th></tr></thead>
135
+ <tbody>
136
+ <% { "ttft_ms" => "ttft", "total_ms" => "total", "first_balloon_ms" => "first balloon", "queue_ms" => "queue", "prep_ms" => "prep", "tools_ms" => "tools" }.each do |key, label| %>
137
+ <% w = turns["windows"][key] %>
138
+ <tr><td><%= label %></td><td class="num"><%= w["measured"] %></td>
139
+ <% %w[p50 p95].each do |pct| %><td class="num"><%= w[pct].nil? ? "—" : format("%.0f", w[pct]) %></td><% end %></tr>
140
+ <% end %>
141
+ </tbody>
142
+ </table></div>
143
+ </section>
144
+ <% unless turns["tools"].empty? %>
145
+ <section class="card model-panel model-detail">
146
+ <div class="section-head"><h2>Tool health</h2><span class="muted">every call in these turns</span></div>
147
+ <div class="model-table-scroll"><table class="grid">
148
+ <thead><tr><th>tool</th><th class="num">calls</th><th class="num">ok %</th><th class="num">p50 (ms)</th><th class="num">p95 (ms)</th></tr></thead>
149
+ <tbody>
150
+ <% turns["tools"].sort_by { |t| -t["failures"] }.each do |t| %>
151
+ <tr><td><%= t["tool"] %></td><td class="num"><%= t["calls"] %></td>
152
+ <td class="num"><%= format("%.1f", (t["calls"] - t["failures"]) * 100.0 / t["calls"]) %></td>
153
+ <% %w[p50_ms p95_ms].each do |k| %><td class="num"><%= t[k].nil? ? "—" : t[k] %></td><% end %></tr>
154
+ <% end %>
155
+ </tbody>
156
+ </table></div>
157
+ </section>
158
+ <% end %>
159
+ <% end %>
160
+ <% if requests.positive? %>
161
+ <section class="card model-panel model-detail">
162
+ <div class="section-head"><h2>Slowest requests</h2><span class="muted">up to 20 measured requests</span></div>
163
+ <% if @report["slowest"].empty? %>
164
+ <div class="empty">No measured request durations yet.</div>
165
+ <% else %>
166
+ <div class="model-table-scroll"><table class="grid">
167
+ <thead><tr><th>task</th><th>provider</th><th>model</th><th>status</th><th class="num">ms</th><th class="num">reported USD</th></tr></thead>
168
+ <tbody>
169
+ <% @report["slowest"].each do |row| %>
170
+ <tr>
171
+ <td><a href="<%= task_path(row["task_id"]) %>"><span title="<%= row["task_id"] %>"><%= row["task_id"].to_s.length > 18 ? "#{row["task_id"][0, 8]}…#{row["task_id"][-6, 6]}" : row["task_id"] %></span></a></td>
172
+ <td><span class="model-provider"><%= row["provider"] || "—" %></span></td><td class="model-name"><%= row["model"] || "—" %></td><td><%= row["status"] || "—" %></td>
173
+ <td class="num"><%= row["duration_ms"].nil? ? "—" : format("%.2f", row["duration_ms"]) %></td>
174
+ <td class="num"><%= row["cost"].nil? ? "—" : format("%.6f", row["cost"]) %></td>
175
+ </tr>
176
+ <% end %>
177
+ </tbody>
178
+ </table></div>
179
+ <% end %>
180
+ </section>
181
+ <% end %>
182
+ <details class="model-methodology"><summary>About these metrics</summary>
183
+ <p>Request latency includes native retries. Retries count usage attempts after the first, not task executions. Percentiles use measured durations, never averages of bucket percentiles.</p>
184
+ <p>Cost and token values are reported subtotals when coverage is incomplete. A dash means no value was reported. Chart intervals use UTC; missing measurements are not interpolated.</p>
185
+ <p>History starts with deployment. Deleting a task removes its model history; retention and tenant purge remove it too. Earlier requests are not backfilled.</p>
186
+ </details>
187
+ </div>
@@ -1,7 +1,8 @@
1
+ <div class="reports-page reports-parity">
1
2
  <header class="page-head">
2
3
  <div>
3
- <nav class="crumbs"><span>operate</span></nav>
4
- <h1>Parity<span class="sub">the shadow experiment</span></h1>
4
+ <%== crumbs_for("Parity") %>
5
+ <h1>Parity<span class="sub">Compare replies in the shadow experiment</span></h1>
5
6
  </div>
6
7
  <div class="head-actions">
7
8
  <%== agent_filter_form("/studio/parity", @agent) %>
@@ -28,7 +29,7 @@
28
29
  </div>
29
30
  <% sha_check = r.checks.find { |c| c.id == :criterion_sha } %>
30
31
  <div class="skill-desc">
31
- <span class="mono"><%= r.criterion_sha %></span>
32
+ <details class="report-details"><summary>Frozen criterion</summary><p class="mono"><%= r.criterion_sha %></p></details>
32
33
  <% if sha_check&.met %>
33
34
  <span class="pill ok">frozen · matches every pair in the window</span>
34
35
  <% else %>
@@ -37,8 +38,32 @@
37
38
  </div>
38
39
  </div>
39
40
 
40
- <h2>Checks<span class="sub">the arithmetic, not the conclusion</span></h2>
41
+ <h2>Judge a batch</h2>
41
42
  <div class="card">
43
+ <form method="post" action="/studio/parity" class="actions">
44
+ <%== csrf_tag %>
45
+ <label>
46
+ Agent (all stores when blank)
47
+ <input type="text" name="agent" placeholder="agent-id" value="<%= @agent %>">
48
+ </label>
49
+ <label>
50
+ Limit
51
+ <input type="number" name="limit" value="50" min="1" max="200">
52
+ </label>
53
+ <button type="submit" class="primary" data-turbo-submits-with="Judging…">Judge <%= @pending %> complete pair(s)</button>
54
+ </form>
55
+ <p class="muted">Only complete pairs are judged, up to the batch limit. <a href="/studio/settings?s=evals">Configure judges</a></p>
56
+ <div class="skill-desc muted">
57
+ Cost: <span class="mono"><%= @judge_cost %></span> provider calls
58
+ (<%= @pending %> pairs × <%= @criterion.rule.min_judge_models %> judges × 2 orders).
59
+ <% if Array((@judge_models || {})["judges"]).empty? %>
60
+ <span class="pill err">No judges configured. Configure judges in Settings before running a batch.</span>
61
+ <% end %>
62
+ </div>
63
+ </div>
64
+
65
+ <h2>Readiness checks<span class="sub">Each requirement must pass</span></h2>
66
+ <div class="card report-table">
42
67
  <table class="grid">
43
68
  <thead><tr><th>check</th><th>required</th><th>actual</th><th></th></tr></thead>
44
69
  <tbody>
@@ -55,7 +80,7 @@
55
80
  </div>
56
81
 
57
82
  <h2>Pairs / day<span class="sub">floor <%= @criterion.rule.pairs_per_day %> · a gap day invalidates the window</span></h2>
58
- <div class="card">
83
+ <div class="card report-table">
59
84
  <table class="grid">
60
85
  <thead><tr><th>date</th><th>pairs</th><th>decided</th><th></th></tr></thead>
61
86
  <tbody>
@@ -72,8 +97,8 @@
72
97
  </div>
73
98
 
74
99
  <% if r.counts %>
75
- <h2>Per store<span class="sub">a store blocks its own cut even when the aggregate passes</span></h2>
76
- <div class="card">
100
+ <h2>Per store<span class="sub">Each agent must meet its own threshold</span></h2>
101
+ <div class="card report-table">
77
102
  <table class="grid">
78
103
  <thead><tr><th>store</th><th>decided</th><th>win-or-tie</th><th>worse</th><th>meets floor</th></tr></thead>
79
104
  <tbody>
@@ -101,42 +126,21 @@
101
126
  </div>
102
127
  <% end %>
103
128
 
104
- <h2>Judge a batch</h2>
105
- <div class="card">
106
- <form method="post" action="/studio/parity" class="actions">
107
- <%== csrf_tag %>
108
- <label>
109
- Agent (all stores when blank)
110
- <input type="text" name="agent" placeholder="agent-id">
111
- </label>
112
- <label>
113
- Limit
114
- <input type="number" name="limit" value="50" min="1" max="200">
115
- </label>
116
- <button type="submit" class="primary">Judge <%= @pending %> complete pair(s)</button>
117
- </form>
118
- <div class="skill-desc muted">
119
- Cost: <span class="mono"><%= @judge_cost %></span> provider calls
120
- (<%= @pending %> pairs × <%= @criterion.rule.min_judge_models %> judges × 2 orders).
121
- <% if Array((@judge_models || {})["judges"]).empty? %>
122
- <span class="pill err">no judges configured in settings['evals'] — the press will refuse</span>
123
- <% end %>
124
- </div>
125
- </div>
126
-
127
129
  <h2>Recent pairs<span class="sub">inbound · incumbent · insika</span></h2>
128
130
  <div class="card">
129
131
  <ul class="history">
130
132
  <% @pairs.each do |p| %>
131
133
  <li>
132
134
  <span class="pill <%= { open: 'warn', complete: 'info', silent: 'info', judged: 'ok', incomplete: 'err' }.fetch(p.status, 'info') %>"><%= p.status %></span>
133
- <span class="mono"><%= p.id[0, 12] %></span>
135
+ <span class="mono" title="<%= p.id %>"><%= p.id[0, 12] %></span>
134
136
  <% if p.agent %><span class="mono"><%= p.agent %></span><% end %>
135
137
  <% if p.outcome %><span class="pill"><%= p.outcome %></span><% end %>
136
- <% if p.session_id %><a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(p.session_id) %>">session →</a><% end %>
138
+ <% if p.session_id %><a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(p.session_id) %>">open session</a><% end %>
137
139
  <div class="skill-desc muted"><strong>customer:</strong> <%= p.inbound.to_s %></div>
140
+ <details class="report-details"><summary>Compare replies</summary>
138
141
  <div class="skill-desc"><strong>incumbent:</strong> <%= p.incumbent_reply.to_s %></div>
139
142
  <div class="skill-desc"><strong>insika:</strong> <%= p.insika_reply.to_s %></div>
143
+ </details>
140
144
  <% if p.verdict.is_a?(Hash) && presence(p.verdict["reason"]) %>
141
145
  <div class="skill-desc muted">why: <%= p.verdict["reason"] %></div>
142
146
  <% end %>
@@ -145,3 +149,5 @@
145
149
  </ul>
146
150
  </div>
147
151
  <% end %>
152
+
153
+ </div>
@@ -1,18 +1,22 @@
1
1
  <%# Playground — chat surface (T4): config bar on top, transcript scrolls, composer
2
2
  pinned at the bottom. Same form fields/route + live-transcript targets as before;
3
3
  fires the same send_message as the API. %>
4
+ <header class="page-head">
5
+ <div>
6
+ <%== crumbs_for("Playground") %>
7
+ <h1>Playground<span class="sub">Test an agent in a new or existing conversation</span></h1>
8
+ </div>
9
+ </header>
10
+
4
11
  <div class="chat"
5
12
  data-controller="live-transcript"
6
13
  data-live-transcript-session-value="<%= @session_id %>">
7
14
  <form class="chat-form" method="post" action="/studio/playground">
8
15
  <%== csrf_tag %>
16
+ <% if @new_session_id %><input type="hidden" name="new_session_id" value="<%= @new_session_id %>"><% end %>
9
17
  <div class="chat-head">
10
18
  <label class="chat-field">agent
11
- <select name="agent">
12
- <% @agents.each do |id| %>
13
- <option value="<%= id %>"<%== ' selected' if id == @agent %>><%= id %></option>
14
- <% end %>
15
- </select>
19
+ <%== render("_agent_picker", locals: { name: "agent", options: @agents, value: @agent }) %>
16
20
  </label>
17
21
  <label class="chat-field">session
18
22
  <input type="text" name="session_id" value="<%= @session_id %>" placeholder="empty = new conversation"
@@ -25,10 +29,10 @@
25
29
  </label>
26
30
  <span class="pill info chat-status" data-live-transcript-target="status">disconnected</span>
27
31
  <details class="pin chat-pin">
28
- <summary>overrides <span class="muted">— pin a NEW conversation (Chat › Agent › Model › platform)</span></summary>
32
+ <summary>Model overrides <span class="muted">— new conversations only</span></summary>
33
+ <p class="muted">Leave blank to use the agent’s defaults. An existing session keeps its saved model settings.</p>
29
34
  <div class="row">
30
- <label>model<input type="text" name="model" placeholder="leave empty = agent/platform default"></label>
31
- <label>provider<input type="text" name="provider" placeholder="optional"></label>
35
+ <label>model<%== model_select("model_ref", nil, "agent/platform default") %></label>
32
36
  <label>reasoning<%== thinking_select("thinking", nil, "agent/platform default") %></label>
33
37
  </div>
34
38
  </details>
@@ -40,7 +44,7 @@
40
44
  <% if @sent_message %><%== render("_message", locals: { m: { "role" => "user", "content" => @sent_message, "at" => nil }, prev: nil }) %><% end %>
41
45
  </div>
42
46
  <div class="empty chat-empty" data-live-transcript-target="empty"<%== ' hidden' unless Array(@history).empty? && @sent_message.nil? %>>
43
- <% if @session_id %>Waiting for turn events…<% else %>Send a message to begin a conversation.<% end %>
47
+ <% if @session_id %>Waiting for turn events…<% else %>Choose an agent and send a message to begin. Leave the session blank for a new conversation.<% end %>
44
48
  </div>
45
49
  </div>
46
50
 
@@ -1,234 +1,25 @@
1
+ <%# Refinement — miller columns: master run list | detail frame, scoped to
2
+ one agent at a time via agent_filter_form (a run always belongs to one
3
+ agent). A row click loads the detail beside the list (no full page load)
4
+ and advances the URL. The detail pane defaults to the run that most needs
5
+ a look — see render_refinement — and hosts the Run form in its head, since
6
+ starting a run is an action on the CURRENTLY VIEWED agent. %>
1
7
  <header class="page-head">
2
8
  <div>
3
- <nav class="crumbs"><span>refinement</span></nav>
4
- <h1>Refinement<span class="sub"><%= @agent ? "#{@agent}" : "no agent" %></span></h1>
9
+ <%== crumbs_for("Refinement") %>
10
+ <h1>Refinement<span class="sub"><%= @agent || "no agent" %></span></h1>
11
+ </div>
12
+ <div class="head-actions">
13
+ <%== agent_filter_form("/studio/refinement", @agent) %>
14
+ <a class="btn" href="/studio/chats">Open chats</a>
5
15
  </div>
6
- <a class="btn" href="/studio/chats">Chats →</a>
7
16
  </header>
8
17
 
9
18
  <% if @agents.empty? %>
10
19
  <div class="empty">No agents yet. Create one first — a refinement run reads an agent's own traffic.</div>
11
20
  <% else %>
12
- <div class="card">
13
- <form method="post" action="/studio/refinement" class="actions">
14
- <%== csrf_tag %>
15
- <label>
16
- Agent
17
- <select name="agent">
18
- <% @agents.each do |id| %>
19
- <option value="<%= id %>"<%== ' selected' if id == @agent %>><%= id %></option>
20
- <% end %>
21
- </select>
22
- </label>
23
- <label title="Ignore the previous run and read the full window instead of only what happened since.">
24
- <input type="checkbox" name="full" value="1"> full window
25
- </label>
26
- <button type="submit" class="primary">Run</button>
27
- </form>
28
- <div class="skill-desc muted">
29
- A run reads this agent's own tasks, transcripts and tool traces and records what
30
- broke, ranked. It never edits the agent and never calls a model.
31
- </div>
21
+ <div class="drill" data-controller="list-filter">
22
+ <%== render("_refinement_master", locals: { active_id: @selected&.id }) %>
23
+ <%== render("refinement_detail", locals: { frame_only: true }) %>
32
24
  </div>
33
-
34
- <% if @agent && @agents.size > 1 %>
35
- <div class="filter-bar">
36
- <% @agents.each do |id| %>
37
- <a class="btn" href="/studio/refinement?agent=<%= Rack::Utils.escape(id) %>"<%== ' aria-current="page"' if id == @agent %>><%= id %></a>
38
- <% end %>
39
- </div>
40
- <% end %>
41
-
42
- <%# The review IS the product. A gated proposal shows the exact
43
- edit, what it claims to fix, and how it scored against the accepted baseline,
44
- because approving is a decision and a decision needs those three things. %>
45
- <% if @proposal %>
46
- <h2>Proposal<span class="sub">gated · awaiting your approval</span></h2>
47
- <div class="card">
48
- <% cand = @proposal.candidate || {} %>
49
- <div class="skill-desc">
50
- <span class="pill ok">gate passed</span>
51
- <span class="mono"><%= @proposal.gate["passed_cases"] %>/<%= @proposal.gate["cases"] %> case(s)</span>
52
- <span class="muted">no regression against <%= @proposal.gate["baseline_cases"] %> baselined case(s)</span>
53
- <span class="muted">· proposed by <span class="mono"><%= cand["proposer"] %></span></span>
54
- <%# When a PANEL ran, the operator is looking at the winner of
55
- several, and which ones it beat is part of the decision. %>
56
- <% if @proposal.panel.size > 1 %>
57
- <span class="muted">· best of <%= @proposal.panel.size %> candidate(s)</span>
58
- <% end %>
59
- <% if @proposal.cost && @proposal.cost["spent"].to_i.positive? %>
60
- <span class="muted">· <%= @proposal.cost["spent"] %> tokens<% if @proposal.cost["cached"].to_i.positive? %>
61
- (<%= @proposal.cost["cached"] %> cached)<% end %></span>
62
- <% end %>
63
- </div>
64
- <% if presence(cand["rationale"]) %>
65
- <div class="skill-desc muted"><%= cand["rationale"] %></div>
66
- <% end %>
67
-
68
- <% Array(cand["edits"]).each do |e| %>
69
- <div class="skill-desc">
70
- <span class="pill"><%= e["op"] %></span>
71
- <span class="mono"><%= e["file"] %></span>
72
- <% if presence(e["anchor"]) %><span class="muted"><%= e["anchor"] %></span><% end %>
73
- <% if Array(e["addresses"]).any? %>
74
- <span class="muted">· addresses <%= Array(e["addresses"]).join(", ") %></span>
75
- <% end %>
76
- </div>
77
- <pre class="diff"><% if e["op"] == "replace" %><span class="del">- <%= e["before"] %></span>
78
- <% end %><span class="ins">+ <%= e["after"] %></span></pre>
79
- <% end %>
80
-
81
- <form method="post" action="/studio/refinement/resolve" class="actions">
82
- <%== csrf_tag %>
83
- <input type="hidden" name="agent" value="<%= @agent %>">
84
- <input type="hidden" name="run_id" value="<%= @proposal.id %>">
85
- <label>
86
- Note
87
- <input type="text" name="note" placeholder="why (optional)">
88
- </label>
89
- <button type="submit" class="primary" name="decision" value="approved">Approve &amp; apply</button>
90
- <button type="submit" class="ghost" name="decision" value="rejected">Reject</button>
91
- </form>
92
- <div class="skill-desc muted">
93
- Approving writes the edits to the agent's files and versions the previous
94
- content — undo it any time from the file's History.
95
- </div>
96
- </div>
97
-
98
- <%# The candidates that LOST. Shown because the panel's value is comparative: an
99
- operator deciding whether the loop earns its keep needs to see that three
100
- models were asked and what the other two wrote. %>
101
- <% losers = @proposal.panel.reject { |e| (e["candidate"] || {})["id"] == cand["id"] } %>
102
- <% if losers.any? %>
103
- <h2>Also proposed<span class="sub">scored and not chosen</span></h2>
104
- <div class="card">
105
- <ul class="history">
106
- <% losers.each do |entry| %>
107
- <% other = entry["candidate"] || {}; verdict = entry["gate"] || {} %>
108
- <li>
109
- <span class="pill <%= verdict["passed"] ? 'ok' : 'err' %>"><%= verdict["passed"] ? "passed" : "no" %></span>
110
- <span class="mono"><%= Array(entry["proposers"]).join(" + ") %></span>
111
- <span class="preview muted">
112
- <%= Array(other["edits"]).size %> edit(s)
113
- <% if verdict["cases"].to_i.positive? %>· <%= verdict["passed_cases"] %>/<%= verdict["cases"] %> case(s)<% end %>
114
- <% if presence(verdict["reason"]) %>· <%= verdict["reason"] %><% end %>
115
- </span>
116
- </li>
117
- <% end %>
118
- </ul>
119
- </div>
120
- <% end %>
121
- <% end %>
122
-
123
- <% if @run.nil? %>
124
- <div class="empty">No run yet for <span class="mono"><%= @agent %></span>. Press Run to read the last conversations.</div>
125
- <% else %>
126
- <h2>Findings<span class="sub"><%= @run.findings.size %> · <%= @run.status %><% if @run.excluded.to_i.positive? %> · <%= @run.excluded %> turn(s) excluded<% end %> · <%= @run.finished_at || @run.started_at %></span></h2>
127
-
128
- <%# The model writes the candidate, the gate scores it. Offered on
129
- a finished report and only when nothing is already awaiting an answer: a second
130
- proposal while one is pending would ask the operator to review two edits to the
131
- same file that were never scored together. %>
132
- <% if @proposal.nil? && @run.status == :completed && @run.findings.any? %>
133
- <div class="card">
134
- <form method="post" action="/studio/refinement/propose" class="actions">
135
- <%== csrf_tag %>
136
- <input type="hidden" name="agent" value="<%= @agent %>">
137
- <input type="hidden" name="run_id" value="<%= @run.id %>">
138
- <button type="submit" class="primary">Propose a fix</button>
139
- </form>
140
- <div class="skill-desc muted">
141
- Asks this agent's configured proposer(s) for a small, anchored edit to its
142
- allowlisted files, then scores each one by replaying the golden set against a
143
- throwaway clone and keeps the best. Nothing is written until you approve it.
144
- Takes a while and costs provider calls — the replay is a real conversation per
145
- case, per candidate.
146
- </div>
147
- </div>
148
- <% end %>
149
-
150
- <%# `mode: auto_apply` changed this agent's files with nobody in
151
- the loop, so the page owes the operator exactly three things: what changed, why,
152
- and where to undo it. The write versioned, so History IS the undo. %>
153
- <% if @run.status == :applied && (@run.decision || {})["by"] == "auto_apply" %>
154
- <div class="card">
155
- <div class="skill-desc">
156
- <span class="pill ok">auto-applied</span>
157
- <span class="muted"><%= (@run.decision || {})["note"] %> · <%= (@run.decision || {})["at"] %></span>
158
- </div>
159
- <% Array((@run.candidate || {})["edits"]).each do |e| %>
160
- <div class="skill-desc">
161
- <span class="pill"><%= e["op"] %></span>
162
- <a class="mono" href="/studio/agents/<%= Rack::Utils.escape(@agent) %>?file=<%= Rack::Utils.escape(e["file"]) %>"><%= e["file"] %></a>
163
- </div>
164
- <pre class="diff"><% if e["op"] == "replace" %><span class="del">- <%= e["before"] %></span>
165
- <% end %><span class="ins">+ <%= e["after"] %></span></pre>
166
- <% end %>
167
- <div class="skill-desc muted">
168
- Undo it from the file's History — the previous content is one click away.
169
- </div>
170
- </div>
171
- <% end %>
172
-
173
- <%# A gate that refused is the most useful thing on this page and the easiest to
174
- lose: without it, pressing Propose and getting nothing back reads as a bug. %>
175
- <% if @run.status == :rejected && @run.gate.is_a?(Hash) && !@run.gate_passed? %>
176
- <div class="card">
177
- <div class="skill-desc">
178
- <span class="pill err">gate refused</span>
179
- <span class="mono"><%= @run.gate["reason"] %></span>
180
- </div>
181
- <% Array((@run.candidate || {})["dropped"]).each do |d| %>
182
- <div class="skill-desc muted">dropped <span class="mono"><%= d["file"] %></span> — <%= d["reason"] %></div>
183
- <% end %>
184
- </div>
185
- <% end %>
186
-
187
- <% if @run.status == :failed %>
188
- <div class="empty">This run failed: <span class="mono"><%= @run.error %></span></div>
189
- <% elsif @run.findings.empty? %>
190
- <div class="empty">Nothing found in this window. Either traffic was clean, or the window was empty.</div>
191
- <% else %>
192
- <div class="card">
193
- <ul class="history">
194
- <% @run.findings.each do |f| %>
195
- <li>
196
- <span class="pill"><%= f["kind"] %></span>
197
- <span class="mono">×<%= f["count"] %></span>
198
- <span class="preview"><%= f["title"] %></span>
199
- <% if f["detail"] %><div class="skill-desc muted"><%= f["detail"] %></div><% end %>
200
- <% if Array(f["sessions"]).any? %>
201
- <div class="skill-desc muted">
202
- <% Array(f["sessions"]).each do |sid| %>
203
- <a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>"><%= sid[0, 12] %></a>
204
- <% end %>
205
- </div>
206
- <% end %>
207
- </li>
208
- <% end %>
209
- </ul>
210
- </div>
211
- <% end %>
212
- <% end %>
213
-
214
- <% if @runs.any? %>
215
- <h2>Runs<span class="sub">most recent first</span></h2>
216
- <div class="card">
217
- <ul class="history">
218
- <% @runs.each do |run| %>
219
- <li>
220
- <span class="pill <%= run.status == :failed ? 'err' : 'ok' %>"><%= run.status %></span>
221
- <span class="mono"><%= run.id[0, 12] %></span>
222
- <span class="preview muted">
223
- <%= run.findings.size %> finding(s) ·
224
- window <%= refinement_window(run.window) %> ·
225
- <% if run.panel.size > 1 %><%= run.panel.size %> candidate(s) · <% end %>
226
- <% if run.cost && run.cost["spent"].to_i.positive? %><%= run.cost["spent"] %> tokens · <% end %>
227
- <%= run.started_at %>
228
- </span>
229
- </li>
230
- <% end %>
231
- </ul>
232
- </div>
233
- <% end %>
234
25
  <% end %>