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.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # WS4: intent routing as DATA. When `AgentProfile#routes` is present, the
5
+ # turn's message is classified into one of the configured routes with a cheap
6
+ # model BEFORE the agent chat is assembled. This class owns the PURE parts —
7
+ # normalizing the route config, generating the classifier prompt from the
8
+ # route descriptions, and parsing the model's answer back into a route — so
9
+ # they are testable without a provider. The ask itself and its usage are the
10
+ # Executor's (a pre-stage call counted in the turn's usage and trace).
11
+ #
12
+ # Config shape (string keys at the persistence boundary — the pack and the
13
+ # Studio store it like any other free-form hash):
14
+ #
15
+ # { "shopping" => "the customer wants to browse products",
16
+ # "order" => { "description" => "asks about an existing order",
17
+ # "delegate" => "order-agent" }, # hand the turn to that agent
18
+ # "human" => { "description" => "the customer is frustrated or asks for a person",
19
+ # "stuck" => true, "message" => "..." }, # end the turn :stuck (WS5)
20
+ # "default" => "shopping", # deterministic fallback
21
+ # "model" => "deepseek-v4-flash" } # the CHEAP classifier
22
+ #
23
+ # `default`/`model`/`provider` are reserved top-level keys; everything else is
24
+ # a route name (single token). A route value is a description String or a Hash
25
+ # with `description` + optional `delegate` (an existing agent the turn is
26
+ # handed to) / `stuck` (the turn ends with the WS5 stuck outcome) / `message`
27
+ # (the consumer-facing lead-in for a stuck route).
28
+ class Routing
29
+ RESERVED = %w[model provider default].freeze
30
+ # A name the classifier can actually answer with: one lowercase token.
31
+ NAME = /\A[a-z0-9][a-z0-9_-]*\z/
32
+ # The turn's usage fields the classifier call can contribute.
33
+ TOKEN_FIELDS = %i[input_tokens output_tokens cached_tokens cache_creation_tokens total_tokens].freeze
34
+
35
+ Entry = Data.define(:name, :description, :delegate, :stuck, :message)
36
+
37
+ class << self
38
+ # -> { entries: [Entry], default: String, model: String, provider: String }
39
+ # | nil (routes absent/empty = routing off). Raises ValidationError on a
40
+ # route name the classifier could never answer with (spaces/symbols) or on
41
+ # a config with zero routes — a routing config that silently routes nothing
42
+ # is a config error, not a quiet off.
43
+ def normalize(routes)
44
+ return nil unless routes.is_a?(Hash) && !routes.empty?
45
+
46
+ bad = routes.keys.map(&:to_s).reject { |k| RESERVED.include?(k) || k.match?(NAME) }
47
+ unless bad.empty?
48
+ raise Insika::ValidationError,
49
+ "route names may not contain spaces or symbols: #{bad.join(', ')}"
50
+ end
51
+
52
+ entries = routes.each_with_object([]) do |(name, value), acc|
53
+ next if RESERVED.include?(name.to_s)
54
+
55
+ cfg = value.is_a?(Hash) ? stringify(value) : { "description" => value.to_s }
56
+ acc << Entry.new(name: name.to_s, description: cfg["description"].to_s,
57
+ delegate: cfg["delegate"].to_s, stuck: cfg["stuck"] == true,
58
+ message: cfg["message"].to_s)
59
+ end
60
+ raise Insika::ValidationError, "routes must define at least one route" if entries.empty?
61
+
62
+ default = routes["default"].to_s
63
+ default = entries.first.name if default.empty?
64
+ unless entries.any? { |e| e.name == default }
65
+ raise Insika::ValidationError,
66
+ "default route '#{default}' is not a configured route"
67
+ end
68
+ { entries: entries, default: default,
69
+ model: routes["model"].to_s, provider: routes["provider"].to_s }
70
+ end
71
+
72
+ # The classifier's instructions, auto-generated from the route descriptions
73
+ # (config-over-code: no per-route prompt file).
74
+ def classifier_prompt(meta)
75
+ lines = meta[:entries].map do |e|
76
+ "-#{e.name}: #{e.description.empty? ? e.name : e.description}"
77
+ end
78
+ <<~PROMPT
79
+ Classify the customer's message into exactly one of these intents.
80
+ Reply with ONLY the intent name — nothing else.
81
+
82
+ #{lines.join("\n")}
83
+ PROMPT
84
+ end
85
+
86
+ # -> Symbol: the route the model named; the DEFAULT when it named nothing
87
+ # usable (prose, punctuation, an unknown name, empty). Deterministic by
88
+ # construction — a chatty or confused classifier can never invent a route.
89
+ def parse(text, meta)
90
+ token = text.to_s.strip.split(/\s+/).first.to_s.downcase
91
+ .delete_suffix(".").delete_suffix(",")
92
+ names = meta[:entries].map(&:name)
93
+ (names.include?(token) ? token : meta[:default]).to_sym
94
+ end
95
+ end
96
+
97
+ def self.stringify(hash)
98
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
99
+ end
100
+ end
101
+ end
@@ -16,8 +16,19 @@ module Insika
16
16
  # moderator: "provider/model"|nil, # LLM moderator model; nil = deterministic only
