insika 0.1.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.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -7,14 +7,14 @@ require_relative "config"
7
7
 
8
8
  module Insika
9
9
  module Safety
10
- # Input guardrail as a Middleware (RFC-0009 §3.1). It sits on the ONE seam that
10
+ # Input guardrail as a Middleware. It sits on the ONE seam that
11
11
  # already short-circuits structurally (stage 4: a link that does not call `nxt`),
12
12
  # and uses the NEW graceful-halt contract: instead of `halt_reason` (which the
13
13
  # Executor maps to a turn FAILURE), it sets `halt_response` (a safe reply) +
14
14
  # `guardrail_block` (audit metadata) and returns without calling `nxt`. The
15
15
  # Executor completes the turn with that safe reply, never touching the LLM.
16
16
  #
17
- # Two tiers (D2):
17
+ # Two tiers:
18
18
  # 1. deterministic scan (always, cheap, zero-token) — Detectors#scan_input;
19
19
  # 2. LLM moderator (opt-in per agent) — only when the deterministic tier let
20
20
  # the message through, so the cheap layer short-circuits the expensive one.
@@ -23,7 +23,7 @@ module Insika
23
23
  # link lives ONCE in the global MiddlewareStack — there is no per-agent stack.
24
24
  #
25
25
  # `moderator_factory` (optional): ->(config) { Moderator | nil }, built by the
26
- # Safety::Factory. nil = deterministic only (Fase A/B parity).
26
+ # Safety::Factory. nil = deterministic only (B parity).
27
27
  class InputGuardrail < Insika::Middleware
28
28
  def initialize(moderator_factory: nil)
29
29
  @moderator_factory = moderator_factory
@@ -33,7 +33,7 @@ module Insika
33
33
  config = Config.from_profile(state.profile)
34
34
  return nxt.call(state) unless config.input
35
35
 
36
- hit = Detectors.scan_input(state.message.to_s, categories: config.input_categories)
36
+ hit = Detectors.scan_input(state.message.to_s, categories: config.input_categories, corpus: config.corpus)
37
37
  return block(state, config, category: hit[:category], source: :deterministic, detail: hit[:matched]) if hit
38
38
 
39
39
  if config.moderator? && (mod = build_moderator(config))
@@ -43,6 +43,11 @@ module Insika
43
43
  return block(state, config, category: category, source: :moderator,
44
44
  detail: verdict.reason, action: verdict.action)
45
45
  end
46
+ # an UNAVAILABLE moderator fails open — the turn proceeds —
47
+ # but silence is not a negative: record the degradation on the state so the
48
+ # Executor (single emitter) emits :guardrail_flagged and a degraded tier
49
+ # never looks identical to a healthy one in the audit stream.
50
+ flag_unavailable(state, verdict) if verdict.unavailable?
46
51
  end
47
52
 
48
53
  nxt.call(state)
@@ -54,11 +59,21 @@ module Insika
54
59
  @moderator_factory&.call(config)
55
60
  end
56
61
 
62
+ # Appends the audit flag for a degraded moderator tier. Same
63
+ # state-carried channel the OutputValidator uses; detail is the moderator's
64
+ # own reason string ("moderator error (fail-open)" / "unparseable
65
+ # (fail-open)"), never message content.
66
+ def flag_unavailable(state, verdict)
67
+ state.guardrail_flags = Array(state.guardrail_flags) + [{
68
+ category: "moderator_unavailable", source: "moderator", detail: verdict.reason.to_s[0, 200]
69
+ }]
70
+ end
71
+
57
72
  # The block category. `escalate` (an ACTION) becomes the `escalate` category so
58
73
  # the agent can address it; otherwise the moderator's own category flows
59
74
  # through UNCOLLAPSED — the safe-reply lookup (SafeResponses.for) resolves an
60
75
  # unknown one to the agent's `default` / the neutral built-in, so we don't need
61
- # to force it into a fixed bucket here (configuration over convention, §7).
76
+ # to force it into a fixed bucket here (configuration over convention).
62
77
  def moderator_category(verdict)
63
78
  return :escalate if verdict.action.to_s == "escalate"
64
79
 
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Safety
7
- # LLM content moderator (RFC-0009 Fase C / D2). The subtle tier the regex can't
7
+ # LLM content moderator. The subtle tier the regex can't
8
8
  # reach: social engineering (a fabricated prior promise), veiled hostility, tone.
