insika 0.8.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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -1,24 +1,27 @@
1
- <%# the outcome funnel per store. Reads the fold's cells and the
1
+ <div class="reports-page reports-funnel">
2
+ <%# Outcomes & conversion by agent. Reads the fold's cells and the
2
3
  BudgetLedger's current counters (D6); the ONLY mutation on this page — the
3
4
  baseline freeze — dispatches :freeze_funnel_baseline on the bus (D7). No
4
5
  stage vocabulary of the engine's own: everything here comes from the pack
5
6
  declaration (D1). %>
6
7
  <header class="page-head">
7
8
  <div>
8
- <nav class="crumbs"><span>operate</span></nav>
9
- <h1>Funnel<span class="sub">the outcome funnel per store</span></h1>
9
+ <%== crumbs_for("Funnel") %>
10
+ <h1>Funnel<span class="sub">Outcomes & conversion by agent</span></h1>
10
11
  </div>
11
12
  <div class="head-actions">
12
- <%== agent_filter_form("/studio/funnel", @agent) %>
13
13
  <form method="get" action="/studio/funnel" class="actions inline">
14
- <label>
15
- Period (days)
16
- <select name="period" data-controller="auto-submit" data-action="change->auto-submit#submit">
17
- <% [7, 30, 90].each do |p| %>
18
- <option value="<%= p %>"<%== ' selected' if @period == p %>><%= p %></option>
14
+ <label>Agent
15
+ <%== render("_agent_picker", locals: { name: "agent", options: insika[:profile_source].ids.sort, value: @agent, blank: "All agents — type to search" }) %>
16
+ </label>
17
+ <label>Period
18
+ <select name="period">
19
+ <% ([7, 30, 90] + [@period]).uniq.sort.each do |p| %>
20
+ <option value="<%= p %>"<%== ' selected' if @period == p %>>Last <%= p %> days</option>
19
21
  <% end %>
20
22
  </select>
21
23
  </label>
24
+ <button type="submit">Apply filters</button>
22
25
  </form>
23
26
  </div>
24
27
  </header>
@@ -42,41 +45,97 @@
42
45
  <% max = [counts.values.max || 0, 1].max %>
43
46
  <div class="card">
44
47
  <div class="section-head">
45
- <h3><%= tenant || "platform" %><span class="muted">folded by the tick · recomputable from the outcome store</span></h3>
46
- <span class="muted">primary <strong><%= decl.primary %></strong> · attribution window <%= decl.attribution_window %> (carried, never computed)</span>
48
+ <h3><%= tenant || "platform" %></h3>
49
+ <span class="muted">Primary outcome · <strong><%= decl.primary %></strong></span>
50
+ </div>
51
+
52
+ <%# D6: spend next to the counts, NOT cost-per-conversion. The
53
+ BudgetLedger holds current-window TOKEN totals, no currency and no
54
+ per-day series; a cost/conversion ratio is deferred until
55
+ the ruler proves itself (E3). %>
56
+ <div class="kpi-strip">
57
+ <%== render("_kpi", locals: { label: decl.first_stage, value: first, sub: "last #{@period}d" }) %>
58
+ <%== render("_kpi", locals: { label: decl.primary, value: counts[decl.primary].to_i }) %>
59
+ <%== render("_kpi", locals: { label: "Conversion", value: conversion ? format("%.4f", conversion) : "—" }) %>
60
+ <%== render("_kpi", locals: { label: "Tokens today", value: spend[:daily], sub: "month #{spend[:monthly]}" }) %>
47
61
  </div>
