insika 0.2.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- metadata +77 -1
data/lib/insika/studio/app.rb
CHANGED
|
@@ -81,10 +81,18 @@ module Studio
|
|
|
81
81
|
def configure(command_bus:, profile_source:, event_stream:, config:,
|
|
82
82
|
agent_file_store: nil, skill_store: nil, skill_catalog: nil,
|
|
83
83
|
tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
|
|
84
|
+
memory_audit_store: nil,
|
|
84
85
|
settings_store: nil, llm_provider_store: nil, mcp_store: nil,
|
|
85
86
|
system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
|
|
87
|
+
cache_series_store: nil,
|
|
86
88
|
task_store: nil, checkpoint_store: nil, pending_action_store: nil,
|
|
87
|
-
refinement_store: nil, golden_store: nil, session_secret: nil
|
|
89
|
+
refinement_store: nil, golden_store: nil, session_secret: nil,
|
|
90
|
+
outcome_store: nil, shadow_pair_store: nil, parity_criterion: nil,
|
|
91
|
+
funnel_store: nil, budget_ledger: nil,
|
|
92
|
+
followup_store: nil, contact_store: nil,
|
|
93
|
+
proposal_store: nil,
|
|
94
|
+
harvest_store: nil, harvest_criterion: nil, negative_list: nil,
|
|
95
|
+
channel_registry: nil)
|
|
88
96
|
@insika = {
|
|
89
97
|
command_bus: command_bus, profile_source: profile_source,
|
|
90
98
|
event_stream: event_stream, config: config,
|
|
@@ -94,6 +102,9 @@ module Studio
|
|
|
94
102
|
# shows the data-tools in the matrix; the store feeds the authoring page.
|
|
95
103
|
tool_store: tool_store,
|
|
96
104
|
memory_store: memory_store, session_store: session_store,
|
|
105
|
+
# the Customers drill renders the audit lines (digests +
|
|
106
|
+
# counts, never content). nil = the audit section renders empty.
|
|
107
|
+
memory_audit_store: memory_audit_store,
|
|
97
108
|
# runtime config (settings/LLM/MCP) + global system
|
|
98
109
|
# files + conversations index. All optional (empty-state if nil).
|
|
99
110
|
settings_store: settings_store, llm_provider_store: llm_provider_store,
|
|
@@ -104,6 +115,10 @@ module Studio
|
|
|
104
115
|
# per-session context breakdown: tokens by category +
|
|
105
116
|
# budget per turn, on the same viewer. Counts only, no content.
|
|
106
117
|
context_trace_store: context_trace_store,
|
|
118
|
+
# per-AGENT cache-hit series (percentages + counts, no
|
|
119
|
+
# content) — the cache tab on the agent detail. nil = tab renders
|
|
120
|
+
# "No turns recorded.".
|
|
121
|
+
cache_series_store: cache_series_store,
|
|
107
122
|
# operate: tasks + human-in-the-loop approvals. Reads the
|
|
108
123
|
# task/checkpoint/pending stores to render; controls (pause/resume/
|
|
109
124
|
# cancel/approve) dispatch on the bus — parity with server/admin.
|
|
@@ -113,7 +128,37 @@ module Studio
|
|
|
113
128
|
# Read-only here; the "Run" button dispatches :run_refinement on the bus.
|
|
114
129
|
refinement_store: refinement_store,
|
|
115
130
|
# eval cases: read to render; writes go through:write_golden.
|
|
116
|
-
golden_store: golden_store
|
|
131
|
+
golden_store: golden_store,
|
|
132
|
+
# WS7: business outcomes — the agents grid's last-outcome pill; the
|
|
133
|
+
# agent detail shows the per-day series. Reads only; recording goes
|
|
134
|
+
# through POST /v1/outcomes.
|
|
135
|
+
outcome_store: outcome_store,
|
|
136
|
+
# the Funnel page reads the fold's cells and the
|
|
137
|
+
# BudgetLedger's current counters (D6). nil = the page renders its
|
|
138
|
+
# empty state.
|
|
139
|
+
funnel_store: funnel_store, budget_ledger: budget_ledger,
|
|
140
|
+
# the shadow pair store (read to render /studio/parity), the
|
|
141
|
+
# frozen criterion (folded by Verdict), and the channel registry (the
|
|
142
|
+
# nav row only exists when a shadow channel is registered — the page
|
|
143
|
+
# is not an invitation, unlike Refinement's).
|
|
144
|
+
shadow_pair_store: shadow_pair_store, parity_criterion: parity_criterion,
|
|
145
|
+
channel_registry: channel_registry,
|
|
146
|
+
# the Follow-ups page READS the stores directly;
|
|
147
|
+
# its only mutations — cancel a pending record, force-revoke a contact
|
|
148
|
+
# — dispatch :cancel_followup / :revoke_contact on the bus. nil = the
|
|
149
|
+
# page renders its empty state.
|
|
150
|
+
followup_store: followup_store, contact_store: contact_store,
|
|
151
|
+
# the Facts (wiki) page reads the proposal store directly
|
|
152
|
+
# (approvals/rejections dispatch :resolve_proposal on the bus).
|
|
153
|
+
# nil = the page renders its empty state.
|
|
154
|
+
proposal_store: proposal_store,
|
|
155
|
+
# the Harvest page reads the harvest store directly
|
|
156
|
+
# (the pending/awaiting/promoted lists); its mutations — mine, gate,
|
|
157
|
+
# promote, reject, rollback — dispatch bus commands. The criterion
|
|
158
|
+
# (boot-loaded, with its sha) and the negative list render the two
|
|
159
|
+
# pre-registered artifacts. nil = the page renders its empty state.
|
|
160
|
+
harvest_store: harvest_store, harvest_criterion: harvest_criterion,
|
|
161
|
+
negative_list: negative_list
|
|
117
162
|
}.freeze
|
|
118
163
|
# "restart recommended" flag — in memory, PER PROCESS. A
|
|
119
164
|
# config change that the runtime only re-reads at boot (e.g.: MCP instances are
|
|
@@ -233,11 +278,15 @@ module Studio
|
|
|
233
278
|
r.is do
|
|
234
279
|
r.get do
|
|
235
280
|
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
281
|
+
# WS7 scorecard: the LAST outcome per agent, computed once for the
|
|
282
|
+
# whole grid (a store scan per card would be n scans). Series live
|
|
283
|
+
# on the agent detail — one agent's periods, not n charts here.
|
|
284
|
+
@latest_outcomes = insika[:outcome_store]&.latest_per_agent
|
|
236
285
|
view("agents")
|
|
237
286
|
end
|
|
238
287
|
|
|
239
288
|
# POST /studio/agents — creates an agent ("everyone creates
|
|
240
|
-
# their own
|
|
289
|
+
# their own agent"). Fires :create_agent; redirects to the new one's detail.
|
|
241
290
|
r.post do
|
|
242
291
|
check_csrf!
|
|
243
292
|
id = presence(r.params["id"])
|
|
@@ -269,20 +318,86 @@ module Studio
|
|
|
269
318
|
with_flash("Configuration saved.") do
|
|
270
319
|
dispatch(:update_agent, config_patch(r))
|
|
271
320
|
end
|
|
272
|
-
r.
|
|
321
|
+
group = CONFIG_SECTIONS.include?(r.params["cfg"]) ? r.params["cfg"] : "model"
|
|
322
|
+
r.redirect(agent_path(id, "config", "cfg=#{group}"))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Automatic-loop triggers, per agent. The loops (distillation,
|
|
326
|
+
# harvest, follow-up firing, funnel fold) already run on their own
|
|
327
|
+
# worker fibers when the agent declares them; these buttons exist so
|
|
328
|
+
# an operator can ask for a pass NOW, without waiting for the
|
|
329
|
+
# window. All three dispatch bus commands — the Studio never runs a
|
|
330
|
+
# loop itself.
|
|
331
|
+
r.post "distill" do
|
|
332
|
+
check_csrf!
|
|
333
|
+
done = 0
|
|
334
|
+
skipped = 0
|
|
335
|
+
begin
|
|
336
|
+
due_distill_sessions(id).each do |sid|
|
|
337
|
+
result = dispatch(:run_distillation, { session_id: sid })
|
|
338
|
+
if result && result[:distilled]
|
|
339
|
+
done += 1
|
|
340
|
+
else
|
|
341
|
+
skipped += 1
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
flash["notice"] = "Distilled #{done} session(s)" + (skipped.positive? ? " (#{skipped} skipped)." : ".")
|
|
345
|
+
rescue Insika::ValidationError, Insika::NotFoundError => e
|
|
346
|
+
flash["error"] = e.message
|
|
347
|
+
end
|
|
348
|
+
r.redirect(agent_path(id, "loops"))
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
r.post "harvest" do
|
|
352
|
+
check_csrf!
|
|
353
|
+
begin
|
|
354
|
+
result = dispatch(:run_harvest, { agent: id })
|
|
355
|
+
if result && result[:mined]
|
|
356
|
+
flash["notice"] = "Harvest run finished — #{result[:candidates]} candidate(s) mined."
|
|
357
|
+
elsif result && result[:skipped]
|
|
358
|
+
flash["notice"] = "Nothing mined — #{result[:skipped]}."
|
|
359
|
+
else
|
|
360
|
+
flash["notice"] = "Harvest run finished."
|
|
361
|
+
end
|
|
362
|
+
rescue Insika::ValidationError, Insika::NotFoundError => e
|
|
363
|
+
flash["error"] = e.message
|
|
364
|
+
end
|
|
365
|
+
r.redirect(agent_path(id, "loops"))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
r.post "refinement" do
|
|
369
|
+
check_csrf!
|
|
370
|
+
with_flash("Refinement run finished — see the Refinement page.") do
|
|
371
|
+
dispatch(:run_refinement, { agent: id })
|
|
372
|
+
end
|
|
373
|
+
r.redirect(agent_path(id, "loops"))
|
|
273
374
|
end
|
|
274
375
|
|
|
275
376
|
# Store-backed prompts. Writing also ensures the file
|
|
276
377
|
# enters `prompt_files` — otherwise the Prompt provider wouldn't load it.
|
|
277
378
|
# prompt_files is synced by the Commands themselves (write/delete
|
|
278
379
|
# register/remove the file) — the Studio just dispatches the operation.
|
|
380
|
+
#
|
|
381
|
+
# The prompts section renders as a drill (master file list | editor), so
|
|
382
|
+
# the file under edit rides the URL like skills does: GET .../prompts/:file
|
|
383
|
+
# opens that file, .../prompts/new opens the create form.
|
|
279
384
|
r.on "prompts" do
|
|
385
|
+
r.get "new" do
|
|
386
|
+
@prompt_selected = ""
|
|
387
|
+
render_agent_detail
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
r.get String do |file|
|
|
391
|
+
@prompt_selected = utf8(file)
|
|
392
|
+
render_agent_detail
|
|
393
|
+
end
|
|
394
|
+
|
|
280
395
|
r.post "delete" do
|
|
281
396
|
check_csrf!
|
|
282
397
|
with_flash("Prompt removed.") do
|
|
283
398
|
dispatch(:delete_agent_file, { agent_id: id, file: presence(r.params["file"]) })
|
|
284
399
|
end
|
|
285
|
-
r.redirect(agent_path(id))
|
|
400
|
+
r.redirect(agent_path(id, "prompts"))
|
|
286
401
|
end
|
|
287
402
|
|
|
288
403
|
r.post "restore" do
|
|
@@ -293,7 +408,7 @@ module Studio
|
|
|
293
408
|
version: r.params["version"]
|
|
294
409
|
})
|
|
295
410
|
end
|
|
296
|
-
r.redirect(
|
|
411
|
+
r.redirect(prompt_edit_path(id, r.params["file"]))
|
|
297
412
|
end
|
|
298
413
|
|
|
299
414
|
r.post do
|
|
@@ -303,7 +418,7 @@ module Studio
|
|
|
303
418
|
agent_id: id, file: presence(r.params["file"]), content: r.params["content"].to_s
|
|
304
419
|
})
|
|
305
420
|
end
|
|
306
|
-
r.redirect(
|
|
421
|
+
r.redirect(prompt_edit_path(id, r.params["file"]))
|
|
307
422
|
end
|
|
308
423
|
end
|
|
309
424
|
|
|
@@ -358,7 +473,7 @@ module Studio
|
|
|
358
473
|
|
|
359
474
|
# Agent memory. Scoped by tenant = agent id — the
|
|
360
475
|
# SAME tenant the playground uses when chatting, so what is edited
|
|
361
|
-
# here is what the
|
|
476
|
+
# here is what the agent reads on the turn. Each agent, its own memory.
|
|
362
477
|
r.on "memory" do
|
|
363
478
|
r.post "fact" do
|
|
364
479
|
check_csrf!
|
|
@@ -674,11 +789,90 @@ module Studio
|
|
|
674
789
|
.group_by { |t| t["turn"] }
|
|
675
790
|
# Context breakdown per turn: chronological entries.
|
|
676
791
|
@context_traces = insika[:context_trace_store]&.for_session(sid) || []
|
|
792
|
+
# the stage history — the session's outcomes folded
|
|
793
|
+
# into the agent's declared stages (unmapped kinds render raw).
|
|
794
|
+
@stage_history = stage_history_for(sid)
|
|
677
795
|
view("session")
|
|
678
796
|
end
|
|
679
797
|
end
|
|
680
798
|
end
|
|
681
799
|
|
|
800
|
+
# --- Customers: the memory drill ----------------
|
|
801
|
+
# Derived from the store, not a registry: the index enumerates memory
|
|
802
|
+
# cells and classifies them by shape (D6). Every mutation dispatches a
|
|
803
|
+
# Command on the bus — the transport's constitutional rule (app.rb:21).
|
|
804
|
+
# The cell scope in the URL is parsed back into the (tenant, customer)
|
|
805
|
+
# pair: a "memory:acme:c-1" URL must edit the SAME cell the turn reads
|
|
806
|
+
# (E1 live) — passing the raw scope as `tenant` would double-prefix it.
|
|
807
|
+
r.on "customers" do
|
|
808
|
+
r.is do
|
|
809
|
+
r.get { render_customers }
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
r.on String do |raw_scope|
|
|
813
|
+
# Roda captures the segment URL-encoded; a customer cell contains
|
|
814
|
+
# colons (memory:acme:c-1), so decode before parsing.
|
|
815
|
+
scope = Rack::Utils.unescape_path(utf8(raw_scope))
|
|
816
|
+
cell = Insika::MemoryStore.parse_cell(scope)
|
|
817
|
+
|
|
818
|
+
r.get { render_customer(scope, cell) }
|
|
819
|
+
|
|
820
|
+
# Add/edit a fact inline (upsert) — the operator's stamp + audit.
|
|
821
|
+
r.post "fact" do
|
|
822
|
+
check_csrf!
|
|
823
|
+
with_flash("Fact saved.") do
|
|
824
|
+
dispatch(:memory_put_fact, {
|
|
825
|
+
tenant: cell[:tenant], customer: cell[:customer],
|
|
826
|
+
key: presence(r.params["key"]), value: r.params["value"].to_s,
|
|
827
|
+
expires_at: presence(r.params["expires_at"]), operator: "studio"
|
|
828
|
+
})
|
|
829
|
+
end
|
|
830
|
+
r.redirect(customer_path(scope))
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
r.post "forget-fact" do
|
|
834
|
+
check_csrf!
|
|
835
|
+
with_flash("Fact forgotten.") do
|
|
836
|
+
dispatch(:memory_forget_fact, {
|
|
837
|
+
tenant: cell[:tenant], customer: cell[:customer],
|
|
838
|
+
key: presence(r.params["key"]), operator: "studio"
|
|
839
|
+
})
|
|
840
|
+
end
|
|
841
|
+
r.redirect(customer_path(scope))
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# The LGPD access right: JSON download of the cell's content (D7 —
|
|
845
|
+
# the RETURN value is the download; the event stays counts-only). A
|
|
846
|
+
# cell with no customer (a _default cell, reachable by URL) is a
|
|
847
|
+
# ValidationError -> red flash, never a 500.
|
|
848
|
+
r.post "export" do
|
|
849
|
+
check_csrf!
|
|
850
|
+
result = dispatch(:export_customer_memory, {
|
|
851
|
+
tenant: cell[:tenant], customer: cell[:customer],
|
|
852
|
+
operator: "studio"
|
|
853
|
+
})
|
|
854
|
+
response["content-type"] = "application/json"
|
|
855
|
+
response["content-disposition"] =
|
|
856
|
+
%(attachment; filename="memory-#{Rack::Utils.escape_path(scope.tr(':', '-'))}.json")
|
|
857
|
+
JSON.pretty_generate(result)
|
|
858
|
+
rescue Insika::ValidationError => e
|
|
859
|
+
flash["error"] = e.message
|
|
860
|
+
r.redirect(customer_path(scope))
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# The LGPD forget — purges the cell AND the customer's sessions.
|
|
864
|
+
r.post "forget" do
|
|
865
|
+
check_csrf!
|
|
866
|
+
with_flash("Customer forgotten.") do
|
|
867
|
+
dispatch(:forget_customer, {
|
|
868
|
+
tenant: cell[:tenant], customer: cell[:customer], operator: "studio"
|
|
869
|
+
})
|
|
870
|
+
end
|
|
871
|
+
r.redirect("/studio/customers")
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
|
|
682
876
|
# Tasks: list + detail + operator controls -------
|
|
683
877
|
# Parity with server/admin: READS the task/checkpoint/pending stores to
|
|
684
878
|
# render; pause/resume/cancel dispatch Commands on the bus (never a direct
|
|
@@ -803,6 +997,156 @@ end
|
|
|
803
997
|
end
|
|
804
998
|
end
|
|
805
999
|
|
|
1000
|
+
# --- Harvest: the gated skill loop ----------------------
|
|
1001
|
+
# Reads the HarvestStore directly (the Studio's constitutional split);
|
|
1002
|
+
# every mutation — mine now, gate, promote, reject, rollback — dispatches
|
|
1003
|
+
# a bus command. The gate POST is slow on purpose: it replays the golden
|
|
1004
|
+
# set, like Refinement's propose button.
|
|
1005
|
+
r.on "harvest" do
|
|
1006
|
+
r.is do
|
|
1007
|
+
r.get { render_harvest }
|
|
1008
|
+
r.post do # mine now (manual trigger, D10)
|
|
1009
|
+
check_csrf!
|
|
1010
|
+
agent = presence(r.params["agent"])
|
|
1011
|
+
payload = { agent: agent, full: r.params["full"] == "1" }
|
|
1012
|
+
control_action(:run_harvest, payload, ok: "Harvest run finished.")
|
|
1013
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
r.post "gate" do # the double gate on one candidate (slow — replay)
|
|
1018
|
+
check_csrf!
|
|
1019
|
+
agent = presence(r.params["agent"])
|
|
1020
|
+
payload = { candidate_id: presence(r.params["candidate_id"]) }
|
|
1021
|
+
control_action(:gate_harvest, payload, ok: "Gated — see the verdicts below.")
|
|
1022
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
r.post "promote" do
|
|
1026
|
+
check_csrf!
|
|
1027
|
+
agent = presence(r.params["agent"])
|
|
1028
|
+
payload = { candidate_id: presence(r.params["candidate_id"]),
|
|
1029
|
+
operator: "studio", note: presence(r.params["note"]) }.compact
|
|
1030
|
+
control_action(:promote_harvest, payload,
|
|
1031
|
+
ok: "Promoted — the skill is live for this store.")
|
|
1032
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
r.post "reject" do
|
|
1036
|
+
check_csrf!
|
|
1037
|
+
agent = presence(r.params["agent"])
|
|
1038
|
+
payload = { candidate_id: presence(r.params["candidate_id"]),
|
|
1039
|
+
operator: "studio", note: presence(r.params["note"]) }.compact
|
|
1040
|
+
control_action(:reject_harvest, payload, ok: "Skill rejected.")
|
|
1041
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
r.post "rollback" do
|
|
1045
|
+
check_csrf!
|
|
1046
|
+
agent = presence(r.params["agent"])
|
|
1047
|
+
payload = { snapshot_ref: presence(r.params["snapshot_ref"]),
|
|
1048
|
+
operator: "studio", reason: presence(r.params["reason"]) }.compact
|
|
1049
|
+
control_action(:rollback_harvest, payload, ok: "Rolled back to the snapshot.")
|
|
1050
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1051
|
+
end
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
# --- Facts: the human gate on distilled proposals --------
|
|
1055
|
+
# Reads ProposalStore directly (the Studio's constitutional split — reads
|
|
1056
|
+
# hit the stores, mutations go through the bus); the ONLY mutation — the
|
|
1057
|
+
# approve/reject/dismiss answer — dispatches :resolve_proposal.
|
|
1058
|
+
r.on "facts" do
|
|
1059
|
+
r.get { render_facts }
|
|
1060
|
+
r.post "resolve" do
|
|
1061
|
+
check_csrf!
|
|
1062
|
+
decision = presence(r.params["decision"])
|
|
1063
|
+
ok = { "approved" => "Fact saved to memory.", "rejected" => "Proposal rejected.",
|
|
1064
|
+
"dismissed" => "Proposal dismissed — it will not be proposed again." }[decision]
|
|
1065
|
+
with_flash(ok || "Proposal resolved.") do
|
|
1066
|
+
dispatch(:resolve_proposal, { proposal_id: presence(r.params["proposal_id"]),
|
|
1067
|
+
decision: decision, note: presence(r.params["note"]),
|
|
1068
|
+
operator: "studio" })
|
|
1069
|
+
end
|
|
1070
|
+
filter = presence(r.params["filter"])
|
|
1071
|
+
r.redirect("/studio/facts#{filter ? "?store=#{Rack::Utils.escape(filter)}" : ""}")
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# --- Parity: the running shadow verdict -----------------
|
|
1076
|
+
# Reads stores and folds the verdict on demand; the judge button is a slow
|
|
1077
|
+
# synchronous POST, exactly like Refinement's gate, for the same reason: it
|
|
1078
|
+
# returns when the answer is real. There is deliberately no "freeze the
|
|
1079
|
+
# criterion" button — freezing is a git commit, reviewed by a human.
|
|
1080
|
+
r.on "parity" do
|
|
1081
|
+
r.is do
|
|
1082
|
+
r.get { render_parity }
|
|
1083
|
+
r.post do
|
|
1084
|
+
check_csrf!
|
|
1085
|
+
agent = presence(r.params["agent"])
|
|
1086
|
+
limit = presence(r.params["limit"]).to_i
|
|
1087
|
+
payload = { agent: agent }.compact
|
|
1088
|
+
payload[:limit] = limit if limit.positive?
|
|
1089
|
+
control_action(:judge_shadow_pairs, payload,
|
|
1090
|
+
ok: "Judged — the verdict below is recomputed.")
|
|
1091
|
+
r.redirect("/studio/parity?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1092
|
+
end
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# --- Funnel: the outcome funnel per store ----------------
|
|
1097
|
+
# Reads the fold's cells + the BudgetLedger's current counters directly
|
|
1098
|
+
# (D7 — the scorecard's constitutional split); the ONE mutation — the
|
|
1099
|
+
# baseline freeze — dispatches :freeze_funnel_baseline on the bus.
|
|
1100
|
+
r.on "funnel" do
|
|
1101
|
+
r.get { render_funnel }
|
|
1102
|
+
r.on String do |agent_id|
|
|
1103
|
+
agent_id = utf8(agent_id)
|
|
1104
|
+
r.post "freeze" do
|
|
1105
|
+
check_csrf!
|
|
1106
|
+
with_flash("Baseline frozen.") do
|
|
1107
|
+
dispatch(:freeze_funnel_baseline,
|
|
1108
|
+
{ agent: agent_id,
|
|
1109
|
+
from: presence(r.params["from"]),
|
|
1110
|
+
to: presence(r.params["to"]),
|
|
1111
|
+
# the card's hidden tenant field: WITHOUT it, a
|
|
1112
|
+
# multi-tenant deployment freezes the "platform" pair
|
|
1113
|
+
# while the operator believes they froze the store's.
|
|
1114
|
+
tenant: presence(r.params["tenant"]),
|
|
1115
|
+
operator: "studio" })
|
|
1116
|
+
end
|
|
1117
|
+
r.redirect("/studio/funnel")
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# --- Follow-ups: the schedule records + the A/B readout --
|
|
1123
|
+
# Reads FollowupStore/ContactStore/OutcomeStore directly (D10 — the
|
|
1124
|
+
# Studio's constitutional split); the ONLY mutations — cancel a pending
|
|
1125
|
+
# record, force-revoke a contact (an opt-out event that never arrived) —
|
|
1126
|
+
# dispatch bus commands.
|
|
1127
|
+
r.on "followups" do
|
|
1128
|
+
r.get { render_followups }
|
|
1129
|
+
r.on String do |agent_id|
|
|
1130
|
+
agent_id = utf8(agent_id)
|
|
1131
|
+
r.post "cancel" do
|
|
1132
|
+
check_csrf!
|
|
1133
|
+
with_flash("Follow-up cancelled.") do
|
|
1134
|
+
dispatch(:cancel_followup, { followup_id: r.params["id"] })
|
|
1135
|
+
end
|
|
1136
|
+
r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
|
|
1137
|
+
end
|
|
1138
|
+
r.post "revoke" do
|
|
1139
|
+
check_csrf!
|
|
1140
|
+
with_flash("Contact revoked — every pending follow-up cancelled.") do
|
|
1141
|
+
dispatch(:revoke_contact,
|
|
1142
|
+
{ customer: r.params["customer"], operator: "studio" },
|
|
1143
|
+
tenant: presence(r.params["tenant"]))
|
|
1144
|
+
end
|
|
1145
|
+
r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
end
|
|
1149
|
+
|
|
806
1150
|
# Playground: sends `send_message` (the SAME Command as the API) and streams the
|
|
807
1151
|
# response live through the `live-transcript` island (SSE from /studio/events).
|
|
808
1152
|
r.on "playground" do
|
|
@@ -810,6 +1154,14 @@ end
|
|
|
810
1154
|
@agent = presence(r.params["agent"]) || default_agent
|
|
811
1155
|
@session_id = presence(r.params["session_id"])
|
|
812
1156
|
@agents = insika[:profile_source].ids.sort
|
|
1157
|
+
# Session combobox: recent conversations as <datalist> suggestions for the
|
|
1158
|
+
# session field. The open session always appears, even if it fell off the
|
|
1159
|
+
# recent list (it is the one being edited).
|
|
1160
|
+
@recent = recent_sessions(limit: 8)
|
|
1161
|
+
if @session_id && @recent.none? { |s| s.id == @session_id }
|
|
1162
|
+
current = insika[:session_store]&.find(@session_id)
|
|
1163
|
+
@recent.unshift(current) if current
|
|
1164
|
+
end
|
|
813
1165
|
# Server-side echo + continuity: render the session's persisted
|
|
814
1166
|
# transcript as bubbles. The user's message is only persisted at the END
|
|
815
1167
|
# of the turn, so the just-sent message rides a one-shot flash bubble
|
|
@@ -876,12 +1228,24 @@ end
|
|
|
876
1228
|
]],
|
|
877
1229
|
["operate", [
|
|
878
1230
|
["Chats", "/studio/chats", :chats],
|
|
1231
|
+
["Customers", "/studio/customers", :chats],
|
|
879
1232
|
["Playground", "/studio/playground", :playground],
|
|
1233
|
+
["Funnel", "/studio/funnel", :funnel],
|
|
1234
|
+
# the Follow-ups page — scheduled, fired, blocked and
|
|
1235
|
+
# cancelled records per agent + the per-arm A/B readout card.
|
|
1236
|
+
["Follow-ups", "/studio/followups", :followups],
|
|
1237
|
+
# the Facts (wiki) page — the human gate on distilled
|
|
1238
|
+
# proposals (approve/reject/dismiss, the latch, the CAS re-present).
|
|
1239
|
+
["Facts", "/studio/facts", :facts],
|
|
1240
|
+
# the Harvest page — the human gate on mined skills
|
|
1241
|
+
# (gate, promote, reject, rollback; the append-only promotion log).
|
|
1242
|
+
["Harvest", "/studio/harvest", :harvest],
|
|
880
1243
|
["Tasks", "/studio/tasks", :tasks],
|
|
881
1244
|
["Approvals", "/studio/approvals", :approvals],
|
|
882
1245
|
["Refinement", "/studio/refinement", :refinement],
|
|
883
1246
|
["Evals", "/studio/evals", :evals]
|
|
884
|
-
]
|
|
1247
|
+
] + parity_nav_item
|
|
1248
|
+
]
|
|
885
1249
|
]
|
|
886
1250
|
end
|
|
887
1251
|
|
|
@@ -962,7 +1326,7 @@ end
|
|
|
962
1326
|
|
|
963
1327
|
def default_agent
|
|
964
1328
|
ids = insika[:profile_source].ids
|
|
965
|
-
ids.include?("
|
|
1329
|
+
ids.include?("demo") ? "demo" : (ids.first || "demo")
|
|
966
1330
|
end
|
|
967
1331
|
|
|
968
1332
|
# --- Transcript display helpers (playground + session viewer) ------------
|
|
@@ -1049,6 +1413,7 @@ end
|
|
|
1049
1413
|
|
|
1050
1414
|
def render_agent_detail
|
|
1051
1415
|
id = @agent.id
|
|
1416
|
+
@config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
|
|
1052
1417
|
store = insika[:agent_file_store]
|
|
1053
1418
|
@prompt_files = Array(@agent.prompt_files).map do |name|
|
|
1054
1419
|
{
|
|
@@ -1069,6 +1434,14 @@ end
|
|
|
1069
1434
|
@facts = mem ? mem.facts(tenant: id) : []
|
|
1070
1435
|
@notes = mem ? mem.notes(tenant: id, limit: 20) : []
|
|
1071
1436
|
@recent_sessions = recent_sessions
|
|
1437
|
+
# WS7: last outcome + per-day series for THIS agent. The grid already
|
|
1438
|
+
# shows the last-outcome pill; the series is the period view.
|
|
1439
|
+
outcomes = insika[:outcome_store]
|
|
1440
|
+
@latest_outcome = outcomes&.latest_per_agent&.[](id)
|
|
1441
|
+
@outcome_series = outcomes ? outcomes.series(agent: id) : {}
|
|
1442
|
+
# the per-agent cache-hit series (nil store -> the view's
|
|
1443
|
+
# empty state; same guard as the tool trace).
|
|
1444
|
+
@cache_series = insika[:cache_series_store]&.for_agent(id) || []
|
|
1072
1445
|
view("agent_detail")
|
|
1073
1446
|
end
|
|
1074
1447
|
|
|
@@ -1080,6 +1453,93 @@ end
|
|
|
1080
1453
|
params[key.to_s].nil? ? "" : params[key.to_s]
|
|
1081
1454
|
end
|
|
1082
1455
|
|
|
1456
|
+
# A free-form config block (string-keyed by AgentProfile.build) rendered
|
|
1457
|
+
# as the JSON textarea the form parses back. Pretty-printed so an operator
|
|
1458
|
+
# actually reads the current state; "" when the block is absent.
|
|
1459
|
+
def agent_json(config)
|
|
1460
|
+
return "" if config.nil? || config.empty?
|
|
1461
|
+
|
|
1462
|
+
JSON.pretty_generate(config)
|
|
1463
|
+
rescue StandardError
|
|
1464
|
+
config.to_s
|
|
1465
|
+
end
|
|
1466
|
+
|
|
1467
|
+
# A list field as comma-joined text for the form's textarea.
|
|
1468
|
+
def agent_list(list)
|
|
1469
|
+
Array(list).join(", ")
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
# A list field as newline-joined lines for the form's textarea.
|
|
1473
|
+
def agent_lines(list)
|
|
1474
|
+
Array(list).join("\n")
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# The shared "filter by agent" GET form used by every shared screen
|
|
1478
|
+
# (home, chats, tasks, approvals, customers, evals, funnel, follow-ups,
|
|
1479
|
+
# facts, parity). Same markup everywhere: a select that submits on change.
|
|
1480
|
+
def agent_filter_form(path, current)
|
|
1481
|
+
ids = insika[:profile_source].ids.sort
|
|
1482
|
+
options = [["", "all"]] + ids.map { |id| [id, id] }
|
|
1483
|
+
rows = options.map do |value, label|
|
|
1484
|
+
%(<option value="#{value}"#{' selected' if value.to_s == current.to_s}>#{label}</option>)
|
|
1485
|
+
end.join
|
|
1486
|
+
%(<form method="get" action="#{path}" class="actions inline"><label>Agent <select name="agent" onchange="this.form.submit()">#{rows}</select></label></form>)
|
|
1487
|
+
end
|
|
1488
|
+
|
|
1489
|
+
# The sessions of one agent — the session stamps its agent in
|
|
1490
|
+
# `vars["agent"]`, so any list of sessions is filterable by agent.
|
|
1491
|
+
def agent_sessions(sessions, agent)
|
|
1492
|
+
return sessions if agent.nil? || agent.empty?
|
|
1493
|
+
|
|
1494
|
+
sessions.select { |s| session_agent(s) == agent }
|
|
1495
|
+
end
|
|
1496
|
+
|
|
1497
|
+
def session_agent(session)
|
|
1498
|
+
vars = session.respond_to?(:vars) ? session.vars : nil
|
|
1499
|
+
vars.is_a?(Hash) ? vars["agent"].to_s : ""
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
# The distill button's scope (the DistillEngine's own scan, scoped to ONE
|
|
1503
|
+
# agent): the agent's sessions that are idle past the pack's window, long
|
|
1504
|
+
# enough, and not yet distilled. Oldest first, capped — each entry is a
|
|
1505
|
+
# provider call, so one click stays bounded. Reads hit the stores; the
|
|
1506
|
+
# writes go through :run_distillation on the bus.
|
|
1507
|
+
def due_distill_sessions(agent_id, limit: 5)
|
|
1508
|
+
store = insika[:session_store]
|
|
1509
|
+
proposals = insika[:proposal_store]
|
|
1510
|
+
profile = insika[:profile_source].fetch(agent_id)
|
|
1511
|
+
return [] if profile.nil? || store.nil? || proposals.nil?
|
|
1512
|
+
|
|
1513
|
+
config = Insika::Coercion.deep_stringify(profile.distill) || {}
|
|
1514
|
+
idle_hours = positive_int(config["idle_hours"]) || 6
|
|
1515
|
+
min_messages = positive_int(config["min_messages"]) || 3
|
|
1516
|
+
cutoff = Time.now.utc - idle_hours * 3600
|
|
1517
|
+
store.each_id.filter_map do |sid|
|
|
1518
|
+
session = store.find(sid)
|
|
1519
|
+
next unless session
|
|
1520
|
+
next unless session.vars.is_a?(Hash) && session.vars["agent"] == agent_id
|
|
1521
|
+
next if Insika::Coercion.blank?(session.vars["customer"])
|
|
1522
|
+
next unless aged?(session.updated_at, cutoff)
|
|
1523
|
+
next if session.messages.size < min_messages
|
|
1524
|
+
next if proposals.distilled?(sid)
|
|
1525
|
+
|
|
1526
|
+
[sid, session.updated_at.to_s]
|
|
1527
|
+
end.sort_by(&:last).map(&:first).first(limit)
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
def positive_int(value)
|
|
1531
|
+
v = value.to_i
|
|
1532
|
+
v.positive? ? v : nil
|
|
1533
|
+
end
|
|
1534
|
+
|
|
1535
|
+
def aged?(updated_at, cutoff)
|
|
1536
|
+
return false if Insika::Coercion.blank?(updated_at)
|
|
1537
|
+
|
|
1538
|
+
Time.iso8601(updated_at.to_s) <= cutoff
|
|
1539
|
+
rescue ArgumentError
|
|
1540
|
+
false
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1083
1543
|
# Renders the reasoning <select> shared by the agent config, settings and
|
|
1084
1544
|
# playground (4-layer). `blank_label` names the empty option — the
|
|
1085
1545
|
# "inherit the broader layer" / provider-default choice. Values come from a
|
|
@@ -1364,9 +1824,12 @@ end
|
|
|
1364
1824
|
# At-a-glance dashboard: counts + activity + recent conversations. All from
|
|
1365
1825
|
# data the Studio already reads (one scan of the session store); no new
|
|
1366
1826
|
# metrics pipeline. `active_now` = sessions touched in the last 5 minutes.
|
|
1827
|
+
# `?agent=` narrows every number to one agent (the sessions stamp their
|
|
1828
|
+
# author in `vars["agent"]`).
|
|
1367
1829
|
def render_home
|
|
1368
1830
|
ps = insika[:profile_source]
|
|
1369
|
-
|
|
1831
|
+
@agent = presence(request.params["agent"])
|
|
1832
|
+
sessions = agent_sessions(all_sessions, @agent)
|
|
1370
1833
|
@counts = {
|
|
1371
1834
|
"conversations" => sessions.size,
|
|
1372
1835
|
"messages" => sessions.sum { |s| Array(s.messages).size },
|
|
@@ -1446,6 +1909,10 @@ end
|
|
|
1446
1909
|
|
|
1447
1910
|
# --- Settings + LLM providers ----------------------------------
|
|
1448
1911
|
|
|
1912
|
+
# The agent config tab's groups, in sidebar order. The view renders the
|
|
1913
|
+
# same keys; a bogus ?cfg= falls back to the first group.
|
|
1914
|
+
CONFIG_SECTIONS = %w[model guardrails grounding funnel followups distill harvest refinement budget_rel routing advanced].freeze
|
|
1915
|
+
|
|
1449
1916
|
SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
|
|
1450
1917
|
def render_settings
|
|
1451
1918
|
store = insika[:settings_store]
|
|
@@ -1483,20 +1950,67 @@ end
|
|
|
1483
1950
|
# --- Chats -----------------------------------------------------
|
|
1484
1951
|
|
|
1485
1952
|
def render_chats
|
|
1486
|
-
@
|
|
1953
|
+
@agent = presence(request.params["agent"])
|
|
1954
|
+
@sessions = agent_sessions(recent_sessions(limit: 100), @agent)
|
|
1487
1955
|
view("chats")
|
|
1488
1956
|
end
|
|
1489
1957
|
|
|
1958
|
+
# --- Customers ----------------------------------
|
|
1959
|
+
|
|
1960
|
+
# The index: every CUSTOMER memory cell, grouped by tenant. The caller
|
|
1961
|
+
# passes `reserved:` (profile ids + _default) so the agent-memory tab's
|
|
1962
|
+
# cells and the shared cell stay out of the drill (D6 — the store is
|
|
1963
|
+
# policy-free). Fact count per cell via a keys-only list (no payload
|
|
1964
|
+
# reads per conversation on every render).
|
|
1965
|
+
def render_customers
|
|
1966
|
+
mem = insika[:memory_store]
|
|
1967
|
+
agent_ids = insika[:profile_source] ? insika[:profile_source].all.map(&:id) : []
|
|
1968
|
+
@agent = presence(request.params["agent"])
|
|
1969
|
+
@rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
|
|
1970
|
+
{ cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
|
|
1971
|
+
end : []
|
|
1972
|
+
# ?agent= narrows to one tenant — the memory scope is the agent (the
|
|
1973
|
+
# agent-memory tab writes under tenant = agent id).
|
|
1974
|
+
@rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
|
|
1975
|
+
@by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
|
|
1976
|
+
view("customers")
|
|
1977
|
+
end
|
|
1978
|
+
|
|
1979
|
+
# The detail: the parsed cell, its facts (expired excluded by C1), notes
|
|
1980
|
+
# (append-only), and the operator audit (digests + counts, never content).
|
|
1981
|
+
def render_customer(scope, cell)
|
|
1982
|
+
@cell = cell
|
|
1983
|
+
mem = insika[:memory_store]
|
|
1984
|
+
@facts = mem ? mem.facts(tenant: cell[:tenant], customer: cell[:customer]) : []
|
|
1985
|
+
@notes = mem ? mem.notes(tenant: cell[:tenant], customer: cell[:customer], limit: 50) : []
|
|
1986
|
+
@audit = insika[:memory_audit_store]&.for_cell(scope) || []
|
|
1987
|
+
view("customer")
|
|
1988
|
+
end
|
|
1989
|
+
|
|
1990
|
+
def customer_path(scope)
|
|
1991
|
+
# escape_path, not escape: the route decodes with unescape_path, which
|
|
1992
|
+
# does not turn '+' back into a space (escape's form-encoding would).
|
|
1993
|
+
"/studio/customers/#{Rack::Utils.escape_path(scope)}"
|
|
1994
|
+
end
|
|
1995
|
+
|
|
1490
1996
|
# Tasks & Approvals --------------------------------
|
|
1491
1997
|
|
|
1492
1998
|
# Task list, most-recently-updated first. Empty-state if no store was injected.
|
|
1999
|
+
# `?agent=` narrows to one agent (the task's command payload stamps it).
|
|
1493
2000
|
def render_tasks
|
|
1494
2001
|
store = insika[:task_store]
|
|
2002
|
+
@agent = presence(request.params["agent"])
|
|
1495
2003
|
@tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
|
|
2004
|
+
@tasks = @tasks.select { |t| task_agent(t) == @agent } if @agent
|
|
1496
2005
|
@tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
|
|
1497
2006
|
view("tasks")
|
|
1498
2007
|
end
|
|
1499
2008
|
|
|
2009
|
+
def task_agent(task)
|
|
2010
|
+
command = task.command
|
|
2011
|
+
command.is_a?(Hash) ? command.dig("payload", "agent").to_s : ""
|
|
2012
|
+
end
|
|
2013
|
+
|
|
1500
2014
|
# Task detail: @task is set by the route. Adds the open approvals for this task
|
|
1501
2015
|
# and its latest checkpoint (both degrade to empty when the store is absent).
|
|
1502
2016
|
def render_task_detail(id)
|
|
@@ -1506,11 +2020,16 @@ end
|
|
|
1506
2020
|
end
|
|
1507
2021
|
|
|
1508
2022
|
# Approvals inbox: every :pending action across tasks, each paired with its
|
|
1509
|
-
# task (for the status pill + a link into the task detail).
|
|
2023
|
+
# task (for the status pill + a link into the task detail). `?agent=`
|
|
2024
|
+
# narrows to one agent (via the owning task).
|
|
1510
2025
|
def render_approvals
|
|
1511
2026
|
pstore = insika[:pending_action_store]
|
|
1512
2027
|
tstore = insika[:task_store]
|
|
2028
|
+
@agent = presence(request.params["agent"])
|
|
1513
2029
|
@approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
|
|
2030
|
+
if @agent
|
|
2031
|
+
@approvals = @approvals.select { |a| task_agent(a[:task]) == @agent if a[:task] }
|
|
2032
|
+
end
|
|
1514
2033
|
view("approvals")
|
|
1515
2034
|
end
|
|
1516
2035
|
|
|
@@ -1523,6 +2042,8 @@ def render_evals
|
|
|
1523
2042
|
store = insika[:golden_store]
|
|
1524
2043
|
@cases = store ? store.all : []
|
|
1525
2044
|
@invalid = store ? store.invalid : []
|
|
2045
|
+
@filter = presence(request.params["agent"])
|
|
2046
|
+
@cases = @cases.select { |g| g.agent == @filter } if @filter
|
|
1526
2047
|
@by_agent = @cases.group_by(&:agent).sort.to_h
|
|
1527
2048
|
wanted = presence(request.params["id"])
|
|
1528
2049
|
@case = wanted && @cases.find { |g| g.id == wanted }
|
|
@@ -1556,6 +2077,331 @@ end
|
|
|
1556
2077
|
view("refinement")
|
|
1557
2078
|
end
|
|
1558
2079
|
|
|
2080
|
+
# --- Parity -------------------------------------------------
|
|
2081
|
+
|
|
2082
|
+
# The nav row exists ONLY when a shadow channel is registered — the page is
|
|
2083
|
+
# not an invitation, unlike Refinement's.
|
|
2084
|
+
def parity_nav_item
|
|
2085
|
+
shadow_channel? ? [["Parity", "/studio/parity", :parity]] : []
|
|
2086
|
+
end
|
|
2087
|
+
|
|
2088
|
+
def shadow_channel?
|
|
2089
|
+
registry = insika[:channel_registry]
|
|
2090
|
+
return false unless registry
|
|
2091
|
+
|
|
2092
|
+
Array(registry.names).any? do |name|
|
|
2093
|
+
channel = registry.find(name)
|
|
2094
|
+
channel.respond_to?(:shadow?) && channel.shadow?
|
|
2095
|
+
end
|
|
2096
|
+
end
|
|
2097
|
+
|
|
2098
|
+
def parity_pairs
|
|
2099
|
+
insika[:shadow_pair_store] ? insika[:shadow_pair_store].each.to_a : []
|
|
2100
|
+
end
|
|
2101
|
+
|
|
2102
|
+
def render_parity
|
|
2103
|
+
@criterion = insika[:parity_criterion]
|
|
2104
|
+
@agent = presence(request.params["agent"])
|
|
2105
|
+
# ONE materialization per request: the fold and the pair list share this
|
|
2106
|
+
# array (the store's counts would scan the whole table a second time).
|
|
2107
|
+
pairs = parity_pairs
|
|
2108
|
+
pairs = pairs.select { |p| p.agent.to_s == @agent } if @agent
|
|
2109
|
+
@report = @criterion && Insika::Parity::Verdict.fold(pairs: pairs, criterion: @criterion)
|
|
2110
|
+
@pairs = pairs.sort_by { |p| p.created_at.to_s }.reverse.first(50)
|
|
2111
|
+
@pending = pairs.count { |p| p.status == :complete }
|
|
2112
|
+
if @criterion
|
|
2113
|
+
@judge_cost = @pending * @criterion.rule.min_judge_models * 2
|
|
2114
|
+
@judge_models = ((insika[:settings_store]&.get || {})["evals"] || {})
|
|
2115
|
+
end
|
|
2116
|
+
# Judge agreement : split/unknown from the fold, plus how many
|
|
2117
|
+
# verdicts flipped with presentation order.
|
|
2118
|
+
@agreement = { order_dependent: pairs.count { |p| p.verdict.is_a?(Hash) && p.verdict["order_dependent"] } }
|
|
2119
|
+
view("parity")
|
|
2120
|
+
end
|
|
2121
|
+
|
|
2122
|
+
# --- Funnel ------------------------------------------------
|
|
2123
|
+
|
|
2124
|
+
# One card per STORE (agent) that declares a funnel, each with a block per
|
|
2125
|
+
# tenant that has folded cells. The only mutation on this page — the
|
|
2126
|
+
# baseline freeze — dispatches :freeze_funnel_baseline (D7).
|
|
2127
|
+
def render_funnel
|
|
2128
|
+
profiles = insika[:profile_source]&.all || []
|
|
2129
|
+
p = request.params["period"].to_i
|
|
2130
|
+
@period = p.positive? ? p : 30
|
|
2131
|
+
@agent = presence(request.params["agent"])
|
|
2132
|
+
profiles = profiles.select { |p| p.id == @agent } if @agent
|
|
2133
|
+
@stores = profiles.filter_map do |p|
|
|
2134
|
+
decl = Insika::FunnelDeclaration.parse(p.funnel)
|
|
2135
|
+
next unless decl
|
|
2136
|
+
|
|
2137
|
+
{ profile: p, decl: decl, tenants: tenants_for(p.id) }
|
|
2138
|
+
end
|
|
2139
|
+
view("funnel")
|
|
2140
|
+
end
|
|
2141
|
+
|
|
2142
|
+
# the Follow-ups page. Per agent WITH a parsed policy, one
|
|
2143
|
+
# block: the pending/fired/cancelled/blocked lists (blocked rows carry the
|
|
2144
|
+
# reason pill), the per-arm counts, the read-only policy summary and the
|
|
2145
|
+
# A/B card (D10 — reads stores, mutates through bus commands only).
|
|
2146
|
+
def render_followups
|
|
2147
|
+
profiles = insika[:profile_source]&.all || []
|
|
2148
|
+
@agent = presence(request.params["agent"])
|
|
2149
|
+
profiles = profiles.select { |p| p.id == @agent } if @agent
|
|
2150
|
+
@stores = profiles.filter_map do |p|
|
|
2151
|
+
decl = Insika::FollowupPolicy.parse(p.followup)
|
|
2152
|
+
next unless decl
|
|
2153
|
+
|
|
2154
|
+
{
|
|
2155
|
+
profile: p, policy: decl,
|
|
2156
|
+
tenant: presence(request.params["tenant"]) || "platform",
|
|
2157
|
+
records: followup_records(p.id, decl),
|
|
2158
|
+
ab: followup_ab(p.id, decl)
|
|
2159
|
+
}
|
|
2160
|
+
end
|
|
2161
|
+
@has_followups = !@stores.empty?
|
|
2162
|
+
view("followups")
|
|
2163
|
+
end
|
|
2164
|
+
|
|
2165
|
+
# the Facts (wiki) page — the human gate on distilled
|
|
2166
|
+
# proposals. Reads ProposalStore + SessionStore directly (D7 — evidence is
|
|
2167
|
+
# a link, never a copy: the excerpt is read from the session at request
|
|
2168
|
+
# time); the mutations dispatch :resolve_proposal on the bus.
|
|
2169
|
+
def render_facts
|
|
2170
|
+
store = insika[:proposal_store]
|
|
2171
|
+
@pending = store ? store.pending(limit: 100) : []
|
|
2172
|
+
@stale = store ? store.stale(limit: 50) : []
|
|
2173
|
+
@recent = store ? store.resolved(limit: 20) : []
|
|
2174
|
+
# `?agent=` filters by the proposal's AGENT (the origin session's
|
|
2175
|
+
# stamp); `?store=` keeps filtering by the memory-cell scope (the
|
|
2176
|
+
# pre-review behavior). Both narrow the same three lists.
|
|
2177
|
+
@filter = presence(request.params["agent"]) || presence(request.params["store"])
|
|
2178
|
+
if @filter
|
|
2179
|
+
@pending = @pending.select { |p| scope_matches?(p, @filter) }
|
|
2180
|
+
@stale = @stale.select { |p| scope_matches?(p, @filter) }
|
|
2181
|
+
@recent = @recent.select { |p| scope_matches?(p, @filter) }
|
|
2182
|
+
end
|
|
2183
|
+
# The select: the filtered agents (profile ids) PLUS every scope a
|
|
2184
|
+
# proposal ever carried, so an agent with proposals and one without can
|
|
2185
|
+
# both be picked.
|
|
2186
|
+
@stores = (insika[:profile_source].ids.sort +
|
|
2187
|
+
(@pending + @stale + @recent).map(&:scope)).uniq.sort
|
|
2188
|
+
@sessions = insika[:session_store]
|
|
2189
|
+
view("facts")
|
|
2190
|
+
end
|
|
2191
|
+
|
|
2192
|
+
# A proposal matches the filter as the memory scope it carries, or as the
|
|
2193
|
+
# agent of the session it came from (when the session is still around).
|
|
2194
|
+
def scope_matches?(proposal, filter)
|
|
2195
|
+
return true if proposal.scope == filter
|
|
2196
|
+
|
|
2197
|
+
session = insika[:session_store]&.find(proposal.session_ref)
|
|
2198
|
+
session && session_agent(session) == filter
|
|
2199
|
+
end
|
|
2200
|
+
|
|
2201
|
+
# the Harvest page — the human gate on mined skills. Reads
|
|
2202
|
+
# HarvestStore/SessionStore directly (the evidence excerpt is read from the
|
|
2203
|
+
# session at request time — D7: ids and indexes only are stored); every
|
|
2204
|
+
# mutation dispatches a bus command.
|
|
2205
|
+
def render_harvest
|
|
2206
|
+
@agents = insika[:profile_source].ids.sort
|
|
2207
|
+
@agent = presence(request.params["agent"]) || @agents.first
|
|
2208
|
+
store = insika[:harvest_store]
|
|
2209
|
+
# EVERY list respects the agent filter (the review fix) — the page is a
|
|
2210
|
+
# per-store inbox, not a global one.
|
|
2211
|
+
@candidates = @agent && store ? store.candidates(agent_id: @agent, status: "awaiting_approval") : []
|
|
2212
|
+
@pending_gates = @agent && store ? store.candidates(agent_id: @agent, status: "pending").first(50) : []
|
|
2213
|
+
@blocked = if @agent && store
|
|
2214
|
+
store.candidates(agent_id: @agent, status: "gated")
|
|
2215
|
+
.select { |c| !c.eval_gate || !c.conversion_gate || c.conversion_gate["passed"] == false }
|
|
2216
|
+
.first(50)
|
|
2217
|
+
else
|
|
2218
|
+
[]
|
|
2219
|
+
end
|
|
2220
|
+
@recent = @agent && store ? store.candidates(agent_id: @agent, status: "promoted").first(20) : []
|
|
2221
|
+
@runs = @agent && store ? store.runs_for(@agent, limit: 5) : []
|
|
2222
|
+
@promotions = @agent && store ? store.promotions(agent_id: @agent, limit: 50) : []
|
|
2223
|
+
@criterion = insika[:harvest_criterion]
|
|
2224
|
+
@funnel = insika[:funnel_store]
|
|
2225
|
+
@sessions = insika[:session_store]
|
|
2226
|
+
@negative = insika[:negative_list]
|
|
2227
|
+
@filter = presence(request.params["agent"])
|
|
2228
|
+
view("harvest")
|
|
2229
|
+
end
|
|
2230
|
+
|
|
2231
|
+
# The evidence excerpt for a candidate — the origin sessions' messages at
|
|
2232
|
+
# the stored indexes, read at request time (D7: ids and indexes only).
|
|
2233
|
+
#
|
|
2234
|
+
# An index is validated by the miner as "fits at least one origin
|
|
2235
|
+
# session", so it must NOT be rendered against every origin session — the
|
|
2236
|
+
# same index would point at different conversations (the review fix).
|
|
2237
|
+
# Each index renders ONE message, from the first origin session where it
|
|
2238
|
+
# is in range, labeled with that session.
|
|
2239
|
+
def candidate_excerpt(candidate)
|
|
2240
|
+
Array(candidate.evidence_turns).filter_map do |i|
|
|
2241
|
+
session = Array(candidate.origin).filter_map { |sid| @sessions&.find(sid) }
|
|
2242
|
+
.find { |s| s.messages && s.messages[i] }
|
|
2243
|
+
next unless session
|
|
2244
|
+
|
|
2245
|
+
[session.id, i, session.messages[i]["content"].to_s]
|
|
2246
|
+
end
|
|
2247
|
+
end
|
|
2248
|
+
|
|
2249
|
+
# The negative list's rejection counts per rule, read from the RUN
|
|
2250
|
+
# records (D4 — the counts the Studio shows are what the engine logged:
|
|
2251
|
+
# the run carries { rule_id => count, "ungrounded" => N, ... }).
|
|
2252
|
+
def negative_rule_counts
|
|
2253
|
+
store = insika[:harvest_store]
|
|
2254
|
+
return {} unless store
|
|
2255
|
+
|
|
2256
|
+
store.runs_for(@agent || "", limit: 200).each_with_object(Hash.new(0)) do |run, acc|
|
|
2257
|
+
Array(run.rejected).each { |rule, n| acc[rule] += n.to_i }
|
|
2258
|
+
end
|
|
2259
|
+
end
|
|
2260
|
+
|
|
2261
|
+
# The excerpt for a proposal's evidence — the transcript messages at the
|
|
2262
|
+
# stored indexes, read at request time (D7: ids and indexes only are
|
|
2263
|
+
# stored). Deep-links to the existing session page.
|
|
2264
|
+
def proposal_excerpt(proposal)
|
|
2265
|
+
session = @sessions&.find(proposal.session_ref)
|
|
2266
|
+
return [] unless session && session.messages
|
|
2267
|
+
|
|
2268
|
+
Array(proposal.evidence).filter_map do |i|
|
|
2269
|
+
session.messages[i] && session.messages[i]["content"].to_s
|
|
2270
|
+
end
|
|
2271
|
+
end
|
|
2272
|
+
|
|
2273
|
+
# The agent's records across every status (a nil store reads as none).
|
|
2274
|
+
def followup_records(agent_id, _decl)
|
|
2275
|
+
store = insika[:followup_store]
|
|
2276
|
+
return [] unless store
|
|
2277
|
+
|
|
2278
|
+
store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
|
|
2279
|
+
agent: agent_id)
|
|
2280
|
+
end
|
|
2281
|
+
|
|
2282
|
+
# The A/B readout per arm (D3 — a read-only fold over the same records):
|
|
2283
|
+
# sent = fired count in the period;
|
|
2284
|
+
# conversions = outcome records whose session_id is a fired record's
|
|
2285
|
+
# session (primary-filtered by the declaration
|
|
2286
|
+
# when one exists, else the first outcome per session);
|
|
2287
|
+
# opt-outs = contact cells flipped :revoked after a fired_at.
|
|
2288
|
+
# The note anchors the ruler outside the card: the A/B is the operator's;
|
|
2289
|
+
# this card reads the baseline.
|
|
2290
|
+
def followup_ab(agent_id, _decl)
|
|
2291
|
+
store = insika[:followup_store]
|
|
2292
|
+
return {} unless store
|
|
2293
|
+
|
|
2294
|
+
from = (Time.now.utc - 30 * 86_400).iso8601
|
|
2295
|
+
fired = store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
|
|
2296
|
+
agent: agent_id)
|
|
2297
|
+
.select { |r| r.status == "fired" && r.at.to_s >= from }
|
|
2298
|
+
fired.group_by(&:arm).each_with_object({}) do |(arm, records), acc|
|
|
2299
|
+
sessions = records.filter_map(&:session_id)
|
|
2300
|
+
acc[arm] = { sent: records.size, conversions: conversions_for(agent_id, sessions),
|
|
2301
|
+
opt_outs: opt_outs_since(records) }
|
|
2302
|
+
end
|
|
2303
|
+
end
|
|
2304
|
+
|
|
2305
|
+
# The conversions of the arm's fired sessions. Primary-filtered by the
|
|
2306
|
+
# funnel declaration when the agent declares one; else the FIRST
|
|
2307
|
+
# outcome per session (an outcome record is one vote per session).
|
|
2308
|
+
def conversions_for(agent_id, sessions)
|
|
2309
|
+
outcomes = insika[:outcome_store]
|
|
2310
|
+
return 0 unless outcomes
|
|
2311
|
+
|
|
2312
|
+
records = sessions.empty? ? [] : outcomes.all.select { |r| sessions.include?(r.session_id) }
|
|
2313
|
+
primary = primary_stage_for(agent_id)
|
|
2314
|
+
return records.count { |r| r.outcome == primary } if primary
|
|
2315
|
+
|
|
2316
|
+
records.group_by(&:session_id).size
|
|
2317
|
+
end
|
|
2318
|
+
|
|
2319
|
+
def primary_stage_for(agent_id)
|
|
2320
|
+
profile = insika[:profile_source]&.fetch(agent_id)
|
|
2321
|
+
decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
|
|
2322
|
+
decl && decl.primary
|
|
2323
|
+
end
|
|
2324
|
+
|
|
2325
|
+
# The opt-outs of ONE arm: contact cells of the customers THAT ARM fired
|
|
2326
|
+
# to (same tenant), flipped :revoked after that customer's own fire. Never
|
|
2327
|
+
# a global revoked count — an unrelated opt-out (another tenant, another
|
|
2328
|
+
# customer) must not count against an arm, or the H-followup kill decision
|
|
2329
|
+
# reads strangers.
|
|
2330
|
+
def opt_outs_since(records)
|
|
2331
|
+
store = insika[:contact_store]
|
|
2332
|
+
return 0 unless store
|
|
2333
|
+
|
|
2334
|
+
cells = store.cells
|
|
2335
|
+
# group this arm's fires by (tenant, customer) -> the earliest fire
|
|
2336
|
+
records.group_by { |r| [r.tenant, r.customer] }.sum do |(tenant, customer), recs|
|
|
2337
|
+
fired_at = recs.filter_map { |r| parse_time(r.fired_at || r.at) }.min
|
|
2338
|
+
next 0 if fired_at.nil?
|
|
2339
|
+
|
|
2340
|
+
cell = cells["#{tenant}:#{customer}"]
|
|
2341
|
+
cell && cell["state"] == "revoked" && revoke_after?(cell, fired_at) ? 1 : 0
|
|
2342
|
+
end
|
|
2343
|
+
end
|
|
2344
|
+
|
|
2345
|
+
def revoke_after?(cell, fired_at)
|
|
2346
|
+
updated = parse_time(cell["updated_at"])
|
|
2347
|
+
updated && updated > fired_at
|
|
2348
|
+
end
|
|
2349
|
+
|
|
2350
|
+
def parse_time(value)
|
|
2351
|
+
Time.iso8601(value.to_s)
|
|
2352
|
+
rescue ArgumentError
|
|
2353
|
+
nil
|
|
2354
|
+
end
|
|
2355
|
+
|
|
2356
|
+
# Tenant list per agent, derived from the store cells (D7, the 0031 drill
|
|
2357
|
+
# pattern) — multi-tenant deployments show one card block per tenant; a
|
|
2358
|
+
# single-tenant deployment just "platform".
|
|
2359
|
+
def tenants_for(agent_id)
|
|
2360
|
+
cells = insika[:funnel_store]&.pairs.to_a || []
|
|
2361
|
+
cells.select { |c| c[:agent] == agent_id }.map { |c| c[:tenant] }
|
|
2362
|
+
end
|
|
2363
|
+
|
|
2364
|
+
# Sums the folded counts over the period -> { stage => count } for the view.
|
|
2365
|
+
# A nil store (base wiring) reads as all-zero — the page still renders the
|
|
2366
|
+
# declaration and the freeze form.
|
|
2367
|
+
def funnel_period_counts(store, tenant, agent, period)
|
|
2368
|
+
return {} unless store
|
|
2369
|
+
|
|
2370
|
+
today = Date.today.iso8601
|
|
2371
|
+
from = (Date.today - period).iso8601
|
|
2372
|
+
store.days(tenant: tenant, agent: agent, from: from, to: today)
|
|
2373
|
+
.each_with_object(Hash.new(0)) { |(_, counts), acc| counts.each { |s, n| acc[s] += n } }
|
|
2374
|
+
end
|
|
2375
|
+
|
|
2376
|
+
# BudgetLedger's current counters (D6): "tokens today / this month". nil
|
|
2377
|
+
# store -> the view renders 0 (the empty state).
|
|
2378
|
+
def funnel_spend(store, tenant, agent)
|
|
2379
|
+
return { daily: 0, monthly: 0 } unless store
|
|
2380
|
+
|
|
2381
|
+
store.current(tenant: tenant, agent: agent)
|
|
2382
|
+
end
|
|
2383
|
+
|
|
2384
|
+
# the session's stage history — each outcome record of the
|
|
2385
|
+
# session folded to its stage by the agent's declaration (unmapped kinds
|
|
2386
|
+
# render the raw outcome with a muted note — the hole is visible).
|
|
2387
|
+
# Without a declaration the block renders the raw outcomes.
|
|
2388
|
+
def stage_history_for(sid)
|
|
2389
|
+
records = Array(insika[:outcome_store]&.all&.select { |r| r.session_id == sid })
|
|
2390
|
+
.sort_by { |r| r.at.to_s }
|
|
2391
|
+
return [] if records.empty?
|
|
2392
|
+
|
|
2393
|
+
agent = records.first.agent
|
|
2394
|
+
profile = insika[:profile_source]&.fetch(agent)
|
|
2395
|
+
decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
|
|
2396
|
+
{
|
|
2397
|
+
declaration: decl,
|
|
2398
|
+
rows: records.map do |r|
|
|
2399
|
+
stage = decl && decl.advance_on[r.outcome]
|
|
2400
|
+
{ outcome: r.outcome, stage: stage, at: r.at, value: r.value }
|
|
2401
|
+
end
|
|
2402
|
+
}
|
|
2403
|
+
end
|
|
2404
|
+
|
|
1559
2405
|
# A run's window in words. An EMPTY window means "the collector's own default" —
|
|
1560
2406
|
# resolve it for the operator instead of rendering a blank.
|
|
1561
2407
|
def refinement_window(window)
|
|
@@ -1641,11 +2487,26 @@ end
|
|
|
1641
2487
|
"/studio/playground?#{query}"
|
|
1642
2488
|
end
|
|
1643
2489
|
|
|
1644
|
-
def agent_path(id, anchor = nil)
|
|
2490
|
+
def agent_path(id, anchor = nil, query = nil)
|
|
1645
2491
|
base = "/studio/agents/#{Rack::Utils.escape(id)}"
|
|
2492
|
+
base += "?#{query}" if query
|
|
1646
2493
|
anchor ? "#{base}##{anchor}" : base
|
|
1647
2494
|
end
|
|
1648
2495
|
|
|
2496
|
+
# Deterministic avatar hue: the id's fingerprint, so agents/sessions are
|
|
2497
|
+
# scannable at a glance (Linear-style). CSP forbids inline styles, so the
|
|
2498
|
+
# hue is a class: avatar-h0..avatar-h9 in application.css.
|
|
2499
|
+
def avatar_class(id)
|
|
2500
|
+
"avatar-h#{id.to_s.bytes.sum % 10}"
|
|
2501
|
+
end
|
|
2502
|
+
|
|
2503
|
+
# Where a prompt POST lands back: the same file open in the drill, scrolled
|
|
2504
|
+
# to the prompts section. No file (a delete, say) → the agent page itself.
|
|
2505
|
+
def prompt_edit_path(id, file)
|
|
2506
|
+
name = presence(file)
|
|
2507
|
+
name ? "#{agent_path(id)}/prompts/#{Rack::Utils.escape(name)}#prompts" : agent_path(id, "prompts")
|
|
2508
|
+
end
|
|
2509
|
+
|
|
1649
2510
|
# Serves a versioned asset from dist. `File.basename` kills path traversal; only
|
|
1650
2511
|
# files that exist in the dist dir are served.
|
|
1651
2512
|
def serve_asset(name)
|