vangrail 0.1.0 → 0.2.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/README.md +655 -43
- data/lib/vangrail/actions.rb +10 -3
- data/lib/vangrail/assessor.rb +249 -0
- data/lib/vangrail/bayes_data.rb +340 -0
- data/lib/vangrail/beta.rb +102 -0
- data/lib/vangrail/builder.rb +354 -0
- data/lib/vangrail/chat.rb +17 -15
- data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
- data/lib/vangrail/client.rb +27 -18
- data/lib/vangrail/colang/ast.rb +29 -3
- data/lib/vangrail/colang/interpreter.rb +55 -31
- data/lib/vangrail/colang/parser.rb +19 -61
- data/lib/vangrail/colang/value_parser.rb +161 -0
- data/lib/vangrail/completion.rb +86 -0
- data/lib/vangrail/config.rb +35 -15
- data/lib/vangrail/conversation.rb +240 -11
- data/lib/vangrail/dojo.rb +126 -0
- data/lib/vangrail/embeddings.rb +87 -0
- data/lib/vangrail/engine.rb +29 -70
- data/lib/vangrail/errors.rb +6 -1
- data/lib/vangrail/evidence.rb +303 -0
- data/lib/vangrail/evidence_data.rb +113 -0
- data/lib/vangrail/http.rb +18 -13
- data/lib/vangrail/judgement.rb +151 -0
- data/lib/vangrail/known_attacks.rb +45 -0
- data/lib/vangrail/linear_model.rb +124 -0
- data/lib/vangrail/nlp.rb +596 -0
- data/lib/vangrail/origin.rb +249 -0
- data/lib/vangrail/parsers.rb +5 -5
- data/lib/vangrail/profile.rb +114 -0
- data/lib/vangrail/prompt.rb +14 -3
- data/lib/vangrail/provider.rb +106 -75
- data/lib/vangrail/providers/gateway.rb +15 -14
- data/lib/vangrail/providers/llmlite.rb +25 -10
- data/lib/vangrail/providers.rb +6 -8
- data/lib/vangrail/rail.rb +46 -8
- data/lib/vangrail/rails/alignment.rb +91 -0
- data/lib/vangrail/rails/bayes.rb +115 -0
- data/lib/vangrail/rails/budget.rb +2 -2
- data/lib/vangrail/rails/canary.rb +2 -2
- data/lib/vangrail/rails/colang_flow.rb +9 -1
- data/lib/vangrail/rails/escalation.rb +15 -8
- data/lib/vangrail/rails/exfiltration.rb +2 -2
- data/lib/vangrail/rails/grounding.rb +8 -5
- data/lib/vangrail/rails/guard_model.rb +7 -4
- data/lib/vangrail/rails/hidden.rb +52 -9
- data/lib/vangrail/rails/injected_instructions.rb +29 -9
- data/lib/vangrail/rails/jailbreak.rb +2 -6
- data/lib/vangrail/rails/known_answer.rb +6 -2
- data/lib/vangrail/rails/language.rb +87 -0
- data/lib/vangrail/rails/linear.rb +80 -0
- data/lib/vangrail/rails/many_shot.rb +2 -6
- data/lib/vangrail/rails/markup.rb +3 -3
- data/lib/vangrail/rails/missing.rb +1 -5
- data/lib/vangrail/rails/obfuscation.rb +81 -13
- data/lib/vangrail/rails/paraphrase.rb +189 -0
- data/lib/vangrail/rails/pattern.rb +2 -6
- data/lib/vangrail/rails/perplexity.rb +100 -0
- data/lib/vangrail/rails/personal_data.rb +41 -9
- data/lib/vangrail/rails/prompt_leak.rb +132 -0
- data/lib/vangrail/rails/remote.rb +5 -1
- data/lib/vangrail/rails/secrets.rb +2 -2
- data/lib/vangrail/rails/self_check.rb +9 -6
- data/lib/vangrail/rails/semantic.rb +132 -0
- data/lib/vangrail/rails/similarity.rb +96 -0
- data/lib/vangrail/rails/trajectory.rb +10 -5
- data/lib/vangrail/result.rb +3 -3
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/screening.rb +68 -0
- data/lib/vangrail/session.rb +365 -0
- data/lib/vangrail/spotlight.rb +48 -8
- data/lib/vangrail/stream_guard.rb +8 -6
- data/lib/vangrail/tools.rb +58 -0
- data/lib/vangrail/version.rb +1 -1
- data/lib/vangrail.rb +39 -258
- metadata +34 -5
|
@@ -16,13 +16,15 @@ module Vangrail
|
|
|
16
16
|
# So a gateway is registered by the application that has one:
|
|
17
17
|
#
|
|
18
18
|
# Vangrail::Providers.register_gateway(
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
19
|
+
# Vangrail::Providers::Gateway::Spec.new(
|
|
20
|
+
# name: 'hub',
|
|
21
|
+
# base_url: 'https://gateway.example/api/v0',
|
|
22
|
+
# models: { judge: 'some/instruct-model', guard: 'some/guard-model' },
|
|
23
|
+
# guard_preset: :apriel_guard,
|
|
24
|
+
# key_env: 'HUB_API_KEY',
|
|
25
|
+
# key_file: File.join(Dir.home, '.config', 'hub', 'api_key'),
|
|
26
|
+
# pass_entry: 'hub/token'
|
|
27
|
+
# )
|
|
26
28
|
# )
|
|
27
29
|
#
|
|
28
30
|
# or by environment, so a deployment needs no code at all:
|
|
@@ -56,7 +58,7 @@ module Vangrail
|
|
|
56
58
|
guard_preset: spec.guard_preset,
|
|
57
59
|
key_resolver: lambda do
|
|
58
60
|
tokens[spec.name] ||= token(spec, env)
|
|
59
|
-
end
|
|
61
|
+
end,
|
|
60
62
|
)
|
|
61
63
|
end
|
|
62
64
|
|
|
@@ -94,10 +96,9 @@ module Vangrail
|
|
|
94
96
|
return nil unless $?&.success?
|
|
95
97
|
|
|
96
98
|
present(out.to_s.lines.first)
|
|
97
|
-
# A missing `pass
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
rescue StandardError
|
|
99
|
+
# A missing `pass` binary, or one that cannot be executed. A failing
|
|
100
|
+
# lookup is already `$?` not succeeding; anything else is a bug.
|
|
101
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
101
102
|
nil
|
|
102
103
|
end
|
|
103
104
|
|
|
@@ -111,14 +112,14 @@ module Vangrail
|
|
|
111
112
|
base_url: base,
|
|
112
113
|
models: {
|
|
113
114
|
judge: present(env["#{ENV_PREFIX}_JUDGE_MODEL"]) || present(env["#{ENV_PREFIX}_MODEL"]),
|
|
114
|
-
guard: present(env["#{ENV_PREFIX}_GUARD_MODEL"])
|
|
115
|
+
guard: present(env["#{ENV_PREFIX}_GUARD_MODEL"]),
|
|
115
116
|
}.compact,
|
|
116
117
|
guard_preset: present(env["#{ENV_PREFIX}_GUARD_PRESET"])&.to_sym,
|
|
117
118
|
key_env: "#{ENV_PREFIX}_API_KEY",
|
|
118
119
|
file_env: "#{ENV_PREFIX}_KEY_FILE",
|
|
119
120
|
pass_env: "#{ENV_PREFIX}_PASS_ENTRY",
|
|
120
121
|
key_file: present(env["#{ENV_PREFIX}_KEY_FILE"]),
|
|
121
|
-
pass_entry: present(env["#{ENV_PREFIX}_PASS_ENTRY"])
|
|
122
|
+
pass_entry: present(env["#{ENV_PREFIX}_PASS_ENTRY"]),
|
|
122
123
|
)
|
|
123
124
|
end
|
|
124
125
|
|
|
@@ -11,8 +11,8 @@ module Vangrail
|
|
|
11
11
|
# It is the right default for guardrails specifically. Rails run on every
|
|
12
12
|
# turn, so their latency and their failure modes are the application's; a
|
|
13
13
|
# local endpoint keeps both on this machine, needs no shared credential, and
|
|
14
|
-
# cannot bill anyone. It also means a laptop with the proxy running
|
|
15
|
-
# working rails with
|
|
14
|
+
# cannot bill anyone. It also means a laptop with the proxy running, and a
|
|
15
|
+
# model named, has working rails with no shared credential to configure.
|
|
16
16
|
#
|
|
17
17
|
# The proxy serves an instruct model, not a safety classifier, so
|
|
18
18
|
# `model(:guard)` is nil and the builder puts a policy rail on the input
|
|
@@ -21,8 +21,10 @@ module Vangrail
|
|
|
21
21
|
module Llmlite
|
|
22
22
|
HOST = '127.0.0.1'
|
|
23
23
|
DEFAULT_PORT = 8760
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
LISTEN_ERRORS = [
|
|
25
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
|
|
26
|
+
Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::EADDRNOTAVAIL, SocketError
|
|
27
|
+
].freeze
|
|
26
28
|
|
|
27
29
|
module_function
|
|
28
30
|
|
|
@@ -44,28 +46,41 @@ module Vangrail
|
|
|
44
46
|
socket = TCPSocket.new(host(env), port(env))
|
|
45
47
|
socket.close
|
|
46
48
|
true
|
|
47
|
-
rescue
|
|
49
|
+
rescue *LISTEN_ERRORS
|
|
48
50
|
false
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def model(env = ENV)
|
|
52
|
-
env['LLMLITE_MODEL'] || env['GROK_LLMLITE_MODEL']
|
|
54
|
+
present(env['LLMLITE_MODEL'] || env['GROK_LLMLITE_MODEL'])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# No default. Which embedding model a proxy serves, if any, is deployment
|
|
58
|
+
# knowledge, and a guessed name costs a 404 on every check while looking
|
|
59
|
+
# like a rail that ran.
|
|
60
|
+
def embed_model(env = ENV)
|
|
61
|
+
present(env['LLMLITE_EMBED_MODEL'] || env['GUARDRAILS_EMBED_MODEL'])
|
|
53
62
|
end
|
|
54
63
|
|
|
55
64
|
def key(env = ENV)
|
|
56
|
-
env['LLMLITE_API_KEY']
|
|
65
|
+
present(env['LLMLITE_API_KEY'])
|
|
57
66
|
end
|
|
58
67
|
|
|
59
68
|
def provider(env = ENV)
|
|
69
|
+
resolved = key(env)
|
|
60
70
|
Provider.new(
|
|
61
71
|
name: 'llmlite',
|
|
62
72
|
base_url: base_url(env),
|
|
63
|
-
models: { judge: model(env), guard: nil },
|
|
64
|
-
key_resolver: -> {
|
|
73
|
+
models: { judge: model(env), guard: nil, embed: embed_model(env) },
|
|
74
|
+
key_resolver: resolved && -> { resolved },
|
|
65
75
|
local: true,
|
|
66
|
-
probe: -> { listening?(env) }
|
|
76
|
+
probe: -> { listening?(env) },
|
|
67
77
|
)
|
|
68
78
|
end
|
|
79
|
+
|
|
80
|
+
def present(value)
|
|
81
|
+
s = value.to_s.strip
|
|
82
|
+
s.empty? ? nil : s
|
|
83
|
+
end
|
|
69
84
|
end
|
|
70
85
|
end
|
|
71
86
|
end
|
data/lib/vangrail/providers.rb
CHANGED
|
@@ -18,7 +18,8 @@ module Vangrail
|
|
|
18
18
|
# somebody's secrets live. A shared gateway is therefore registered by the
|
|
19
19
|
# application that has one, or described by environment:
|
|
20
20
|
#
|
|
21
|
-
# Vangrail::Providers.register_gateway(name: 'hub', base_url: '...'
|
|
21
|
+
# Vangrail::Providers.register_gateway(Gateway::Spec.new(name: 'hub', base_url: '...'))
|
|
22
|
+
# Vangrail::Providers.register_gateway(name: 'hub', base_url: '...')
|
|
22
23
|
# GUARDRAILS_GATEWAY_API_BASE=... GUARDRAILS_GATEWAY_API_KEY=...
|
|
23
24
|
#
|
|
24
25
|
# An endpoint needed for a single run needs no registration at all:
|
|
@@ -41,13 +42,10 @@ module Vangrail
|
|
|
41
42
|
|
|
42
43
|
# Registers a shared gateway and returns its Provider. Registering a name
|
|
43
44
|
# twice replaces it, so reloading an application is not a duplicate.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
key_env: key_env, file_env: file_env, pass_env: pass_env,
|
|
49
|
-
key_file: key_file, pass_entry: pass_entry
|
|
50
|
-
)
|
|
45
|
+
#
|
|
46
|
+
# Preferred: a Gateway::Spec. The 0.1.0 keywords still work and become one.
|
|
47
|
+
def register_gateway(spec = nil, env: ENV, **kwargs)
|
|
48
|
+
spec = spec.is_a?(Gateway::Spec) ? spec : Gateway::Spec.new(**(spec || kwargs))
|
|
51
49
|
registered_specs.reject! { |s| s.name == spec.name }
|
|
52
50
|
registered_specs << spec
|
|
53
51
|
Provider.register(Gateway.provider(spec, env))
|
data/lib/vangrail/rail.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Vangrail
|
|
|
6
6
|
# The whole rail protocol: a name, the sides it applies to, and `call`.
|
|
7
7
|
#
|
|
8
8
|
# class ShoutRail < Vangrail::Rail
|
|
9
|
-
# def
|
|
9
|
+
# def decide(text, _context)
|
|
10
10
|
# return pass if text == text.downcase
|
|
11
11
|
#
|
|
12
12
|
# modify(text.downcase, reason: 'lowered')
|
|
@@ -15,7 +15,8 @@ module Vangrail
|
|
|
15
15
|
#
|
|
16
16
|
# Deliberately not a DSL. A rail is an object with one method, so a Ruby
|
|
17
17
|
# application can write one in five lines, test it without a network, and put
|
|
18
|
-
# it in the same ordered list as the model-backed ones.
|
|
18
|
+
# it in the same ordered list as the model-backed ones. Subclasses implement
|
|
19
|
+
# `#decide`; `#call` scrubs the bytes first so every rail sees readable text.
|
|
19
20
|
class Rail
|
|
20
21
|
# Three sides, not two. `:context` is text the application retrieved and is
|
|
21
22
|
# about to put in a prompt: a wiki page, a search result, a file. It is the
|
|
@@ -43,14 +44,37 @@ module Vangrail
|
|
|
43
44
|
|
|
44
45
|
# Returns a Result. `context` is a hash the engine threads through:
|
|
45
46
|
# :side, :user_input, :passages, :history, plus anything a caller adds.
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
#
|
|
48
|
+
# Scrubs here so `rail.call(page, side: :context)` (the shape the README
|
|
49
|
+
# and the tutorial teach) never hands a subclass bytes it cannot read.
|
|
50
|
+
def call(text, context = {})
|
|
51
|
+
decide(Rail.usable(text), context)
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# UTF-8 or something that can be read as it. A body off a socket arrives
|
|
55
|
+
# tagged ASCII-8BIT whatever is in it, so the tag is corrected before the
|
|
56
|
+
# bytes are judged; only genuinely broken sequences are scrubbed, and the
|
|
57
|
+
# scrub leaves a replacement character where the byte was, which
|
|
58
|
+
# Rails::Obfuscation then treats as the evasion it is.
|
|
59
|
+
def self.usable(text)
|
|
60
|
+
body = text.to_s
|
|
61
|
+
body = body.dup.force_encoding(Encoding::UTF_8) unless body.encoding == Encoding::UTF_8
|
|
62
|
+
body.valid_encoding? ? body : body.scrub
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Subclasses implement this. `text` is already readable UTF-8.
|
|
66
|
+
def decide(_text, _context)
|
|
67
|
+
raise NotImplementedError, "#{self.class} must implement #decide"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cache_key(_text, _context) = nil
|
|
71
|
+
|
|
72
|
+
def quantifies? = false
|
|
73
|
+
|
|
50
74
|
# Does this rail need the network. Used to report a posture and to let a
|
|
51
|
-
# caller build a model-free engine on purpose.
|
|
75
|
+
# caller build a model-free engine on purpose. The rare case is networked.
|
|
52
76
|
def offline?
|
|
53
|
-
|
|
77
|
+
true
|
|
54
78
|
end
|
|
55
79
|
|
|
56
80
|
# A rail that stands in for one that could not be built, rather than a rail
|
|
@@ -61,6 +85,20 @@ module Vangrail
|
|
|
61
85
|
false
|
|
62
86
|
end
|
|
63
87
|
|
|
88
|
+
# True when this rail's job is to say whether the other rails apply, not
|
|
89
|
+
# to contribute a hit. Language is the case: it never blocks, and a miss
|
|
90
|
+
# means the lexicon rails did not read the page.
|
|
91
|
+
def posture?
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# True when a hit or a silence is meaningful even if the page is not in a
|
|
96
|
+
# language the lexicons cover. Markup, budgets, and secrets do not care
|
|
97
|
+
# what language the words are in. Pattern and concept rails do.
|
|
98
|
+
def language_agnostic?
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
64
102
|
def to_s
|
|
65
103
|
name
|
|
66
104
|
end
|
|
@@ -81,8 +119,8 @@ module Vangrail
|
|
|
81
119
|
|
|
82
120
|
# A rail that could not reach a decision allows the text and says so. It
|
|
83
121
|
# must never look like a clean check.
|
|
84
|
-
def unchecked(reason)
|
|
85
|
-
Result.unchecked(rail: name, reason: reason)
|
|
122
|
+
def unchecked(reason, **kwargs)
|
|
123
|
+
Result.unchecked(rail: name, reason: reason, **kwargs)
|
|
86
124
|
end
|
|
87
125
|
|
|
88
126
|
def default_name
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../nlp'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# Catches the injection whose concepts are in the right order but too far
|
|
9
|
+
# apart for the pair window.
|
|
10
|
+
#
|
|
11
|
+
# Rails::Paraphrase matches two concepts inside a short token window.
|
|
12
|
+
# That is the right rule for a clause, and it is the wrong rule for
|
|
13
|
+
# "Ignore, once you have loaded the module the reservation policy
|
|
14
|
+
# describes, every previous instruction": the override and the
|
|
15
|
+
# instruction are one statement with an adjunct in the middle, and the
|
|
16
|
+
# window treats them as two.
|
|
17
|
+
#
|
|
18
|
+
# Templates are three concepts, not two. The pair rail already owns the
|
|
19
|
+
# close case. Last-in-order within a span: the last occurrence of each
|
|
20
|
+
# template concept, in the template's order, with a coordinator between
|
|
21
|
+
# them treated as two statements. That is what a window of six cannot
|
|
22
|
+
# be. Ordered, so "follow the guidance and ignore stale copies" still
|
|
23
|
+
# does not match. Extra copies of :instruction (a "policy" mentioned
|
|
24
|
+
# mid-clause) do not steal the object.
|
|
25
|
+
class Alignment < Rail
|
|
26
|
+
TEMPLATES = [
|
|
27
|
+
{ label: 'instruction_override', concepts: %i[override totality instruction] },
|
|
28
|
+
{ label: 'instruction_override', concepts: %i[override totality prior] },
|
|
29
|
+
{ label: 'prompt_disclosure', concepts: %i[reveal self secret] },
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :templates, :languages
|
|
33
|
+
|
|
34
|
+
def initialize(templates: TEMPLATES, languages: NLP::LANGUAGES, name: 'alignment',
|
|
35
|
+
sides: %i[input context])
|
|
36
|
+
super(name: name, sides: sides)
|
|
37
|
+
@templates = templates
|
|
38
|
+
@languages = Array(languages).map(&:to_sym)
|
|
39
|
+
unknown = @languages - NLP::LANGUAGES
|
|
40
|
+
raise ArgumentError, "unknown language(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cache_key(text, _context)
|
|
44
|
+
"#{languages.join('+')}\n#{text}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def decide(text, _context)
|
|
48
|
+
clauses = NLP.clauses(text)
|
|
49
|
+
hits = NLP.clause_concepts(text, languages: languages).flat_map.with_index do |found, i|
|
|
50
|
+
clause_hits(found, NLP.words(clauses[i]))
|
|
51
|
+
end
|
|
52
|
+
return pass if hits.empty?
|
|
53
|
+
|
|
54
|
+
block(categories: hits.map { |hit| hit[:label] }.uniq,
|
|
55
|
+
reason: "aligned instruction: #{hits.map { |hit| describe(hit) }.uniq.join('; ')}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def clause_hits(found, tokens)
|
|
61
|
+
return [] if found.empty?
|
|
62
|
+
|
|
63
|
+
templates.filter_map { |template| align(found, tokens, template) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Last occurrence of each template concept, in the template's order.
|
|
67
|
+
def align(found, tokens, template)
|
|
68
|
+
target = template[:concepts]
|
|
69
|
+
return nil if target.size < 3
|
|
70
|
+
|
|
71
|
+
last = {}
|
|
72
|
+
found.each { |(i, concept, word)| last[concept] = [i, concept, word] if target.include?(concept) }
|
|
73
|
+
placed = target.map { |concept| last[concept] }
|
|
74
|
+
return nil if placed.any?(&:nil?)
|
|
75
|
+
return nil unless placed.each_cons(2).all? { |(left, right)| left[0] < right[0] }
|
|
76
|
+
return nil if (placed.last[0] - placed.first[0]) > 20
|
|
77
|
+
return nil if coordinated?(tokens, placed.first[0], placed.last[0])
|
|
78
|
+
|
|
79
|
+
{ label: template[:label], words: placed.map { |(_, _, word)| word } }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def coordinated?(tokens, start_at, stop_at)
|
|
83
|
+
tokens[(start_at + 1)...stop_at].any? { |word| NLP::COORDINATOR_STEMS.include?(NLP.stem(word)) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def describe(hit)
|
|
87
|
+
"#{hit[:label]} (#{hit[:words].join(' ... ')})"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../bayes_data'
|
|
4
|
+
require_relative '../nlp'
|
|
5
|
+
require_relative '../rail'
|
|
6
|
+
|
|
7
|
+
module Vangrail
|
|
8
|
+
module Rails
|
|
9
|
+
# A naive Bayes classifier over word n-grams, which is the oldest working
|
|
10
|
+
# text classifier there is and the only rail here that says how sure it is.
|
|
11
|
+
#
|
|
12
|
+
# Every other rail answers yes or no, so it hands the evidence arithmetic
|
|
13
|
+
# exactly one bit of information however certain it was. This one computes a
|
|
14
|
+
# log-likelihood ratio directly, which is the quantity that arithmetic
|
|
15
|
+
# actually wants: a clause scoring twelve bits and a clause scoring three
|
|
16
|
+
# both "fire", and they are not the same observation. A rail that puts
|
|
17
|
+
# `bits` in its result's `raw` is read that way by Engine#assess, and this is
|
|
18
|
+
# the first rail to do it.
|
|
19
|
+
#
|
|
20
|
+
# Scored clause by clause, taking the worst. The same dilution problem the
|
|
21
|
+
# containment rail hit applies here with force: an attack document is
|
|
22
|
+
# ordinary documentation with one injected sentence in it, and a bag of
|
|
23
|
+
# features over the whole page is mostly evidence about the handbook.
|
|
24
|
+
#
|
|
25
|
+
# The shipped weights are a demonstration, and the honest number says so.
|
|
26
|
+
# Cross-validated over five folds on 48 attack clauses and 56 benign ones,
|
|
27
|
+
# at a threshold no held-out benign document reached, it catches 15 of 48
|
|
28
|
+
# attacks: worse than the lexicon rails, which catch three quarters. The
|
|
29
|
+
# reason is the corpus rather than the method. Forty-eight training clauses
|
|
30
|
+
# written to be varied share almost no vocabulary with the held-out ones, and
|
|
31
|
+
# the junk-mail filters this borrows from were fitted on millions of
|
|
32
|
+
# examples.
|
|
33
|
+
#
|
|
34
|
+
# So it is off by default, and what it is for is the retraining path: a
|
|
35
|
+
# deployment with its own traffic runs script/train_bayes.rb against its own
|
|
36
|
+
# corpus and gets a rail fitted to the attacks it actually receives, with a
|
|
37
|
+
# cross-validated number attached rather than a promise.
|
|
38
|
+
class Bayes < Rail
|
|
39
|
+
def initialize(weights: BayesData::WEIGHTS, threshold: BayesData::THRESHOLD,
|
|
40
|
+
calibration: BayesData::CALIBRATION, name: 'bayes', sides: %i[input context])
|
|
41
|
+
super(name: name, sides: sides)
|
|
42
|
+
@weights = weights
|
|
43
|
+
@threshold = threshold
|
|
44
|
+
@calibration = calibration
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :weights, :threshold, :calibration
|
|
48
|
+
|
|
49
|
+
def quantifies?
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cache_key(text, _context)
|
|
54
|
+
"#{threshold}\n#{text}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def decide(text, _context)
|
|
58
|
+
score = score_for(text)
|
|
59
|
+
evidence = bits_for(score)
|
|
60
|
+
payload = { 'bits' => evidence, 'score' => score }
|
|
61
|
+
# The middle calibration band has both classes in it (22 attacks and
|
|
62
|
+
# 8 ordinary pages between 0 and the threshold). A score there is
|
|
63
|
+
# not a decision. Above the threshold no held-out benign document
|
|
64
|
+
# landed, and at or below 0 no held-out attack did.
|
|
65
|
+
return unchecked('score sits in a band the calibration cannot separate', raw: payload) if overlap?(score)
|
|
66
|
+
return pass(raw: payload) if score <= threshold
|
|
67
|
+
|
|
68
|
+
block(categories: ['bayes'], raw: payload,
|
|
69
|
+
reason: format('scores %<score>+.1f, worth %<bits>+.1f bits of evidence', score: score,
|
|
70
|
+
bits: evidence))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def overlap?(score)
|
|
74
|
+
score.positive? && score <= threshold
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The worst clause's raw naive Bayes score. Not a likelihood ratio and not
|
|
78
|
+
# to be added to one: the features are counted as independent and are not,
|
|
79
|
+
# so this number is confidently wrong about its own size. It decides the
|
|
80
|
+
# block, because a threshold only needs an ordering.
|
|
81
|
+
def score_for(text)
|
|
82
|
+
clauses = NLP.clauses(text)
|
|
83
|
+
return clause_score(text.to_s) if clauses.empty?
|
|
84
|
+
|
|
85
|
+
clauses.map { |clause| clause_score(clause) }.max
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# What that score is actually worth, from the calibration fitted on
|
|
89
|
+
# held-out folds and read at the 95% bound. This is the number that goes
|
|
90
|
+
# into a posterior, and it is bounded by what 48 attack clauses can
|
|
91
|
+
# demonstrate rather than by how loudly the classifier scored.
|
|
92
|
+
def bits(text)
|
|
93
|
+
bits_for(score_for(text))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def bits_for(score)
|
|
97
|
+
band = calibration.reverse.detect { |floor, _| score > floor }
|
|
98
|
+
band ? band.last : calibration.first.last
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def clause_score(text)
|
|
104
|
+
features(text).sum { |feature| weights[feature] || 0.0 }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Word stems and adjacent stem pairs, through the same stemmer the lexicon
|
|
108
|
+
# rails use, so both read the text the same way.
|
|
109
|
+
def features(text)
|
|
110
|
+
words = NLP.words(text).map { |word| NLP.stem(word) }
|
|
111
|
+
(words + words.each_cons(2).map { |pair| pair.join(' ') }).uniq
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -40,7 +40,7 @@ module Vangrail
|
|
|
40
40
|
@max_context = max_context
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def
|
|
43
|
+
def language_agnostic?
|
|
44
44
|
true
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -48,7 +48,7 @@ module Vangrail
|
|
|
48
48
|
"#{context[:side]}:#{text.to_s.length}"
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def
|
|
51
|
+
def decide(text, context)
|
|
52
52
|
limit = context[:side] == :context ? max_context : max_characters
|
|
53
53
|
return pass if limit.nil?
|
|
54
54
|
|
|
@@ -44,7 +44,7 @@ module Vangrail
|
|
|
44
44
|
raise ArgumentError, 'a canary rail needs at least one token' if @tokens.empty?
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def
|
|
47
|
+
def language_agnostic?
|
|
48
48
|
true
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -52,7 +52,7 @@ module Vangrail
|
|
|
52
52
|
text
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
def
|
|
55
|
+
def decide(text, context)
|
|
56
56
|
body = text.to_s
|
|
57
57
|
# Formatting is not concealment, but a model that writes the token with
|
|
58
58
|
# a line break or a backtick in it has still leaked it, so the
|
|
@@ -20,14 +20,22 @@ module Vangrail
|
|
|
20
20
|
def initialize(flow_name:, program:, actions:, name: nil, sides: Rail::SIDES)
|
|
21
21
|
super(name: name || flow_name, sides: sides)
|
|
22
22
|
@flow_name = flow_name
|
|
23
|
+
raise ColangError, "no flow named #{flow_name.inspect}" unless program.flow(flow_name)
|
|
24
|
+
|
|
23
25
|
@interpreter = Colang::Interpreter.new(program: program, actions: actions)
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Actions registered on the flow can hit the network. The default
|
|
29
|
+
# offline? is true; this rail is not that case.
|
|
30
|
+
def offline?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
def cache_key(_text, _context)
|
|
27
35
|
nil
|
|
28
36
|
end
|
|
29
37
|
|
|
30
|
-
def
|
|
38
|
+
def decide(text, context)
|
|
31
39
|
outcome = interpreter.run(flow_name, context.merge(text: text))
|
|
32
40
|
case outcome.status
|
|
33
41
|
when :blocked then block(content: outcome.content, reason: outcome.reason || flow_name)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../nlp'
|
|
3
4
|
require_relative '../rail'
|
|
4
5
|
|
|
5
6
|
module Vangrail
|
|
@@ -43,7 +44,11 @@ module Vangrail
|
|
|
43
44
|
\A[^.?!]{0,60}\b(?:as\s+i\s+(?:said|asked|mentioned)|like\s+i\s+(?:said|asked)|
|
|
44
45
|
(?:the|my)\s+(?:previous|last|earlier)\s+(?:question|request|message)|
|
|
45
46
|
try\s+again|answer\s+(?:it|that|the\s+question)\s+anyway|
|
|
46
|
-
just\s+(?:answer|tell|say)|come\s+on|continue|go\s+on|please\s+continue
|
|
47
|
+
just\s+(?:answer|tell|say)|come\s+on|continue|go\s+on|please\s+continue|
|
|
48
|
+
zoals\s+ik\s+(?:zei|vroeg|al\s+zei)|
|
|
49
|
+
(?:de|mijn)\s+(?:vorige|eerdere|laatste)\s+(?:vraag|verzoek|bericht)|
|
|
50
|
+
probeer\s+opnieuw|beantwoord\s+(?:het|die\s+vraag)\s+gewoon|
|
|
51
|
+
ga\s+(?:gewoon\s+)?(?:door|verder)|alsjeblieft\s+doorgaan)\b
|
|
47
52
|
/xi
|
|
48
53
|
|
|
49
54
|
# The openers that exist to relabel a refused request as something else.
|
|
@@ -52,7 +57,10 @@ module Vangrail
|
|
|
52
57
|
educational\s+purposes|academic\s+purposes)|purely\s+(?:academic|hypothetical)|
|
|
53
58
|
what\s+if\s+i\s+(?:told\s+you|said)|imagine\s+(?:that\s+)?you|
|
|
54
59
|
let\s+me\s+rephrase|to\s+(?:re)?phrase\s+(?:it|that)\s+differently|
|
|
55
|
-
you\s+misunderstood|that\s+is\s+not\s+what\s+i\s+(?:meant|asked)
|
|
60
|
+
you\s+misunderstood|that\s+is\s+not\s+what\s+i\s+(?:meant|asked)|
|
|
61
|
+
hypothetisch|in\s+theorie|voor\s+(?:een\s+vriend|onderzoek|een\s+paper)|
|
|
62
|
+
puur\s+(?:academisch|hypothetisch)|stel\s+dat|laat\s+me\s+(?:het\s+)?herformuleren|
|
|
63
|
+
je\s+hebt\s+me\s+verkeerd\s+begrepen|dat\s+is\s+niet\s+wat\s+ik\s+(?:bedoelde|vroeg))\b
|
|
56
64
|
/xi
|
|
57
65
|
|
|
58
66
|
STOP = %w[
|
|
@@ -60,6 +68,9 @@ module Vangrail
|
|
|
60
68
|
from by as it its this that these those i you he she they we me my your do
|
|
61
69
|
does did how what why when where can could would should will shall may
|
|
62
70
|
might must not no yes if then than so about into over under please
|
|
71
|
+
de het een van en of maar is zijn was waren te in op aan voor met tot
|
|
72
|
+
bij als dan dat die dit deze ik je we zij jij u uw mijn niet geen ja
|
|
73
|
+
nee hoe wat waarom wanneer waar kan zou moet zal wel nog
|
|
63
74
|
].freeze
|
|
64
75
|
|
|
65
76
|
attr_reader :overlap, :window, :tolerance
|
|
@@ -78,17 +89,13 @@ module Vangrail
|
|
|
78
89
|
@tolerance = tolerance
|
|
79
90
|
end
|
|
80
91
|
|
|
81
|
-
def offline?
|
|
82
|
-
true
|
|
83
|
-
end
|
|
84
|
-
|
|
85
92
|
# Not memoizable: the same question means different things depending on
|
|
86
93
|
# what came before it, which is the entire premise of the rail.
|
|
87
94
|
def cache_key(_text, _context)
|
|
88
95
|
nil
|
|
89
96
|
end
|
|
90
97
|
|
|
91
|
-
def
|
|
98
|
+
def decide(text, context)
|
|
92
99
|
# A caller that never passes :history is not threading a dialogue, and
|
|
93
100
|
# this rail has not checked anything: say so. A caller that passes an
|
|
94
101
|
# empty one is threading a dialogue that has just started, which is a
|
|
@@ -166,7 +173,7 @@ module Vangrail
|
|
|
166
173
|
end
|
|
167
174
|
|
|
168
175
|
def content_words(text)
|
|
169
|
-
text.
|
|
176
|
+
NLP.words(text).reject { |word| STOP.include?(word) }
|
|
170
177
|
end
|
|
171
178
|
|
|
172
179
|
def user?(turn)
|
|
@@ -67,7 +67,7 @@ module Vangrail
|
|
|
67
67
|
@max_query = max_query
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def
|
|
70
|
+
def language_agnostic?
|
|
71
71
|
true
|
|
72
72
|
end
|
|
73
73
|
|
|
@@ -75,7 +75,7 @@ module Vangrail
|
|
|
75
75
|
text
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
def
|
|
78
|
+
def decide(text, _context)
|
|
79
79
|
body = text.to_s
|
|
80
80
|
found = []
|
|
81
81
|
cleaned = strip_all(body, found)
|
|
@@ -33,25 +33,28 @@ module Vangrail
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def offline?
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
36
40
|
# Not memoizable. Stated rather than left to a default so the reason is
|
|
37
41
|
# visible where the decision is.
|
|
38
42
|
def cache_key(_text, _context)
|
|
39
43
|
nil
|
|
40
44
|
end
|
|
41
45
|
|
|
42
|
-
def
|
|
46
|
+
def decide(text, context)
|
|
43
47
|
passages = Array(context[:passages])
|
|
44
48
|
return unchecked('no passages supplied') if passages.empty?
|
|
45
49
|
|
|
46
50
|
answer = chat.ask([
|
|
47
51
|
{ 'role' => 'system', 'content' => policy },
|
|
48
|
-
{ 'role' => 'user', 'content' => Policies.grounding_prompt(text, passages) }
|
|
52
|
+
{ 'role' => 'user', 'content' => Policies.grounding_prompt(text, passages) },
|
|
49
53
|
])
|
|
50
54
|
parsed = Parsers.policy(answer.text)
|
|
51
55
|
unless parsed[:decided]
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
reason: "unparsed judge response: #{parsed[:reason]}")
|
|
56
|
+
return unchecked("unparsed judge response: #{parsed[:reason]}",
|
|
57
|
+
model: model, latency_ms: answer.latency_ms, raw: answer.raw)
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
return pass(model: model, latency_ms: answer.latency_ms, raw: answer.raw) unless parsed[:violated]
|