48
- <svg class="barchart" viewBox="0 0 700 120" preserveAspectRatio="none" role="img" aria-label="stage counts, last <%= @period %> days">
49
- <% bw = 700.0 / decl.stages.length * 0.6 %>
62
+
63
+ <%# Chart geometry lives in a 720-unit viewBox and the .chart-funnel
64
+ cap holds the RENDERED width at 720px too — a wide viewport never
65
+ stretches the SVG past that, so a bar drawn at <= 28 units stays
66
+ <= 28 real pixels instead of scaling up with the page. %>
67
+ <svg class="chart chart-funnel" viewBox="0 0 720 120" preserveAspectRatio="none" role="img" aria-label="stage counts, last <%= @period %> days">
68
+ <% bw = [720.0 / decl.stages.length * 0.6, 28.0].min %>
50
69
  <% decl.stages.each_with_index do |stage, i| %>
51
70
  <% c = counts[stage].to_i %>
52
71
  <% h = (c.to_f / max) * 96.0 %>
53
- <% x = (i * (700.0 / decl.stages.length)) + ((700.0 / decl.stages.length - bw) / 2.0) %>
72
+ <% x = (i * (720.0 / decl.stages.length)) + ((720.0 / decl.stages.length - bw) / 2.0) %>
54
73
  <rect class="bar<%== ' bar-empty' if c.zero? %>" x="<%= format('%.1f', x) %>" y="<%= format('%.1f', c.zero? ? 108.0 : 108.0 - h) %>"
55
74
  width="<%= format('%.1f', bw) %>" height="<%= format('%.1f', c.zero? ? 3.0 : [h, 3.0].max) %>" rx="2">
56
75
  <title><%= stage %> · <%= c %></title>
57
76
  </rect>
58
77
  <% end %>
59
- <line class="barchart-axis-line" x1="0" y1="108" x2="700" y2="108" />
78
+ <line class="axis" x1="0" y1="108" x2="720" y2="108" />
60
79
  </svg>
61
- <div class="barchart-axis">
80
+ <div class="chart-labels">
62
81
  <% decl.stages.each do |stage| %>
63
82
  <span><%= stage %> · <%= counts[stage].to_i %></span>
64
83
  <% end %>
65
84
  </div>
