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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# the schedule records of the follow-up feature. The store owns
|
|
8
|
+
# the `pending|fired|cancelled|blocked` states (never written by a consumer —
|
|
9
|
+
# the task_store.rb state-machine idiom) and the (customer, reason) scans.
|
|
10
|
+
# It holds no policy (C2) and no contact cells (C3).
|
|
11
|
+
#
|
|
12
|
+
# States:
|
|
13
|
+
# pending -> fired | cancelled | blocked
|
|
14
|
+
# `fired` is set ONLY inside the same transaction that created the synthetic
|
|
15
|
+
# task (D5 — the record and the turn commit together or together fail); a
|
|
16
|
+
# `blocked` record carries `blocked_reason` — auditable, never silent.
|
|
17
|
+
#
|
|
18
|
+
# Record key: "<tenant>:<agent>:<customer>:<at>:#{uuid}" — per-customer /
|
|
19
|
+
# per-agent scans are prefixes, and the scheduled `at` lives in the key so a
|
|
20
|
+
# fired record keeps its scheduled time for the A/B card. Blank tenant -> the
|
|
21
|
+
# literal "platform" (outcome_store.rb's rule — contact, follow-up and
|
|
22
|
+
# outcome keys share one tenant segment so the purge prefix scans line up).
|
|
23
|
+
class FollowupStore
|
|
24
|
+
SCOPE = "followups"
|
|
25
|
+
STATUSES = %w[pending fired cancelled blocked].freeze
|
|
26
|
+
|
|
27
|
+
Record = Data.define(:id, :tenant, :agent, :customer, :session_id, :at,
|
|
28
|
+
:reason, :arm, :status, :task_id, :blocked_reason,
|
|
29
|
+
:transport, :created_at, :updated_at, :fired_at)
|
|
30
|
+
|
|
31
|
+
def initialize(store:)
|
|
32
|
+
@store = store
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# -> Record (status :pending). `at` must be a future ISO8601 (a Time or an
|
|
36
|
+
# ISO8601 string — ValidationError otherwise, "the follow-up would already
|
|
37
|
+
# be due"). The caller decided the arm (C1) and the transport (C7).
|
|
38
|
+
def create(tenant:, agent:, customer:, session_id:, at:, reason:, arm:,
|
|
39
|
+
transport: nil, id: SecureRandom.uuid, now: Time.now.utc)
|
|
40
|
+
time = parse_at(at)
|
|
41
|
+
if time <= now
|
|
42
|
+
raise Insika::ValidationError,
|
|
43
|
+
"follow-up #{id.inspect} would already be due (at #{time.iso8601} <= now)"
|
|
44
|
+
end
|
|
45
|
+
if (prior = pending_for(tenant: tenant, agent: agent, customer: customer, reason: reason))
|
|
46
|
+
raise Insika::ValidationError,
|
|
47
|
+
"a follow-up for (customer #{customer.inspect}, reason #{reason.inspect}) is already " \
|
|
48
|
+
"pending: #{prior.id}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
time = time.utc.iso8601
|
|
52
|
+
record = { "id" => id.to_s, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
|
|
53
|
+
"customer" => customer.to_s, "session_id" => session_id.to_s,
|
|
54
|
+
"at" => time, "reason" => reason.to_s, "arm" => arm.to_s,
|
|
55
|
+
"status" => "pending", "task_id" => nil, "blocked_reason" => nil,
|
|
56
|
+
"transport" => transport.to_s, "created_at" => now.iso8601,
|
|
57
|
+
"updated_at" => now.iso8601, "fired_at" => nil }
|
|
58
|
+
@store.set(SCOPE, key_for(record), record)
|
|
59
|
+
to_record(record)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# -> Record; NotFoundError on a nonexistent id. The only path out of
|
|
63
|
+
# pending besides the engine's fired/blocked. Idempotent: an
|
|
64
|
+
# already-cancelled record returns as-is (a repeat of the same call is not
|
|
65
|
+
# an error).
|
|
66
|
+
def cancel(id:, now: Time.now.utc)
|
|
67
|
+
mutate(id, now) do |record|
|
|
68
|
+
status = record["status"]
|
|
69
|
+
unless %w[pending cancelled].include?(status)
|
|
70
|
+
raise ArgumentError, "follow-up #{id}: cannot cancel a #{status} record"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
record["status"] = "cancelled"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The engine's atomic claim: pending -> fired, WITH task_id. Read-check-
|
|
78
|
+
# write inside Store#transaction (D5 — the record and the task commit
|
|
79
|
+
# together). A second claim raises. `fired_at` stamps WHEN the fire
|
|
80
|
+
# happened — the frequency ceiling and the A/B card count FIRES, never the
|
|
81
|
+
# scheduled time (a record booked days ago and fired after a tick outage
|
|
82
|
+
# must still count against the cap).
|
|
83
|
+
def transition_fired(id:, task_id:, now: Time.now.utc)
|
|
84
|
+
mutate(id, now) do |record|
|
|
85
|
+
raise ArgumentError, "follow-up #{id}: not pending (#{record['status']}) — it fires once" unless record["status"] == "pending"
|
|
86
|
+
|
|
87
|
+
record["status"] = "fired"
|
|
88
|
+
record["task_id"] = task_id.to_s
|
|
89
|
+
record["fired_at"] = now.utc.iso8601
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# pending -> blocked, with the failing rule name. -> Record
|
|
94
|
+
def block(id:, reason:, now: Time.now.utc)
|
|
95
|
+
mutate(id, now) do |record|
|
|
96
|
+
raise ArgumentError, "follow-up #{id}: not pending (#{record['status']}) — only a pending record blocks" unless record["status"] == "pending"
|
|
97
|
+
|
|
98
|
+
record["status"] = "blocked"
|
|
99
|
+
record["blocked_reason"] = reason.to_s
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# -> Record | nil
|
|
104
|
+
def find(id)
|
|
105
|
+
record = @store.get(SCOPE, key_for_id(id))
|
|
106
|
+
record && to_record(record)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# pending AND at <= now, oldest first (at, then id — determinism).
|
|
110
|
+
def due(now: Time.now.utc)
|
|
111
|
+
cutoff = now.iso8601
|
|
112
|
+
@store.list(SCOPE).filter_map do |k|
|
|
113
|
+
record = @store.get(SCOPE, k)
|
|
114
|
+
next unless record && record["status"] == "pending" && record["at"].to_s <= cutoff
|
|
115
|
+
|
|
116
|
+
to_record(record)
|
|
117
|
+
end.sort_by { |r| [r.at.to_s, r.id] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ALL records of one (tenant, agent) — the Follow-ups page's read (C10),
|
|
121
|
+
# lexicographic (the scheduled at first, so a fired record keeps its
|
|
122
|
+
# position for the A/B card).
|
|
123
|
+
def for_agent(tenant:, agent:)
|
|
124
|
+
prefix = "#{tenant_id(tenant)}:#{agent}:"
|
|
125
|
+
@store.list(SCOPE).filter_map do |k|
|
|
126
|
+
next unless k.start_with?(prefix)
|
|
127
|
+
|
|
128
|
+
to_record(@store.get(SCOPE, k))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The dedup scans (D7): the OLDEST pending record of the pair (nil when
|
|
133
|
+
# none) and how many fired records of the customer fall inside the window
|
|
134
|
+
# (the frequency gate). `pending_for` scans the pair's keys in key order —
|
|
135
|
+
# the key embeds the scheduled `at` and the id, so the first pending record
|
|
136
|
+
# found IS the oldest pending.
|
|
137
|
+
def pending_for(tenant:, agent:, customer:, reason:)
|
|
138
|
+
pending_record_for(tenant: tenant, agent: agent, customer: customer, reason: reason)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def pending_for?(tenant:, agent:, customer:, reason:)
|
|
142
|
+
!pending_record_for(tenant: tenant, agent: agent, customer: customer, reason: reason).nil?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def fired_in_window(tenant:, customer:, since:)
|
|
146
|
+
boundary = since.iso8601
|
|
147
|
+
count = 0
|
|
148
|
+
@store.list(SCOPE).each do |k|
|
|
149
|
+
record = @store.get(SCOPE, k)
|
|
150
|
+
next unless record
|
|
151
|
+
next unless record["status"] == "fired"
|
|
152
|
+
next unless record["tenant"] == tenant_id(tenant)
|
|
153
|
+
next unless record["customer"] == customer.to_s
|
|
154
|
+
# the frequency gate counts FIRES, never the scheduled time — a record
|
|
155
|
+
# booked long ago and fired after a backlog still lands in the window.
|
|
156
|
+
next unless record["fired_at"].to_s >= boundary
|
|
157
|
+
|
|
158
|
+
count += 1
|
|
159
|
+
end
|
|
160
|
+
count
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Purge/prune (C11 — the LGPD footprint): one customer's records; a
|
|
164
|
+
# tenant's; records older than the cutoff — TERMINAL (fired/cancelled/
|
|
165
|
+
# blocked) OR pendings past their at (a zombie that will never fire;
|
|
166
|
+
# retention ages it out rather than firing late). All count-returning and
|
|
167
|
+
# nil-safe.
|
|
168
|
+
|
|
169
|
+
# cancels every PENDING record of the customer inside ONE
|
|
170
|
+
# transaction — the opt-out discipline (D2: a half-cancelled opt-out is
|
|
171
|
+
# the spam bug). Blocked/fired records are never touched. -> count
|
|
172
|
+
# cancelled.
|
|
173
|
+
def cancel_pending_for(tenant:, customer:)
|
|
174
|
+
count = 0
|
|
175
|
+
@store.transaction do
|
|
176
|
+
@store.list(SCOPE).each do |k|
|
|
177
|
+
record = @store.get(SCOPE, k)
|
|
178
|
+
next unless record && record["status"] == "pending"
|
|
179
|
+
next unless record["tenant"] == tenant_id(tenant)
|
|
180
|
+
next unless record["customer"] == customer.to_s
|
|
181
|
+
|
|
182
|
+
record["status"] = "cancelled"
|
|
183
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
184
|
+
@store.set(SCOPE, k, record)
|
|
185
|
+
count += 1
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
count
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def purge_customer(tenant:, customer:)
|
|
192
|
+
removed = 0
|
|
193
|
+
@store.list(SCOPE).each do |k|
|
|
194
|
+
record = @store.get(SCOPE, k)
|
|
195
|
+
next unless record && record["tenant"] == tenant_id(tenant)
|
|
196
|
+
next unless record["customer"] == customer.to_s
|
|
197
|
+
|
|
198
|
+
@store.delete(SCOPE, k)
|
|
199
|
+
removed += 1
|
|
200
|
+
end
|
|
201
|
+
removed
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def purge(tenant:)
|
|
205
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
206
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
207
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
208
|
+
keys.size
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# -> count removed.
|
|
212
|
+
def delete_older_than(time)
|
|
213
|
+
cutoff = time.utc.iso8601
|
|
214
|
+
removed = 0
|
|
215
|
+
@store.list(SCOPE).each do |k|
|
|
216
|
+
record = @store.get(SCOPE, k)
|
|
217
|
+
next unless record
|
|
218
|
+
|
|
219
|
+
terminal = record["status"] != "pending"
|
|
220
|
+
# TERMINAL records age by their updated_at; a PENDING record is a
|
|
221
|
+
# zombie when its scheduled `at` has passed (it will never fire —
|
|
222
|
+
# retention ages it out rather than firing late).
|
|
223
|
+
next unless (terminal && record["updated_at"].to_s < cutoff) ||
|
|
224
|
+
(!terminal && record["at"].to_s < cutoff)
|
|
225
|
+
|
|
226
|
+
@store.delete(SCOPE, k)
|
|
227
|
+
removed += 1
|
|
228
|
+
end
|
|
229
|
+
removed
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
# -> Record | nil — the OLDEST pending record of the (agent, customer,
|
|
235
|
+
# reason) pair, if any (the dedup rule holds at creation AND at fire: the
|
|
236
|
+
# firer only fires the oldest pending per pair).
|
|
237
|
+
def pending_record_for(tenant:, agent:, customer:, reason:)
|
|
238
|
+
prefix = "#{tenant_id(tenant)}:#{agent}:"
|
|
239
|
+
@store.list(SCOPE).each do |k|
|
|
240
|
+
next unless k.start_with?(prefix)
|
|
241
|
+
|
|
242
|
+
record = @store.get(SCOPE, k)
|
|
243
|
+
next unless record && record["status"] == "pending"
|
|
244
|
+
next unless record["customer"] == customer.to_s
|
|
245
|
+
next unless record["reason"] == reason.to_s
|
|
246
|
+
|
|
247
|
+
return to_record(record)
|
|
248
|
+
end
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def mutate(id, now)
|
|
253
|
+
@store.transaction do
|
|
254
|
+
record = @store.get(SCOPE, key_for_id(id))
|
|
255
|
+
raise Insika::NotFoundError, "follow-up not found: #{id}" if record.nil?
|
|
256
|
+
|
|
257
|
+
yield record
|
|
258
|
+
record["updated_at"] = now.utc.iso8601
|
|
259
|
+
@store.set(SCOPE, key_for_id(id), record)
|
|
260
|
+
to_record(record)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def key_for(record)
|
|
265
|
+
# tenant + agent + customer + the scheduled at + id: per-customer /
|
|
266
|
+
# per-agent scans are prefixes, the at-first ordering keeps a fired
|
|
267
|
+
# record at its scheduled position (the A/B card's lexicographic list).
|
|
268
|
+
"#{record['tenant']}:#{record['agent']}:#{record['customer']}:#{record['at']}:#{record['id']}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def key_for_id(id)
|
|
272
|
+
# the id is the LAST segment, so a find is a suffix match over the
|
|
273
|
+
# scope's keys (there is no stable prefix for an id alone).
|
|
274
|
+
@store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def parse_at(at)
|
|
278
|
+
case at
|
|
279
|
+
when Time then at.utc
|
|
280
|
+
else
|
|
281
|
+
begin
|
|
282
|
+
Time.iso8601(at.to_s).utc
|
|
283
|
+
rescue ArgumentError
|
|
284
|
+
raise Insika::ValidationError, "follow-up `at` must be an ISO8601 time, got: #{at.inspect}"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def tenant_id(tenant)
|
|
290
|
+
t = tenant.to_s
|
|
291
|
+
t.empty? ? "platform" : t
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def to_record(rec)
|
|
295
|
+
return nil if rec.nil?
|
|
296
|
+
|
|
297
|
+
Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
|
|
298
|
+
customer: rec["customer"], session_id: rec["session_id"],
|
|
299
|
+
at: rec["at"], reason: rec["reason"], arm: rec["arm"],
|
|
300
|
+
status: rec["status"], task_id: rec["task_id"],
|
|
301
|
+
blocked_reason: rec["blocked_reason"], transport: rec["transport"],
|
|
302
|
+
created_at: rec["created_at"], updated_at: rec["updated_at"],
|
|
303
|
+
fired_at: rec["fired_at"])
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# the parsed funnel declaration of ONE agent — the ONLY shape
|
|
5
|
+
# the engine accepts, shared by the fold, the doctor, the Studio and the
|
|
6
|
+
# freeze command. Pure value object: it never touches a store, and the engine
|
|
7
|
+
# never hard-codes a stage name (D1) — `stages`/`primary`/`advance_on` are the
|
|
8
|
+
# forge's vocabulary, carried as data.
|
|
9
|
+
#
|
|
10
|
+
# `parse` returns nil on a malformed hash (D8: the fold skips that agent, the
|
|
11
|
+
# doctor explains the exact defect); `parse!` raises Insika::ValidationError
|
|
12
|
+
# naming the field. `attribution_window` is carried data, never computed (D4).
|
|
13
|
+
class FunnelDeclaration
|
|
14
|
+
WINDOW_RE = /\A\d+h\z/
|
|
15
|
+
|
|
16
|
+
attr_reader :stages, :advance_on, :primary, :attribution_window
|
|
17
|
+
|
|
18
|
+
def self.parse(hash)
|
|
19
|
+
new(hash)
|
|
20
|
+
rescue Insika::ValidationError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.parse!(hash)
|
|
25
|
+
new(hash)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(hash)
|
|
29
|
+
raise Insika::ValidationError, "funnel: declaration must be a Hash" unless hash.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
h = hash.transform_keys(&:to_s)
|
|
32
|
+
@stages = stages_of(h)
|
|
33
|
+
@advance_on = advance_on_of(h, @stages)
|
|
34
|
+
@primary = primary_of(h, @stages)
|
|
35
|
+
@attribution_window = window_of(h)
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# position of a stage in the declared order. -> Integer | nil
|
|
40
|
+
def index_of(stage)
|
|
41
|
+
index = @stages.index(stage.to_s)
|
|
42
|
+
index.nil? ? nil : index
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# the first declared stage — the funnel's denominator.
|
|
46
|
+
def first_stage = @stages.first
|
|
47
|
+
|
|
48
|
+
# "72h" -> 72 (Integer). Always valid once parsed.
|
|
49
|
+
def window_hours = @attribution_window.to_i
|
|
50
|
+
|
|
51
|
+
# Value-object equality: two declarations parsed from equal input (symbol
|
|
52
|
+
# or string keys) are the same declaration.
|
|
53
|
+
def ==(other)
|
|
54
|
+
other.is_a?(FunnelDeclaration) &&
|
|
55
|
+
@stages == other.stages && @advance_on == other.advance_on &&
|
|
56
|
+
@primary == other.primary && @attribution_window == other.attribution_window
|
|
57
|
+
end
|
|
58
|
+
alias eql? ==
|
|
59
|
+
|
|
60
|
+
def hash
|
|
61
|
+
[@stages, @advance_on, @primary, @attribution_window].hash
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def stages_of(hash)
|
|
67
|
+
list = hash["stages"]
|
|
68
|
+
raise Insika::ValidationError, "funnel.stages: must be a non-empty Array of non-blank Strings" unless valid_stages?(list)
|
|
69
|
+
|
|
70
|
+
list
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def valid_stages?(list)
|
|
74
|
+
list.is_a?(Array) && !list.empty? && list.all? { |s| s.is_a?(String) && !s.strip.empty? } &&
|
|
75
|
+
list.uniq.length == list.length
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def advance_on_of(hash, stages)
|
|
79
|
+
map = hash["advance_on"]
|
|
80
|
+
raise Insika::ValidationError, "funnel.advance_on: must be a non-empty Hash of kind => stage" unless map.is_a?(Hash) && !map.empty?
|
|
81
|
+
|
|
82
|
+
map = map.transform_keys(&:to_s)
|
|
83
|
+
unless map.all? { |kind, stage| !kind.strip.empty? && stage.is_a?(String) && stages.include?(stage) }
|
|
84
|
+
raise Insika::ValidationError, "funnel.advance_on: every value must be one of the declared stages"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
map
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def primary_of(hash, stages)
|
|
91
|
+
primary = hash["primary"]
|
|
92
|
+
raise Insika::ValidationError, "funnel.primary: must be one of the declared stages" unless primary.is_a?(String) && stages.include?(primary)
|
|
93
|
+
|
|
94
|
+
primary
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def window_of(hash)
|
|
98
|
+
window = hash["attribution_window"]
|
|
99
|
+
unless window.is_a?(String) && WINDOW_RE.match?(window)
|
|
100
|
+
raise Insika::ValidationError, "funnel.attribution_window: must match /\\A\\d+h\\z/ (e.g. \"72h\")"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
window
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# the tick-driven fold of WS7 outcome records into the
|
|
8
|
+
# pack-declared stages (the tick's cadence). One pass per claim
|
|
9
|
+
# window; the fold is idempotent across crashes via the per-pair {at, ids}
|
|
10
|
+
# cursor (D3) and per-pair transactions. Pairs whose declaration is absent or
|
|
11
|
+
# malformed are skipped (D8), as are outcome kinds the declaration does not
|
|
12
|
+
# map — the funnel shows the hole. Nothing else: no attribution
|
|
13
|
+
# (D4), no stage vocabulary of its own (D1).
|
|
14
|
+
class FunnelFold
|
|
15
|
+
SCOPE = "funnel_fold"
|
|
16
|
+
KEY = "claim"
|
|
17
|
+
DEFAULT_WINDOW = 300 # seconds; one folding worker per window
|
|
18
|
+
|
|
19
|
+
def initialize(outcome_store:, funnel_store:, profiles:, store:,
|
|
20
|
+
window: DEFAULT_WINDOW, now: nil)
|
|
21
|
+
@outcome_store = outcome_store
|
|
22
|
+
@funnel_store = funnel_store
|
|
23
|
+
@profiles = profiles
|
|
24
|
+
@store = store
|
|
25
|
+
@window = window
|
|
26
|
+
@now = now
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# One pass. -> { claimed: true, folded: Integer, skipped: Integer, pairs: Integer }
|
|
30
|
+
# | { claimed: false } (another worker holds the window — the
|
|
31
|
+
# retention.rb:74 shape, so the tick summary reads uniformly)
|
|
32
|
+
def run
|
|
33
|
+
return { claimed: false } unless claim
|
|
34
|
+
|
|
35
|
+
folded = 0
|
|
36
|
+
skipped = 0
|
|
37
|
+
pairs = 0
|
|
38
|
+
# The pairs come from the OUTCOME keys (prefix scan, no record reads);
|
|
39
|
+
# each pair's records are read by `for_pair`, so a pair without a
|
|
40
|
+
# declaration is never even read (a full `all` would read every record
|
|
41
|
+
# of every pair — the O(n) the key shape is there to avoid).
|
|
42
|
+
@outcome_store.pairs.each do |pair|
|
|
43
|
+
begin
|
|
44
|
+
declaration = declaration_for(pair[:agent])
|
|
45
|
+
next unless declaration
|
|
46
|
+
|
|
47
|
+
folded += fold_pair(tenant: pair[:tenant], agent: pair[:agent],
|
|
48
|
+
declaration: declaration, skipped: -> { skipped += 1 })
|
|
49
|
+
pairs += 1
|
|
50
|
+
rescue StandardError
|
|
51
|
+
# a broken pair must not hold every other store's funnel hostage —
|
|
52
|
+
# its own transaction already rolled back; the pass keeps folding.
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
{ claimed: true, folded: folded, skipped: skipped, pairs: pairs }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# From scratch (E1's "recompute matches the incremental fold", and the
|
|
60
|
+
# repair path for backfilled outcomes): wipes the pair's day cells and
|
|
61
|
+
# rebuilds them from every outcome record. `tenant:` takes the store's
|
|
62
|
+
# spellings alike (nil/""/"platform" = the no-tenant pair) — `for_pair` and
|
|
63
|
+
# `delete_days` normalize at their own key boundaries, so no tenant is
|
|
64
|
+
# ever mixed with another (a "platform" recompute never folds "acme"'s
|
|
65
|
+
# records). -> folded count.
|
|
66
|
+
def recompute(tenant:, agent:, declaration:)
|
|
67
|
+
@funnel_store.delete_days(tenant: tenant, agent: agent)
|
|
68
|
+
fold_pair(tenant: tenant, agent: agent, declaration: declaration, full: true)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# -> FunnelDeclaration | nil — absent or malformed declarations read as
|
|
74
|
+
# "nothing to fold" (D8); never raises.
|
|
75
|
+
def declaration_for(agent)
|
|
76
|
+
profile = @profiles.fetch(agent)
|
|
77
|
+
return nil unless profile
|
|
78
|
+
|
|
79
|
+
Insika::FunnelDeclaration.parse(profile.funnel)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The fold for one pair: cursor-filtered, ONE transaction (all adds + the
|
|
83
|
+
# cursor write — D3: a crash leaves either the old or the new state, never
|
|
84
|
+
# a half-fold). `full: true` (recompute) ignores the cursor — every record
|
|
85
|
+
# folds and the cursor rewrites from the newest. Records are read by key
|
|
86
|
+
# prefix, optionally skipping the keys older than the cursor's day without
|
|
87
|
+
# reading them. -> folded count.
|
|
88
|
+
def fold_pair(tenant:, agent:, declaration:, records: nil, skipped: nil, full: false)
|
|
89
|
+
cursor = @funnel_store.cursor(tenant: tenant, agent: agent)
|
|
90
|
+
# The cursor's DAY is the prefix bound: records on that day may still be
|
|
91
|
+
# newer than the cursor (boundary handling below); anything older is
|
|
92
|
+
# already folded. `full` reads the whole pair.
|
|
93
|
+
records ||= @outcome_store.for_pair(tenant: tenant, agent: agent,
|
|
94
|
+
since_date: full ? nil : cursor["at"]&.[](0, 10))
|
|
95
|
+
new_records = records.select { |r| full || new_record?(r, cursor) }
|
|
96
|
+
.sort_by { |r| [r.at.to_s, r.id] }
|
|
97
|
+
return 0 if new_records.empty? && !full
|
|
98
|
+
if new_records.empty?
|
|
99
|
+
# recompute of an emptied pair: nothing to fold, but the stale cursor
|
|
100
|
+
# must not outlive its records (a later backfill would be invisible).
|
|
101
|
+
@funnel_store.set_cursor(tenant: tenant, agent: agent, at: nil, ids: [])
|
|
102
|
+
return 0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
folded = 0
|
|
106
|
+
# Grouped by DAY (the cell key), never by the full timestamp: N events on
|
|
107
|
+
# one day must be ONE read-modify-write of the day cell, not N.
|
|
108
|
+
counts_by_day = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
109
|
+
at_by_day = {}
|
|
110
|
+
newest_at = full ? nil : cursor["at"]
|
|
111
|
+
ids_at_newest = []
|
|
112
|
+
|
|
113
|
+
new_records.each do |record|
|
|
114
|
+
# The cursor advances over SKIPPED records too (they are not new any
|
|
115
|
+
# more): a pair whose integration only emits unmapped kinds would
|
|
116
|
+
# otherwise re-read the same records every window forever.
|
|
117
|
+
if newest_at.nil? || record.at.to_s > newest_at.to_s
|
|
118
|
+
newest_at = record.at.to_s
|
|
119
|
+
ids_at_newest = [record.id]
|
|
120
|
+
elsif record.at.to_s == newest_at.to_s
|
|
121
|
+
ids_at_newest << record.id
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
index = declaration.index_of(declaration.advance_on[record.outcome])
|
|
125
|
+
if index.nil?
|
|
126
|
+
skipped&.call
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
day = record.at.to_s[0, 10] # the record's at is always UTC ISO8601
|
|
131
|
+
declaration.stages[0..index].each { |stage| counts_by_day[day][stage] += 1 }
|
|
132
|
+
at_by_day[day] ||= record.at.to_s
|
|
133
|
+
folded += 1
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
@store.transaction do
|
|
137
|
+
counts_by_day.each do |day, counts|
|
|
138
|
+
@funnel_store.add(tenant: tenant, agent: agent, at: at_by_day[day], counts: counts)
|
|
139
|
+
end
|
|
140
|
+
@funnel_store.set_cursor(tenant: tenant, agent: agent,
|
|
141
|
+
at: newest_at, ids: ids_at_newest)
|
|
142
|
+
end
|
|
143
|
+
folded
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# `at > cursor.at` or boundary-equal and not already folded (D3). The at
|
|
147
|
+
# field is ISO8601 UTC, so the comparison is lexicographic; a record with
|
|
148
|
+
# `at` older than the cursor (a backfill) is missed incrementally —
|
|
149
|
+
# `recompute` is the repair path.
|
|
150
|
+
def new_record?(record, cursor)
|
|
151
|
+
at = cursor["at"]
|
|
152
|
+
return true if at.nil?
|
|
153
|
+
return true if record.at.to_s > at.to_s
|
|
154
|
+
|
|
155
|
+
record.at.to_s == at.to_s && !cursor["ids"].include?(record.id)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The claim window (tick.rb:93's idiom — read-check-write on one key inside
|
|
159
|
+
# a transaction): the scan is O(outcome records), fine at 288 passes/day,
|
|
160
|
+
# but it does not ride every 60 s tick.
|
|
161
|
+
def claim
|
|
162
|
+
now_time = @now || Time.now.utc
|
|
163
|
+
@store.transaction do
|
|
164
|
+
current = @store.get(SCOPE, KEY)
|
|
165
|
+
last = current && begin
|
|
166
|
+
Time.iso8601(current["claimed_at"].to_s)
|
|
167
|
+
rescue ArgumentError
|
|
168
|
+
nil # a corrupted claim is not a claim — take the window
|
|
169
|
+
end
|
|
170
|
+
if last.nil? || (now_time - last) >= @window
|
|
171
|
+
@store.set(SCOPE, KEY, { "claimed_at" => now_time.iso8601 })
|
|
172
|
+
true
|
|
173
|
+
else
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|