insika 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +3 -3
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/lib/insika/studio/app.rb
CHANGED
|
@@ -30,8 +30,8 @@ module Studio
|
|
|
30
30
|
# Same-origin assets: versioned esbuild bundle in `assets/dist/*`, served
|
|
31
31
|
# by `/studio/assets/dist/*`. Strict `'self'` CSP (no `unsafe-inline`).
|
|
32
32
|
class App < Roda
|
|
33
|
-
include Forms # form -> command-payload parsing
|
|
34
|
-
include NavIcons # nav SVG helper
|
|
33
|
+
include Forms # form -> command-payload parsing
|
|
34
|
+
include NavIcons # nav SVG helper
|
|
35
35
|
|
|
36
36
|
# The session cookie lives N days. 7 days = parity with the OpenClaw default.
|
|
37
37
|
SESSION_MAX_AGE = 7 * 24 * 3600
|
|
@@ -81,36 +81,84 @@ module Studio
|
|
|
81
81
|
def configure(command_bus:, profile_source:, event_stream:, config:,
|
|
82
82
|
agent_file_store: nil, skill_store: nil, skill_catalog: nil,
|
|
83
83
|
tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
|
|
84
|
+
memory_audit_store: nil,
|
|
84
85
|
settings_store: nil, llm_provider_store: nil, mcp_store: nil,
|
|
85
|
-
system_file_store: nil, tool_trace_store: nil,
|
|
86
|
+
system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
|
|
87
|
+
cache_series_store: nil,
|
|
86
88
|
task_store: nil, checkpoint_store: nil, pending_action_store: nil,
|
|
87
|
-
refinement_store: nil, golden_store: nil, session_secret: nil
|
|
89
|
+
refinement_store: nil, golden_store: nil, session_secret: nil,
|
|
90
|
+
outcome_store: nil, shadow_pair_store: nil, parity_criterion: nil,
|
|
91
|
+
funnel_store: nil, budget_ledger: nil,
|
|
92
|
+
followup_store: nil, contact_store: nil,
|
|
93
|
+
proposal_store: nil,
|
|
94
|
+
harvest_store: nil, harvest_criterion: nil, negative_list: nil,
|
|
95
|
+
channel_registry: nil)
|
|
88
96
|
@insika = {
|
|
89
97
|
command_bus: command_bus, profile_source: profile_source,
|
|
90
98
|
event_stream: event_stream, config: config,
|
|
91
99
|
agent_file_store: agent_file_store, skill_store: skill_store,
|
|
92
100
|
skill_catalog: skill_catalog, tool_catalog: tool_catalog,
|
|
93
|
-
# tool_store: DATA-DEFINED tool definitions
|
|
101
|
+
# tool_store: DATA-DEFINED tool definitions. The catalog already
|
|
94
102
|
# shows the data-tools in the matrix; the store feeds the authoring page.
|
|
95
103
|
tool_store: tool_store,
|
|
96
104
|
memory_store: memory_store, session_store: session_store,
|
|
105
|
+
# the Customers drill renders the audit lines (digests +
|
|
106
|
+
# counts, never content). nil = the audit section renders empty.
|
|
107
|
+
memory_audit_store: memory_audit_store,
|
|
97
108
|
# runtime config (settings/LLM/MCP) + global system
|
|
98
109
|
# files + conversations index. All optional (empty-state if nil).
|
|
99
110
|
settings_store: settings_store, llm_provider_store: llm_provider_store,
|
|
100
111
|
mcp_store: mcp_store, system_file_store: system_file_store,
|
|
101
112
|
# per-session tool-call trace (debug): args + result + status per
|
|
102
|
-
# turn, rendered in the session viewer
|
|
113
|
+
# turn, rendered in the session viewer.
|
|
103
114
|
tool_trace_store: tool_trace_store,
|
|
104
|
-
#
|
|
115
|
+
# per-session context breakdown: tokens by category +
|
|
116
|
+
# budget per turn, on the same viewer. Counts only, no content.
|
|
117
|
+
context_trace_store: context_trace_store,
|
|
118
|
+
# per-AGENT cache-hit series (percentages + counts, no
|
|
119
|
+
# content) — the cache tab on the agent detail. nil = tab renders
|
|
120
|
+
# "No turns recorded.".
|
|
121
|
+
cache_series_store: cache_series_store,
|
|
122
|
+
# operate: tasks + human-in-the-loop approvals. Reads the
|
|
105
123
|
# task/checkpoint/pending stores to render; controls (pause/resume/
|
|
106
124
|
# cancel/approve) dispatch on the bus — parity with server/admin.
|
|
107
125
|
task_store: task_store, checkpoint_store: checkpoint_store,
|
|
108
126
|
pending_action_store: pending_action_store,
|
|
109
|
-
# refinement runs
|
|
127
|
+
# refinement runs: the ranked failure report per agent.
|
|
110
128
|
# Read-only here; the "Run" button dispatches :run_refinement on the bus.
|
|
111
129
|
refinement_store: refinement_store,
|
|
112
|
-
# eval cases
|
|
113
|
-
golden_store: golden_store
|
|
130
|
+
# eval cases: read to render; writes go through:write_golden.
|
|
131
|
+
golden_store: golden_store,
|
|
132
|
+
# WS7: business outcomes — the agents grid's last-outcome pill; the
|
|
133
|
+
# agent detail shows the per-day series. Reads only; recording goes
|
|
134
|
+
# through POST /v1/outcomes.
|
|
135
|
+
outcome_store: outcome_store,
|
|
136
|
+
# the Funnel page reads the fold's cells and the
|
|
137
|
+
# BudgetLedger's current counters (D6). nil = the page renders its
|
|
138
|
+
# empty state.
|
|
139
|
+
funnel_store: funnel_store, budget_ledger: budget_ledger,
|
|
140
|
+
# the shadow pair store (read to render /studio/parity), the
|
|
141
|
+
# frozen criterion (folded by Verdict), and the channel registry (the
|
|
142
|
+
# nav row only exists when a shadow channel is registered — the page
|
|
143
|
+
# is not an invitation, unlike Refinement's).
|
|
144
|
+
shadow_pair_store: shadow_pair_store, parity_criterion: parity_criterion,
|
|
145
|
+
channel_registry: channel_registry,
|
|
146
|
+
# the Follow-ups page READS the stores directly;
|
|
147
|
+
# its only mutations — cancel a pending record, force-revoke a contact
|
|
148
|
+
# — dispatch :cancel_followup / :revoke_contact on the bus. nil = the
|
|
149
|
+
# page renders its empty state.
|
|
150
|
+
followup_store: followup_store, contact_store: contact_store,
|
|
151
|
+
# the Facts (wiki) page reads the proposal store directly
|
|
152
|
+
# (approvals/rejections dispatch :resolve_proposal on the bus).
|
|
153
|
+
# nil = the page renders its empty state.
|
|
154
|
+
proposal_store: proposal_store,
|
|
155
|
+
# the Harvest page reads the harvest store directly
|
|
156
|
+
# (the pending/awaiting/promoted lists); its mutations — mine, gate,
|
|
157
|
+
# promote, reject, rollback — dispatch bus commands. The criterion
|
|
158
|
+
# (boot-loaded, with its sha) and the negative list render the two
|
|
159
|
+
# pre-registered artifacts. nil = the page renders its empty state.
|
|
160
|
+
harvest_store: harvest_store, harvest_criterion: harvest_criterion,
|
|
161
|
+
negative_list: negative_list
|
|
114
162
|
}.freeze
|
|
115
163
|
# "restart recommended" flag — in memory, PER PROCESS. A
|
|
116
164
|
# config change that the runtime only re-reads at boot (e.g.: MCP instances are
|
|
@@ -230,11 +278,15 @@ module Studio
|
|
|
230
278
|
r.is do
|
|
231
279
|
r.get do
|
|
232
280
|
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
281
|
+
# WS7 scorecard: the LAST outcome per agent, computed once for the
|
|
282
|
+
# whole grid (a store scan per card would be n scans). Series live
|
|
283
|
+
# on the agent detail — one agent's periods, not n charts here.
|
|
284
|
+
@latest_outcomes = insika[:outcome_store]&.latest_per_agent
|
|
233
285
|
view("agents")
|
|
234
286
|
end
|
|
235
287
|
|
|
236
288
|
# POST /studio/agents — creates an agent ("everyone creates
|
|
237
|
-
# their own
|
|
289
|
+
# their own agent"). Fires :create_agent; redirects to the new one's detail.
|
|
238
290
|
r.post do
|
|
239
291
|
check_csrf!
|
|
240
292
|
id = presence(r.params["id"])
|
|
@@ -266,20 +318,86 @@ module Studio
|
|
|
266
318
|
with_flash("Configuration saved.") do
|
|
267
319
|
dispatch(:update_agent, config_patch(r))
|
|
268
320
|
end
|
|
269
|
-
r.
|
|
321
|
+
group = CONFIG_SECTIONS.include?(r.params["cfg"]) ? r.params["cfg"] : "model"
|
|
322
|
+
r.redirect(agent_path(id, "config", "cfg=#{group}"))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Automatic-loop triggers, per agent. The loops (distillation,
|
|
326
|
+
# harvest, follow-up firing, funnel fold) already run on their own
|
|
327
|
+
# worker fibers when the agent declares them; these buttons exist so
|
|
328
|
+
# an operator can ask for a pass NOW, without waiting for the
|
|
329
|
+
# window. All three dispatch bus commands — the Studio never runs a
|
|
330
|
+
# loop itself.
|
|
331
|
+
r.post "distill" do
|
|
332
|
+
check_csrf!
|
|
333
|
+
done = 0
|
|
334
|
+
skipped = 0
|
|
335
|
+
begin
|
|
336
|
+
due_distill_sessions(id).each do |sid|
|
|
337
|
+
result = dispatch(:run_distillation, { session_id: sid })
|
|
338
|
+
if result && result[:distilled]
|
|
339
|
+
done += 1
|
|
340
|
+
else
|
|
341
|
+
skipped += 1
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
flash["notice"] = "Distilled #{done} session(s)" + (skipped.positive? ? " (#{skipped} skipped)." : ".")
|
|
345
|
+
rescue Insika::ValidationError, Insika::NotFoundError => e
|
|
346
|
+
flash["error"] = e.message
|
|
347
|
+
end
|
|
348
|
+
r.redirect(agent_path(id, "loops"))
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
r.post "harvest" do
|
|
352
|
+
check_csrf!
|
|
353
|
+
begin
|
|
354
|
+
result = dispatch(:run_harvest, { agent: id })
|
|
355
|
+
if result && result[:mined]
|
|
356
|
+
flash["notice"] = "Harvest run finished — #{result[:candidates]} candidate(s) mined."
|
|
357
|
+
elsif result && result[:skipped]
|
|
358
|
+
flash["notice"] = "Nothing mined — #{result[:skipped]}."
|
|
359
|
+
else
|
|
360
|
+
flash["notice"] = "Harvest run finished."
|
|
361
|
+
end
|
|
362
|
+
rescue Insika::ValidationError, Insika::NotFoundError => e
|
|
363
|
+
flash["error"] = e.message
|
|
364
|
+
end
|
|
365
|
+
r.redirect(agent_path(id, "loops"))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
r.post "refinement" do
|
|
369
|
+
check_csrf!
|
|
370
|
+
with_flash("Refinement run finished — see the Refinement page.") do
|
|
371
|
+
dispatch(:run_refinement, { agent: id })
|
|
372
|
+
end
|
|
373
|
+
r.redirect(agent_path(id, "loops"))
|
|
270
374
|
end
|
|
271
375
|
|
|
272
376
|
# Store-backed prompts. Writing also ensures the file
|
|
273
377
|
# enters `prompt_files` — otherwise the Prompt provider wouldn't load it.
|
|
274
378
|
# prompt_files is synced by the Commands themselves (write/delete
|
|
275
379
|
# register/remove the file) — the Studio just dispatches the operation.
|
|
380
|
+
#
|
|
381
|
+
# The prompts section renders as a drill (master file list | editor), so
|
|
382
|
+
# the file under edit rides the URL like skills does: GET .../prompts/:file
|
|
383
|
+
# opens that file, .../prompts/new opens the create form.
|
|
276
384
|
r.on "prompts" do
|
|
385
|
+
r.get "new" do
|
|
386
|
+
@prompt_selected = ""
|
|
387
|
+
render_agent_detail
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
r.get String do |file|
|
|
391
|
+
@prompt_selected = utf8(file)
|
|
392
|
+
render_agent_detail
|
|
393
|
+
end
|
|
394
|
+
|
|
277
395
|
r.post "delete" do
|
|
278
396
|
check_csrf!
|
|
279
397
|
with_flash("Prompt removed.") do
|
|
280
398
|
dispatch(:delete_agent_file, { agent_id: id, file: presence(r.params["file"]) })
|
|
281
399
|
end
|
|
282
|
-
r.redirect(agent_path(id))
|
|
400
|
+
r.redirect(agent_path(id, "prompts"))
|
|
283
401
|
end
|
|
284
402
|
|
|
285
403
|
r.post "restore" do
|
|
@@ -290,7 +408,7 @@ module Studio
|
|
|
290
408
|
version: r.params["version"]
|
|
291
409
|
})
|
|
292
410
|
end
|
|
293
|
-
r.redirect(
|
|
411
|
+
r.redirect(prompt_edit_path(id, r.params["file"]))
|
|
294
412
|
end
|
|
295
413
|
|
|
296
414
|
r.post do
|
|
@@ -300,24 +418,62 @@ module Studio
|
|
|
300
418
|
agent_id: id, file: presence(r.params["file"]), content: r.params["content"].to_s
|
|
301
419
|
})
|
|
302
420
|
end
|
|
303
|
-
r.redirect(
|
|
421
|
+
r.redirect(prompt_edit_path(id, r.params["file"]))
|
|
304
422
|
end
|
|
305
423
|
end
|
|
306
424
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
425
|
+
r.on "skills" do
|
|
426
|
+
# POST /studio/agents/:id/skills → :update_agent with the `skills`
|
|
427
|
+
# allowlist. "all" = nil; otherwise the checked subset (possibly []).
|
|
428
|
+
r.is do
|
|
429
|
+
r.post do
|
|
430
|
+
check_csrf!
|
|
431
|
+
skills = r.params["all_skills"] == "1" ? nil : Array(r.params["skills"]).map(&:to_s)
|
|
432
|
+
with_flash("Skills updated.") do
|
|
433
|
+
dispatch(:update_agent, { id: id, skills: skills })
|
|
434
|
+
end
|
|
435
|
+
r.redirect(agent_path(id))
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# /studio/agents/:id/skills/:name — this agent's OWN version of a skill.
|
|
440
|
+
# TWO path segments, which is exactly why the store takes the agent as a
|
|
441
|
+
# second argument: a composite "agent/name" key would put a `/` inside
|
|
442
|
+
# what this route serves as one segment.
|
|
443
|
+
r.on String do |name|
|
|
444
|
+
name = utf8(name)
|
|
445
|
+
r.is do
|
|
446
|
+
r.get do
|
|
447
|
+
load_skills_master(agent: id)
|
|
448
|
+
@selected = name
|
|
449
|
+
@scope_agent = id
|
|
450
|
+
@skill_content = skill_source(name, agent: id)
|
|
451
|
+
view("skills")
|
|
452
|
+
end
|
|
453
|
+
# Saving the specialization: same command, `agent:` set.
|
|
454
|
+
r.post do
|
|
455
|
+
check_csrf!
|
|
456
|
+
with_flash("Specialization saved.") do
|
|
457
|
+
dispatch(:write_skill, { name: name, agent: id, content: r.params["content"].to_s })
|
|
458
|
+
end
|
|
459
|
+
r.redirect("#{agent_path(id)}/skills/#{Rack::Utils.escape(name)}")
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Un-specialize: the shared skill stays, and the agent falls back to it.
|
|
464
|
+
r.post "delete" do
|
|
465
|
+
check_csrf!
|
|
466
|
+
with_flash("Specialization removed — the agent falls back to the shared skill.") do
|
|
467
|
+
dispatch(:delete_skill, { name: name, agent: id })
|
|
468
|
+
end
|
|
469
|
+
r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
|
|
470
|
+
end
|
|
314
471
|
end
|
|
315
|
-
r.redirect(agent_path(id))
|
|
316
472
|
end
|
|
317
473
|
|
|
318
474
|
# Agent memory. Scoped by tenant = agent id — the
|
|
319
475
|
# SAME tenant the playground uses when chatting, so what is edited
|
|
320
|
-
# here is what the
|
|
476
|
+
# here is what the agent reads on the turn. Each agent, its own memory.
|
|
321
477
|
r.on "memory" do
|
|
322
478
|
r.post "fact" do
|
|
323
479
|
check_csrf!
|
|
@@ -381,25 +537,37 @@ module Studio
|
|
|
381
537
|
view("skills")
|
|
382
538
|
end
|
|
383
539
|
end
|
|
384
|
-
# Enables/disables the skill on N agents at once
|
|
540
|
+
# Enables/disables the skill on N agents at once, and marks it always-on for
|
|
541
|
+
# the ones that checked `eager`. Both are per-agent decisions about the same
|
|
542
|
+
# skill, so they are the same form and the same dispatch.
|
|
385
543
|
r.post "agents" do
|
|
386
544
|
check_csrf!
|
|
387
545
|
agent_ids = Array(r.params["agent_ids"]).map(&:to_s)
|
|
546
|
+
eager_ids = Array(r.params["eager_ids"]).map(&:to_s)
|
|
388
547
|
result = with_flash("Skill's agents updated.") do
|
|
389
|
-
dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids })
|
|
390
|
-
end
|
|
391
|
-
skipped = Array(result && result[:skipped_all])
|
|
392
|
-
if skipped.any?
|
|
393
|
-
flash["notice"] = "#{flash['notice']} — #{skipped.size} agent(s) with 'all' skills were left intact."
|
|
548
|
+
dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids, eager_ids: eager_ids })
|
|
394
549
|
end
|
|
550
|
+
note_skipped(result)
|
|
395
551
|
r.redirect("/studio/skills")
|
|
396
552
|
end
|
|
553
|
+
|
|
554
|
+
# Seeds a per-agent override from the SHARED body and opens it for editing.
|
|
555
|
+
# Seeded and not blank: specializing means "this, but for me" — starting from
|
|
556
|
+
# an empty editor is how the two versions drift apart on day one.
|
|
557
|
+
r.post "specialize" do
|
|
558
|
+
check_csrf!
|
|
559
|
+
agent_id = presence(r.params["agent_id"])
|
|
560
|
+
with_flash("Specialized for #{agent_id}.") do
|
|
561
|
+
dispatch(:write_skill, { name: name, agent: agent_id, content: skill_source(name) })
|
|
562
|
+
end
|
|
563
|
+
r.redirect("/studio/agents/#{Rack::Utils.escape(agent_id.to_s)}/skills/#{Rack::Utils.escape(name)}")
|
|
564
|
+
end
|
|
397
565
|
end
|
|
398
566
|
end
|
|
399
567
|
|
|
400
568
|
# --- Tools: tool × agent matrix + DATA-DEFINED tool authoring -----------
|
|
401
569
|
r.on "tools" do
|
|
402
|
-
# Data-defined tool authoring
|
|
570
|
+
# Data-defined tool authoring. Under /tools/def/* — BEFORE the
|
|
403
571
|
# generic `r.post String` matcher (which is the allow/deny matrix per agent :id).
|
|
404
572
|
r.on "def" do
|
|
405
573
|
# /studio/tools/def/new — empty editor.
|
|
@@ -474,7 +642,7 @@ module Studio
|
|
|
474
642
|
r.on "settings" do
|
|
475
643
|
r.is do
|
|
476
644
|
r.get { render_settings }
|
|
477
|
-
# General settings (streaming/timeouts
|
|
645
|
+
# General settings (streaming/timeouts) → :update_settings.
|
|
478
646
|
r.post do
|
|
479
647
|
check_csrf!
|
|
480
648
|
with_flash("Settings saved.") do
|
|
@@ -484,7 +652,7 @@ module Studio
|
|
|
484
652
|
end
|
|
485
653
|
end
|
|
486
654
|
|
|
487
|
-
# Platform model defaults (sub-resource, v2
|
|
655
|
+
# Platform model defaults (sub-resource, v2): its own form/route so a
|
|
488
656
|
# general-settings save never clobbers the model layer.
|
|
489
657
|
r.post "models" do
|
|
490
658
|
check_csrf!
|
|
@@ -494,7 +662,7 @@ module Studio
|
|
|
494
662
|
r.redirect("/studio/settings?s=models")
|
|
495
663
|
end
|
|
496
664
|
|
|
497
|
-
# Per-model reasoning defaults (
|
|
665
|
+
# Per-model reasoning defaults (the per-model layer): its own form so a
|
|
498
666
|
# model-defaults save never clobbers the per-model map, and vice-versa.
|
|
499
667
|
r.post "model-params" do
|
|
500
668
|
check_csrf!
|
|
@@ -504,7 +672,7 @@ module Studio
|
|
|
504
672
|
r.redirect("/studio/settings?s=models")
|
|
505
673
|
end
|
|
506
674
|
|
|
507
|
-
# Edge limits
|
|
675
|
+
# Edge limits: the platform rate-limit/cost layer.
|
|
508
676
|
# Its own form for the same reason as models — saves never cross-clobber.
|
|
509
677
|
r.post "edge" do
|
|
510
678
|
check_csrf!
|
|
@@ -514,7 +682,7 @@ module Studio
|
|
|
514
682
|
r.redirect("/studio/settings?s=edge")
|
|
515
683
|
end
|
|
516
684
|
|
|
517
|
-
# Evals
|
|
685
|
+
# Evals: the judge PANEL and how it agrees. Its own
|
|
518
686
|
# form, like models and edge — a save here must not clobber those.
|
|
519
687
|
r.post "evals" do
|
|
520
688
|
check_csrf!
|
|
@@ -619,12 +787,93 @@ module Studio
|
|
|
619
787
|
# Session's tool-call trace (debug): grouped by turn in the view.
|
|
620
788
|
@tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
|
|
621
789
|
.group_by { |t| t["turn"] }
|
|
790
|
+
# Context breakdown per turn: chronological entries.
|
|
791
|
+
@context_traces = insika[:context_trace_store]&.for_session(sid) || []
|
|
792
|
+
# the stage history — the session's outcomes folded
|
|
793
|
+
# into the agent's declared stages (unmapped kinds render raw).
|
|
794
|
+
@stage_history = stage_history_for(sid)
|
|
622
795
|
view("session")
|
|
623
796
|
end
|
|
624
797
|
end
|
|
625
798
|
end
|
|
626
799
|
|
|
627
|
-
# ---
|
|
800
|
+
# --- Customers: the memory drill ----------------
|
|
801
|
+
# Derived from the store, not a registry: the index enumerates memory
|
|
802
|
+
# cells and classifies them by shape (D6). Every mutation dispatches a
|
|
803
|
+
# Command on the bus — the transport's constitutional rule (app.rb:21).
|
|
804
|
+
# The cell scope in the URL is parsed back into the (tenant, customer)
|
|
805
|
+
# pair: a "memory:acme:c-1" URL must edit the SAME cell the turn reads
|
|
806
|
+
# (E1 live) — passing the raw scope as `tenant` would double-prefix it.
|
|
807
|
+
r.on "customers" do
|
|
808
|
+
r.is do
|
|
809
|
+
r.get { render_customers }
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
r.on String do |raw_scope|
|
|
813
|
+
# Roda captures the segment URL-encoded; a customer cell contains
|
|
814
|
+
# colons (memory:acme:c-1), so decode before parsing.
|
|
815
|
+
scope = Rack::Utils.unescape_path(utf8(raw_scope))
|
|
816
|
+
cell = Insika::MemoryStore.parse_cell(scope)
|
|
817
|
+
|
|
818
|
+
r.get { render_customer(scope, cell) }
|
|
819
|
+
|
|
820
|
+
# Add/edit a fact inline (upsert) — the operator's stamp + audit.
|
|
821
|
+
r.post "fact" do
|
|
822
|
+
check_csrf!
|
|
823
|
+
with_flash("Fact saved.") do
|
|
824
|
+
dispatch(:memory_put_fact, {
|
|
825
|
+
tenant: cell[:tenant], customer: cell[:customer],
|
|
826
|
+
key: presence(r.params["key"]), value: r.params["value"].to_s,
|
|
827
|
+
expires_at: presence(r.params["expires_at"]), operator: "studio"
|
|
828
|
+
})
|
|
829
|
+
end
|
|
830
|
+
r.redirect(customer_path(scope))
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
r.post "forget-fact" do
|
|
834
|
+
check_csrf!
|
|
835
|
+
with_flash("Fact forgotten.") do
|
|
836
|
+
dispatch(:memory_forget_fact, {
|
|
837
|
+
tenant: cell[:tenant], customer: cell[:customer],
|
|
838
|
+
key: presence(r.params["key"]), operator: "studio"
|
|
839
|
+
})
|
|
840
|
+
end
|
|
841
|
+
r.redirect(customer_path(scope))
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# The LGPD access right: JSON download of the cell's content (D7 —
|
|
845
|
+
# the RETURN value is the download; the event stays counts-only). A
|
|
846
|
+
# cell with no customer (a _default cell, reachable by URL) is a
|
|
847
|
+
# ValidationError -> red flash, never a 500.
|
|
848
|
+
r.post "export" do
|
|
849
|
+
check_csrf!
|
|
850
|
+
result = dispatch(:export_customer_memory, {
|
|
851
|
+
tenant: cell[:tenant], customer: cell[:customer],
|
|
852
|
+
operator: "studio"
|
|
853
|
+
})
|
|
854
|
+
response["content-type"] = "application/json"
|
|
855
|
+
response["content-disposition"] =
|
|
856
|
+
%(attachment; filename="memory-#{Rack::Utils.escape_path(scope.tr(':', '-'))}.json")
|
|
857
|
+
JSON.pretty_generate(result)
|
|
858
|
+
rescue Insika::ValidationError => e
|
|
859
|
+
flash["error"] = e.message
|
|
860
|
+
r.redirect(customer_path(scope))
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# The LGPD forget — purges the cell AND the customer's sessions.
|
|
864
|
+
r.post "forget" do
|
|
865
|
+
check_csrf!
|
|
866
|
+
with_flash("Customer forgotten.") do
|
|
867
|
+
dispatch(:forget_customer, {
|
|
868
|
+
tenant: cell[:tenant], customer: cell[:customer], operator: "studio"
|
|
869
|
+
})
|
|
870
|
+
end
|
|
871
|
+
r.redirect("/studio/customers")
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Tasks: list + detail + operator controls -------
|
|
628
877
|
# Parity with server/admin: READS the task/checkpoint/pending stores to
|
|
629
878
|
# render; pause/resume/cancel dispatch Commands on the bus (never a direct
|
|
630
879
|
# store write). Every control audits the ATTEMPT to the EventStream first.
|
|
@@ -658,7 +907,7 @@ module Studio
|
|
|
658
907
|
end
|
|
659
908
|
end
|
|
660
909
|
|
|
661
|
-
#
|
|
910
|
+
# Approvals: human-in-the-loop inbox -------------
|
|
662
911
|
# Lists every :pending action across tasks; resolving one dispatches
|
|
663
912
|
# :approve_action, which resolves the store AND wakes the suspended turn.
|
|
664
913
|
r.on "approvals" do
|
|
@@ -677,7 +926,7 @@ module Studio
|
|
|
677
926
|
end
|
|
678
927
|
end
|
|
679
928
|
|
|
680
|
-
#
|
|
929
|
+
# Evals: the cases that grade an agent -------
|
|
681
930
|
# A case is DATA in the same YAML shape the corpus files use, so what an operator
|
|
682
931
|
# edits here is what a pull request would review. The one loader validates it, on
|
|
683
932
|
# the way in — a malformed case is a red flash, never a silently skipped test.
|
|
@@ -702,8 +951,8 @@ r.on "evals" do
|
|
|
702
951
|
end
|
|
703
952
|
|
|
704
953
|
# --- Refinement: what broke in real traffic, and what to do about it --
|
|
705
|
-
# `POST /refinement` runs the report
|
|
706
|
-
#
|
|
954
|
+
# `POST /refinement` runs the report. `POST
|
|
955
|
+
# refinement/propose` is: the configured model writes a candidate from
|
|
707
956
|
# the findings and the gate scores it by replaying the golden set. `POST
|
|
708
957
|
# /refinement/resolve` is a human approving or rejecting what the gate passed.
|
|
709
958
|
# All three go through the bus like every other Studio write — this page reads
|
|
@@ -748,6 +997,156 @@ end
|
|
|
748
997
|
end
|
|
749
998
|
end
|
|
750
999
|
|
|
1000
|
+
# --- Harvest: the gated skill loop ----------------------
|
|
1001
|
+
# Reads the HarvestStore directly (the Studio's constitutional split);
|
|
1002
|
+
# every mutation — mine now, gate, promote, reject, rollback — dispatches
|
|
1003
|
+
# a bus command. The gate POST is slow on purpose: it replays the golden
|
|
1004
|
+
# set, like Refinement's propose button.
|
|
1005
|
+
r.on "harvest" do
|
|
1006
|
+
r.is do
|
|
1007
|
+
r.get { render_harvest }
|
|
1008
|
+
r.post do # mine now (manual trigger, D10)
|
|
1009
|
+
check_csrf!
|
|
1010
|
+
agent = presence(r.params["agent"])
|
|
1011
|
+
payload = { agent: agent, full: r.params["full"] == "1" }
|
|
1012
|
+
control_action(:run_harvest, payload, ok: "Harvest run finished.")
|
|
1013
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
r.post "gate" do # the double gate on one candidate (slow — replay)
|
|
1018
|
+
check_csrf!
|
|
1019
|
+
agent = presence(r.params["agent"])
|
|
1020
|
+
payload = { candidate_id: presence(r.params["candidate_id"]) }
|
|
1021
|
+
control_action(:gate_harvest, payload, ok: "Gated — see the verdicts below.")
|
|
1022
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
r.post "promote" do
|
|
1026
|
+
check_csrf!
|
|
1027
|
+
agent = presence(r.params["agent"])
|
|
1028
|
+
payload = { candidate_id: presence(r.params["candidate_id"]),
|
|
1029
|
+
operator: "studio", note: presence(r.params["note"]) }.compact
|
|
1030
|
+
control_action(:promote_harvest, payload,
|
|
1031
|
+
ok: "Promoted — the skill is live for this store.")
|
|
1032
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
r.post "reject" do
|
|
1036
|
+
check_csrf!
|
|
1037
|
+
agent = presence(r.params["agent"])
|
|
1038
|
+
payload = { candidate_id: presence(r.params["candidate_id"]),
|
|
1039
|
+
operator: "studio", note: presence(r.params["note"]) }.compact
|
|
1040
|
+
control_action(:reject_harvest, payload, ok: "Skill rejected.")
|
|
1041
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
r.post "rollback" do
|
|
1045
|
+
check_csrf!
|
|
1046
|
+
agent = presence(r.params["agent"])
|
|
1047
|
+
payload = { snapshot_ref: presence(r.params["snapshot_ref"]),
|
|
1048
|
+
operator: "studio", reason: presence(r.params["reason"]) }.compact
|
|
1049
|
+
control_action(:rollback_harvest, payload, ok: "Rolled back to the snapshot.")
|
|
1050
|
+
r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1051
|
+
end
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
# --- Facts: the human gate on distilled proposals --------
|
|
1055
|
+
# Reads ProposalStore directly (the Studio's constitutional split — reads
|
|
1056
|
+
# hit the stores, mutations go through the bus); the ONLY mutation — the
|
|
1057
|
+
# approve/reject/dismiss answer — dispatches :resolve_proposal.
|
|
1058
|
+
r.on "facts" do
|
|
1059
|
+
r.get { render_facts }
|
|
1060
|
+
r.post "resolve" do
|
|
1061
|
+
check_csrf!
|
|
1062
|
+
decision = presence(r.params["decision"])
|
|
1063
|
+
ok = { "approved" => "Fact saved to memory.", "rejected" => "Proposal rejected.",
|
|
1064
|
+
"dismissed" => "Proposal dismissed — it will not be proposed again." }[decision]
|
|
1065
|
+
with_flash(ok || "Proposal resolved.") do
|
|
1066
|
+
dispatch(:resolve_proposal, { proposal_id: presence(r.params["proposal_id"]),
|
|
1067
|
+
decision: decision, note: presence(r.params["note"]),
|
|
1068
|
+
operator: "studio" })
|
|
1069
|
+
end
|
|
1070
|
+
filter = presence(r.params["filter"])
|
|
1071
|
+
r.redirect("/studio/facts#{filter ? "?store=#{Rack::Utils.escape(filter)}" : ""}")
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# --- Parity: the running shadow verdict -----------------
|
|
1076
|
+
# Reads stores and folds the verdict on demand; the judge button is a slow
|
|
1077
|
+
# synchronous POST, exactly like Refinement's gate, for the same reason: it
|
|
1078
|
+
# returns when the answer is real. There is deliberately no "freeze the
|
|
1079
|
+
# criterion" button — freezing is a git commit, reviewed by a human.
|
|
1080
|
+
r.on "parity" do
|
|
1081
|
+
r.is do
|
|
1082
|
+
r.get { render_parity }
|
|
1083
|
+
r.post do
|
|
1084
|
+
check_csrf!
|
|
1085
|
+
agent = presence(r.params["agent"])
|
|
1086
|
+
limit = presence(r.params["limit"]).to_i
|
|
1087
|
+
payload = { agent: agent }.compact
|
|
1088
|
+
payload[:limit] = limit if limit.positive?
|
|
1089
|
+
control_action(:judge_shadow_pairs, payload,
|
|
1090
|
+
ok: "Judged — the verdict below is recomputed.")
|
|
1091
|
+
r.redirect("/studio/parity?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
1092
|
+
end
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# --- Funnel: the outcome funnel per store ----------------
|
|
1097
|
+
# Reads the fold's cells + the BudgetLedger's current counters directly
|
|
1098
|
+
# (D7 — the scorecard's constitutional split); the ONE mutation — the
|
|
1099
|
+
# baseline freeze — dispatches :freeze_funnel_baseline on the bus.
|
|
1100
|
+
r.on "funnel" do
|
|
1101
|
+
r.get { render_funnel }
|
|
1102
|
+
r.on String do |agent_id|
|
|
1103
|
+
agent_id = utf8(agent_id)
|
|
1104
|
+
r.post "freeze" do
|
|
1105
|
+
check_csrf!
|
|
1106
|
+
with_flash("Baseline frozen.") do
|
|
1107
|
+
dispatch(:freeze_funnel_baseline,
|
|
1108
|
+
{ agent: agent_id,
|
|
1109
|
+
from: presence(r.params["from"]),
|
|
1110
|
+
to: presence(r.params["to"]),
|
|
1111
|
+
# the card's hidden tenant field: WITHOUT it, a
|
|
1112
|
+
# multi-tenant deployment freezes the "platform" pair
|
|
1113
|
+
# while the operator believes they froze the store's.
|
|
1114
|
+
tenant: presence(r.params["tenant"]),
|
|
1115
|
+
operator: "studio" })
|
|
1116
|
+
end
|
|
1117
|
+
r.redirect("/studio/funnel")
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# --- Follow-ups: the schedule records + the A/B readout --
|
|
1123
|
+
# Reads FollowupStore/ContactStore/OutcomeStore directly (D10 — the
|
|
1124
|
+
# Studio's constitutional split); the ONLY mutations — cancel a pending
|
|
1125
|
+
# record, force-revoke a contact (an opt-out event that never arrived) —
|
|
1126
|
+
# dispatch bus commands.
|
|
1127
|
+
r.on "followups" do
|
|
1128
|
+
r.get { render_followups }
|
|
1129
|
+
r.on String do |agent_id|
|
|
1130
|
+
agent_id = utf8(agent_id)
|
|
1131
|
+
r.post "cancel" do
|
|
1132
|
+
check_csrf!
|
|
1133
|
+
with_flash("Follow-up cancelled.") do
|
|
1134
|
+
dispatch(:cancel_followup, { followup_id: r.params["id"] })
|
|
1135
|
+
end
|
|
1136
|
+
r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
|
|
1137
|
+
end
|
|
1138
|
+
r.post "revoke" do
|
|
1139
|
+
check_csrf!
|
|
1140
|
+
with_flash("Contact revoked — every pending follow-up cancelled.") do
|
|
1141
|
+
dispatch(:revoke_contact,
|
|
1142
|
+
{ customer: r.params["customer"], operator: "studio" },
|
|
1143
|
+
tenant: presence(r.params["tenant"]))
|
|
1144
|
+
end
|
|
1145
|
+
r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
end
|
|
1149
|
+
|
|
751
1150
|
# Playground: sends `send_message` (the SAME Command as the API) and streams the
|
|
752
1151
|
# response live through the `live-transcript` island (SSE from /studio/events).
|
|
753
1152
|
r.on "playground" do
|
|
@@ -755,7 +1154,15 @@ end
|
|
|
755
1154
|
@agent = presence(r.params["agent"]) || default_agent
|
|
756
1155
|
@session_id = presence(r.params["session_id"])
|
|
757
1156
|
@agents = insika[:profile_source].ids.sort
|
|
758
|
-
#
|
|
1157
|
+
# Session combobox: recent conversations as <datalist> suggestions for the
|
|
1158
|
+
# session field. The open session always appears, even if it fell off the
|
|
1159
|
+
# recent list (it is the one being edited).
|
|
1160
|
+
@recent = recent_sessions(limit: 8)
|
|
1161
|
+
if @session_id && @recent.none? { |s| s.id == @session_id }
|
|
1162
|
+
current = insika[:session_store]&.find(@session_id)
|
|
1163
|
+
@recent.unshift(current) if current
|
|
1164
|
+
end
|
|
1165
|
+
# Server-side echo + continuity: render the session's persisted
|
|
759
1166
|
# transcript as bubbles. The user's message is only persisted at the END
|
|
760
1167
|
# of the turn, so the just-sent message rides a one-shot flash bubble
|
|
761
1168
|
# (@sent_message) until it lands in history — an optimistic JS echo can't
|
|
@@ -772,7 +1179,7 @@ end
|
|
|
772
1179
|
# Blank session = new conversation: created via Command (create_session
|
|
773
1180
|
# generates the id — the Studio doesn't write to the store directly). A typed id
|
|
774
1181
|
# continues an existing conversation (send_message requires it to exist).
|
|
775
|
-
# The per-chat model pin
|
|
1182
|
+
# The per-chat model pin is set at creation and rides the whole
|
|
776
1183
|
# conversation, so it only applies to a NEW session — an existing one keeps
|
|
777
1184
|
# whatever it was pinned to.
|
|
778
1185
|
session_id = typed_session ||
|
|
@@ -780,7 +1187,7 @@ end
|
|
|
780
1187
|
provider: presence(r.params["provider"]),
|
|
781
1188
|
thinking: presence(r.params["thinking"]))
|
|
782
1189
|
dispatch_send_message(agent: agent, session_id: session_id, message: message)
|
|
783
|
-
# Optimistic echo of the just-sent message
|
|
1190
|
+
# Optimistic echo of the just-sent message: survives the redirect
|
|
784
1191
|
# as a one-shot flash, rendered as a user bubble on the next GET.
|
|
785
1192
|
flash["sent_message"] = message unless message.empty?
|
|
786
1193
|
r.redirect(playground_path(agent, session_id))
|
|
@@ -821,16 +1228,28 @@ end
|
|
|
821
1228
|
]],
|
|
822
1229
|
["operate", [
|
|
823
1230
|
["Chats", "/studio/chats", :chats],
|
|
1231
|
+
["Customers", "/studio/customers", :chats],
|
|
824
1232
|
["Playground", "/studio/playground", :playground],
|
|
1233
|
+
["Funnel", "/studio/funnel", :funnel],
|
|
1234
|
+
# the Follow-ups page — scheduled, fired, blocked and
|
|
1235
|
+
# cancelled records per agent + the per-arm A/B readout card.
|
|
1236
|
+
["Follow-ups", "/studio/followups", :followups],
|
|
1237
|
+
# the Facts (wiki) page — the human gate on distilled
|
|
1238
|
+
# proposals (approve/reject/dismiss, the latch, the CAS re-present).
|
|
1239
|
+
["Facts", "/studio/facts", :facts],
|
|
1240
|
+
# the Harvest page — the human gate on mined skills
|
|
1241
|
+
# (gate, promote, reject, rollback; the append-only promotion log).
|
|
1242
|
+
["Harvest", "/studio/harvest", :harvest],
|
|
825
1243
|
["Tasks", "/studio/tasks", :tasks],
|
|
826
1244
|
["Approvals", "/studio/approvals", :approvals],
|
|
827
1245
|
["Refinement", "/studio/refinement", :refinement],
|
|
828
1246
|
["Evals", "/studio/evals", :evals]
|
|
829
|
-
]
|
|
1247
|
+
] + parity_nav_item
|
|
1248
|
+
]
|
|
830
1249
|
]
|
|
831
1250
|
end
|
|
832
1251
|
|
|
833
|
-
# NAV_ICONS + nav_icon moved to Studio::NavIcons
|
|
1252
|
+
# NAV_ICONS + nav_icon moved to Studio::NavIcons.
|
|
834
1253
|
|
|
835
1254
|
def authenticated? = session["auth"] == true
|
|
836
1255
|
|
|
@@ -907,14 +1326,14 @@ end
|
|
|
907
1326
|
|
|
908
1327
|
def default_agent
|
|
909
1328
|
ids = insika[:profile_source].ids
|
|
910
|
-
ids.include?("
|
|
1329
|
+
ids.include?("demo") ? "demo" : (ids.first || "demo")
|
|
911
1330
|
end
|
|
912
1331
|
|
|
913
1332
|
# --- Transcript display helpers (playground + session viewer) ------------
|
|
914
1333
|
|
|
915
1334
|
# A stored message's content for display: strings as-is; structured payloads
|
|
916
1335
|
# as pretty JSON. NEVER Ruby #inspect (session.erb:45 leaked hashrockets to the
|
|
917
|
-
# operator)
|
|
1336
|
+
# operator)..
|
|
918
1337
|
def message_content(content)
|
|
919
1338
|
return content.to_s if content.is_a?(String)
|
|
920
1339
|
|
|
@@ -923,6 +1342,29 @@ end
|
|
|
923
1342
|
content.to_s
|
|
924
1343
|
end
|
|
925
1344
|
|
|
1345
|
+
# An activation label from the context trace, as the operator reads it:
|
|
1346
|
+
# "gift-concierge · trigger:presente". The REASON is the point of the card — a
|
|
1347
|
+
# bare list of names answers "was something injected", never "which one did I
|
|
1348
|
+
# trigger". A label with no reason (a plugin-supplied body) shows just the name.
|
|
1349
|
+
def skill_label(label)
|
|
1350
|
+
return label.to_s unless label.is_a?(Hash)
|
|
1351
|
+
|
|
1352
|
+
name = label["name"].to_s
|
|
1353
|
+
reason = label["reason"].to_s
|
|
1354
|
+
reason.empty? ? name : "#{name} · #{reason}"
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
# One word for the whole turn, for the collapsed summary: the shared reason when
|
|
1358
|
+
# every body arrived the same way, "mixed" when they did not. A turn CAN mix (the
|
|
1359
|
+
# agent's eager set plus what this message triggered), which is exactly why the
|
|
1360
|
+
# single per-turn `mode` this replaced was a lie waiting to happen.
|
|
1361
|
+
def skill_mode(labels)
|
|
1362
|
+
kinds = Array(labels).map { |l| (l.is_a?(Hash) ? l["reason"].to_s : "").split(":").first }.uniq
|
|
1363
|
+
return "context" if kinds.empty? || kinds.any?(&:nil?) || kinds.include?("")
|
|
1364
|
+
|
|
1365
|
+
kinds.length == 1 ? kinds.first : "mixed"
|
|
1366
|
+
end
|
|
1367
|
+
|
|
926
1368
|
# ISO8601 → "HH:MM" for a transcript timestamp; "" when unparseable/absent.
|
|
927
1369
|
def short_time(iso)
|
|
928
1370
|
Time.parse(iso.to_s).strftime("%H:%M")
|
|
@@ -971,6 +1413,7 @@ end
|
|
|
971
1413
|
|
|
972
1414
|
def render_agent_detail
|
|
973
1415
|
id = @agent.id
|
|
1416
|
+
@config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
|
|
974
1417
|
store = insika[:agent_file_store]
|
|
975
1418
|
@prompt_files = Array(@agent.prompt_files).map do |name|
|
|
976
1419
|
{
|
|
@@ -981,16 +1424,24 @@ end
|
|
|
981
1424
|
end
|
|
982
1425
|
@all_skills = insika[:skill_catalog]&.all || []
|
|
983
1426
|
@agent_skills = @agent.skills.nil? ? nil : Array(@agent.skills).map(&:to_s)
|
|
984
|
-
# v2
|
|
1427
|
+
# v2 config surfaces: generation params + model fence. AgentProfile.build
|
|
985
1428
|
# string-keys these hashes, so the form helpers read plain string keys.
|
|
986
1429
|
@params = @agent.params
|
|
987
1430
|
@model_policy_allow = model_policy_allow(@agent)
|
|
988
|
-
# guardrails config
|
|
1431
|
+
# guardrails config; nil when the agent never configured any.
|
|
989
1432
|
@guardrails = @agent.guardrails || {}
|
|
990
1433
|
mem = insika[:memory_store]
|
|
991
1434
|
@facts = mem ? mem.facts(tenant: id) : []
|
|
992
1435
|
@notes = mem ? mem.notes(tenant: id, limit: 20) : []
|
|
993
1436
|
@recent_sessions = recent_sessions
|
|
1437
|
+
# WS7: last outcome + per-day series for THIS agent. The grid already
|
|
1438
|
+
# shows the last-outcome pill; the series is the period view.
|
|
1439
|
+
outcomes = insika[:outcome_store]
|
|
1440
|
+
@latest_outcome = outcomes&.latest_per_agent&.[](id)
|
|
1441
|
+
@outcome_series = outcomes ? outcomes.series(agent: id) : {}
|
|
1442
|
+
# the per-agent cache-hit series (nil store -> the view's
|
|
1443
|
+
# empty state; same guard as the tool trace).
|
|
1444
|
+
@cache_series = insika[:cache_series_store]&.for_agent(id) || []
|
|
994
1445
|
view("agent_detail")
|
|
995
1446
|
end
|
|
996
1447
|
|
|
@@ -1002,8 +1453,95 @@ end
|
|
|
1002
1453
|
params[key.to_s].nil? ? "" : params[key.to_s]
|
|
1003
1454
|
end
|
|
1004
1455
|
|
|
1456
|
+
# A free-form config block (string-keyed by AgentProfile.build) rendered
|
|
1457
|
+
# as the JSON textarea the form parses back. Pretty-printed so an operator
|
|
1458
|
+
# actually reads the current state; "" when the block is absent.
|
|
1459
|
+
def agent_json(config)
|
|
1460
|
+
return "" if config.nil? || config.empty?
|
|
1461
|
+
|
|
1462
|
+
JSON.pretty_generate(config)
|
|
1463
|
+
rescue StandardError
|
|
1464
|
+
config.to_s
|
|
1465
|
+
end
|
|
1466
|
+
|
|
1467
|
+
# A list field as comma-joined text for the form's textarea.
|
|
1468
|
+
def agent_list(list)
|
|
1469
|
+
Array(list).join(", ")
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
# A list field as newline-joined lines for the form's textarea.
|
|
1473
|
+
def agent_lines(list)
|
|
1474
|
+
Array(list).join("\n")
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# The shared "filter by agent" GET form used by every shared screen
|
|
1478
|
+
# (home, chats, tasks, approvals, customers, evals, funnel, follow-ups,
|
|
1479
|
+
# facts, parity). Same markup everywhere: a select that submits on change.
|
|
1480
|
+
def agent_filter_form(path, current)
|
|
1481
|
+
ids = insika[:profile_source].ids.sort
|
|
1482
|
+
options = [["", "all"]] + ids.map { |id| [id, id] }
|
|
1483
|
+
rows = options.map do |value, label|
|
|
1484
|
+
%(<option value="#{value}"#{' selected' if value.to_s == current.to_s}>#{label}</option>)
|
|
1485
|
+
end.join
|
|
1486
|
+
%(<form method="get" action="#{path}" class="actions inline"><label>Agent <select name="agent" onchange="this.form.submit()">#{rows}</select></label></form>)
|
|
1487
|
+
end
|
|
1488
|
+
|
|
1489
|
+
# The sessions of one agent — the session stamps its agent in
|
|
1490
|
+
# `vars["agent"]`, so any list of sessions is filterable by agent.
|
|
1491
|
+
def agent_sessions(sessions, agent)
|
|
1492
|
+
return sessions if agent.nil? || agent.empty?
|
|
1493
|
+
|
|
1494
|
+
sessions.select { |s| session_agent(s) == agent }
|
|
1495
|
+
end
|
|
1496
|
+
|
|
1497
|
+
def session_agent(session)
|
|
1498
|
+
vars = session.respond_to?(:vars) ? session.vars : nil
|
|
1499
|
+
vars.is_a?(Hash) ? vars["agent"].to_s : ""
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
# The distill button's scope (the DistillEngine's own scan, scoped to ONE
|
|
1503
|
+
# agent): the agent's sessions that are idle past the pack's window, long
|
|
1504
|
+
# enough, and not yet distilled. Oldest first, capped — each entry is a
|
|
1505
|
+
# provider call, so one click stays bounded. Reads hit the stores; the
|
|
1506
|
+
# writes go through :run_distillation on the bus.
|
|
1507
|
+
def due_distill_sessions(agent_id, limit: 5)
|
|
1508
|
+
store = insika[:session_store]
|
|
1509
|
+
proposals = insika[:proposal_store]
|
|
1510
|
+
profile = insika[:profile_source].fetch(agent_id)
|
|
1511
|
+
return [] if profile.nil? || store.nil? || proposals.nil?
|
|
1512
|
+
|
|
1513
|
+
config = Insika::Coercion.deep_stringify(profile.distill) || {}
|
|
1514
|
+
idle_hours = positive_int(config["idle_hours"]) || 6
|
|
1515
|
+
min_messages = positive_int(config["min_messages"]) || 3
|
|
1516
|
+
cutoff = Time.now.utc - idle_hours * 3600
|
|
1517
|
+
store.each_id.filter_map do |sid|
|
|
1518
|
+
session = store.find(sid)
|
|
1519
|
+
next unless session
|
|
1520
|
+
next unless session.vars.is_a?(Hash) && session.vars["agent"] == agent_id
|
|
1521
|
+
next if Insika::Coercion.blank?(session.vars["customer"])
|
|
1522
|
+
next unless aged?(session.updated_at, cutoff)
|
|
1523
|
+
next if session.messages.size < min_messages
|
|
1524
|
+
next if proposals.distilled?(sid)
|
|
1525
|
+
|
|
1526
|
+
[sid, session.updated_at.to_s]
|
|
1527
|
+
end.sort_by(&:last).map(&:first).first(limit)
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
def positive_int(value)
|
|
1531
|
+
v = value.to_i
|
|
1532
|
+
v.positive? ? v : nil
|
|
1533
|
+
end
|
|
1534
|
+
|
|
1535
|
+
def aged?(updated_at, cutoff)
|
|
1536
|
+
return false if Insika::Coercion.blank?(updated_at)
|
|
1537
|
+
|
|
1538
|
+
Time.iso8601(updated_at.to_s) <= cutoff
|
|
1539
|
+
rescue ArgumentError
|
|
1540
|
+
false
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1005
1543
|
# Renders the reasoning <select> shared by the agent config, settings and
|
|
1006
|
-
# playground (
|
|
1544
|
+
# playground (4-layer). `blank_label` names the empty option — the
|
|
1007
1545
|
# "inherit the broader layer" / provider-default choice. Values come from a
|
|
1008
1546
|
# fixed constant (safe to emit); `current` is only compared, never output.
|
|
1009
1547
|
def thinking_select(name, current, blank_label)
|
|
@@ -1047,21 +1585,34 @@ end
|
|
|
1047
1585
|
|
|
1048
1586
|
# Config patch from the form (native types: memory bool, limits int).
|
|
1049
1587
|
# Preserves the existing limits, overwriting only the form's fields.
|
|
1050
|
-
# `model` is OPTIONAL as of v2
|
|
1588
|
+
# `model` is OPTIONAL as of v2: blank clears it, so the agent inherits
|
|
1051
1589
|
# the platform `default_model` via the ModelResolver — the config form is the
|
|
1052
1590
|
# place that surfaces that layering. params/model_policy: see the helpers below.
|
|
1053
1591
|
# config_patch/guardrails_patch/guardrail_responses_patch/params_patch/
|
|
1054
|
-
# model_policy_patch/coerce moved to Studio::Forms
|
|
1592
|
+
# model_policy_patch/coerce moved to Studio::Forms.
|
|
1055
1593
|
|
|
1056
1594
|
# --- Skills index --------------------------------------------------------
|
|
1057
1595
|
|
|
1058
1596
|
# Master data for the Skills drill-down (list + authored badges + agents),
|
|
1059
1597
|
# shared by every skill route so the master pane always renders. @selected
|
|
1060
1598
|
# drives the detail pane (nil = none, "" = new, name = edit).
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1599
|
+
# `agent` renders the AGENT's view of the catalog: its own version of each shared
|
|
1600
|
+
# skill, plus whatever is private to it. Absent = the shared catalog.
|
|
1601
|
+
def load_skills_master(agent: nil)
|
|
1602
|
+
catalog = insika[:skill_catalog]
|
|
1603
|
+
@skills = (catalog ? catalog.all(agent: agent) : []).sort_by(&:name)
|
|
1604
|
+
@stored = insika[:skill_store] ? insika[:skill_store].names(agent: agent) : []
|
|
1064
1605
|
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
1606
|
+
# Which agents specialized THIS skill — the availability grid shows it, so an
|
|
1607
|
+
# override is discoverable from the shared skill it overrides.
|
|
1608
|
+
@specialized = insika[:skill_store] ? specialized_by : {}
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
# { skill name => [agent ids] } across every agent scope in the store.
|
|
1612
|
+
def specialized_by
|
|
1613
|
+
insika[:skill_store].agents.each_with_object({}) do |agent, acc|
|
|
1614
|
+
insika[:skill_store].names(agent: agent).each { |name| (acc[name] ||= []) << agent }
|
|
1615
|
+
end
|
|
1065
1616
|
end
|
|
1066
1617
|
|
|
1067
1618
|
def render_skills_index
|
|
@@ -1076,14 +1627,20 @@ end
|
|
|
1076
1627
|
# Raw content for the editor: prefer the store (real SKILL.md), otherwise
|
|
1077
1628
|
# reconstruct from what the catalog parsed (editing a disk skill
|
|
1078
1629
|
# creates an override in the store — Store wins).
|
|
1079
|
-
def skill_source(name)
|
|
1080
|
-
raw = insika[:skill_store]&.get(name)
|
|
1630
|
+
def skill_source(name, agent: nil)
|
|
1631
|
+
raw = insika[:skill_store]&.get(name, agent: agent)
|
|
1081
1632
|
return raw if raw
|
|
1082
1633
|
|
|
1083
|
-
skill = insika[:skill_catalog]&.find(name)
|
|
1634
|
+
skill = insika[:skill_catalog]&.find(name, agent: agent)
|
|
1084
1635
|
return new_skill_template(name) unless skill
|
|
1085
1636
|
|
|
1086
|
-
|
|
1637
|
+
# The WHOLE frontmatter, not just name/description: this text seeds the
|
|
1638
|
+
# specialize action and any first edit of a disk skill, and an override that
|
|
1639
|
+
# silently drops `triggers:` turns the agent's deterministic activation off.
|
|
1640
|
+
fm = ["name: #{skill.name}", "description: #{skill.description}"]
|
|
1641
|
+
fm << "triggers: #{skill.triggers.join(', ')}" if Array(skill.triggers).any?
|
|
1642
|
+
fm << "companions: #{skill.companions.join(', ')}" if Array(skill.companions).any?
|
|
1643
|
+
"---\n#{fm.join("\n")}\n---\n\n#{skill.body}\n"
|
|
1087
1644
|
end
|
|
1088
1645
|
|
|
1089
1646
|
def new_skill_template(name = "my-skill")
|
|
@@ -1097,6 +1654,31 @@ end
|
|
|
1097
1654
|
profile.skills.nil? || Array(profile.skills).map(&:to_s).include?(skill_name.to_s)
|
|
1098
1655
|
end
|
|
1099
1656
|
|
|
1657
|
+
# Is this skill always-on for this agent? `skills_eager` is nil/false (none), true
|
|
1658
|
+
# (blanket) or a list — NOT allowlist semantics, so nil must read as false here.
|
|
1659
|
+
def skill_eager_for?(profile, skill_name)
|
|
1660
|
+
spec = profile.skills_eager
|
|
1661
|
+
return true if Insika::Coercion.truthy?(spec)
|
|
1662
|
+
return false if spec.nil? || spec == false
|
|
1663
|
+
|
|
1664
|
+
Array(spec).map(&:to_s).include?(skill_name.to_s)
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1667
|
+
# The two "left intact" outcomes of :set_skill_agents: an agent whose allowlist (or
|
|
1668
|
+
# eager set) is "all" cannot have ONE name removed from it without materializing an
|
|
1669
|
+
# explicit list, which would be a destructive surprise. Say so instead of silently
|
|
1670
|
+
# doing nothing.
|
|
1671
|
+
def note_skipped(result)
|
|
1672
|
+
return unless result
|
|
1673
|
+
|
|
1674
|
+
parts = []
|
|
1675
|
+
skipped = Array(result[:skipped_all])
|
|
1676
|
+
eager = Array(result[:skipped_eager_all])
|
|
1677
|
+
parts << "#{skipped.size} agent(s) with 'all' skills were left intact" if skipped.any?
|
|
1678
|
+
parts << "#{eager.size} agent(s) with blanket eager were left intact" if eager.any?
|
|
1679
|
+
flash["notice"] = "#{flash['notice']} — #{parts.join(', ')}." if parts.any?
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1100
1682
|
# --- Tools matrix --------------------------------------------------------
|
|
1101
1683
|
|
|
1102
1684
|
def render_tools_matrix
|
|
@@ -1137,7 +1719,7 @@ end
|
|
|
1137
1719
|
tools.count { |t| allow.include?(t.name.to_s) && !tool_denied_for?(profile, t.name) }
|
|
1138
1720
|
end
|
|
1139
1721
|
|
|
1140
|
-
#
|
|
1722
|
+
# Data-defined tool authoring -------------------------------
|
|
1141
1723
|
|
|
1142
1724
|
def render_tool_edit(name:, tool:)
|
|
1143
1725
|
@tool_name = name
|
|
@@ -1164,7 +1746,7 @@ end
|
|
|
1164
1746
|
}
|
|
1165
1747
|
end
|
|
1166
1748
|
|
|
1167
|
-
# tool_patch/parse_parameters moved to Studio::Forms
|
|
1749
|
+
# tool_patch/parse_parameters moved to Studio::Forms.
|
|
1168
1750
|
|
|
1169
1751
|
# Inverse of parse_parameters: the stored params -> text for the textarea. A schema
|
|
1170
1752
|
# the flat sugar can express round-trips as pipe lines (the friendly form); anything
|
|
@@ -1242,9 +1824,12 @@ end
|
|
|
1242
1824
|
# At-a-glance dashboard: counts + activity + recent conversations. All from
|
|
1243
1825
|
# data the Studio already reads (one scan of the session store); no new
|
|
1244
1826
|
# metrics pipeline. `active_now` = sessions touched in the last 5 minutes.
|
|
1827
|
+
# `?agent=` narrows every number to one agent (the sessions stamp their
|
|
1828
|
+
# author in `vars["agent"]`).
|
|
1245
1829
|
def render_home
|
|
1246
1830
|
ps = insika[:profile_source]
|
|
1247
|
-
|
|
1831
|
+
@agent = presence(request.params["agent"])
|
|
1832
|
+
sessions = agent_sessions(all_sessions, @agent)
|
|
1248
1833
|
@counts = {
|
|
1249
1834
|
"conversations" => sessions.size,
|
|
1250
1835
|
"messages" => sessions.sum { |s| Array(s.messages).size },
|
|
@@ -1324,6 +1909,10 @@ end
|
|
|
1324
1909
|
|
|
1325
1910
|
# --- Settings + LLM providers ----------------------------------
|
|
1326
1911
|
|
|
1912
|
+
# The agent config tab's groups, in sidebar order. The view renders the
|
|
1913
|
+
# same keys; a bogus ?cfg= falls back to the first group.
|
|
1914
|
+
CONFIG_SECTIONS = %w[model guardrails grounding funnel followups distill harvest refinement budget_rel routing advanced].freeze
|
|
1915
|
+
|
|
1327
1916
|
SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
|
|
1328
1917
|
def render_settings
|
|
1329
1918
|
store = insika[:settings_store]
|
|
@@ -1333,10 +1922,10 @@ end
|
|
|
1333
1922
|
view("settings")
|
|
1334
1923
|
end
|
|
1335
1924
|
|
|
1336
|
-
# Settings patch from the form. streaming
|
|
1337
|
-
#
|
|
1338
|
-
#
|
|
1339
|
-
# settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms
|
|
1925
|
+
# Settings patch from the form. streaming is a bool (checkbox); the timeouts
|
|
1926
|
+
# are integers. Only what came in the form enters the patch (the rest and
|
|
1927
|
+
# the defaults are preserved in the store).
|
|
1928
|
+
# settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms.
|
|
1340
1929
|
|
|
1341
1930
|
# --- MCP -------------------------------------------------------
|
|
1342
1931
|
|
|
@@ -1345,7 +1934,7 @@ end
|
|
|
1345
1934
|
view("mcp")
|
|
1346
1935
|
end
|
|
1347
1936
|
|
|
1348
|
-
# mcp_patch moved to Studio::Forms
|
|
1937
|
+
# mcp_patch moved to Studio::Forms.
|
|
1349
1938
|
|
|
1350
1939
|
# --- System-files ----------------------------------------------
|
|
1351
1940
|
|
|
@@ -1361,20 +1950,67 @@ end
|
|
|
1361
1950
|
# --- Chats -----------------------------------------------------
|
|
1362
1951
|
|
|
1363
1952
|
def render_chats
|
|
1364
|
-
@
|
|
1953
|
+
@agent = presence(request.params["agent"])
|
|
1954
|
+
@sessions = agent_sessions(recent_sessions(limit: 100), @agent)
|
|
1365
1955
|
view("chats")
|
|
1366
1956
|
end
|
|
1367
1957
|
|
|
1368
|
-
# ---
|
|
1958
|
+
# --- Customers ----------------------------------
|
|
1959
|
+
|
|
1960
|
+
# The index: every CUSTOMER memory cell, grouped by tenant. The caller
|
|
1961
|
+
# passes `reserved:` (profile ids + _default) so the agent-memory tab's
|
|
1962
|
+
# cells and the shared cell stay out of the drill (D6 — the store is
|
|
1963
|
+
# policy-free). Fact count per cell via a keys-only list (no payload
|
|
1964
|
+
# reads per conversation on every render).
|
|
1965
|
+
def render_customers
|
|
1966
|
+
mem = insika[:memory_store]
|
|
1967
|
+
agent_ids = insika[:profile_source] ? insika[:profile_source].all.map(&:id) : []
|
|
1968
|
+
@agent = presence(request.params["agent"])
|
|
1969
|
+
@rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
|
|
1970
|
+
{ cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
|
|
1971
|
+
end : []
|
|
1972
|
+
# ?agent= narrows to one tenant — the memory scope is the agent (the
|
|
1973
|
+
# agent-memory tab writes under tenant = agent id).
|
|
1974
|
+
@rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
|
|
1975
|
+
@by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
|
|
1976
|
+
view("customers")
|
|
1977
|
+
end
|
|
1978
|
+
|
|
1979
|
+
# The detail: the parsed cell, its facts (expired excluded by C1), notes
|
|
1980
|
+
# (append-only), and the operator audit (digests + counts, never content).
|
|
1981
|
+
def render_customer(scope, cell)
|
|
1982
|
+
@cell = cell
|
|
1983
|
+
mem = insika[:memory_store]
|
|
1984
|
+
@facts = mem ? mem.facts(tenant: cell[:tenant], customer: cell[:customer]) : []
|
|
1985
|
+
@notes = mem ? mem.notes(tenant: cell[:tenant], customer: cell[:customer], limit: 50) : []
|
|
1986
|
+
@audit = insika[:memory_audit_store]&.for_cell(scope) || []
|
|
1987
|
+
view("customer")
|
|
1988
|
+
end
|
|
1989
|
+
|
|
1990
|
+
def customer_path(scope)
|
|
1991
|
+
# escape_path, not escape: the route decodes with unescape_path, which
|
|
1992
|
+
# does not turn '+' back into a space (escape's form-encoding would).
|
|
1993
|
+
"/studio/customers/#{Rack::Utils.escape_path(scope)}"
|
|
1994
|
+
end
|
|
1995
|
+
|
|
1996
|
+
# Tasks & Approvals --------------------------------
|
|
1369
1997
|
|
|
1370
1998
|
# Task list, most-recently-updated first. Empty-state if no store was injected.
|
|
1999
|
+
# `?agent=` narrows to one agent (the task's command payload stamps it).
|
|
1371
2000
|
def render_tasks
|
|
1372
2001
|
store = insika[:task_store]
|
|
2002
|
+
@agent = presence(request.params["agent"])
|
|
1373
2003
|
@tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
|
|
2004
|
+
@tasks = @tasks.select { |t| task_agent(t) == @agent } if @agent
|
|
1374
2005
|
@tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
|
|
1375
2006
|
view("tasks")
|
|
1376
2007
|
end
|
|
1377
2008
|
|
|
2009
|
+
def task_agent(task)
|
|
2010
|
+
command = task.command
|
|
2011
|
+
command.is_a?(Hash) ? command.dig("payload", "agent").to_s : ""
|
|
2012
|
+
end
|
|
2013
|
+
|
|
1378
2014
|
# Task detail: @task is set by the route. Adds the open approvals for this task
|
|
1379
2015
|
# and its latest checkpoint (both degrade to empty when the store is absent).
|
|
1380
2016
|
def render_task_detail(id)
|
|
@@ -1384,15 +2020,20 @@ end
|
|
|
1384
2020
|
end
|
|
1385
2021
|
|
|
1386
2022
|
# Approvals inbox: every :pending action across tasks, each paired with its
|
|
1387
|
-
# task (for the status pill + a link into the task detail).
|
|
2023
|
+
# task (for the status pill + a link into the task detail). `?agent=`
|
|
2024
|
+
# narrows to one agent (via the owning task).
|
|
1388
2025
|
def render_approvals
|
|
1389
2026
|
pstore = insika[:pending_action_store]
|
|
1390
2027
|
tstore = insika[:task_store]
|
|
2028
|
+
@agent = presence(request.params["agent"])
|
|
1391
2029
|
@approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
|
|
2030
|
+
if @agent
|
|
2031
|
+
@approvals = @approvals.select { |a| task_agent(a[:task]) == @agent if a[:task] }
|
|
2032
|
+
end
|
|
1392
2033
|
view("approvals")
|
|
1393
2034
|
end
|
|
1394
2035
|
|
|
1395
|
-
#
|
|
2036
|
+
# Evals -------------------------------------
|
|
1396
2037
|
|
|
1397
2038
|
# The stored cases, grouped by agent, plus the one being edited (?id=). Cases whose
|
|
1398
2039
|
# stored mapping no longer validates are listed separately: a broken case must be
|
|
@@ -1401,6 +2042,8 @@ def render_evals
|
|
|
1401
2042
|
store = insika[:golden_store]
|
|
1402
2043
|
@cases = store ? store.all : []
|
|
1403
2044
|
@invalid = store ? store.invalid : []
|
|
2045
|
+
@filter = presence(request.params["agent"])
|
|
2046
|
+
@cases = @cases.select { |g| g.agent == @filter } if @filter
|
|
1404
2047
|
@by_agent = @cases.group_by(&:agent).sort.to_h
|
|
1405
2048
|
wanted = presence(request.params["id"])
|
|
1406
2049
|
@case = wanted && @cases.find { |g| g.id == wanted }
|
|
@@ -1417,7 +2060,7 @@ def golden_yaml(golden)
|
|
|
1417
2060
|
YAML.dump(h.merge("expect" => golden.expect))
|
|
1418
2061
|
end
|
|
1419
2062
|
|
|
1420
|
-
#
|
|
2063
|
+
# Refinement -----------------------------
|
|
1421
2064
|
|
|
1422
2065
|
# The agent's latest failure report + its run history. Empty-state when no store
|
|
1423
2066
|
# was injected or the agent has never been run — the page is the invitation to
|
|
@@ -1434,6 +2077,331 @@ end
|
|
|
1434
2077
|
view("refinement")
|
|
1435
2078
|
end
|
|
1436
2079
|
|
|
2080
|
+
# --- Parity -------------------------------------------------
|
|
2081
|
+
|
|
2082
|
+
# The nav row exists ONLY when a shadow channel is registered — the page is
|
|
2083
|
+
# not an invitation, unlike Refinement's.
|
|
2084
|
+
def parity_nav_item
|
|
2085
|
+
shadow_channel? ? [["Parity", "/studio/parity", :parity]] : []
|
|
2086
|
+
end
|
|
2087
|
+
|
|
2088
|
+
def shadow_channel?
|
|
2089
|
+
registry = insika[:channel_registry]
|
|
2090
|
+
return false unless registry
|
|
2091
|
+
|
|
2092
|
+
Array(registry.names).any? do |name|
|
|
2093
|
+
channel = registry.find(name)
|
|
2094
|
+
channel.respond_to?(:shadow?) && channel.shadow?
|
|
2095
|
+
end
|
|
2096
|
+
end
|
|
2097
|
+
|
|
2098
|
+
def parity_pairs
|
|
2099
|
+
insika[:shadow_pair_store] ? insika[:shadow_pair_store].each.to_a : []
|
|
2100
|
+
end
|
|
2101
|
+
|
|
2102
|
+
def render_parity
|
|
2103
|
+
@criterion = insika[:parity_criterion]
|
|
2104
|
+
@agent = presence(request.params["agent"])
|
|
2105
|
+
# ONE materialization per request: the fold and the pair list share this
|
|
2106
|
+
# array (the store's counts would scan the whole table a second time).
|
|
2107
|
+
pairs = parity_pairs
|
|
2108
|
+
pairs = pairs.select { |p| p.agent.to_s == @agent } if @agent
|
|
2109
|
+
@report = @criterion && Insika::Parity::Verdict.fold(pairs: pairs, criterion: @criterion)
|
|
2110
|
+
@pairs = pairs.sort_by { |p| p.created_at.to_s }.reverse.first(50)
|
|
2111
|
+
@pending = pairs.count { |p| p.status == :complete }
|
|
2112
|
+
if @criterion
|
|
2113
|
+
@judge_cost = @pending * @criterion.rule.min_judge_models * 2
|
|
2114
|
+
@judge_models = ((insika[:settings_store]&.get || {})["evals"] || {})
|
|
2115
|
+
end
|
|
2116
|
+
# Judge agreement : split/unknown from the fold, plus how many
|
|
2117
|
+
# verdicts flipped with presentation order.
|
|
2118
|
+
@agreement = { order_dependent: pairs.count { |p| p.verdict.is_a?(Hash) && p.verdict["order_dependent"] } }
|
|
2119
|
+
view("parity")
|
|
2120
|
+
end
|
|
2121
|
+
|
|
2122
|
+
# --- Funnel ------------------------------------------------
|
|
2123
|
+
|
|
2124
|
+
# One card per STORE (agent) that declares a funnel, each with a block per
|
|
2125
|
+
# tenant that has folded cells. The only mutation on this page — the
|
|
2126
|
+
# baseline freeze — dispatches :freeze_funnel_baseline (D7).
|
|
2127
|
+
def render_funnel
|
|
2128
|
+
profiles = insika[:profile_source]&.all || []
|
|
2129
|
+
p = request.params["period"].to_i
|
|
2130
|
+
@period = p.positive? ? p : 30
|
|
2131
|
+
@agent = presence(request.params["agent"])
|
|
2132
|
+
profiles = profiles.select { |p| p.id == @agent } if @agent
|
|
2133
|
+
@stores = profiles.filter_map do |p|
|
|
2134
|
+
decl = Insika::FunnelDeclaration.parse(p.funnel)
|
|
2135
|
+
next unless decl
|
|
2136
|
+
|
|
2137
|
+
{ profile: p, decl: decl, tenants: tenants_for(p.id) }
|
|
2138
|
+
end
|
|
2139
|
+
view("funnel")
|
|
2140
|
+
end
|
|
2141
|
+
|
|
2142
|
+
# the Follow-ups page. Per agent WITH a parsed policy, one
|
|
2143
|
+
# block: the pending/fired/cancelled/blocked lists (blocked rows carry the
|
|
2144
|
+
# reason pill), the per-arm counts, the read-only policy summary and the
|
|
2145
|
+
# A/B card (D10 — reads stores, mutates through bus commands only).
|
|
2146
|
+
def render_followups
|
|
2147
|
+
profiles = insika[:profile_source]&.all || []
|
|
2148
|
+
@agent = presence(request.params["agent"])
|
|
2149
|
+
profiles = profiles.select { |p| p.id == @agent } if @agent
|
|
2150
|
+
@stores = profiles.filter_map do |p|
|
|
2151
|
+
decl = Insika::FollowupPolicy.parse(p.followup)
|
|
2152
|
+
next unless decl
|
|
2153
|
+
|
|
2154
|
+
{
|
|
2155
|
+
profile: p, policy: decl,
|
|
2156
|
+
tenant: presence(request.params["tenant"]) || "platform",
|
|
2157
|
+
records: followup_records(p.id, decl),
|
|
2158
|
+
ab: followup_ab(p.id, decl)
|
|
2159
|
+
}
|
|
2160
|
+
end
|
|
2161
|
+
@has_followups = !@stores.empty?
|
|
2162
|
+
view("followups")
|
|
2163
|
+
end
|
|
2164
|
+
|
|
2165
|
+
# the Facts (wiki) page — the human gate on distilled
|
|
2166
|
+
# proposals. Reads ProposalStore + SessionStore directly (D7 — evidence is
|
|
2167
|
+
# a link, never a copy: the excerpt is read from the session at request
|
|
2168
|
+
# time); the mutations dispatch :resolve_proposal on the bus.
|
|
2169
|
+
def render_facts
|
|
2170
|
+
store = insika[:proposal_store]
|
|
2171
|
+
@pending = store ? store.pending(limit: 100) : []
|
|
2172
|
+
@stale = store ? store.stale(limit: 50) : []
|
|
2173
|
+
@recent = store ? store.resolved(limit: 20) : []
|
|
2174
|
+
# `?agent=` filters by the proposal's AGENT (the origin session's
|
|
2175
|
+
# stamp); `?store=` keeps filtering by the memory-cell scope (the
|
|
2176
|
+
# pre-review behavior). Both narrow the same three lists.
|
|
2177
|
+
@filter = presence(request.params["agent"]) || presence(request.params["store"])
|
|
2178
|
+
if @filter
|
|
2179
|
+
@pending = @pending.select { |p| scope_matches?(p, @filter) }
|
|
2180
|
+
@stale = @stale.select { |p| scope_matches?(p, @filter) }
|
|
2181
|
+
@recent = @recent.select { |p| scope_matches?(p, @filter) }
|
|
2182
|
+
end
|
|
2183
|
+
# The select: the filtered agents (profile ids) PLUS every scope a
|
|
2184
|
+
# proposal ever carried, so an agent with proposals and one without can
|
|
2185
|
+
# both be picked.
|
|
2186
|
+
@stores = (insika[:profile_source].ids.sort +
|
|
2187
|
+
(@pending + @stale + @recent).map(&:scope)).uniq.sort
|
|
2188
|
+
@sessions = insika[:session_store]
|
|
2189
|
+
view("facts")
|
|
2190
|
+
end
|
|
2191
|
+
|
|
2192
|
+
# A proposal matches the filter as the memory scope it carries, or as the
|
|
2193
|
+
# agent of the session it came from (when the session is still around).
|
|
2194
|
+
def scope_matches?(proposal, filter)
|
|
2195
|
+
return true if proposal.scope == filter
|
|
2196
|
+
|
|
2197
|
+
session = insika[:session_store]&.find(proposal.session_ref)
|
|
2198
|
+
session && session_agent(session) == filter
|
|
2199
|
+
end
|
|
2200
|
+
|
|
2201
|
+
# the Harvest page — the human gate on mined skills. Reads
|
|
2202
|
+
# HarvestStore/SessionStore directly (the evidence excerpt is read from the
|
|
2203
|
+
# session at request time — D7: ids and indexes only are stored); every
|
|
2204
|
+
# mutation dispatches a bus command.
|
|
2205
|
+
def render_harvest
|
|
2206
|
+
@agents = insika[:profile_source].ids.sort
|
|
2207
|
+
@agent = presence(request.params["agent"]) || @agents.first
|
|
2208
|
+
store = insika[:harvest_store]
|
|
2209
|
+
# EVERY list respects the agent filter (the review fix) — the page is a
|
|
2210
|
+
# per-store inbox, not a global one.
|
|
2211
|
+
@candidates = @agent && store ? store.candidates(agent_id: @agent, status: "awaiting_approval") : []
|
|
2212
|
+
@pending_gates = @agent && store ? store.candidates(agent_id: @agent, status: "pending").first(50) : []
|
|
2213
|
+
@blocked = if @agent && store
|
|
2214
|
+
store.candidates(agent_id: @agent, status: "gated")
|
|
2215
|
+
.select { |c| !c.eval_gate || !c.conversion_gate || c.conversion_gate["passed"] == false }
|
|
2216
|
+
.first(50)
|
|
2217
|
+
else
|
|
2218
|
+
[]
|
|
2219
|
+
end
|
|
2220
|
+
@recent = @agent && store ? store.candidates(agent_id: @agent, status: "promoted").first(20) : []
|
|
2221
|
+
@runs = @agent && store ? store.runs_for(@agent, limit: 5) : []
|
|
2222
|
+
@promotions = @agent && store ? store.promotions(agent_id: @agent, limit: 50) : []
|
|
2223
|
+
@criterion = insika[:harvest_criterion]
|
|
2224
|
+
@funnel = insika[:funnel_store]
|
|
2225
|
+
@sessions = insika[:session_store]
|
|
2226
|
+
@negative = insika[:negative_list]
|
|
2227
|
+
@filter = presence(request.params["agent"])
|
|
2228
|
+
view("harvest")
|
|
2229
|
+
end
|
|
2230
|
+
|
|
2231
|
+
# The evidence excerpt for a candidate — the origin sessions' messages at
|
|
2232
|
+
# the stored indexes, read at request time (D7: ids and indexes only).
|
|
2233
|
+
#
|
|
2234
|
+
# An index is validated by the miner as "fits at least one origin
|
|
2235
|
+
# session", so it must NOT be rendered against every origin session — the
|
|
2236
|
+
# same index would point at different conversations (the review fix).
|
|
2237
|
+
# Each index renders ONE message, from the first origin session where it
|
|
2238
|
+
# is in range, labeled with that session.
|
|
2239
|
+
def candidate_excerpt(candidate)
|
|
2240
|
+
Array(candidate.evidence_turns).filter_map do |i|
|
|
2241
|
+
session = Array(candidate.origin).filter_map { |sid| @sessions&.find(sid) }
|
|
2242
|
+
.find { |s| s.messages && s.messages[i] }
|
|
2243
|
+
next unless session
|
|
2244
|
+
|
|
2245
|
+
[session.id, i, session.messages[i]["content"].to_s]
|
|
2246
|
+
end
|
|
2247
|
+
end
|
|
2248
|
+
|
|
2249
|
+
# The negative list's rejection counts per rule, read from the RUN
|
|
2250
|
+
# records (D4 — the counts the Studio shows are what the engine logged:
|
|
2251
|
+
# the run carries { rule_id => count, "ungrounded" => N, ... }).
|
|
2252
|
+
def negative_rule_counts
|
|
2253
|
+
store = insika[:harvest_store]
|
|
2254
|
+
return {} unless store
|
|
2255
|
+
|
|
2256
|
+
store.runs_for(@agent || "", limit: 200).each_with_object(Hash.new(0)) do |run, acc|
|
|
2257
|
+
Array(run.rejected).each { |rule, n| acc[rule] += n.to_i }
|
|
2258
|
+
end
|
|
2259
|
+
end
|
|
2260
|
+
|
|
2261
|
+
# The excerpt for a proposal's evidence — the transcript messages at the
|
|
2262
|
+
# stored indexes, read at request time (D7: ids and indexes only are
|
|
2263
|
+
# stored). Deep-links to the existing session page.
|
|
2264
|
+
def proposal_excerpt(proposal)
|
|
2265
|
+
session = @sessions&.find(proposal.session_ref)
|
|
2266
|
+
return [] unless session && session.messages
|
|
2267
|
+
|
|
2268
|
+
Array(proposal.evidence).filter_map do |i|
|
|
2269
|
+
session.messages[i] && session.messages[i]["content"].to_s
|
|
2270
|
+
end
|
|
2271
|
+
end
|
|
2272
|
+
|
|
2273
|
+
# The agent's records across every status (a nil store reads as none).
|
|
2274
|
+
def followup_records(agent_id, _decl)
|
|
2275
|
+
store = insika[:followup_store]
|
|
2276
|
+
return [] unless store
|
|
2277
|
+
|
|
2278
|
+
store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
|
|
2279
|
+
agent: agent_id)
|
|
2280
|
+
end
|
|
2281
|
+
|
|
2282
|
+
# The A/B readout per arm (D3 — a read-only fold over the same records):
|
|
2283
|
+
# sent = fired count in the period;
|
|
2284
|
+
# conversions = outcome records whose session_id is a fired record's
|
|
2285
|
+
# session (primary-filtered by the declaration
|
|
2286
|
+
# when one exists, else the first outcome per session);
|
|
2287
|
+
# opt-outs = contact cells flipped :revoked after a fired_at.
|
|
2288
|
+
# The note anchors the ruler outside the card: the A/B is the operator's;
|
|
2289
|
+
# this card reads the baseline.
|
|
2290
|
+
def followup_ab(agent_id, _decl)
|
|
2291
|
+
store = insika[:followup_store]
|
|
2292
|
+
return {} unless store
|
|
2293
|
+
|
|
2294
|
+
from = (Time.now.utc - 30 * 86_400).iso8601
|
|
2295
|
+
fired = store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
|
|
2296
|
+
agent: agent_id)
|
|
2297
|
+
.select { |r| r.status == "fired" && r.at.to_s >= from }
|
|
2298
|
+
fired.group_by(&:arm).each_with_object({}) do |(arm, records), acc|
|
|
2299
|
+
sessions = records.filter_map(&:session_id)
|
|
2300
|
+
acc[arm] = { sent: records.size, conversions: conversions_for(agent_id, sessions),
|
|
2301
|
+
opt_outs: opt_outs_since(records) }
|
|
2302
|
+
end
|
|
2303
|
+
end
|
|
2304
|
+
|
|
2305
|
+
# The conversions of the arm's fired sessions. Primary-filtered by the
|
|
2306
|
+
# funnel declaration when the agent declares one; else the FIRST
|
|
2307
|
+
# outcome per session (an outcome record is one vote per session).
|
|
2308
|
+
def conversions_for(agent_id, sessions)
|
|
2309
|
+
outcomes = insika[:outcome_store]
|
|
2310
|
+
return 0 unless outcomes
|
|
2311
|
+
|
|
2312
|
+
records = sessions.empty? ? [] : outcomes.all.select { |r| sessions.include?(r.session_id) }
|
|
2313
|
+
primary = primary_stage_for(agent_id)
|
|
2314
|
+
return records.count { |r| r.outcome == primary } if primary
|
|
2315
|
+
|
|
2316
|
+
records.group_by(&:session_id).size
|
|
2317
|
+
end
|
|
2318
|
+
|
|
2319
|
+
def primary_stage_for(agent_id)
|
|
2320
|
+
profile = insika[:profile_source]&.fetch(agent_id)
|
|
2321
|
+
decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
|
|
2322
|
+
decl && decl.primary
|
|
2323
|
+
end
|
|
2324
|
+
|
|
2325
|
+
# The opt-outs of ONE arm: contact cells of the customers THAT ARM fired
|
|
2326
|
+
# to (same tenant), flipped :revoked after that customer's own fire. Never
|
|
2327
|
+
# a global revoked count — an unrelated opt-out (another tenant, another
|
|
2328
|
+
# customer) must not count against an arm, or the H-followup kill decision
|
|
2329
|
+
# reads strangers.
|
|
2330
|
+
def opt_outs_since(records)
|
|
2331
|
+
store = insika[:contact_store]
|
|
2332
|
+
return 0 unless store
|
|
2333
|
+
|
|
2334
|
+
cells = store.cells
|
|
2335
|
+
# group this arm's fires by (tenant, customer) -> the earliest fire
|
|
2336
|
+
records.group_by { |r| [r.tenant, r.customer] }.sum do |(tenant, customer), recs|
|
|
2337
|
+
fired_at = recs.filter_map { |r| parse_time(r.fired_at || r.at) }.min
|
|
2338
|
+
next 0 if fired_at.nil?
|
|
2339
|
+
|
|
2340
|
+
cell = cells["#{tenant}:#{customer}"]
|
|
2341
|
+
cell && cell["state"] == "revoked" && revoke_after?(cell, fired_at) ? 1 : 0
|
|
2342
|
+
end
|
|
2343
|
+
end
|
|
2344
|
+
|
|
2345
|
+
def revoke_after?(cell, fired_at)
|
|
2346
|
+
updated = parse_time(cell["updated_at"])
|
|
2347
|
+
updated && updated > fired_at
|
|
2348
|
+
end
|
|
2349
|
+
|
|
2350
|
+
def parse_time(value)
|
|
2351
|
+
Time.iso8601(value.to_s)
|
|
2352
|
+
rescue ArgumentError
|
|
2353
|
+
nil
|
|
2354
|
+
end
|
|
2355
|
+
|
|
2356
|
+
# Tenant list per agent, derived from the store cells (D7, the 0031 drill
|
|
2357
|
+
# pattern) — multi-tenant deployments show one card block per tenant; a
|
|
2358
|
+
# single-tenant deployment just "platform".
|
|
2359
|
+
def tenants_for(agent_id)
|
|
2360
|
+
cells = insika[:funnel_store]&.pairs.to_a || []
|
|
2361
|
+
cells.select { |c| c[:agent] == agent_id }.map { |c| c[:tenant] }
|
|
2362
|
+
end
|
|
2363
|
+
|
|
2364
|
+
# Sums the folded counts over the period -> { stage => count } for the view.
|
|
2365
|
+
# A nil store (base wiring) reads as all-zero — the page still renders the
|
|
2366
|
+
# declaration and the freeze form.
|
|
2367
|
+
def funnel_period_counts(store, tenant, agent, period)
|
|
2368
|
+
return {} unless store
|
|
2369
|
+
|
|
2370
|
+
today = Date.today.iso8601
|
|
2371
|
+
from = (Date.today - period).iso8601
|
|
2372
|
+
store.days(tenant: tenant, agent: agent, from: from, to: today)
|
|
2373
|
+
.each_with_object(Hash.new(0)) { |(_, counts), acc| counts.each { |s, n| acc[s] += n } }
|
|
2374
|
+
end
|
|
2375
|
+
|
|
2376
|
+
# BudgetLedger's current counters (D6): "tokens today / this month". nil
|
|
2377
|
+
# store -> the view renders 0 (the empty state).
|
|
2378
|
+
def funnel_spend(store, tenant, agent)
|
|
2379
|
+
return { daily: 0, monthly: 0 } unless store
|
|
2380
|
+
|
|
2381
|
+
store.current(tenant: tenant, agent: agent)
|
|
2382
|
+
end
|
|
2383
|
+
|
|
2384
|
+
# the session's stage history — each outcome record of the
|
|
2385
|
+
# session folded to its stage by the agent's declaration (unmapped kinds
|
|
2386
|
+
# render the raw outcome with a muted note — the hole is visible).
|
|
2387
|
+
# Without a declaration the block renders the raw outcomes.
|
|
2388
|
+
def stage_history_for(sid)
|
|
2389
|
+
records = Array(insika[:outcome_store]&.all&.select { |r| r.session_id == sid })
|
|
2390
|
+
.sort_by { |r| r.at.to_s }
|
|
2391
|
+
return [] if records.empty?
|
|
2392
|
+
|
|
2393
|
+
agent = records.first.agent
|
|
2394
|
+
profile = insika[:profile_source]&.fetch(agent)
|
|
2395
|
+
decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
|
|
2396
|
+
{
|
|
2397
|
+
declaration: decl,
|
|
2398
|
+
rows: records.map do |r|
|
|
2399
|
+
stage = decl && decl.advance_on[r.outcome]
|
|
2400
|
+
{ outcome: r.outcome, stage: stage, at: r.at, value: r.value }
|
|
2401
|
+
end
|
|
2402
|
+
}
|
|
2403
|
+
end
|
|
2404
|
+
|
|
1437
2405
|
# A run's window in words. An EMPTY window means "the collector's own default" —
|
|
1438
2406
|
# resolve it for the operator instead of rendering a blank.
|
|
1439
2407
|
def refinement_window(window)
|
|
@@ -1484,7 +2452,7 @@ end
|
|
|
1484
2452
|
|
|
1485
2453
|
def task_path(id) = "/studio/tasks/#{Rack::Utils.escape(id.to_s)}"
|
|
1486
2454
|
|
|
1487
|
-
# parse_kv_lines moved to Studio::Forms
|
|
2455
|
+
# parse_kv_lines moved to Studio::Forms.
|
|
1488
2456
|
|
|
1489
2457
|
# CSV/whitespace -> [String], blanks dropped.
|
|
1490
2458
|
def split_list(str)
|
|
@@ -1519,11 +2487,26 @@ end
|
|
|
1519
2487
|
"/studio/playground?#{query}"
|
|
1520
2488
|
end
|
|
1521
2489
|
|
|
1522
|
-
def agent_path(id, anchor = nil)
|
|
2490
|
+
def agent_path(id, anchor = nil, query = nil)
|
|
1523
2491
|
base = "/studio/agents/#{Rack::Utils.escape(id)}"
|
|
2492
|
+
base += "?#{query}" if query
|
|
1524
2493
|
anchor ? "#{base}##{anchor}" : base
|
|
1525
2494
|
end
|
|
1526
2495
|
|
|
2496
|
+
# Deterministic avatar hue: the id's fingerprint, so agents/sessions are
|
|
2497
|
+
# scannable at a glance (Linear-style). CSP forbids inline styles, so the
|
|
2498
|
+
# hue is a class: avatar-h0..avatar-h9 in application.css.
|
|
2499
|
+
def avatar_class(id)
|
|
2500
|
+
"avatar-h#{id.to_s.bytes.sum % 10}"
|
|
2501
|
+
end
|
|
2502
|
+
|
|
2503
|
+
# Where a prompt POST lands back: the same file open in the drill, scrolled
|
|
2504
|
+
# to the prompts section. No file (a delete, say) → the agent page itself.
|
|
2505
|
+
def prompt_edit_path(id, file)
|
|
2506
|
+
name = presence(file)
|
|
2507
|
+
name ? "#{agent_path(id)}/prompts/#{Rack::Utils.escape(name)}#prompts" : agent_path(id, "prompts")
|
|
2508
|
+
end
|
|
2509
|
+
|
|
1527
2510
|
# Serves a versioned asset from dist. `File.basename` kills path traversal; only
|
|
1528
2511
|
# files that exist in the dist dir are served.
|
|
1529
2512
|
def serve_asset(name)
|