66
- <div class="skill-desc">
67
- <span class="pill <%= conversion && conversion.positive? ? 'ok' : 'info' %>">
68
- conversion <%= conversion ? format("%.4f", conversion) : "—" %>
69
- </span>
70
- <span class="muted"><%= counts[decl.primary].to_i %> × <%= decl.primary %> / <%= counts[decl.first_stage].to_i %> × <%= decl.first_stage %>, last <%= @period %> days</span>
71
- <%# D6: spend next to the counts, NOT cost-per-conversion. The
72
- BudgetLedger holds current-window TOKEN totals, no currency and no
73
- per-day series; a cost/conversion ratio is deferred until
74
- the ruler proves itself (E3). %>
75
- <span class="muted">· tokens today <strong><%= spend[:daily] %></strong> / this month <strong><%= spend[:monthly] %></strong></span>
76
- </div>
85
+
86
+ <%# Where conversations stop: the loss between consecutive stages, the
87
+ biggest one marked. Each row drills (?drop=) into the conversations
88
+ that reached the stage but not the next, grouped by the last thing
89
+ that happened in them. %>
90
+ <% if decl.stages.length > 1 %>
91
+ <div class="section-head">
92
+ <h3>Where conversations stop</h3>
93
+ <span class="muted">lost between stages · last <%= @period %>d</span>
94
+ </div>
95
+ <table class="grid">
96
+ <thead><tr><th>between</th><th class="num">lost</th><th class="num">share</th><th></th></tr></thead>
97
+ <tbody>
98
+ <% funnel_drops(decl, counts).each do |d| %>
99
+ <tr><td><a href="<%= funnel_drop_path(profile.id, tenant, d[:from]) %>"><%= d[:from] %> → <%= d[:to] %></a></td>
100
+ <td class="num"><%= d[:lost] %></td>
101
+ <td class="num"><%= d[:of].zero? ? "—" : "#{(100.0 * d[:lost] / d[:of]).round}%" %></td>
102
+ <td><% if d[:biggest] %><span class="pill warn">biggest drop</span><% end %></td></tr>
103
+ <% end %>
104
+ </tbody>
105
+ </table>
106
+ <% groups = @agent == profile.id && @drop_tenant == tenant && @drop ? funnel_drop_sessions(decl, tenant, profile.id, @drop, @period) : nil %>
107
+ <% if groups %>
108
+ <h4>Reached <%= @drop %>, not <%= decl.stages[decl.index_of(@drop) + 1] %></h4>
109
+ <% if @drop_total.to_i > Studio::App::DROP_CLASSIFY_CAP %>
110
+ <p class="muted"><%= @drop_total %> stopped here; the newest <%= Studio::App::DROP_CLASSIFY_CAP %> are grouped below.</p>
111
+ <% end %>
112
+ <% if groups.empty? %>
113
+ <div class="empty">No conversation with an outcome stopped at <%= @drop %> in this window.</div>
114
+ <% else %>
115
+ <ul class="kv">
116
+ <% groups.each do |reason, sids| %>
117
+ <li><span class="k"><%= Studio::App::DROP_REASONS[reason] %> · <%= sids.size %></span>
118
+ <span class="v">
119
+ <% sids.first(Studio::App::DROP_LIST_CAP).each do |sid| %>
120
+ <a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>" title="<%= sid %>"><%= sid[0, 20] %></a>
121
+ <% end %>
122
+ <% if sids.size > Studio::App::DROP_LIST_CAP %><span class="muted">+<%= sids.size - Studio::App::DROP_LIST_CAP %> more</span><% end %>
123
+ </span></li>
124
+ <% end %>
125
+ </ul>
126
+ <% end %>
127
+ <% end %>
128
+ <% end %>
129
+
130
+ <details class="report-details">
131
+ <summary>How this funnel is counted</summary>
132
+ <p>Counts are folded by the tick and can be recomputed from the outcome store. Conversion is the primary outcome count divided by the first-stage count.</p>
133
+ <p>Stage losses compare these folded counts. A drill lists conversations instead: each one's furthest stage across its outcomes in the window.</p>
134
+ <p>Attribution window: <%= decl.attribution_window %> (carried, never computed). Token totals use the current budget window.</p>
135
+ </details>
77
136
 
78
137
  <% baseline = insika[:funnel_store]&.baseline(tenant: tenant, agent: profile.id) %>
79
- <div class="skill-desc">
138
+ <div class="actions">
80
139
  <% if baseline %>
81
140
  <span class="pill ok">baseline frozen</span>
82
141
  <span class="muted"><%= baseline["from"] %> → <%= baseline["to"] %> · <%= baseline["primary"] %> <%= baseline["primary_count"] %> · conversion <%= baseline["conversion"] ? format("%.4f", baseline["conversion"]) : "—" %> · <%= baseline["window"] %> window · frozen <%= baseline["frozen_at"] %></span>
@@ -84,23 +143,25 @@
84
143
  <span class="pill warn">no baseline</span>
85
144
  <span class="muted">≥ 28 days of data required — the freeze refuses a shorter span</span>
86
145
  <% end %>
146
+ <form method="post" action="/studio/funnel/<%= Rack::Utils.escape(profile.id) %>/freeze" class="actions">
147
+ <%== csrf_tag %>
148
+ <label>
149
+ From
150
+ <input type="date" name="from" value="<%= baseline && baseline["from"] %>">
151
+ </label>
152
+ <label>
153
+ To
154
+ <input type="date" name="to" value="<%= baseline && baseline["to"] %>">
155
+ </label>
156
+ <button type="submit" class="primary">Freeze baseline</button>
157
+ <% if tenant %>
158
+ <input type="hidden" name="tenant" value="<%= tenant %>">
159
+ <% end %>
160
+ </form>
87
161
  </div>