9
9
  #
10
10
  # Pure over an injected `ask` callable (prompt -> raw model text), exactly like
@@ -13,48 +13,56 @@ module Insika
13
13
  #
14
14
  # FAIL-OPEN by construction: the deterministic layer already ran and caught the
15
15
  # gross cases, so an unparseable/failed moderator reply must NOT block a
16
- # legitimate customer — it degrades to `allow`. Blocking is the high-stakes
17
- # direction (RFC §6: a false positive turns away a real buyer).
16
+ # legitimate customer. Blocking is the high-stakes direction (a false
17
+ # positive turns away a real buyer). But fail-open is not a fake negative
18
+ # a moderator that could not answer returns the third state
19
+ # `unavailable` — it does not block, and it is NOT recorded as a clean `allow`,
20
+ # so a degraded tier is distinguishable from a healthy one in the audit stream.
18
21
  class Moderator
19
22
  Verdict = Struct.new(:category, :action, :reason, keyword_init: true) do
20
23
  def block? = %w[refuse escalate].include?(action.to_s)
24
+ def unavailable? = action.to_s == "unavailable"
21
25
  end
22
26
 
23
27
  CATEGORIES = %w[injection abuse sexual self_harm off_topic safe].freeze
24
- ACTIONS = %w[allow refuse escalate].freeze
28
+ ACTIONS = %w[allow refuse escalate unavailable].freeze
25
29
 
26
30
  # ask: ->(prompt) { "<raw model text>" }.
27
31
  def initialize(ask:)
28
32
  @ask = ask
29
33
  end
30
34
 
31
- # Classifies a user message. Returns a Verdict; on ANY failure -> allow/safe
32
- # (fail-open). `context` is optional free text (e.g. the agent's domain) woven
35
+ # Classifies a user message. Returns a Verdict; on ANY failure -> unavailable
36
+ # (fail-open: never blocks, but never masquerades as a real `allow` either —
37
+ # `context` is optional free text (e.g. the agent's domain) woven
33
38
  # into the prompt.
34
39
  def classify(message, context: nil)
35
40
  raw = @ask.call(build_prompt(message.to_s, context)).to_s
36
41
  parse(raw)
37
42
  rescue StandardError
38
- Verdict.new(category: "safe", action: "allow", reason: "moderator error (fail-open)")
43
+ Verdict.new(category: "safe", action: "unavailable", reason: "moderator error (fail-open)")
39
44
  end
40
45
 
41
46
  private
42
47
 
43
48
  def parse(raw)
44
49
  json = raw[/\{.*\}/m]
45
- return safe_verdict unless json
50
+ return unavailable_verdict unless json
46
51
 
47
52
  data = JSON.parse(json)
48
53
  action = data["action"].to_s.strip.downcase
49
- action = "allow" unless ACTIONS.include?(action)
54
+ # An out-of-enum action is a reply we cannot honor — normalize to
55
+ # unavailable, not allow: inventing a negative the model never gave is the
56
+ # same ambiguity removes.
57
+ action = "unavailable" unless ACTIONS.include?(action)
50
58
  category = data["category"].to_s.strip.downcase
51
59
  category = "safe" unless CATEGORIES.include?(category)
52
60
  Verdict.new(category: category, action: action, reason: data["reason"].to_s)
53
61
  rescue JSON::ParserError
54
- safe_verdict
62
+ unavailable_verdict
55
63
  end
56
64
 
57
- def safe_verdict = Verdict.new(category: "safe", action: "allow", reason: "unparseable (fail-open)")
65
+ def unavailable_verdict = Verdict.new(category: "safe", action: "unavailable", reason: "unparseable (fail-open)")
58
66
 
59
67
  def build_prompt(message, context)
60
68
  <<~PROMPT
@@ -4,12 +4,12 @@ require_relative "detectors"
4
4
 
5
5
  module Insika
6
6
  module Safety
7
- # Deterministic output redaction on the STREAM (RFC-0009 §3.2 / D3). The turn
7
+ # Deterministic output redaction on the STREAM. The turn
8
8
  # streams `content` deltas, so we cannot "unsay" text already sent — the filter
9
9
  # sits between the provider chunks and the `:content` event and only ever emits
10
10
  # text it has PROVEN clean.
11
11
  #
