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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- 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 +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -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/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -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/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -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 +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- 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/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -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 +17 -2
- 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/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- 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 +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- 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 +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -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/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- 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 +16 -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/steer_injector.rb +21 -10
- 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 +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- 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 +6 -3
- 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 +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- 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 +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- 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 +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- 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 +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -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_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -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 +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- 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
|
-
#
|
|
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
|
|
@@ -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)
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
58
|
+
if (m = @buf.match(@corpus.open_tail))
|
|
55
59
|
cut = [cut, m.begin(0)].min
|
|
56
60
|
end
|
|
57
61
|
|
|
58
|
-
|
|
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 =
|
|
76
|
+
redacted, counts = @corpus.redact(chunk)
|
|
73
77
|
counts.each { |name, n| @redaction_counts[name] += n }
|
|
74
78
|
@emitted << redacted
|
|
75
79
|
redacted
|