Class: Insika::ProposalStore
- Inherits:
-
Object
- Object
- Insika::ProposalStore
- Defined in:
- lib/insika/proposal_store.rb
Overview
the proposals and the two persisted mechanisms the gates
on — the latched dedup ledger (D3: the rows themselves ARE the ledger —
a dismissed/rejected tuple is never proposed again, and an unanswered
proposal is not piled on) and the per-session distilled marker (D2:
written only after a pass completes, so a crash mid-pass leaves the marker
unwritten and the next pass re-scans). A dumb domain store — it holds no
policy (which tuple is a fact is the distiller's job), no memory facts and
no model. The scope string (the memory cell) is built by the callers from
the MemoryStore::parse_cell shape; the store keys by
(tenant, customer) explicitly.
Statuses: pending -> approved | rejected | dismissed | stale.
stale is the CAS-lost re-present (E3): the proposal carries the fact's
CURRENT value (current_value) next to the proposed one, never a silent
overwrite.
Defined Under Namespace
Classes: Proposal
Constant Summary collapse
- SCOPE =
"proposals"- STATUSES =
%w[pending approved rejected dismissed stale].freeze
- TERMINAL =
%w[approved rejected dismissed].freeze
- PROPOSAL_PREFIX =
"p:"- MARKER_PREFIX =
"s:"
Instance Method Summary collapse
-
#approve(id:, operator: nil, note: nil, now: Time.now.utc) ⇒ Object
---- transitions, each read-check-write on @store.transaction ---- pending -> terminal.
-
#create(tenant:, customer:, session_ref:, key:, value:, confidence: nil, evidence: [], expected_revision: nil, expected_existed: false, id: SecureRandom.uuid, now: Time.now.utc) ⇒ Object
-> Proposal (status :pending).
-
#decided?(tenant:, customer:, key:, value:) ⇒ Boolean
---- the latched dedup (D3) ---- true when a dismissed/rejected row exists for the exact tuple — the latch.
-
#delete_older_than(time) ⇒ Object
Age-based prune (the retention sweep).
- #dismiss(id:, operator: nil, note: nil, now: Time.now.utc) ⇒ Object
- #distilled?(session_ref) ⇒ Boolean
- #distilled_sessions(agent_id = nil) ⇒ Object
- #find(id) ⇒ Object
-
#initialize(store:) ⇒ ProposalStore
constructor
A new instance of ProposalStore.
-
#mark_distilled(session_ref, agent:, proposals:, dropped:, deduped: 0, cost: nil, now: Time.now.utc) ⇒ Object
---- the per-session marker (D2) ---- Written ONLY after a pass completes (RunDistillation).
-
#mark_stale(id:, current_value:, operator: nil, now: Time.now.utc) ⇒ Object
pending -> stale, CAS lost;
current_value= the fact as it stands (the re-present's second value, E3). -
#open_pending?(tenant:, customer:, key:) ⇒ Boolean
true when a pending row exists for (scope, key) — no piling.
-
#pending(limit: 100) ⇒ Object
The wiki's lists.
-
#purge(tenant:) ⇒ Object
A tenant's proposals.
-
#purge_customer(tenant:, customer:) ⇒ Object
One customer's proposals, EVERY status.
- #reject(id:, operator: nil, note: nil, now: Time.now.utc) ⇒ Object
-
#resolved(limit: 20) ⇒ Object
The wiki's Recent list: every terminal status (approved/rejected/ dismissed), most recent first — the operator's audit trail.
- #stale(limit: 50) ⇒ Object
Constructor Details
#initialize(store:) ⇒ ProposalStore
Returns a new instance of ProposalStore.
34 35 36 |
# File 'lib/insika/proposal_store.rb', line 34 def initialize(store:) @store = store end |
Instance Method Details
#approve(id:, operator: nil, note: nil, now: Time.now.utc) ⇒ Object
---- transitions, each read-check-write on @store.transaction ---- pending -> terminal. ArgumentError for a wrong source state (the task_store.rb state-machine idiom).
118 119 120 |
# File 'lib/insika/proposal_store.rb', line 118 def approve(id:, operator: nil, note: nil, now: Time.now.utc) transition(id, "approved", operator: operator, note: note, now: now) end |
#create(tenant:, customer:, session_ref:, key:, value:, confidence: nil, evidence: [], expected_revision: nil, expected_existed: false, id: SecureRandom.uuid, now: Time.now.utc) ⇒ Object
-> Proposal (status :pending). The caller (RunDistillation) already ran
the dedup checks; the store writes. evidence = message indexes; the
revision baseline (D5) travels with the record.
The tenant is stored VERBATIM — nil in a single-tenant deployment, so
the scope is the bare customer cell and the approval reads/writes the
SAME cell the Memory provider injects (memory_store.rb's
blank-tenant + customer -> "memory:
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/insika/proposal_store.rb', line 48 def create(tenant:, customer:, session_ref:, key:, value:, confidence: nil, evidence: [], expected_revision: nil, expected_existed: false, id: SecureRandom.uuid, now: Time.now.utc) tenant = tenant_key(tenant) stamp = now.iso8601(6) record = { "id" => id.to_s, "status" => "pending", "tenant" => tenant, "customer" => customer.to_s, "scope" => [tenant, customer.to_s].compact.join(":"), "session_ref" => session_ref.to_s, "key" => key.to_s, "value" => value.to_s, "confidence" => confidence, "evidence" => Array(evidence).map(&:to_i), "expected_revision" => expected_revision, "expected_existed" => !!expected_existed, "current_value" => nil, "operator" => nil, "note" => nil, "created_at" => stamp, "updated_at" => stamp } @store.set(SCOPE, PROPOSAL_PREFIX + id.to_s, record) to_proposal(record) end |
#decided?(tenant:, customer:, key:, value:) ⇒ Boolean
---- the latched dedup (D3) ----
true when a dismissed/rejected row exists for the exact tuple — the
latch. Persisted rows ARE the ledger. A different value for the same
name is a different tuple.
99 100 101 102 103 104 105 |
# File 'lib/insika/proposal_store.rb', line 99 def decided?(tenant:, customer:, key:, value:) scan.any? do |p| tenant_key(p.tenant) == tenant_key(tenant) && p.customer == customer.to_s && p.key == key.to_s && p.value == value.to_s && %w[dismissed rejected].include?(p.status) end end |
#delete_older_than(time) ⇒ Object
Age-based prune (the retention sweep). TERMINAL rows age by their updated_at; a PENDING row is a zombie past the cutoff (its transcript is dead). Session MARKERS die WITH their proposals — a marker past the cutoff is evidence about a dead transcript (the session aged out under the same retention window), and keeping it would lock an unreviewed proposal out of re-distillation forever. -> count removed.
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/insika/proposal_store.rb', line 195 def delete_older_than(time) cutoff = time.utc.iso8601 removed = 0 @store.transaction do proposal_keys.each do |k| record = @store.get(SCOPE, k) next unless record terminal = TERMINAL.include?(record["status"]) stamp = terminal ? record["updated_at"] : record["created_at"] next unless stamp && stamp.to_s < cutoff @store.delete(SCOPE, k) removed += 1 end marker_keys.each do |k| marker = @store.get(SCOPE, k) next unless marker && marker["distilled_at"].to_s < cutoff @store.delete(SCOPE, k) removed += 1 end end removed end |
#dismiss(id:, operator: nil, note: nil, now: Time.now.utc) ⇒ Object
126 127 128 |
# File 'lib/insika/proposal_store.rb', line 126 def dismiss(id:, operator: nil, note: nil, now: Time.now.utc) transition(id, "dismissed", operator: operator, note: note, now: now) end |
#distilled?(session_ref) ⇒ Boolean
147 148 149 |
# File 'lib/insika/proposal_store.rb', line 147 def distilled?(session_ref) !@store.get(SCOPE, MARKER_PREFIX + session_ref.to_s).nil? end |
#distilled_sessions(agent_id = nil) ⇒ Object
151 152 153 154 |
# File 'lib/insika/proposal_store.rb', line 151 def distilled_sessions(agent_id = nil) keys = agent_id ? marker_keys.select { |k| marker_agent(k) == agent_id.to_s } : marker_keys keys.map { |k| k.delete_prefix(MARKER_PREFIX) } end |
#find(id) ⇒ Object
67 68 69 70 |
# File 'lib/insika/proposal_store.rb', line 67 def find(id) record = @store.get(SCOPE, PROPOSAL_PREFIX + id.to_s) record && to_proposal(record) end |
#mark_distilled(session_ref, agent:, proposals:, dropped:, deduped: 0, cost: nil, now: Time.now.utc) ⇒ Object
---- the per-session marker (D2) ---- Written ONLY after a pass completes (RunDistillation). -> the marker hash.
138 139 140 141 142 143 144 145 |
# File 'lib/insika/proposal_store.rb', line 138 def mark_distilled(session_ref, agent:, proposals:, dropped:, deduped: 0, cost: nil, now: Time.now.utc) marker = { "session_ref" => session_ref.to_s, "agent" => agent.to_s, "distilled_at" => now.iso8601, "proposals" => proposals.to_i, "dropped" => dropped, "deduped" => deduped.to_i, "cost" => cost } @store.set(SCOPE, MARKER_PREFIX + session_ref.to_s, marker) marker end |
#mark_stale(id:, current_value:, operator: nil, now: Time.now.utc) ⇒ Object
pending -> stale, CAS lost; current_value = the fact as it stands (the
re-present's second value, E3).
132 133 134 |
# File 'lib/insika/proposal_store.rb', line 132 def mark_stale(id:, current_value:, operator: nil, now: Time.now.utc) transition(id, "stale", operator: operator, current_value: current_value, now: now) end |
#open_pending?(tenant:, customer:, key:) ⇒ Boolean
true when a pending row exists for (scope, key) — no piling.
108 109 110 111 112 113 |
# File 'lib/insika/proposal_store.rb', line 108 def open_pending?(tenant:, customer:, key:) scan.any? do |p| tenant_key(p.tenant) == tenant_key(tenant) && p.customer == customer.to_s && p.key == key.to_s && p.status == "pending" end end |
#pending(limit: 100) ⇒ Object
The wiki's lists. pending = pending, oldest first (the operator works
the oldest proposal first — evidence ages).
74 75 76 77 78 |
# File 'lib/insika/proposal_store.rb', line 74 def pending(limit: 100) scan.select { |p| p.status == "pending" } .sort_by { |p| [p.created_at.to_s, p.id] } .first(limit) end |
#purge(tenant:) ⇒ Object
A tenant's proposals. -> count removed.
175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/insika/proposal_store.rb', line 175 def purge(tenant:) removed = 0 @store.transaction do proposal_keys.each do |k| record = @store.get(SCOPE, k) next unless record && record["tenant"] == tenant_key(tenant) @store.delete(SCOPE, k) removed += 1 end end removed end |
#purge_customer(tenant:, customer:) ⇒ Object
One customer's proposals, EVERY status. -> count removed.
159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/insika/proposal_store.rb', line 159 def purge_customer(tenant:, customer:) removed = 0 @store.transaction do proposal_keys.each do |k| record = @store.get(SCOPE, k) next unless record && record["tenant"] == tenant_key(tenant) next unless record["customer"] == customer.to_s @store.delete(SCOPE, k) removed += 1 end end removed end |
#reject(id:, operator: nil, note: nil, now: Time.now.utc) ⇒ Object
122 123 124 |
# File 'lib/insika/proposal_store.rb', line 122 def reject(id:, operator: nil, note: nil, now: Time.now.utc) transition(id, "rejected", operator: operator, note: note, now: now) end |
#resolved(limit: 20) ⇒ Object
The wiki's Recent list: every terminal status (approved/rejected/ dismissed), most recent first — the operator's audit trail.
88 89 90 91 92 93 |
# File 'lib/insika/proposal_store.rb', line 88 def resolved(limit: 20) scan.select { |p| TERMINAL.include?(p.status) } .sort_by { |p| p.updated_at.to_s } .reverse .first(limit) end |
#stale(limit: 50) ⇒ Object
80 81 82 83 84 |
# File 'lib/insika/proposal_store.rb', line 80 def stale(limit: 50) scan.select { |p| p.status == "stale" } .sort_by { |p| p.updated_at.to_s } .first(limit) end |