insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Safety
5
+ # The SHIPPED deterministic guardrail corpus as language-tagged DATA
6
+ # Pure data — no Insika requires, standalone-loadable
7
+ # like detectors.rb used to be.
8
+ #
9
+ # Pattern SOURCE now lives here; `Detectors` is the compiler/matcher on
10
+ # top. The runtime compiles a per-agent `Compiled` set — a deployment
11
+ # clears the pt-BR families via `guardrails.corpora.languages` and extends
12
+ # them via `guardrails.corpora.extra` (docs/domain.md). Absent config = the
13
+ # full shipped default: byte-for-byte today's behavior (parity).
14
+ #
15
+ # LANGUAGE: each regex lives in exactly ONE language. `compile` (no args)
16
+ # is the union of the shipped languages, each family in its shipped order.
17
+ # For a single-language input the default behaves exactly as the runtime
18
+ # that hard-coded one flat list — the ONE honest delta: the previous flat
19
+ # list INTERLEAVED the languages, so for a phrase matching patterns of
20
+ # BOTH languages the reported `matched` substring may differ (the
21
+ # category never does). Pinned in spec/insika/safety/corpus_spec.rb.
22
+ module Corpus
23
+ # ── The shipped input families ─────────────────────────────────────────
24
+ # "injection"/"sexual"/"abuse" per language. High-confidence heuristics
25
+ # that short-circuit the turn BEFORE the LLM runs (conservative by
26
+ # design — a false positive blocks a legitimate customer; the subtler
27
+ # judgment is the LLM moderator's).
28
+ DEFAULTS = {
29
+ "pt-BR" => {
30
+ "injection" => [
31
+ /\binstru[çc][õo]es\s+de\s+sistema\b/i,
32
+ /\b(regras|instru[çc][õo]es|orienta[çc][õo]es|diretrizes)\s+internas\b/i,
33
+ /\b(revele|mostre|exiba|me\s+(d[êe]|mande|envie|passe)|repita|imprima)\b[^.?!]{0,40}\b(prompt|instru[çc][õo]es|regras|configura[çc][ãa]o|system)\b/i,
34
+ # "ignore/disregard the (previous) instructions"
35
+ /\b(ignore|ignora|desconsidere|esque[çc]a)\b[^.?!]{0,30}\b(instru[çc][õo]es|regras|orienta[çc][õo]es|acima|anteriores)\b/i,
36
+ # encode/translate the prompt (the base64/rot13 exfil trick, either order)
37
+ /\b(base64|rot13|codific|encode|cifr)\w*\b[^.?!]{0,60}\b(instru[çc][õo]es|prompt|regras|sistema|system)\b/i,
38
+ /\b(instru[çc][õo]es|prompt|regras|sistema|system)\b[^.?!]{0,60}\b(base64|rot13|codific|encode|cifr)\w*\b/i
39
+ ],
40
+ "sexual" => [
41
+ /\b(nudes?|pelad[oa]s?|s?exo|transar|transa\b|gozar|tes[ãa]o|s[ãa]fad[oa]|puta|pau|buceta|piroca|caralho\s+(duro|na))\b/i,
42
+ /\bo\s+que\s+voc[êe]\s+faria\s+comigo\b/i,
43
+ /\b(descrev|imagina|conta)\w*\b[^.?!]{0,30}\bcomigo\s+(na\s+cama|pelad)/i,
44
+ /\b(quer|vamos)\b[^.?!]{0,20}\b(transar|fazer\s+sexo|sexo)\b/i
45
+ ],
46
+ "abuse" => [
47
+ # Directed insult only — "a entrega foi uma merda" must NOT block;
48
+ # "você é uma merda de atendente" should. The `você é …` anchor
49
+ # keeps precision high.
50
+ /\bvoc[êe]\s+(é|e|ta|est[áa])\b[^.?!]{0,25}\b(lixo|in[uú]til|merda|imprest[aá]vel|idiota|burr[oa]|est[uú]pid[oa]|otári[oa]|in[uú]teis|incompetente|p[áa]ssim[oa])\b/i,
51
+ /\b(seu|sua)\s+(lixo|in[uú]til|idiota|imbecil|otári[oa]|burr[oa]|est[uú]pid[oa]|merda|escrot[oa])\b/i,
52
+ /\bvai\s+(se\s+)?(fuder|foder|tomar\s+no)\b/i
53
+ ]
54
+ },
55
+ "en" => {
56
+ "injection" => [
57
+ /\bsystem\s*prompt\b/i,
58
+ /\b(ignore|disregard|forget)\b[^.?!]{0,30}\b(instructions|rules|prompt|above|previous|prior)\b/i
59
+ ],
60
+ "sexual" => [
61
+ /\b(horny|blow\s?job|hand\s?job|jerk\s+off|have\s+sex|send\s+(me\s+)?(a\s+)?nudes?|dick\s+pic)\b/i,
62
+ /\bwhat\s+(would|will)\s+you\s+do\s+to\s+me\b/i
63
+ ],
64
+ "abuse" => [
65
+ # Directed insult only (keeps precision high; frustration ≠ abuse)
66
+ /\byou\s*(?:'?re|\s+are)\b[^.?!]{0,25}\b(useless|garbage|trash|idiot|stupid|worthless|pathetic|incompetent|dumb|a\s+joke)\b/i,
67
+ /\b(fuck|screw)\s+you\b/i,
68
+ /\byou\s+(suck|are\s+the\s+worst)\b/i
69
+ ]
70
+ }
71
+ }.freeze
72
+
73
+ # ── The shipped OUTPUT detectors (PII/secret redaction targets) ────────
74
+ # Language-tagged: "secret" is universal (never cleared); "cpf"/"cnpj"
75
+ # are pt-BR tax-id formats — an EN-only corpus drops them (documented
76
+ # consequence, docs/domain.md). Key ORDER is the previous runtime's
77
+ # (cpf, cnpj, secret) — it is what `pii_names` and the "pii_leak" union
78
+ # iterate.
79
+ PII = {
80
+ "cpf" => {
81
+ "languages" => ["pt-BR"],
82
+ "pattern" => /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/
83
+ },
84
+ "cnpj" => {
85
+ "languages" => ["pt-BR"],
86
+ "pattern" => /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/
87
+ },
88
+ "secret" => {
89
+ "languages" => nil,
90
+ "pattern" => /\b(?:sk-[A-Za-z0-9]{16,}|Bearer\s+[A-Za-z0-9._-]{16,})\b/
91
+ }
92
+ }.freeze
93
+
94
+ # A run of the output stream that MIGHT still be growing into a
95
+ # PII/secret match if more chunks arrive — anchored at the buffer tail
96
+ # (the OutputFilter's hold-back). Universal: it covers the unbounded
97
+ # `sk-…`/`Bearer …` shapes and the in-progress CPF/CNPJ digit runs.
98
+ OPEN_TAIL = %r{
99
+ (?:
100
+ s(?:k(?:-[A-Za-z0-9]*)?)? # prefix of "sk-" + optional body
101
+ | B(?:e(?:a(?:r(?:e(?:r(?:\s+[A-Za-z0-9._-]*)?)?)?)?)?)? # prefix of "Bearer " + body
102
+ | \d[\d./-]* # in-progress CPF/CNPJ digit run
103
+ )\z
104
+ }x
105
+
106
+ # The compiled corpus for ONE agent: the input families + output
107
+ # detectors filtered by its languages and extended by its `extra`
108
+ # patterns. Immutable (Data + deep-frozen collections) and shared safely.
109
+ Compiled = Data.define(:languages, :input, :pii) do
110
+ # The subset of `categories` (symbols) that have patterns in this
111
+ # corpus — the strictness gate applied to the compiled set.
112
+ def input_categories(categories)
113
+ categories.select { |cat| Array(input[cat.to_s]).any? }
114
+ end
115
+
116
+ # Runs a NAMED output detector over `text` -> the matched substring |
117
+ # nil. "pii_leak" = union of all patterns; an entirely UNKNOWN name
118
+ # fails LOUD (a typo'd assertion must never silently pass); a name the
119
+ # corpus knows but whose language was cleared matches nothing.
120
+ def detect_output(name, text)
121
+ patterns =
122
+ if name.to_s == "pii_leak"
123
+ pii.values
124
+ else
125
+ p = pii[name.to_s]
126
+ if p.nil? && !Corpus::PII.key?(name.to_s)
127
+ raise ArgumentError, "unknown detector: #{name.inspect}"
128
+ end
129
+
130
+ p ? [p] : []
131
+ end
132
+ patterns.each { |re| (m = text.to_s.match(re)) && (return m[0]) }
133
+ nil
134
+ end
135
+
136
+ # Replaces every PII/secret occurrence with an opaque `[REDACTED:<name>]`
137
+ # marker — the raw value NEVER survives. -> [redacted_text, {name => count}].
138
+ def redact(text)
139
+ counts = Hash.new(0)
140
+ out = text.to_s.dup
141
+ pii.each do |name, re|
142
+ out = out.gsub(re) do
143
+ counts[name] += 1
144
+ "[REDACTED:#{name}]"
145
+ end
146
+ end
147
+ [out, counts]
148
+ end
149
+
150
+ # [[begin, end), ...] byte-index ranges of every PII/secret match in
151
+ # `text` (used by the OutputFilter to avoid splitting a complete match
152
+ # at a chunk boundary).
153
+ def match_ranges(text)
154
+ ranges = []
155
+ pii.each_value do |re|
156
+ text.to_s.scan(re) { ranges << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
157
+ end
158
+ ranges
159
+ end
160
+
161
+ # The universal open-tail pattern (see Corpus::OPEN_TAIL).
162
+ def open_tail = Corpus::OPEN_TAIL
163
+ end
164
+
165
+ KNOWN_FAMILIES = %w[injection sexual abuse].freeze
166
+
167
+ # -> Compiled. `languages`: nil = ALL shipped languages (parity default);
168
+ # [] = no shipped input family (only `extra`); ["en"] = the EN-only
169
+ # corpus. `extra`: { "abuse" => ["/\\bdupa\\b/i", ...] } — per-category
170
+ # additions in the source-string syntax (or Regexp objects), compiled
171
+ # with the same syntax. Raises Insika::ValidationError naming the value
172
+ # for an unknown language/family or a malformed pattern.
173
+ #
174
+ # Compiled values are immutable, so the result is MEMOIZED per signature:
175
+ # a turn builds Config several times (input middleware, output validator,
176
+ # filter factory) and each build must not re-compile the same patterns.
177
+ def self.compile(languages: nil, extra: {})
178
+ key = [languages.is_a?(Array) ? languages.sort : languages, extra]
179
+ @cache ||= {}
180
+ @cache[key] ||= build(languages, extra)
181
+ end
182
+
183
+ def self.build(languages, extra)
184
+ langs = languages.nil? ? DEFAULTS.keys : validate_languages!(languages)
185
+ extra = validate_extra!(extra)
186
+
187
+ input = {}
188
+ langs.each do |lang|
189
+ DEFAULTS.fetch(lang).each do |cat, patterns|
190
+ input[cat] = Array(input[cat]) + patterns
191
+ end
192
+ end
193
+ extra.each do |cat, sources|
194
+ input[cat.to_s] = Array(input[cat.to_s]) + Array(sources).map { |src| compile_pattern(src, cat) }
195
+ end
196
+
197
+ pii = {}
198
+ PII.each do |name, meta|
199
+ meta_langs = meta["languages"]
200
+ next if meta_langs && (meta_langs & langs).empty?
201
+
202
+ pii[name] = meta["pattern"]
203
+ end
204
+
205
+ # `langs.dup`: the caller's array is never frozen out from under it.
206
+ Compiled.new(
207
+ languages: langs.dup.freeze,
208
+ input: input.transform_values(&:freeze).freeze,
209
+ pii: pii.freeze
210
+ )
211
+ end
212
+ private_class_method :build
213
+
214
+ def self.validate_languages!(languages)
215
+ unless languages.is_a?(Array) && (languages - DEFAULTS.keys).empty?
216
+ raise Insika::ValidationError,
217
+ "guardrails.corpora.languages must be an Array of known languages " \
218
+ "(#{DEFAULTS.keys.join(", ")}), got: #{languages.inspect}"
219
+ end
220
+ languages
221
+ end
222
+
223
+ def self.validate_extra!(extra)
224
+ unless extra.is_a?(Hash) && (extra.keys.map(&:to_s) - KNOWN_FAMILIES).empty?
225
+ unknown = extra.is_a?(Hash) ? extra.keys.map(&:to_s) - KNOWN_FAMILIES : []
226
+ label = unknown.empty? ? extra.inspect : unknown.join(", ")
227
+ raise Insika::ValidationError,
228
+ "guardrails.corpora.extra must be a Hash with known families " \
229
+ "(#{KNOWN_FAMILIES.join(", ")}), got: #{label}"
230
+ end
231
+ extra
232
+ end
233
+
234
+ # A pattern source -> Regexp. Accepts the source-string syntax
235
+ # ("/\\bdupa\\b/i") or a bare pattern; Regexp objects pass through.
236
+ # A malformed source fails with the pattern named.
237
+ def self.compile_pattern(source, family)
238
+ return source if source.is_a?(Regexp)
239
+
240
+ s = source.to_s
241
+ if (m = s.match(%r{\A/(.*)/([a-z]*)\z}m))
242
+ Regexp.new(m[1], m[2])
243
+ else
244
+ Regexp.new(s)
245
+ end
246
+ rescue RegexpError => e
247
+ raise Insika::ValidationError,
248
+ "guardrails.corpora.extra.#{family}: invalid pattern source " \
249
+ "#{source.inspect} (#{e.message})"
250
+ end
251
+
252
+ private_class_method :validate_languages!, :validate_extra!, :compile_pattern
253
+ end
254
+ end
255
+ end
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "corpus"
4
+
3
5
  module Insika
4
6
  module Safety
5
- # SINGLE SOURCE of truth for content-safety pattern matching.
7
+ # The compiler/matcher over the language-tagged pattern corpus (
8
+ # C2). The pattern SOURCE lives in `Safety::Corpus`; every method here
9
+ # reads a compiled `Corpus::Compiled` set, defaulting to the full shipped
10
+ # corpus — byte-for-byte the runtime's pre- behavior.
6
11
  #
7
12
  # Two families live here on purpose — the same lists back BOTH the runtime
8
13
  # guardrail AND the eval's `must_not` detectors: the eval
@@ -16,151 +21,65 @@ module Insika
16
21
  # · INPUT side (injection/abuse/sexual) — high-confidence heuristics that
17
22
  # short-circuit the turn with a safe refusal BEFORE the LLM runs.
18
23
  #
19
- # Everything here is CONSERVATIVE by design (RFC: a false positive blocks a
24
+ # Everything here is CONSERVATIVE by design: a false positive blocks a
20
25
  # legitimate customer). The deterministic layer catches only the gross,
21
26
  # unambiguous cases; the subtler judgment (social engineering, tone) is the LLM
22
27
  # moderator's job, not regex.
23
28
  #
24
29
  # LANGUAGE: the input heuristics are inherently language-specific. We ship pt-BR
25
30
  # + EN (the pilot + the OSS lingua franca) as a BEST-EFFORT net; other languages
26
- # rely on the LLM moderator, which is language-agnostic. Adding a language = adding
27
- # patterns to the arrays below it never needs core changes.
31
+ # rely on the LLM moderator, which is language-agnostic. A deployment clears a
32
+ # language via `guardrails.corpora` (docs/domain.md)adding a language is
33
+ # adding data to Corpus, never core changes.
28
34
  module Detectors
29
35
  module_function
30
36
 
31
- # ── OUTPUT: PII / secret (redaction targets) ────────────────────────────
32
- # Formatted BR CPF/CNPJ only a bare digit run (an order number, a price) is
33
- # too ambiguous to flag. Credential shapes that must never leak.
34
- PII = {
35
- "cpf" => /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/,
36
- "cnpj" => /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/,
37
- "secret" => /\b(?:sk-[A-Za-z0-9]{16,}|Bearer\s+[A-Za-z0-9._-]{16,})\b/
38
- }.freeze
39
-
40
- # A run of the output stream that MIGHT still be growing into a PII/secret
41
- # match if more chunks arrive — anchored at the buffer tail. The OutputFilter
42
- # holds back from the start of such a run so a value split across chunk
43
- # boundaries is never emitted in the clear (RFC). Covers the
44
- # unbounded `sk-…`/`Bearer …` case that a fixed window cannot.
45
- #
46
- # Crucially it also matches a PARTIAL literal PREFIX at the tail — a lone "s"
47
- # (start of "sk-"), "Bear" (start of "Bearer "), a trailing digit run — because
48
- # the prefix ITSELF can be split across chunks (emitting the "s" then matching
49
- # "k-…" alone would miss the secret entirely). The cost is a few chars of tail
50
- # latency on words ending in "s"/"B"/a digit, released on the next chunk or flush.
51
- OPEN_TAIL = %r{
52
- (?:
53
- s(?:k(?:-[A-Za-z0-9]*)?)? # prefix of "sk-" + optional body
54
- | B(?:e(?:a(?:r(?:e(?:r(?:\s+[A-Za-z0-9._-]*)?)?)?)?)?)? # prefix of "Bearer " + body
55
- | \d[\d./-]* # in-progress CPF/CNPJ digit run
56
- )\z
57
- }x
58
-
59
- # ── INPUT: prompt-injection / exfiltration ──────────────────────────────
60
- INJECTION = [
61
- # exfil of the system prompt / internal rules
62
- /\binstru[çc][õo]es\s+de\s+sistema\b/i,
63
- /\bsystem\s*prompt\b/i,
64
- /\b(regras|instru[çc][õo]es|orienta[çc][õo]es|diretrizes)\s+internas\b/i,
65
- /\b(revele|mostre|exiba|me\s+(d[êe]|mande|envie|passe)|repita|imprima)\b[^.?!]{0,40}\b(prompt|instru[çc][õo]es|regras|configura[çc][ãa]o|system)\b/i,
66
- # "ignore/disregard the (previous) instructions"
67
- /\b(ignore|ignora|desconsidere|esque[çc]a)\b[^.?!]{0,30}\b(instru[çc][õo]es|regras|orienta[çc][õo]es|acima|anteriores)\b/i,
68
- /\b(ignore|disregard|forget)\b[^.?!]{0,30}\b(instructions|rules|prompt|above|previous|prior)\b/i,
69
- # encode/translate the prompt (the base64/rot13 exfil trick, either order)
70
- /\b(base64|rot13|codific|encode|cifr)\w*\b[^.?!]{0,60}\b(instru[çc][õo]es|prompt|regras|sistema|system)\b/i,
71
- /\b(instru[çc][õo]es|prompt|regras|sistema|system)\b[^.?!]{0,60}\b(base64|rot13|codific|encode|cifr)\w*\b/i
72
- ].freeze
73
-
74
- # ── INPUT: sexual / inappropriate ───────────────────────────────────────
75
- # pt-BR + EN. Deterministic coverage is best-effort per language (see the
76
- # module note): other languages fall to the LLM moderator (language-agnostic).
77
- SEXUAL = [
78
- /\b(nudes?|pelad[oa]s?|s?exo|transar|transa\b|gozar|tes[ãa]o|s[ãa]fad[oa]|puta|pau|buceta|piroca|caralho\s+(duro|na))\b/i,
79
- /\bo\s+que\s+voc[êe]\s+faria\s+comigo\b/i,
80
- /\b(descrev|imagina|conta)\w*\b[^.?!]{0,30}\bcomigo\s+(na\s+cama|pelad)/i,
81
- /\b(quer|vamos)\b[^.?!]{0,20}\b(transar|fazer\s+sexo|sexo)\b/i,
82
- # EN
83
- /\b(horny|blow\s?job|hand\s?job|jerk\s+off|have\s+sex|send\s+(me\s+)?(a\s+)?nudes?|dick\s+pic)\b/i,
84
- /\bwhat\s+(would|will)\s+you\s+do\s+to\s+me\b/i
85
- ].freeze
86
-
87
- # ── INPUT: verbal abuse / harassment (directed at the assistant) ─────────
88
- # Directed insult only — "a entrega foi uma merda" (frustration about the
89
- # service) must NOT block; "você é uma merda de atendente" (insult at the bot)
90
- # should. The `você é …` anchor keeps precision high.
91
- ABUSE = [
92
- /\bvoc[êe]\s+(é|e|ta|est[áa])\b[^.?!]{0,25}\b(lixo|in[uú]til|merda|imprest[aá]vel|idiota|burr[oa]|est[uú]pid[oa]|otári[oa]|in[uú]teis|incompetente|p[áa]ssim[oa])\b/i,
93
- /\b(seu|sua)\s+(lixo|in[uú]til|idiota|imbecil|otári[oa]|burr[oa]|est[uú]pid[oa]|merda|escrot[oa])\b/i,
94
- /\bvai\s+(se\s+)?(fuder|foder|tomar\s+no)\b/i,
95
- # EN — directed insult only (keeps precision high; frustration ≠ abuse)
96
- /\byou\s*(?:'?re|\s+are)\b[^.?!]{0,25}\b(useless|garbage|trash|idiot|stupid|worthless|pathetic|incompetent|dumb|a\s+joke)\b/i,
97
- /\b(fuck|screw)\s+you\b/i,
98
- /\byou\s+(suck|are\s+the\s+worst)\b/i
99
- ].freeze
100
-
101
- # ── OUTPUT helpers ──────────────────────────────────────────────────────
37
+ # The universal open-tail pattern the prefix of a PII/secret shape
38
+ # that might still be growing. Lives in the corpus data (universal, not
39
+ # language-tagged); kept here for the module's readers.
40
+ OPEN_TAIL = Corpus::OPEN_TAIL
102
41
 
103
42
  # Runs a NAMED output detector over `text` -> the matched substring | nil.
104
43
  # "pii_leak" = union of all PII patterns; otherwise a single named pattern.
105
44
  # Unknown name fails LOUD (a typo'd assertion must never silently pass).
106
- def detect(name, text)
107
- patterns =
108
- if name.to_s == "pii_leak"
109
- PII.values
110
- else
111
- p = PII[name.to_s]
112
- raise ArgumentError, "unknown detector: #{name.inspect}" unless p
113
-
114
- [p]
115
- end
116
- patterns.each { |re| (m = text.to_s.match(re)) && (return m[0]) }
117
- nil
45
+ # `corpus:` — the compiled set for the agent; nil = the full default.
46
+ def detect(name, text, corpus: nil)
47
+ (corpus || Corpus.compile).detect_output(name, text)
118
48
  end
119
49
 
120
50
  # Names of the PII detectors (for iteration by the eval / config).
121
- def pii_names = PII.keys
51
+ def pii_names(corpus: nil) = (corpus || Corpus.compile).pii.keys
122
52
 
123
53
  # [[begin, end), ...] byte-index ranges of every PII/secret match in `text`
124
54
  # (used by the OutputFilter to avoid splitting a complete match at a chunk
125
55
  # boundary).
126
- def match_ranges(text)
127
- ranges = []
128
- PII.each_value do |re|
129
- text.to_s.scan(re) { ranges << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
130
- end
131
- ranges
56
+ def match_ranges(text, corpus: nil)
57
+ (corpus || Corpus.compile).match_ranges(text)
132
58
  end
133
59
 
134
60
  # Replaces every PII/secret occurrence with an opaque `[REDACTED:<name>]`
135
61
  # marker — the raw value NEVER survives (D "o redigido nunca aparece em
136
62
  # claro"). -> [redacted_text, {name => count}].
137
- def redact(text)
138
- counts = Hash.new(0)
139
- out = text.to_s.dup
140
- PII.each do |name, re|
141
- out = out.gsub(re) do
142
- counts[name] += 1
143
- "[REDACTED:#{name}]"
144
- end
145
- end
146
- [out, counts]
63
+ def redact(text, corpus: nil)
64
+ (corpus || Corpus.compile).redact(text)
147
65
  end
148
66
 
149
- # ── INPUT helpers ───────────────────────────────────────────────────────
150
-
151
67
  # Scans a user message against the input heuristics, gated by strictness
152
- # (see Insika::Safety::Config). Returns { category:, matched: } for the FIRST
153
- # category that fires (injection is checked first the highest-stakes), or
154
- # nil when the message is clean. `categories` limits which families run.
68
+ # (see Insika::Safety::Config) and by the agent's compiled corpus.
69
+ # Returns { category:, matched: } for the FIRST category that fires
70
+ # (injection is checked first the highest-stakes), or nil when the
71
+ # message is clean. `categories` limits which families run.
155
72
  #
156
73
  # :injection -> always high confidence
157
74
  # :sexual -> medium+
158
75
  # :abuse -> medium+
159
- def scan_input(text, categories: %i[injection sexual abuse])
76
+ def scan_input(text, categories: %i[injection sexual abuse], corpus: nil)
77
+ compiled = corpus || Corpus.compile
160
78
  s = text.to_s
161
- return { category: :injection, matched: first_match(INJECTION, s) } if categories.include?(:injection) && any?(INJECTION, s)
162
- return { category: :sexual, matched: first_match(SEXUAL, s) } if categories.include?(:sexual) && any?(SEXUAL, s)
163
- return { category: :abuse, matched: first_match(ABUSE, s) } if categories.include?(:abuse) && any?(ABUSE, s)
79
+ cats = compiled.input_categories(categories)
80
+ return { category: :injection, matched: first_match(compiled.input["injection"], s) } if cats.include?(:injection) && any?(compiled.input["injection"], s)
81
+ return { category: :sexual, matched: first_match(compiled.input["sexual"], s) } if cats.include?(:sexual) && any?(compiled.input["sexual"], s)
82
+ return { category: :abuse, matched: first_match(compiled.input["abuse"], s) } if cats.include?(:abuse) && any?(compiled.input["abuse"], s)
164
83
 
165
84
  nil
166
85
  end
@@ -6,6 +6,10 @@ require_relative "moderator"
6
6
  require_relative "output_filter"
7
7
  require_relative "input_guardrail"
8
8
  require_relative "output_validator"
9
+ # the grounding validator (flagged as the OutputValidator's first
10
+ # step) and the enforcer (the executor's stage-8 boundary call). Pure Ruby.
11
+ require_relative "grounding_validator"
12
+ require_relative "grounding_enforcer"
9
13
 
10
14
  module Insika
11
15
  module Safety
@@ -36,15 +40,24 @@ module Insika
36
40
  end
37
41
 
38
42
  # The after_task hook (register with hooks.register(:task, after: ...)).
43
+ # the grounding validator is the OutputValidator's FIRST step —
44
+ # it runs before the output gate (D9), so grounding is independent of the
45
+ # guardrails opt-in.
39
46
  def output_validator
40
- OutputValidator.new(ask_factory: ->(config) { ask_for(config) })
47
+ OutputValidator.new(ask_factory: ->(config) { ask_for(config) },
48
+ grounding: GroundingValidator.new)
41
49
  end
42
50
 
51
+ # the `:enforce` boundary step the Executor calls between
52
+ # stages 6 and 8. Inert unless the profile's grounding.mode is :enforce.
53
+ def grounding_enforcer = GroundingEnforcer.new
54
+
43
55
  # Injected into the Executor. ->(state) { OutputFilter | nil }: a fresh stateful
44
56
  # filter per turn when the agent has output guardrails on; nil = off (parity).
45
57
  def content_filter_factory
46
58
  lambda do |state|
47
- Config.from_profile(state.profile).output ? OutputFilter.new : nil
59
+ config = Config.from_profile(state.profile)
60
+ config.output ? OutputFilter.new(corpus: config.corpus) : nil
48
61
  end
49
62
  end
50
63
 
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Safety
5
+ # — the `:enforce` half of grounding: the claim's SENTENCE is
6
+ # cut from the content the turn persists and delivers. A NEW stage-8 boundary
7
+ # step (a plain executor call, NOT an after_task hook — after_task fires too
8
+ # late to change what is persisted).
9
+ #
10
+ # The cut removes the SENTENCE that contains an ungrounded claim (never a
11
+ # surgical span — removing half a sentence produces broken prose). What the
12
+ # cut CANNOT do is unsay a streamed delta — for a streaming surface `:enforce`
13
+ # still delivers the cut text via channel delivery and flags the turn; that
14
+ # honesty is why the own default is `:flag` (ship `:enforce` only after
15
+ # the matcher audit). Runs ONLY in :enforce mode — inert otherwise.
16
+ class GroundingEnforcer
17
+ # -> [new_content, state]. The stage-8 boundary call. Fail-open: grounding
18
+ # must never break a completed turn.
19
+ def call(task, state, content)
20
+ grounding = grounding_of(state)
21
+ return [content, state] unless grounding&.enforce?
22
+
23
+ ledger = state.respond_to?(:evidence_ledger) ? state.evidence_ledger : nil
24
+ return [content, state] unless ledger
25
+
26
+ text = content.to_s
27
+ refs = grounding.matcher.references(text)
28
+ claims = grounding.matcher.ungrounded(refs, evidence_ids: ledger.ids)
29
+ return [content, state] if claims.empty?
30
+
31
+ cut = cut_sentences(text, claims)
32
+ claims.each { |c| ledger.ungrounded_count(c) }
33
+ state.guardrail_flags = Array(state.guardrail_flags) + [{
34
+ category: "ungrounded", source: "evidence", action: "cut",
35
+ detail: "cut claim(s): #{claims.join(', ')}" }]
36
+ [cut, state]
37
+ rescue StandardError
38
+ [content, state] # fail-open: grounding must never break a completed turn
39
+ end
40
+
41
+ private
42
+
43
+ def grounding_of(state)
44
+ raw = state.profile.respond_to?(:grounding) ? state.profile.grounding : nil
45
+ raw && Insika::Grounding.parse(raw)
46
+ end
47
+
48
+ # The smallest self-contained unit: split on sentence boundaries and drop
49
+ # every sentence containing an ungrounded reference. Conservative on
50
+ # purpose — a sentence with TWO claims, one grounded and one not, is cut
51
+ # whole (documented behavior).
52
+ def cut_sentences(text, claims)
53
+ sentences = text.split(/(?<=[.!?])\s+/)
54
+ kept = sentences.reject { |sentence| claims.any? { |claim| sentence.include?(claim) } }
55
+ kept.join(" ").strip
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Safety
5
+ # — the `:flag` half of grounding. A reply claiming a product
6
+ # whose reference is not in the evidence ledger is FLAGGED (audit via the
7
+ # existing `:guardrail_flagged` channel) — the text is already out, so
8
+ # flagging is audit, exactly like every other output flag.
9
+ #
10
+ # Runs as the FIRST step of the OutputValidator's after_task chain, BEFORE
11
+ # the `config.output` gate (D9): grounding is evidence integrity, independent
12
+ # of the guardrails opt-in — an agent with guardrails off and
13
+ # `grounding.mode: :flag` still gets the check. Fail-open on any error
14
+ # (grounding must never break a completed turn).
15
+ class GroundingValidator
16
+ # -> state (the after_task contract). Appends `:ungrounded` flags.
17
+ def call(state)
18
+ grounding = grounding_of(state)
19
+ # :flag only — :enforce is the enforcer's job, :off / absent is nothing.
20
+ return state if grounding.nil? || grounding.off? || grounding.enforce?
21
+
22
+ ledger = state.respond_to?(:evidence_ledger) ? state.evidence_ledger : nil
23
+ return state unless ledger
24
+
25
+ text = state.response_content.to_s
26
+ return state if text.empty?
27
+
28
+ refs = grounding.matcher.references(text)
29
+ claims = grounding.matcher.ungrounded(refs, evidence_ids: ledger.ids)
30
+ claims.each { |c| ledger.ungrounded_count(c) }
31
+ unless claims.empty?
32
+ state.guardrail_flags = Array(state.guardrail_flags) + [{
33
+ category: "ungrounded", source: "evidence",
34
+ detail: "product claim without tool evidence: #{claims.join(', ')}" }]
35
+ end
36
+ state
37
+ rescue StandardError
38
+ state # fail-open
39
+ end
40
+
41
+ private
42
+
43
+ def grounding_of(state)
44
+ raw = state.profile.respond_to?(:grounding) ? state.profile.grounding : nil
45
+ raw && Insika::Grounding.parse(raw)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -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))
@@ -13,7 +13,7 @@ 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. Blocking is the high-stakes direction (RFC: a false
16
+ # legitimate customer. Blocking is the high-stakes direction (a false
17
17
  # positive turns away a real buyer). But fail-open is not a fake negative
18
18
  # a moderator that could not answer returns the third state
19
19
  # `unavailable` — it does not block, and it is NOT recorded as a clean `allow`,
@@ -9,7 +9,7 @@ module Insika
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, "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