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,548 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# the durable half of the harvest. One record per mining RUN
|
|
8
|
+
# (window, candidates, cost), the per-candidate lifecycle (the unit a human
|
|
9
|
+
# acts on — D8), the APPEND-ONLY promotion log, the pre-promotion snapshots,
|
|
10
|
+
# and the per-session mined markers (D10's re-scan discipline). A dumb
|
|
11
|
+
# domain store: no policy (which candidate is worth gating is the gates'),
|
|
12
|
+
# no model, no skills — the SkillStore stays the skill's home.
|
|
13
|
+
#
|
|
14
|
+
# Key shapes (string keys, Store-contract JSON):
|
|
15
|
+
# "harvest" "run:<agent>:<started_at>:<id>" -> the mining run
|
|
16
|
+
# "harvest" "cand:<id>" -> one candidate lifecycle
|
|
17
|
+
# "harvest" "promo:<agent>:<at>:<id>" -> APPEND ONLY (D8)
|
|
18
|
+
# "harvest" "snap:<id>" -> the pre-promotion state
|
|
19
|
+
# "harvest" "session:<session_ref>" -> the D10 marker
|
|
20
|
+
#
|
|
21
|
+
# The agent id must not contain ":" (the run-key split — the RefinementStore
|
|
22
|
+
# rule). The `body` is the only content the store holds: behavior
|
|
23
|
+
# instructions, the same trust level as SkillStore content (D11).
|
|
24
|
+
class HarvestStore
|
|
25
|
+
SCOPE = "harvest"
|
|
26
|
+
|
|
27
|
+
# Run statuses: mining -> completed | no_candidates | failed. The run does
|
|
28
|
+
# NOT carry awaiting_approval: several candidates of one run can be at
|
|
29
|
+
# different gates (D8) — the human answers CANDIDATES, the run only frames.
|
|
30
|
+
RUN_STATUSES = %w[mining completed no_candidates failed].freeze
|
|
31
|
+
# Candidate statuses (the lifecycle a human acts on):
|
|
32
|
+
# pending -> gated -> awaiting_approval -> promoted | rejected
|
|
33
|
+
# (gated FAIL -> rejected with the report; the latch)
|
|
34
|
+
CANDIDATE_STATUSES = %w[pending gated awaiting_approval promoted rejected].freeze
|
|
35
|
+
# A candidate is OPEN (dedup-suppressing) until terminal.
|
|
36
|
+
OPEN_STATUSES = %w[pending gated awaiting_approval].freeze
|
|
37
|
+
|
|
38
|
+
Candidate = Data.define(:id, :run_id, :agent, :name, :description, :body,
|
|
39
|
+
:triggers, :rationale, :origin, :evidence_turns,
|
|
40
|
+
:proposer, :status, :rejected, :eval_gate,
|
|
41
|
+
:conversion_gate, :criterion_sha, :decision,
|
|
42
|
+
:promotion_ref, :created_at, :updated_at)
|
|
43
|
+
Promotion = Data.define(:id, :agent, :skill, :origin, :eval_ref,
|
|
44
|
+
:conversion_ref, :approver, :snapshot_ref,
|
|
45
|
+
:criterion_sha, :rolled_back_at, :at)
|
|
46
|
+
Snapshot = Data.define(:id, :agent, :skill, :content, :existed, :enabled_for, :at)
|
|
47
|
+
Run = Data.define(:id, :agent_id, :status, :window, :candidates, :rejected,
|
|
48
|
+
:budget, :cost, :started_at, :finished_at, :error)
|
|
49
|
+
|
|
50
|
+
# session_store: optional — the session source for `unmined_sessions`
|
|
51
|
+
# (D10). nil = the scan is inert (the engine may enumerate sessions
|
|
52
|
+
# itself; the store never invents a session space).
|
|
53
|
+
def initialize(store:, session_store: nil)
|
|
54
|
+
@store = store
|
|
55
|
+
@sessions = session_store
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ---- runs -----------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# Opens a run (:mining). `window` is the miner's window as data
|
|
61
|
+
# ({ "last_sessions" => N } | { "since" => iso8601 } | { "session_ids" => [...] }).
|
|
62
|
+
# `budget` is the pack's harvest.miner.budget (the cap the gates read —
|
|
63
|
+
# the refinement budget discipline). -> Run.
|
|
64
|
+
def create_run(agent_id:, window: {}, budget: nil, id: SecureRandom.uuid)
|
|
65
|
+
agent = agent_id.to_s
|
|
66
|
+
raise Insika::ValidationError, "agent_id is required" if agent.empty?
|
|
67
|
+
raise Insika::ValidationError, "agent_id must not contain ':'" if agent.include?(":")
|
|
68
|
+
|
|
69
|
+
started = timestamp
|
|
70
|
+
record = {
|
|
71
|
+
"id" => id.to_s, "agent_id" => agent, "status" => "mining",
|
|
72
|
+
"window" => Coercion.deep_stringify(window || {}),
|
|
73
|
+
"candidates" => 0, "rejected" => {}, "budget" => Coercion.deep_stringify(budget),
|
|
74
|
+
"cost" => nil,
|
|
75
|
+
"started_at" => started, "finished_at" => nil, "error" => nil
|
|
76
|
+
}
|
|
77
|
+
@store.set(SCOPE, "run:#{agent}:#{started}:#{id}", record)
|
|
78
|
+
to_run(record)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Closes a run with its candidate count. Zero candidates -> :no_candidates
|
|
82
|
+
# (a distinct outcome from :completed — "we looked and it was clean").
|
|
83
|
+
# `rejected` is the { reason/rule => count } map the filters counted (D4:
|
|
84
|
+
# "every rejected-by-list candidate is logged with the matching rule").
|
|
85
|
+
# -> Run. ArgumentError when the run is already terminal.
|
|
86
|
+
def complete_run(id, candidates:, cost: nil, rejected: nil)
|
|
87
|
+
update_run(id) do |record|
|
|
88
|
+
guard_run_state!(record, "mining")
|
|
89
|
+
record["candidates"] = Integer(candidates)
|
|
90
|
+
record["rejected"] = rejected ? Coercion.deep_stringify(rejected) : {}
|
|
91
|
+
record["cost"] = Coercion.deep_stringify(cost) if cost
|
|
92
|
+
record["status"] = Integer(candidates).positive? ? "completed" : "no_candidates"
|
|
93
|
+
record["finished_at"] = timestamp
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Closes a run as :failed, recording the error. -> Run.
|
|
98
|
+
def fail_run(id, error:)
|
|
99
|
+
update_run(id) do |record|
|
|
100
|
+
guard_run_state!(record, "mining")
|
|
101
|
+
record["status"] = "failed"
|
|
102
|
+
record["error"] = error.to_s
|
|
103
|
+
record["finished_at"] = timestamp
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def find_run(id)
|
|
108
|
+
key = run_key(id)
|
|
109
|
+
key && to_run(@store.get(SCOPE, key))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# -> [Run] for one agent, MOST RECENT FIRST, capped.
|
|
113
|
+
def runs_for(agent_id, limit: 20)
|
|
114
|
+
keys = @store.list(SCOPE, "run:#{agent_id}:").reverse
|
|
115
|
+
keys = keys.first(limit) if limit
|
|
116
|
+
keys.filter_map { |k| to_run(@store.get(SCOPE, k)) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ---- candidates -----------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# The engine stamps agent/origin/proposer — never the model (D3).
|
|
122
|
+
def create_candidate(run_id:, agent:, name:, description:, body:,
|
|
123
|
+
triggers: [], rationale:, origin:, evidence_turns: [],
|
|
124
|
+
proposer:, id: SecureRandom.uuid)
|
|
125
|
+
now = timestamp
|
|
126
|
+
record = {
|
|
127
|
+
"id" => id.to_s, "run_id" => run_id.to_s, "agent" => agent.to_s,
|
|
128
|
+
"name" => name.to_s, "description" => description.to_s, "body" => body.to_s,
|
|
129
|
+
"triggers" => Array(triggers).map(&:to_s), "rationale" => rationale.to_s,
|
|
130
|
+
"origin" => Array(origin).map(&:to_s),
|
|
131
|
+
"evidence_turns" => Array(evidence_turns).map(&:to_i),
|
|
132
|
+
"proposer" => proposer.to_s, "status" => "pending",
|
|
133
|
+
"rejected" => [], "eval_gate" => nil, "conversion_gate" => nil,
|
|
134
|
+
"criterion_sha" => nil, "decision" => nil, "promotion_ref" => nil,
|
|
135
|
+
"created_at" => now, "updated_at" => now
|
|
136
|
+
}
|
|
137
|
+
@store.set(SCOPE, "cand:#{id}", record)
|
|
138
|
+
to_candidate(record)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def find_candidate(id)
|
|
142
|
+
to_candidate(@store.get(SCOPE, "cand:#{id}"))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# -> [Candidate] filtered by agent/status, most recent first.
|
|
146
|
+
def candidates(agent_id: nil, status: nil)
|
|
147
|
+
scan_candidates.select do |c|
|
|
148
|
+
(agent_id.nil? || c.agent == agent_id.to_s) &&
|
|
149
|
+
(status.nil? || c.status == status.to_s)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# -> [Candidate] EVERY candidate parked on a human, most recent first.
|
|
154
|
+
def awaiting_approval(limit: 50)
|
|
155
|
+
list = candidates(status: "awaiting_approval").sort_by { |c| c.updated_at.to_s }.reverse
|
|
156
|
+
list.first(limit)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Dedup: an OPEN (non-terminal) candidate with the same (agent, name)
|
|
160
|
+
# suppresses a re-proposal. -> bool.
|
|
161
|
+
def open_pending?(agent:, name:)
|
|
162
|
+
@store.list(SCOPE, "cand:").any? do |k|
|
|
163
|
+
record = @store.get(SCOPE, k)
|
|
164
|
+
next false unless record
|
|
165
|
+
next false unless record["agent"] == agent.to_s && record["name"] == name.to_s
|
|
166
|
+
|
|
167
|
+
OPEN_STATUSES.include?(record["status"].to_s)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Transitions (read-check-write on #transaction; ArgumentError on a wrong
|
|
172
|
+
# source state — the task_store idiom).
|
|
173
|
+
|
|
174
|
+
# Appends a { rule, reason } rejection entry (the negative-list / gate
|
|
175
|
+
# evidence lines). Accumulates; only on a non-terminal candidate.
|
|
176
|
+
def attach_rejected(candidate_id, rules:, reason:)
|
|
177
|
+
update_candidate(candidate_id) do |record|
|
|
178
|
+
guard_candidate_non_terminal!(record)
|
|
179
|
+
list = record["rejected"] || []
|
|
180
|
+
record["rejected"] = list + Array(rules).map { |r| { "rule" => r.to_s, "reason" => reason.to_s } }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Records both gate reports + the boot-loaded criterion sha; pending ->
|
|
185
|
+
# gated.
|
|
186
|
+
def attach_gate(candidate_id, eval_gate:, conversion_gate:, criterion_sha:)
|
|
187
|
+
update_candidate(candidate_id) do |record|
|
|
188
|
+
guard_candidate_state!(record, "pending")
|
|
189
|
+
record["eval_gate"] = Coercion.deep_stringify(eval_gate)
|
|
190
|
+
record["conversion_gate"] = Coercion.deep_stringify(conversion_gate)
|
|
191
|
+
record["criterion_sha"] = criterion_sha.to_s
|
|
192
|
+
record["status"] = "gated"
|
|
193
|
+
record["updated_at"] = timestamp
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# gated -> awaiting_approval. The refusal parks: a conversion REFUSAL
|
|
198
|
+
# keeps the candidate AT gated (the page shows the ruler's hole).
|
|
199
|
+
def mark_awaiting(candidate_id)
|
|
200
|
+
update_candidate(candidate_id) do |record|
|
|
201
|
+
guard_candidate_state!(record, "gated")
|
|
202
|
+
record["status"] = "awaiting_approval"
|
|
203
|
+
record["updated_at"] = timestamp
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# -> rejected (terminal) from pending | gated | awaiting_approval — a
|
|
208
|
+
# human may always outvote the miner. The decision records by/at/note.
|
|
209
|
+
def mark_rejected(candidate_id, operator:, note: nil)
|
|
210
|
+
update_candidate(candidate_id) do |record|
|
|
211
|
+
guard_candidate_non_terminal!(record)
|
|
212
|
+
record["status"] = "rejected"
|
|
213
|
+
record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s,
|
|
214
|
+
"at" => timestamp, "note" => Coercion.presence(note) }.compact
|
|
215
|
+
record["updated_at"] = timestamp
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# awaiting_approval -> promoted. Recorded ONLY after the writes land (the
|
|
220
|
+
# record-after rule).
|
|
221
|
+
def mark_promoted(candidate_id, promotion_ref:)
|
|
222
|
+
update_candidate(candidate_id) do |record|
|
|
223
|
+
guard_candidate_state!(record, "awaiting_approval")
|
|
224
|
+
record["status"] = "promoted"
|
|
225
|
+
record["promotion_ref"] = promotion_ref.to_s
|
|
226
|
+
record["updated_at"] = timestamp
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# D8-bis: the conversion ruler is re-read AT APPROVE TIME and may have
|
|
231
|
+
# moved below the threshold since gating. The promote path re-checks; a
|
|
232
|
+
# dip parks the candidate BACK at gated with the FRESH report — the
|
|
233
|
+
# operator re-decides, and a skill does not land into a ruler that has
|
|
234
|
+
# moved the wrong way. awaiting_approval -> gated.
|
|
235
|
+
def recheck_conversion(candidate_id, conversion_gate:)
|
|
236
|
+
update_candidate(candidate_id) do |record|
|
|
237
|
+
guard_candidate_state!(record, "awaiting_approval")
|
|
238
|
+
record["conversion_gate"] = Coercion.deep_stringify(conversion_gate)
|
|
239
|
+
record["status"] = "gated"
|
|
240
|
+
record["updated_at"] = timestamp
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# ---- the append-only promotion log + snapshots ----------------------------
|
|
245
|
+
|
|
246
|
+
# Appends the promotion row — NO update, NO delete, no re-key. A second row with
|
|
247
|
+
# the same id is refused loudly.
|
|
248
|
+
def append_promotion(id:, agent:, skill:, origin: [], eval_ref: nil,
|
|
249
|
+
conversion_ref: nil, approver:, snapshot_ref: nil,
|
|
250
|
+
criterion_sha: nil, at: nil)
|
|
251
|
+
id = id.to_s
|
|
252
|
+
agent = agent.to_s
|
|
253
|
+
raise Insika::ValidationError, "promotion id is required" if id.empty?
|
|
254
|
+
raise Insika::ValidationError, "promotion already recorded: #{id}" if find_promotion_key(id)
|
|
255
|
+
|
|
256
|
+
now = at || timestamp
|
|
257
|
+
record = {
|
|
258
|
+
"id" => id, "agent" => agent, "skill" => skill.to_s,
|
|
259
|
+
"origin" => Array(origin).map(&:to_s), "eval_ref" => eval_ref.to_s,
|
|
260
|
+
"conversion_ref" => conversion_ref.to_s, "approver" => (Coercion.presence(approver) || "operator").to_s,
|
|
261
|
+
"snapshot_ref" => snapshot_ref.to_s, "criterion_sha" => criterion_sha.to_s,
|
|
262
|
+
"rolled_back_at" => nil, "at" => now
|
|
263
|
+
}
|
|
264
|
+
@store.set(SCOPE, "promo:#{agent}:#{now}:#{id}", record)
|
|
265
|
+
to_promotion(record)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Stamps rolled_back_at on the row — the log stays the single ledger (D9).
|
|
269
|
+
# -> Promotion.
|
|
270
|
+
def append_rollback(promotion_id:, operator: nil, reason: nil)
|
|
271
|
+
key = find_promotion_key(promotion_id.to_s)
|
|
272
|
+
raise Insika::NotFoundError, "promotion not found: #{promotion_id}" if key.nil?
|
|
273
|
+
|
|
274
|
+
record = @store.get(SCOPE, key)
|
|
275
|
+
record["rolled_back_at"] = timestamp
|
|
276
|
+
@store.set(SCOPE, key, record)
|
|
277
|
+
to_promotion(record)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# -> [Promotion] new-first, optionally per agent, capped.
|
|
281
|
+
def promotions(agent_id: nil, limit: 100)
|
|
282
|
+
keys = @store.list(SCOPE, agent_id ? "promo:#{agent_id}:" : "promo:")
|
|
283
|
+
rows = keys.filter_map { |k| to_promotion(@store.get(SCOPE, k)) }
|
|
284
|
+
rows.sort_by { |p| p.at.to_s }.reverse.first(limit)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The pre-promotion state (D8 — snapshot FIRST, then the writes).
|
|
288
|
+
def create_snapshot(agent:, skill:, content:, existed:, enabled_for:)
|
|
289
|
+
id = SecureRandom.uuid
|
|
290
|
+
record = { "id" => id, "agent" => agent.to_s, "skill" => skill.to_s,
|
|
291
|
+
"content" => content, "existed" => !!existed,
|
|
292
|
+
"enabled_for" => Array(enabled_for).map(&:to_s),
|
|
293
|
+
"at" => timestamp }
|
|
294
|
+
@store.set(SCOPE, "snap:#{id}", record)
|
|
295
|
+
to_snapshot(record)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def find_snapshot(id)
|
|
299
|
+
to_snapshot(@store.get(SCOPE, "snap:#{id}"))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# ---- the per-session marker (D10 - the re-scan discipline) ----------------
|
|
303
|
+
|
|
304
|
+
def mark_mined(session_ref, candidates: 0)
|
|
305
|
+
ref = session_ref.to_s
|
|
306
|
+
return {} if ref.empty?
|
|
307
|
+
|
|
308
|
+
record = { "session_ref" => ref, "mined_at" => timestamp,
|
|
309
|
+
"candidates" => Integer(candidates) }
|
|
310
|
+
@store.set(SCOPE, "session:#{ref}", record)
|
|
311
|
+
record
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def mined?(session_ref)
|
|
315
|
+
!@store.get(SCOPE, "session:#{session_ref.to_s}").nil?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# The engine's scan space: every session the source knows MINUS the marked
|
|
319
|
+
# set, optionally bounded by `since` (sessions updated at/after it — the
|
|
320
|
+
# incremental boundary). Nil source = inert (parity — the caller that owns
|
|
321
|
+
# a SessionStore enumerates itself).
|
|
322
|
+
# -> [String]
|
|
323
|
+
def unmined_sessions(since: nil)
|
|
324
|
+
return [] unless @sessions
|
|
325
|
+
|
|
326
|
+
@sessions.each_id.filter_map do |id|
|
|
327
|
+
next if mined?(id)
|
|
328
|
+
|
|
329
|
+
if since
|
|
330
|
+
session = @sessions.respond_to?(:find) ? @sessions.find(id) : nil
|
|
331
|
+
next unless session
|
|
332
|
+
next unless Time.parse(session.updated_at.to_s).utc >= Time.parse(since.to_s).utc
|
|
333
|
+
end
|
|
334
|
+
id.to_s
|
|
335
|
+
rescue ArgumentError
|
|
336
|
+
next
|
|
337
|
+
end.to_a
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ---- LGPD / retention (C13) -----------------------------------------------
|
|
341
|
+
|
|
342
|
+
# The tenant is the store's (candidates reference sessions; sessions carry
|
|
343
|
+
# the tenant prefix) — a prefix scan over the scope keys. Removes the
|
|
344
|
+
# tenant's candidates, promotion rows, their snapshots and the session
|
|
345
|
+
# markers. -> count removed.
|
|
346
|
+
def purge(tenant:)
|
|
347
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
348
|
+
removed = 0
|
|
349
|
+
|
|
350
|
+
promo_refs = []
|
|
351
|
+
@store.list(SCOPE, "promo:").each do |k|
|
|
352
|
+
record = @store.get(SCOPE, k)
|
|
353
|
+
next unless record && Array(record["origin"]).any? { |o| o.to_s.start_with?(prefix) }
|
|
354
|
+
|
|
355
|
+
@store.delete(SCOPE, k)
|
|
356
|
+
promo_refs << record["snapshot_ref"].to_s
|
|
357
|
+
removed += 1
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
@store.list(SCOPE, "cand:").each do |k|
|
|
361
|
+
record = @store.get(SCOPE, k)
|
|
362
|
+
next unless record && Array(record["origin"]).any? { |o| o.to_s.start_with?(prefix) }
|
|
363
|
+
|
|
364
|
+
@store.delete(SCOPE, k)
|
|
365
|
+
removed += 1
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
promo_refs.each do |ref|
|
|
369
|
+
next if ref.empty?
|
|
370
|
+
|
|
371
|
+
snap_key = "snap:#{ref}"
|
|
372
|
+
existing = @store.get(SCOPE, snap_key)
|
|
373
|
+
next unless existing
|
|
374
|
+
|
|
375
|
+
@store.delete(SCOPE, snap_key)
|
|
376
|
+
removed += 1
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
@store.list(SCOPE, "session:#{prefix}").each do |k|
|
|
380
|
+
@store.delete(SCOPE, k)
|
|
381
|
+
removed += 1
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
removed
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Candidates (pending AND terminal), log rows, snapshots and runs past the
|
|
388
|
+
# cutoff. They are re-derivable (D2) — pruning is never data loss. The
|
|
389
|
+
# session MARKERS are never pruned (the marker is the claim).
|
|
390
|
+
# -> count removed.
|
|
391
|
+
def delete_older_than(time)
|
|
392
|
+
cutoff = Time.parse(time.to_s).utc
|
|
393
|
+
removed = 0
|
|
394
|
+
|
|
395
|
+
[["cand:", "created_at"], ["snap:", "at"], ["run:", "started_at"]].each do |prefix, field|
|
|
396
|
+
@store.list(SCOPE, prefix).each do |k|
|
|
397
|
+
record = @store.get(SCOPE, k)
|
|
398
|
+
next unless record && record[field]
|
|
399
|
+
|
|
400
|
+
begin
|
|
401
|
+
next unless Time.parse(record[field].to_s).utc < cutoff
|
|
402
|
+
rescue ArgumentError
|
|
403
|
+
next
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
@store.delete(SCOPE, k)
|
|
407
|
+
removed += 1
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
@store.list(SCOPE, "promo:").each do |k|
|
|
412
|
+
record = @store.get(SCOPE, k)
|
|
413
|
+
next unless record && record["at"]
|
|
414
|
+
|
|
415
|
+
begin
|
|
416
|
+
next unless Time.parse(record["at"].to_s).utc < cutoff
|
|
417
|
+
rescue ArgumentError
|
|
418
|
+
next
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
@store.delete(SCOPE, k)
|
|
422
|
+
removed += 1
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
removed
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
private
|
|
429
|
+
|
|
430
|
+
def tenant_id(tenant)
|
|
431
|
+
t = tenant.to_s
|
|
432
|
+
t.empty? ? "platform" : t
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Fractional seconds so the run/promotion keys ("...:<started_at>:<id>")
|
|
436
|
+
# order CHRONOLOGICALLY under the store's lexicographic contract even for two
|
|
437
|
+
# writes in the same second (the "runs_for is newest-first" guarantee).
|
|
438
|
+
def timestamp = Time.now.utc.iso8601(6)
|
|
439
|
+
|
|
440
|
+
def run_key(id)
|
|
441
|
+
suffix = ":#{id}"
|
|
442
|
+
@store.list(SCOPE, "run:").find { |k| k.end_with?(suffix) }
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def find_promotion_key(id)
|
|
446
|
+
suffix = ":#{id}"
|
|
447
|
+
@store.list(SCOPE, "promo:").find { |k| k.end_with?(suffix) }
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def update_run(id)
|
|
451
|
+
key = run_key(id.to_s)
|
|
452
|
+
raise Insika::NotFoundError, "harvest run not found: #{id}" if key.nil?
|
|
453
|
+
|
|
454
|
+
record = @store.get(SCOPE, key)
|
|
455
|
+
if record.nil?
|
|
456
|
+
raise Insika::NotFoundError, "harvest run not found: #{id}"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
yield record
|
|
460
|
+
@store.set(SCOPE, key, record)
|
|
461
|
+
to_run(record)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def guard_run_state!(record, expected)
|
|
465
|
+
return if record["status"] == expected
|
|
466
|
+
|
|
467
|
+
raise ArgumentError, "run #{record['id']} is #{record['status']}, expected #{expected}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def update_candidate(candidate_id)
|
|
471
|
+
key = "cand:#{candidate_id}"
|
|
472
|
+
record = @store.get(SCOPE, key)
|
|
473
|
+
raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}" if record.nil?
|
|
474
|
+
|
|
475
|
+
yield record
|
|
476
|
+
record["updated_at"] = timestamp
|
|
477
|
+
@store.set(SCOPE, key, record)
|
|
478
|
+
to_candidate(record)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def guard_candidate_state!(record, expected)
|
|
482
|
+
return if record["status"] == expected
|
|
483
|
+
|
|
484
|
+
raise ArgumentError, "candidate #{record['id']} is #{record['status']}, expected #{expected}"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def guard_candidate_non_terminal!(record)
|
|
488
|
+
return if OPEN_STATUSES.include?(record["status"].to_s)
|
|
489
|
+
|
|
490
|
+
raise ArgumentError, "candidate #{record['id']} is already #{record['status']}"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def scan_candidates
|
|
494
|
+
@store.list(SCOPE, "cand:").filter_map { |k| to_candidate(@store.get(SCOPE, k)) }
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def to_run(record)
|
|
498
|
+
return nil if record.nil?
|
|
499
|
+
|
|
500
|
+
Run.new(
|
|
501
|
+
id: record["id"], agent_id: record["agent_id"],
|
|
502
|
+
status: record["status"].to_s, window: record["window"] || {},
|
|
503
|
+
candidates: record["candidates"] || 0, rejected: record["rejected"] || {},
|
|
504
|
+
budget: record["budget"], cost: record["cost"],
|
|
505
|
+
started_at: record["started_at"], finished_at: record["finished_at"],
|
|
506
|
+
error: record["error"]
|
|
507
|
+
)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def to_candidate(record)
|
|
511
|
+
return nil if record.nil?
|
|
512
|
+
|
|
513
|
+
Candidate.new(
|
|
514
|
+
id: record["id"], run_id: record["run_id"], agent: record["agent"],
|
|
515
|
+
name: record["name"], description: record["description"], body: record["body"],
|
|
516
|
+
triggers: Array(record["triggers"]), rationale: record["rationale"],
|
|
517
|
+
origin: Array(record["origin"]), evidence_turns: Array(record["evidence_turns"]),
|
|
518
|
+
proposer: record["proposer"], status: record["status"].to_s,
|
|
519
|
+
rejected: record["rejected"] || [], eval_gate: record["eval_gate"],
|
|
520
|
+
conversion_gate: record["conversion_gate"], criterion_sha: record["criterion_sha"],
|
|
521
|
+
decision: record["decision"], promotion_ref: record["promotion_ref"],
|
|
522
|
+
created_at: record["created_at"], updated_at: record["updated_at"]
|
|
523
|
+
)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def to_promotion(record)
|
|
527
|
+
return nil if record.nil?
|
|
528
|
+
|
|
529
|
+
Promotion.new(
|
|
530
|
+
id: record["id"], agent: record["agent"], skill: record["skill"],
|
|
531
|
+
origin: Array(record["origin"]), eval_ref: record["eval_ref"],
|
|
532
|
+
conversion_ref: record["conversion_ref"], approver: record["approver"],
|
|
533
|
+
snapshot_ref: record["snapshot_ref"], criterion_sha: record["criterion_sha"],
|
|
534
|
+
rolled_back_at: record["rolled_back_at"], at: record["at"]
|
|
535
|
+
)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def to_snapshot(record)
|
|
539
|
+
return nil if record.nil?
|
|
540
|
+
|
|
541
|
+
Snapshot.new(
|
|
542
|
+
id: record["id"], agent: record["agent"], skill: record["skill"],
|
|
543
|
+
content: record["content"], existed: record["existed"] == true,
|
|
544
|
+
enabled_for: Array(record["enabled_for"]), at: record["at"]
|
|
545
|
+
)
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|