insika 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- metadata +77 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<header class="page-head">
|
|
2
|
-
<h1>Agents<span class="sub"><%= @agents.size %> configured · one
|
|
2
|
+
<h1>Agents<span class="sub"><%= @agents.size %> configured · one agent each</span></h1>
|
|
3
3
|
<a class="btn primary" href="/studio/playground">Open playground →</a>
|
|
4
4
|
</header>
|
|
5
5
|
|
|
6
6
|
<% if @agents.empty? %>
|
|
7
|
-
<div class="empty">No agents yet. Create your first
|
|
7
|
+
<div class="empty">No agents yet. Create your first agent below (id + model), then give it an identity on its detail page.</div>
|
|
8
8
|
<% else %>
|
|
9
9
|
<div data-controller="list-filter">
|
|
10
10
|
<div class="filter-bar">
|
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
<% @agents.each do |a| %>
|
|
17
17
|
<article class="card agent" data-list-filter-target="item" data-filter-text="<%= a.id %> <%= a.model %> <%= a.provider %>">
|
|
18
18
|
<a class="agent-head" href="/studio/agents/<%= Rack::Utils.escape(a.id) %>">
|
|
19
|
-
<span class="avatar"><%= a.id[0, 2].upcase %></span>
|
|
19
|
+
<span class="avatar <%= avatar_class(a.id) %>"><%= a.id[0, 2].upcase %></span>
|
|
20
20
|
<div>
|
|
21
21
|
<div class="agent-id"><%= a.id %></div>
|
|
22
22
|
<div class="muted mono"><%= a.model %><%== " · #{h(a.provider)}" if a.provider %></div>
|
|
23
23
|
</div>
|
|
24
24
|
<% if a.memory %><span class="pill info">memory</span><% end %>
|
|
25
|
+
<% if (latest = @latest_outcomes&.dig(a.id)) %>
|
|
26
|
+
<span class="pill <%= latest[:outcome] == "conversion" ? "ok" : "warn" %>"
|
|
27
|
+
title="last outcome recorded via /v1/outcomes"><%= latest[:outcome] %><%== format(" · R$ %0.2f", latest[:value]) if latest[:value] && latest[:value].positive? %></span>
|
|
28
|
+
<% end %>
|
|
25
29
|
</a>
|
|
26
30
|
<dl class="agent-meta">
|
|
27
31
|
<div><dt>tools</dt><dd><%= a.tools_allow.nil? ? "all" : a.tools_allow.size %></dd></div>
|
|
@@ -44,8 +48,8 @@
|
|
|
44
48
|
<form method="post" action="/studio/agents">
|
|
45
49
|
<%== csrf_tag %>
|
|
46
50
|
<div class="grid-2">
|
|
47
|
-
<label>id <span class="muted">(unique slug, e.g.
|
|
48
|
-
<input type="text" name="id" placeholder="
|
|
51
|
+
<label>id <span class="muted">(unique slug, e.g. demo)</span>
|
|
52
|
+
<input type="text" name="id" placeholder="demo" required>
|
|
49
53
|
</label>
|
|
50
54
|
<label>model
|
|
51
55
|
<input type="text" name="model" placeholder="deepseek-v4-flash" required>
|
|
@@ -56,7 +60,7 @@
|
|
|
56
60
|
</label>
|
|
57
61
|
<label class="check">
|
|
58
62
|
<input type="checkbox" name="memory" value="1" checked>
|
|
59
|
-
cross-session memory (the
|
|
63
|
+
cross-session memory (the agent remembers across conversations)
|
|
60
64
|
</label>
|
|
61
65
|
<div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Creating…">Create agent</button></div>
|
|
62
66
|
</form>
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
<nav class="crumbs"><span>approvals</span></nav>
|
|
4
4
|
<h1>Approvals<span class="sub"><%= @approvals.size %> pending</span></h1>
|
|
5
5
|
</div>
|
|
6
|
-
<
|
|
6
|
+
<div class="head-actions">
|
|
7
|
+
<%== agent_filter_form("/studio/approvals", @agent) %>
|
|
8
|
+
<a class="btn" href="/studio/tasks">Tasks →</a>
|
|
9
|
+
</div>
|
|
7
10
|
</header>
|
|
8
11
|
|
|
9
12
|
<% if @approvals.empty? %>
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
<nav class="crumbs"><a href="/studio/agents">agents</a> / <span>chats</span></nav>
|
|
4
4
|
<h1>Chats<span class="sub"><%= @sessions.size %> recent conversation(s)</span></h1>
|
|
5
5
|
</div>
|
|
6
|
-
<
|
|
6
|
+
<div class="head-actions">
|
|
7
|
+
<%== agent_filter_form("/studio/chats", @agent) %>
|
|
8
|
+
<a class="btn primary" href="/studio/playground">New conversation →</a>
|
|
9
|
+
</div>
|
|
7
10
|
</header>
|
|
8
11
|
|
|
9
12
|
<% if @sessions.empty? %>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<header class="page-head">
|
|
2
|
+
<div>
|
|
3
|
+
<nav class="crumbs"><a href="/studio/customers">customers</a></nav>
|
|
4
|
+
<h1><span class="mono"><%= @cell[:customer] || @cell[:scope] %></span><span class="sub">customer memory</span></h1>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="actions">
|
|
7
|
+
<form method="post" action="<%= customer_path(@cell[:scope]) %>/export">
|
|
8
|
+
<%== csrf_tag %>
|
|
9
|
+
<button type="submit" class="btn" data-turbo-submits-with="Exporting…">Export</button>
|
|
10
|
+
</form>
|
|
11
|
+
<form method="post" action="<%= customer_path(@cell[:scope]) %>/forget"
|
|
12
|
+
data-turbo-confirm="Forget <%= @cell[:customer] %>? Their memory cell AND their sessions are purged. This cannot be undone.">
|
|
13
|
+
<%== csrf_tag %>
|
|
14
|
+
<button type="submit" class="btn danger" data-turbo-submits-with="Forgetting…">Forget</button>
|
|
15
|
+
</form>
|
|
16
|
+
</div>
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<section class="section">
|
|
20
|
+
<div class="section-head">
|
|
21
|
+
<h2>Facts</h2>
|
|
22
|
+
<p class="muted">what the next turn reads — expired facts are never injected .</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="card">
|
|
25
|
+
<% if @facts.empty? %>
|
|
26
|
+
<p class="muted">No facts.</p>
|
|
27
|
+
<% else %>
|
|
28
|
+
<ul class="kv">
|
|
29
|
+
<% @facts.each do |f| %>
|
|
30
|
+
<li>
|
|
31
|
+
<span class="k mono"><%= f.key %></span>
|
|
32
|
+
<span class="v"><%= f.value %></span>
|
|
33
|
+
<span class="muted mono"><%= f.origin %> · <%= f.created_at %><%= f.expires_at ? " · expires #{f.expires_at}" : "" %></span>
|
|
34
|
+
<form method="post" action="<%= customer_path(@cell[:scope]) %>/forget-fact"
|
|
35
|
+
data-turbo-confirm="Forget "<%= f.key %>"? The next turn stops seeing it.">
|
|
36
|
+
<%== csrf_tag %>
|
|
37
|
+
<input type="hidden" name="key" value="<%= f.key %>">
|
|
38
|
+
<button type="submit" class="ghost danger" data-turbo-submits-with="Forgetting…">Forget</button>
|
|
39
|
+
</form>
|
|
40
|
+
</li>
|
|
41
|
+
<% end %>
|
|
42
|
+
</ul>
|
|
43
|
+
<% end %>
|
|
44
|
+
<form class="inline-form" method="post" action="<%= customer_path(@cell[:scope]) %>/fact">
|
|
45
|
+
<%== csrf_tag %>
|
|
46
|
+
<input type="text" name="key" placeholder="key (e.g. size)" required>
|
|
47
|
+
<input type="text" name="value" placeholder="value" required>
|
|
48
|
+
<input type="text" name="expires_at" placeholder="expires_at (ISO8601, optional)">
|
|
49
|
+
<button type="submit" class="primary" data-turbo-submits-with="Saving…">Save fact</button>
|
|
50
|
+
</form>
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<section class="section">
|
|
55
|
+
<div class="section-head">
|
|
56
|
+
<h2>Notes <span class="muted">(recent — append-only)</span></h2>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="card">
|
|
59
|
+
<% if @notes.empty? %>
|
|
60
|
+
<p class="muted">No notes.</p>
|
|
61
|
+
<% else %>
|
|
62
|
+
<ul class="notes">
|
|
63
|
+
<% @notes.each do |n| %>
|
|
64
|
+
<li><span class="muted mono"><%= n.created_at %></span> <%= n.text %></li>
|
|
65
|
+
<% end %>
|
|
66
|
+
</ul>
|
|
67
|
+
<% end %>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
71
|
+
<section class="section">
|
|
72
|
+
<div class="section-head">
|
|
73
|
+
<h2>Audit <span class="muted">(operator mutations — digests, never content)</span></h2>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="card">
|
|
76
|
+
<% if @audit.empty? %>
|
|
77
|
+
<p class="muted">No operator mutations recorded.</p>
|
|
78
|
+
<% else %>
|
|
79
|
+
<ul class="history">
|
|
80
|
+
<% @audit.each do |e| %>
|
|
81
|
+
<li>
|
|
82
|
+
<span class="pill<%= e.action == "purge" ? " danger" : "" %>"><%= e.action %></span>
|
|
83
|
+
<% if e.key %><span class="mono"><%= e.key %></span><% end %>
|
|
84
|
+
<span class="preview muted">
|
|
85
|
+
<%= e.actor %> · <%= e.at %>
|
|
86
|
+
<% if e.old_hash || e.new_hash %> · <%= e.old_hash ? e.old_hash[0, 8] : "∅" %>→<%= e.new_hash ? e.new_hash[0, 8] : "∅" %><% end %>
|
|
87
|
+
<% if e.note %> · <%= e.note %><% end %>
|
|
88
|
+
</span>
|
|
89
|
+
</li>
|
|
90
|
+
<% end %>
|
|
91
|
+
</ul>
|
|
92
|
+
<% end %>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<header class="page-head">
|
|
2
|
+
<div>
|
|
3
|
+
<nav class="crumbs"><span>customers</span></nav>
|
|
4
|
+
<h1>Customers<span class="sub"><%= @rows.size %> with memory</span></h1>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="head-actions">
|
|
7
|
+
<%== agent_filter_form("/studio/customers", @agent) %>
|
|
8
|
+
</div>
|
|
9
|
+
</header>
|
|
10
|
+
|
|
11
|
+
<% if @rows.empty? %>
|
|
12
|
+
<div class="empty">No customers with memory yet — facts appear here when a turn or the operator writes to a customer cell.</div>
|
|
13
|
+
<% else %>
|
|
14
|
+
<% @by_tenant.each do |tenant, rows| %>
|
|
15
|
+
<section class="section">
|
|
16
|
+
<div class="section-head">
|
|
17
|
+
<h2><%= tenant.nil? ? "Shared (no tenant)" : tenant %></h2>
|
|
18
|
+
<p class="muted"><%= rows.size %> customer cell(s)</p>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="card">
|
|
21
|
+
<ul class="history">
|
|
22
|
+
<% rows.each do |row| %>
|
|
23
|
+
<li>
|
|
24
|
+
<a href="<%= customer_path(row[:cell][:scope]) %>"><span class="mono"><%= row[:cell][:customer] %></span></a>
|
|
25
|
+
<span class="preview muted"><%= row[:count] %> fact(s)</span>
|
|
26
|
+
</li>
|
|
27
|
+
<% end %>
|
|
28
|
+
</ul>
|
|
29
|
+
</div>
|
|
30
|
+
</section>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% end %>
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
<nav class="crumbs"><span>evals</span></nav>
|
|
4
4
|
<h1>Evals<span class="sub"><%= @cases.size %> case(s)</span></h1>
|
|
5
5
|
</div>
|
|
6
|
-
<
|
|
6
|
+
<div class="head-actions">
|
|
7
|
+
<%== agent_filter_form("/studio/evals", @filter) %>
|
|
8
|
+
<a class="btn" href="/studio/settings?s=evals">Judges →</a>
|
|
9
|
+
</div>
|
|
7
10
|
</header>
|
|
8
11
|
|
|
9
12
|
<% if @invalid.any? %>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<%# the Facts (wiki) page — the human gate on distilled proposals.
|
|
2
|
+
Nothing is ever applied automatically: each pending fact needs a click.
|
|
3
|
+
Reads ProposalStore + SessionStore directly (evidence is a link, never a
|
|
4
|
+
copy — D7); the approve/reject/dismiss mutations dispatch :resolve_proposal
|
|
5
|
+
on the bus. %>
|
|
6
|
+
<header class="page-head">
|
|
7
|
+
<div>
|
|
8
|
+
<nav class="crumbs"><span>operate</span></nav>
|
|
9
|
+
<h1>Facts<span class="sub">distilled customer facts, human-gated</span></h1>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="head-actions">
|
|
12
|
+
<form method="get" action="/studio/facts" class="actions inline">
|
|
13
|
+
<label>
|
|
14
|
+
Agent / scope
|
|
15
|
+
<select name="agent" onchange="this.form.submit()">
|
|
16
|
+
<option value="">all</option>
|
|
17
|
+
<% @stores.each do |scope| %>
|
|
18
|
+
<option value="<%= scope %>"<%== ' selected' if scope == @filter %>><%= scope %></option>
|
|
19
|
+
<% end %>
|
|
20
|
+
</select>
|
|
21
|
+
</label>
|
|
22
|
+
</form>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<% if insika[:proposal_store].nil? %>
|
|
27
|
+
<div class="empty">
|
|
28
|
+
Distillation is off — nothing distills until an agent declares a
|
|
29
|
+
<span class="mono">distill:</span> block in its pack.
|
|
30
|
+
</div>
|
|
31
|
+
<% elsif @pending.empty? && @stale.empty? && @recent.empty? %>
|
|
32
|
+
<div class="empty">
|
|
33
|
+
No proposals yet — facts appear here when a session's traffic is distilled.
|
|
34
|
+
Nothing is ever applied automatically.
|
|
35
|
+
</div>
|
|
36
|
+
<% else %>
|
|
37
|
+
<% unless @pending.empty? %>
|
|
38
|
+
<h2>Pending<span class="sub">oldest first — evidence ages</span></h2>
|
|
39
|
+
<% @pending.each do |p| %>
|
|
40
|
+
<div class="card">
|
|
41
|
+
<div class="section-head">
|
|
42
|
+
<h3><span class="mono"><%= p.key %></span> = <strong><%= p.value %></strong>
|
|
43
|
+
<% if p.confidence %><span class="pill info"><%= format("%.2f", p.confidence) %></span><% end %>
|
|
44
|
+
</h3>
|
|
45
|
+
<span class="muted"><%= p.scope %> · <a href="/studio/sessions/<%= Rack::Utils.escape(p.session_ref) %>"><%= p.session_ref %></a></span>
|
|
46
|
+
</div>
|
|
47
|
+
<% excerpt = proposal_excerpt(p) %>
|
|
48
|
+
<% unless excerpt.empty? %>
|
|
49
|
+
<div class="skill-desc muted">
|
|
50
|
+
<% excerpt.each do |line| %>
|
|
51
|
+
<div class="evidence"><%= line %></div>
|
|
52
|
+
<% end %>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
<div class="actions">
|
|
56
|
+
<form method="post" action="/studio/facts/resolve" class="inline">
|
|
57
|
+
<%== csrf_tag %>
|
|
58
|
+
<input type="hidden" name="proposal_id" value="<%= p.id %>">
|
|
59
|
+
<input type="hidden" name="decision" value="approved">
|
|
60
|
+
<% if @filter %><input type="hidden" name="filter" value="<%= @filter %>"><% end %>
|
|
61
|
+
<button type="submit" class="primary">Approve & save to memory</button>
|
|
62
|
+
</form>
|
|
63
|
+
<form method="post" action="/studio/facts/resolve" class="inline">
|
|
64
|
+
<%== csrf_tag %>
|
|
65
|
+
<input type="hidden" name="proposal_id" value="<%= p.id %>">
|
|
66
|
+
<input type="hidden" name="decision" value="rejected">
|
|
67
|
+
<% if @filter %><input type="hidden" name="filter" value="<%= @filter %>"><% end %>
|
|
68
|
+
<input type="text" name="note" placeholder="reason (optional)">
|
|
69
|
+
<button type="submit" class="secondary">Reject</button>
|
|
70
|
+
</form>
|
|
71
|
+
<form method="post" action="/studio/facts/resolve" class="inline">
|
|
72
|
+
<%== csrf_tag %>
|
|
73
|
+
<input type="hidden" name="proposal_id" value="<%= p.id %>">
|
|
74
|
+
<input type="hidden" name="decision" value="dismissed">
|
|
75
|
+
<% if @filter %><input type="hidden" name="filter" value="<%= @filter %>"><% end %>
|
|
76
|
+
<button type="submit" class="ghost">Dismiss<span class="muted"> — will not be proposed again</span></button>
|
|
77
|
+
</form>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
<% end %>
|
|
82
|
+
|
|
83
|
+
<% unless @stale.empty? %>
|
|
84
|
+
<h2>Stale<span class="sub">the fact moved after this was distilled — the operator's edit wins (CAS)</span></h2>
|
|
85
|
+
<% @stale.each do |p| %>
|
|
86
|
+
<div class="card">
|
|
87
|
+
<div class="section-head">
|
|
88
|
+
<h3>
|
|
89
|
+
<span class="mono"><%= p.key %></span> =
|
|
90
|
+
<s><%= p.value %></s>
|
|
91
|
+
<span class="pill warn"><%= p.current_value %></span>
|
|
92
|
+
<span class="muted">(your edit)</span>
|
|
93
|
+
</h3>
|
|
94
|
+
<span class="muted"><%= p.scope %> · <a href="/studio/sessions/<%= Rack::Utils.escape(p.session_ref) %>"><%= p.session_ref %></a></span>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="skill-desc">
|
|
97
|
+
The proposed value was not applied — the fact stands as you left it.
|
|
98
|
+
</div>
|
|
99
|
+
<div class="actions">
|
|
100
|
+
<form method="post" action="/studio/facts/resolve" class="inline">
|
|
101
|
+
<%== csrf_tag %>
|
|
102
|
+
<input type="hidden" name="proposal_id" value="<%= p.id %>">
|
|
103
|
+
<input type="hidden" name="decision" value="dismissed">
|
|
104
|
+
<% if @filter %><input type="hidden" name="filter" value="<%= @filter %>"><% end %>
|
|
105
|
+
<button type="submit" class="ghost">Dismiss<span class="muted"> — resolve the re-present</span></button>
|
|
106
|
+
</form>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<% end %>
|
|
110
|
+
<% end %>
|
|
111
|
+
|
|
112
|
+
<% unless @recent.empty? %>
|
|
113
|
+
<h2>Recent<span class="sub">resolved, most recent first</span></h2>
|
|
114
|
+
<div class="card">
|
|
115
|
+
<table>
|
|
116
|
+
<thead>
|
|
117
|
+
<tr><th>fact</th><th>status</th><th>by</th><th>note</th><th>session</th></tr>
|
|
118
|
+
</thead>
|
|
119
|
+
<tbody>
|
|
120
|
+
<% @recent.first(20).each do |p| %>
|
|
121
|
+
<tr>
|
|
122
|
+
<td><span class="mono"><%= p.key %></span> = <%= p.value %></td>
|
|
123
|
+
<td><span class="pill <%= %w[approved].include?(p.status) ? 'ok' : 'info' %>"><%= p.status %></span></td>
|
|
124
|
+
<td><%= p.operator %></td>
|
|
125
|
+
<td class="muted"><%= p.note %></td>
|
|
126
|
+
<td><a href="/studio/sessions/<%= Rack::Utils.escape(p.session_ref) %>"><%= p.session_ref %></a></td>
|
|
127
|
+
</tr>
|
|
128
|
+
<% end %>
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</div>
|
|
132
|
+
<% end %>
|
|
133
|
+
<% end %>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<%# the Follow-ups page. Reads the FollowupStore/ContactStore/
|
|
2
|
+
OutcomeStore directly (D10); the ONLY mutations — cancel a pending record,
|
|
3
|
+
force-revoke a contact — dispatch bus commands. Nothing here is engine
|
|
4
|
+
vocabulary: the arm names, the reasons and the policy values are the pack's
|
|
5
|
+
(D1/D8). %>
|
|
6
|
+
<header class="page-head">
|
|
7
|
+
<div>
|
|
8
|
+
<nav class="crumbs"><span>operate</span></nav>
|
|
9
|
+
<h1>Follow-ups<span class="sub">scheduled, fired, blocked, cancelled — per agent</span></h1>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="head-actions">
|
|
12
|
+
<%== agent_filter_form("/studio/followups", @agent) %>
|
|
13
|
+
</div>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<% if !@has_followups %>
|
|
17
|
+
<div class="empty">
|
|
18
|
+
No follow-ups — add a <span class="mono">followup:</span> block to an agent's pack and
|
|
19
|
+
the records it schedules land here.
|
|
20
|
+
</div>
|
|
21
|
+
<% else %>
|
|
22
|
+
<% @stores.each do |store| %>
|
|
23
|
+
<% profile, policy, records = store[:profile], store[:policy], store[:records] %>
|
|
24
|
+
<% pending = records.select { |r| r.status == "pending" } %>
|
|
25
|
+
<% fired = records.select { |r| r.status == "fired" } %>
|
|
26
|
+
<% cancelled = records.select { |r| r.status == "cancelled" } %>
|
|
27
|
+
<% blocked = records.select { |r| r.status == "blocked" } %>
|
|
28
|
+
<h2><%= profile.id %><span class="sub">arm <strong><%= policy.arm %></strong> · <%= policy.cancel_keywords.size %> keyword(s) · silence after <%= policy.silence_after_sends %> send(s)</span></h2>
|
|
29
|
+
|
|
30
|
+
<div class="card">
|
|
31
|
+
<div class="section-head">
|
|
32
|
+
<h3>Schedule records</h3>
|
|
33
|
+
<span class="muted"><%= pending.size %> pending · <%= fired.size %> fired · <%= cancelled.size %> cancelled · <%= blocked.size %> blocked</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<% if records.empty? %>
|
|
37
|
+
<div class="empty">No follow-up records for this agent yet — the <span class="mono">schedule</span> tool writes them.</div>
|
|
38
|
+
<% else %>
|
|
39
|
+
<table class="grid">
|
|
40
|
+
<thead>
|
|
41
|
+
<tr>
|
|
42
|
+
<th>customer</th><th>at</th><th>reason</th><th>arm</th><th>status</th><th></th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
<% records.each do |r| %>
|
|
47
|
+
<tr>
|
|
48
|
+
<td class="mono"><%= r.customer %></td>
|
|
49
|
+
<td class="mono"><%= r.at %></td>
|
|
50
|
+
<td><%= r.reason %></td>
|
|
51
|
+
<td><span class="pill info"><%= r.arm %></span></td>
|
|
52
|
+
<td>
|
|
53
|
+
<% if r.status == "pending" %>
|
|
54
|
+
<span class="pill warn">pending</span>
|
|
55
|
+
<% elsif r.status == "fired" %>
|
|
56
|
+
<span class="pill ok">fired</span>
|
|
57
|
+
<% elsif r.status == "cancelled" %>
|
|
58
|
+
<span class="pill">cancelled</span>
|
|
59
|
+
<% else %>
|
|
60
|
+
<span class="pill error">blocked · <%= r.blocked_reason %></span>
|
|
61
|
+
<% end %>
|
|
62
|
+
</td>
|
|
63
|
+
<td>
|
|
64
|
+
<% if r.status == "pending" %>
|
|
65
|
+
<form method="post" action="/studio/followups/<%= Rack::Utils.escape(profile.id) %>/cancel" class="actions">
|
|
66
|
+
<%== csrf_tag %>
|
|
67
|
+
<input type="hidden" name="id" value="<%= r.id %>">
|
|
68
|
+
<button type="submit" class="danger">Cancel</button>
|
|
69
|
+
</form>
|
|
70
|
+
<form method="post" action="/studio/followups/<%= Rack::Utils.escape(profile.id) %>/revoke" class="actions">
|
|
71
|
+
<%== csrf_tag %>
|
|
72
|
+
<input type="hidden" name="customer" value="<%= r.customer %>">
|
|
73
|
+
<input type="hidden" name="tenant" value="<%= r.tenant %>">
|
|
74
|
+
<button type="submit" class="danger" title="A channel opt-out event that never arrived — revokes the contact and cancels its pending follow-ups">Revoke contact</button>
|
|
75
|
+
</form>
|
|
76
|
+
<% end %>
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
<% end %>
|
|
80
|
+
</tbody>
|
|
81
|
+
</table>
|
|
82
|
+
<% end %>
|
|
83
|
+
|
|
84
|
+
<div class="section-head" style="margin-top: 1.2rem;">
|
|
85
|
+
<h3>Policy <span class="muted">pack data — edit it on the agent's pack</span></h3>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="skill-desc">
|
|
88
|
+
<% qh = policy.quiet_hours %>
|
|
89
|
+
<span class="pill info"><%= qh ? "quiet #{qh.start}-#{qh.end} <#{qh.timezone}>" : "no quiet hours" %></span>
|
|
90
|
+
<span class="pill info"><%= policy.max_frequency ? "max #{policy.max_frequency}" : "no frequency ceiling" %></span>
|
|
91
|
+
<span class="muted"><%= policy.cancel_keywords.empty? ? "no cancellation keywords" : "keywords: #{policy.cancel_keywords.join(' · ')}" %></span>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<%# D3: the A/B card — a READ-ONLY fold over the same records. The ruler
|
|
96
|
+
outside the card is the operator's baseline; this card only
|
|
97
|
+
reads it (the note says so). %>
|
|
98
|
+
<div class="card">
|
|
99
|
+
<div class="section-head">
|
|
100
|
+
<h3>A/B readout <span class="muted">last 30 days · per arm</span></h3>
|
|
101
|
+
<span class="muted">the A/B is the operator's — the card reads the baseline</span>
|
|
102
|
+
</div>
|
|
103
|
+
<% if store[:ab].empty? %>
|
|
104
|
+
<div class="empty">Nothing fired in the period — the readout appears when the engine fires.</div>
|
|
105
|
+
<% else %>
|
|
106
|
+
<table class="grid">
|
|
107
|
+
<thead>
|
|
108
|
+
<tr><th>arm</th><th>sent</th><th>conversions</th><th>conversion %</th><th>opt-outs</th></tr>
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody>
|
|
111
|
+
<% store[:ab].each do |arm, row| %>
|
|
112
|
+
<tr>
|
|
113
|
+
<td><span class="pill info"><%= arm %></span></td>
|
|
114
|
+
<td><%= row[:sent] %></td>
|
|
115
|
+
<td><%= row[:conversions] %></td>
|
|
116
|
+
<td><%= row[:sent].zero? ? "—" : format("%.1f", row[:conversions].to_f / row[:sent] * 100) %>%</td>
|
|
117
|
+
<td><%= row[:opt_outs] %></td>
|
|
118
|
+
</tr>
|
|
119
|
+
<% end %>
|
|
120
|
+
</tbody>
|
|
121
|
+
</table>
|
|
122
|
+
<% end %>
|
|
123
|
+
</div>
|
|
124
|
+
<% end %>
|
|
125
|
+
<% end %>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<%# the outcome funnel per store. Reads the fold's cells and the
|
|
2
|
+
BudgetLedger's current counters (D6); the ONLY mutation on this page — the
|
|
3
|
+
baseline freeze — dispatches :freeze_funnel_baseline on the bus (D7). No
|
|
4
|
+
stage vocabulary of the engine's own: everything here comes from the pack
|
|
5
|
+
declaration (D1). %>
|
|
6
|
+
<header class="page-head">
|
|
7
|
+
<div>
|
|
8
|
+
<nav class="crumbs"><span>operate</span></nav>
|
|
9
|
+
<h1>Funnel<span class="sub">the outcome funnel per store</span></h1>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="head-actions">
|
|
12
|
+
<%== agent_filter_form("/studio/funnel", @agent) %>
|
|
13
|
+
<form method="get" action="/studio/funnel" class="actions inline">
|
|
14
|
+
<label>
|
|
15
|
+
Period (days)
|
|
16
|
+
<select name="period" onchange="this.form.submit()">
|
|
17
|
+
<% [7, 30, 90].each do |p| %>
|
|
18
|
+
<option value="<%= p %>"<%== ' selected' if @period == p %>><%= p %></option>
|
|
19
|
+
<% end %>
|
|
20
|
+
</select>
|
|
21
|
+
</label>
|
|
22
|
+
</form>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<% if @stores.empty? %>
|
|
27
|
+
<div class="empty">
|
|
28
|
+
No outcome funnel declared — add a <span class="mono">funnel:</span> block to an
|
|
29
|
+
agent's pack and its stage counts fold here.
|
|
30
|
+
</div>
|
|
31
|
+
<% else %>
|
|
32
|
+
<% @stores.each do |store| %>
|
|
33
|
+
<% profile, decl = store[:profile], store[:decl] %>
|
|
34
|
+
<% tenants = store[:tenants] %>
|
|
35
|
+
<% tenants = [nil] if tenants.empty? %>
|
|
36
|
+
<h2><%= profile.id %><span class="sub"><%= decl.stages.join(" → ") %></span></h2>
|
|
37
|
+
<% tenants.each do |tenant| %>
|
|
38
|
+
<% counts = funnel_period_counts(insika[:funnel_store], tenant, profile.id, @period) %>
|
|
39
|
+
<% spend = funnel_spend(insika[:budget_ledger], tenant, profile.id) %>
|
|
40
|
+
<% first = counts[decl.first_stage].to_i %>
|
|
41
|
+
<% conversion = first.zero? ? nil : counts[decl.primary].to_f / first %>
|
|
42
|
+
<% max = [counts.values.max || 0, 1].max %>
|
|
43
|
+
<div class="card">
|
|
44
|
+
<div class="section-head">
|
|
45
|
+
<h3><%= tenant || "platform" %><span class="muted">folded by the tick · recomputable from the outcome store</span></h3>
|
|
46
|
+
<span class="muted">primary <strong><%= decl.primary %></strong> · attribution window <%= decl.attribution_window %> (carried, never computed)</span>
|
|
47
|
+
</div>
|
|
48
|
+
<svg class="barchart" viewBox="0 0 700 120" preserveAspectRatio="none" role="img" aria-label="stage counts, last <%= @period %> days">
|
|
49
|
+
<% bw = 700.0 / decl.stages.length * 0.6 %>
|
|
50
|
+
<% decl.stages.each_with_index do |stage, i| %>
|
|
51
|
+
<% c = counts[stage].to_i %>
|
|
52
|
+
<% h = (c.to_f / max) * 96.0 %>
|
|
53
|
+
<% x = (i * (700.0 / decl.stages.length)) + ((700.0 / decl.stages.length - bw) / 2.0) %>
|
|
54
|
+
<rect class="bar<%== ' bar-empty' if c.zero? %>" x="<%= format('%.1f', x) %>" y="<%= format('%.1f', c.zero? ? 108.0 : 108.0 - h) %>"
|
|
55
|
+
width="<%= format('%.1f', bw) %>" height="<%= format('%.1f', c.zero? ? 3.0 : [h, 3.0].max) %>" rx="2">
|
|
56
|
+
<title><%= stage %> · <%= c %></title>
|
|
57
|
+
</rect>
|
|
58
|
+
<% end %>
|
|
59
|
+
<line class="barchart-axis-line" x1="0" y1="108" x2="700" y2="108" />
|
|
60
|
+
</svg>
|
|
61
|
+
<div class="barchart-axis">
|
|
62
|
+
<% decl.stages.each do |stage| %>
|
|
63
|
+
<span><%= stage %> · <%= counts[stage].to_i %></span>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="skill-desc">
|
|
67
|
+
<span class="pill <%= conversion && conversion.positive? ? 'ok' : 'info' %>">
|
|
68
|
+
conversion <%= conversion ? format("%.4f", conversion) : "—" %>
|
|
69
|
+
</span>
|
|
70
|
+
<span class="muted"><%= counts[decl.primary].to_i %> × <%= decl.primary %> / <%= counts[decl.first_stage].to_i %> × <%= decl.first_stage %>, last <%= @period %> days</span>
|
|
71
|
+
<%# D6: spend next to the counts, NOT cost-per-conversion. The
|
|
72
|
+
BudgetLedger holds current-window TOKEN totals, no currency and no
|
|
73
|
+
per-day series; a cost/conversion ratio is deferred until
|
|
74
|
+
the ruler proves itself (E3). %>
|
|
75
|
+
<span class="muted">· tokens today <strong><%= spend[:daily] %></strong> / this month <strong><%= spend[:monthly] %></strong></span>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<% baseline = insika[:funnel_store]&.baseline(tenant: tenant, agent: profile.id) %>
|
|
79
|
+
<div class="skill-desc">
|
|
80
|
+
<% if baseline %>
|
|
81
|
+
<span class="pill ok">baseline frozen</span>
|
|
82
|
+
<span class="muted"><%= baseline["from"] %> → <%= baseline["to"] %> · <%= baseline["primary"] %> <%= baseline["primary_count"] %> · conversion <%= baseline["conversion"] ? format("%.4f", baseline["conversion"]) : "—" %> · <%= baseline["window"] %> window · frozen <%= baseline["frozen_at"] %></span>
|
|
83
|
+
<% else %>
|
|
84
|
+
<span class="pill warn">no baseline</span>
|
|
85
|
+
<span class="muted">≥ 28 days of data required — the freeze refuses a shorter span</span>
|
|
86
|
+
<% end %>
|
|
87
|
+
</div>
|
|
88
|
+
<form method="post" action="/studio/funnel/<%= Rack::Utils.escape(profile.id) %>/freeze" class="actions">
|
|
89
|
+
<%== csrf_tag %>
|
|
90
|
+
<label>
|
|
91
|
+
From
|
|
92
|
+
<input type="date" name="from" value="<%= baseline && baseline["from"] %>">
|
|
93
|
+
</label>
|
|
94
|
+
<label>
|
|
95
|
+
To
|
|
96
|
+
<input type="date" name="to" value="<%= baseline && baseline["to"] %>">
|
|
97
|
+
</label>
|
|
98
|
+
<button type="submit" class="primary">Freeze baseline</button>
|
|
99
|
+
<% if tenant %>
|
|
100
|
+
<input type="hidden" name="tenant" value="<%= tenant %>">
|
|
101
|
+
<% end %>
|
|
102
|
+
</form>
|
|
103
|
+
</div>
|
|
104
|
+
<% end %>
|
|
105
|
+
<% end %>
|
|
106
|
+
<% end %>
|