88
- <form method="post" action="/studio/funnel/<%= Rack::Utils.escape(profile.id) %>/freeze" class="actions">
89
- <%== csrf_tag %>
90
- <label>
91
- From
92
- <input type="date" name="from" value="<%= baseline && baseline["from"] %>">
93
- </label>
94
- <label>
95
- To
96
- <input type="date" name="to" value="<%= baseline && baseline["to"] %>">
97
- </label>
98
- <button type="submit" class="primary">Freeze baseline</button>
99
- <% if tenant %>
100
- <input type="hidden" name="tenant" value="<%= tenant %>">
101
- <% end %>
102
- </form>
103
162
  </div>
104
163
  <% end %>
105
164
  <% end %>
106
165
  <% end %>
166
+
167
+ </div>
@@ -1,7 +1,10 @@
1
- <header class="page-head">
1
+ <header class="page-head harvest-head">
2
2
  <div>
3
- <nav class="crumbs"><span>harvest</span></nav>
4
- <h1>Harvest<span class="sub"><%= @agent ? "#{@agent}" : "no agent" %></span></h1>
3
+ <%== crumbs_for("Harvest") %>
4
+ <h1>Harvest<span class="sub">Turn conversation evidence into reusable skills</span></h1>
5
+ </div>
6
+ <div class="head-actions">
7
+ <%== agent_filter_form("/studio/harvest", @agent) %>
5
8
  </div>
6
9
  </header>
7
10
 
@@ -11,44 +14,47 @@
11
14
  <div class="empty">No agents yet. Add a <span class="mono">harvest:</span> block to an agent's pack to mine its finished traffic.</div>
12
15
  <% else %>
13
16
  <div class="card">
17
+ <h2>Find new skills</h2>
14
18
  <form method="post" action="/studio/harvest" class="actions">
15
19
  <%== csrf_tag %>
16
20
  <label>
17
21
  Agent
18
- <select name="agent">
19
- <% @agents.each do |id| %>
20
- <option value="<%= id %>"<%== ' selected' if id == @agent %>><%= id %></option>
21
- <% end %>
22
- </select>
22
+ <%== render("_agent_picker", locals: { name: "agent", options: @agents, value: @agent }) %>
23
23
  </label>
24
- <label title="Ignore the mined markers and re-read the whole window (the ledger dedup still protects).">
25
- <input type="checkbox" name="full" value="1"> full window
24
+ <label class="check" title="Ignore the mined markers and re-read the whole window (the ledger dedup still protects).">
25
+ <input type="checkbox" name="full" value="1"> Re-read full window
26
26
  </label>
27
- <button type="submit" class="primary">Mine now</button>
27
+ <button type="submit" class="primary" data-turbo-submits-with="Mining…">Mine now</button>
28
28
  </form>
29
29
  <div class="skill-desc muted">
30
- Reads this agent's finished conversations, asks the configured miner for SKILL
31
- proposals, and filters them through the negative list and the evidence ledger.
32
- Nothing is ever applied automatically — a candidate only lands after the double
33
- gate AND a human approval.
30
+ Reviews finished conversations and proposes skills. Nothing is applied until the evidence checks pass and you approve it.
34
31
  </div>
35
32
  </div>
36
33
 
37
- <% if @agents.size > 1 %>
38
- <div class="filter-bar">
39
- <% @agents.each do |id| %>
40
- <a class="btn" href="/studio/harvest?agent=<%= Rack::Utils.escape(id) %>"<%== ' aria-current="page"' if id == @agent %>><%= id %></a>
41
- <% end %>
42
- </div>
34
+ <% if @idle_reason == :off %>
35
+ <div class="empty">Harvest is off for this agent — turn it on in <a href="/studio/agents/<%= Rack::Utils.escape(@agent) %>?cfg=harvest">Agents › Config › harvest</a>.</div>
36
+ <% elsif @idle_reason == :no_matcher %>
37
+ <div class="empty">Not mining: harvest is on but there is <strong>no grounding matcher</strong>, so the automatic loop skips every conversation. Set a matcher SKU (and uncheck "use the platform default") in <a href="/studio/agents/<%= Rack::Utils.escape(@agent) %>?cfg=grounding">Agents › Config › grounding</a>.</div>
38
+ <% end %>
39
+
40
+ <% if @candidates.empty? && @blocked.empty? && @pending_gates.empty? %>
41
+ <div class="empty">No skills awaiting review. Mine finished conversations to find candidates.</div>
43
42
  <% end %>
