insika 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- metadata +77 -1
|
@@ -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
|
data/lib/insika/safety/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
27
|
-
#
|
|
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
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|
-
|
|
162
|
-
return { category: :
|
|
163
|
-
return { category: :
|
|
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
|