12
- # The hard part (RFC §6, "fronteira de chunk"): a CPF/secret can arrive split
12
+ # The hard part ("fronteira de chunk"): a CPF/secret can arrive split
13
13
  # across two chunks and neither half matches on its own. A naive delta-by-delta
14
14
  # regex leaks "by construction". So the filter keeps a SLIDING BUFFER: it retains
15
15
  # the tail that might still be growing into a match (Detectors::OPEN_TAIL, which
@@ -21,7 +21,11 @@ module Insika
21
21
  class OutputFilter
22
22
  attr_reader :redaction_counts
23
23
 
24
- def initialize
24
+ # `corpus` : the compiled corpus for the turn — the
25
+ # language-filtered PII set and its open-tail pattern. Default = the
26
+ # full shipped corpus (parity).
27
+ def initialize(corpus: Corpus.compile)
28
+ @corpus = corpus
25
29
  @buf = +""
26
30
  @emitted = +"" # full redacted text emitted so far (for the persisted content)
27
31
  @redaction_counts = Hash.new(0)
@@ -51,11 +55,11 @@ module Insika
51
55
  def safe_cut
52
56
  cut = @buf.length
53
57
 
54
- if (m = @buf.match(Detectors::OPEN_TAIL))
58
+ if (m = @buf.match(@corpus.open_tail))
55
59
  cut = [cut, m.begin(0)].min
56
60
  end
57
61
 
58
- Detectors.match_ranges(@buf).each do |b, e|
62
+ @corpus.match_ranges(@buf).each do |b, e|
59
63
  cut = b if b < cut && cut < e
60
64
  end
61
65
 
@@ -69,7 +73,7 @@ module Insika
69
73
 
70
74
  chunk = @buf[0...cut]
71
75
  @buf = @buf[cut..] || +""
72
- redacted, counts = Detectors.redact(chunk)
76
+ redacted, counts = @corpus.redact(chunk)
73
77
  counts.each { |name, n| @redaction_counts[name] += n }
74
78
  @emitted << redacted
75
79
  redacted
@@ -6,11 +6,11 @@ require_relative "config"
6
6
 
7
7
  module Insika
8
8
  module Safety
9
- # Post-turn output validator (RFC-0009 §3.2 / D3). Runs as an `after_task` hook:
9
+ # Post-turn output validator. Runs as an `after_task` hook:
10
10
  # with the FINAL assistant text assembled, it does a richer check than the
11
11
  # stream filter can — an unverified discount/price promise, a tone slip — and
12
12
  # FLAGS it (audit) rather than blocking. It is honest about the streaming limit
13
- # (D3): for a streaming agent the text is already out the door, so this is
13
+ # for a streaming agent the text is already out the door, so this is
14
14
  # detection, not prevention; true pre-emission blocking needs `streaming:false`
15
15
  # (a per-profile override left for a later slice).
16
16
  #
@@ -27,13 +27,19 @@ module Insika
27
27
  class OutputValidator
28
28
  # `ask_factory` (optional): ->(config) { ->(prompt){text} | nil }, built by the
29
29
  # Safety::Factory from the utility_model. nil = deterministic only.
30
- def initialize(ask_factory: nil)
30
+ # `grounding` (optional): a GroundingValidator — its step runs
31
+ # FIRST in #call, before the `config.output` gate (D9: grounding is
32
+ # evidence integrity, independent of the guardrails opt-in).
33
+ def initialize(ask_factory: nil, grounding: nil)
31
34
  @ask_factory = ask_factory
35
+ @grounding = grounding
32
36
  end
33
37
 
34
38
  # after_task hook body. Idempotent and defensive: never raises out (a hook
35
- # error must not fail a committed turn).
39
+ # error must not fail a committed turn). Grounding runs BEFORE the output
40
+ # gate so an agent with guardrails off and grounding on still gets the check.
36
41
  def call(state)
42
+ state = @grounding&.call(state) || state
37
43
  config = Config.from_profile(state.profile)
38
44
  return state unless config.output
39
45
 
@@ -41,7 +47,7 @@ module Insika
41
47
  return state if text.empty?
42
48
 
43
49
  flags = []
44
- flags.concat(deterministic_flags(text))
50
+ flags.concat(deterministic_flags(text, config.corpus))
45
51
  flags.concat(llm_flags(text, config)) if config.moderator?
46
52
 