44
43
 
45
44
  <%# The human's inbox. %>
46
45
  <% if @candidates.any? %>
47
- <h2>Awaiting approval<span class="sub">the human's answer</span></h2>
46
+ <h2>Awaiting approval<span class="sub">review evidence before applying</span></h2>
48
47
  <% @candidates.each do |cand| %>
49
- <div class="card">
48
+ <div class="card harvest-candidate">
49
+ <div class="review-heading"><div><h3><%= cand.name %></h3><p class="muted"><%= cand.description %></p></div><span class="pill warn">Awaiting approval</span></div>
50
+ <section class="harvest-proposal">
51
+ <h3>Proposed skill</h3>
52
+ <p class="muted"><%= cand.rationale %></p>
53
+ <div class="markdown-preview" data-controller="markdown"><%= cand.body %></div>
54
+ </section>
55
+ <details class="review-details"><summary>Evaluation results</summary>
50
56
  <div class="skill-desc">
51
- <span class="pill ok">gated</span>
57
+ <span class="status <%= status_class(cand.status) %>">gated</span>
52
58
  <span class="mono"><%= cand.name %></span>
53
59
  <span class="muted">· <%= cand.description %></span>
54
60
  </div>
@@ -60,19 +66,23 @@
60
66
  <span class="mono"><%= cand.proposer %></span> · <%= (cand.eval_gate || {})["passed_cases"] %>/<%= (cand.eval_gate || {})["cases"] %> case(s) no regression
61
67
  · conversion <%= (cand.conversion_gate || {})["current"] %> vs <%= (cand.conversion_gate || {})["baseline"] %> (threshold <%= (cand.conversion_gate || {})["threshold"] %>)
62
68
  </div>
69
+ </details>
63
70
  <% if (excerpt = candidate_excerpt(cand)).any? %>
64
- <details class="skill-desc">
65
- <summary>evidence excerpt</summary>
71
+ <details class="review-details" open>
72
+ <summary>Conversation evidence</summary>
66
73
  <% excerpt.each do |sid, idx, text| %>
67
- <div class="preview muted">
68
- <a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>"><%= sid %></a> [<%= idx %>]: <%= text %>
74
+ <div class="harvest-evidence">
75
+ <a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>">Open conversation ↗</a>
76
+ <p class="review-source"><%= text %></p>
69
77
  </div>
70
78
  <% end %>
71
79
  </details>
72
80
  <% end %>
81
+ <details class="review-details"><summary>Source conversations</summary>
73
82
  <% Array(cand.origin).each do |sid| %>
74
83
  <a class="mono muted" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>"><%= sid %></a>
75
84
  <% end %>
85
+ </details>
76
86
  <form method="post" action="/studio/harvest/promote" class="actions">
77
87
  <%== csrf_tag %>
78
88
  <input type="hidden" name="agent" value="<%= cand.agent %>">
@@ -81,13 +91,11 @@
81
91
  Note
82
92
  <input type="text" name="note" placeholder="why (optional)">
83
93
  </label>
84
- <button type="submit" class="primary">Promote</button>
85
- <button type="submit" class="ghost" formaction="/studio/harvest/reject">Reject</button>
94
+ <button type="submit" class="primary" data-turbo-submits-with="Promoting…">Promote</button>
95
+ <button type="submit" class="danger" formaction="/studio/harvest/reject">Reject</button>
86
96
  </form>
87
97
  <div class="skill-desc muted">
88
- Promoting snapshots the pre-promotion state, writes the skill into this store's
89
- scope, enables it on the allowlist and appends the promotion log row. Rollback
90
- is the one-click mirror.
98
+ Promoting saves and enables this skill for the agent. The previous state is saved so you can roll it back.
91
99
  </div>
