Class: Insika::OutcomeStore
- Inherits:
-
Object
- Object
- Insika::OutcomeStore
- Defined in:
- lib/insika/outcome_store.rb
Overview
WS7: business outcomes over real traffic, recorded per conversation by the
OPERATOR or the integration (POST /v1/outcomes) — conversion,
escalation, deflected, … optionally with a monetary value. The engine
TRANSPORTS the outcome and never interprets it (the same rule as alerts):
what "conversion" means is the operator's. Durable in the KV backend,
tenant-scoped (WS1) — a tenant can only ever read/write its own cells (the
key carries the tenant, and the read methods narrow on it).
The Studio's scorecard is the consumer: the LAST outcome per agent as a pill on the grid, and the per-day series on the agent detail — both fed from the same store, no scheduler.
Defined Under Namespace
Classes: Record
Constant Summary collapse
- SCOPE =
"outcomes"
Instance Method Summary collapse
-
#all(tenant: nil, agent: nil) ⇒ Object
All records, NEWEST first.
-
#create(tenant:, agent:, session_id: nil, outcome:, value: nil, id: SecureRandom.uuid, at: Time.now.utc) ⇒ Object
-> Record.
-
#delete_older_than(time) ⇒ Object
Purges records older than the cutoff (WS8 retention — the tick's sweep).
-
#for_pair(tenant:, agent:, since_date: nil) ⇒ Object
the fold's per-pair read — one KEY-prefix scan (the key IS tenant:agent:YYYY-MM-DD:id, WS1), optionally skipping the keys older than an ISO date WITHOUT reading them (the fold's cursor day — everything before it is already folded).
-
#initialize(store:) ⇒ OutcomeStore
constructor
A new instance of OutcomeStore.
-
#latest_per_agent(tenant: nil) ⇒ Object
-> { agent => { outcome:, value:, at:, session_id: } }: the LAST outcome per agent — the Studio's scorecard state card.
-
#pairs ⇒ Object
the distinct (tenant, agent) pairs present in the store — one key scan, no record reads.
-
#purge(tenant:) ⇒ Object
Purges a tenant's records (WS8 phase 2 — delete_tenant_data).
-
#series(tenant: nil, agent: nil, period: :day) ⇒ Object
-> { "YYYY-MM-DD" => { outcome => { count:, value: } } } — per-period series for the Studio.
Constructor Details
#initialize(store:) ⇒ OutcomeStore
Returns a new instance of OutcomeStore.
23 24 25 |
# File 'lib/insika/outcome_store.rb', line 23 def initialize(store:) @store = store end |
Instance Method Details
#all(tenant: nil, agent: nil) ⇒ Object
All records, NEWEST first. tenant: narrows to one tenant's (WS1 — the
read path a tenant query uses); agent: narrows further. An outcome is a
fact, never a secret: the fields are ids, an outcome name and a number.
NOTE: tenant: nil means NO filter — the record's tenant field is
tenant.to_s ("" for a single-tenant write), so a caller that wants only
the no-tenant records must pass tenant: "" explicitly (the FOLD uses
for_pair, never this nil-means-everything trap).
45 46 47 48 49 50 |
# File 'lib/insika/outcome_store.rb', line 45 def all(tenant: nil, agent: nil) records = @store.list(SCOPE).filter_map { |k| to_record(@store.get(SCOPE, k)) } records.select! { |r| r.tenant == tenant } if tenant records.select! { |r| r.agent == agent } if agent records.sort_by(&:at).reverse end |
#create(tenant:, agent:, session_id: nil, outcome:, value: nil, id: SecureRandom.uuid, at: Time.now.utc) ⇒ Object
-> Record. at defaults to now (UTC); value defaults to 0.0 (an
outcome without a value is a unit outcome).
29 30 31 32 33 34 35 36 |
# File 'lib/insika/outcome_store.rb', line 29 def create(tenant:, agent:, session_id: nil, outcome:, value: nil, id: SecureRandom.uuid, at: Time.now.utc) time = at.utc record = { "id" => id, "tenant" => tenant.to_s, "agent" => agent.to_s, "session_id" => session_id.to_s, "outcome" => outcome.to_s, "value" => value.to_f, "at" => time.iso8601 } @store.set(SCOPE, key(tenant, agent, time, id), record) to_record(record) end |
#delete_older_than(time) ⇒ Object
Purges records older than the cutoff (WS8 retention — the tick's sweep).
at is ISO8601 UTC, so the comparison is lexicographic. -> count removed.
105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/insika/outcome_store.rb', line 105 def delete_older_than(time) cutoff = time.utc.iso8601 removed = 0 @store.list(SCOPE).each do |k| rec = @store.get(SCOPE, k) next unless rec && rec["at"].to_s < cutoff @store.delete(SCOPE, k) removed += 1 end removed end |
#for_pair(tenant:, agent:, since_date: nil) ⇒ Object
the fold's per-pair read — one KEY-prefix scan (the key IS
tenant:agent:YYYY-MM-DD:id, WS1), optionally skipping the keys older than
an ISO date WITHOUT reading them (the fold's cursor day — everything
before it is already folded). tenant: takes either spelling (nil/""
and "platform" all mean the no-tenant key segment). -> [Record].
57 58 59 60 61 62 |
# File 'lib/insika/outcome_store.rb', line 57 def for_pair(tenant:, agent:, since_date: nil) prefix = "#{key_tenant(tenant)}:#{agent}:" keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) } keys.select! { |k| date_segment(k) >= since_date } if since_date keys.filter_map { |k| to_record(@store.get(SCOPE, k)) } end |
#latest_per_agent(tenant: nil) ⇒ Object
-> { agent => { outcome:, value:, at:, session_id: } }: the LAST outcome per agent — the Studio's scorecard state card. A map so the agent grid looks up in O(1) per card.
75 76 77 78 79 80 |
# File 'lib/insika/outcome_store.rb', line 75 def latest_per_agent(tenant: nil) all(tenant: tenant).each_with_object({}) do |r, acc| acc[r.agent] ||= { outcome: r.outcome, value: r.value, at: r.at, session_id: r.session_id } end end |
#pairs ⇒ Object
the distinct (tenant, agent) pairs present in the store —
one key scan, no record reads. tenant is nil for the no-tenant segment
(the FunnelStore#pairs spelling; every key-based API normalizes it back).
-> [{ tenant: String | nil, agent: String }]
68 69 70 |
# File 'lib/insika/outcome_store.rb', line 68 def pairs @store.list(SCOPE).map { |k| pair_of(k) }.uniq end |
#purge(tenant:) ⇒ Object
Purges a tenant's records (WS8 phase 2 — delete_tenant_data). The tenant is the FIRST key segment, so the purge is a prefix scan — the key IS the isolation (WS1). -> count removed.
96 97 98 99 100 101 |
# File 'lib/insika/outcome_store.rb', line 96 def purge(tenant:) prefix = "#{tenant}:" keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) } keys.each { |k| @store.delete(SCOPE, k) } keys.size end |
#series(tenant: nil, agent: nil, period: :day) ⇒ Object
-> { "YYYY-MM-DD" => { outcome => { count:, value: } } } — per-period
series for the Studio. period: :month buckets by "YYYY-MM".
84 85 86 87 88 89 90 91 |
# File 'lib/insika/outcome_store.rb', line 84 def series(tenant: nil, agent: nil, period: :day) all(tenant: tenant, agent: agent).each_with_object({}) do |r, acc| bucket = period == :month ? r.at[0, 7] : r.at[0, 10] cell = ((acc[bucket] ||= {})[r.outcome] ||= { count: 0, value: 0.0 }) cell[:count] += 1 cell[:value] += r.value end end |