47
53
  state.guardrail_flags = Array(state.guardrail_flags) + flags unless flags.empty?
@@ -55,8 +61,8 @@ module Insika
55
61
  # Residual PII/secret that somehow reached the final text (the stream filter
56
62
  # should have caught it — this is defense in depth, and the flag itself
57
63
  # carries category counts, never the raw value).
58
- def deterministic_flags(text)
59
- _redacted, counts = Detectors.redact(text)
64
+ def deterministic_flags(text, corpus)
65
+ _redacted, counts = Detectors.redact(text, corpus: corpus)
60
66
  return [] if counts.empty?
61
67
 
62
68
  [{ category: "pii_residual", source: "deterministic", detail: counts.map { |k, v| "#{k}:#{v}" }.join(",") }]
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Insika
4
4
  module Safety
5
- # Safe reply for a blocked turn (RFC-0009 D5/§7). A blocked turn NEVER returns a
5
+ # Safe reply for a blocked turn. A blocked turn NEVER returns a
6
6
  # raw error nor silence — it completes gracefully with one of these.
7
7
  #
8
8
  # CONFIGURATION OVER CONVENTION: this is OSS across arbitrary businesses and
@@ -10,8 +10,8 @@ module Insika
10
10
  # sandbox primitive — always on, independent of the exec provider (local or
11
11
  # docker) and of the engine's approval layer.
12
12
  #
13
- # Extracted VERBATIM from the insika-code prototype's `Workspace` (item 5)
14
- # and promoted to a core primitive (item 35). The two layers of defense:
13
+ # Extracted VERBATIM from the insika-code prototype's `Workspace`
14
+ # and promoted to a core primitive. The two layers of defense:
15
15
  # 1. `File.expand_path` normalizes `..` traversal; the expanded path must be
16
16
  # the root itself or a descendant of it (string containment on a
17
17
  # separator boundary, so `/ws-evil` does not pass for root `/ws`).
@@ -7,7 +7,7 @@ require_relative "sandbox/local"
7
7
  require_relative "sandbox/docker"
8
8
 
9
9
  module Insika
10
- # Sandbox primitive (item 35 / COMPETITIVE-ANALYSIS §4.6): a single, pluggable
10
+ # Sandbox primitive (COMPETITIVE-ANALYSIS): a single, pluggable
11
11
  # interface for confined execution, promoted to the core from the insika-code
12
12
  # prototype. Two halves:
13
13
  #
@@ -131,6 +131,41 @@ module Insika
131
131
  end
132
132
  end
133
133
 
134
+ # Walks a dotted path on a plain object: nil when a segment
135
+ # is absent or the intermediate is not a Hash. Shared by the evidence output
136
+ # check and the Processor's extraction.
137
+ def dig(obj, path)
138
+ path.to_s.split(".").reduce(obj) do |cur, seg|
139
+ return nil unless cur.is_a?(Hash) && cur.key?(seg)
140
+
141
+ cur[seg]
142
+ end
143
+ end
144
+
145
+ # The evidence RESULT contract: {items: [{id, line}]} .
146
+ # -> nil | String. Same idiom as `violation`: nil = fine, one message = what
147
+ # is wrong. A malformed evidence result is a correctable TOOL answer — the
148
+ # envelope returns it to the model as `{error:}`, exactly like a malformed
149
+ # call is today. A raw body that is NOT an object (a bare JSON array from a
150
+ # search, a string, nil) is a violation — never a silent `{items: []}` that
151
+ # the model reads as "no products".
152
+ def violation_output(spec, raw)
153
+ return nil if spec.nil?
154
+ return "evidence: result must be an object" unless raw.is_a?(Hash)
155
+
156
+ items = dig(raw, spec.items_path)
157
+ return "evidence: items is missing" if items.nil?
158
+ return "evidence: items must be a list" unless items.is_a?(Array)
159
+
160
+ items.each_with_index do |item, i|
161
+ ok = item.is_a?(Hash) &&
162
+ Coercion.present?(item["id"] || item[:id]) &&
163
+ (item["line"] || item[:line]).is_a?(String)
164
+ return "evidence: items[#{i}] must be {id, line}" unless ok
165
+ end
166
+ nil
167
+ end
168
+
134
169
  # Name the shape the way a model reads it, not the way Ruby does.
135
170
  def kind(value)
136
171
  case value