92
100
  </div>
93
101
  <% end %>
@@ -95,13 +103,17 @@
95
103
 
96
104
  <%# Gated-but-blocked: the ruler's hole, named. %>
97
105
  <% if @blocked.any? %>
98
- <h2>Blocked<span class="sub">gated, waiting on the ruler</span></h2>
106
+ <h2>Blocked<span class="sub">resolve these before approval</span></h2>
99
107
  <div class="card">
100
108
  <ul class="history">
101
109
  <% @blocked.each do |cand| %>
102
110
  <li>
103
- <span class="pill err">blocked</span>
111
+ <span class="status <%= status_class('blocked') %>">blocked</span>
104
112
  <span class="mono"><%= cand.name %></span>
113
+ <% ev = cand.eval_gate || {} %>
114
+ <% if ev["passed"] == false && ev["reason"] && (cand.conversion_gate || {})["reason"] %>
115
+ <span class="preview muted"><%= ev["reason"] %> ·</span>
116
+ <% end %>
105
117
  <span class="preview muted">
106
118
  <% reason = (cand.conversion_gate || {})["reason"] %>
107
119
  <% if reason == "no_frozen_baseline" %>
@@ -113,6 +125,14 @@
113
125
  <%= (cand.eval_gate || {})["reason"] %>
114
126
  <% end %>
115
127
  </span>
128
+ <%== render("_harvest_candidate_detail", locals: { cand: cand }) %>
129
+ <form method="post" action="/studio/harvest/gate" class="actions inline">
130
+ <%== csrf_tag %>
131
+ <input type="hidden" name="agent" value="<%= cand.agent %>">
132
+ <input type="hidden" name="candidate_id" value="<%= cand.id %>">
133
+ <button type="submit" data-turbo-submits-with="Checking…" title="Re-run once the missing piece is fixed">Run checks again</button>
134
+ <button type="submit" class="danger" formaction="/studio/harvest/reject">Reject</button>
135
+ </form>
116
136
  </li>
117
137
  <% end %>
118
138
  </ul>
@@ -121,20 +141,21 @@
121
141
 
122
142
  <%# Mined, not yet gated. %>
123
143
  <% if @pending_gates.any? %>
124
- <h2>Pending<span class="sub">mined, awaiting the double gate</span></h2>
144
+ <h2>Pending<span class="sub">awaiting evaluation and conversion checks</span></h2>
125
145
  <div class="card">
126
146
  <ul class="history">
127
147
  <% @pending_gates.each do |cand| %>
128
148
  <li>
129
- <span class="pill">pending</span>
149
+ <span class="status <%= status_class(cand.status) %>"><%= cand.status %></span>
130
150
  <span class="mono"><%= cand.name %></span>
131
151
  <span class="preview muted"><%= cand.description %></span>
152
+ <%== render("_harvest_candidate_detail", locals: { cand: cand }) %>
132
153
  <form method="post" action="/studio/harvest/gate" class="actions inline">
133
154
  <%== csrf_tag %>
134
155
  <input type="hidden" name="agent" value="<%= cand.agent %>">
135
156
  <input type="hidden" name="candidate_id" value="<%= cand.id %>">
136
- <button type="submit" class="primary">Gate it</button>
137
- <button type="submit" class="ghost" formaction="/studio/harvest/reject">Reject</button>
157
+ <button type="submit" class="primary" data-turbo-submits-with="Checking…">Run checks</button>
158
+ <button type="submit" class="danger" formaction="/studio/harvest/reject">Reject</button>
138
159
  </form>
139
160
  </li>
140
161
  <% end %>
@@ -144,15 +165,15 @@
144
165
 
145
166
  <%# The promotion log — the single ledger, with the rollback mirror. %>
146
167
  <% if @promotions.any? %>
