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/agent_profile.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "coercion"
|
|
4
|
+
require_relative "tool_definition"
|
|
4
5
|
|
|
5
6
|
module Insika
|
|
6
7
|
# Single point of per-agent policy.
|
|
@@ -63,6 +64,14 @@ module Insika
|
|
|
63
64
|
# a context provider injecting volatile content into
|
|
64
65
|
# :system turns every turn into a paid cache WRITE with
|
|
65
66
|
# no read hit. Enable only for stable-system agents.
|
|
67
|
+
:tool_persistence, # the engine's "Tool discipline" block in the system
|
|
68
|
+
# prompt (retry weak/empty tool results with a different
|
|
69
|
+
# approach before giving up). THE ONE OPT-OUT FIELD:
|
|
70
|
+
# nil/true = ON (the proven default — every reference
|
|
71
|
+
# harness ships it), false = OFF. Deliberately inverted
|
|
72
|
+
# from the opt-in fields above: the exception here is
|
|
73
|
+
# turning the good behavior OFF, so that is what an
|
|
74
|
+
# operator declares. Read by Context::Providers::Prompt.
|
|
66
75
|
:tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
|
|
67
76
|
# history (A3/C3): nil/false = OFF (parity); true = ON.
|
|
68
77
|
# Same opt-in as `memory`. When ON, the history the
|
|
@@ -103,6 +112,14 @@ module Insika
|
|
|
103
112
|
# breaker_open / delivery_failed events are POSTed to
|
|
104
113
|
# the URL as JSON (outbox + claim, at-most-once).
|
|
105
114
|
# nil/absent = no webhook (parity).
|
|
115
|
+
:routes, # intent routing (WS4): { "route" => "description" or a
|
|
116
|
+
# Hash with description/delegate/stuck/message,
|
|
117
|
+
# "default" => route, "model" => cheap classifier }.
|
|
118
|
+
# When present, the message is classified BEFORE the
|
|
119
|
+
# ask with the cheap model; the route lands on the
|
|
120
|
+
# turn (state.route + :route_classified) and may
|
|
121
|
+
# delegate to an existing agent or end the turn :stuck
|
|
122
|
+
# (WS5). nil/absent = no routing (parity).
|
|
106
123
|
:stuck_signal, # the agent may signal it cannot proceed (WS5):
|
|
107
124
|
# nil/false = OFF (parity — the signal_stuck system
|
|
108
125
|
# tool is not wired); true = ON (the model may call
|
|
@@ -111,6 +128,19 @@ module Insika
|
|
|
111
128
|
# event the consumer acts on). Same opt-in as
|
|
112
129
|
# `memory`. What "stuck" MEANS is the consumer's call
|
|
113
130
|
# (escalation via CRM/operator), never the engine's.
|
|
131
|
+
:outputs, # generated-media output policy (WS9, saída):
|
|
132
|
+
# { "image" => { "model" => …, "size" => "1024x1024" },
|
|
133
|
+
# "tts" => { "model" => "tts-1", "voice" => "alloy",
|
|
134
|
+
# "format" => "mp3" } }. THE AGENT'S HALF of the
|
|
135
|
+
# media-output gate — nil/absent = the agent never
|
|
136
|
+
# generates media (opt-in like `capabilities`, do NOT
|
|
137
|
+
# "fix" to nil = all). The other half is the CHANNEL'S:
|
|
138
|
+
# the request must declare it can receive the media
|
|
139
|
+
# (`channel.capabilities` — "image_output" /
|
|
140
|
+
# "audio_output"); only with BOTH does the model see
|
|
141
|
+
# the generate_image/tts tools (the abstraction admits
|
|
142
|
+
# only what leaks). Generated media rides the turn's
|
|
143
|
+
# `output_parts` in the envelope, never the answer text.
|
|
114
144
|
:model_policy, # governance of WHICH models the agent may use:
|
|
115
145
|
# { "allow" => [refs] }. nil = NO fence (all models —
|
|
116
146
|
# parity). Enforced on the RESOLVED model (ModelResolver).
|
|
@@ -162,10 +192,72 @@ module Insika
|
|
|
162
192
|
# (WhatsApp) puts the deliberation in front of a
|
|
163
193
|
# customer; that is the operator's call to make, not a
|
|
164
194
|
# default to inherit.
|
|
165
|
-
:
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
195
|
+
:grounding, # the pack's grounding policy —
|
|
196
|
+
# { "mode" => "flag"|"enforce"|"off",
|
|
197
|
+
# "matcher" => { "sku" => …,
|
|
198
|
+
# "name_keys" => [...] } }. OPT-IN:
|
|
199
|
+
# nil/absent = OFF (parity, zero allocations).
|
|
200
|
+
# Deep-stringified like the other hashes.
|
|
201
|
+
:metadata, # free-form agent metadata, stable per agent
|
|
202
|
+
# (from the pack `agent.config.json`). Home of the `store_id`
|
|
203
|
+
# that becomes turn context (ctx.store_id).
|
|
204
|
+
# It is NOT a policy — never decides security. {} = absent.
|
|
205
|
+
:briefing_fields, # the per-session working-state schema this agent
|
|
206
|
+
# keeps and asks for: a flat [String] of
|
|
207
|
+
# field names the pack declares. []/nil/absent = the
|
|
208
|
+
# feature is OFF (no provider output, no tools — visibly
|
|
209
|
+
# removable). Names are engine-owned store keys
|
|
210
|
+
# and tool text, so they are validated against NAME_RE at
|
|
211
|
+
# build time. Data, never a policy: the engine owns the
|
|
212
|
+
# briefing object, the pack owns the fields.
|
|
213
|
+
:funnel, # the outcome funnel declaration — pack
|
|
214
|
+
# data, exactly like budget/reliability:
|
|
215
|
+
# { "stages" => ["greeted", "qualified", "cart", "paid"],
|
|
216
|
+
# "advance_on" => { "pix_paid" => "paid", … },
|
|
217
|
+
# "primary" => "paid", "attribution_window" => "72h" }.
|
|
218
|
+
# The ENGINE never hard-codes a stage name: the fold,
|
|
219
|
+
# the doctor and the Studio read this declaration (D1).
|
|
220
|
+
# nil/absent = no funnel (parity — nothing folds).
|
|
221
|
+
# Deep-stringified like the other free-form hashes;
|
|
222
|
+
# shape-validated by FunnelDeclaration, never here (D8).
|
|
223
|
+
:followup, # the follow-up declaration — pack data,
|
|
224
|
+
# exactly like budget/funnel:
|
|
225
|
+
# { "arm" => "schedule",
|
|
226
|
+
# "policy" => { "quiet_hours" => { "timezone" => "…",
|
|
227
|
+
# "start" => "21:30", "end" => "09:00" },
|
|
228
|
+
# "max_frequency" => "2/24h",
|
|
229
|
+
# "cancel_keywords" => ["não quero mais contato"],
|
|
230
|
+
# "silence_after_sends" => 3 } }.
|
|
231
|
+
# The engine OWNS the firing, never a policy value (D1);
|
|
232
|
+
# shape-validated by FollowupPolicy, never here (D9).
|
|
233
|
+
# nil/absent = the feature is off (parity).
|
|
234
|
+
# Deep-stringified like the other free-form hashes.
|
|
235
|
+
:distill, # the session-distillation declaration — pack
|
|
236
|
+
# data, exactly like refinement/followup:
|
|
237
|
+
# { "enabled" => bool, "prompt" => "<pack-authored markdown
|
|
238
|
+
# — what counts as a fact for this store>",
|
|
239
|
+
# "model" => "<ref — absent = the platform utility_model>",
|
|
240
|
+
# "idle_hours" => 6, "min_messages" => 3,
|
|
241
|
+
# "max_proposals" => 10 }.
|
|
242
|
+
# The ENGINE assembles the scope from the session; the
|
|
243
|
+
# model only names facts (D1). nil/absent = the feature is
|
|
244
|
+
# off (parity, byte-identical engine). Shape-validated by
|
|
245
|
+
# the command/engine, never here (the refinement precedent).
|
|
246
|
+
# Deep-stringified like the other free-form hashes.
|
|
247
|
+
:harvest # the gated-harvest declaration — pack data,
|
|
248
|
+
# exactly like refinement/distill:
|
|
249
|
+
# { "enabled" => bool,
|
|
250
|
+
# "negative_list" => [ { "rule" => "…", "pattern" => "…",
|
|
251
|
+
# "note" => "…" } ],
|
|
252
|
+
# "miner" => { "model" => "<ref — absent = the platform
|
|
253
|
+
# utility_model>", "window" => { "last_sessions" => N },
|
|
254
|
+
# "max_proposals" => N, "budget" => { "tokens" => N } },
|
|
255
|
+
# "idle_hours" => 24, "min_messages" => 3 }.
|
|
256
|
+
# The ENGINE mines (reads sessions, asks the miner, filters
|
|
257
|
+
# through the negative list + grounding), never authors a
|
|
258
|
+
# rule (D4). nil/absent = the loop is off (parity).
|
|
259
|
+
# Shape-validated by the command/engine/doctor, never here.
|
|
260
|
+
# Deep-stringified like the other free-form hashes.
|
|
169
261
|
)
|
|
170
262
|
|
|
171
263
|
# Reopened class (not a Data.define block): a constant assigned inside
|
|
@@ -193,10 +285,12 @@ module Insika
|
|
|
193
285
|
skills_eager: nil, context_providers: nil, workflows_allow: nil,
|
|
194
286
|
policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
|
|
195
287
|
capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
|
|
196
|
-
prompt_caching: nil, tool_output_compression: nil,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
288
|
+
prompt_caching: nil, tool_persistence: nil, tool_output_compression: nil,
|
|
289
|
+
params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
|
|
290
|
+
refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
|
|
291
|
+
budget: nil, reliability: nil, alerts: nil, routes: nil, stuck_signal: nil,
|
|
292
|
+
outputs: nil, briefing_fields: nil, grounding: nil, funnel: nil,
|
|
293
|
+
followup: nil, distill: nil, harvest: nil)
|
|
200
294
|
new(
|
|
201
295
|
id: id, model: model, provider: provider, base_prompt: base_prompt,
|
|
202
296
|
prompt_files: Array(prompt_files), tools_allow: tools_allow,
|
|
@@ -210,7 +304,8 @@ module Insika
|
|
|
210
304
|
# readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
|
|
211
305
|
subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
|
|
212
306
|
tools_deferred: tools_deferred, memory: memory,
|
|
213
|
-
prompt_caching: prompt_caching,
|
|
307
|
+
prompt_caching: prompt_caching, tool_persistence: tool_persistence,
|
|
308
|
+
tool_output_compression: tool_output_compression,
|
|
214
309
|
# The free-form hashes arrive with symbol keys (internal build) OR string
|
|
215
310
|
# keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
|
|
216
311
|
# here — the single front door every profile passes through — so no reader
|
|
@@ -228,10 +323,50 @@ module Insika
|
|
|
228
323
|
budget: Coercion.deep_stringify(budget),
|
|
229
324
|
reliability: Coercion.deep_stringify(reliability),
|
|
230
325
|
alerts: Coercion.deep_stringify(alerts),
|
|
231
|
-
|
|
326
|
+
routes: Coercion.deep_stringify(routes),
|
|
327
|
+
stuck_signal: stuck_signal,
|
|
328
|
+
outputs: Coercion.deep_stringify(outputs),
|
|
329
|
+
# Flat [String] — same discipline as capabilities_declared: a
|
|
330
|
+
# symbol/string mix would be a silent miss in the provider's known-set.
|
|
331
|
+
briefing_fields: normalize_briefing_fields(briefing_fields),
|
|
332
|
+
# grounding is profile DATA, deep-stringified like the other
|
|
333
|
+
# free-form hashes; parsed into a Grounding per turn by the validator/
|
|
334
|
+
# enforcer. nil = off (parity).
|
|
335
|
+
grounding: Coercion.deep_stringify(grounding),
|
|
336
|
+
# funnel is profile DATA, deep-stringified like the other
|
|
337
|
+
# free-form hashes; parsed into a FunnelDeclaration by the fold/doctor/
|
|
338
|
+
# Studio (shape-validated THERE, never here — D8). nil = no funnel (parity).
|
|
339
|
+
funnel: Coercion.deep_stringify(funnel),
|
|
340
|
+
# followup is profile DATA, deep-stringified like the other
|
|
341
|
+
# free-form hashes; parsed into a FollowupPolicy by the tool/engine/
|
|
342
|
+
# doctor/Studio (shape-validated THERE, never here — D9). nil = off (parity).
|
|
343
|
+
followup: Coercion.deep_stringify(followup),
|
|
344
|
+
# distill is profile DATA, deep-stringified like the other
|
|
345
|
+
# free-form hashes; shape-validated by the command/engine/doctor
|
|
346
|
+
# (never here — the refinement precedent). nil = off (parity).
|
|
347
|
+
distill: Coercion.deep_stringify(distill),
|
|
348
|
+
# harvest is profile DATA, deep-stringified like the other
|
|
349
|
+
# free-form hashes; shape-validated by the command/engine/doctor
|
|
350
|
+
# (never here — the refinement precedent). nil = off (parity).
|
|
351
|
+
harvest: Coercion.deep_stringify(harvest)
|
|
232
352
|
)
|
|
233
353
|
end
|
|
234
354
|
|
|
355
|
+
# nil -> []; strings; trim + drop empties + uniq (stable order); every name
|
|
356
|
+
# must match ToolDefinition::NAME_RE (\A[a-z][a-z0-9_]*\z) or it is a
|
|
357
|
+
# ValidationError at build time — the names become tool-description text,
|
|
358
|
+
# store keys and context-block lines, so "size ok" or "tamanho do cliente"
|
|
359
|
+
# is refused here, not corrupted later.
|
|
360
|
+
def self.normalize_briefing_fields(list)
|
|
361
|
+
names = Array(list).map { |f| f.to_s.strip }.reject(&:empty?).uniq
|
|
362
|
+
bad = names.reject { |n| ToolDefinition::NAME_RE.match?(n) }
|
|
363
|
+
unless bad.empty?
|
|
364
|
+
raise Insika::ValidationError,
|
|
365
|
+
"briefing_fields must match #{ToolDefinition::NAME_RE.inspect}: #{bad.join(', ')}"
|
|
366
|
+
end
|
|
367
|
+
names
|
|
368
|
+
end
|
|
369
|
+
|
|
235
370
|
# opt-in for an optional tool = being in the agent's allow list.
|
|
236
371
|
def tool_opted_in?(name)
|
|
237
372
|
Array(tools_allow).include?(name)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# — pure: confirmed answer text -> the balloons a progressive
|
|
5
|
+
# channel should POST. Paragraphs are the unit (`\n\n` is the seam E1 cares
|
|
6
|
+
# about); the sentence split fires only as a SOFT cap for one paragraph that
|
|
7
|
+
# grew past `SENTENCE_AFTER`. It never splits inside a fenced code block.
|
|
8
|
+
#
|
|
9
|
+
# No events, no outbox, no `:intermediate` — if it is handed loop narration
|
|
10
|
+
# that is a caller bug; the splitter has no way to tell.
|
|
11
|
+
module BalloonSplitter
|
|
12
|
+
# Soft cap after which a single paragraph is split on sentences. WhatsApp's
|
|
13
|
+
# hard cap is ~4096; this is a readability cap, not a platform one.
|
|
14
|
+
SENTENCE_AFTER = 600
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# -> [String], at least one when `text` is non-blank, else [].
|
|
19
|
+
def split(text)
|
|
20
|
+
raw = Insika::Coercion.presence(text)
|
|
21
|
+
return [] if raw.nil?
|
|
22
|
+
|
|
23
|
+
paragraphs = raw.split(PARAGRAPH_BREAK)
|
|
24
|
+
balloons = group_fenced(paragraphs)
|
|
25
|
+
balloons.flat_map { |balloon| split_long(balloon) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A fenced code block (` ``` ` … ` ``` `) spans paragraphs and stays ONE
|
|
29
|
+
# balloon — newlines inside it are code, not a seam. A fence that never
|
|
30
|
+
# closes still ends as one balloon (garbage in, one balloon out).
|
|
31
|
+
def group_fenced(paragraphs)
|
|
32
|
+
balloons = []
|
|
33
|
+
fence = nil
|
|
34
|
+
paragraphs.each do |para|
|
|
35
|
+
piece = para.strip
|
|
36
|
+
next if piece.empty?
|
|
37
|
+
|
|
38
|
+
if fence
|
|
39
|
+
fence << piece
|
|
40
|
+
# a line starting with ``` closes the block, wherever it lands — the
|
|
41
|
+
# close fence of ordinary markdown shares a paragraph with its code.
|
|
42
|
+
if closes_fence?(piece)
|
|
43
|
+
balloons << fence.join("\n\n")
|
|
44
|
+
fence = nil
|
|
45
|
+
end
|
|
46
|
+
elsif piece.lines.first.to_s.start_with?(FENCE)
|
|
47
|
+
fence = [piece]
|
|
48
|
+
# one paragraph may open AND close the block (```ruby\nx = 1\n```) —
|
|
49
|
+
# the closer is a later line of the same piece.
|
|
50
|
+
if closes_fence?(piece, opener: true)
|
|
51
|
+
balloons << fence.join("\n\n")
|
|
52
|
+
fence = nil
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
balloons << piece
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
balloons << fence.join("\n\n") if fence
|
|
59
|
+
balloons
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Does this paragraph close an open fence? Any line starting with the fence
|
|
63
|
+
# marker counts. `opener: true` skips the FIRST line — the line that opened
|
|
64
|
+
# the block, which cannot be its own closer.
|
|
65
|
+
def closes_fence?(piece, opener: false)
|
|
66
|
+
lines = piece.lines
|
|
67
|
+
lines[(opener ? 1 : 0)..].any? { |line| line.start_with?(FENCE) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The soft cap: a paragraph longer than SENTENCE_AFTER splits after sentence
|
|
71
|
+
# closers followed by whitespace — which is why `3.9s` and `www.` survive
|
|
72
|
+
# (their period is not followed by whitespace) and a decimal does too. A
|
|
73
|
+
# leftover without a closer stays one balloon.
|
|
74
|
+
#
|
|
75
|
+
# Sentences are then RE-GROUPED into ~SENTENCE_AFTER blocks (
|
|
76
|
+
# the split is a cap, not a mandate). Without the regroup, a 680-char
|
|
77
|
+
# paragraph of short sentences would atomize into one balloon per sentence —
|
|
78
|
+
# a paragraph that was ONE bubble becomes 40 WhatsApp messages for no
|
|
79
|
+
# latency win.
|
|
80
|
+
def split_long(balloon)
|
|
81
|
+
return [balloon] if balloon.length <= SENTENCE_AFTER
|
|
82
|
+
|
|
83
|
+
parts = balloon.split(SENTENCE_BOUNDARY).map(&:strip).reject(&:empty?)
|
|
84
|
+
return [balloon] if parts.empty?
|
|
85
|
+
|
|
86
|
+
parts.each_with_object([+""]) do |sentence, balloons|
|
|
87
|
+
if balloons.last.empty?
|
|
88
|
+
balloons.last << sentence
|
|
89
|
+
elsif balloons.last.length + sentence.length + 1 <= SENTENCE_AFTER
|
|
90
|
+
balloons.last << " #{sentence}"
|
|
91
|
+
else
|
|
92
|
+
balloons << +sentence
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
PARAGRAPH_BREAK = /\n\s*\n+/
|
|
98
|
+
FENCE = "```"
|
|
99
|
+
SENTENCE_BOUNDARY = /(?<=[.!?…])\s+(?=\S)/
|
|
100
|
+
private_constant :PARAGRAPH_BREAK, :FENCE, :SENTENCE_BOUNDARY
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/insika/budget_ledger.rb
CHANGED
|
@@ -18,9 +18,11 @@ module Insika
|
|
|
18
18
|
# BEGIN IMMEDIATE: no lost update. No enforcement here — the middleware is
|
|
19
19
|
# WS2; this file is only correct accounting.
|
|
20
20
|
#
|
|
21
|
-
# Growth is bounded
|
|
22
|
-
# day
|
|
23
|
-
#
|
|
21
|
+
# Growth is bounded on the hot path AND swept: each `add` deletes the (id)'s
|
|
22
|
+
# previous day and previous month cell (an ACTIVE scope holds at most 4 keys),
|
|
23
|
+
# and `prune` — the daily sweep on the tick — drops everything else: the cells
|
|
24
|
+
# of an id that went idle for more than one window and the alert flags, which
|
|
25
|
+
# the hot path never collects.
|
|
24
26
|
class BudgetLedger
|
|
25
27
|
SCOPE = "budget_counters"
|
|
26
28
|
ALERT_SCOPE = "budget_alerts"
|
|
@@ -87,6 +89,25 @@ module Insika
|
|
|
87
89
|
!@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
|
|
88
90
|
end
|
|
89
91
|
|
|
92
|
+
# The GC of both scopes: drops every cell whose window is not the CURRENT
|
|
93
|
+
# one. `add`'s two deletes only reach the IMMEDIATELY previous day/month, so
|
|
94
|
+
# a scope that goes idle for two days leaves its counter behind forever, and
|
|
95
|
+
# the alert flags were never collected at all — unbounded row growth the WS8
|
|
96
|
+
# retention sweep does not reach (that one is age-based over CONTENT; these
|
|
97
|
+
# are counters with no timestamp). Every key of both scopes ENDS in its
|
|
98
|
+
# bucket, so one rule sweeps both. -> count of cells removed.
|
|
99
|
+
def prune(now: Time.now)
|
|
100
|
+
day = daily_bucket(now)
|
|
101
|
+
month = month_bucket(now)
|
|
102
|
+
@store.transaction do
|
|
103
|
+
[SCOPE, ALERT_SCOPE].sum do |scope|
|
|
104
|
+
stale = @store.list(scope).select { |k| past?(k.rpartition(":").last.to_i, day, month) }
|
|
105
|
+
stale.each { |k| @store.delete(scope, k) }
|
|
106
|
+
stale.size
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
90
111
|
private
|
|
91
112
|
|
|
92
113
|
# No tenant (single_tenant default) is a LITERAL "platform" cell, never a
|
|
@@ -95,6 +116,16 @@ module Insika
|
|
|
95
116
|
[tenant || "platform", agent].join(":")
|
|
96
117
|
end
|
|
97
118
|
|
|
119
|
+
# Is that bucket a window STRICTLY BEHIND the live one? The two kinds of
|
|
120
|
+
# bucket cannot collide — an epoch-day is a multiple of 86_400 (~1.7e9), a
|
|
121
|
+
# calendar month is year*12+month (~24e3) — so the magnitude tells them
|
|
122
|
+
# apart. STRICTLY behind, never "not the current one": a host whose clock
|
|
123
|
+
# runs minutes ahead writes tomorrow's cell around midnight, and a sweeper
|
|
124
|
+
# that deleted it would hand that tenant a fresh day of budget.
|
|
125
|
+
def past?(bucket, day, month)
|
|
126
|
+
bucket >= DAY ? bucket < day : bucket.positive? && bucket < month
|
|
127
|
+
end
|
|
128
|
+
|
|
98
129
|
def bump(id, bucket, by)
|
|
99
130
|
total = @store.get(SCOPE, key(id, bucket)).to_i + by
|
|
100
131
|
@store.set(SCOPE, key(id, bucket), total)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# — per-AGENT cache-hit series (scope "cache_series"), for the
|
|
5
|
+
# Studio agent-detail plot. Sessions do not stamp their agent, so the
|
|
6
|
+
# per-session context trace cannot answer "cache-hit over time for THIS
|
|
7
|
+
# agent"; this capped list can. Entries are counts and a category name only —
|
|
8
|
+
# PII-free by construction. No retention hook: the cap bounds growth.
|
|
9
|
+
class CacheSeriesStore
|
|
10
|
+
SCOPE = "cache_series"
|
|
11
|
+
MAX_PER_AGENT = 200 # oldest dropped; one entry per turn, so 200 is a
|
|
12
|
+
# rolling window, not a leak path
|
|
13
|
+
|
|
14
|
+
def initialize(store:)
|
|
15
|
+
@store = store
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Appends a sanitized entry for the agent; caps. Rescues everything — the
|
|
19
|
+
# series never breaks the turn.
|
|
20
|
+
def record(agent:, entry:)
|
|
21
|
+
return if agent.to_s.empty?
|
|
22
|
+
|
|
23
|
+
list = (@store.get(SCOPE, agent.to_s) || []) + [sanitize(entry)]
|
|
24
|
+
@store.set(SCOPE, agent.to_s, list.last(MAX_PER_AGENT))
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# -> [Hash] the agent's series, chronological. [] if none.
|
|
30
|
+
def for_agent(agent) = @store.get(SCOPE, agent.to_s) || []
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def sanitize(entry)
|
|
35
|
+
e = entry.is_a?(Hash) ? entry : {}
|
|
36
|
+
{
|
|
37
|
+
"at" => (e[:at] || e["at"])&.to_s,
|
|
38
|
+
"turn" => int(e[:turn] || e["turn"]),
|
|
39
|
+
"hit_pct" => int_or_nil(e[:hit_pct] || e["hit_pct"]),
|
|
40
|
+
"cached_tokens" => int(e[:cached_tokens] || e["cached_tokens"]),
|
|
41
|
+
"prompt_tokens" => int(e[:prompt_tokens] || e["prompt_tokens"]),
|
|
42
|
+
"invalidation_reason" => (e[:invalidation_reason] || e["invalidation_reason"])&.to_s
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def int(value) = Integer(value || 0)
|
|
47
|
+
def int_or_nil(value) = value.nil? ? nil : Integer(value)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -26,7 +26,8 @@ module Insika
|
|
|
26
26
|
BACKOFF_SECONDS = [1, 5].freeze
|
|
27
27
|
|
|
28
28
|
def initialize(channels:, outbox:, session_store:, event_stream: nil,
|
|
29
|
-
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil
|
|
29
|
+
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil,
|
|
30
|
+
shadow_pairs: nil, criterion_sha: nil)
|
|
30
31
|
@channels = channels
|
|
31
32
|
@outbox = outbox
|
|
32
33
|
@session_store = session_store
|
|
@@ -34,29 +35,66 @@ module Insika
|
|
|
34
35
|
@max_attempts = max_attempts
|
|
35
36
|
@backoff = Array(backoff)
|
|
36
37
|
@sleeper = sleeper || method(:default_sleep)
|
|
38
|
+
@shadow_pairs = shadow_pairs
|
|
39
|
+
@criterion_sha = criterion_sha
|
|
37
40
|
end
|
|
38
41
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
42
|
+
# the pair store and the frozen criterion's sha. Both default to
|
|
43
|
+
# nil (parity — a graph without them behaves exactly as today); the server
|
|
44
|
+
# root sets them at boot, after the criterion file has been loaded and
|
|
45
|
+
# refused-or-accepted (the graph itself reads no env and no file).
|
|
46
|
+
attr_writer :shadow_pairs, :criterion_sha
|
|
47
|
+
|
|
48
|
+
# Confirmed answer -> 0..N pending Deliveries, in order .
|
|
49
|
+
# A progressive channel splits on paragraphs (BalloonSplitter); everything
|
|
50
|
+
# else is the single whole-answer row.
|
|
51
|
+
# -> [] when there is nothing to send (the cheap exits):
|
|
41
52
|
# · the turn did not come in through a channel,
|
|
42
53
|
# · the channel is Shape A (answers on its own stream — no `deliver`),
|
|
43
54
|
# · the answer is empty (a turn that died mid-message published nothing, and
|
|
44
55
|
# half a sentence was never an answer),
|
|
56
|
+
# · the channel is in SHADOW mode: the answer is recorded as a
|
|
57
|
+
# pair and nothing is dispatched — zero outbox writes, ever (E1),
|
|
45
58
|
# · or we do not know who to send it to.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
#
|
|
60
|
+
# `attachments` (evidence cards) ride the outbox payload as an
|
|
61
|
+
# ADDITIVE key on the LAST balloon — a Shape B channel that reads `payload`
|
|
62
|
+
# ignores it (JSON contract, additive); one that renders cards consumes it.
|
|
63
|
+
def record_balloons(task:, channel_id:, content:, progressive:, attachments: nil)
|
|
49
64
|
channel = @channels&.find(channel_id)
|
|
50
|
-
return
|
|
65
|
+
return [] unless channel.respond_to?(:deliver)
|
|
66
|
+
# Shadow records ONE pair for the whole answer — a balloon per paragraph
|
|
67
|
+
# would mint N pairs for one turn.
|
|
68
|
+
if shadow?(channel)
|
|
69
|
+
record_shadow(task, channel_id, content)
|
|
70
|
+
return []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return [] if content.to_s.strip.empty?
|
|
51
74
|
|
|
52
75
|
to = recipient(channel, task.session_id)
|
|
53
|
-
return
|
|
76
|
+
return [] if to.nil? || to.empty?
|
|
77
|
+
|
|
78
|
+
parts = progressive ? Insika::BalloonSplitter.split(content) : [content.to_s]
|
|
79
|
+
parts = parts.reject { |p| p.to_s.strip.empty? }
|
|
80
|
+
return [] if parts.empty?
|
|
81
|
+
|
|
82
|
+
multi = parts.size > 1
|
|
83
|
+
parts.each_with_index.map do |part, i|
|
|
84
|
+
last = i == parts.size - 1
|
|
85
|
+
create_pending(task, channel_id, part, to,
|
|
86
|
+
index: multi ? i : nil, final: multi ? last : nil,
|
|
87
|
+
attachments: last ? attachments : nil)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def shadow?(channel) = channel.respond_to?(:shadow?) && channel.shadow?
|
|
54
92
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
93
|
+
# does this channel flush progressively? Duck-typed — a channel
|
|
94
|
+
# that does not answer `progressive?` is `:at_end`.
|
|
95
|
+
def progressive?(channel_id)
|
|
96
|
+
channel = @channels&.find(channel_id)
|
|
97
|
+
channel.respond_to?(:progressive?) && channel.progressive?
|
|
60
98
|
end
|
|
61
99
|
|
|
62
100
|
# Claim + POST + bounded retry. Safe to call twice: the second caller loses the
|
|
@@ -90,6 +128,73 @@ module Insika
|
|
|
90
128
|
|
|
91
129
|
private
|
|
92
130
|
|
|
131
|
+
# One outbox row for a confirmed balloon. `index`/`final` ride the payload
|
|
132
|
+
# only when non-nil — a single-balloon progressive turn is indistinguishable
|
|
133
|
+
# from an `:at_end` one on the wire. `index` also lands on the RECORD, which
|
|
134
|
+
# is what the boot sweep orders by. `attachments` :
|
|
135
|
+
# validated for the outbox — malformed entries dropped, never a turn failure.
|
|
136
|
+
def create_pending(task, channel_id, content, to, index: nil, final: nil, attachments: nil)
|
|
137
|
+
payload = { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
|
|
138
|
+
"content" => content.to_s }
|
|
139
|
+
payload["index"] = index if index
|
|
140
|
+
payload["final"] = final unless final.nil?
|
|
141
|
+
atts = Insika::Evidence.valid_attachments(attachments)
|
|
142
|
+
payload["attachments"] = atts unless atts.empty?
|
|
143
|
+
@outbox.create(channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
|
|
144
|
+
payload: payload, index: index.to_i)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Our half of the shadow pair (C3). One store upsert on the turn's terminal,
|
|
148
|
+
# then nil — `Executor#finalize_channel_delivery` returns without dispatching.
|
|
149
|
+
# The ordering rules: no event_id -> :shadow_unpairable (C1 makes this
|
|
150
|
+
# unreachable through the relay; a plugin channel could still get it wrong);
|
|
151
|
+
# no pair store wired -> the same event (fail-closed, nothing delivered);
|
|
152
|
+
# no recipient -> the same event (a pair keyed on an empty external_id can
|
|
153
|
+
# never meet the mirror's half).
|
|
154
|
+
def record_shadow(task, channel_id, content)
|
|
155
|
+
command = task.respond_to?(:command) ? task.command : nil
|
|
156
|
+
payload = command.is_a?(Hash) ? (command["payload"] || command[:payload] || {}) : {}
|
|
157
|
+
agent = payload["agent"] || payload[:agent]
|
|
158
|
+
message = payload["message"] || payload[:message]
|
|
159
|
+
event_id = Insika::Coercion.presence(payload["event_id"] || payload[:event_id])
|
|
160
|
+
if event_id.nil? || @shadow_pairs.nil?
|
|
161
|
+
emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
channel = @channels&.find(channel_id)
|
|
166
|
+
external_id = recipient(channel, task.session_id)
|
|
167
|
+
# The same empty-recipient guard the delivery path has: a pair keyed on an
|
|
168
|
+
# empty external_id can never meet the mirror's half (its digest differs),
|
|
169
|
+
# so the pair would sit :open forever. C1 makes this unreachable through
|
|
170
|
+
# the relay; a plugin channel could still get it wrong.
|
|
171
|
+
if Insika::Coercion.presence(external_id).nil?
|
|
172
|
+
emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
silent = content.to_s.strip.empty?
|
|
177
|
+
id = Insika::ShadowPairStore.key_for(channel: channel_id, external_id: external_id,
|
|
178
|
+
event_id: event_id)
|
|
179
|
+
@shadow_pairs.record_ours(id: id, channel: channel_id, agent: agent,
|
|
180
|
+
session_id: task.session_id, task_id: task.id,
|
|
181
|
+
event_id: event_id, inbound: message.to_s,
|
|
182
|
+
reply: content.to_s, criterion_sha: @criterion_sha)
|
|
183
|
+
emit_shadow(:shadow_recorded, channel_id, agent, id, silent: silent)
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Metadata only: the stream reaches every subscriber and stays free of
|
|
188
|
+
# customer content, per the Studio's own emit_operator_action rule.
|
|
189
|
+
def emit_shadow(type, channel, agent, pair_id, silent:)
|
|
190
|
+
return unless @event_stream
|
|
191
|
+
|
|
192
|
+
data = { channel: channel.to_s, agent: agent, pair_id: pair_id }.compact
|
|
193
|
+
data[:silent] = silent unless silent.nil?
|
|
194
|
+
@event_stream.emit(Insika::Event.new(type: type, data: data,
|
|
195
|
+
meta: { at: Time.now.utc.iso8601 }))
|
|
196
|
+
end
|
|
197
|
+
|
|
93
198
|
def attempt(delivery, channel)
|
|
94
199
|
last_error = nil
|
|
95
200
|
|