insika 0.9.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/studio/app.rb
CHANGED
|
@@ -92,7 +92,7 @@ module Studio
|
|
|
92
92
|
tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
|
|
93
93
|
memory_audit_store: nil,
|
|
94
94
|
settings_store: nil, llm_provider_store: nil, mcp_store: nil,
|
|
95
|
-
system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
|
|
95
|
+
system_file_store: nil, tool_trace_store: nil, llm_trace_store: nil, model_metrics_store: nil, context_trace_store: nil,
|
|
96
96
|
cache_series_store: nil,
|
|
97
97
|
task_store: nil, checkpoint_store: nil, pending_action_store: nil,
|
|
98
98
|
refinement_store: nil, golden_store: nil, session_secret: nil,
|
|
@@ -122,7 +122,7 @@ module Studio
|
|
|
122
122
|
mcp_store: mcp_store, system_file_store: system_file_store,
|
|
123
123
|
# per-session tool-call trace (debug): args + result + status per
|
|
124
124
|
# turn, rendered in the session viewer.
|
|
125
|
-
tool_trace_store: tool_trace_store,
|
|
125
|
+
tool_trace_store: tool_trace_store, llm_trace_store: llm_trace_store, model_metrics_store: model_metrics_store,
|
|
126
126
|
# per-session context breakdown: tokens by category +
|
|
127
127
|
# budget per turn, on the same viewer. Counts only, no content.
|
|
128
128
|
context_trace_store: context_trace_store,
|
|
@@ -216,6 +216,14 @@ module Studio
|
|
|
216
216
|
|
|
217
217
|
# Versioned assets: public (the UI loads the bundle BEFORE login).
|
|
218
218
|
r.on "assets", "dist" do
|
|
219
|
+
# The embedded webfonts are the one sub-directory under dist; the "fonts"
|
|
220
|
+
# segment is matched literally, so serve_asset still only ever joins a
|
|
221
|
+
# basename onto a path this app chose.
|
|
222
|
+
r.on "fonts" do
|
|
223
|
+
r.get String do |name|
|
|
224
|
+
serve_asset(name, "fonts")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
219
227
|
r.get String do |name|
|
|
220
228
|
serve_asset(name)
|
|
221
229
|
end
|
|
@@ -224,7 +232,8 @@ module Studio
|
|
|
224
232
|
# Login: the only path without a session. GET shows the form; POST validates the
|
|
225
233
|
# token in constant time and, if ok, marks the session and redirects.
|
|
226
234
|
r.is "login" do
|
|
227
|
-
|
|
235
|
+
# Already signed in: the form would render inside the shell — go home.
|
|
236
|
+
r.get { authenticated? ? r.redirect("/studio/home") : view("login") }
|
|
228
237
|
r.post do
|
|
229
238
|
check_csrf!
|
|
230
239
|
if authenticate(r.params["token"])
|
|
@@ -269,7 +278,8 @@ module Studio
|
|
|
269
278
|
r.get "events" do
|
|
270
279
|
next_404 unless insika[:event_stream] # no stream wired: the feature is not there
|
|
271
280
|
subscription = insika[:event_stream].subscribe(
|
|
272
|
-
task_id: presence(r.params["task_id"]), session_id: presence(r.params["session_id"])
|
|
281
|
+
task_id: presence(r.params["task_id"]), session_id: presence(r.params["session_id"]),
|
|
282
|
+
replay: true # a viewer arriving mid-turn sees what the turn already did
|
|
273
283
|
)
|
|
274
284
|
r.halt([200,
|
|
275
285
|
{ "content-type" => "text/event-stream", "cache-control" => "no-cache",
|
|
@@ -277,24 +287,30 @@ module Studio
|
|
|
277
287
|
Insika::Server::SSEBody.new(subscription: subscription)])
|
|
278
288
|
end
|
|
279
289
|
|
|
280
|
-
# `/studio
|
|
290
|
+
# `/studio/` → overview home. A bare `/studio` reaches here with an empty
|
|
291
|
+
# path (Rack::URLMap strips the mount), which r.root does not match.
|
|
292
|
+
r.redirect("/studio/home") if r.remaining_path.empty?
|
|
281
293
|
r.root { r.redirect("/studio/home") }
|
|
282
294
|
|
|
283
295
|
# --- Overview: at-a-glance dashboard ---------------------
|
|
296
|
+
r.on "inbox" do
|
|
297
|
+
r.is { r.get { render_inbox } }
|
|
298
|
+
end
|
|
299
|
+
|
|
284
300
|
r.on "home" do
|
|
285
301
|
r.is { r.get { render_home } }
|
|
286
302
|
end
|
|
287
303
|
|
|
304
|
+
r.on "models" do
|
|
305
|
+
r.is { r.get { render_models } }
|
|
306
|
+
end
|
|
307
|
+
|
|
288
308
|
# --- Agents: list + detail/authoring ---------------------
|
|
289
309
|
r.on "agents" do
|
|
290
310
|
# /studio/agents — agents grid (reads the ProfileSource).
|
|
291
311
|
r.is do
|
|
292
312
|
r.get do
|
|
293
313
|
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
294
|
-
# WS7 scorecard: the LAST outcome per agent, computed once for the
|
|
295
|
-
# whole grid (a store scan per card would be n scans). Series live
|
|
296
|
-
# on the agent detail — one agent's periods, not n charts here.
|
|
297
|
-
@latest_outcomes = insika[:outcome_store]&.latest_per_agent
|
|
298
314
|
# "New from template" gallery — cheap (frontmatter
|
|
299
315
|
# parse only, no evaluation) so it's safe on every render.
|
|
300
316
|
@templates = Insika::Templates.all
|
|
@@ -307,10 +323,13 @@ module Studio
|
|
|
307
323
|
check_csrf!
|
|
308
324
|
id = presence(r.params["id"])
|
|
309
325
|
result = with_flash("Agent '#{id}' created.") do
|
|
326
|
+
model, provider = picked_model(r)
|
|
310
327
|
dispatch(:create_agent, {
|
|
311
|
-
id: id, model:
|
|
312
|
-
|
|
313
|
-
|
|
328
|
+
id: id, model: model, provider: provider,
|
|
329
|
+
memory: r.params["memory"] == "1",
|
|
330
|
+
# Starts with nothing allowed: kits and the pickers decide
|
|
331
|
+
# what it gets (an "all" start made every kit a no-op).
|
|
332
|
+
tools_allow: [], skills: []
|
|
314
333
|
})
|
|
315
334
|
end
|
|
316
335
|
r.redirect(result ? agent_path(id) : "/studio/agents")
|
|
@@ -344,6 +363,10 @@ module Studio
|
|
|
344
363
|
# Config/model → :update_agent (patch merge).
|
|
345
364
|
r.post "config" do
|
|
346
365
|
check_csrf!
|
|
366
|
+
# A section the agent stops inheriting starts from the platform's
|
|
367
|
+
# value, so the keys its form does not show (knowledge index,
|
|
368
|
+
# guardrails corpora) carry over.
|
|
369
|
+
@agent = Insika::AgentDefaults.apply_safely(@agent, platform_agent_defaults)
|
|
347
370
|
with_flash("Configuration saved.") do
|
|
348
371
|
dispatch(:update_agent, config_patch(r))
|
|
349
372
|
end
|
|
@@ -451,6 +474,30 @@ module Studio
|
|
|
451
474
|
end
|
|
452
475
|
end
|
|
453
476
|
|
|
477
|
+
# POST /studio/agents/:id/tools — the agent's Tools tab. "all" = nil;
|
|
478
|
+
# otherwise the checked subset. `deny` is preserved.
|
|
479
|
+
r.post "tools" do
|
|
480
|
+
check_csrf!
|
|
481
|
+
profile = insika[:profile_source].fetch(id)
|
|
482
|
+
next_404 unless profile
|
|
483
|
+
all = r.params["all_tools"] == "1"
|
|
484
|
+
allow = all ? nil : Array(r.params["tools"]).map(&:to_s)
|
|
485
|
+
# "all tools" means every tool: a leftover group list would narrow it to
|
|
486
|
+
# those groups. Otherwise only the server switches this tab rendered
|
|
487
|
+
# (managed_groups) change; other groups (a pack's) stay.
|
|
488
|
+
groups = if all then nil
|
|
489
|
+
else
|
|
490
|
+
managed = Array(r.params["managed_groups"]).map(&:to_s)
|
|
491
|
+
kept = Array(profile.tools_allow_groups).map(&:to_s) - managed
|
|
492
|
+
picked = kept | Array(r.params["tool_groups"]).map(&:to_s)
|
|
493
|
+
picked.empty? && profile.tools_allow_groups.nil? ? nil : picked
|
|
494
|
+
end
|
|
495
|
+
with_flash("Tools updated.") do
|
|
496
|
+
dispatch(:set_agent_tools, { id: id, allow: allow, deny: Array(profile.tools_deny), allow_groups: groups })
|
|
497
|
+
end
|
|
498
|
+
r.redirect(agent_path(id, nil, "tab=tools"))
|
|
499
|
+
end
|
|
500
|
+
|
|
454
501
|
r.on "skills" do
|
|
455
502
|
# POST /studio/agents/:id/skills → :update_agent with the `skills`
|
|
456
503
|
# allowlist. "all" = nil; otherwise the checked subset (possibly []).
|
|
@@ -477,6 +524,7 @@ module Studio
|
|
|
477
524
|
@selected = name
|
|
478
525
|
@scope_agent = id
|
|
479
526
|
@skill_content = skill_source(name, agent: id)
|
|
527
|
+
@skill_versions = skill_versions(name, agent: id)
|
|
480
528
|
view("skills")
|
|
481
529
|
end
|
|
482
530
|
# Saving the specialization: same command, `agent:` set.
|
|
@@ -489,6 +537,12 @@ module Studio
|
|
|
489
537
|
end
|
|
490
538
|
end
|
|
491
539
|
|
|
540
|
+
r.post "restore" do
|
|
541
|
+
check_csrf!
|
|
542
|
+
with_flash("Version restored.") { restore_skill_version(name, r.params["version"], agent: id) }
|
|
543
|
+
r.redirect("#{agent_path(id)}/skills/#{Rack::Utils.escape(name)}")
|
|
544
|
+
end
|
|
545
|
+
|
|
492
546
|
# Un-specialize: the shared skill stays, and the agent falls back to it.
|
|
493
547
|
r.post "delete" do
|
|
494
548
|
check_csrf!
|
|
@@ -563,9 +617,15 @@ module Studio
|
|
|
563
617
|
load_skills_master
|
|
564
618
|
@selected = name
|
|
565
619
|
@skill_content = skill_source(name)
|
|
620
|
+
@skill_versions = skill_versions(name)
|
|
566
621
|
view("skills")
|
|
567
622
|
end
|
|
568
623
|
end
|
|
624
|
+
r.post "restore" do
|
|
625
|
+
check_csrf!
|
|
626
|
+
with_flash("Version restored.") { restore_skill_version(name, r.params["version"]) }
|
|
627
|
+
r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
|
|
628
|
+
end
|
|
569
629
|
# Enables/disables the skill on N agents at once, and marks it always-on for
|
|
570
630
|
# the ones that checked `eager`. Both are per-agent decisions about the same
|
|
571
631
|
# skill, so they are the same form and the same dispatch.
|
|
@@ -586,6 +646,11 @@ module Studio
|
|
|
586
646
|
r.post "specialize" do
|
|
587
647
|
check_csrf!
|
|
588
648
|
agent_id = presence(r.params["agent_id"])
|
|
649
|
+
# the picker is free text (type-to-search), so the id is checked here
|
|
650
|
+
unless agent_id && insika[:profile_source].fetch(agent_id)
|
|
651
|
+
flash["error"] = "Agent '#{agent_id}' not found."
|
|
652
|
+
r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
|
|
653
|
+
end
|
|
589
654
|
with_flash("Specialized for #{agent_id}.") do
|
|
590
655
|
dispatch(:write_skill, { name: name, agent: agent_id, content: skill_source(name) })
|
|
591
656
|
end
|
|
@@ -594,6 +659,69 @@ module Studio
|
|
|
594
659
|
end
|
|
595
660
|
end
|
|
596
661
|
|
|
662
|
+
# --- Kits: named bundles of skills + tools agents subscribe to ----------
|
|
663
|
+
r.on "kits" do
|
|
664
|
+
r.is do
|
|
665
|
+
r.get do
|
|
666
|
+
load_kits
|
|
667
|
+
@selected = nil
|
|
668
|
+
view("kits")
|
|
669
|
+
end
|
|
670
|
+
r.post do
|
|
671
|
+
check_csrf!
|
|
672
|
+
name = presence(r.params["name"]).to_s
|
|
673
|
+
payload = { name: name, description: r.params["description"].to_s,
|
|
674
|
+
skills: Array(r.params["skills"]).map(&:to_s),
|
|
675
|
+
tools: Array(r.params["tools"]).map(&:to_s),
|
|
676
|
+
tool_groups: Array(r.params["tool_groups"]).map(&:to_s) }
|
|
677
|
+
result = with_flash("Kit '#{name}' saved.") { dispatch(:write_kit, payload) }
|
|
678
|
+
r.redirect(result ? "/studio/kits/#{Rack::Utils.escape(name)}" : "/studio/kits")
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
r.get "new" do
|
|
683
|
+
load_kits
|
|
684
|
+
@selected = ""
|
|
685
|
+
@kit = {}
|
|
686
|
+
view("kits")
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
r.on String do |name|
|
|
690
|
+
name = utf8(name)
|
|
691
|
+
r.is do
|
|
692
|
+
r.get do
|
|
693
|
+
load_kits
|
|
694
|
+
@kit = @kits[name]
|
|
695
|
+
next_404 unless @kit
|
|
696
|
+
@selected = name
|
|
697
|
+
view("kits")
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
# Membership lives on the AGENT (AgentProfile#kits). Only agents whose
|
|
701
|
+
# membership of THIS kit changed are written, and their other kits stay.
|
|
702
|
+
r.post "agents" do
|
|
703
|
+
check_csrf!
|
|
704
|
+
chosen = Array(r.params["agent_ids"]).map(&:to_s)
|
|
705
|
+
with_flash("Kit '#{name}' agents updated.") do
|
|
706
|
+
insika[:profile_source].all.each do |a|
|
|
707
|
+
has = Array(a.kits).include?(name)
|
|
708
|
+
want = chosen.include?(a.id)
|
|
709
|
+
next if has == want
|
|
710
|
+
|
|
711
|
+
kits = want ? Array(a.kits) | [name] : Array(a.kits) - [name]
|
|
712
|
+
dispatch(:update_agent, { id: a.id, kits: kits })
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
r.redirect("/studio/kits/#{Rack::Utils.escape(name)}")
|
|
716
|
+
end
|
|
717
|
+
r.post "delete" do
|
|
718
|
+
check_csrf!
|
|
719
|
+
with_flash("Kit '#{name}' removed.") { dispatch(:delete_kit, { name: name }) }
|
|
720
|
+
r.redirect("/studio/kits")
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
597
725
|
# --- Tools: tool × agent matrix + DATA-DEFINED tool authoring -----------
|
|
598
726
|
r.on "tools" do
|
|
599
727
|
# Data-defined tool authoring. Under /tools/def/* — BEFORE the
|
|
@@ -650,20 +778,16 @@ module Studio
|
|
|
650
778
|
end
|
|
651
779
|
end
|
|
652
780
|
|
|
653
|
-
r.is { r.get {
|
|
781
|
+
r.is { r.get { render_tools_page } }
|
|
654
782
|
|
|
655
|
-
#
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
with_flash("Agent '#{id}' tools updated.") do
|
|
664
|
-
dispatch(:set_agent_tools, { id: id, allow: allow, deny: Array(profile.tools_deny) })
|
|
665
|
-
end
|
|
666
|
-
r.redirect("/studio/tools?a=#{Rack::Utils.escape(id)}")
|
|
783
|
+
# GET /studio/tools/info/:name — read-only card for a code or MCP tool.
|
|
784
|
+
r.get "info", String do |name|
|
|
785
|
+
name = utf8(name)
|
|
786
|
+
load_tool_catalog
|
|
787
|
+
@info_tool = @tools.find { |t| t.name == name }
|
|
788
|
+
next_404 unless @info_tool
|
|
789
|
+
@info_label = @tool_groups.find { |_, tools| tools.include?(@info_tool) }&.first
|
|
790
|
+
render_tools_page(detail: :info, selected: name)
|
|
667
791
|
end
|
|
668
792
|
end
|
|
669
793
|
|
|
@@ -711,6 +835,26 @@ module Studio
|
|
|
711
835
|
r.redirect("/studio/settings?s=edge")
|
|
712
836
|
end
|
|
713
837
|
|
|
838
|
+
# Burst policy: the platform QueuePolicy layer. Its own form for the same
|
|
839
|
+
# reason as edge — and so "apply to every agent" is one save instead of
|
|
840
|
+
# one edit per agent (each agent still overrides in its config).
|
|
841
|
+
# Platform agent defaults: what an agent with no value of its own runs with.
|
|
842
|
+
r.post "agent-defaults" do
|
|
843
|
+
check_csrf!
|
|
844
|
+
with_flash("Agent defaults saved.") do
|
|
845
|
+
dispatch(:update_settings, { patch: { "agent_defaults" => agent_defaults_patch(r) } })
|
|
846
|
+
end
|
|
847
|
+
r.redirect("/studio/settings?s=agents")
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
r.post "queue" do
|
|
851
|
+
check_csrf!
|
|
852
|
+
with_flash("Burst policy saved.") do
|
|
853
|
+
dispatch(:update_settings, { patch: queue_patch(r) })
|
|
854
|
+
end
|
|
855
|
+
r.redirect("/studio/settings?s=burst")
|
|
856
|
+
end
|
|
857
|
+
|
|
714
858
|
# Evals: the judge PANEL and how it agrees. Its own
|
|
715
859
|
# form, like models and edge — a save here must not clobber those.
|
|
716
860
|
r.post "evals" do
|
|
@@ -855,6 +999,13 @@ module Studio
|
|
|
855
999
|
end
|
|
856
1000
|
end
|
|
857
1001
|
|
|
1002
|
+
# --- Live floor: the latest conversations as cards ------------
|
|
1003
|
+
# Read-only. live_floor_controller keeps the cards and the event
|
|
1004
|
+
# ticker moving over /studio/events; the server only renders the baseline.
|
|
1005
|
+
r.on "live" do
|
|
1006
|
+
r.is { r.get { render_live } }
|
|
1007
|
+
end
|
|
1008
|
+
|
|
858
1009
|
# --- Chats: conversations index ------------------------------
|
|
859
1010
|
# Read-only: lists sessions and links to the existing viewer (/sessions/:id).
|
|
860
1011
|
r.on "chats" do
|
|
@@ -868,18 +1019,36 @@ module Studio
|
|
|
868
1019
|
# renders the two right zones alone.
|
|
869
1020
|
r.on "sessions" do
|
|
870
1021
|
r.on String do |sid|
|
|
871
|
-
|
|
1022
|
+
# Roda captures the segment URL-encoded; ids may hold a colon
|
|
1023
|
+
# (tenant:session), which every link escapes as %3A.
|
|
1024
|
+
sid = utf8(Rack::Utils.unescape_path(sid))
|
|
1025
|
+
# POST /studio/sessions/:id/guardrail — a person's verdict on one
|
|
1026
|
+
# guardrail flag (false positive / resolved / undo).
|
|
1027
|
+
r.post "guardrail" do
|
|
1028
|
+
check_csrf!
|
|
1029
|
+
status = presence(r.params["status"])
|
|
1030
|
+
with_flash(status ? "Guardrail flag marked #{status == "reported" ? "as a false positive" : status}." : "Guardrail report undone.") do
|
|
1031
|
+
dispatch(:review_guardrail_flag, { session_id: sid, message: r.params["message"],
|
|
1032
|
+
flag: r.params["flag"], status: status })
|
|
1033
|
+
end
|
|
1034
|
+
r.redirect(presence(r.params["back"])&.start_with?("/studio/") ? r.params["back"] : "/studio/sessions/#{Rack::Utils.escape_path(sid)}")
|
|
1035
|
+
end
|
|
1036
|
+
|
|
872
1037
|
r.get do
|
|
873
1038
|
@session = insika[:session_store]&.find(sid)
|
|
874
1039
|
next_404 unless @session
|
|
875
1040
|
|
|
876
1041
|
# master column: recent conversations, current one active
|
|
877
|
-
|
|
878
|
-
# Session's tool-call trace (debug)
|
|
1042
|
+
setup_chat_list(r.params)
|
|
1043
|
+
# Session's tool-call trace (debug), one group per turn of a task:
|
|
1044
|
+
# `turn` restarts at 1 every task (entries without a task_id, written
|
|
1045
|
+
# before it, group by consecutive turn).
|
|
879
1046
|
@tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
|
|
880
|
-
.
|
|
1047
|
+
.chunk_while { |a, b| a["task_id"] == b["task_id"] && a["turn"] == b["turn"] }.to_a
|
|
881
1048
|
# Context breakdown per turn: chronological entries.
|
|
882
1049
|
@context_traces = insika[:context_trace_store]&.for_session(sid) || []
|
|
1050
|
+
# Per-reply "why did it say that?" panels, drawn under each reply.
|
|
1051
|
+
@why = why_panels(Array(@session.messages), @context_traces, @tool_traces.flatten)
|
|
883
1052
|
# the stage history — the session's outcomes folded
|
|
884
1053
|
# into the agent's declared stages (unmapped kinds render raw).
|
|
885
1054
|
@stage_history = stage_history_for(sid)
|
|
@@ -907,7 +1076,7 @@ module Studio
|
|
|
907
1076
|
r.on String do |raw_scope|
|
|
908
1077
|
# Roda captures the segment URL-encoded; a customer cell contains
|
|
909
1078
|
# colons (memory:acme:c-1), so decode before parsing.
|
|
910
|
-
scope = Rack::Utils.unescape_path(
|
|
1079
|
+
scope = utf8(Rack::Utils.unescape_path(raw_scope))
|
|
911
1080
|
cell = Insika::MemoryStore.parse_cell(scope)
|
|
912
1081
|
|
|
913
1082
|
r.get { render_customer(scope, cell) }
|
|
@@ -922,7 +1091,7 @@ module Studio
|
|
|
922
1091
|
expires_at: presence(r.params["expires_at"]), operator: "studio"
|
|
923
1092
|
})
|
|
924
1093
|
end
|
|
925
|
-
r.redirect(customer_path(scope))
|
|
1094
|
+
r.redirect(customer_path(scope, agent: presence(r.params["agent"])))
|
|
926
1095
|
end
|
|
927
1096
|
|
|
928
1097
|
r.post "forget-fact" do
|
|
@@ -933,7 +1102,7 @@ module Studio
|
|
|
933
1102
|
key: presence(r.params["key"]), operator: "studio"
|
|
934
1103
|
})
|
|
935
1104
|
end
|
|
936
|
-
r.redirect(customer_path(scope))
|
|
1105
|
+
r.redirect(customer_path(scope, agent: presence(r.params["agent"])))
|
|
937
1106
|
end
|
|
938
1107
|
|
|
939
1108
|
# The LGPD access right: JSON download of the cell's content (D7 —
|
|
@@ -952,7 +1121,7 @@ module Studio
|
|
|
952
1121
|
JSON.pretty_generate(result)
|
|
953
1122
|
rescue Insika::ValidationError => e
|
|
954
1123
|
flash["error"] = e.message
|
|
955
|
-
r.redirect(customer_path(scope))
|
|
1124
|
+
r.redirect(customer_path(scope, agent: presence(r.params["agent"])))
|
|
956
1125
|
end
|
|
957
1126
|
|
|
958
1127
|
# The LGPD forget — purges the cell AND the customer's sessions.
|
|
@@ -963,7 +1132,8 @@ module Studio
|
|
|
963
1132
|
tenant: cell[:tenant], customer: cell[:customer], operator: "studio"
|
|
964
1133
|
})
|
|
965
1134
|
end
|
|
966
|
-
r.
|
|
1135
|
+
agent = presence(r.params["agent"])
|
|
1136
|
+
r.redirect(agent ? "/studio/customers?agent=#{Rack::Utils.escape(agent)}" : "/studio/customers")
|
|
967
1137
|
end
|
|
968
1138
|
end
|
|
969
1139
|
end
|
|
@@ -1090,6 +1260,14 @@ end
|
|
|
1090
1260
|
control_action(:resolve_refinement, payload, ok: ok)
|
|
1091
1261
|
r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1092
1262
|
end
|
|
1263
|
+
|
|
1264
|
+
# GET /studio/refinement/:id — one run's findings/proposal/diff. Same
|
|
1265
|
+
# miller-column pattern as agent_detail/fact_detail: a Turbo-Frame
|
|
1266
|
+
# request gets the pane alone, a plain hit gets the shell.
|
|
1267
|
+
r.on String do |id|
|
|
1268
|
+
id = utf8(id)
|
|
1269
|
+
r.get { render_refinement_detail(id) }
|
|
1270
|
+
end
|
|
1093
1271
|
end
|
|
1094
1272
|
|
|
1095
1273
|
# --- Harvest: the gated skill loop ----------------------
|
|
@@ -1164,7 +1342,7 @@ end
|
|
|
1164
1342
|
dispatch(:write_concept, { agent: agent, name: name, tenant: tenant,
|
|
1165
1343
|
content: r.params["content"].to_s }.compact)
|
|
1166
1344
|
end
|
|
1167
|
-
r.redirect(knowledge_path(agent, name, tenant))
|
|
1345
|
+
r.redirect(knowledge_path(agent, name, tenant, status: presence(r.params["status"])))
|
|
1168
1346
|
end
|
|
1169
1347
|
end
|
|
1170
1348
|
|
|
@@ -1201,7 +1379,7 @@ end
|
|
|
1201
1379
|
with_flash("Concept removed.") do
|
|
1202
1380
|
dispatch(:delete_concept, { agent: agent, name: name, tenant: tenant }.compact)
|
|
1203
1381
|
end
|
|
1204
|
-
r.redirect(
|
|
1382
|
+
r.redirect(knowledge_path(agent, nil, tenant, status: presence(r.params["status"])))
|
|
1205
1383
|
end
|
|
1206
1384
|
|
|
1207
1385
|
r.post "restore" do
|
|
@@ -1212,7 +1390,7 @@ end
|
|
|
1212
1390
|
dispatch(:restore_concept, { agent: agent, name: name, tenant: tenant,
|
|
1213
1391
|
version: r.params["version"] }.compact)
|
|
1214
1392
|
end
|
|
1215
|
-
r.redirect(knowledge_path(agent, name, tenant))
|
|
1393
|
+
r.redirect(knowledge_path(agent, name, tenant, status: presence(r.params["status"])))
|
|
1216
1394
|
end
|
|
1217
1395
|
end
|
|
1218
1396
|
end
|
|
@@ -1222,7 +1400,10 @@ end
|
|
|
1222
1400
|
# hit the stores, mutations go through the bus); the ONLY mutation — the
|
|
1223
1401
|
# approve/reject/dismiss answer — dispatches :resolve_proposal.
|
|
1224
1402
|
r.on "facts" do
|
|
1225
|
-
r.
|
|
1403
|
+
r.is do
|
|
1404
|
+
r.get { render_facts }
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1226
1407
|
r.post "resolve" do
|
|
1227
1408
|
check_csrf!
|
|
1228
1409
|
decision = presence(r.params["decision"])
|
|
@@ -1234,7 +1415,17 @@ end
|
|
|
1234
1415
|
operator: "studio" })
|
|
1235
1416
|
end
|
|
1236
1417
|
filter = presence(r.params["filter"])
|
|
1237
|
-
|
|
1418
|
+
# lands the operator on the NEXT pending item (same filter scope), not
|
|
1419
|
+
# an empty list — resolving one fact should feed straight into the next.
|
|
1420
|
+
r.redirect(next_fact_path(filter))
|
|
1421
|
+
end
|
|
1422
|
+
|
|
1423
|
+
# GET /studio/facts/:id — one fact's evidence + decision (pending/stale)
|
|
1424
|
+
# or its resolution (recent). Same miller-column pattern as agent_detail:
|
|
1425
|
+
# a Turbo-Frame request gets the pane alone, a plain hit gets the shell.
|
|
1426
|
+
r.on String do |id|
|
|
1427
|
+
id = utf8(id)
|
|
1428
|
+
r.get { render_fact_detail(id) }
|
|
1238
1429
|
end
|
|
1239
1430
|
end
|
|
1240
1431
|
|
|
@@ -1354,7 +1545,10 @@ end
|
|
|
1354
1545
|
# (@sent_message) until it lands in history — an optimistic JS echo can't
|
|
1355
1546
|
# work here (POST→redirect wipes the DOM).
|
|
1356
1547
|
@history = @session_id ? Array(insika[:session_store]&.find(@session_id)&.messages) : []
|
|
1357
|
-
@sent_message = flash["sent_message"]
|
|
1548
|
+
@sent_message = flash["sent_message"] || running_message(@session_id)
|
|
1549
|
+
# The id a NEW conversation will get, minted here so the router can
|
|
1550
|
+
# send its first turn to the worker that will keep running it.
|
|
1551
|
+
@new_session_id = SecureRandom.uuid unless @session_id
|
|
1358
1552
|
view("playground")
|
|
1359
1553
|
end
|
|
1360
1554
|
r.post do
|
|
@@ -1362,15 +1556,18 @@ end
|
|
|
1362
1556
|
agent = presence(r.params["agent"]) || default_agent
|
|
1363
1557
|
typed_session = presence(r.params["session_id"])
|
|
1364
1558
|
message = r.params["message"].to_s
|
|
1365
|
-
# Blank session = new conversation: created via Command
|
|
1366
|
-
#
|
|
1367
|
-
#
|
|
1559
|
+
# Blank session = new conversation: created via Command with the id the
|
|
1560
|
+
# page minted (the router already sent this POST to that id's worker —
|
|
1561
|
+
# the Studio doesn't write to the store directly). A typed id continues
|
|
1562
|
+
# an existing conversation (send_message requires it to exist); so does
|
|
1563
|
+
# a re-submitted page whose minted id already exists.
|
|
1368
1564
|
# The per-chat model pin is set at creation and rides the whole
|
|
1369
1565
|
# conversation, so it only applies to a NEW session — an existing one keeps
|
|
1370
1566
|
# whatever it was pinned to.
|
|
1567
|
+
minted = presence(r.params["new_session_id"])
|
|
1371
1568
|
session_id = typed_session ||
|
|
1372
|
-
|
|
1373
|
-
|
|
1569
|
+
(minted if minted && insika[:session_store]&.find(minted)) ||
|
|
1570
|
+
create_session(id: minted, **pinned_model(r),
|
|
1374
1571
|
thinking: presence(r.params["thinking"]))
|
|
1375
1572
|
dispatch_send_message(agent: agent, session_id: session_id, message: message)
|
|
1376
1573
|
# Optimistic echo of the just-sent message: survives the redirect
|
|
@@ -1394,53 +1591,162 @@ end
|
|
|
1394
1591
|
|
|
1395
1592
|
def insika = self.class.insika
|
|
1396
1593
|
|
|
1397
|
-
# Sidebar navigation, grouped by operator intent (
|
|
1398
|
-
#
|
|
1399
|
-
#
|
|
1594
|
+
# Sidebar navigation, grouped by operator intent (operate / review /
|
|
1595
|
+
# build / insights / platform). Each item: [label, href, icon-key] plus an
|
|
1596
|
+
# optional pending count (Review items) the layout shows as a badge. The
|
|
1597
|
+
# view marks the active item by comparing the path and renders the icon via
|
|
1598
|
+
# `nav_icon`.
|
|
1400
1599
|
def nav_sections
|
|
1600
|
+
counts = review_counts
|
|
1401
1601
|
[
|
|
1402
1602
|
["", [
|
|
1403
1603
|
["Home", "/studio/home", :home]
|
|
1404
1604
|
]],
|
|
1605
|
+
["operate", [
|
|
1606
|
+
["Live", "/studio/live", :live],
|
|
1607
|
+
["Chats", "/studio/chats", :chats],
|
|
1608
|
+
["Customers", "/studio/customers", :chats],
|
|
1609
|
+
["Playground", "/studio/playground", :playground]
|
|
1610
|
+
]],
|
|
1611
|
+
["review", [
|
|
1612
|
+
["Inbox", "/studio/inbox", :approvals, counts.values.sum],
|
|
1613
|
+
["Approvals", "/studio/approvals", :approvals, counts[:approvals]],
|
|
1614
|
+
# the human gate on distilled proposals (approve/reject/dismiss).
|
|
1615
|
+
["Facts", "/studio/facts", :facts, counts[:facts]],
|
|
1616
|
+
# the human gate on mined skills (gate, promote, reject, rollback).
|
|
1617
|
+
["Harvest", "/studio/harvest", :harvest, counts[:harvest]],
|
|
1618
|
+
["Refinement", "/studio/refinement", :refinement, counts[:refinement]]
|
|
1619
|
+
]],
|
|
1405
1620
|
["build", [
|
|
1406
1621
|
["Agents", "/studio/agents", :agents],
|
|
1407
1622
|
["Skills", "/studio/skills", :skills],
|
|
1623
|
+
["Kits", "/studio/kits", :kits],
|
|
1408
1624
|
["Tools", "/studio/tools", :tools],
|
|
1409
|
-
["System files", "/studio/system-files", :system]
|
|
1410
|
-
]],
|
|
1411
|
-
["runtime", [
|
|
1412
1625
|
["MCP", "/studio/mcp", :mcp],
|
|
1413
|
-
["
|
|
1626
|
+
["System files", "/studio/system-files", :system]
|
|
1414
1627
|
]],
|
|
1415
|
-
["
|
|
1416
|
-
["Chats", "/studio/chats", :chats],
|
|
1417
|
-
["Customers", "/studio/customers", :chats],
|
|
1418
|
-
["Playground", "/studio/playground", :playground],
|
|
1628
|
+
["insights", [
|
|
1419
1629
|
["Funnel", "/studio/funnel", :funnel],
|
|
1420
|
-
#
|
|
1421
|
-
# cancelled records per agent + the per-arm A/B readout card.
|
|
1630
|
+
# scheduled, fired, blocked and cancelled follow-ups per agent.
|
|
1422
1631
|
["Follow-ups", "/studio/followups", :followups],
|
|
1423
|
-
# the
|
|
1424
|
-
# list, sandboxed preview, signed sharing link.
|
|
1632
|
+
# the report destination: per-agent list, preview, signed link.
|
|
1425
1633
|
["Artifacts", "/studio/artifacts", :artifacts],
|
|
1426
|
-
# the
|
|
1427
|
-
# proposals (approve/reject/dismiss, the latch, the CAS re-present).
|
|
1428
|
-
["Facts", "/studio/facts", :facts],
|
|
1429
|
-
# the Harvest page — the human gate on mined skills
|
|
1430
|
-
# (gate, promote, reject, rollback; the append-only promotion log).
|
|
1431
|
-
["Harvest", "/studio/harvest", :harvest],
|
|
1432
|
-
# the Knowledge page — concepts the engine extracted from
|
|
1433
|
-
# finished conversations; edit, delete, resolve a conflict.
|
|
1634
|
+
# concepts the engine extracted from finished conversations.
|
|
1434
1635
|
["Knowledge", "/studio/knowledge", :knowledge],
|
|
1435
|
-
["
|
|
1436
|
-
["
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1636
|
+
["Evals", "/studio/evals", :evals],
|
|
1637
|
+
["Tasks", "/studio/tasks", :tasks]
|
|
1638
|
+
] + parity_nav_item],
|
|
1639
|
+
["platform", [
|
|
1640
|
+
["Models", "/studio/models", :settings],
|
|
1641
|
+
["Settings", "/studio/settings", :settings]
|
|
1642
|
+
]]
|
|
1441
1643
|
]
|
|
1442
1644
|
end
|
|
1443
1645
|
|
|
1646
|
+
# What the ⌘K palette lists: [group, label, href] for every nav page,
|
|
1647
|
+
# then every agent.
|
|
1648
|
+
def palette_entries
|
|
1649
|
+
pages = nav_sections.flat_map do |group, items|
|
|
1650
|
+
items.map { |label, href, _| [group, label, href] }
|
|
1651
|
+
end
|
|
1652
|
+
agents = Array(insika[:profile_source]&.ids).sort.map { |id| ["agent", id, "/studio/agents/#{Rack::Utils.escape_path(id)}"] }
|
|
1653
|
+
pages + agents
|
|
1654
|
+
end
|
|
1655
|
+
|
|
1656
|
+
# The Overview's "Needs attention" row: [text, href, tone] for every
|
|
1657
|
+
# non-zero item — review queues, customers waiting on a reply, tools that
|
|
1658
|
+
# failed in the last 24h. `turns` is the metrics store's 24h turn block.
|
|
1659
|
+
def attention_items(turns)
|
|
1660
|
+
counts = review_counts
|
|
1661
|
+
failing = Array(turns && turns["tools"]).count { |t| t["failures"].to_i.positive? }
|
|
1662
|
+
[
|
|
1663
|
+
[counts[:approvals], "approval(s) waiting", "/studio/approvals", "warn"],
|
|
1664
|
+
[counts[:facts], "fact proposal(s) to review", "/studio/facts", "info"],
|
|
1665
|
+
[counts[:harvest], "mined skill(s) to review", "/studio/harvest", "info"],
|
|
1666
|
+
[counts[:refinement], "refinement run(s) to review", "/studio/refinement", "info"],
|
|
1667
|
+
[counts[:false_positives], "guardrail false positive(s) reported", "/studio/inbox?type=false_positive", "err"],
|
|
1668
|
+
[Array(@stalled).size, "customer(s) waiting for a reply", "/studio/chats", "warn"],
|
|
1669
|
+
[failing, "tool(s) failing in 24h", "/studio/tools", "err"]
|
|
1670
|
+
].select { |n, *| n.positive? }.map { |n, text, href, tone| ["#{n} #{text}", href, tone] }
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
# Items parked on a human, per Review page — the sidebar badges.
|
|
1674
|
+
def review_counts = review_queues.transform_values(&:size)
|
|
1675
|
+
|
|
1676
|
+
# The Review queues themselves, read once per request (the sidebar and the
|
|
1677
|
+
# inbox share them); a store that is missing or fails is empty (the nav
|
|
1678
|
+
# never breaks a page).
|
|
1679
|
+
def review_queues
|
|
1680
|
+
@review_queues ||= {
|
|
1681
|
+
approvals: safe_list { insika[:pending_action_store]&.all_open(kind: Insika::PendingActionStore::OPERATOR) },
|
|
1682
|
+
facts: safe_list { insika[:proposal_store]&.pending(limit: 100) },
|
|
1683
|
+
harvest: safe_list { insika[:harvest_store]&.awaiting_approval(limit: 100) },
|
|
1684
|
+
refinement: safe_list { insika[:refinement_store]&.awaiting_approval(limit: 100) },
|
|
1685
|
+
false_positives: safe_list { reported_sessions }
|
|
1686
|
+
}
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
# Every session's stats, read once per request: the sidebar badge, the
|
|
1690
|
+
# inbox and the page itself all ask for them.
|
|
1691
|
+
def session_stats
|
|
1692
|
+
@session_stats ||= insika[:session_store].all_stats
|
|
1693
|
+
end
|
|
1694
|
+
|
|
1695
|
+
# Sessions holding a guardrail flag someone reported as a false positive
|
|
1696
|
+
# and nobody resolved yet (stats only).
|
|
1697
|
+
def reported_sessions
|
|
1698
|
+
store = insika[:session_store]
|
|
1699
|
+
store.respond_to?(:all_stats) ? session_stats.select { |s| s.reported.positive? } : []
|
|
1700
|
+
end
|
|
1701
|
+
|
|
1702
|
+
INBOX_TYPES = { "approval" => "Approval", "fact" => "Fact", "skill" => "Skill",
|
|
1703
|
+
"refinement" => "Refinement", "false_positive" => "False positive" }.freeze
|
|
1704
|
+
|
|
1705
|
+
# The Review inbox: every item parked on a person, oldest first, each
|
|
1706
|
+
# linking to the page where it is decided. -> [{ type:, title:, agent:, at:, href: }]
|
|
1707
|
+
def inbox_items
|
|
1708
|
+
tasks = insika[:task_store]
|
|
1709
|
+
queues = review_queues
|
|
1710
|
+
items = []
|
|
1711
|
+
queues[:approvals].each do |pa|
|
|
1712
|
+
items << { type: "approval", title: "#{pa.tool} needs approval", agent: (task = tasks&.find(pa.task_id)) && task_agent(task),
|
|
1713
|
+
at: pa.requested_at, href: "/studio/approvals" }
|
|
1714
|
+
end
|
|
1715
|
+
queues[:facts].each do |p|
|
|
1716
|
+
items << { type: "fact", title: "#{p.key}: #{p.value}"[0, 120], agent: p.scope,
|
|
1717
|
+
at: p.created_at, href: "/studio/facts/#{Rack::Utils.escape_path(p.id)}" }
|
|
1718
|
+
end
|
|
1719
|
+
queues[:harvest].each do |c|
|
|
1720
|
+
items << { type: "skill", title: "Mined skill: #{c.name}", agent: c.agent,
|
|
1721
|
+
at: c.updated_at, href: "/studio/harvest?agent=#{Rack::Utils.escape(c.agent.to_s)}" }
|
|
1722
|
+
end
|
|
1723
|
+
queues[:refinement].each do |run|
|
|
1724
|
+
items << { type: "refinement", title: "Refinement run waiting for a decision", agent: run.agent_id,
|
|
1725
|
+
at: run.finished_at || run.started_at, href: "/studio/refinement/#{Rack::Utils.escape_path(run.id)}" }
|
|
1726
|
+
end
|
|
1727
|
+
queues[:false_positives].each do |st|
|
|
1728
|
+
who = presence(st.vars["customer_name"]) || presence(st.vars["customer"]) || st.id
|
|
1729
|
+
items << { type: "false_positive", title: "Guardrail false positive · #{who}", agent: st.vars["agent"],
|
|
1730
|
+
at: st.reported_at || st.updated_at, href: "/studio/sessions/#{Rack::Utils.escape_path(st.id)}" }
|
|
1731
|
+
end
|
|
1732
|
+
items.sort_by { |i| i[:at].to_s }
|
|
1733
|
+
end
|
|
1734
|
+
|
|
1735
|
+
def render_inbox
|
|
1736
|
+
all = inbox_items
|
|
1737
|
+
@inbox_counts = INBOX_TYPES.keys.to_h { |t| [t, all.count { |i| i[:type] == t }] }
|
|
1738
|
+
@inbox_type = INBOX_TYPES.key?(request.params["type"]) ? request.params["type"] : nil
|
|
1739
|
+
@inbox_total = all.size
|
|
1740
|
+
@items = @inbox_type ? all.select { |i| i[:type] == @inbox_type } : all
|
|
1741
|
+
view("inbox")
|
|
1742
|
+
end
|
|
1743
|
+
|
|
1744
|
+
def safe_list
|
|
1745
|
+
Array(yield)
|
|
1746
|
+
rescue StandardError
|
|
1747
|
+
[]
|
|
1748
|
+
end
|
|
1749
|
+
|
|
1444
1750
|
# NAV_ICONS + nav_icon moved to Studio::NavIcons.
|
|
1445
1751
|
|
|
1446
1752
|
def authenticated? = session["auth"] == true
|
|
@@ -1465,6 +1771,25 @@ end
|
|
|
1465
1771
|
path == target || path.start_with?("#{target}/")
|
|
1466
1772
|
end
|
|
1467
1773
|
|
|
1774
|
+
# The nav group the current page sits in ("operate" / "review" / "build" / …),
|
|
1775
|
+
# or nil for the ungrouped Home.
|
|
1776
|
+
def nav_group
|
|
1777
|
+
group, = nav_sections.find { |_, items| items.any? { |(_, href, _)| nav_active?(href) } }
|
|
1778
|
+
presence(group)
|
|
1779
|
+
end
|
|
1780
|
+
|
|
1781
|
+
# The one-segment breadcrumb above a page title. It is always the nav GROUP,
|
|
1782
|
+
# never the page's own name: a crumb that echoes the title ("knowledge /
|
|
1783
|
+
# Knowledge") tells the operator nothing. Renders "" when there is no group,
|
|
1784
|
+
# or when the group would only repeat the title. Pages with a real hierarchy
|
|
1785
|
+
# (agents / :id, chats / session) spell their crumbs out in the view.
|
|
1786
|
+
def crumbs_for(title)
|
|
1787
|
+
group = nav_group
|
|
1788
|
+
return "" if group.nil? || group.casecmp?(title.to_s)
|
|
1789
|
+
|
|
1790
|
+
%(<nav class="crumbs"><span>#{h(group)}</span></nav>)
|
|
1791
|
+
end
|
|
1792
|
+
|
|
1468
1793
|
# CSP nonce for inline styles (CodeMirror's injected theme). Stable PER SESSION,
|
|
1469
1794
|
# not per request: the browser enforces the CSP from the initial document load
|
|
1470
1795
|
# for the whole SPA session, but Turbo Drive fetches later pages carrying their
|
|
@@ -1484,6 +1809,15 @@ end
|
|
|
1484
1809
|
# they are looking at. No new state; just reads the process env.
|
|
1485
1810
|
def env_label = (presence(Insika::EnvSchema.read("INSIKA_ENV")) || presence(ENV["RACK_ENV"]) || "local").to_s
|
|
1486
1811
|
|
|
1812
|
+
# The top-of-page environment strip: nil on production (no strip), else
|
|
1813
|
+
# the label plus a style — staging amber, anything else (dev, local) blue.
|
|
1814
|
+
def env_banner
|
|
1815
|
+
label = env_label.downcase
|
|
1816
|
+
return nil if %w[production prod].include?(label)
|
|
1817
|
+
|
|
1818
|
+
{ label: label, kind: label.start_with?("stag") ? "staging" : "other" }
|
|
1819
|
+
end
|
|
1820
|
+
|
|
1487
1821
|
# Theme preference read from the cookie (applied server-side on <html> → no
|
|
1488
1822
|
# flash). Strict allowlist: an unexpected value falls back to "auto".
|
|
1489
1823
|
THEMES = %w[auto light dark].freeze
|
|
@@ -1625,6 +1959,10 @@ end
|
|
|
1625
1959
|
|
|
1626
1960
|
def render_agent_detail
|
|
1627
1961
|
id = @agent.id
|
|
1962
|
+
# The config sections show what the agent runs with: its own values, or
|
|
1963
|
+
# the platform's where it inherits (the inherit boxes read @agent_own).
|
|
1964
|
+
@agent_own = @agent
|
|
1965
|
+
@agent = Insika::AgentDefaults.apply_safely(@agent, platform_agent_defaults)
|
|
1628
1966
|
# Which subnav tab the frame should land on. Selecting a prompt file or
|
|
1629
1967
|
# a config group is a real navigation (advances the frame + history),
|
|
1630
1968
|
# which reconnects the `tabs` Stimulus controller — but Turbo's history
|
|
@@ -1636,6 +1974,8 @@ end
|
|
|
1636
1974
|
"prompts"
|
|
1637
1975
|
elsif request.params["cfg"]
|
|
1638
1976
|
"config"
|
|
1977
|
+
elsif %w[tools skills].include?(request.params["tab"])
|
|
1978
|
+
request.params["tab"]
|
|
1639
1979
|
end
|
|
1640
1980
|
@config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
|
|
1641
1981
|
store = insika[:agent_file_store]
|
|
@@ -1643,11 +1983,13 @@ end
|
|
|
1643
1983
|
{
|
|
1644
1984
|
name: name.to_s,
|
|
1645
1985
|
content: store&.read(id, name).to_s,
|
|
1986
|
+
updated_at: store&.updated_at(id, name),
|
|
1646
1987
|
versions: store ? store.versions(id, name) : []
|
|
1647
1988
|
}
|
|
1648
1989
|
end
|
|
1649
|
-
@all_skills = insika[:skill_catalog]&.all || []
|
|
1990
|
+
@all_skills = insika[:skill_catalog]&.all(agent: id) || []
|
|
1650
1991
|
@agent_skills = @agent.skills.nil? ? nil : Array(@agent.skills).map(&:to_s)
|
|
1992
|
+
load_tool_catalog
|
|
1651
1993
|
# v2 config surfaces: generation params + model fence. AgentProfile.build
|
|
1652
1994
|
# string-keys these hashes, so the form helpers read plain string keys.
|
|
1653
1995
|
@params = @agent.params
|
|
@@ -1657,10 +1999,17 @@ end
|
|
|
1657
1999
|
mem = insika[:memory_store]
|
|
1658
2000
|
@facts = mem ? mem.facts(tenant: id) : []
|
|
1659
2001
|
@notes = mem ? mem.notes(tenant: id, limit: 20) : []
|
|
1660
|
-
|
|
2002
|
+
# Sessions stamp their agent (vars["agent"]); keep this agent's only.
|
|
2003
|
+
# ponytail: scans the 200 most recent sessions; a per-agent index if an
|
|
2004
|
+
# agent's conversations fall out of that window.
|
|
2005
|
+
@recent_sessions = agent_sessions(recent_sessions(limit: 200), id).first(8)
|
|
1661
2006
|
# WS7: last outcome + per-day series for THIS agent. The grid already
|
|
1662
2007
|
# shows the last-outcome pill; the series is the period view.
|
|
1663
2008
|
outcomes = insika[:outcome_store]
|
|
2009
|
+
# one read of this agent's outcomes, shared by the performance and History tabs
|
|
2010
|
+
mine = Array(outcomes&.all(agent: id))
|
|
2011
|
+
agent_performance(id, mine)
|
|
2012
|
+
agent_changes(id, mine)
|
|
1664
2013
|
@latest_outcome = outcomes&.latest_per_agent&.[](id)
|
|
1665
2014
|
@outcome_series = outcomes ? outcomes.series(agent: id) : {}
|
|
1666
2015
|
# the per-agent cache-hit series (nil store -> the view's
|
|
@@ -1672,7 +2021,6 @@ end
|
|
|
1672
2021
|
# the master column: the detail page IS the shell when
|
|
1673
2022
|
# visited directly; only a frame request renders the pane alone.
|
|
1674
2023
|
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
1675
|
-
@latest_outcomes = insika[:outcome_store]&.latest_per_agent
|
|
1676
2024
|
if turbo_frame?("agent-detail")
|
|
1677
2025
|
render("agent_detail", locals: { frame_only: true }, layout: false)
|
|
1678
2026
|
else
|
|
@@ -1699,6 +2047,33 @@ end
|
|
|
1699
2047
|
config.to_s
|
|
1700
2048
|
end
|
|
1701
2049
|
|
|
2050
|
+
# "Use the platform default" box of an inheritable config section, on the
|
|
2051
|
+
# agent page only. Checked while the agent has no value of its own.
|
|
2052
|
+
def inherit_toggle(field)
|
|
2053
|
+
return "" if @agent_own.nil?
|
|
2054
|
+
|
|
2055
|
+
checked = @agent_own.public_send(field).nil? ? " checked" : ""
|
|
2056
|
+
%(<label class="check inherit"><input type="checkbox" name="inherit[]" value="#{field}"#{checked}> ) +
|
|
2057
|
+
%(use the platform default <span class="muted">(Settings › Agent defaults)</span></label>)
|
|
2058
|
+
end
|
|
2059
|
+
|
|
2060
|
+
def platform_agent_defaults
|
|
2061
|
+
store = insika[:settings_store]
|
|
2062
|
+
(store ? store.get : Insika::SettingsStore::DEFAULTS)["agent_defaults"] || {}
|
|
2063
|
+
end
|
|
2064
|
+
|
|
2065
|
+
# The platform defaults as a profile, so the agent's sections render them.
|
|
2066
|
+
# A field that no longer builds (a rerank model the registry dropped) shows
|
|
2067
|
+
# blank, so this page stays the place to fix it.
|
|
2068
|
+
def agent_defaults_profile
|
|
2069
|
+
fields = platform_agent_defaults.slice(*Insika::AgentDefaults::FIELDS).select do |field, value|
|
|
2070
|
+
Insika::AgentDefaults.validate!(field => value)
|
|
2071
|
+
rescue Insika::ValidationError
|
|
2072
|
+
false
|
|
2073
|
+
end
|
|
2074
|
+
Insika::AgentProfile.build(id: "agent-defaults", model: nil, **fields.transform_keys(&:to_sym))
|
|
2075
|
+
end
|
|
2076
|
+
|
|
1702
2077
|
# A list field as comma-joined text for the form's textarea.
|
|
1703
2078
|
def agent_list(list)
|
|
1704
2079
|
Array(list).join(", ")
|
|
@@ -1713,12 +2088,10 @@ end
|
|
|
1713
2088
|
# (home, chats, tasks, approvals, customers, evals, funnel, follow-ups,
|
|
1714
2089
|
# facts, parity). Same markup everywhere: a select that submits on change.
|
|
1715
2090
|
def agent_filter_form(path, current)
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
end.join
|
|
1721
|
-
%(<form method="get" action="#{path}" class="actions inline"><label>Agent <select name="agent" data-controller="auto-submit" data-action="change->auto-submit#submit">#{rows}</select></label></form>)
|
|
2091
|
+
picker = render("_agent_picker", locals: { name: "agent", options: insika[:profile_source].ids.sort,
|
|
2092
|
+
value: current, blank: "All agents — type to search",
|
|
2093
|
+
auto_submit: true })
|
|
2094
|
+
%(<form method="get" action="#{path}" class="actions inline"><label>Agent #{picker}</label></form>)
|
|
1722
2095
|
end
|
|
1723
2096
|
|
|
1724
2097
|
# The sessions of one agent — the session stamps its agent in
|
|
@@ -1734,6 +2107,34 @@ end
|
|
|
1734
2107
|
vars.is_a?(Hash) ? vars["agent"].to_s : ""
|
|
1735
2108
|
end
|
|
1736
2109
|
|
|
2110
|
+
# The customer a session belongs to — blank for a session that never got
|
|
2111
|
+
# far enough to identify one (a fresh playground run, an abandoned turn).
|
|
2112
|
+
# Same vars lookup as session_agent, one key over.
|
|
2113
|
+
def session_customer(session)
|
|
2114
|
+
vars = session.respond_to?(:vars) ? session.vars : nil
|
|
2115
|
+
vars.is_a?(Hash) ? vars["customer"].to_s : ""
|
|
2116
|
+
end
|
|
2117
|
+
|
|
2118
|
+
# The label a conversation shows: the caller's customer_name, else the
|
|
2119
|
+
# customer key. Blank when neither was sent.
|
|
2120
|
+
def session_customer_label(session)
|
|
2121
|
+
vars = session.respond_to?(:vars) ? session.vars : nil
|
|
2122
|
+
presence(vars.is_a?(Hash) ? vars["customer_name"] : nil) || session_customer(session)
|
|
2123
|
+
end
|
|
2124
|
+
|
|
2125
|
+
def session_customer_phone(session)
|
|
2126
|
+
vars = session.respond_to?(:vars) ? session.vars : nil
|
|
2127
|
+
presence(vars.is_a?(Hash) ? vars["customer_phone"] : nil)
|
|
2128
|
+
end
|
|
2129
|
+
|
|
2130
|
+
# The Chats/History "active" presence dot: lit while the session's last
|
|
2131
|
+
# activity sits inside the same 5-minute window the home dashboard's
|
|
2132
|
+
# conversation rail uses (views/home.erb) — one rule, read in both places.
|
|
2133
|
+
def session_fresh?(session, cutoff: Time.now - 300)
|
|
2134
|
+
t = parse_time(session.updated_at)
|
|
2135
|
+
!t.nil? && t >= cutoff
|
|
2136
|
+
end
|
|
2137
|
+
|
|
1737
2138
|
# The distill button's scope (the DistillEngine's own scan, scoped to ONE
|
|
1738
2139
|
# agent): the agent's sessions that are idle past the pack's window, long
|
|
1739
2140
|
# enough, and not yet distilled. Oldest first, capped — each entry is a
|
|
@@ -1749,9 +2150,8 @@ end
|
|
|
1749
2150
|
idle_hours = positive_int(config["idle_hours"]) || 6
|
|
1750
2151
|
min_messages = positive_int(config["min_messages"]) || 3
|
|
1751
2152
|
cutoff = Time.now.utc - idle_hours * 3600
|
|
1752
|
-
store.
|
|
1753
|
-
|
|
1754
|
-
next unless session
|
|
2153
|
+
every_record(store).filter_map do |session|
|
|
2154
|
+
sid = session.id
|
|
1755
2155
|
next unless session.vars.is_a?(Hash) && session.vars["agent"] == agent_id
|
|
1756
2156
|
next if Insika::Coercion.blank?(session.vars["customer"])
|
|
1757
2157
|
next unless aged?(session.updated_at, cutoff)
|
|
@@ -1788,6 +2188,68 @@ end
|
|
|
1788
2188
|
%(<select name="#{name}">#{rows}</select>)
|
|
1789
2189
|
end
|
|
1790
2190
|
|
|
2191
|
+
# The model pickers offer ONLY refs that resolve: each Studio provider's declared
|
|
2192
|
+
# models, plus the chat models of every provider RubyLLM holds a key for that the
|
|
2193
|
+
# Studio does not declare (e.g. a DEEPSEEK_API_KEY deployment). A typed ref is how
|
|
2194
|
+
# "openai/gpt-x" under provider "openai" reached the API as "openai/openai/gpt-x".
|
|
2195
|
+
# -> ["provider/model", ...] sorted.
|
|
2196
|
+
def model_refs
|
|
2197
|
+
@model_refs ||= begin
|
|
2198
|
+
providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
|
|
2199
|
+
declared = providers.flat_map { |p| Array(p["models"]).map { |m| "#{p['api']}/#{m}" } }
|
|
2200
|
+
apis = providers.map { |p| p["api"].to_s }
|
|
2201
|
+
native = RubyLLM::Provider.configured_providers(RubyLLM.config).flat_map do |klass|
|
|
2202
|
+
slug = klass.slug.to_s
|
|
2203
|
+
apis.include?(slug) ? [] : RubyLLM.models.by_provider(slug).chat_models.map { |m| "#{slug}/#{m.id}" }
|
|
2204
|
+
end
|
|
2205
|
+
(declared + native).uniq.sort
|
|
2206
|
+
end
|
|
2207
|
+
end
|
|
2208
|
+
|
|
2209
|
+
# Type-to-search picker over model_refs (the _agent_picker idiom: <input list>
|
|
2210
|
+
# over a <datalist>, native, no JS). The datalist suggests; the SERVER decides —
|
|
2211
|
+
# `checked_model_ref` refuses a typed ref outside the list. A stored value outside
|
|
2212
|
+
# it is shown as is, marked, and may be saved back unchanged.
|
|
2213
|
+
def model_select(name, current, blank_label)
|
|
2214
|
+
cur = current.to_s
|
|
2215
|
+
esc = ->(s) { Rack::Utils.escape_html(s.to_s) }
|
|
2216
|
+
list_id = "model-options-#{@model_picker_seq = (@model_picker_seq || 0) + 1}"
|
|
2217
|
+
options = model_refs.map { |ref| %(<option value="#{esc.(ref)}"></option>) }.join
|
|
2218
|
+
stale = cur.empty? || model_refs.include?(cur) ? "" : %( <span class="muted">not configured</span>)
|
|
2219
|
+
%(<input type="search" class="filter-input" name="#{esc.(name)}" list="#{list_id}" value="#{esc.(cur)}" ) +
|
|
2220
|
+
%(autocomplete="off" placeholder="#{esc.(blank_label)} — type to search">#{stale}) +
|
|
2221
|
+
%(<datalist id="#{list_id}">#{options}</datalist>)
|
|
2222
|
+
end
|
|
2223
|
+
|
|
2224
|
+
# An ORDERED list of refs (fallback chains, proposers): one picker per slot,
|
|
2225
|
+
# posted as "<name>[]", always one empty slot more than stored (min `min`).
|
|
2226
|
+
def model_slots(name, values, min: 3)
|
|
2227
|
+
values = Array(values)
|
|
2228
|
+
slots = [values.size + 1, min].max.times.map do |i|
|
|
2229
|
+
%(<label>#{i + 1}. #{model_select("#{name}[]", values[i], 'empty')}</label>)
|
|
2230
|
+
end
|
|
2231
|
+
%(<div class="grid-2">#{slots.join}</div>)
|
|
2232
|
+
end
|
|
2233
|
+
|
|
2234
|
+
# A picked ref must be one the picker offers; `kept` (the stored value) may come
|
|
2235
|
+
# back unchanged even when it is no longer listed. Blank = nil (inherit/none).
|
|
2236
|
+
def checked_model_ref(raw, kept = nil)
|
|
2237
|
+
ref = presence(raw)
|
|
2238
|
+
return ref if ref.nil? || model_refs.include?(ref) || Array(kept).include?(ref)
|
|
2239
|
+
|
|
2240
|
+
raise Insika::ValidationError, "unknown model '#{ref}' — pick one from the list (Settings › LLM providers)"
|
|
2241
|
+
end
|
|
2242
|
+
|
|
2243
|
+
# "provider/model" for a stored pair (the agent and the platform default keep
|
|
2244
|
+
# them apart); a bare model when there is no provider.
|
|
2245
|
+
def model_ref_of(model, provider)
|
|
2246
|
+
m = model.to_s
|
|
2247
|
+
p = provider.to_s
|
|
2248
|
+
return "" if m.empty?
|
|
2249
|
+
|
|
2250
|
+
p.empty? ? m : "#{p}/#{m}"
|
|
2251
|
+
end
|
|
2252
|
+
|
|
1791
2253
|
# Renders the per-model reasoning map ({ "ref" => { "thinking" => v } }) back to
|
|
1792
2254
|
# the textarea lines "ref | thinking" that model_params_patch parses. Only refs
|
|
1793
2255
|
# with a set thinking are shown (blank ones are inherit no-ops).
|
|
@@ -1841,6 +2303,38 @@ end
|
|
|
1841
2303
|
# Which agents specialized THIS skill — the availability grid shows it, so an
|
|
1842
2304
|
# override is discoverable from the shared skill it overrides.
|
|
1843
2305
|
@specialized = insika[:skill_store] ? specialized_by : {}
|
|
2306
|
+
@skill_reach = skill_reach(@agents, @skills)
|
|
2307
|
+
@skill_groups = skill_groups(@skills)
|
|
2308
|
+
end
|
|
2309
|
+
|
|
2310
|
+
# { skill name => [agent ids that can load it] }: own list + kits, or "all".
|
|
2311
|
+
def skill_reach(agents, skills)
|
|
2312
|
+
reach = Hash.new { |h, k| h[k] = [] }
|
|
2313
|
+
agents.each do |own|
|
|
2314
|
+
p = Insika::Kits.apply(own, kits_by_name)
|
|
2315
|
+
allowed = p.skills.nil? ? nil : Array(p.skills).map(&:to_s)
|
|
2316
|
+
skills.each { |s| reach[s.name] << p.id if allowed.nil? || allowed.include?(s.name) }
|
|
2317
|
+
end
|
|
2318
|
+
reach
|
|
2319
|
+
end
|
|
2320
|
+
|
|
2321
|
+
# [[label, skills]]: skills sharing a name prefix ("grocery-core",
|
|
2322
|
+
# "grocery-offers" -> "grocery") group together; the rest go to "Other", last.
|
|
2323
|
+
def skill_groups(skills)
|
|
2324
|
+
prefix = ->(s) { s.name.include?("-") ? s.name.split("-").first : nil }
|
|
2325
|
+
shared = skills.filter_map { |s| prefix.(s) }.tally.select { |_, n| n > 1 }.keys
|
|
2326
|
+
skills.group_by { |s| shared.include?(prefix.(s)) ? prefix.(s) : "Other" }
|
|
2327
|
+
.sort_by { |label, _| [label == "Other" ? 1 : 0, label] }
|
|
2328
|
+
end
|
|
2329
|
+
|
|
2330
|
+
# Kits drill: master = every kit; the editor checks skills/tools/groups from the
|
|
2331
|
+
# live catalogs. @selected nil = none open, "" = new.
|
|
2332
|
+
def load_kits
|
|
2333
|
+
@kits = (insika[:settings_store]&.kits || {}).sort.to_h
|
|
2334
|
+
@skills = (insika[:skill_catalog]&.all || []).sort_by(&:name)
|
|
2335
|
+
@tools = (insika[:tool_catalog]&.all || []).sort_by(&:name)
|
|
2336
|
+
@tool_group_names = @tools.filter_map { |t| t.plugin.to_s if t.plugin.to_s.start_with?("mcp:") }.uniq.sort
|
|
2337
|
+
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
1844
2338
|
end
|
|
1845
2339
|
|
|
1846
2340
|
# { skill name => [agent ids] } across every agent scope in the store.
|
|
@@ -1862,6 +2356,31 @@ end
|
|
|
1862
2356
|
# Raw content for the editor: prefer the store (real SKILL.md), otherwise
|
|
1863
2357
|
# reconstruct from what the catalog parsed (editing a disk skill
|
|
1864
2358
|
# creates an override in the store — Store wins).
|
|
2359
|
+
# -> [ { "content" =>, "from" =>, "to" => } ] most recent first, the current
|
|
2360
|
+
# one included (to: nil). Each version was in use from its write until the
|
|
2361
|
+
# next one, so a turn's time picks the version it ran. Disk-only skill: [].
|
|
2362
|
+
def skill_versions(name, agent: nil)
|
|
2363
|
+
store = insika[:skill_store]
|
|
2364
|
+
current = store&.get(name, agent: agent)
|
|
2365
|
+
return [] unless current
|
|
2366
|
+
|
|
2367
|
+
all = [{ "content" => current, "at" => store.updated_at(name, agent: agent) }] + store.versions(name, agent: agent)
|
|
2368
|
+
all.each_with_index.map do |v, i|
|
|
2369
|
+
{ "content" => v["content"], "from" => v["at"], "to" => i.zero? ? nil : all[i - 1]["at"] }
|
|
2370
|
+
end
|
|
2371
|
+
end
|
|
2372
|
+
|
|
2373
|
+
# Restoring is writing an old text again: the same command as a save, so the
|
|
2374
|
+
# catalog reloads and the old version stays in the history. `version` indexes
|
|
2375
|
+
# SkillStore#versions (0 = the one before the current).
|
|
2376
|
+
def restore_skill_version(name, version, agent: nil)
|
|
2377
|
+
i = Integer(version.to_s, exception: false)
|
|
2378
|
+
old = i&.>=(0) ? insika[:skill_store]&.versions(name, agent: agent)&.[](i) : nil
|
|
2379
|
+
raise Insika::ValidationError, "version #{version} does not exist" unless old
|
|
2380
|
+
|
|
2381
|
+
dispatch(:write_skill, { name: name, agent: agent, content: old["content"] })
|
|
2382
|
+
end
|
|
2383
|
+
|
|
1865
2384
|
def skill_source(name, agent: nil)
|
|
1866
2385
|
raw = insika[:skill_store]&.get(name, agent: agent)
|
|
1867
2386
|
return raw if raw
|
|
@@ -1916,22 +2435,39 @@ end
|
|
|
1916
2435
|
|
|
1917
2436
|
# --- Tools matrix --------------------------------------------------------
|
|
1918
2437
|
|
|
1919
|
-
|
|
2438
|
+
# The live tool catalog, grouped by origin (Native / HTTP / MCP: x). Feeds the
|
|
2439
|
+
# Tools page and the agent's Tools tab.
|
|
2440
|
+
def load_tool_catalog
|
|
1920
2441
|
@tools = (insika[:tool_catalog]&.all || []).sort_by(&:name)
|
|
1921
2442
|
# Names of the DATA-DEFINED tools (editable via the UI). The rest of the catalog are
|
|
1922
2443
|
# code tools (allow/deny only). Used to mark and link the editor.
|
|
1923
2444
|
@data_tool_names = insika[:tool_store] ? insika[:tool_store].names : []
|
|
2445
|
+
# A data tool's `group` (commerce, b2b, groceries…) splits the HTTP section, so a
|
|
2446
|
+
# catalog with every store type's tools reads by type instead of one long list.
|
|
2447
|
+
data_groups = (insika[:tool_store]&.all || []).to_h { |d| [(d["name"] || d[:name]).to_s, (d["group"] || d[:group]).to_s] }
|
|
2448
|
+
@tool_groups = @tools.group_by do |tool|
|
|
2449
|
+
if tool.plugin.to_s.start_with?("mcp:")
|
|
2450
|
+
"MCP: #{tool.plugin.delete_prefix('mcp:')}"
|
|
2451
|
+
elsif @data_tool_names.include?(tool.name)
|
|
2452
|
+
group = data_groups[tool.name]
|
|
2453
|
+
group.to_s.empty? ? "HTTP tools" : "HTTP: #{group}"
|
|
2454
|
+
else
|
|
2455
|
+
"Native tools"
|
|
2456
|
+
end
|
|
2457
|
+
end.sort_by { |label, _| [label == "Native tools" ? 0 : label.start_with?("HTTP") ? 1 : 2, label] }
|
|
1924
2458
|
# Stored but NOT in the catalog = the overlay refused the definition and dropped it
|
|
1925
2459
|
# (only a stderr warn otherwise). The pane still links its editor, so the panel is
|
|
1926
2460
|
# where you see it and where you fix it. `insika doctor` reports the same set.
|
|
1927
2461
|
@dropped_tool_names = @data_tool_names - @tools.map(&:name)
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2462
|
+
end
|
|
2463
|
+
|
|
2464
|
+
# The Tools page: catalog master + one detail (nil landing, :edit, :info). A
|
|
2465
|
+
# Turbo-Frame request renders just the detail pane.
|
|
2466
|
+
def render_tools_page(detail: nil, selected: nil)
|
|
2467
|
+
load_tool_catalog unless @tools
|
|
2468
|
+
@tool_reach = tool_reach(insika[:profile_source].all, @tools)
|
|
2469
|
+
@detail = detail
|
|
2470
|
+
@selected = selected
|
|
1935
2471
|
if turbo_frame?("tool-detail")
|
|
1936
2472
|
render("tools", locals: { frame_only: true }, layout: false)
|
|
1937
2473
|
else
|
|
@@ -1939,6 +2475,57 @@ end
|
|
|
1939
2475
|
end
|
|
1940
2476
|
end
|
|
1941
2477
|
|
|
2478
|
+
# { tool name => [agent ids that can call it] }: the agent's own list, its kits,
|
|
2479
|
+
# its tool groups, or "all"; deny wins. The same reading the turn's
|
|
2480
|
+
# ToolAllowlist policy makes, over the catalog.
|
|
2481
|
+
def tool_reach(agents, tools)
|
|
2482
|
+
reach = Hash.new { |h, k| h[k] = [] }
|
|
2483
|
+
agents.sort_by(&:id).each do |own|
|
|
2484
|
+
p = Insika::Kits.apply(own, kits_by_name)
|
|
2485
|
+
open = p.tools_allow.nil? && p.tools_allow_groups.nil?
|
|
2486
|
+
allow = Array(p.tools_allow).map(&:to_s)
|
|
2487
|
+
groups = Array(p.tools_allow_groups).map(&:to_s)
|
|
2488
|
+
deny = Array(p.tools_deny).map(&:to_s)
|
|
2489
|
+
tools.each do |t|
|
|
2490
|
+
next if deny.include?(t.name)
|
|
2491
|
+
|
|
2492
|
+
reach[t.name] << p.id if open || allow.include?(t.name) || groups.include?(t.plugin.to_s)
|
|
2493
|
+
end
|
|
2494
|
+
end
|
|
2495
|
+
reach
|
|
2496
|
+
end
|
|
2497
|
+
|
|
2498
|
+
# Kits as stored (Settings["kits"]), read once per request.
|
|
2499
|
+
def kits_by_name = (@kits_by_name ||= insika[:settings_store]&.kits || {})
|
|
2500
|
+
|
|
2501
|
+
# { item name => kit name } for what the agent gets from its kits (key "tools" or
|
|
2502
|
+
# "skills"). The views show these on and locked: they come from the kit at turn
|
|
2503
|
+
# time, never from the agent's own list, so a save must not post them.
|
|
2504
|
+
def kit_sources(profile, key)
|
|
2505
|
+
Array(profile.kits).each_with_object({}) do |name, acc|
|
|
2506
|
+
kit = kits_by_name[name]
|
|
2507
|
+
next unless kit.is_a?(Hash)
|
|
2508
|
+
|
|
2509
|
+
Array(kit[key]).each { |item| acc[item] ||= name }
|
|
2510
|
+
end
|
|
2511
|
+
end
|
|
2512
|
+
|
|
2513
|
+
# "3 tools · 2 skills" (own lists + kits; "all" when the list is open) for the
|
|
2514
|
+
# agents master row.
|
|
2515
|
+
def agent_reach(own)
|
|
2516
|
+
p = Insika::Kits.apply(own, kits_by_name)
|
|
2517
|
+
groups = Array(p.tools_allow_groups).size
|
|
2518
|
+
tools = if p.tools_allow.nil? && p.tools_allow_groups.nil? then "all tools"
|
|
2519
|
+
else "#{Array(p.tools_allow).size} tools#{" + #{groups} #{groups == 1 ? "server" : "servers"}" if groups.positive?}"
|
|
2520
|
+
end
|
|
2521
|
+
skills = p.skills.nil? ? "all skills" : "#{Array(p.skills).size} skills"
|
|
2522
|
+
"#{tools} · #{skills}"
|
|
2523
|
+
end
|
|
2524
|
+
|
|
2525
|
+
# The agent can call every tool or load every skill: kits add nothing to it and
|
|
2526
|
+
# any tool or skill added later reaches it without anyone deciding.
|
|
2527
|
+
def agent_open?(own) = (own.tools_allow.nil? && own.tools_allow_groups.nil?) || own.skills.nil?
|
|
2528
|
+
|
|
1942
2529
|
# nil = all; otherwise the list. Pre-checks the checkboxes per agent.
|
|
1943
2530
|
def tool_allowed_for?(profile, tool_name)
|
|
1944
2531
|
profile.tools_allow.nil? || Array(profile.tools_allow).map(&:to_s).include?(tool_name.to_s)
|
|
@@ -1966,7 +2553,7 @@ end
|
|
|
1966
2553
|
@tool_name = name
|
|
1967
2554
|
@form = tool_form(tool)
|
|
1968
2555
|
@versions = tool && insika[:tool_store] ? insika[:tool_store].versions(name) : []
|
|
1969
|
-
|
|
2556
|
+
render_tools_page(detail: :edit, selected: name)
|
|
1970
2557
|
end
|
|
1971
2558
|
|
|
1972
2559
|
# Definition (masked) -> Hash of text fields ready for the form. tool=nil
|
|
@@ -2069,13 +2656,22 @@ end
|
|
|
2069
2656
|
# `?agent=` narrows every number to one agent (the sessions stamp their
|
|
2070
2657
|
# author in `vars["agent"]`). The live layer (live_home_controller) only
|
|
2071
2658
|
# repaints what this renders — it never computes its own baseline.
|
|
2659
|
+
HOME_WINDOW_DAYS = 14
|
|
2660
|
+
|
|
2661
|
+
# Everything on the home is read from the sessions of the last 14 days — the
|
|
2662
|
+
# window the charts already show — never from every session ever stored. The
|
|
2663
|
+
# conversations total comes from the keys alone (no record read); with an agent
|
|
2664
|
+
# filter it is the window's, since the agent lives inside the record.
|
|
2072
2665
|
def render_home
|
|
2073
2666
|
ps = insika[:profile_source]
|
|
2074
2667
|
@agent = presence(request.params["agent"])
|
|
2075
|
-
|
|
2668
|
+
now = Time.now.utc
|
|
2669
|
+
window = window_sessions(now)
|
|
2670
|
+
sessions = agent_sessions(window, @agent)
|
|
2671
|
+
home_operations(window, now, ps)
|
|
2076
2672
|
@counts = {
|
|
2077
|
-
"conversations" => sessions.size,
|
|
2078
|
-
"messages" => sessions.sum { |s|
|
|
2673
|
+
"conversations" => @agent ? sessions.size : session_total,
|
|
2674
|
+
"messages" => sessions.sum { |s| message_count(s) },
|
|
2079
2675
|
"agents" => ps ? ps.all.size : 0,
|
|
2080
2676
|
"skills" => insika[:skill_catalog] ? insika[:skill_catalog].all.size : 0,
|
|
2081
2677
|
"tools" => insika[:tool_catalog] ? insika[:tool_catalog].all.size : 0,
|
|
@@ -2089,10 +2685,12 @@ end
|
|
|
2089
2685
|
# matching and the 24h floor was built three hours in the future, silently
|
|
2090
2686
|
# emptying both charts. Instant comparisons (`cutoff`) never had the bug;
|
|
2091
2687
|
# calendar arithmetic did.
|
|
2092
|
-
now = Time.now.utc
|
|
2093
2688
|
cutoff = now - (5 * 60)
|
|
2094
|
-
@active_now = sessions.count { |s| (t =
|
|
2689
|
+
@active_now = sessions.count { |s| (t = utc_time(s.updated_at)) && t >= cutoff }
|
|
2690
|
+
# the window holds stats; the short list needs the sessions themselves
|
|
2095
2691
|
@recent = sessions.sort_by { |s| s.updated_at.to_s }.reverse.first(8)
|
|
2692
|
+
.filter_map { |s| s.respond_to?(:messages) ? s : insika[:session_store].find(s.id) }
|
|
2693
|
+
@recent = agent_sessions(recent_sessions(limit: 8), @agent) if @recent.empty?
|
|
2096
2694
|
@activity = activity_by_day(sessions, days: 14, now: now)
|
|
2097
2695
|
# 24h sparkline: conversations touched per hour, oldest
|
|
2098
2696
|
# first — the same session scan, bucketed finer.
|
|
@@ -2110,11 +2708,152 @@ end
|
|
|
2110
2708
|
view("home")
|
|
2111
2709
|
end
|
|
2112
2710
|
|
|
2113
|
-
|
|
2711
|
+
# The agent page's Performance tab: per fixed period (24h/7d/30d) the
|
|
2712
|
+
# conversations and messages it touched (session stats), its outcomes, and
|
|
2713
|
+
# the model/turn numbers from ModelMetricsStore#windows (one read).
|
|
2714
|
+
def agent_performance(id, records)
|
|
2715
|
+
now = Time.now.utc
|
|
2716
|
+
@perf = insika[:model_metrics_store]&.windows(agent: id, now: now) || {}
|
|
2717
|
+
store = insika[:session_store]
|
|
2718
|
+
stats = if store.nil? then []
|
|
2719
|
+
elsif store.respond_to?(:all_stats) then session_stats
|
|
2720
|
+
else every_record(store)
|
|
2721
|
+
end
|
|
2722
|
+
mine = agent_sessions(stats, id)
|
|
2723
|
+
outcome_times = records.map { |r| r.at.to_s }
|
|
2724
|
+
@perf_activity = Insika::ModelMetricsStore::PERIODS.to_h do |period, seconds|
|
|
2725
|
+
cut = now - seconds
|
|
2726
|
+
touched = mine.select { |s| (t = utc_time(s.updated_at)) && t >= cut }
|
|
2727
|
+
[period, { conversations: touched.size, messages: touched.sum { |s| message_count(s) },
|
|
2728
|
+
outcomes: outcome_times.count { |at| at >= cut.iso8601 } }]
|
|
2729
|
+
end
|
|
2730
|
+
end
|
|
2731
|
+
|
|
2732
|
+
CHANGE_WINDOW_DAYS = 30
|
|
2733
|
+
CHANGE_COMPARE_DAYS = 7
|
|
2734
|
+
|
|
2735
|
+
# The History tab's "what changed, and what it did": outcomes per day over
|
|
2736
|
+
# the last 30 days (the funnel's primary outcome when declared, else all),
|
|
2737
|
+
# one marker per prompt-file write in that window, and per write the
|
|
2738
|
+
# outcome count 7 days after vs 7 days before. Plain counts; nil = that
|
|
2739
|
+
# window isn't whole yet (after) or predates the outcome data (before).
|
|
2740
|
+
def agent_changes(id, records)
|
|
2741
|
+
now = Time.now.utc
|
|
2742
|
+
week = CHANGE_COMPARE_DAYS * 86_400
|
|
2743
|
+
covered_from = parse_time(records.last&.at) # all() is newest first
|
|
2744
|
+
@change_primary = primary_stage_for(id)
|
|
2745
|
+
counted = @change_primary ? records.select { |r| reached_primary?(id, r) } : records
|
|
2746
|
+
times = counted.filter_map { |r| parse_time(r.at) }
|
|
2747
|
+
first_day = now.to_date - (CHANGE_WINDOW_DAYS - 1)
|
|
2748
|
+
@change_days = (0...CHANGE_WINDOW_DAYS).map { |i| [first_day + i, 0] }.to_h
|
|
2749
|
+
times.each { |t| @change_days[t.to_date] += 1 if @change_days.key?(t.to_date) }
|
|
2750
|
+
@change_start = Time.utc(first_day.year, first_day.month, first_day.day)
|
|
2751
|
+
# [current, newest older, …]: write i replaced write i + 1, which is
|
|
2752
|
+
# history index i — the version a prompt's Restore form puts back.
|
|
2753
|
+
changes_of = lambda do |label, kind, writes|
|
|
2754
|
+
writes.each_cons(2).each_with_index.filter_map do |(after, before), i|
|
|
2755
|
+
at = parse_time(after["at"])
|
|
2756
|
+
next unless at && at >= @change_start
|
|
2757
|
+
|
|
2758
|
+
old_lines = before["content"].to_s.lines.map(&:chomp).reject { |l| l.strip.empty? }
|
|
2759
|
+
new_lines = after["content"].to_s.lines.map(&:chomp).reject { |l| l.strip.empty? }
|
|
2760
|
+
{ file: label, kind: kind, at: at, version: i,
|
|
2761
|
+
removed: old_lines - new_lines, added: new_lines - old_lines,
|
|
2762
|
+
before: covered_from && covered_from <= at - week ? times.count { |t| t >= at - week && t < at } : nil,
|
|
2763
|
+
after: at + week <= now ? times.count { |t| t >= at && t < at + week } : nil }
|
|
2764
|
+
end
|
|
2765
|
+
end
|
|
2766
|
+
prompts = @prompt_files.flat_map do |pf|
|
|
2767
|
+
changes_of.call(pf[:name], :prompt, [{ "content" => pf[:content], "at" => pf[:updated_at] }] + pf[:versions])
|
|
2768
|
+
end
|
|
2769
|
+
# this agent's own skill versions (shared skills change for every agent)
|
|
2770
|
+
skills = insika[:skill_store]
|
|
2771
|
+
own = skills ? skills.names(agent: id).flat_map do |name|
|
|
2772
|
+
changes_of.call(name, :skill, [{ "content" => skills.get(name, agent: id), "at" => skills.updated_at(name, agent: id) }] +
|
|
2773
|
+
skills.versions(name, agent: id))
|
|
2774
|
+
end : []
|
|
2775
|
+
@changes = (prompts + own).sort_by { |c| c[:at] }.reverse
|
|
2776
|
+
end
|
|
2777
|
+
|
|
2778
|
+
COLD_AGENT_DAYS = 3
|
|
2779
|
+
STALLED_AFTER = 10 * 60
|
|
2780
|
+
|
|
2781
|
+
def last_role(session)
|
|
2782
|
+
return session.last_role if session.respond_to?(:last_role)
|
|
2783
|
+
|
|
2784
|
+
last = Array(session.messages).last
|
|
2785
|
+
last.is_a?(Hash) ? last["role"].to_s : nil
|
|
2786
|
+
end
|
|
2787
|
+
|
|
2788
|
+
# The operational half of the home, all over the last 24h and the agent
|
|
2789
|
+
# filter: cost (vs the 24h before), outcomes, turn p95 and failures, tool
|
|
2790
|
+
# health — from ModelMetricsStore and OutcomeStore. The agent ranking and
|
|
2791
|
+
# the cold agents read the unfiltered 14-day window: they compare agents.
|
|
2792
|
+
def home_operations(window, now, profiles)
|
|
2793
|
+
metrics = insika[:model_metrics_store]
|
|
2794
|
+
# one read for the 24h numbers, the cost before them and the daily chart
|
|
2795
|
+
@day = metrics.respond_to?(:home) ? metrics.home(agent: @agent, now: now, days: HOME_WINDOW_DAYS) : nil
|
|
2796
|
+
@day_before_cost = @day&.fetch("day_before_cost")
|
|
2797
|
+
day_ago = (now - 86_400).iso8601
|
|
2798
|
+
@outcomes_24h = insika[:outcome_store]&.all(agent: @agent)&.count { |r| r.at.to_s >= day_ago }
|
|
2799
|
+
by_agent = window.group_by { |s| session_agent(s) }.reject { |agent, _| agent.empty? }
|
|
2800
|
+
@agent_ranking = by_agent.map do |agent, list|
|
|
2801
|
+
{ agent: agent, conversations: list.size, today: list.count { |s| (t = utc_time(s.updated_at)) && t >= now - 86_400 },
|
|
2802
|
+
messages: list.sum { |s| message_count(s) } }
|
|
2803
|
+
end.sort_by { |row| [-row[:today], -row[:conversations], row[:agent]] }.first(8)
|
|
2804
|
+
# Stalled: the customer spoke last, 10+ minutes ago, within the last 24h.
|
|
2805
|
+
stalled_from, stalled_to = now - 86_400, now - STALLED_AFTER
|
|
2806
|
+
@stalled = agent_sessions(window, @agent).filter_map do |s|
|
|
2807
|
+
t = utc_time(s.updated_at)
|
|
2808
|
+
next unless t && t >= stalled_from && t <= stalled_to && last_role(s) == "user"
|
|
2809
|
+
|
|
2810
|
+
{ id: s.id, agent: session_agent(s), waiting: t }
|
|
2811
|
+
end.sort_by { |row| row[:waiting] }.first(8)
|
|
2812
|
+
@daily = Array(@day&.fetch("daily"))
|
|
2813
|
+
cold_floor = now - COLD_AGENT_DAYS * 86_400
|
|
2814
|
+
@cold_agents = profiles ? profiles.all.map { |p| p.id.to_s }.sort.reject do |id|
|
|
2815
|
+
Array(by_agent[id]).any? { |s| (t = utc_time(s.updated_at)) && t >= cold_floor }
|
|
2816
|
+
end : []
|
|
2817
|
+
end
|
|
2818
|
+
|
|
2819
|
+
# Every record of a session/task store: one bulk read when the store offers
|
|
2820
|
+
# `all`, otherwise (an injected store with only each_id/find) one read per id.
|
|
2821
|
+
def every_record(store)
|
|
2822
|
+
store.respond_to?(:all) ? store.all : store.each_id.filter_map { |id| store.find(id) }
|
|
2823
|
+
end
|
|
2824
|
+
|
|
2825
|
+
# Sessions touched in the last HOME_WINDOW_DAYS. A store that keeps session
|
|
2826
|
+
# stats gives all of them (a few bytes each) and its session ids: stats of a
|
|
2827
|
+
# session that is gone are ignored, and the ids are the conversations total.
|
|
2828
|
+
# Any other store (an injected double) is read whole and filtered.
|
|
2829
|
+
# ponytail: reads every session's stats; a per-day index if the count of
|
|
2830
|
+
# sessions ever makes that slow.
|
|
2831
|
+
def window_sessions(now)
|
|
2114
2832
|
store = insika[:session_store]
|
|
2115
2833
|
return [] unless store
|
|
2116
2834
|
|
|
2117
|
-
|
|
2835
|
+
floor = now - (HOME_WINDOW_DAYS * 86_400)
|
|
2836
|
+
in_window = ->(s) { (t = utc_time(s.updated_at)) && t >= floor }
|
|
2837
|
+
return every_record(store).select(&in_window) unless store.respond_to?(:all_stats)
|
|
2838
|
+
|
|
2839
|
+
ids = store.each_id.to_set
|
|
2840
|
+
@session_total = ids.size
|
|
2841
|
+
session_stats.select { |s| ids.include?(s.id) && in_window.call(s) }
|
|
2842
|
+
end
|
|
2843
|
+
|
|
2844
|
+
# A session's message count, whether the home holds the session or its stats.
|
|
2845
|
+
def message_count(session)
|
|
2846
|
+
session.respond_to?(:message_count) ? session.message_count : Array(session.messages).size
|
|
2847
|
+
end
|
|
2848
|
+
|
|
2849
|
+
def session_total
|
|
2850
|
+
return @session_total if @session_total
|
|
2851
|
+
|
|
2852
|
+
store = insika[:session_store]
|
|
2853
|
+
return 0 unless store
|
|
2854
|
+
|
|
2855
|
+
# a paging store counts by key (a Struct double answers #count too, hence #recent)
|
|
2856
|
+
store.respond_to?(:recent) && store.respond_to?(:count) ? store.count : every_record(store).size
|
|
2118
2857
|
end
|
|
2119
2858
|
|
|
2120
2859
|
def parse_time(str)
|
|
@@ -2161,7 +2900,7 @@ end
|
|
|
2161
2900
|
def message_delta(sessions, now)
|
|
2162
2901
|
today = now.to_date
|
|
2163
2902
|
sum = ->(date) do
|
|
2164
|
-
sessions.sum { |s| (t = utc_time(s.updated_at)) && t.to_date == date ?
|
|
2903
|
+
sessions.sum { |s| (t = utc_time(s.updated_at)) && t.to_date == date ? message_count(s) : 0 }
|
|
2165
2904
|
end
|
|
2166
2905
|
sum.call(today) - sum.call(today - 1)
|
|
2167
2906
|
end
|
|
@@ -2171,8 +2910,9 @@ end
|
|
|
2171
2910
|
# anything else would otherwise bucket by its own zone. `getutc`, not `utc`:
|
|
2172
2911
|
# the latter mutates the receiver.
|
|
2173
2912
|
def utc_time(value)
|
|
2174
|
-
|
|
2175
|
-
|
|
2913
|
+
# one parse per stamp per request: the home reads tens of thousands of them,
|
|
2914
|
+
# several times each (window, activity, trend)
|
|
2915
|
+
(@utc_times ||= {}).fetch(value) { @utc_times[value] = parse_time(value)&.getutc }
|
|
2176
2916
|
end
|
|
2177
2917
|
|
|
2178
2918
|
# --- History -------------------------------------------------------------
|
|
@@ -2183,8 +2923,9 @@ end
|
|
|
2183
2923
|
store = insika[:session_store]
|
|
2184
2924
|
return [] unless store
|
|
2185
2925
|
|
|
2186
|
-
store.
|
|
2187
|
-
|
|
2926
|
+
return store.recent(limit) if store.respond_to?(:recent)
|
|
2927
|
+
|
|
2928
|
+
every_record(store).sort_by { |s| s.updated_at.to_s }.reverse.first(limit)
|
|
2188
2929
|
end
|
|
2189
2930
|
|
|
2190
2931
|
# Compact relative age ("just now", "9min", "3h", "2d") for a timestamp string.
|
|
@@ -2202,6 +2943,19 @@ end
|
|
|
2202
2943
|
"#{hrs / 24}d"
|
|
2203
2944
|
end
|
|
2204
2945
|
|
|
2946
|
+
# Same pattern as markdown.js TAG_TOKEN, on escaped text.
|
|
2947
|
+
TAG_TOKEN = %r{</?[a-z][\w-]*(?:\s+[\w-]+(?:=(?:".*?"|'.*?'|[\w-]+))?)*\s*/?>}i
|
|
2948
|
+
|
|
2949
|
+
# Escaped text with its markup tags (<carrossel/>, <vitrine formato="x">)
|
|
2950
|
+
# wrapped like markdown.js markTags does, for text not rendered as Markdown.
|
|
2951
|
+
# With `limit`: a one-line preview, cut without splitting a tag (a half tag
|
|
2952
|
+
# "<ve" would read as plain text).
|
|
2953
|
+
def mark_tags(text, limit = nil)
|
|
2954
|
+
s = text.to_s
|
|
2955
|
+
s = s.gsub(/\s+/, " ").strip[0, limit].sub(/<[^>]*\z/, "").rstrip if limit
|
|
2956
|
+
Rack::Utils.escape_html(s).gsub(TAG_TOKEN) { %(<span class="tag-token">#{_1}</span>) }
|
|
2957
|
+
end
|
|
2958
|
+
|
|
2205
2959
|
def session_preview(session)
|
|
2206
2960
|
last = Array(session.messages).reverse.find { |m| %w[user assistant].include?(m["role"]) }
|
|
2207
2961
|
# Structured content previews as JSON (message_content), never #inspect —
|
|
@@ -2213,14 +2967,19 @@ end
|
|
|
2213
2967
|
|
|
2214
2968
|
# The agent config tab's groups, in sidebar order. The view renders the
|
|
2215
2969
|
# same keys; a bogus ?cfg= falls back to the first group.
|
|
2216
|
-
CONFIG_SECTIONS = %w[model guardrails grounding funnel followups schedules distill harvest refinement budget_rel routing advanced].freeze
|
|
2970
|
+
CONFIG_SECTIONS = %w[model retrieval guardrails grounding funnel followups schedules distill harvest refinement budget_rel routing advanced].freeze
|
|
2217
2971
|
|
|
2218
|
-
SETTINGS_SECTIONS = %w[general models edge evals llm demo].freeze
|
|
2972
|
+
SETTINGS_SECTIONS = %w[general models edge burst agents evals llm demo].freeze
|
|
2219
2973
|
def render_settings
|
|
2220
2974
|
store = insika[:settings_store]
|
|
2221
2975
|
@settings = store ? store.get : Insika::SettingsStore::DEFAULTS
|
|
2222
2976
|
@providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
|
|
2223
2977
|
@section = SETTINGS_SECTIONS.include?(request.params["s"]) ? request.params["s"] : "general"
|
|
2978
|
+
if @section == "agents"
|
|
2979
|
+
@agent = agent_defaults_profile
|
|
2980
|
+
@guardrails = @agent.guardrails || {}
|
|
2981
|
+
@agent_defaults_form = true
|
|
2982
|
+
end
|
|
2224
2983
|
@persistence = insika[:config][:persistence].to_s
|
|
2225
2984
|
view("settings")
|
|
2226
2985
|
end
|
|
@@ -2314,11 +3073,190 @@ end
|
|
|
2314
3073
|
# --- Chats -----------------------------------------------------
|
|
2315
3074
|
|
|
2316
3075
|
def render_chats
|
|
2317
|
-
|
|
2318
|
-
@sessions = agent_sessions(recent_sessions(limit: 100), @agent)
|
|
3076
|
+
setup_chat_list(request.params)
|
|
2319
3077
|
view("chats")
|
|
2320
3078
|
end
|
|
2321
3079
|
|
|
3080
|
+
CHATS_PAGE = 100
|
|
3081
|
+
|
|
3082
|
+
# --- Live floor ---------------------------------------------
|
|
3083
|
+
|
|
3084
|
+
LIVE_CARDS = 24
|
|
3085
|
+
|
|
3086
|
+
# The live floor's baseline: the latest conversations, newest first.
|
|
3087
|
+
# ?agent= narrows the cards to one agent: newest first over the stats (a
|
|
3088
|
+
# few bytes each), then only the shown sessions are read whole.
|
|
3089
|
+
def render_live
|
|
3090
|
+
@agent = presence(request.params["agent"])
|
|
3091
|
+
store = insika[:session_store]
|
|
3092
|
+
@sessions = if @agent && store.respond_to?(:all_stats)
|
|
3093
|
+
session_stats.select { |s| session_agent(s) == @agent }
|
|
3094
|
+
.sort_by { |s| s.updated_at.to_s }.last(LIVE_CARDS).reverse
|
|
3095
|
+
.filter_map { |s| store.find(s.id) }
|
|
3096
|
+
else
|
|
3097
|
+
agent_sessions(recent_sessions(limit: LIVE_CARDS), @agent)
|
|
3098
|
+
end
|
|
3099
|
+
view("live")
|
|
3100
|
+
end
|
|
3101
|
+
|
|
3102
|
+
# The Chats master list, shared by the index and the session viewer. Agent
|
|
3103
|
+
# and id/customer search run over EVERY session's stats (a few bytes each), newest
|
|
3104
|
+
# first; only the rows shown are read whole. ?limit= grows the page.
|
|
3105
|
+
def setup_chat_list(params)
|
|
3106
|
+
@agent = presence(params["agent"])
|
|
3107
|
+
@query = presence(params["q"].to_s.strip)
|
|
3108
|
+
@status = CHAT_STATUSES.include?(params["status"]) ? params["status"] : nil
|
|
3109
|
+
@limit = [params["limit"].to_i, CHATS_PAGE].max
|
|
3110
|
+
store = insika[:session_store]
|
|
3111
|
+
return @sessions = [] unless store
|
|
3112
|
+
|
|
3113
|
+
all = store.respond_to?(:all_stats) ? session_stats : every_record(store)
|
|
3114
|
+
@chats_all = all.size
|
|
3115
|
+
# the agents column: one row per agent with conversations, latest first
|
|
3116
|
+
@chat_agents = all.group_by { |s| session_agent(s) }.except("")
|
|
3117
|
+
.map { |id, list| [id, list.size, list.map { |s| s.updated_at.to_s }.max] }
|
|
3118
|
+
.sort_by { |row| row.last }.reverse
|
|
3119
|
+
rows = agent_sessions(all, @agent)
|
|
3120
|
+
rows = rows.select { |s| chat_matches?(s, @query.downcase) } if @query
|
|
3121
|
+
@status_counts = CHAT_STATUSES.to_h { |st| [st, rows.count { |s| chat_status?(s, st) }] }
|
|
3122
|
+
rows = rows.select { |s| chat_status?(s, @status) } if @status
|
|
3123
|
+
rows = rows.sort_by { |s| s.updated_at.to_s }.reverse
|
|
3124
|
+
@chats_total = rows.size
|
|
3125
|
+
@sessions = rows.first(@limit).filter_map { |s| store.find(s.id) }
|
|
3126
|
+
@more = [CHATS_PAGE, rows.size - @limit].min
|
|
3127
|
+
end
|
|
3128
|
+
|
|
3129
|
+
# The Chats search: the session id, the customer key, name or phone.
|
|
3130
|
+
def chat_matches?(stat, query)
|
|
3131
|
+
vars = stat.vars.is_a?(Hash) ? stat.vars : {}
|
|
3132
|
+
[stat.id, vars["customer"], *vars.values_at(*Insika::SessionStore::CUSTOMER_LABELS)]
|
|
3133
|
+
.any? { |v| v.to_s.downcase.include?(query) }
|
|
3134
|
+
end
|
|
3135
|
+
|
|
3136
|
+
# One turn's tool calls as waterfall bars: each entry's start is its
|
|
3137
|
+
# stamp minus its duration; x/w are percent of the turn's span. Entries
|
|
3138
|
+
# without a parseable stamp are left out. -> [{ tool:, ok:, ms:, x:, w: }]
|
|
3139
|
+
# The skill a load_skill trace entry loaded, for the link to its page; nil for
|
|
3140
|
+
# any other tool. Args arrive as a Hash or as the JSON text the model sent.
|
|
3141
|
+
def loaded_skill_name(entry)
|
|
3142
|
+
return nil unless entry["tool"].to_s == "load_skill"
|
|
3143
|
+
|
|
3144
|
+
args = entry["args"]
|
|
3145
|
+
args = JSON.parse(args.to_s) rescue nil unless args.is_a?(Hash)
|
|
3146
|
+
name = args.is_a?(Hash) ? args["name"].to_s : ""
|
|
3147
|
+
name.empty? ? nil : name
|
|
3148
|
+
end
|
|
3149
|
+
|
|
3150
|
+
def trace_waterfall(entries)
|
|
3151
|
+
rows = entries.filter_map do |t|
|
|
3152
|
+
finish = utc_time(t["at"]) or next
|
|
3153
|
+
ms = t["ms"].to_f
|
|
3154
|
+
{ tool: t["tool"], ok: t["ok"] && !t["gate"], ms: t["ms"], start: finish - (ms / 1000), finish: finish }
|
|
3155
|
+
end
|
|
3156
|
+
return [] if rows.empty?
|
|
3157
|
+
|
|
3158
|
+
first = rows.map { _1[:start] }.min
|
|
3159
|
+
span = [rows.map { _1[:finish] }.max - first, 0.001].max
|
|
3160
|
+
rows.map do |r|
|
|
3161
|
+
x = ((r[:start] - first) / span * 100).round(2)
|
|
3162
|
+
r.slice(:tool, :ok, :ms).merge(x: x, w: [((r[:finish] - r[:start]) / span * 100).round(2), 0.8].max.clamp(0, 100 - x))
|
|
3163
|
+
end
|
|
3164
|
+
end
|
|
3165
|
+
|
|
3166
|
+
# "Why did it say that?" — one entry per reply, keyed by the index of the
|
|
3167
|
+
# turn's LAST assistant message with text. Messages persisted with a task_id
|
|
3168
|
+
# split where it changes; older ones guess: a turn opens on a user message
|
|
3169
|
+
# that is not steered (a steered one joins the running turn). The context
|
|
3170
|
+
# entry matches by task_id (older ones: by time, it is written before the
|
|
3171
|
+
# turn's messages are persisted), tool traces by call id (the persisted
|
|
3172
|
+
# tool_calls carry it). -> { index => { context:, tools:, calls:, flags: } }
|
|
3173
|
+
def why_panels(messages, context_traces, tool_traces)
|
|
3174
|
+
by_call = tool_traces.to_h { [_1["call_id"].to_s, _1] }
|
|
3175
|
+
pending = context_traces.sort_by { _1["at"].to_s }
|
|
3176
|
+
turns = messages.each_index.slice_before do |i|
|
|
3177
|
+
task, prev = messages[i]["task_id"].to_s, i.positive? ? messages[i - 1]["task_id"].to_s : ""
|
|
3178
|
+
next task != prev unless task.empty? || prev.empty?
|
|
3179
|
+
|
|
3180
|
+
messages[i]["role"].to_s == "user" && !(i.positive? && messages[i - 1]["role"].to_s == "tool")
|
|
3181
|
+
end
|
|
3182
|
+
turns.each_with_object({}) do |idx, acc|
|
|
3183
|
+
msgs = messages.values_at(*idx)
|
|
3184
|
+
at = msgs.first["at"].to_s
|
|
3185
|
+
context = nil
|
|
3186
|
+
if (task_id = presence(msgs.first["task_id"].to_s))
|
|
3187
|
+
# exact: the turn's own trace (the last one, if the task suspended and resumed)
|
|
3188
|
+
context = context_traces.select { _1["task_id"].to_s == task_id }.max_by { _1["at"].to_s }
|
|
3189
|
+
pending.reject! { _1["at"].to_s <= at }
|
|
3190
|
+
else
|
|
3191
|
+
# older messages carry no task_id: the latest trace recorded before the turn was saved
|
|
3192
|
+
context = pending.shift while !at.empty? && pending.first && pending.first["at"].to_s <= at
|
|
3193
|
+
end
|
|
3194
|
+
reply = idx.reverse.find { |i| messages[i]["role"].to_s == "assistant" && !messages[i]["content"].to_s.empty? } or next
|
|
3195
|
+
calls = msgs.flat_map { |m| Array(m["tool_calls"]) }
|
|
3196
|
+
acc[reply] = { context: context, tools: by_call.values_at(*calls.map { _1["id"].to_s }).compact,
|
|
3197
|
+
calls: calls.map { _1["name"].to_s }.uniq,
|
|
3198
|
+
flags: msgs.select { _1["role"].to_s == "user" }.flat_map { Array(_1["guardrail_flags"]) } }
|
|
3199
|
+
end
|
|
3200
|
+
end
|
|
3201
|
+
|
|
3202
|
+
# A case prefilled from one reply (/studio/evals?session=&reply=): the
|
|
3203
|
+
# customer's messages up to it as `turns`, the transcript as the `reference`,
|
|
3204
|
+
# the tools its turn called as `tools_called`. Nothing is written here — the
|
|
3205
|
+
# operator adds the rubric and saves through the same POST as any case.
|
|
3206
|
+
def session_case_yaml(sid, reply)
|
|
3207
|
+
return nil if reply.negative?
|
|
3208
|
+
|
|
3209
|
+
session = insika[:session_store]&.find(sid) or return nil
|
|
3210
|
+
msgs = Array(session.messages).first(reply + 1)
|
|
3211
|
+
panel = why_panels(msgs, [], [])[reply] or return nil
|
|
3212
|
+
agent = presence(session_agent(session)) || "my-agent"
|
|
3213
|
+
said = msgs.select { %w[user assistant].include?(_1["role"].to_s) && !_1["content"].to_s.empty? }
|
|
3214
|
+
expect = { "tools_called" => panel[:calls], "must_not" => %w[pii_leak tool_error], "rubric" => "" }
|
|
3215
|
+
expect.delete("tools_called") if panel[:calls].empty?
|
|
3216
|
+
YAML.dump({ "id" => "#{agent}-#{sid[0, 8]}-#{reply}", "agent" => agent,
|
|
3217
|
+
"turns" => said.select { _1["role"] == "user" }.map { { "user" => message_content(_1["content"]).to_s } },
|
|
3218
|
+
"reference" => { "source" => "session #{sid}",
|
|
3219
|
+
# origin keeps an operator-typed reply from being judged as the model's
|
|
3220
|
+
"messages" => said.map do |m|
|
|
3221
|
+
{ "role" => m["role"], "text" => message_content(m["content"]).to_s,
|
|
3222
|
+
"origin" => Insika::MessageOrigin.origin_of(m) }.compact
|
|
3223
|
+
end },
|
|
3224
|
+
"expect" => expect })
|
|
3225
|
+
end
|
|
3226
|
+
|
|
3227
|
+
CHAT_STATUSES = %w[active flagged].freeze
|
|
3228
|
+
|
|
3229
|
+
# The Chats status tabs: "active" = touched in the last 5 minutes (the
|
|
3230
|
+
# presence dot), "flagged" = a guardrail flagged one of its messages and
|
|
3231
|
+
# nobody resolved it yet.
|
|
3232
|
+
def chat_status?(stat, status)
|
|
3233
|
+
case status
|
|
3234
|
+
when "active" then session_fresh?(stat)
|
|
3235
|
+
when "flagged" then session_flagged?(stat)
|
|
3236
|
+
else true
|
|
3237
|
+
end
|
|
3238
|
+
end
|
|
3239
|
+
|
|
3240
|
+
def session_flagged?(session)
|
|
3241
|
+
if session.respond_to?(:flagged) then session.flagged.positive?
|
|
3242
|
+
else Array(session.respond_to?(:messages) ? session.messages : nil).any? { |m| m.is_a?(Hash) && Array(m["guardrail_flags"]).any? { Insika::SessionStore.open_flag?(_1) } }
|
|
3243
|
+
end
|
|
3244
|
+
end
|
|
3245
|
+
|
|
3246
|
+
# Conversations of the same customer key (stats only) — the session's
|
|
3247
|
+
# Customer card.
|
|
3248
|
+
def customer_chat_count(customer)
|
|
3249
|
+
store = insika[:session_store]
|
|
3250
|
+
return 1 if customer.to_s.empty? || !store.respond_to?(:all_stats)
|
|
3251
|
+
|
|
3252
|
+
[session_stats.count { |s| s.vars["customer"].to_s == customer }, 1].max
|
|
3253
|
+
end
|
|
3254
|
+
|
|
3255
|
+
def chats_path(**extra)
|
|
3256
|
+
query = Rack::Utils.build_query({ agent: @agent, q: @query, status: @status, **extra }.compact)
|
|
3257
|
+
query.empty? ? "/studio/chats" : "/studio/chats?#{query}"
|
|
3258
|
+
end
|
|
3259
|
+
|
|
2322
3260
|
# --- Customers ----------------------------------
|
|
2323
3261
|
|
|
2324
3262
|
# The index: every CUSTOMER memory cell, grouped by tenant. The caller
|
|
@@ -2328,15 +3266,7 @@ end
|
|
|
2328
3266
|
# reads per conversation on every render).
|
|
2329
3267
|
def render_customers
|
|
2330
3268
|
mem = insika[:memory_store]
|
|
2331
|
-
|
|
2332
|
-
@agent = presence(request.params["agent"])
|
|
2333
|
-
@rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
|
|
2334
|
-
{ cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
|
|
2335
|
-
end : []
|
|
2336
|
-
# ?agent= narrows to one tenant — the memory scope is the agent (the
|
|
2337
|
-
# agent-memory tab writes under tenant = agent id).
|
|
2338
|
-
@rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
|
|
2339
|
-
@by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
|
|
3269
|
+
setup_customer_rows(mem)
|
|
2340
3270
|
view("customers")
|
|
2341
3271
|
end
|
|
2342
3272
|
|
|
@@ -2348,28 +3278,150 @@ end
|
|
|
2348
3278
|
@facts = mem ? mem.facts(tenant: cell[:tenant], customer: cell[:customer]) : []
|
|
2349
3279
|
@notes = mem ? mem.notes(tenant: cell[:tenant], customer: cell[:customer], limit: 50) : []
|
|
2350
3280
|
@audit = insika[:memory_audit_store]&.for_cell(scope) || []
|
|
2351
|
-
|
|
3281
|
+
# master column: the detail page IS the shell when visited directly;
|
|
3282
|
+
# only a frame request renders the pane alone.
|
|
3283
|
+
setup_customer_rows(mem)
|
|
3284
|
+
if turbo_frame?("customer-detail")
|
|
3285
|
+
render("customer", locals: { frame_only: true }, layout: false)
|
|
3286
|
+
else
|
|
3287
|
+
view("customer", locals: { frame_only: false })
|
|
3288
|
+
end
|
|
2352
3289
|
end
|
|
2353
3290
|
|
|
2354
|
-
def customer_path(scope)
|
|
3291
|
+
def customer_path(scope, agent: nil)
|
|
2355
3292
|
# escape_path, not escape: the route decodes with unescape_path, which
|
|
2356
3293
|
# does not turn '+' back into a space (escape's form-encoding would).
|
|
2357
|
-
"/studio/customers/#{Rack::Utils.escape_path(scope)}"
|
|
3294
|
+
path = "/studio/customers/#{Rack::Utils.escape_path(scope)}"
|
|
3295
|
+
agent ? "#{path}?agent=#{Rack::Utils.escape(agent)}" : path
|
|
3296
|
+
end
|
|
3297
|
+
|
|
3298
|
+
# Shared helper for Customers index and detail: loads all customer cells,
|
|
3299
|
+
# counts facts per cell, filters by agent if ?agent= is present, and
|
|
3300
|
+
# groups by tenant. Sets @rows and @by_tenant.
|
|
3301
|
+
def setup_customer_rows(mem)
|
|
3302
|
+
agent_ids = insika[:profile_source] ? insika[:profile_source].all.map(&:id) : []
|
|
3303
|
+
@rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
|
|
3304
|
+
{ cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
|
|
3305
|
+
end : []
|
|
3306
|
+
@agent = presence(request.params["agent"])
|
|
3307
|
+
@rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
|
|
3308
|
+
@by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
|
|
2358
3309
|
end
|
|
2359
3310
|
|
|
2360
3311
|
# Tasks & Approvals --------------------------------
|
|
2361
3312
|
|
|
3313
|
+
def render_models
|
|
3314
|
+
@period = request.params["period"]
|
|
3315
|
+
@agent = presence(request.params["agent"])
|
|
3316
|
+
@provider = presence(request.params["provider"])
|
|
3317
|
+
@model = presence(request.params["model"])
|
|
3318
|
+
# Dates count only for "Custom range" (or a bare ?from&to link): picking a fixed period wins.
|
|
3319
|
+
range = model_custom_range(request.params["from"], request.params["to"]) if [nil, "custom"].include?(@period)
|
|
3320
|
+
@range_from, @range_to = range&.map { |t| t.strftime("%Y-%m-%d") }
|
|
3321
|
+
args = { period: @period || "7d", agent: @agent, provider: @provider, model: @model }
|
|
3322
|
+
args.merge!(from: range[0], to: range[1] + 86_400) if range # `to` is inclusive: through the end of that day
|
|
3323
|
+
@report = insika[:model_metrics_store]&.report(**args)
|
|
3324
|
+
@period = @report ? @report["period"] : (%w[24h 7d 30d].include?(@period) ? @period : "7d")
|
|
3325
|
+
view("models")
|
|
3326
|
+
end
|
|
3327
|
+
|
|
3328
|
+
MODEL_RANGE_MAX_DAYS = 366
|
|
3329
|
+
|
|
3330
|
+
# [from, to] as UTC midnights from two YYYY-MM-DD fields; nil when either is
|
|
3331
|
+
# missing or malformed, reversed, or wider than a year (the fixed period applies).
|
|
3332
|
+
def model_custom_range(from, to)
|
|
3333
|
+
first, last = [from, to].map { |d| Time.strptime("#{d} UTC", "%Y-%m-%d %Z").utc }
|
|
3334
|
+
return nil if first > last || (last - first) / 86_400 > MODEL_RANGE_MAX_DAYS
|
|
3335
|
+
|
|
3336
|
+
[first, last]
|
|
3337
|
+
rescue ArgumentError, TypeError
|
|
3338
|
+
nil
|
|
3339
|
+
end
|
|
3340
|
+
|
|
3341
|
+
# The x-axis/table label follows the bucket size, custom ranges included.
|
|
3342
|
+
def model_interval_label(step)
|
|
3343
|
+
step = step.to_i
|
|
3344
|
+
return "Hourly" if step <= 3600
|
|
3345
|
+
return "6-hour intervals" if step <= 6 * 3600
|
|
3346
|
+
return "Daily" if step <= 86_400
|
|
3347
|
+
|
|
3348
|
+
"#{(step / 86_400.0).round}-day intervals"
|
|
3349
|
+
end
|
|
3350
|
+
|
|
3351
|
+
# Money for the home: cents above a dollar, 4 significant digits below it,
|
|
3352
|
+
# so a day of cheap turns never reads as $0.00.
|
|
3353
|
+
def usd(value)
|
|
3354
|
+
return "—" if value.nil?
|
|
3355
|
+
|
|
3356
|
+
value.abs >= 1 || value.zero? ? format("$%.2f", value) : format("$%.4g", value)
|
|
3357
|
+
end
|
|
3358
|
+
|
|
3359
|
+
def model_chart_value(value, unit)
|
|
3360
|
+
return "—" if value.nil?
|
|
3361
|
+
|
|
3362
|
+
case unit
|
|
3363
|
+
when "USD" then format("$%.6f", value)
|
|
3364
|
+
when "ms" then format("%.2f ms", value)
|
|
3365
|
+
else value.to_i.to_s
|
|
3366
|
+
end
|
|
3367
|
+
end
|
|
3368
|
+
|
|
3369
|
+
def model_chart_tick(value, unit)
|
|
3370
|
+
return "$0" if unit == "USD" && value.zero?
|
|
3371
|
+
return format("$%.4g", value) if unit == "USD"
|
|
3372
|
+
return format("%.1f s", value / 1000.0) if unit == "ms" && value >= 1000
|
|
3373
|
+
return format("%.3g ms", value) if unit == "ms"
|
|
3374
|
+
|
|
3375
|
+
format("%.3g", value)
|
|
3376
|
+
end
|
|
3377
|
+
|
|
3378
|
+
def model_chart_paths(points)
|
|
3379
|
+
points.slice_when { |left, right| left.nil? || right.nil? }.filter_map do |segment|
|
|
3380
|
+
values = segment.compact
|
|
3381
|
+
next if values.empty?
|
|
3382
|
+
|
|
3383
|
+
"M" + values.map { |x, y| format("%.2f %.2f", x, y) }.join(" L")
|
|
3384
|
+
end
|
|
3385
|
+
end
|
|
3386
|
+
|
|
2362
3387
|
# Task list, most-recently-updated first. Empty-state if no store was injected.
|
|
2363
3388
|
# `?agent=` narrows to one agent (the task's command payload stamps it).
|
|
3389
|
+
TASKS_PER_PAGE = 50
|
|
3390
|
+
# ponytail: the agent filter scans this many recent tasks (the payload holds
|
|
3391
|
+
# the agent, not the key); an index by agent if older matches ever matter.
|
|
3392
|
+
TASKS_AGENT_SCAN = 5_000
|
|
3393
|
+
|
|
3394
|
+
# One page of tasks, most recently updated first. Reads one page more than it
|
|
3395
|
+
# shows to know whether there is a next one, never the whole store.
|
|
2364
3396
|
def render_tasks
|
|
2365
3397
|
store = insika[:task_store]
|
|
2366
3398
|
@agent = presence(request.params["agent"])
|
|
2367
|
-
@
|
|
2368
|
-
|
|
2369
|
-
|
|
3399
|
+
@page = request.params["page"].to_i.clamp(1, 1_000_000)
|
|
3400
|
+
offset = (@page - 1) * TASKS_PER_PAGE
|
|
3401
|
+
rows =
|
|
3402
|
+
if store.nil? then []
|
|
3403
|
+
elsif @agent
|
|
3404
|
+
recent_tasks(store, TASKS_AGENT_SCAN, 0).select { |t| task_agent(t) == @agent }
|
|
3405
|
+
.drop(offset).first(TASKS_PER_PAGE + 1)
|
|
3406
|
+
else
|
|
3407
|
+
recent_tasks(store, TASKS_PER_PAGE + 1, offset)
|
|
3408
|
+
end
|
|
3409
|
+
@more = rows.size > TASKS_PER_PAGE
|
|
3410
|
+
@tasks = rows.first(TASKS_PER_PAGE)
|
|
2370
3411
|
view("tasks")
|
|
2371
3412
|
end
|
|
2372
3413
|
|
|
3414
|
+
def recent_tasks(store, limit, offset)
|
|
3415
|
+
return store.recent(limit, offset: offset) if store.respond_to?(:recent)
|
|
3416
|
+
|
|
3417
|
+
every_record(store).sort_by { |t| t.updated_at.to_s }.reverse.drop(offset).first(limit)
|
|
3418
|
+
end
|
|
3419
|
+
|
|
3420
|
+
def tasks_page_path(page)
|
|
3421
|
+
query = { "agent" => @agent, "page" => (page if page > 1) }.compact
|
|
3422
|
+
query.empty? ? "/studio/tasks" : "/studio/tasks?#{Rack::Utils.build_query(query)}"
|
|
3423
|
+
end
|
|
3424
|
+
|
|
2373
3425
|
def task_agent(task)
|
|
2374
3426
|
command = task.command
|
|
2375
3427
|
command.is_a?(Hash) ? command.dig("payload", "agent").to_s : ""
|
|
@@ -2378,8 +3430,9 @@ end
|
|
|
2378
3430
|
# Task detail: @task is set by the route. Adds the open approvals for this task
|
|
2379
3431
|
# and its latest checkpoint (both degrade to empty when the store is absent).
|
|
2380
3432
|
def render_task_detail(id)
|
|
2381
|
-
@pending = insika[:pending_action_store] ? insika[:pending_action_store].open_for(id) : []
|
|
3433
|
+
@pending = insika[:pending_action_store] ? insika[:pending_action_store].open_for(id, kind: Insika::PendingActionStore::OPERATOR) : []
|
|
2382
3434
|
@checkpoint = insika[:checkpoint_store]&.latest(id)
|
|
3435
|
+
@llm_trace = insika[:llm_trace_store]&.for_task(id) || { "entries" => [], "truncated" => false }
|
|
2383
3436
|
view("task")
|
|
2384
3437
|
end
|
|
2385
3438
|
|
|
@@ -2390,7 +3443,7 @@ end
|
|
|
2390
3443
|
pstore = insika[:pending_action_store]
|
|
2391
3444
|
tstore = insika[:task_store]
|
|
2392
3445
|
@agent = presence(request.params["agent"])
|
|
2393
|
-
@approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
|
|
3446
|
+
@approvals = pstore ? pstore.all_open(kind: Insika::PendingActionStore::OPERATOR).map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
|
|
2394
3447
|
if @agent
|
|
2395
3448
|
@approvals = @approvals.select { |a| task_agent(a[:task]) == @agent if a[:task] }
|
|
2396
3449
|
end
|
|
@@ -2412,6 +3465,8 @@ def render_evals
|
|
|
2412
3465
|
wanted = presence(request.params["id"])
|
|
2413
3466
|
@case = wanted && @cases.find { |g| g.id == wanted }
|
|
2414
3467
|
@case_yaml = @case ? golden_yaml(@case) : nil
|
|
3468
|
+
from = presence(request.params["session"])
|
|
3469
|
+
@case_yaml ||= session_case_yaml(utf8(from), request.params["reply"].to_i) if from
|
|
2415
3470
|
view("evals")
|
|
2416
3471
|
end
|
|
2417
3472
|
|
|
@@ -2433,19 +3488,43 @@ end
|
|
|
2433
3488
|
|
|
2434
3489
|
# Refinement -----------------------------
|
|
2435
3490
|
|
|
2436
|
-
# The
|
|
2437
|
-
#
|
|
2438
|
-
#
|
|
3491
|
+
# The index: master pane lists the scoped agent's run history; the detail
|
|
3492
|
+
# pane defaults to the run that most needs a look — one awaiting a human
|
|
3493
|
+
# answer if there is one (at most one is possible: gating requires a
|
|
3494
|
+
# `completed` run and there is one lifecycle per record), else the most
|
|
3495
|
+
# recent terminal run. Empty-state when no store was injected or the
|
|
3496
|
+
# agent has never been run — the page is the invitation to run it, so
|
|
3497
|
+
# there is nothing to hide behind a nil.
|
|
2439
3498
|
def render_refinement
|
|
3499
|
+
refinement_rows(request.params["agent"])
|
|
3500
|
+
@selected = @runs.find(&:awaiting_approval?) || @runs.find(&:terminal?)
|
|
3501
|
+
view("refinement")
|
|
3502
|
+
end
|
|
3503
|
+
|
|
3504
|
+
# GET /studio/refinement/:id — one run, whatever its status, with the
|
|
3505
|
+
# master pane scoped to ITS agent (a deep link may land on a run that
|
|
3506
|
+
# isn't the current default selection).
|
|
3507
|
+
def render_refinement_detail(id)
|
|
3508
|
+
@selected = insika[:refinement_store]&.find(id)
|
|
3509
|
+
next_404 unless @selected
|
|
3510
|
+
|
|
3511
|
+
refinement_rows(@selected.agent_id)
|
|
3512
|
+
if turbo_frame?("refinement-detail")
|
|
3513
|
+
render("refinement_detail", locals: { frame_only: true }, layout: false)
|
|
3514
|
+
else
|
|
3515
|
+
view("refinement_detail", locals: { frame_only: false })
|
|
3516
|
+
end
|
|
3517
|
+
end
|
|
3518
|
+
|
|
3519
|
+
# Shared read for both Refinement routes: every agent id (the Run form's
|
|
3520
|
+
# target and the scope filter's options), the ?agent= scope (falling back
|
|
3521
|
+
# to the first agent so there is always something to show), and that
|
|
3522
|
+
# agent's run history, most recent first.
|
|
3523
|
+
def refinement_rows(requested_agent)
|
|
2440
3524
|
@agents = insika[:profile_source].ids.sort
|
|
2441
|
-
@agent = presence(
|
|
3525
|
+
@agent = presence(requested_agent) || @agents.first
|
|
2442
3526
|
store = insika[:refinement_store]
|
|
2443
3527
|
@runs = @agent && store ? store.for_agent(@agent, limit: 10) : []
|
|
2444
|
-
@run = @runs.find(&:terminal?)
|
|
2445
|
-
# The one run that owes this agent a human answer. At most one is possible:
|
|
2446
|
-
# gating requires a `completed` run and there is one lifecycle per record.
|
|
2447
|
-
@proposal = @runs.find(&:awaiting_approval?)
|
|
2448
|
-
view("refinement")
|
|
2449
3528
|
end
|
|
2450
3529
|
|
|
2451
3530
|
# --- Parity -------------------------------------------------
|
|
@@ -2587,6 +3666,9 @@ end
|
|
|
2587
3666
|
p = request.params["period"].to_i
|
|
2588
3667
|
@period = p.positive? ? p : 30
|
|
2589
3668
|
@agent = presence(request.params["agent"])
|
|
3669
|
+
# ?drop=<stage>&tenant=: the "where conversations stop" drill of ONE card
|
|
3670
|
+
@drop = presence(request.params["drop"])
|
|
3671
|
+
@drop_tenant = presence(request.params["tenant"])
|
|
2590
3672
|
profiles = profiles.select { |p| p.id == @agent } if @agent
|
|
2591
3673
|
@stores = profiles.filter_map do |p|
|
|
2592
3674
|
decl = Insika::FunnelDeclaration.parse(p.funnel)
|
|
@@ -2625,6 +3707,32 @@ end
|
|
|
2625
3707
|
# a link, never a copy: the excerpt is read from the session at request
|
|
2626
3708
|
# time); the mutations dispatch :resolve_proposal on the bus.
|
|
2627
3709
|
def render_facts
|
|
3710
|
+
facts_queues
|
|
3711
|
+
# The landing selection: the first pending item (the operator's next
|
|
3712
|
+
# thing to review) — falling back to stale, then a resolved one, so the
|
|
3713
|
+
# pane is never blank while ANY proposal exists. `.empty` only when the
|
|
3714
|
+
# whole queue (pending + stale + recent) is.
|
|
3715
|
+
@selected = @pending.first || @stale.first || @recent.first
|
|
3716
|
+
view("facts")
|
|
3717
|
+
end
|
|
3718
|
+
|
|
3719
|
+
# GET /studio/facts/:id — a specific proposal, whatever its status. Reads
|
|
3720
|
+
# the store directly (a resolved fact past the `resolved(limit: 20)` window
|
|
3721
|
+
# still opens by id — the truncated lists are for the queue, not a lookup).
|
|
3722
|
+
def render_fact_detail(id)
|
|
3723
|
+
facts_queues
|
|
3724
|
+
@selected = insika[:proposal_store]&.find(id)
|
|
3725
|
+
next_404 unless @selected
|
|
3726
|
+
if turbo_frame?("fact-detail")
|
|
3727
|
+
render("fact_detail", layout: false)
|
|
3728
|
+
else
|
|
3729
|
+
view("facts")
|
|
3730
|
+
end
|
|
3731
|
+
end
|
|
3732
|
+
|
|
3733
|
+
# Shared read for both Facts routes: the three queues (pending/stale/
|
|
3734
|
+
# recent), the `?agent=`/`?store=` filter, and the scope picklist.
|
|
3735
|
+
def facts_queues
|
|
2628
3736
|
store = insika[:proposal_store]
|
|
2629
3737
|
@pending = store ? store.pending(limit: 100) : []
|
|
2630
3738
|
@stale = store ? store.stale(limit: 50) : []
|
|
@@ -2644,7 +3752,6 @@ end
|
|
|
2644
3752
|
@stores = (insika[:profile_source].ids.sort +
|
|
2645
3753
|
(@pending + @stale + @recent).map(&:scope)).uniq.sort
|
|
2646
3754
|
@sessions = insika[:session_store]
|
|
2647
|
-
view("facts")
|
|
2648
3755
|
end
|
|
2649
3756
|
|
|
2650
3757
|
# A proposal matches the filter as the memory scope it carries, or as the
|
|
@@ -2656,6 +3763,19 @@ end
|
|
|
2656
3763
|
session && session_agent(session) == filter
|
|
2657
3764
|
end
|
|
2658
3765
|
|
|
3766
|
+
# Where a resolve POST lands: the next pending item in the SAME filtered
|
|
3767
|
+
# scope the operator was working, so approve/reject/dismiss feeds straight
|
|
3768
|
+
# into the next thing to review instead of dumping them back on a list.
|
|
3769
|
+
# No pending left (in that scope) -> the plain facts list (stale/recent,
|
|
3770
|
+
# or the empty state).
|
|
3771
|
+
def next_fact_path(filter)
|
|
3772
|
+
store = insika[:proposal_store]
|
|
3773
|
+
pending = store ? store.pending(limit: 100) : []
|
|
3774
|
+
pending = pending.select { |p| scope_matches?(p, filter) } if filter
|
|
3775
|
+
base = pending.first ? "/studio/facts/#{Rack::Utils.escape(pending.first.id)}" : "/studio/facts"
|
|
3776
|
+
filter ? "#{base}?store=#{Rack::Utils.escape(filter)}" : base
|
|
3777
|
+
end
|
|
3778
|
+
|
|
2659
3779
|
# the Harvest page — the human gate on mined skills. Reads
|
|
2660
3780
|
# HarvestStore/SessionStore directly (the evidence excerpt is read from the
|
|
2661
3781
|
# session at request time — D7: ids and indexes only are stored); every
|
|
@@ -2683,9 +3803,28 @@ end
|
|
|
2683
3803
|
@sessions = insika[:session_store]
|
|
2684
3804
|
@negative = insika[:negative_list]
|
|
2685
3805
|
@filter = presence(request.params["agent"])
|
|
3806
|
+
@idle_reason = harvest_idle_reason(@agent)
|
|
2686
3807
|
view("harvest")
|
|
2687
3808
|
end
|
|
2688
3809
|
|
|
3810
|
+
# Why the automatic loop elects none of this agent's conversations — the
|
|
3811
|
+
# engine skips them silently (HarvestEngine#harvest_declared?), so the page
|
|
3812
|
+
# says it. nil when the agent is minable.
|
|
3813
|
+
def harvest_idle_reason(agent_id)
|
|
3814
|
+
profile = agent_id && insika[:profile_source].fetch(agent_id)
|
|
3815
|
+
return nil unless profile
|
|
3816
|
+
|
|
3817
|
+
# The loop reads profiles with the platform defaults applied (an agent
|
|
3818
|
+
# with no harvest of its own inherits Settings › Agent defaults).
|
|
3819
|
+
profile = Insika::AgentDefaults.apply_safely(profile, platform_agent_defaults)
|
|
3820
|
+
return :off unless Insika::Coercion.truthy?((profile.harvest || {})["enabled"])
|
|
3821
|
+
|
|
3822
|
+
grounding = Insika::Grounding.parse(profile.grounding)
|
|
3823
|
+
grounding&.matcher&.sku? ? nil : :no_matcher
|
|
3824
|
+
rescue StandardError
|
|
3825
|
+
:no_matcher # a matcher that does not parse is no matcher
|
|
3826
|
+
end
|
|
3827
|
+
|
|
2689
3828
|
# --- Knowledge index -------------------------------------------------
|
|
2690
3829
|
|
|
2691
3830
|
# Master data for the Knowledge drill-down: every concept of ONE agent
|
|
@@ -2693,7 +3832,7 @@ end
|
|
|
2693
3832
|
# occurrences/updated_at). A record that fails to parse (corrupted by a
|
|
2694
3833
|
# hand edit) still shows by name, blank elsewhere, rather than vanishing.
|
|
2695
3834
|
def load_knowledge_master(agent:, tenant: nil)
|
|
2696
|
-
@status ||= "all"
|
|
3835
|
+
@status ||= presence(request.params["status"]) || "all"
|
|
2697
3836
|
store = insika[:knowledge_store]
|
|
2698
3837
|
names = agent && store ? store.names(agent, tenant: tenant) : []
|
|
2699
3838
|
concepts = names.filter_map do |n|
|
|
@@ -2747,9 +3886,10 @@ end
|
|
|
2747
3886
|
|
|
2748
3887
|
# The redirect/link target after a knowledge write or restore — back to
|
|
2749
3888
|
# the concept's own detail pane when a name is known, else the index.
|
|
2750
|
-
def knowledge_path(agent, name, tenant)
|
|
3889
|
+
def knowledge_path(agent, name, tenant, status: nil)
|
|
2751
3890
|
q = "agent=#{Rack::Utils.escape(agent.to_s)}"
|
|
2752
3891
|
q += "&tenant=#{Rack::Utils.escape(tenant)}" if tenant
|
|
3892
|
+
q += "&status=#{Rack::Utils.escape(status)}" if status
|
|
2753
3893
|
name ? "/studio/knowledge/#{Rack::Utils.escape(name)}?#{q}" : "/studio/knowledge?#{q}"
|
|
2754
3894
|
end
|
|
2755
3895
|
|
|
@@ -2816,10 +3956,22 @@ end
|
|
|
2816
3956
|
store = insika[:followup_store]
|
|
2817
3957
|
return {} unless store
|
|
2818
3958
|
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
3959
|
+
fired_all = store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
|
|
3960
|
+
agent: agent_id)
|
|
3961
|
+
.select { |r| r.status == "fired" }
|
|
3962
|
+
return {} if fired_all.empty?
|
|
3963
|
+
|
|
3964
|
+
# The window is 30 days of FIRES (fired_at, never the scheduled `at` —
|
|
3965
|
+
# followup_store.rb's rule, the same one the frequency ceiling follows),
|
|
3966
|
+
# anchored on the most recent fire across ALL arms of this agent — not
|
|
3967
|
+
# per arm — rather than the wall clock. A wall-clock anchor would blank
|
|
3968
|
+
# the card between visits. One shared window (not each arm's own last
|
|
3969
|
+
# fire) is also the right call for an A/B readout: both arms are then
|
|
3970
|
+
# measured over the same calendar span, so their percentages are
|
|
3971
|
+
# actually comparable.
|
|
3972
|
+
latest = fired_all.filter_map { |r| parse_time(r.fired_at || r.at) }.max
|
|
3973
|
+
from = (latest - 30 * 86_400).iso8601
|
|
3974
|
+
fired = fired_all.select { |r| (r.fired_at || r.at).to_s >= from }
|
|
2823
3975
|
fired.group_by(&:arm).each_with_object({}) do |(arm, records), acc|
|
|
2824
3976
|
sessions = records.filter_map(&:session_id)
|
|
2825
3977
|
acc[arm] = { sent: records.size, conversions: conversions_for(agent_id, sessions),
|
|
@@ -2835,16 +3987,27 @@ end
|
|
|
2835
3987
|
return 0 unless outcomes
|
|
2836
3988
|
|
|
2837
3989
|
records = sessions.empty? ? [] : outcomes.all.select { |r| sessions.include?(r.session_id) }
|
|
2838
|
-
|
|
2839
|
-
return records.count { |r| r.outcome == primary } if primary
|
|
3990
|
+
return records.count { |r| reached_primary?(agent_id, r) } if primary_stage_for(agent_id)
|
|
2840
3991
|
|
|
2841
3992
|
records.group_by(&:session_id).size
|
|
2842
3993
|
end
|
|
2843
3994
|
|
|
2844
3995
|
def primary_stage_for(agent_id)
|
|
3996
|
+
funnel_decl_for(agent_id)&.primary
|
|
3997
|
+
end
|
|
3998
|
+
|
|
3999
|
+
# An outcome record is a kind; the primary is a stage. advance_on maps one to the other.
|
|
4000
|
+
def reached_primary?(agent_id, record)
|
|
4001
|
+
decl = funnel_decl_for(agent_id)
|
|
4002
|
+
decl && decl.advance_on[record.outcome].to_s == decl.primary.to_s
|
|
4003
|
+
end
|
|
4004
|
+
|
|
4005
|
+
def funnel_decl_for(agent_id)
|
|
4006
|
+
@funnel_decls ||= {}
|
|
4007
|
+
return @funnel_decls[agent_id] if @funnel_decls.key?(agent_id)
|
|
4008
|
+
|
|
2845
4009
|
profile = insika[:profile_source]&.fetch(agent_id)
|
|
2846
|
-
|
|
2847
|
-
decl && decl.primary
|
|
4010
|
+
@funnel_decls[agent_id] = profile && Insika::FunnelDeclaration.parse(profile.funnel)
|
|
2848
4011
|
end
|
|
2849
4012
|
|
|
2850
4013
|
# The opt-outs of ONE arm: contact cells of the customers THAT ARM fired
|
|
@@ -2898,6 +4061,73 @@ end
|
|
|
2898
4061
|
.each_with_object(Hash.new(0)) { |(_, counts), acc| counts.each { |s, n| acc[s] += n } }
|
|
2899
4062
|
end
|
|
2900
4063
|
|
|
4064
|
+
# The loss between each pair of consecutive declared stages, in order; the
|
|
4065
|
+
# biggest positive one is marked (ties: the earliest stage).
|
|
4066
|
+
# -> [{ from:, to:, lost:, of:, biggest: }]
|
|
4067
|
+
def funnel_drops(decl, counts)
|
|
4068
|
+
rows = decl.stages.each_cons(2).map do |a, b|
|
|
4069
|
+
# stages are counted apart, so a later one can outgrow the earlier: no negative loss
|
|
4070
|
+
{ from: a, to: b, lost: [counts[a].to_i - counts[b].to_i, 0].max, of: counts[a].to_i, biggest: false }
|
|
4071
|
+
end
|
|
4072
|
+
top = rows.max_by { |row| row[:lost] }
|
|
4073
|
+
top[:biggest] = true if top && top[:lost].positive?
|
|
4074
|
+
rows
|
|
4075
|
+
end
|
|
4076
|
+
|
|
4077
|
+
def funnel_drop_path(agent, tenant, stage)
|
|
4078
|
+
query = Rack::Utils.build_query({ agent: agent, period: @period, drop: stage, tenant: tenant }.compact)
|
|
4079
|
+
"/studio/funnel?#{query}"
|
|
4080
|
+
end
|
|
4081
|
+
|
|
4082
|
+
# The last thing that happened in a conversation, most specific first.
|
|
4083
|
+
DROP_REASONS = {
|
|
4084
|
+
"tool_failed" => "Last tool call failed",
|
|
4085
|
+
"flagged" => "A guardrail flagged a message",
|
|
4086
|
+
"waiting" => "Customer waiting on a reply",
|
|
4087
|
+
"quiet" => "Customer went quiet after the agent's reply",
|
|
4088
|
+
"unknown" => "No message to read",
|
|
4089
|
+
"gone" => "Conversation no longer stored"
|
|
4090
|
+
}.freeze
|
|
4091
|
+
DROP_LIST_CAP = 10
|
|
4092
|
+
# Each classified session costs two reads (session + tool trace): only the
|
|
4093
|
+
# newest ones are classified; the view says how many were left out.
|
|
4094
|
+
DROP_CLASSIFY_CAP = 200
|
|
4095
|
+
|
|
4096
|
+
# The conversations that reached `stage` but not the next one in the
|
|
4097
|
+
# period: each session's furthest stage over its outcome records (the
|
|
4098
|
+
# fold's own advance_on mapping), kept when it is exactly `stage`.
|
|
4099
|
+
# -> [[reason, [session_id]]] in DROP_REASONS order, newest first, over at
|
|
4100
|
+
# most DROP_CLASSIFY_CAP sessions (@drop_total = all of them) | nil (no such drop)
|
|
4101
|
+
def funnel_drop_sessions(decl, tenant, agent, stage, period)
|
|
4102
|
+
index = decl.index_of(stage)
|
|
4103
|
+
outcomes = insika[:outcome_store]
|
|
4104
|
+
return nil unless index && index < decl.stages.length - 1 && outcomes
|
|
4105
|
+
|
|
4106
|
+
furthest = Hash.new(-1)
|
|
4107
|
+
latest = {}
|
|
4108
|
+
outcomes.for_pair(tenant: tenant, agent: agent, since_date: (Date.today - period).iso8601).each do |r|
|
|
4109
|
+
i = decl.index_of(decl.advance_on[r.outcome].to_s)
|
|
4110
|
+
next unless i && !r.session_id.to_s.empty?
|
|
4111
|
+
|
|
4112
|
+
furthest[r.session_id] = [furthest[r.session_id], i].max
|
|
4113
|
+
latest[r.session_id] = [latest[r.session_id].to_s, r.at.to_s].max
|
|
4114
|
+
end
|
|
4115
|
+
stuck = furthest.select { |_, i| i == index }.keys.sort_by { |sid| latest[sid] }.reverse
|
|
4116
|
+
@drop_total = stuck.size
|
|
4117
|
+
stuck.first(DROP_CLASSIFY_CAP)
|
|
4118
|
+
.group_by { |sid| drop_reason(sid) }
|
|
4119
|
+
.sort_by { |reason, _| DROP_REASONS.keys.index(reason) }
|
|
4120
|
+
end
|
|
4121
|
+
|
|
4122
|
+
def drop_reason(sid)
|
|
4123
|
+
session = insika[:session_store]&.find(sid)
|
|
4124
|
+
return "gone" unless session
|
|
4125
|
+
return "tool_failed" if Array(insika[:tool_trace_store]&.for_session(sid)).last&.[]("ok") == false
|
|
4126
|
+
return "flagged" if session_flagged?(session)
|
|
4127
|
+
|
|
4128
|
+
{ "user" => "waiting", "assistant" => "quiet" }.fetch(last_role(session), "unknown")
|
|
4129
|
+
end
|
|
4130
|
+
|
|
2901
4131
|
# BudgetLedger's current counters (D6): "tokens today / this month". nil
|
|
2902
4132
|
# store -> the view renders 0 (the empty state).
|
|
2903
4133
|
def funnel_spend(store, tenant, agent)
|
|
@@ -2935,6 +4165,24 @@ end
|
|
|
2935
4165
|
"last #{window['last_sessions'] || Insika::Refinement::EvidenceCollector::DEFAULT_WINDOW} conversation(s)"
|
|
2936
4166
|
end
|
|
2937
4167
|
|
|
4168
|
+
# How long a run took (finished_at - started_at), or how long an open one
|
|
4169
|
+
# has been running so far (against now) — compact, like time_ago's units.
|
|
4170
|
+
def refinement_elapsed(run)
|
|
4171
|
+
start = parse_time(run.started_at)
|
|
4172
|
+
return nil unless start
|
|
4173
|
+
|
|
4174
|
+
finish = run.finished_at ? parse_time(run.finished_at) : Time.now
|
|
4175
|
+
return nil unless finish
|
|
4176
|
+
|
|
4177
|
+
secs = (finish - start).to_i
|
|
4178
|
+
return "#{secs}s" if secs < 60
|
|
4179
|
+
|
|
4180
|
+
mins = secs / 60
|
|
4181
|
+
return "#{mins}min" if mins < 60
|
|
4182
|
+
|
|
4183
|
+
"#{mins / 60}h"
|
|
4184
|
+
end
|
|
4185
|
+
|
|
2938
4186
|
# An operator control (pause/resume/cancel/approve): audits the ATTEMPT to the
|
|
2939
4187
|
# shared EventStream BEFORE dispatching — accountability survives a Command
|
|
2940
4188
|
# failure (parity with server/admin's `act`) — then dispatches via the bus with
|
|
@@ -2962,15 +4210,18 @@ end
|
|
|
2962
4210
|
# is the operator). Mirrors server/admin's operator_of default.
|
|
2963
4211
|
def operator_label = "studio"
|
|
2964
4212
|
|
|
2965
|
-
# Semantic status class
|
|
2966
|
-
#
|
|
2967
|
-
#
|
|
4213
|
+
# Semantic status class, shared by the `.pill` and `.status` CSS: one
|
|
4214
|
+
# vocabulary (ok / warn / err / info / neutral, plus the pill-only `run`)
|
|
4215
|
+
# for every status string the console renders — task lifecycle, follow-up
|
|
4216
|
+
# arms, approval decisions. `neutral` is the "nothing happened" tone: a
|
|
4217
|
+
# cancelled or dismissed item is not a failure.
|
|
2968
4218
|
def status_class(status)
|
|
2969
4219
|
case status.to_s
|
|
2970
|
-
when "completed" then "ok"
|
|
4220
|
+
when "completed", "fired", "approved", "ok", "applied" then "ok"
|
|
2971
4221
|
when "running" then "run"
|
|
2972
|
-
when "waiting", "queued", "paused" then "warn"
|
|
2973
|
-
when "failed", "
|
|
4222
|
+
when "pending", "waiting", "queued", "paused", "awaiting_approval" then "warn"
|
|
4223
|
+
when "blocked", "failed", "rejected" then "err"
|
|
4224
|
+
when "cancelled", "dismissed", "no_findings" then "neutral"
|
|
2974
4225
|
else "info"
|
|
2975
4226
|
end
|
|
2976
4227
|
end
|
|
@@ -2993,13 +4244,33 @@ end
|
|
|
2993
4244
|
dispatch(:send_message, { agent: agent, session_id: session_id, message: message }, tenant: agent)
|
|
2994
4245
|
end
|
|
2995
4246
|
|
|
2996
|
-
# Creates a new session via the bus (create_session generates
|
|
4247
|
+
# Creates a new session via the bus (with `id`, or create_session generates one) and returns
|
|
2997
4248
|
# the id. `model`/`provider` (optional) become the per-chat pin: CreateSession
|
|
2998
4249
|
# stashes them in the reserved `vars["__llm__"]` slot the ModelResolver reads as
|
|
2999
4250
|
# the highest-precedence layer (Chat > Agent > platform default). Only non-blank
|
|
3000
4251
|
# values are sent, so an empty override leaves the session unpinned.
|
|
3001
|
-
|
|
4252
|
+
# The message of the turn still running in `session_id`, nil when none: it is
|
|
4253
|
+
# only saved with the turn, at its end, so a viewer who comes back mid-turn
|
|
4254
|
+
# (the flash already spent) would not see what was asked. Only the most
|
|
4255
|
+
# recently updated tasks are read — a running turn is always among them.
|
|
4256
|
+
def running_message(session_id)
|
|
4257
|
+
return unless session_id
|
|
4258
|
+
|
|
4259
|
+
task = insika[:task_store]&.recent(20)&.find do |t|
|
|
4260
|
+
t.session_id == session_id && %i[queued running waiting paused].include?(t.status)
|
|
4261
|
+
end
|
|
4262
|
+
task&.command&.dig("payload", "message")
|
|
4263
|
+
end
|
|
4264
|
+
|
|
4265
|
+
# The playground's model picker -> { model:, provider: } for create_session.
|
|
4266
|
+
def pinned_model(r)
|
|
4267
|
+
model, provider = picked_model(r)
|
|
4268
|
+
{ model: model, provider: provider }
|
|
4269
|
+
end
|
|
4270
|
+
|
|
4271
|
+
def create_session(id: nil, model: nil, provider: nil, thinking: nil)
|
|
3002
4272
|
payload = { vars: { "canal" => "studio" } }
|
|
4273
|
+
payload[:id] = id if id
|
|
3003
4274
|
payload[:model] = model if model
|
|
3004
4275
|
payload[:provider] = provider if provider
|
|
3005
4276
|
payload[:thinking] = thinking if thinking
|
|
@@ -3045,6 +4316,16 @@ end
|
|
|
3045
4316
|
"avatar-h#{id.to_s.bytes.sum % 10}"
|
|
3046
4317
|
end
|
|
3047
4318
|
|
|
4319
|
+
# Same idea for `.identity`'s 28px circle, but over the NINE themed --av-N
|
|
4320
|
+
# ramps only (no h0 — the brand accent is reserved for the product's own
|
|
4321
|
+
# marks, not for an arbitrary row). Deterministic: the same id always
|
|
4322
|
+
# yields the same hue, so a list keeps its colours across renders.
|
|
4323
|
+
def avatar_hue(id) = (id.to_s.bytes.sum % 9) + 1
|
|
4324
|
+
|
|
4325
|
+
# The readable head of a uuid. Callers put the FULL value in `title` and
|
|
4326
|
+
# `data-copy` so nothing is lost by the truncation.
|
|
4327
|
+
def short_id(uuid) = uuid.to_s[0, 8]
|
|
4328
|
+
|
|
3048
4329
|
# Where a prompt POST lands back: the same file open in the drill, scrolled
|
|
3049
4330
|
# to the prompts section. No file (a delete, say) → the agent page itself.
|
|
3050
4331
|
def prompt_edit_path(id, file)
|
|
@@ -3053,11 +4334,14 @@ end
|
|
|
3053
4334
|
end
|
|
3054
4335
|
|
|
3055
4336
|
# Serves a versioned asset from dist. `File.basename` kills path traversal; only
|
|
3056
|
-
# files that exist in the dist dir are served.
|
|
3057
|
-
|
|
4337
|
+
# files that exist in the dist dir are served. `dir` is the one allowed
|
|
4338
|
+
# sub-directory ("fonts"), passed by the route as a literal — never by the
|
|
4339
|
+
# client — so the traversal guard still holds.
|
|
4340
|
+
def serve_asset(name, dir = nil)
|
|
3058
4341
|
base = File.basename(name)
|
|
3059
|
-
|
|
3060
|
-
|
|
4342
|
+
root = dir ? File.join(ASSETS_DIR, dir) : ASSETS_DIR
|
|
4343
|
+
path = File.join(root, base)
|
|
4344
|
+
unless File.file?(path) && File.fnmatch(File.join(root, "*"), path)
|
|
3061
4345
|
response.status = 404
|
|
3062
4346
|
return "not found"
|
|
3063
4347
|
end
|
|
@@ -3068,7 +4352,9 @@ end
|
|
|
3068
4352
|
# Assets are tiny and same-origin, so the revalidation cost is negligible —
|
|
3069
4353
|
# correctness over caching for an actively-edited admin UI.
|
|
3070
4354
|
response["cache-control"] = "no-cache"
|
|
3071
|
-
File.read
|
|
4355
|
+
# Webfonts are binary: File.read would tag the bytes UTF-8 and any later
|
|
4356
|
+
# String operation on the body would raise on the first invalid sequence.
|
|
4357
|
+
base.end_with?(".woff2") ? File.binread(path) : File.read(path)
|
|
3072
4358
|
end
|
|
3073
4359
|
|
|
3074
4360
|
# Cache-busting URL for a dist asset. Dist files are served under a STABLE
|
|
@@ -3084,6 +4370,12 @@ end
|
|
|
3084
4370
|
"/studio/assets/dist/#{base}?v=#{v}"
|
|
3085
4371
|
end
|
|
3086
4372
|
|
|
4373
|
+
# URL of an embedded webfont. No `?v=` cache-bust on purpose: the file name
|
|
4374
|
+
# already names the family and weight (a different font is a different
|
|
4375
|
+
# file), and the preload link in layout.erb has to match the @font-face
|
|
4376
|
+
# `src:` in application.css BYTE FOR BYTE or the browser fetches twice.
|
|
4377
|
+
def font_path(file) = "/studio/assets/dist/fonts/#{file}"
|
|
4378
|
+
|
|
3087
4379
|
def presence(str) = Insika::Coercion.presence(str)
|
|
3088
4380
|
|
|
3089
4381
|
# Masked-secret sentinel (to pre-fill credential fields in the
|