17
17
  # strictness: "low"|"medium"|"high", # which input categories fire (default medium)
18
18
  # responses: { <category> => "<safe reply>", ... } # per-agent override, see below
19
+ # corpora: { "languages" => ["en"], "extra" => { "abuse" => ["/\\bdupa\\b/i"] } }
19
20
  # }
20
21
  #
22
+ # `corpora` is the removability knob for the shipped
23
+ # pt-BR corpus: `languages` filters the shipped families (nil = all,
24
+ # [] = none, ["en"] = the EN-only corpus — dropping the pt-BR input
25
+ # heuristics AND the CPF/CNPJ output redaction, a documented consequence),
26
+ # `extra` adds source-string patterns per family. Absent = the full
27
+ # shipped default, byte-for-byte today's behavior (parity). Validation
28
+ # fails loud (Insika::ValidationError naming the value): the doctor's
29
+ # guardrail-corpora check compiles every declaration, so a typo'd language
30
+ # surfaces at boot — `insika doctor` exits non-zero — never mid-turn.
31
+ #
21
32
  # `responses` is the CONFIGURATION-OVER-CONVENTION knob. The engine
22
33
  # ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
23
34
  # OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
@@ -37,14 +48,18 @@ module Insika
37
48
 
38
49
  DEFAULT_STRICTNESS = :medium
39
50
 
40
- attr_reader :input, :output, :moderator, :strictness, :responses
51
+ attr_reader :input, :output, :moderator, :strictness, :responses, :corpora
41
52
 
42
- def initialize(input:, output:, moderator:, strictness:, responses: {})
53
+ def initialize(input:, output:, moderator:, strictness:, responses: {}, corpora: nil)
43
54
  @input = input
44
55
  @output = output
45
56
  @moderator = moderator
46
57
  @strictness = strictness
47
58
  @responses = responses # { "category" => "safe reply" }, agent override map
59
+ @corpora = corpora # { "languages" => [...]?, "extra" => {...}? } | nil (nil = the shipped default)
60
+ # Built ONCE at construction: the compiled corpus the whole turn reads.
61
+ @corpus = Corpus.compile(languages: corpora && corpora["languages"],
62
+ extra: (corpora && corpora["extra"]) || {})
48
63
  end
49
64
 
50
65
  # Builds a Config from a profile. A nil/empty `guardrails` -> the
@@ -58,10 +73,22 @@ module Insika
58
73
  output: bool(h.fetch(:output, true)),
59
74
  moderator: presence(h[:moderator]),
60
75
  strictness: normalize_strictness(h[:strictness]),
61
- responses: normalize_responses(h[:responses])
76
+ responses: normalize_responses(h[:responses]),
77
+ corpora: normalize_corpora(h[:corpora])
62
78
  )
63
79
  end
64
80
 
81
+ # The compiled corpus for this agent — never nil: absent
82
+ # corpora -> the full shipped default.
83
+ def corpus = @corpus
84
+
85
+ # The resolved corpus languages (for the doctor's enumeration, C3):
86
+ # nil corpora (or nil languages) = ALL shipped languages.
87
+ def corpus_languages
88
+ langs = @corpora && @corpora["languages"]
89
+ langs.nil? ? Corpus::DEFAULTS.keys : langs
90
+ end
91
+
65
92
  # Input categories the deterministic scan should run, per strictness.
66
93
  def input_categories = STRICTNESS_CATEGORIES.fetch(@strictness, STRICTNESS_CATEGORIES[DEFAULT_STRICTNESS])
67
94
 
@@ -103,7 +130,20 @@ module Insika
103
130
  end
104
131
  end
105
132
 
106
- private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses
133
+ # `corpora` -> { "languages" => ...?, "extra" => ...? } with STRING keys |
134
+ # nil when absent/empty. Validation of the values happens in
135
+ # Corpus.compile (from_hash fails loud with the value named).
136
+ def self.normalize_corpora(v)
137
+ return nil unless v.is_a?(Hash)
138
+
139
+ h = v.each_with_object({}) { |(k, val), acc| acc[k.to_s] = val }
140
+ return nil if h["languages"].nil? && h["extra"].nil?
141
+
142
+ { "languages" => h["languages"], "extra" => h["extra"] }
143
+ end
144
+
145
+ private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses,
146
+ :normalize_corpora
107
147
  end
108
148
  end
109
149
  end
@@ -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