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
data/lib/insika/session_store.rb
CHANGED
|
@@ -23,7 +23,14 @@ module Insika
|
|
|
23
23
|
KEY_PREFIX = "session:"
|
|
24
24
|
|
|
25
25
|
Session = Data.define(:id, :messages, :vars, :memory_refs,
|
|
26
|
-
:created_at, :updated_at)
|
|
26
|
+
:created_at, :updated_at, :briefing, :evidence) do
|
|
27
|
+
# Trailing members with defaults: an old record without the "briefing" /
|
|
28
|
+
# "evidence" keys reads as empty/nil without a migration.
|
|
29
|
+
def initialize(id:, messages:, vars:, memory_refs:, created_at:, updated_at:,
|
|
30
|
+
briefing: nil, evidence: nil)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
27
34
|
|
|
28
35
|
# store: any Insika::Store (Memory, SQLite, ...) — injected by the
|
|
29
36
|
# composition root (config/wiring.rb). SessionStore does not know the
|
|
@@ -44,6 +51,8 @@ module Insika
|
|
|
44
51
|
"messages" => [],
|
|
45
52
|
"vars" => deep_stringify(vars),
|
|
46
53
|
"memory_refs" => [],
|
|
54
|
+
"briefing" => { "fields" => {}, "next_step" => nil },
|
|
55
|
+
"evidence" => { "ids" => [], "ungrounded" => 0 },
|
|
47
56
|
"created_at" => now,
|
|
48
57
|
"updated_at" => now
|
|
49
58
|
}
|
|
@@ -89,6 +98,58 @@ module Insika
|
|
|
89
98
|
to_session(record)
|
|
90
99
|
end
|
|
91
100
|
|
|
101
|
+
# appends this turn's evidence (ids + ungrounded delta) to the
|
|
102
|
+
# session record. RMW like append_messages — the SessionActor serializes
|
|
103
|
+
# same-session turns; the copy is in the method comment.
|
|
104
|
+
def append_evidence(id, ids:, ungrounded:)
|
|
105
|
+
record = fetch!(id)
|
|
106
|
+
ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 }
|
|
107
|
+
fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS)
|
|
108
|
+
ev["ids"] = fresh
|
|
109
|
+
ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i
|
|
110
|
+
record["updated_at"] = timestamp
|
|
111
|
+
@store.set(SCOPE, key_for(id), record)
|
|
112
|
+
to_session(record)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# -> Session. Upsert ONE briefing field. The pack owns the schema; the
|
|
116
|
+
# engine validates nothing about field NAMES here (the tools do, at the
|
|
117
|
+
# write edge). value is a String (anything else -> to_s); a BLANK value
|
|
118
|
+
# (after strip) REMOVES the key — absence means "not yet asked" (
|
|
119
|
+
# D4). NotFoundError if the session does not exist.
|
|
120
|
+
#
|
|
121
|
+
# CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like
|
|
122
|
+
# append_messages — but the SessionActor argument does NOT apply here. A
|
|
123
|
+
# briefing writer is a system tool, never enveloped, and with
|
|
124
|
+
# tool_concurrency > 1 the gem runs each call in its OWN fiber — outside the
|
|
125
|
+
# actor's per-session turn serialization. The RMW is still safe, for a
|
|
126
|
+
# different reason: nothing in the read/mutate/set path suspends. Store
|
|
127
|
+
# get/set are synchronous (the SQLite write semaphore is a non-yielding fast
|
|
128
|
+
# path when free), and a fiber only switches at a scheduler suspension point
|
|
129
|
+
# — so no other writer can interleave mid-RMW (measured: N concurrent
|
|
130
|
+
# writers lose nothing). It holds ONLY while that path never suspends; an
|
|
131
|
+
# async store (a real yield in get/set) would need a lock or CAS.
|
|
132
|
+
def update_briefing(id, field:, value:)
|
|
133
|
+
record = fetch!(id)
|
|
134
|
+
briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
|
|
135
|
+
value = Coercion.presence(Coercion.utf8(value.to_s))
|
|
136
|
+
value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s)
|
|
137
|
+
record["updated_at"] = timestamp
|
|
138
|
+
@store.set(SCOPE, key_for(id), record)
|
|
139
|
+
to_session(record)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# -> Session. Upsert the agreed next step; a blank text clears to nil
|
|
143
|
+
# NotFoundError if absent.
|
|
144
|
+
def set_next_step(id, text:)
|
|
145
|
+
record = fetch!(id)
|
|
146
|
+
briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
|
|
147
|
+
briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s))
|
|
148
|
+
record["updated_at"] = timestamp
|
|
149
|
+
@store.set(SCOPE, key_for(id), record)
|
|
150
|
+
to_session(record)
|
|
151
|
+
end
|
|
152
|
+
|
|
92
153
|
# -> bool (delegates to the backend: false for a nonexistent id)
|
|
93
154
|
def delete(id)
|
|
94
155
|
@store.delete(SCOPE, key_for(id))
|
|
@@ -126,7 +187,9 @@ module Insika
|
|
|
126
187
|
vars: record["vars"],
|
|
127
188
|
memory_refs: record["memory_refs"],
|
|
128
189
|
created_at: record["created_at"],
|
|
129
|
-
updated_at: record["updated_at"]
|
|
190
|
+
updated_at: record["updated_at"],
|
|
191
|
+
briefing: record["briefing"] || { "fields" => {}, "next_step" => nil },
|
|
192
|
+
evidence: record["evidence"]
|
|
130
193
|
)
|
|
131
194
|
end
|
|
132
195
|
|
|
@@ -31,6 +31,16 @@ module Insika
|
|
|
31
31
|
"turn_timeout" => 120,
|
|
32
32
|
"tool_timeout" => 30,
|
|
33
33
|
"compaction" => { "enabled" => false, "keep_last" => 20 },
|
|
34
|
+
# Data lifecycle (WS8, phase 2): the RETENTION window in days. The
|
|
35
|
+
# tick's Retention sweep purges sessions (+traces), terminal tasks
|
|
36
|
+
# (+checkpoints), memory cells and outcomes older than this. nil/0 =
|
|
37
|
+
# OFF (parity — nothing is ever swept by default).
|
|
38
|
+
"retention_days" => nil,
|
|
39
|
+
# memory TTL. Integer = default for every cell; Hash =
|
|
40
|
+
# per-tenant { "<tenant>" => days, "*" => days } (ops-authored). nil = OFF.
|
|
41
|
+
# Swept by the Retention tick on its OWN daily claim, NOT gated by
|
|
42
|
+
# retention_days (D5). Additive key — reads overlay DEFAULTS.
|
|
43
|
+
"memory_ttl_days" => nil,
|
|
34
44
|
# LLM config v2. Platform-wide model layer, resolved by the
|
|
35
45
|
# ModelResolver under an agent that pins no model of its own:
|
|
36
46
|
# default_model/default_provider -> the platform default (Chat > Agent > HERE)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# C2 — one durable record per mirrored exchange (shadow mode),
|
|
8
|
+
# written by TWO INDEPENDENT HALVES: ours at the turn's terminal, the
|
|
9
|
+
# incumbent's at the mirror. Both land on the same key — a digest of
|
|
10
|
+
# (channel, external_id, event_id), deterministic and order-free — so the two
|
|
11
|
+
# writers converge without an index and without ordering assumptions.
|
|
12
|
+
#
|
|
13
|
+
# It stores and it counts; it does not judge, does not fold a verdict, does
|
|
14
|
+
# not know what the criterion says.
|
|
15
|
+
#
|
|
16
|
+
# Status transitions (never backwards):
|
|
17
|
+
#
|
|
18
|
+
# ┌─ record_incumbent ─┐
|
|
19
|
+
# (nothing) ────┤ ├─▶ open ─▶ complete ─▶ judged
|
|
20
|
+
# └─ record_ours ─────┘ └─▶ silent (never judged)
|
|
21
|
+
# └─▶ incomplete (expire)
|
|
22
|
+
class ShadowPairStore
|
|
23
|
+
SCOPE = "shadow_pairs"
|
|
24
|
+
KEY_PREFIX = "pair:"
|
|
25
|
+
|
|
26
|
+
STATUSES = %i[open complete silent judged incomplete].freeze
|
|
27
|
+
|
|
28
|
+
Pair = Data.define(
|
|
29
|
+
:id, :channel, :agent, :session_id, :task_id, :event_id,
|
|
30
|
+
:inbound, :incumbent_reply, :insika_reply,
|
|
31
|
+
:status, :verdict, :criterion_sha, :created_at, :updated_at
|
|
32
|
+
) do
|
|
33
|
+
def complete? = %i[complete silent].include?(status)
|
|
34
|
+
def judged? = status == :judged
|
|
35
|
+
def outcome = verdict && verdict["outcome"]
|
|
36
|
+
def human_assisted? = verdict && verdict["vs"] == "human-assisted"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(store:)
|
|
40
|
+
@store = store
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The correlation key BOTH writers compute independently. SHA-256 hex of
|
|
44
|
+
# "<channel>\0<external_id>\0<event_id>", truncated to 32 — deterministic,
|
|
45
|
+
# order-free, and it keeps a phone number out of the store's key space.
|
|
46
|
+
def self.key_for(channel:, external_id:, event_id:)
|
|
47
|
+
Digest::SHA256.hexdigest("#{channel}\0#{external_id}\0#{event_id}")[0, 32]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Our half. Upsert: creates the record or fills our fields on the incumbent's.
|
|
51
|
+
# `reply` may be "" — a turn that published nothing (halt_when, an out-of-band
|
|
52
|
+
# tool) is recorded as :silent rather than left invisible. -> Pair
|
|
53
|
+
def record_ours(id:, channel:, agent:, session_id:, task_id:, event_id:,
|
|
54
|
+
inbound:, reply:, criterion_sha:)
|
|
55
|
+
upsert(id) do |record, created|
|
|
56
|
+
record["channel"] = channel.to_s
|
|
57
|
+
record["event_id"] = event_id.to_s
|
|
58
|
+
record["agent"] = agent
|
|
59
|
+
record["session_id"] = session_id&.to_s
|
|
60
|
+
record["task_id"] = task_id&.to_s
|
|
61
|
+
record["inbound"] = inbound.to_s
|
|
62
|
+
record["insika_reply"] = reply.to_s
|
|
63
|
+
record["criterion_sha"] = criterion_sha
|
|
64
|
+
created
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The incumbent's half (the mirror contract). Same upsert shape; the fields
|
|
69
|
+
# this half owns are the reply and, on first write, the timestamp the mirror
|
|
70
|
+
# reports. Never overwrites our half's fields. First-write-wins is enforced
|
|
71
|
+
# HERE, inside the transaction: the customer received ONE reply, and two
|
|
72
|
+
# concurrent mirror retries must not let the second rewrite the evidence.
|
|
73
|
+
# -> Pair
|
|
74
|
+
def record_incumbent(id:, channel:, event_id:, external_id:, reply:, at: nil)
|
|
75
|
+
upsert(id, at: at) do |record, created|
|
|
76
|
+
record["channel"] = channel.to_s
|
|
77
|
+
record["event_id"] = event_id.to_s
|
|
78
|
+
record["incumbent_reply"] = reply.to_s if record["incumbent_reply"].nil?
|
|
79
|
+
created
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -> Pair | nil
|
|
84
|
+
def find(id)
|
|
85
|
+
record = @store.get(SCOPE, key_for(id))
|
|
86
|
+
record && to_pair(record)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Lazy scan over a SNAPSHOT of the keys (deleting under a live enumeration
|
|
90
|
+
# would skip records — the same rule OutboxStore applies).
|
|
91
|
+
def each(&block)
|
|
92
|
+
return enum_for(:each) unless block_given?
|
|
93
|
+
|
|
94
|
+
@store.list(SCOPE, KEY_PREFIX).each do |key|
|
|
95
|
+
record = @store.get(SCOPE, key)
|
|
96
|
+
yield to_pair(record) if record
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -> [Pair] created at or after `time`.
|
|
101
|
+
def since(time)
|
|
102
|
+
cutoff = time.utc.iso8601
|
|
103
|
+
each.select { |p| p.created_at.to_s >= cutoff }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# -> [Pair] status :complete, oldest first — the judging queue. `silent`
|
|
107
|
+
# pairs are NEVER here: finding that pairwise is
|
|
108
|
+
# systematically unfair to a tool that delivers out of band is not
|
|
109
|
+
# something to average away.
|
|
110
|
+
def unjudged(limit: nil, agent: nil)
|
|
111
|
+
pairs = each.select { |p| p.status == :complete }
|
|
112
|
+
.sort_by { |p| p.created_at.to_s }
|
|
113
|
+
pairs = pairs.select { |p| p.agent.to_s == agent.to_s } if agent
|
|
114
|
+
limit ? pairs.first(limit.to_i) : pairs
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# -> { open:, complete:, silent:, judged:, incomplete: }
|
|
118
|
+
def counts(since: nil)
|
|
119
|
+
pairs = since ? self.since(since) : each.to_a
|
|
120
|
+
STATUSES.to_h { |s| [s, pairs.count { |p| p.status == s }] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The panel's Verdict as data. status -> :judged. -> Pair
|
|
124
|
+
def record_verdict(id, verdict:)
|
|
125
|
+
@store.transaction do
|
|
126
|
+
key = key_for(id)
|
|
127
|
+
record = @store.get(SCOPE, key)
|
|
128
|
+
raise Insika::NotFoundError, "shadow pair not found: #{id}" unless record
|
|
129
|
+
|
|
130
|
+
record["verdict"] = Coercion.deep_stringify(verdict)
|
|
131
|
+
record["status"] = "judged"
|
|
132
|
+
record["updated_at"] = timestamp
|
|
133
|
+
@store.set(SCOPE, key, record)
|
|
134
|
+
to_pair(record)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# An `open` pair older than the cutoff will never complete. -> count moved.
|
|
139
|
+
# `complete`/`silent`/`judged` are never touched. Update-style only: a pair
|
|
140
|
+
# deleted between the scan and the write (retention, LGPD purge) is left
|
|
141
|
+
# deleted — an upsert here would resurrect it as a ghost :incomplete record
|
|
142
|
+
# carrying none of its fields.
|
|
143
|
+
def expire(older_than:)
|
|
144
|
+
cutoff = older_than.utc.iso8601
|
|
145
|
+
moved = 0
|
|
146
|
+
each.select { |p| p.status == :open && p.created_at.to_s < cutoff }.each do |pair|
|
|
147
|
+
@store.transaction do
|
|
148
|
+
key = key_for(pair.id)
|
|
149
|
+
record = @store.get(SCOPE, key)
|
|
150
|
+
next unless record && record["status"] == "open"
|
|
151
|
+
|
|
152
|
+
record["status"] = "incomplete"
|
|
153
|
+
record["updated_at"] = timestamp
|
|
154
|
+
@store.set(SCOPE, key, record)
|
|
155
|
+
moved += 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
moved
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# -> Integer. Keys only, no record materialization — a count of pairs must
|
|
162
|
+
# not pay for customer text (doctor's shadow-off check, the Studio).
|
|
163
|
+
def size = @store.list(SCOPE, KEY_PREFIX).length
|
|
164
|
+
|
|
165
|
+
# LGPD / retention (C9): drops every pair of these sessions, whatever its
|
|
166
|
+
# status — the pair holds the customer's own words. -> count removed.
|
|
167
|
+
def purge_sessions(session_ids)
|
|
168
|
+
wanted = Array(session_ids).map(&:to_s)
|
|
169
|
+
return 0 if wanted.empty?
|
|
170
|
+
|
|
171
|
+
doomed = each.select { |p| wanted.include?(p.session_id.to_s) }
|
|
172
|
+
doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
|
|
173
|
+
doomed.size
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Retention: pairs created before the cutoff, TERMINAL statuses only
|
|
177
|
+
# (`judged`/`incomplete`) — an `open`/`complete` record older than the
|
|
178
|
+
# window is still someone's unjudged evidence. -> count removed.
|
|
179
|
+
def delete_older_than(time)
|
|
180
|
+
cutoff = time.utc.iso8601
|
|
181
|
+
doomed = each.select do |p|
|
|
182
|
+
%i[judged incomplete].include?(p.status) && p.created_at.to_s < cutoff
|
|
183
|
+
end
|
|
184
|
+
doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
|
|
185
|
+
doomed.size
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# The shared upsert: read -> merge only the fields THIS half owns (never
|
|
191
|
+
# overwrite the other's with nil) -> recompute status -> write. Runs inside
|
|
192
|
+
# Store#transaction so two halves landing in the same instant serialize on
|
|
193
|
+
# the backend's lock — the same claim mechanic OutboxStore#claim uses.
|
|
194
|
+
def upsert(id, at: nil, &fill)
|
|
195
|
+
@store.transaction do
|
|
196
|
+
key = key_for(id)
|
|
197
|
+
record = @store.get(SCOPE, key)
|
|
198
|
+
created = record.nil?
|
|
199
|
+
unless record
|
|
200
|
+
record = {
|
|
201
|
+
"id" => id.to_s, "channel" => nil, "agent" => nil, "session_id" => nil,
|
|
202
|
+
"task_id" => nil, "event_id" => nil, "inbound" => nil,
|
|
203
|
+
"incumbent_reply" => nil, "insika_reply" => nil, "status" => "open",
|
|
204
|
+
"verdict" => nil, "criterion_sha" => nil,
|
|
205
|
+
"created_at" => arrival_time(at), "updated_at" => timestamp
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
fill.call(record, created)
|
|
209
|
+
record["status"] = status_for(record)
|
|
210
|
+
record["updated_at"] = timestamp
|
|
211
|
+
@store.set(SCOPE, key, record)
|
|
212
|
+
to_pair(record)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Status recomputation, in one place (never backwards — a judged pair stays
|
|
217
|
+
# judged, an expired one stays incomplete):
|
|
218
|
+
def status_for(record)
|
|
219
|
+
return record["status"] if %w[judged incomplete].include?(record["status"])
|
|
220
|
+
return "open" if record["insika_reply"].nil? || record["incumbent_reply"].nil?
|
|
221
|
+
return "silent" if record["insika_reply"].to_s.strip.empty?
|
|
222
|
+
|
|
223
|
+
"complete"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def key_for(id) = "#{KEY_PREFIX}#{id}"
|
|
227
|
+
|
|
228
|
+
# The mirror's reported time on first write; nil = now. A String rides
|
|
229
|
+
# through as-is (it is the wire format); a Time is normalized to ISO8601.
|
|
230
|
+
# A String is ALSO normalized to UTC ISO8601: the mirrors report local
|
|
231
|
+
# offsets (+09:00, -03:00) and every comparison against created_at
|
|
232
|
+
# (since/expire/retention/unjudged ordering) is lexicographic — two offsets
|
|
233
|
+
# would make those comparisons lie. Unparseable input keeps the old
|
|
234
|
+
# ride-through behaviour rather than refusing the pair.
|
|
235
|
+
def arrival_time(at)
|
|
236
|
+
return timestamp if at.nil?
|
|
237
|
+
return at.utc.iso8601 unless at.is_a?(String)
|
|
238
|
+
|
|
239
|
+
Time.iso8601(at).utc.iso8601
|
|
240
|
+
rescue ArgumentError
|
|
241
|
+
at
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def to_pair(record)
|
|
245
|
+
Pair.new(
|
|
246
|
+
id: record["id"], channel: record["channel"], agent: record["agent"],
|
|
247
|
+
session_id: record["session_id"], task_id: record["task_id"],
|
|
248
|
+
event_id: record["event_id"], inbound: record["inbound"],
|
|
249
|
+
incumbent_reply: record["incumbent_reply"], insika_reply: record["insika_reply"],
|
|
250
|
+
status: record["status"].to_sym, verdict: record["verdict"],
|
|
251
|
+
criterion_sha: record["criterion_sha"],
|
|
252
|
+
created_at: record["created_at"], updated_at: record["updated_at"]
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def timestamp = Time.now.utc.iso8601
|
|
257
|
+
end
|
|
258
|
+
end
|
data/lib/insika/skill_catalog.rb
CHANGED
|
@@ -34,6 +34,10 @@ module Insika
|
|
|
34
34
|
@skills, @agent_skills = load_all
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# The SkillStore the catalog overlays — the composition root hands it to
|
|
38
|
+
# the harvest (the dedup reads the AUTHORED skills the catalog serves).
|
|
39
|
+
attr_reader :store
|
|
40
|
+
|
|
37
41
|
# `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
|
|
38
42
|
# same precedence chain the catalog already runs for store-over-disk and
|
|
39
43
|
# workspace-over-managed-over-bundled, with one more dimension.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Soak
|
|
8
|
+
# The frozen pre-declared envelope for a soak run. Parses the
|
|
9
|
+
# fenced `yaml` block inside a markdown file, validates it completely or not
|
|
10
|
+
# at all, and exposes the SHA-256 of the WHOLE file — every hourly snapshot
|
|
11
|
+
# is stamped with it, so editing the envelope mid-run turns the run
|
|
12
|
+
# `invalid` instead of producing a verdict nobody can defend.
|
|
13
|
+
#
|
|
14
|
+
# No defaults, no tolerance: an envelope with a hole is not a
|
|
15
|
+
# pre-declaration. Contrast with EnvSchema (tolerant, warn-and-boot) — that
|
|
16
|
+
# resilience protects a live service; this strictness protects a claim.
|
|
17
|
+
class Envelope
|
|
18
|
+
# Required keys. A missing one is a ConfigError, not a default.
|
|
19
|
+
REQUIRED = %i[
|
|
20
|
+
version target duration_hours warmup_hours
|
|
21
|
+
arrival turns_per_hour session_turns concurrency_cap web_concurrency
|
|
22
|
+
rss_growth_ratio prep_p95_drift_ratio
|
|
23
|
+
restarts_max error_rate_ceiling no_usage_rate_ceiling
|
|
24
|
+
coverage_min_ratio gap_seconds_max hourly_turn_floor
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Written after E1, before E2 (techspec D3). Absent -> `calibrated?` is
|
|
28
|
+
# false and the 72h run refuses to start; the 4h dry run does not require them.
|
|
29
|
+
CALIBRATED = %i[rss_ceiling_mb prep_p95_ceiling_ms total_p95_ceiling_ms].freeze
|
|
30
|
+
|
|
31
|
+
# Keys validated as ratios: must be strictly greater than 1 (the gate is
|
|
32
|
+
# "no more than 1.15x", so a ratio of 1 or below is a nonsense gate).
|
|
33
|
+
RATIO_KEYS = %i[rss_growth_ratio prep_p95_drift_ratio].freeze
|
|
34
|
+
|
|
35
|
+
# Keys validated as positive integers.
|
|
36
|
+
POSITIVE_COUNT_KEYS = %i[
|
|
37
|
+
duration_hours warmup_hours turns_per_hour session_turns
|
|
38
|
+
concurrency_cap web_concurrency gap_seconds_max hourly_turn_floor
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# Keys validated as rates in [0, 1].
|
|
42
|
+
RATE_KEYS = %i[error_rate_ceiling no_usage_rate_ceiling coverage_min_ratio].freeze
|
|
43
|
+
|
|
44
|
+
# Raises Insika::ConfigError: file missing, no fenced yaml block,
|
|
45
|
+
# unparseable, a REQUIRED key absent, or a value out of range.
|
|
46
|
+
def self.load(path)
|
|
47
|
+
bytes = File.binread(path)
|
|
48
|
+
parse(bytes, sha: "sha256:#{Digest::SHA256.hexdigest(bytes)}")
|
|
49
|
+
rescue Errno::ENOENT
|
|
50
|
+
raise ConfigError, "soak envelope not found: #{path}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Pure parse (specs, fixtures). `sha` is stamped verbatim.
|
|
54
|
+
def self.parse(source, sha:)
|
|
55
|
+
new(values: validate(YAML.safe_load(fenced_yaml(source)) || {}), sha: sha)
|
|
56
|
+
rescue Psych::SyntaxError => e
|
|
57
|
+
raise ConfigError, "soak envelope yaml is unparseable: #{e.message.lines.first.to_s.strip}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.fenced_yaml(source)
|
|
61
|
+
match = source.to_s.match(/```yaml\s*\n(.*?)\n```/m)
|
|
62
|
+
raise ConfigError, "soak envelope has no fenced yaml block" unless match
|
|
63
|
+
|
|
64
|
+
# Markdown fence content may be indented; YAML cares about the column.
|
|
65
|
+
lines = match[1].lines
|
|
66
|
+
indent = lines.reject { |l| l.strip.empty? }.map { |l| l[/\A */].length }.min
|
|
67
|
+
lines.map { |l| l[indent..] || "\n" }.join
|
|
68
|
+
end
|
|
69
|
+
private_class_method :fenced_yaml
|
|
70
|
+
|
|
71
|
+
def self.validate(raw)
|
|
72
|
+
values = raw.each_with_object({}) do |(k, v), acc|
|
|
73
|
+
acc[k.to_sym] = v if k.to_s =~ /\A[a-z_][a-z0-9_]*\z/
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
REQUIRED.each do |key|
|
|
77
|
+
raise ConfigError, "soak envelope is missing required key: #{key}" if values[key].nil?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
RATIO_KEYS.each do |key|
|
|
81
|
+
v = values[key]
|
|
82
|
+
unless v.is_a?(Numeric) && v > 1
|
|
83
|
+
raise ConfigError, "soak envelope key #{key} must be > 1 (got #{v.inspect})"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
POSITIVE_COUNT_KEYS.each do |key|
|
|
88
|
+
v = values[key]
|
|
89
|
+
unless v.is_a?(Integer) && v.positive?
|
|
90
|
+
raise ConfigError, "soak envelope key #{key} must be a positive integer (got #{v.inspect})"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
unless values[:restarts_max].is_a?(Integer) && values[:restarts_max] >= 0
|
|
95
|
+
raise ConfigError, "soak envelope key restarts_max must be >= 0 (got #{values[:restarts_max].inspect})"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
RATE_KEYS.each do |key|
|
|
99
|
+
v = values[key]
|
|
100
|
+
unless v.is_a?(Numeric) && v >= 0 && v <= 1
|
|
101
|
+
raise ConfigError, "soak envelope key #{key} must be a rate in 0..1 (got #{v.inspect})"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
unless values[:warmup_hours] < values[:duration_hours]
|
|
106
|
+
raise ConfigError, "soak envelope key warmup_hours must be < duration_hours " \
|
|
107
|
+
"(got #{values[:warmup_hours]} >= #{values[:duration_hours]})"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
CALIBRATED.each do |key|
|
|
111
|
+
v = values[key]
|
|
112
|
+
next if v.nil?
|
|
113
|
+
|
|
114
|
+
unless v.is_a?(Numeric) && v.positive?
|
|
115
|
+
raise ConfigError, "soak envelope key #{key} must be a positive number when set (got #{v.inspect})"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
values.freeze
|
|
120
|
+
end
|
|
121
|
+
private_class_method :validate
|
|
122
|
+
|
|
123
|
+
attr_reader :sha, :values
|
|
124
|
+
|
|
125
|
+
def initialize(values:, sha:)
|
|
126
|
+
@values = values
|
|
127
|
+
@sha = sha
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def [](key) = values[key]
|
|
131
|
+
|
|
132
|
+
def calibrated? = CALIBRATED.all? { |key| !values[key].nil? }
|
|
133
|
+
|
|
134
|
+
# E1 shape (<= 8h): the dry run skips the calibrated ceilings.
|
|
135
|
+
def dry_run? = values[:duration_hours] <= 8
|
|
136
|
+
|
|
137
|
+
def to_h = values.merge(sha: sha)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|