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,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# the durable aggregates of the OUTCOME FUNNEL — per-day stage
|
|
7
|
+
# counts (the fold's cells), the fold cursor and the baseline snapshot.
|
|
8
|
+
# A dumb domain store: it holds no outcome_store and never folds (C4 owns the
|
|
9
|
+
# transformation), and it is recomputable by construction — the OutcomeStore
|
|
10
|
+
# stays the source of truth.
|
|
11
|
+
#
|
|
12
|
+
# Key shapes (string keys, Store-contract JSON):
|
|
13
|
+
# "funnel" "acme:store-support:2026-08-14" -> { "greeted" => 41, "paid" => 3 }
|
|
14
|
+
# "funnel_cursor" "acme:store-support" -> { "at" => ISO8601 | nil, "ids" => [uuid, …] }
|
|
15
|
+
# "funnel_baseline" "acme:store-support" -> { from/to/stages/primary/…/frozen_at }
|
|
16
|
+
#
|
|
17
|
+
# The no-tenant case is the literal "platform" (the outcome KEY's rule), so
|
|
18
|
+
# funnel and outcome keys share the same tenant segment and the purge prefix
|
|
19
|
+
# scan is the same string. A record's `tenant` field is `tenant.to_s` — "" for
|
|
20
|
+
# a single-tenant write — so every key-builder normalizes a blank tenant to
|
|
21
|
+
# "platform" HERE (one place), or a single-tenant fold lands in an ""-prefixed
|
|
22
|
+
# cell that purge("platform") never removes.
|
|
23
|
+
class FunnelStore
|
|
24
|
+
SCOPE = "funnel" # day cells
|
|
25
|
+
CURSOR_SCOPE = "funnel_cursor" # per (tenant, agent)
|
|
26
|
+
BASELINE_SCOPE = "funnel_baseline" # per (tenant, agent)
|
|
27
|
+
|
|
28
|
+
def initialize(store:)
|
|
29
|
+
@store = store
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Cumulative increment (D2): `counts` is the fold's { stage => 1 } hash for
|
|
33
|
+
# the reached prefix stages[0..index] — the STORE stays free of the
|
|
34
|
+
# declaration (D1), it only accumulates. Bumped in ONE transaction.
|
|
35
|
+
# -> { stage => count } the NEW day counts (string keys, declared order).
|
|
36
|
+
def add(tenant:, agent:, at:, counts:)
|
|
37
|
+
id = pair_id(tenant, agent)
|
|
38
|
+
day_key = day_segment(at)
|
|
39
|
+
@store.transaction do
|
|
40
|
+
cell = @store.get(SCOPE, "#{id}:#{day_key}") || {}
|
|
41
|
+
counts.each { |stage, n| cell[stage.to_s] = cell[stage.to_s].to_i + n }
|
|
42
|
+
@store.set(SCOPE, "#{id}:#{day_key}", cell)
|
|
43
|
+
cell
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# -> { stage => count } | {} — one day's cell ("YYYY-MM-DD").
|
|
48
|
+
def day(tenant:, agent:, day:)
|
|
49
|
+
@store.get(SCOPE, "#{pair_id(tenant, agent)}:#{day}") || {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -> { "YYYY-MM-DD" => { stage => count } } sorted ascending, bounded by
|
|
53
|
+
# ISO-date strings from:/to: (inclusive). Empty hash when none.
|
|
54
|
+
def days(tenant:, agent:, from: nil, to: nil)
|
|
55
|
+
prefix = "#{pair_id(tenant, agent)}:"
|
|
56
|
+
@store.list(SCOPE).select { |k| k.start_with?(prefix) }.each_with_object({}) do |k, acc|
|
|
57
|
+
day = k.delete_prefix(prefix)
|
|
58
|
+
next unless within?(day, from, to)
|
|
59
|
+
|
|
60
|
+
acc[day] = @store.get(SCOPE, k)
|
|
61
|
+
end.sort.to_h
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The fold cursor of the pair. -> { "at" => String | nil, "ids" => [String] }
|
|
65
|
+
def cursor(tenant:, agent:)
|
|
66
|
+
record = @store.get(CURSOR_SCOPE, pair_id(tenant, agent))
|
|
67
|
+
{ "at" => record && record["at"], "ids" => Array(record && record["ids"]) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def set_cursor(tenant:, agent:, at:, ids:)
|
|
71
|
+
@store.set(CURSOR_SCOPE, pair_id(tenant, agent), { "at" => at, "ids" => Array(ids) })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -> Hash | nil — the current baseline record (D5), read back verbatim.
|
|
75
|
+
def baseline(tenant:, agent:)
|
|
76
|
+
@store.get(BASELINE_SCOPE, pair_id(tenant, agent))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Overwrites (D5 — one current snapshot per pair, no history).
|
|
80
|
+
def set_baseline(tenant:, agent:, record:)
|
|
81
|
+
@store.set(BASELINE_SCOPE, pair_id(tenant, agent), record)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Purge one tenant: its day cells, cursors and baselines (DeleteTenantData,
|
|
85
|
+
# WS8 — the tenant is the FIRST key segment, so a prefix scan; the key IS
|
|
86
|
+
# the isolation). -> count removed.
|
|
87
|
+
def purge(tenant:)
|
|
88
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
89
|
+
[SCOPE, CURSOR_SCOPE, BASELINE_SCOPE].sum do |scope|
|
|
90
|
+
keys = @store.list(scope).select { |k| k.start_with?(prefix) }
|
|
91
|
+
keys.each { |k| @store.delete(scope, k) }
|
|
92
|
+
keys.size
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Age-based prune of DAY CELLS ONLY (retention, WS8 — outcomes and their
|
|
97
|
+
# fold die together). The day is the key's last segment ("YYYY-MM-DD",
|
|
98
|
+
# lexicographic). Cursors/baselines are tiny and live while their agent
|
|
99
|
+
# does. -> count removed.
|
|
100
|
+
def delete_older_than(time)
|
|
101
|
+
cutoff = time.utc.strftime("%Y-%m-%d")
|
|
102
|
+
removed = 0
|
|
103
|
+
@store.list(SCOPE).each do |k|
|
|
104
|
+
day = k.rpartition(":").last
|
|
105
|
+
next unless day < cutoff
|
|
106
|
+
|
|
107
|
+
@store.delete(SCOPE, k)
|
|
108
|
+
removed += 1
|
|
109
|
+
end
|
|
110
|
+
removed
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# wipes ONE pair's day cells — the recompute repair path (the
|
|
114
|
+
# fold rebuilds them from the outcome store, so it must start from zero,
|
|
115
|
+
# never sum on top). Same tenant normalization as every other key builder
|
|
116
|
+
# (`""`/nil/"platform" all reach the "platform" segment). -> count removed.
|
|
117
|
+
def delete_days(tenant:, agent:)
|
|
118
|
+
prefix = "#{pair_id(tenant, agent)}:"
|
|
119
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
120
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
121
|
+
keys.size
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The pairs that have any day cell — the Studio's derived drill (D7).
|
|
125
|
+
# -> [{ tenant: String | nil, agent: String }]
|
|
126
|
+
def pairs
|
|
127
|
+
@store.list(SCOPE).map { |k| pair_of(k) }.uniq
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def pair_id(tenant, agent)
|
|
133
|
+
"#{tenant_id(tenant)}:#{agent}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The literal "platform" for a blank tenant — the outcome KEY's rule
|
|
137
|
+
# (outcome_store.rb's key), normalized in ONE place.
|
|
138
|
+
def tenant_id(tenant)
|
|
139
|
+
t = tenant.to_s
|
|
140
|
+
t.empty? ? "platform" : t
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def day_segment(at)
|
|
144
|
+
t = at.is_a?(Time) ? at : Time.parse(at.to_s)
|
|
145
|
+
t.utc.strftime("%Y-%m-%d")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Tolerant like the series reads: a malformed bound (not an ISO date) reads
|
|
149
|
+
# as unbounded — never a crash, never a silent empty window.
|
|
150
|
+
DATE_RE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
151
|
+
|
|
152
|
+
def within?(day, from, to)
|
|
153
|
+
lo = DATE_RE.match?(from.to_s) ? from.to_s : nil
|
|
154
|
+
hi = DATE_RE.match?(to.to_s) ? to.to_s : nil
|
|
155
|
+
(lo.nil? || day >= lo) && (hi.nil? || day <= hi)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def pair_of(key)
|
|
159
|
+
tenant, agent, = key.split(":", 3)
|
|
160
|
+
{ tenant: tenant == "platform" ? nil : tenant, agent: agent }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/insika/golden_store.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Insika
|
|
|
20
20
|
# validated by `Evals::GoldenLoader.build` — the one validator, so a case authored in
|
|
21
21
|
# the Studio and a case read from disk cannot diverge. `evals/golden/**` stays the
|
|
22
22
|
# export format and the seed for a fresh deploy (`insika evals:import`), for the same
|
|
23
|
-
# reason the knowledge
|
|
23
|
+
# reason the knowledge base keeps markdown as both: no converter to keep honest.
|
|
24
24
|
#
|
|
25
25
|
# No version history here, unlike the prompt/skill stores: the corpus on disk IS the
|
|
26
26
|
# backup, and re-importing restores it.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../coercion"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# — the pack's deterministic claim extractor + membership check.
|
|
7
|
+
# The pack's DATA (D7): a `sku` regex for the SKU shape. The engine only
|
|
8
|
+
# APPLIES it — it never guesses what a SKU looks like. No NLP, no LLM, no gem.
|
|
9
|
+
#
|
|
10
|
+
# REVIEW-DECISION (rfc-0029 v2): grounding is **SKU-only**. The techspec's
|
|
11
|
+
# name half (name_keys indexing ledger lines to flag names) cannot flag
|
|
12
|
+
# anything: a name found in the text that also came from the ledger is grounded
|
|
13
|
+
# by construction, and detecting a name that matches NO ledger line needs a
|
|
14
|
+
# "this is a product name" signal that does not exist without NLP. It was
|
|
15
|
+
# decorative config that lied — cut, not half-fixed. The ledger keeps ids;
|
|
16
|
+
# `line` stays in the lean envelope for the model's context, it just does not
|
|
17
|
+
# feed the matcher.
|
|
18
|
+
class GroundingMatcher
|
|
19
|
+
# Catastrophic-backtracking cap. A pathological pattern from the pack would
|
|
20
|
+
# otherwise spin the reactor's thread in C — a rescue cannot save a hang, and
|
|
21
|
+
# with_timeout cannot preempt a regex. The final answer is short; 1s is a
|
|
22
|
+
# hard ceiling, not a budget.
|
|
23
|
+
REGEX_TIMEOUT = 1.0
|
|
24
|
+
|
|
25
|
+
def initialize(sku: nil)
|
|
26
|
+
@sku = sku && Regexp.new(sku, timeout: REGEX_TIMEOUT) # validated at build; a re-raise here is a bug
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Is a SKU pattern actually configured? (The doctor's warning and the
|
|
30
|
+
# harvest's D3 refusal ask the same question — a matcher without a sku
|
|
31
|
+
# matches nothing and can verify no claim.)
|
|
32
|
+
def sku? = !@sku.nil?
|
|
33
|
+
|
|
34
|
+
# raw (the pack's matcher Hash) -> GroundingMatcher. The `sku` must compile
|
|
35
|
+
# and be length-capped. Raises ValidationError.
|
|
36
|
+
def self.build(raw)
|
|
37
|
+
h = Coercion.deep_stringify(raw || {})
|
|
38
|
+
sku = Coercion.presence(h["sku"])
|
|
39
|
+
if sku
|
|
40
|
+
begin
|
|
41
|
+
Regexp.new(sku)
|
|
42
|
+
rescue RegexpError
|
|
43
|
+
raise Insika::ValidationError, "grounding.matcher.sku does not compile: #{sku.inspect}"
|
|
44
|
+
end
|
|
45
|
+
if sku.bytesize > Grounding::SKU_MAX
|
|
46
|
+
raise Insika::ValidationError, "grounding.matcher.sku exceeds #{Grounding::SKU_MAX} chars"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
new(sku: sku)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -> [String] SKU-pattern matches in the text, deduped. Capture-group safe:
|
|
53
|
+
# scans the FULL match (Regexp.last_match(0)), so a pack pattern with groups
|
|
54
|
+
# or alternation can never inject nils or empty captures into the references
|
|
55
|
+
# (a nil claim would corrupt the flag detail and crash the sentence cut).
|
|
56
|
+
def references(text)
|
|
57
|
+
return [] unless @sku
|
|
58
|
+
|
|
59
|
+
text.to_enum(:scan, @sku).map { Regexp.last_match(0) }.uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# -> [String] references NOT in the evidence set. The SET is the ledger ids
|
|
63
|
+
# — a claim quoting a ledgered id is grounded; everything else flagged/cut.
|
|
64
|
+
def ungrounded(references, evidence_ids:)
|
|
65
|
+
known = Set.new(evidence_ids.map(&:to_s))
|
|
66
|
+
references.reject { |r| known.include?(r) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coercion"
|
|
4
|
+
require_relative "grounding/matcher"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# — the profile-facing half of grounding: parse the pack's
|
|
8
|
+
# `grounding` data into the per-turn Grounding object (mode + matcher).
|
|
9
|
+
#
|
|
10
|
+
# Data (on the AgentProfile):
|
|
11
|
+
#
|
|
12
|
+
# { "mode" => "flag"|"enforce"|"off",
|
|
13
|
+
# "matcher" => { "sku" => "\b[A-Z]{2,4}\d{4,8}\b" } }
|
|
14
|
+
#
|
|
15
|
+
# Absent = the whole feature is OFF (parity). The matcher is built ONCE per
|
|
16
|
+
# turn (profile parse) and cached on the Grounding object. A `sku` that does
|
|
17
|
+
# not compile, or exceeds 256 chars, is a ValidationError at build — the
|
|
18
|
+
# engine refuses only uncompileable data; a matcher with no sku matches
|
|
19
|
+
# nothing (grounding on is harmless but useless; doctor warns).
|
|
20
|
+
class Grounding
|
|
21
|
+
MODES = %w[flag enforce off].freeze
|
|
22
|
+
SKU_MAX = 256
|
|
23
|
+
|
|
24
|
+
attr_reader :mode, :matcher
|
|
25
|
+
|
|
26
|
+
# raw: the profile's `grounding` Hash | nil | false -> Grounding | nil.
|
|
27
|
+
# nil/false = off. A non-Hash (e.g. true) reads as an empty config.
|
|
28
|
+
def self.parse(raw)
|
|
29
|
+
return nil if raw.nil? || raw == false
|
|
30
|
+
|
|
31
|
+
h = Coercion.deep_stringify(raw.is_a?(Hash) ? raw : {})
|
|
32
|
+
mode = MODES.include?(h["mode"].to_s) ? h["mode"].to_s : "flag" # default :flag
|
|
33
|
+
new(mode: mode, matcher: GroundingMatcher.build(h["matcher"]))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(mode:, matcher:)
|
|
37
|
+
@mode = mode
|
|
38
|
+
@matcher = matcher
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def enforce? = mode == "enforce"
|
|
42
|
+
def off? = mode == "off"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Harvest
|
|
8
|
+
# C8 — the second gate: the store's target METRIC RATE over
|
|
9
|
+
# the criterion's window, compared to the frozen baseline's rate. Outcome
|
|
10
|
+
# is EVIDENCE — the only verdict this object can produce is "the store is
|
|
11
|
+
# measurably worse than the accepted state" and "there is nothing to
|
|
12
|
+
# compare against".
|
|
13
|
+
#
|
|
14
|
+
# The ruler is a RATE (metric ÷ first stage), never a total: the frozen
|
|
15
|
+
# baseline covers a ≥ 28-day span while the window is 72 h — comparing the
|
|
16
|
+
# raw counts would flag a store that tripled its traffic as "worse" (fewer
|
|
17
|
+
# absolute sales in 3 days than in 28). Both sides fold the same
|
|
18
|
+
# denominator, so the comparison measures the per-unit health, which is
|
|
19
|
+
# what the audit can actually read back.
|
|
20
|
+
#
|
|
21
|
+
# Refuse-with-a-named-reason, never pass, on missing data (the P18 lesson
|
|
22
|
+
# applied to the second ruler): no frozen baseline, no criterion, no
|
|
23
|
+
# funnel store, a fold that has not converged, a frozen span shorter than
|
|
24
|
+
# the criterion's `min_span`, a baseline whose rate cannot be computed.
|
|
25
|
+
class ConversionGate
|
|
26
|
+
Result = Data.define(:passed, :reason, :metric, :window, :current,
|
|
27
|
+
:baseline, :threshold, :snapshot_ref) do
|
|
28
|
+
# The candidate's conversion_gate record — the operator's review card
|
|
29
|
+
# shows the ruler (both rates), never just a verdict.
|
|
30
|
+
def to_h
|
|
31
|
+
{ "passed" => passed, "reason" => reason&.to_s, "metric" => metric,
|
|
32
|
+
"window" => window, "current" => current, "baseline" => baseline,
|
|
33
|
+
"threshold" => threshold, "snapshot_ref" => snapshot_ref }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# funnel_store: the FunnelStore (nil = the gate REFUSES — a
|
|
38
|
+
# store without a ruler cannot promote on outcome, D6).
|
|
39
|
+
# criterion: the boot-loaded Harvest::Criterion (nil = refuse).
|
|
40
|
+
def initialize(funnel_store:, criterion:)
|
|
41
|
+
@funnel_store = funnel_store
|
|
42
|
+
@criterion = criterion
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# -> Result. NEVER passes on missing data (D6).
|
|
46
|
+
def call(tenant:, agent:)
|
|
47
|
+
return refuse(:no_criterion) if @criterion.nil?
|
|
48
|
+
return refuse(:no_funnel) if @funnel_store.nil?
|
|
49
|
+
|
|
50
|
+
baseline = @funnel_store.baseline(tenant: tenant, agent: agent)
|
|
51
|
+
return refuse(:no_frozen_baseline) if baseline.nil?
|
|
52
|
+
|
|
53
|
+
# The criterion's metric is a stage NAME from the declaration
|
|
54
|
+
# (D5). The seed token "primary" resolves to the frozen snapshot's
|
|
55
|
+
# primary stage — the declaration's own vocabulary, never a gem
|
|
56
|
+
# constant .
|
|
57
|
+
metric = resolve_metric(@criterion.rule.metric, baseline)
|
|
58
|
+
return refuse(:metric_mismatch) if metric != baseline["primary"].to_s
|
|
59
|
+
|
|
60
|
+
# min_span is LIVE (D5): the frozen baseline must cover the span the
|
|
61
|
+
# criterion pre-registered — a number nobody re-froze to a shorter
|
|
62
|
+
# span is not a number the gate may compare against.
|
|
63
|
+
return refuse(:baseline_span_short) if span_short?(baseline, @criterion.rule.min_span)
|
|
64
|
+
|
|
65
|
+
baseline_rate = rate_of(baseline["stages"] || {}, metric)
|
|
66
|
+
# A baseline whose conversion was nil when frozen (no first-stage
|
|
67
|
+
# events) cannot be the accepted state's ruler.
|
|
68
|
+
return refuse(:no_baseline_rate) if baseline_rate.nil?
|
|
69
|
+
|
|
70
|
+
window_hours = @criterion.rule.window.to_s.delete_suffix("h").to_i
|
|
71
|
+
window_stages = fold_window(tenant: tenant, agent: agent, hours: window_hours)
|
|
72
|
+
return refuse(:no_fold) if window_stages.empty?
|
|
73
|
+
|
|
74
|
+
current_rate = rate_of(window_stages, metric)
|
|
75
|
+
# The window folded no comparable denominator (no first-stage events
|
|
76
|
+
# over the window) — there is nothing to compare.
|
|
77
|
+
return refuse(:no_fold) if current_rate.nil?
|
|
78
|
+
|
|
79
|
+
threshold = @criterion.rule.threshold.to_f
|
|
80
|
+
snapshot_ref = "funnel:#{tenant_id(tenant)}:#{agent}:#{baseline['frozen_at']}"
|
|
81
|
+
|
|
82
|
+
if current_rate >= baseline_rate * (1 - threshold)
|
|
83
|
+
Result.new(passed: true, reason: nil, metric: metric,
|
|
84
|
+
window: @criterion.rule.window.to_s, current: current_rate,
|
|
85
|
+
baseline: baseline_rate, threshold: threshold,
|
|
86
|
+
snapshot_ref: snapshot_ref)
|
|
87
|
+
else
|
|
88
|
+
Result.new(passed: false, reason: :conversion_down,
|
|
89
|
+
metric: metric, window: @criterion.rule.window.to_s,
|
|
90
|
+
current: current_rate, baseline: baseline_rate,
|
|
91
|
+
threshold: threshold, snapshot_ref: snapshot_ref)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def refuse(reason)
|
|
98
|
+
Result.new(passed: false, reason: reason,
|
|
99
|
+
metric: @criterion&.rule&.metric.to_s,
|
|
100
|
+
window: @criterion&.rule&.window.to_s,
|
|
101
|
+
current: nil, baseline: nil,
|
|
102
|
+
threshold: @criterion&.rule&.threshold,
|
|
103
|
+
snapshot_ref: nil)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# "primary" (the seed token) = the frozen snapshot's primary stage;
|
|
107
|
+
# anything else is a literal stage name.
|
|
108
|
+
def resolve_metric(token, baseline)
|
|
109
|
+
token.to_s == "primary" ? baseline["primary"].to_s : token.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# metric ÷ FIRST stage — the same denominator both sides fold, so the
|
|
113
|
+
# totals' different spans cancel out. nil when the denominator is zero
|
|
114
|
+
# (the ruler cannot be expressed).
|
|
115
|
+
def rate_of(stages, metric)
|
|
116
|
+
first = stages.keys.first
|
|
117
|
+
denom = stages[first].to_f
|
|
118
|
+
return nil if denom.zero?
|
|
119
|
+
|
|
120
|
+
stages[metric].to_f / denom
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# "28d" -> 28. The frozen span (from/to) must cover it — FreezeFunnelBaseline
|
|
124
|
+
# enforces the same number at freeze time; this is the gate's own check
|
|
125
|
+
# against a hand-written baseline or a moved criterion.
|
|
126
|
+
def span_short?(baseline, min_span)
|
|
127
|
+
return false if min_span.to_s.empty?
|
|
128
|
+
|
|
129
|
+
from = baseline["from"].to_s
|
|
130
|
+
to = baseline["to"].to_s
|
|
131
|
+
return true if from.empty? || to.empty?
|
|
132
|
+
|
|
133
|
+
days = (Date.iso8601(to) - Date.iso8601(from)).to_i
|
|
134
|
+
days < min_span.to_s.delete_suffix("d").to_i
|
|
135
|
+
rescue Date::Error
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The criterion's window folded over the day cells (the
|
|
140
|
+
# read) — the fold's day cells SUMMED by stage, so the gate can build
|
|
141
|
+
# the window's rate the same way the baseline built its own.
|
|
142
|
+
# -> { stage => count }
|
|
143
|
+
def fold_window(tenant:, agent:, hours:)
|
|
144
|
+
now = Time.now.utc
|
|
145
|
+
from = (now - hours * 3600).strftime("%Y-%m-%d")
|
|
146
|
+
to = now.strftime("%Y-%m-%d")
|
|
147
|
+
@funnel_store.days(tenant: tenant, agent: agent, from: from, to: to)
|
|
148
|
+
.each_with_object(Hash.new(0)) do |(_day, counts), acc|
|
|
149
|
+
counts.each { |stage, n| acc[stage] += n }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def tenant_id(tenant)
|
|
154
|
+
t = tenant.to_s
|
|
155
|
+
t.empty? ? "platform" : t
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Harvest
|
|
8
|
+
# C4 — the frozen conversion criterion, parsed from the deployment's
|
|
9
|
+
# criterion file. The parity shape (Parity::Criterion) copied: one
|
|
10
|
+
# file, one ```yaml fence, the WHOLE bytes hashed (an edit to the rationale
|
|
11
|
+
# invalidates the frozen rule too), strict keys, no defaults. A skill may
|
|
12
|
+
# land only when the store's ruler is not measurably worse than the
|
|
13
|
+
# accepted state — the FILE is the criterion; a profile carries no
|
|
14
|
+
# thresholds, and `GateHarvest`/`PromoteHarvest` receive the loaded
|
|
15
|
+
# criterion (with its sha) at boot.
|
|
16
|
+
class Criterion
|
|
17
|
+
Rule = Data.define(:version, :metric, :window, :threshold, :min_span)
|
|
18
|
+
|
|
19
|
+
attr_reader :rule, :path, :sha
|
|
20
|
+
|
|
21
|
+
def initialize(rule:, path:, sha:)
|
|
22
|
+
@rule = rule
|
|
23
|
+
@path = path
|
|
24
|
+
@sha = sha
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reads the file, extracts the FIRST ```yaml fence, validates STRICTLY.
|
|
28
|
+
# Raises Insika::ConfigError when the file is absent or has no block;
|
|
29
|
+
# Insika::ValidationError on an unknown or missing key, or a malformed
|
|
30
|
+
# value — every defect named.
|
|
31
|
+
def self.load(path)
|
|
32
|
+
bytes = read_bytes(path)
|
|
33
|
+
block = extract_yaml_block(bytes)
|
|
34
|
+
parsed = YAML.safe_load(block, permitted_classes: [], aliases: false)
|
|
35
|
+
rule = build_rule(parsed)
|
|
36
|
+
new(rule: rule, path: path, sha: "sha256:#{Digest::SHA256.hexdigest(bytes)}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h = @rule.to_h
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def read_bytes(path)
|
|
45
|
+
File.read(path, encoding: "UTF-8")
|
|
46
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
|
|
47
|
+
raise Insika::ConfigError, "harvest criterion not readable at #{path}: #{e.class}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def extract_yaml_block(bytes)
|
|
51
|
+
match = bytes.match(/```yaml\n(.*?)\n```/m)
|
|
52
|
+
raise Insika::ConfigError, "harvest criterion has no ```yaml block (nothing to pre-register)" unless match
|
|
53
|
+
|
|
54
|
+
match[1]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_rule(parsed)
|
|
58
|
+
raise Insika::ValidationError, "criterion yaml block must be a mapping" unless parsed.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
raw = parsed.transform_keys(&:to_s)
|
|
61
|
+
keys = raw.keys.sort
|
|
62
|
+
missing = Rule.members.map(&:to_s) - keys
|
|
63
|
+
unknown = keys - Rule.members.map(&:to_s)
|
|
64
|
+
raise Insika::ValidationError, "harvest criterion is missing key(s): #{missing.join(', ')}" if missing.any?
|
|
65
|
+
raise Insika::ValidationError, "harvest criterion has unknown key(s): #{unknown.join(', ')}" if unknown.any?
|
|
66
|
+
|
|
67
|
+
validate!(raw)
|
|
68
|
+
Rule.new(**Rule.members.to_h { |m| [m, raw[m.to_s]] })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Each defect named — a criterion nobody can read is a criterion
|
|
72
|
+
# nobody froze (D5).
|
|
73
|
+
def validate!(raw)
|
|
74
|
+
version = raw["version"]
|
|
75
|
+
raise Insika::ValidationError, "harvest criterion version must be an Integer (got #{version.inspect})" unless version.is_a?(Integer)
|
|
76
|
+
|
|
77
|
+
metric = raw["metric"].to_s
|
|
78
|
+
raise Insika::ValidationError, "harvest criterion metric must be a non-blank string" if metric.empty?
|
|
79
|
+
|
|
80
|
+
window = raw["window"].to_s
|
|
81
|
+
unless window.match?(/\A\d+h\z/)
|
|
82
|
+
raise Insika::ValidationError, "harvest criterion window must match /\\A\\d+h\\z/ (got #{raw['window'].inspect})"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
min_span = raw["min_span"].to_s
|
|
86
|
+
unless min_span.match?(/\A\d+d\z/)
|
|
87
|
+
raise Insika::ValidationError, "harvest criterion min_span must match /\\A\\d+d\\z/ (got #{raw['min_span'].inspect})"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
threshold = raw["threshold"]
|
|
91
|
+
unless threshold.is_a?(Float) && threshold >= 0 && threshold <= 1
|
|
92
|
+
raise Insika::ValidationError, "harvest criterion threshold must be a Float in 0..1 (got #{threshold.inspect})"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|