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/dsl/runtime.rb
CHANGED
|
@@ -178,7 +178,8 @@ module Insika
|
|
|
178
178
|
guardrails: c[:guardrails], context_providers: context_providers(spine, c),
|
|
179
179
|
edge_limiter: c[:edge_limiter],
|
|
180
180
|
executor_extra: { settings_store: c[:settings_store], tool_trace_store: c[:tool_trace_store],
|
|
181
|
-
context_trace_store: c[:context_trace_store],
|
|
181
|
+
context_trace_store: c[:context_trace_store],
|
|
182
|
+
cache_series_store: c[:cache_series_store], llm: @llm }
|
|
182
183
|
)
|
|
183
184
|
register_authoring_commands(graph, c)
|
|
184
185
|
register_workflows(graph)
|
|
@@ -229,6 +230,8 @@ module Insika
|
|
|
229
230
|
mcp_store: Insika::McpStore.new(config_store: config_store),
|
|
230
231
|
tool_trace_store: Insika::ToolTraceStore.new(store: backend),
|
|
231
232
|
context_trace_store: Insika::ContextTraceStore.new(store: backend),
|
|
233
|
+
# the per-AGENT cache-hit series (the Studio agent-detail card).
|
|
234
|
+
cache_series_store: Insika::CacheSeriesStore.new(store: backend),
|
|
232
235
|
tool_registry: tool_registry,
|
|
233
236
|
tool_catalog: Insika::ToolCatalog.new(tool_registry: tool_registry),
|
|
234
237
|
skill_catalog: Insika::SkillCatalog.new([], store: skill_store),
|
|
@@ -267,6 +270,7 @@ module Insika
|
|
|
267
270
|
bus = graph.bus
|
|
268
271
|
es = graph.event_stream
|
|
269
272
|
mem = graph.memory_store
|
|
273
|
+
audit = graph.memory_audit_store
|
|
270
274
|
bus.register(:create_agent, Insika::Commands::CreateAgent.new(profile_source: c[:profile_source], event_stream: es))
|
|
271
275
|
bus.register(:update_agent, Insika::Commands::UpdateAgent.new(profile_source: c[:profile_source], event_stream: es))
|
|
272
276
|
bus.register(:delete_agent, Insika::Commands::DeleteAgent.new(profile_source: c[:profile_source], event_stream: es))
|
|
@@ -276,9 +280,11 @@ module Insika
|
|
|
276
280
|
bus.register(:restore_agent_file, Insika::Commands::RestoreAgentFile.new(profile_source: c[:profile_source], agent_file_store: c[:agent_file_store], event_stream: es))
|
|
277
281
|
bus.register(:write_skill, Insika::Commands::WriteSkill.new(skill_store: c[:skill_store], skill_catalog: c[:skill_catalog], event_stream: es))
|
|
278
282
|
bus.register(:set_skill_agents, Insika::Commands::SetSkillAgents.new(profile_source: c[:profile_source], event_stream: es))
|
|
279
|
-
bus.register(:memory_put_fact, Insika::Commands::MemoryPutFact.new(memory_store: mem, event_stream: es))
|
|
280
|
-
bus.register(:memory_forget_fact, Insika::Commands::MemoryForgetFact.new(memory_store: mem, event_stream: es))
|
|
283
|
+
bus.register(:memory_put_fact, Insika::Commands::MemoryPutFact.new(memory_store: mem, event_stream: es, audit_store: audit))
|
|
284
|
+
bus.register(:memory_forget_fact, Insika::Commands::MemoryForgetFact.new(memory_store: mem, event_stream: es, audit_store: audit))
|
|
281
285
|
bus.register(:memory_add_note, Insika::Commands::MemoryAddNote.new(memory_store: mem, event_stream: es))
|
|
286
|
+
# the LGPD access right — the Studio Customers drill exports.
|
|
287
|
+
bus.register(:export_customer_memory, Insika::Commands::ExportCustomerMemory.new(memory_store: mem, event_stream: es))
|
|
282
288
|
bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
|
|
283
289
|
bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
|
|
284
290
|
bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
|
|
@@ -31,6 +31,11 @@ module Insika
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def run
|
|
34
|
+
# The boot refusal (shadow + no criterion) lives in the AppBuilder; run it
|
|
35
|
+
# BEFORE the Studio is configured so the criterion the Studio folds is the
|
|
36
|
+
# same object the delivery path stamps pairs with.
|
|
37
|
+
@builder.channels?
|
|
38
|
+
register_parity_commands
|
|
34
39
|
configure_studio
|
|
35
40
|
dispatch = Rack::URLMap.new("/studio" => Studio::App, "/" => @builder.app)
|
|
36
41
|
endpoint = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
|
|
@@ -56,6 +61,23 @@ module Insika
|
|
|
56
61
|
def workflows? = @builder.workflows?
|
|
57
62
|
def channels? = @builder.channels?
|
|
58
63
|
|
|
64
|
+
# The parity page renders whenever a shadow channel exists, so the judge
|
|
65
|
+
# button must reach a registered command — the deployment bus registers the
|
|
66
|
+
# same one (config/deployment.rb); a page that answers "unknown command"
|
|
67
|
+
# would make this root a second-class citizen. Registered only when the
|
|
68
|
+
# criterion loaded (a command without one would be a fake judge).
|
|
69
|
+
def register_parity_commands
|
|
70
|
+
criterion = @builder.criterion
|
|
71
|
+
return unless criterion
|
|
72
|
+
|
|
73
|
+
@graph.bus.register(:judge_shadow_pairs,
|
|
74
|
+
Insika::Commands::JudgeShadowPairs.new(
|
|
75
|
+
shadow_pairs: @graph.shadow_pair_store,
|
|
76
|
+
settings_store: @rt.component(:settings_store),
|
|
77
|
+
criterion: criterion, event_stream: @graph.event_stream
|
|
78
|
+
))
|
|
79
|
+
end
|
|
80
|
+
|
|
59
81
|
def configure_studio
|
|
60
82
|
Studio::App.configure(
|
|
61
83
|
command_bus: @graph.bus, profile_source: @graph.profiles,
|
|
@@ -64,16 +86,63 @@ module Insika
|
|
|
64
86
|
agent_file_store: @rt.component(:agent_file_store), skill_store: @rt.component(:skill_store),
|
|
65
87
|
skill_catalog: @graph.skill_catalog, tool_catalog: @graph.tool_catalog,
|
|
66
88
|
tool_store: @rt.component(:tool_store), memory_store: @graph.memory_store,
|
|
89
|
+
# the Customers drill renders the audit lines.
|
|
90
|
+
memory_audit_store: @graph.memory_audit_store,
|
|
67
91
|
session_store: @graph.session_store,
|
|
68
92
|
settings_store: @rt.component(:settings_store), llm_provider_store: @rt.component(:provider_store),
|
|
69
93
|
mcp_store: @rt.component(:mcp_store), system_file_store: @rt.component(:system_file_store),
|
|
70
94
|
tool_trace_store: @rt.component(:tool_trace_store),
|
|
71
95
|
context_trace_store: @rt.component(:context_trace_store),
|
|
96
|
+
# the per-agent cache-hit series (agent-detail card).
|
|
97
|
+
cache_series_store: @rt.component(:cache_series_store),
|
|
72
98
|
task_store: @graph.task_store, checkpoint_store: @graph.checkpoint_store,
|
|
73
|
-
pending_action_store: @graph.pending_action_store
|
|
99
|
+
pending_action_store: @graph.pending_action_store,
|
|
100
|
+
# WS7: the agents-grid scorecard reads the outcome store.
|
|
101
|
+
outcome_store: @graph.outcome_store,
|
|
102
|
+
# the outcome-funnel page reads the fold's cells and the
|
|
103
|
+
# BudgetLedger's current counters (D6); the only mutation — the
|
|
104
|
+
# baseline freeze — dispatches :freeze_funnel_baseline on the bus.
|
|
105
|
+
funnel_store: @graph.funnel_store,
|
|
106
|
+
budget_ledger: @graph.budget_ledger,
|
|
107
|
+
# the parity page (only rendered when a shadow channel exists —
|
|
108
|
+
# the nav row keys off the registry).
|
|
109
|
+
shadow_pair_store: @graph.shadow_pair_store,
|
|
110
|
+
parity_criterion: @builder.criterion,
|
|
111
|
+
channel_registry: @graph.channel_registry,
|
|
112
|
+
# the Follow-ups page reads the stores directly;
|
|
113
|
+
# its only mutations (cancel, force-revoke) dispatch bus commands.
|
|
114
|
+
followup_store: @graph.followup_store,
|
|
115
|
+
contact_store: @graph.contact_store,
|
|
116
|
+
# the Facts (wiki) page reads the proposal store
|
|
117
|
+
# directly; its mutations dispatch :resolve_proposal on the bus.
|
|
118
|
+
proposal_store: @graph.proposal_store,
|
|
119
|
+
# the Harvest page reads the harvest store + the two
|
|
120
|
+
# pre-registered artifacts directly; its mutations dispatch the
|
|
121
|
+
# harvest bus commands. The criterion/negative list load best-effort
|
|
122
|
+
# (nil = the page renders the refusal/empty states).
|
|
123
|
+
harvest_store: @graph.harvest_store,
|
|
124
|
+
harvest_criterion: harvest_criterion,
|
|
125
|
+
negative_list: harvest_negative_list
|
|
74
126
|
)
|
|
75
127
|
end
|
|
76
128
|
|
|
129
|
+
# The frozen criterion — best-effort at boot: a bare install
|
|
130
|
+
# without the env var renders the ruler's hole instead of
|
|
131
|
+
# crashing the Studio.
|
|
132
|
+
def harvest_criterion
|
|
133
|
+
path = Insika::EnvSchema.read("INSIKA_HARVEST_CRITERION")
|
|
134
|
+
path && Insika::Harvest::Criterion.load(path)
|
|
135
|
+
rescue Insika::ConfigError, Insika::ValidationError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def harvest_negative_list
|
|
140
|
+
path = Insika::EnvSchema.read("INSIKA_HARVEST_NEGATIVE")
|
|
141
|
+
path && Insika::Harvest::NegativeList.parse(File.read(path))
|
|
142
|
+
rescue Errno::ENOENT
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
77
146
|
def persistence
|
|
78
147
|
@graph.durable? ? "durable (sqlite)" : "ephemeral (memory)"
|
|
79
148
|
end
|
data/lib/insika/dsl.rb
CHANGED
|
@@ -215,6 +215,46 @@ module Insika
|
|
|
215
215
|
# budget daily: 100_000, monthly: 2_000_000, soft: false
|
|
216
216
|
def budget(hash) = (@config[:budget] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
217
217
|
|
|
218
|
+
# The outcome funnel declaration: the store's stage vocabulary
|
|
219
|
+
# as pack data — the engine folds WS7 outcome kinds into the DECLARED
|
|
220
|
+
# stages, and never hard-codes one itself. Merges, so repeated calls
|
|
221
|
+
# accumulate (like budget).
|
|
222
|
+
# funnel stages: %w[greeted qualified cart paid],
|
|
223
|
+
# advance_on: { "pix_paid" => "paid", "abandoned_cart" => "cart" },
|
|
224
|
+
# primary: "paid", attribution_window: "72h"
|
|
225
|
+
def funnel(hash) = (@config[:funnel] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
226
|
+
|
|
227
|
+
# The follow-up declaration: the agent may book a follow-up
|
|
228
|
+
# with a customer at a future time (`schedule` tool); the engine fires the
|
|
229
|
+
# synthetic turn and enforces the policy (quiet hours, max frequency,
|
|
230
|
+
# cancellation keywords, silence detection) at fire time. Pack data —
|
|
231
|
+
# merges, so repeated calls accumulate (like budget).
|
|
232
|
+
# followup arm: "schedule",
|
|
233
|
+
# policy: { quiet_hours: { timezone: "America/Sao_Paulo",
|
|
234
|
+
# start: "21:30", end: "09:00" },
|
|
235
|
+
# max_frequency: "2/24h",
|
|
236
|
+
# cancel_keywords: ["não quero mais contato"],
|
|
237
|
+
# silence_after_sends: 3 }
|
|
238
|
+
def followup(hash) = (@config[:followup] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
239
|
+
|
|
240
|
+
# The session-distillation declaration: what counts as a fact
|
|
241
|
+
# for THIS store, distilled from finished customer conversations. Pack
|
|
242
|
+
# data — merges, so repeated calls accumulate (like budget). `prompt` and
|
|
243
|
+
# `model` are pack-authored keys the DSL passes through.
|
|
244
|
+
# distill enabled: true, idle_hours: 6, max_proposals: 10
|
|
245
|
+
def distill(hash) = (@config[:distill] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
246
|
+
|
|
247
|
+
# The gated-harvest declaration: the engine may read this
|
|
248
|
+
# agent's finished traffic and propose SKILLS for the store's playbook —
|
|
249
|
+
# through the negative list, the grounding filter and the double gate,
|
|
250
|
+
# never applied automatically. Pack data — merges, so repeated calls
|
|
251
|
+
# accumulate (like budget). `prompt`/`model` are pack-authored keys the
|
|
252
|
+
# DSL passes through.
|
|
253
|
+
# harvest enabled: true,
|
|
254
|
+
# negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
|
|
255
|
+
# miner: { model: "deepseek-v4-flash", window: { last_sessions: 200 } }
|
|
256
|
+
def harvest(hash) = (@config[:harvest] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
257
|
+
|
|
218
258
|
# Provider-interaction reliability, as DATA (WS3): retries + exponential
|
|
219
259
|
# backoff on transient failures, a fallback model chain (mid-turn
|
|
220
260
|
# rotation), and a circuit breaker per (tenant, provider/model) that
|
|
@@ -226,17 +266,51 @@ module Insika
|
|
|
226
266
|
(@config[:reliability] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
227
267
|
end
|
|
228
268
|
|
|
229
|
-
|
|
230
|
-
# breaker_open / delivery_failed events to the webhook
|
|
231
|
-
# alerts webhook: "https://ops.example.com/insika-alerts"
|
|
269
|
+
# Operator alert delivery (WS6): POST this agent's budget_warning /
|
|
270
|
+
# breaker_open / delivery_failed events to the webhook as JSON.
|
|
232
271
|
def alerts(hash) = (@config[:alerts] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
233
272
|
|
|
273
|
+
# Intent routing (WS4): classify each turn's message into one route with a
|
|
274
|
+
# cheap model BEFORE the ask. A Hash: route name -> description (or a Hash
|
|
275
|
+
# with description/delegate/stuck/message), plus the reserved keys
|
|
276
|
+
# "default" (the deterministic fallback) and "model"/"provider" (the cheap
|
|
277
|
+
# classifier). The classifier prompt is generated — data only.
|
|
278
|
+
# routes "shopping" => "the customer wants to browse products",
|
|
279
|
+
# "order" => { "description" => "asks about an existing order",
|
|
280
|
+
# "delegate" => "order-agent" },
|
|
281
|
+
# "human" => { "description" => "the customer asks for a person",
|
|
282
|
+
# "stuck" => true },
|
|
283
|
+
# "default" => "shopping", "model" => "deepseek-v4-flash"
|
|
284
|
+
def routes(hash) = (@config[:routes] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
285
|
+
|
|
234
286
|
# The agent may signal it cannot proceed (WS5): when on, the model
|
|
235
287
|
# can call `signal_stuck`, which ends the turn with `outcome: :stuck` + a final
|
|
236
288
|
# message + a `:turn_stuck` event. What "stuck" means is the consumer's call.
|
|
237
289
|
# stuck_signal true
|
|
238
290
|
def stuck_signal(on = true) = @config[:stuck_signal] = on
|
|
239
291
|
|
|
292
|
+
# The per-session working-state schema this agent keeps and asks for
|
|
293
|
+
# a flat list of field names. [] = off (no provider output,
|
|
294
|
+
# no update_briefing/set_next_step tools).
|
|
295
|
+
# briefing_fields "size", "budget", "delivery_day"
|
|
296
|
+
def briefing_fields(*names)
|
|
297
|
+
@config[:briefing_fields] = names.flatten.map(&:to_s)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Generated-media output policy (WS9, saída): the media kinds this
|
|
301
|
+
# agent MAY generate as turn outputs, with per-kind config. The other
|
|
302
|
+
# half of the gate is the CHANNEL's: the request must declare the
|
|
303
|
+
# matching capability for the tools to exist at all.
|
|
304
|
+
# outputs image: { model: "gpt-image-1", size: "1024x1024" },
|
|
305
|
+
# tts: { model: "tts-1", voice: "alloy" }
|
|
306
|
+
def outputs(hash) = (@config[:outputs] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
307
|
+
|
|
308
|
+
# The engine's "Tool discipline" block in the system prompt (retry a
|
|
309
|
+
# weak/empty tool result with a different approach before giving up).
|
|
310
|
+
# ON by default — this setter exists to turn it OFF:
|
|
311
|
+
# tool_persistence false
|
|
312
|
+
def tool_persistence(on = true) = @config[:tool_persistence] = on
|
|
313
|
+
|
|
240
314
|
# Mechanical tool-result dedupe in the replayed history
|
|
241
315
|
# (no-LLM compaction, apt for bloated transcripts). CHANGES WHAT THE MODEL
|
|
242
316
|
# SEES: repeated identical tool results collapse to a back-reference.
|
|
@@ -250,6 +324,12 @@ module Insika
|
|
|
250
324
|
# responses: { "injection" => "I can't help with that." }
|
|
251
325
|
def guardrails(hash) = (@config[:guardrails] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
252
326
|
|
|
327
|
+
# Evidence-grounding policy: the pack declares how the engine
|
|
328
|
+
# polices product claims against the evidence ledger. Same
|
|
329
|
+
# config-over-code shape as `guardrails`; absent = off (parity).
|
|
330
|
+
# grounding mode: :flag, matcher: { sku: '\b[A-Z]{2,4}\d{4,8}\b' }
|
|
331
|
+
def grounding(hash = {}) = (@config[:grounding] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
332
|
+
|
|
253
333
|
# Refinement — how the agent's own instruction files may be
|
|
254
334
|
# improved from real traffic. Same config-over-code shape as `guardrails`;
|
|
255
335
|
# omitting it entirely leaves the agent report-only (writes nothing).
|
data/lib/insika/edge_limiter.rb
CHANGED
|
@@ -65,8 +65,14 @@ module Insika
|
|
|
65
65
|
# the rate-limit reply exactly when the window is saturated. Entry checks
|
|
66
66
|
# are skipped; the turn's usage still lands on the ledger below.
|
|
67
67
|
resumed = state.resumed
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
# a SCHEDULED turn (the FollowupEngine's kick) skips the
|
|
69
|
+
# ENTRY checks exactly like a resume — a follow-up that trips the token
|
|
70
|
+
# ceiling must not receive the rate-limit REPLY (the customer agreed to
|
|
71
|
+
# this message; the volume control is the follow-up policy, not the flood
|
|
72
|
+
# rail). The turn still runs and its usage still lands on the ledger.
|
|
73
|
+
scheduled = scheduled_turn?(state)
|
|
74
|
+
|
|
75
|
+
if !resumed && !scheduled && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
|
|
70
76
|
breach = check_chat_rate(state, limit, edge)
|
|
71
77
|
return block(state, edge, **breach) if breach
|
|
72
78
|
end
|
|
@@ -75,7 +81,7 @@ module Insika
|
|
|
75
81
|
# `"chat_rate_limit": null`) reads as OFF for that agent, not "inherit".
|
|
76
82
|
if (ceiling = positive(limits.key?(:agent_token_ceiling) ? limits[:agent_token_ceiling] : edge["agent_token_ceiling"]))
|
|
77
83
|
token_window = positive(edge["agent_token_window"]) || DEFAULT_TOKEN_WINDOW
|
|
78
|
-
unless resumed
|
|
84
|
+
unless resumed || scheduled
|
|
79
85
|
spent = @ledger.count(TOKENS_KIND, state.profile.id.to_s, window: token_window)
|
|
80
86
|
if spent >= ceiling
|
|
81
87
|
return block(state, edge, category: :token_ceiling,
|
|
@@ -90,9 +96,11 @@ module Insika
|
|
|
90
96
|
# typed error (never a customer-facing reply); the alert_at warning and
|
|
91
97
|
# the SOFT over-cap both warn once per window + inject a context note.
|
|
92
98
|
# A resumed turn (crash/pause replay) was already admitted: it is never
|
|
93
|
-
# refused twice — its spend still lands on the ledger below.
|
|
99
|
+
# refused twice — its spend still lands on the ledger below. A scheduled
|
|
100
|
+
# turn rides the same rule: the follow-up policy is the
|
|
101
|
+
# volume control, not the budget wall.
|
|
94
102
|
budget_on = budget_configured?(state)
|
|
95
|
-
budget_enforce(state) unless resumed
|
|
103
|
+
budget_enforce(state) unless resumed || scheduled
|
|
96
104
|
|
|
97
105
|
result = begin
|
|
98
106
|
nxt.call(state)
|
|
@@ -110,6 +118,14 @@ module Insika
|
|
|
110
118
|
|
|
111
119
|
private
|
|
112
120
|
|
|
121
|
+
# is this turn the FollowupEngine's synthetic kick? The
|
|
122
|
+
# command type is stamped by the engine only — a consumer cannot send it
|
|
123
|
+
# (the SendMessage edge refuses the spelling, C8).
|
|
124
|
+
def scheduled_turn?(state)
|
|
125
|
+
command = state.respond_to?(:task) && state.task&.command
|
|
126
|
+
command.is_a?(Hash) && command["type"].to_s == "scheduled_followup"
|
|
127
|
+
end
|
|
128
|
+
|
|
113
129
|
# One KV get per turn (same order of cost as the guardrail's config read);
|
|
114
130
|
# no SettingsStore in the wiring -> per-agent limits only.
|
|
115
131
|
def platform_edge
|
|
@@ -231,12 +247,17 @@ module Insika
|
|
|
231
247
|
return if @budget_ledger.mark_alert(tenant: tenant, agent: agent, window: w[:window],
|
|
232
248
|
level: level, now: now)
|
|
233
249
|
|
|
250
|
+
# `tenant` on the META too, not only in the payload: the tenant-scoped
|
|
251
|
+
# /v1/events subscription filters on meta[:tenant] and is fail-closed, so
|
|
252
|
+
# a warning about the tenant's OWN budget never reached the tenant.
|
|
253
|
+
meta = { task_id: state.task&.id, session_id: state.task&.session_id,
|
|
254
|
+
at: Time.now.utc.iso8601 }
|
|
255
|
+
meta[:tenant] = tenant unless tenant.nil?
|
|
234
256
|
@event_stream&.emit(Insika::Event.new(
|
|
235
257
|
type: :budget_warning,
|
|
236
258
|
data: { agent: agent, tenant: tenant, window: w[:window],
|
|
237
259
|
spent: spent, cap: w[:cap], level: level },
|
|
238
|
-
meta:
|
|
239
|
-
at: Time.now.utc.iso8601 }
|
|
260
|
+
meta: meta
|
|
240
261
|
))
|
|
241
262
|
end
|
|
242
263
|
|
data/lib/insika/env_schema.rb
CHANGED
|
@@ -102,9 +102,9 @@ module Insika
|
|
|
102
102
|
spec(name: "INSIKA_ENV", description: "Environment name shown in the Studio (falls back to RACK_ENV)."),
|
|
103
103
|
spec(name: "INSIKA_A2A_AGENT", description: "Agent id to expose over inbound A2A (opt-in)."),
|
|
104
104
|
spec(name: "INSIKA_A2A_REMOTES", type: :csv, description: "Comma-separated remote A2A endpoints."),
|
|
105
|
-
spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools (default: https only)."),
|
|
105
|
+
spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools, channel callbacks and media fetches (default: https only)."),
|
|
106
106
|
spec(name: "INSIKA_EGRESS_ALLOW_PRIVATE", type: :boolean, description: "Allow egress to private/loopback ranges (SSRF guard off)."),
|
|
107
|
-
spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress."),
|
|
107
|
+
spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress (media fetches are NOT pinned by it)."),
|
|
108
108
|
spec(name: "INSIKA_OTEL", type: :boolean, description: "Turn on OpenTelemetry export (opt-in)."),
|
|
109
109
|
spec(name: "INSIKA_MODEL_PRICING", description: "JSON rates table (USD per million tokens) for the estimated-cost attribute; unset -> no cost reported."),
|
|
110
110
|
spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in)."),
|
|
@@ -115,11 +115,18 @@ module Insika
|
|
|
115
115
|
spec(name: "INSIKA_DRAIN_TIMEOUT", type: :integer, description: "Seconds a stopping worker waits for in-flight turns before abandoning them to the next boot's recovery (default 20)."),
|
|
116
116
|
spec(name: "INSIKA_TICK_INTERVAL", type: :integer, description: "Seconds between tick passes (outbox drain + stale recovery sweep). Default 60; 0 disables."),
|
|
117
117
|
spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
|
|
118
|
+
spec(name: "INSIKA_STT_MODEL", description: "Model used to transcribe audio message parts (WS9). Unset -> RubyLLM's default transcription model."),
|
|
119
|
+
spec(name: "INSIKA_STT_LANGUAGE", description: "Language hint for the transcription of audio message parts (WS9)."),
|
|
118
120
|
spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
|
|
119
121
|
spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
|
|
120
122
|
spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
|
|
121
123
|
spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
|
|
122
124
|
spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
|
|
125
|
+
spec(name: "INSIKA_RELAY_SHADOW", type: :boolean, description: "Shadow mode: the relay records replies instead of delivering them."),
|
|
126
|
+
spec(name: "INSIKA_RELAY_DELIVERY", type: :enum, enum: %w[at_end progressive], description: "How the relay flushes the outbox: at_end (one POST) or progressive (one POST per balloon). Unset -> at_end."),
|
|
127
|
+
spec(name: "INSIKA_PARITY_CRITERION", type: :path, description: "The frozen parity criterion file (required in shadow mode)."),
|
|
128
|
+
spec(name: "INSIKA_HARVEST_CRITERION", type: :path, description: "The frozen harvest conversion criterion file (strict-loaded before any promotion)."),
|
|
129
|
+
spec(name: "INSIKA_HARVEST_NEGATIVE", type: :path, description: "The negative-list seed file the harvest CLI imports into agent profiles."),
|
|
123
130
|
spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
|
|
124
131
|
spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
|
|
125
132
|
spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
|
data/lib/insika/errors.rb
CHANGED
|
@@ -182,6 +182,17 @@ module Insika
|
|
|
182
182
|
# tell "the recipient refused" from "the engine has a bug".
|
|
183
183
|
class DeliveryError < Error; end
|
|
184
184
|
|
|
185
|
+
# WS4 routing failed: a route's delegate agent is not configured, or its turn
|
|
186
|
+
# failed. An operator/config error — the envelope names the :routing stage
|
|
187
|
+
# instead of swallowing it as :unknown.
|
|
188
|
+
class RoutingError < Error; end
|
|
189
|
+
|
|
190
|
+
# WS9 media failed: an audio part could not be fetched or transcribed, an
|
|
191
|
+
# image attachment could not be built, or a media URL was egress-blocked. A
|
|
192
|
+
# customer's voice message that never entered the turn must not be silently
|
|
193
|
+
# dropped — the :media stage names it.
|
|
194
|
+
class MediaError < Error; end
|
|
195
|
+
|
|
185
196
|
# Strict configuration violation (— OpenClaw's config discipline:
|
|
186
197
|
# "recusa boot com chave desconhecida, no silent config compat"). Raised by
|
|
187
198
|
# EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
|
|
@@ -59,8 +59,9 @@ module Insika
|
|
|
59
59
|
module Assertions
|
|
60
60
|
# Named negative detectors for `must_not` now live in the runtime. Kept as
|
|
61
61
|
# an alias so any external reference to Evals::Assertions::PII_DETECTORS still
|
|
62
|
-
# resolves; the values ARE the runtime's, never a fork.
|
|
63
|
-
|
|
62
|
+
# resolves; the values ARE the runtime's, never a fork. the
|
|
63
|
+
# pattern data moved to the corpus, still under the same Safety umbrella.
|
|
64
|
+
PII_DETECTORS = Insika::Safety::Corpus::PII
|
|
64
65
|
|
|
65
66
|
# HOW MUCH THE AGENT SHOULD ASK BEFORE ACTING. Declared per
|
|
66
67
|
# case because it is a per-STORE decision, not a universal rule: sometimes the
|
|
@@ -60,12 +60,19 @@ module Insika
|
|
|
60
60
|
def compare(golden:, turns:)
|
|
61
61
|
return nil unless golden.reference?
|
|
62
62
|
|
|
63
|
-
ours
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
compare_texts(ours: Pairwise.transcript(golden.user_turns, turns),
|
|
64
|
+
theirs: Pairwise.reference_transcript(golden.reference_messages),
|
|
65
|
+
vs: golden.human_assisted? ? "human-assisted" : "agent")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# two transcripts, no golden — the shadow seam. The judge is
|
|
69
|
+
# the SAME object with the SAME prompt, so shadow pairs and golden cases are
|
|
70
|
+
# graded by one rule. An empty side returns nil (never a verdict against an
|
|
71
|
+
# empty string — that was a bug in the golden path until this seam landed).
|
|
72
|
+
def compare_texts(ours:, theirs:, vs: "agent")
|
|
73
|
+
return nil if ours.to_s.strip.empty? || theirs.to_s.strip.empty?
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
combine(panel, vs: golden.human_assisted? ? "human-assisted" : "agent")
|
|
75
|
+
combine(@asks.map { |ask| judge_once(ask, ours, theirs) }, vs: vs)
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
# The replayed conversation as the judge reads it: the user turns we sent,
|
|
@@ -81,8 +88,13 @@ module Insika
|
|
|
81
88
|
# is the conversation as the customer received it, and telling it "a person wrote
|
|
82
89
|
# this one" is an invitation to grade the author instead. The fact is carried to
|
|
83
90
|
# the READER as `vs: human-assisted` instead, which is where it changes a decision.
|
|
91
|
+
# A reference with no text at all returns "" — the caller's empty guard then
|
|
92
|
+
# refuses instead of judging against an empty string .
|
|
84
93
|
def self.reference_transcript(messages)
|
|
85
|
-
Array(messages)
|
|
94
|
+
msgs = Array(messages)
|
|
95
|
+
return "" if msgs.all? { |m| m["text"].to_s.strip.empty? }
|
|
96
|
+
|
|
97
|
+
msgs.map do |m|
|
|
86
98
|
speaker = m["role"].to_s == "user" ? "customer" : "assistant"
|
|
87
99
|
"#{speaker}: #{m['text'].to_s.strip}"
|
|
88
100
|
end.join("\n")
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# — the evidence contract (engine half).
|
|
7
|
+
#
|
|
8
|
+
# A tool DECLARES `evidence` in its manifest; the engine then does BOTH jobs
|
|
9
|
+
# from the same declaration: strips the result down to `{items: [{id, line}]}`
|
|
10
|
+
# for the model (the lean envelope), and appends every `id` to the session
|
|
11
|
+
# evidence ledger. No second flag, no "lean but not evidence" mode — a
|
|
12
|
+
# half-configuration cannot exist, which is what keeps "no claim without a
|
|
13
|
+
# tool ID" a tautology at the envelope instead of a convention.
|
|
14
|
+
#
|
|
15
|
+
# Everything here is pure Ruby, no IO: the ToolEnvelope calls it after the real
|
|
16
|
+
# tool returns. It does NOT write anything — the ledger write is the envelope's,
|
|
17
|
+
# via the state.
|
|
18
|
+
module Evidence
|
|
19
|
+
# The declaration (D1/C2.1). Parses three authorable shapes:
|
|
20
|
+
#
|
|
21
|
+
# { "evidence": "products" } // bare kind
|
|
22
|
+
# { "evidence": { "kind": "products" } } // full form
|
|
23
|
+
# { "evidence": { "kind": "products",
|
|
24
|
+
# "items": "results",
|
|
25
|
+
# "attachments": "cards" } } // non-default paths
|
|
26
|
+
#
|
|
27
|
+
# `kind` is the PILOT PACK's value, never a gem constant (the
|
|
28
|
+
# engine owns nothing about product shape).
|
|
29
|
+
Spec = Data.define(:kind, :items_path, :attachments_path) do
|
|
30
|
+
PATH_RE = /\A[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\z/
|
|
31
|
+
|
|
32
|
+
# String | Hash | nil -> Spec | nil. Raises ValidationError on a blank kind
|
|
33
|
+
# or an empty/ill-formed path. All at ingestion, never at the turn.
|
|
34
|
+
def self.parse(raw)
|
|
35
|
+
return nil if raw.nil? || raw == false
|
|
36
|
+
|
|
37
|
+
h = raw.is_a?(String) ? { "kind" => raw } : Coercion.deep_stringify(raw)
|
|
38
|
+
h = h.is_a?(Hash) ? h : {}
|
|
39
|
+
kind = Coercion.presence(h["kind"])
|
|
40
|
+
raise Insika::ValidationError, "evidence.kind is required" if kind.nil?
|
|
41
|
+
|
|
42
|
+
items = presence_path(h["items"], "items")
|
|
43
|
+
attachments = presence_path(h["attachments"], "attachments")
|
|
44
|
+
new(kind: kind, items_path: items, attachments_path: attachments)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{ "kind" => kind, "items" => items_path, "attachments" => attachments_path }.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.presence_path(value, default)
|
|
52
|
+
s = Coercion.presence(value)
|
|
53
|
+
s = default if s.nil?
|
|
54
|
+
raise Insika::ValidationError, "evidence.#{default}: not a dotted path" unless PATH_RE.match?(s)
|
|
55
|
+
|
|
56
|
+
s
|
|
57
|
+
end
|
|
58
|
+
private_class_method :presence_path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The lean result the model sees — the ONLY thing that survives the envelope.
|
|
62
|
+
MAX_ITEMS = 16
|
|
63
|
+
# Line truncation keeps the transcript lean by force (E1).
|
|
64
|
+
LINE_MAX = 200
|
|
65
|
+
# Attachments are a channel nicety, never the answer; bounded on purpose.
|
|
66
|
+
MAX_ATTACHMENTS = 16
|
|
67
|
+
URL_MAX = 500
|
|
68
|
+
|
|
69
|
+
# The attachments contract, validated for the outbox (channel side, never the
|
|
70
|
+
# model): [{ "type" => "card"|"image", "url" => String, "caption" => String|nil }].
|
|
71
|
+
# Entries without a String url, or beyond MAX_ATTACHMENTS, are DROPPED — never
|
|
72
|
+
# a turn failure (the card is a channel nicety, not the answer).
|
|
73
|
+
def self.valid_attachments(list)
|
|
74
|
+
Array(list).filter_map do |entry|
|
|
75
|
+
next unless entry.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
url = (entry["url"] || entry[:url]).to_s
|
|
78
|
+
next if url.empty?
|
|
79
|
+
|
|
80
|
+
caption = Coercion.presence(entry["caption"] || entry[:caption])
|
|
81
|
+
{ "type" => (entry["type"] || entry[:type]).to_s,
|
|
82
|
+
"url" => url[0, URL_MAX],
|
|
83
|
+
"caption" => caption }
|
|
84
|
+
end.first(MAX_ATTACHMENTS)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Result shaping, stateless — callable from any tool-call fiber (the parallel
|
|
88
|
+
# tool_concurrency path). No shared state in this class.
|
|
89
|
+
class Processor
|
|
90
|
+
class << self
|
|
91
|
+
# -> the parsed Hash the evidence paths dig into. For `evidence_envelope`
|
|
92
|
+
# the raw body lives under `__insika_body` (D3); a code tool returns the
|
|
93
|
+
# object itself. Raises JSON::ParserError on a non-JSON envelope body —
|
|
94
|
+
# the envelope turns that into `{error:}`, nothing recorded (fail closed:
|
|
95
|
+
# no IDs, no claims to make).
|
|
96
|
+
def raw(spec, result)
|
|
97
|
+
return result unless result.is_a?(Hash) && result.key?("__insika_body")
|
|
98
|
+
|
|
99
|
+
JSON.parse(result["__insika_body"].to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# -> [lean, attachments]. Assumes the shape already passed
|
|
103
|
+
# SchemaGuard.violation_output. Items are capped and lines truncated; the
|
|
104
|
+
# model must never see a null where the contract says items.
|
|
105
|
+
def build(spec, raw)
|
|
106
|
+
items = SchemaGuard.dig(raw, spec.items_path) || []
|
|
107
|
+
lean_items = items.first(MAX_ITEMS).map do |item|
|
|
108
|
+
{ "id" => (item["id"] || item[:id]).to_s,
|
|
109
|
+
"line" => Coercion.utf8((item["line"] || item[:line]).to_s)[0, LINE_MAX] }
|
|
110
|
+
end
|
|
111
|
+
lean = { "items" => lean_items }
|
|
112
|
+
attachments = Insika::Evidence.valid_attachments(SchemaGuard.dig(raw, spec.attachments_path))
|
|
113
|
+
[lean, attachments]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The session evidence ledger (C4). A session-scoped SET with an `ungrounded`
|
|
120
|
+
# counter: records every product id that entered the context via an
|
|
121
|
+
# evidence-declared tool this session , plus the ungrounded
|
|
122
|
+
# counter that feeds the daily metric. It is NOT a policy object — it records
|
|
123
|
+
# and answers `ids` / `ungrounded` / `lines`; it never decides.
|
|
124
|
+
class EvidenceLedger
|
|
125
|
+
# Oldest-evicted cap: a session that outlives it needs a real cap or the row
|
|
126
|
+
# grows forever.
|
|
127
|
+
MAX_IDS = 1_000
|
|
128
|
+
|
|
129
|
+
def initialize(store: nil, session_id: nil)
|
|
130
|
+
@store = store
|
|
131
|
+
@session_id = session_id
|
|
132
|
+
@ids = []
|
|
133
|
+
@ungrounded = 0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The in-memory accumulator (the envelope appends, the validator/enforcer
|
|
137
|
+
# read the union). The PERSISTED list is appended on flush (Executor, stage 8)
|
|
138
|
+
# — the envelope never blocks on the store.
|
|
139
|
+
def record(ids)
|
|
140
|
+
@ids.concat(Array(ids).map(&:to_s).reject(&:empty?))
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# -> the effective set for THIS turn: persisted session evidence (loaded at
|
|
145
|
+
# build) + the turn's new ids, deduped, capped.
|
|
146
|
+
def ids
|
|
147
|
+
(session_ids + @ids).uniq.last(MAX_IDS)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
attr_reader :ungrounded
|
|
151
|
+
|
|
152
|
+
def ungrounded_count(claim)
|
|
153
|
+
@ungrounded += 1
|
|
154
|
+
claim
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# -> self, flushed to the session record. Idempotent. A store OR not-found
|
|
158
|
+
# failure is swallowed (evidence is audit — it must never fail a committed
|
|
159
|
+
# turn; a session purged mid-turn by forget_customer/session_purge reads as
|
|
160
|
+
# "nothing to append", never an explosion).
|
|
161
|
+
def flush!
|
|
162
|
+
return self unless @store && @session_id
|
|
163
|
+
|
|
164
|
+
@store.append_evidence(@session_id, ids: @ids, ungrounded: @ungrounded)
|
|
165
|
+
@ids = []
|
|
166
|
+
@ungrounded = 0
|
|
167
|
+
self
|
|
168
|
+
rescue Insika::Error
|
|
169
|
+
self
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def session_ids
|
|
175
|
+
return [] unless @store && @session_id
|
|
176
|
+
|
|
177
|
+
session = @store.find(@session_id)
|
|
178
|
+
Array(session&.evidence&.fetch("ids", [])).map(&:to_s)
|
|
179
|
+
rescue Insika::NotFoundError
|
|
180
|
+
[]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|