147
- <h2>Promoted<span class="sub">the append-only log</span></h2>
168
+ <h2>Promoted<span class="sub">promotion history</span></h2>
148
169
  <div class="card">
149
170
  <ul class="history">
150
171
  <% @promotions.each do |row| %>
151
172
  <li>
152
173
  <% if row.rolled_back_at %>
153
- <span class="pill err">rolled back</span>
174
+ <span class="status <%= status_class('rejected') %>">rolled back</span>
154
175
  <% else %>
155
- <span class="pill ok">promoted</span>
176
+ <span class="status <%= status_class('approved') %>">promoted</span>
156
177
  <% end %>
157
178
  <span class="mono"><%= row.skill %></span>
158
179
  <span class="preview muted">
@@ -164,7 +185,7 @@
164
185
  <%== csrf_tag %>
165
186
  <input type="hidden" name="agent" value="<%= row.agent %>">
166
187
  <input type="hidden" name="snapshot_ref" value="<%= row.snapshot_ref %>">
167
- <input type="text" name="reason" placeholder="why (optional)">
188
+ <label>Rollback reason <input type="text" name="reason" placeholder="Optional"></label>
168
189
  <button type="submit" class="ghost">Rollback</button>
169
190
  </form>
170
191
  <% end %>
@@ -176,7 +197,8 @@
176
197
 
177
198
  <%# The negative list block. %>
178
199
  <% if @negative && @negative.rules.any? %>
179
- <h2>Negative list<span class="sub">the versioned seed, engine-applied</span></h2>
200
+ <details class="review-details"><summary>Negative list rules</summary>
201
+ <h2>Negative list<span class="sub">rules that exclude candidates</span></h2>
180
202
  <div class="card">
181
203
  <ul class="history">
182
204
  <% @negative.rules.each do |rule| %>
@@ -193,11 +215,12 @@
193
215
  <span class="mono">insika harvest:negative import --agent ID --file F</span>.
194
216
  </div>
195
217
  </div>
218
+ </details>
196
219
  <% end %>
197
220
 
198
221
  <%# The ruler block. %>
199
222
  <% if @criterion %>
200
- <h2>The conversion criterion<span class="sub">frozen before the first promotion</span></h2>
223
+ <h2>Conversion criterion<span class="sub">frozen before the first promotion</span></h2>
201
224
  <div class="card">
202
225
  <div class="skill-desc">
203
226
  <span class="pill">metric <%= @criterion.rule.metric %></span>
@@ -215,20 +238,23 @@
215
238
 
216
239
  <% if @runs.any? %>
217
240
  <h2>Runs<span class="sub">most recent first</span></h2>
218
- <div class="card">
219
- <ul class="history">
220
- <% @runs.each do |run| %>
221
- <li>
222
- <span class="pill <%= run.status == 'failed' ? 'err' : 'ok' %>"><%= run.status %></span>
223
- <span class="mono"><%= run.id[0, 12] %></span>
224
- <span class="preview muted">
225
- <%= run.candidates %> candidate(s) ·
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>
241
+ <div class="card table-scroll" tabindex="0" role="region" aria-label="Harvest runs">
242
+ <table class="grid">
243
+ <thead>
244
+ <tr><th>status</th><th>run</th><th class="num">candidates</th><th class="num">tokens</th><th class="time">started</th></tr>
245
+ </thead>
246
+ <tbody>
247
+ <% @runs.each do |run| %>
248
+ <tr>
249
+ <td><span class="status <%= status_class(run.status) %>"><%= run.status %></span></td>
250
+ <td class="mono"><%= run.id[0, 12] %></td>
251
+ <td class="num"><%= run.candidates %></td>
252
+ <td class="num"><%= run.cost && run.cost["spent"].to_i.positive? ? run.cost["spent"] : "—" %></td>
253
+ <td class="time"><%= run.started_at %></td>
254
+ </tr>
255
+ <% end %>
256
+ </tbody>
257
+ </table>
232
258
  </div>
233
259
  <% end %>
234
260
  <% end %>