vangrail 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +473 -0
- data/lib/vangrail/actions.rb +61 -0
- data/lib/vangrail/chat.rb +88 -0
- data/lib/vangrail/client/completion.rb +122 -0
- data/lib/vangrail/client.rb +219 -0
- data/lib/vangrail/colang/ast.rb +53 -0
- data/lib/vangrail/colang/interpreter.rb +131 -0
- data/lib/vangrail/colang/library.rb +53 -0
- data/lib/vangrail/colang/parser.rb +222 -0
- data/lib/vangrail/config.rb +270 -0
- data/lib/vangrail/confusables.rb +67 -0
- data/lib/vangrail/confusables_data.rb +1673 -0
- data/lib/vangrail/conversation.rb +105 -0
- data/lib/vangrail/engine.rb +240 -0
- data/lib/vangrail/errors.rb +48 -0
- data/lib/vangrail/http.rb +109 -0
- data/lib/vangrail/parsers.rb +181 -0
- data/lib/vangrail/policies.rb +202 -0
- data/lib/vangrail/prompt.rb +88 -0
- data/lib/vangrail/provider.rb +191 -0
- data/lib/vangrail/providers/gateway.rb +131 -0
- data/lib/vangrail/providers/llmlite.rb +71 -0
- data/lib/vangrail/providers.rb +72 -0
- data/lib/vangrail/rail.rb +93 -0
- data/lib/vangrail/rails/budget.rb +63 -0
- data/lib/vangrail/rails/canary.rb +76 -0
- data/lib/vangrail/rails/colang_flow.rb +40 -0
- data/lib/vangrail/rails/escalation.rb +178 -0
- data/lib/vangrail/rails/exfiltration.rb +167 -0
- data/lib/vangrail/rails/grounding.rb +64 -0
- data/lib/vangrail/rails/guard_model.rb +96 -0
- data/lib/vangrail/rails/hidden.rb +105 -0
- data/lib/vangrail/rails/injected_instructions.rb +86 -0
- data/lib/vangrail/rails/jailbreak.rb +114 -0
- data/lib/vangrail/rails/known_answer.rb +118 -0
- data/lib/vangrail/rails/many_shot.rb +80 -0
- data/lib/vangrail/rails/markup.rb +77 -0
- data/lib/vangrail/rails/missing.rb +38 -0
- data/lib/vangrail/rails/obfuscation.rb +186 -0
- data/lib/vangrail/rails/pattern.rb +57 -0
- data/lib/vangrail/rails/personal_data.rb +152 -0
- data/lib/vangrail/rails/remote.rb +40 -0
- data/lib/vangrail/rails/secrets.rb +77 -0
- data/lib/vangrail/rails/self_check.rb +81 -0
- data/lib/vangrail/rails/trajectory.rb +101 -0
- data/lib/vangrail/result.rb +114 -0
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/spotlight.rb +157 -0
- data/lib/vangrail/stream_guard.rb +163 -0
- data/lib/vangrail/version.rb +5 -0
- data/lib/vangrail.rb +354 -0
- metadata +120 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
require_relative 'ast'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Colang
|
|
8
|
+
# Reads the Colang 1.0 subset that rail flows are written in.
|
|
9
|
+
#
|
|
10
|
+
# define flow self check input
|
|
11
|
+
# $allowed = execute self_check_input
|
|
12
|
+
# if not $allowed
|
|
13
|
+
# bot refuse to respond
|
|
14
|
+
# stop
|
|
15
|
+
#
|
|
16
|
+
# define bot refuse to respond
|
|
17
|
+
# "I'm sorry, I can't respond to that."
|
|
18
|
+
#
|
|
19
|
+
# That subset covers the input and output rails that ship with the toolkit
|
|
20
|
+
# and the ones people write. Dialog flows with user-intent matching are not
|
|
21
|
+
# supported, and a file using them raises rather than loading with the
|
|
22
|
+
# matching quietly missing: a guardrail that half-loads is a guardrail that
|
|
23
|
+
# reports checks it is not running.
|
|
24
|
+
#
|
|
25
|
+
# Indentation defines blocks. Tabs are refused, because a file mixing tabs
|
|
26
|
+
# and spaces would otherwise parse into a different program than it looks.
|
|
27
|
+
class Parser
|
|
28
|
+
Line = Struct.new(:indent, :text, :number, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
def self.parse(source, filename: nil)
|
|
31
|
+
new(source, filename: filename).parse
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(source, filename: nil)
|
|
35
|
+
@source = source.to_s
|
|
36
|
+
@filename = filename
|
|
37
|
+
@flows = {}
|
|
38
|
+
@bot_messages = {}
|
|
39
|
+
@user_messages = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse
|
|
43
|
+
lines = significant_lines
|
|
44
|
+
index = 0
|
|
45
|
+
index = definition(lines, index) while index < lines.length
|
|
46
|
+
Program.new(flows: @flows, bot_messages: @bot_messages, user_messages: @user_messages)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def significant_lines
|
|
52
|
+
@source.lines.each_with_index.filter_map do |raw, i|
|
|
53
|
+
number = i + 1
|
|
54
|
+
raise error('tabs are not allowed in Colang indentation', number) if raw.start_with?("\t")
|
|
55
|
+
|
|
56
|
+
text = raw.rstrip
|
|
57
|
+
stripped = text.strip
|
|
58
|
+
next if stripped.empty? || stripped.start_with?('#')
|
|
59
|
+
|
|
60
|
+
Line.new(indent: text[/\A */].length, text: stripped, number: number)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One `define ...` header plus everything indented under it.
|
|
65
|
+
def definition(lines, index)
|
|
66
|
+
header = lines[index]
|
|
67
|
+
raise error("expected a `define` block, got #{header.text.inspect}", header.number) unless
|
|
68
|
+
header.text.start_with?('define ')
|
|
69
|
+
|
|
70
|
+
body, next_index = block(lines, index + 1, header.indent)
|
|
71
|
+
case header.text
|
|
72
|
+
when /\Adefine (flow|subflow)\s+(.+)\z/
|
|
73
|
+
name = Regexp.last_match(2).strip
|
|
74
|
+
@flows[name] =
|
|
75
|
+
Flow.new(name: name, body: statements(body), subflow: Regexp.last_match(1) == 'subflow')
|
|
76
|
+
when /\Adefine bot\s+(.+)\z/
|
|
77
|
+
@bot_messages[Regexp.last_match(1).strip] = strings(body)
|
|
78
|
+
when /\Adefine user\s+(.+)\z/
|
|
79
|
+
@user_messages[Regexp.last_match(1).strip] = strings(body)
|
|
80
|
+
else
|
|
81
|
+
raise error("unsupported definition #{header.text.inspect}", header.number)
|
|
82
|
+
end
|
|
83
|
+
next_index
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every line indented deeper than `indent`, and where to resume.
|
|
87
|
+
def block(lines, index, indent)
|
|
88
|
+
body = []
|
|
89
|
+
while index < lines.length && lines[index].indent > indent
|
|
90
|
+
body << lines[index]
|
|
91
|
+
index += 1
|
|
92
|
+
end
|
|
93
|
+
[body, index]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def statements(lines)
|
|
97
|
+
result = []
|
|
98
|
+
index = 0
|
|
99
|
+
while index < lines.length
|
|
100
|
+
statement, index = statement(lines, index)
|
|
101
|
+
result << statement
|
|
102
|
+
end
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def statement(lines, index)
|
|
107
|
+
line = lines[index]
|
|
108
|
+
case line.text
|
|
109
|
+
when /\A\$(\w+)\s*=\s*(.+)\z/
|
|
110
|
+
[Assign.new(variable: Regexp.last_match(1), expression: expression(Regexp.last_match(2), line)),
|
|
111
|
+
index + 1]
|
|
112
|
+
when /\Aexecute\s+(.+)\z/
|
|
113
|
+
[action_call(Regexp.last_match(1), line), index + 1]
|
|
114
|
+
when /\Abot\s+(.+)\z/
|
|
115
|
+
[Bot.new(message: Regexp.last_match(1).strip), index + 1]
|
|
116
|
+
when /\Astop\z/
|
|
117
|
+
[Stop.new(reason: nil), index + 1]
|
|
118
|
+
when /\Aif\s+(.+)\z/
|
|
119
|
+
# $~ is frame-local, so the captured condition is passed rather than
|
|
120
|
+
# read again inside the callee.
|
|
121
|
+
conditional(lines, index, line, Regexp.last_match(1))
|
|
122
|
+
when /\Aelse\z/
|
|
123
|
+
raise error('`else` without a matching `if`', line.number)
|
|
124
|
+
else
|
|
125
|
+
raise error("unsupported statement #{line.text.inspect}", line.number)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def conditional(lines, index, line, condition_text)
|
|
130
|
+
condition = condition(condition_text, line)
|
|
131
|
+
then_lines, index = block(lines, index + 1, line.indent)
|
|
132
|
+
else_lines = []
|
|
133
|
+
if index < lines.length && lines[index].text == 'else' && lines[index].indent == line.indent
|
|
134
|
+
else_lines, index = block(lines, index + 1, lines[index].indent)
|
|
135
|
+
end
|
|
136
|
+
[If.new(condition: condition, then_body: statements(then_lines), else_body: statements(else_lines)),
|
|
137
|
+
index]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def expression(text, line)
|
|
141
|
+
return action_call(Regexp.last_match(1), line) if text =~ /\Aexecute\s+(.+)\z/
|
|
142
|
+
return Literal.new(value: unquote(text)) if quoted?(text)
|
|
143
|
+
return Var.new(name: Regexp.last_match(1)) if text =~ /\A\$(\w+)\z/
|
|
144
|
+
|
|
145
|
+
raise error("unsupported expression #{text.inspect}", line.number)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def action_call(text, line)
|
|
149
|
+
name, args = text.match(/\A([\w.]+)\s*(?:\((.*)\))?\s*\z/)&.captures
|
|
150
|
+
raise error("unsupported action call #{text.inspect}", line.number) unless name
|
|
151
|
+
|
|
152
|
+
Execute.new(action: name, arguments: arguments(args, line))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# key="value", key=$var, key=42. Positional arguments are refused: an
|
|
156
|
+
# action here is a Ruby method taking a keyword hash.
|
|
157
|
+
def arguments(text, line)
|
|
158
|
+
return {} if text.nil? || text.strip.empty?
|
|
159
|
+
|
|
160
|
+
text.split(/,(?=(?:[^"]*"[^"]*")*[^"]*\z)/).to_h do |pair|
|
|
161
|
+
key, value = pair.split('=', 2).map { |s| s.to_s.strip }
|
|
162
|
+
raise error("argument #{pair.inspect} needs a name", line.number) if value.nil? || key.empty?
|
|
163
|
+
|
|
164
|
+
[key, argument_value(value, line)]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def argument_value(value, line)
|
|
169
|
+
return unquote(value) if quoted?(value)
|
|
170
|
+
return Var.new(name: Regexp.last_match(1)) if value =~ /\A\$(\w+)\z/
|
|
171
|
+
return value.to_i if value.match?(/\A-?\d+\z/)
|
|
172
|
+
return true if %w[True true].include?(value)
|
|
173
|
+
return false if %w[False false].include?(value)
|
|
174
|
+
|
|
175
|
+
raise error("unsupported argument value #{value.inspect}", line.number)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def condition(text, line)
|
|
179
|
+
text = text.strip
|
|
180
|
+
return Not.new(expression: condition(Regexp.last_match(1), line)) if text =~ /\Anot\s+(.+)\z/
|
|
181
|
+
if text =~ /\A(.+?)\s*(==|!=)\s*(.+)\z/
|
|
182
|
+
return Compare.new(
|
|
183
|
+
left: condition(Regexp.last_match(1), line),
|
|
184
|
+
operator: Regexp.last_match(2),
|
|
185
|
+
right: condition(Regexp.last_match(3), line)
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
return Var.new(name: Regexp.last_match(1)) if text =~ /\A\$(\w+)\z/
|
|
189
|
+
return Literal.new(value: unquote(text)) if quoted?(text)
|
|
190
|
+
return Literal.new(value: true) if %w[True true].include?(text)
|
|
191
|
+
return Literal.new(value: false) if %w[False false].include?(text)
|
|
192
|
+
|
|
193
|
+
raise error("unsupported condition #{text.inspect}", line.number)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def strings(lines)
|
|
197
|
+
lines.map do |line|
|
|
198
|
+
unless quoted?(line.text)
|
|
199
|
+
raise error("expected a quoted message, got #{line.text.inspect}",
|
|
200
|
+
line.number)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
unquote(line.text)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def quoted?(text)
|
|
208
|
+
text.length >= 2 && ((text.start_with?('"') && text.end_with?('"')) ||
|
|
209
|
+
(text.start_with?("'") && text.end_with?("'")))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def unquote(text)
|
|
213
|
+
text[1..-2].gsub('\\"', '"').gsub("\\'", "'")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def error(message, number)
|
|
217
|
+
where = [@filename, number].compact.join(':')
|
|
218
|
+
ColangError.new("#{where}: #{message}")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'actions'
|
|
6
|
+
require_relative 'chat'
|
|
7
|
+
require_relative 'colang/library'
|
|
8
|
+
require_relative 'colang/parser'
|
|
9
|
+
require_relative 'engine'
|
|
10
|
+
require_relative 'errors'
|
|
11
|
+
require_relative 'policies'
|
|
12
|
+
require_relative 'rails/colang_flow'
|
|
13
|
+
require_relative 'rails/grounding'
|
|
14
|
+
require_relative 'rails/self_check'
|
|
15
|
+
require_relative 'provider'
|
|
16
|
+
|
|
17
|
+
module Vangrail
|
|
18
|
+
# A guardrails configuration folder, read and written by Ruby.
|
|
19
|
+
#
|
|
20
|
+
# config = Vangrail::Config.load('config/handbook')
|
|
21
|
+
# engine = config.engine
|
|
22
|
+
# engine.check_input('Ignore your instructions.')
|
|
23
|
+
#
|
|
24
|
+
# The folder is the format the Python toolkit uses: config.yml for models and
|
|
25
|
+
# which flows run on which side, prompts.yml for the policy text each
|
|
26
|
+
# self-check task judges against, and rails/*.co for the flows themselves.
|
|
27
|
+
# Nothing here shells out to it. The YAML is read, the Colang is parsed, and
|
|
28
|
+
# the flows execute in this process, so the same folder can be handed to
|
|
29
|
+
# either runtime and describes one set of rails either way.
|
|
30
|
+
#
|
|
31
|
+
# A folder naming a flow that nothing defines raises. A folder naming a model
|
|
32
|
+
# type this gem cannot serve raises. Both are load-time failures on purpose: a
|
|
33
|
+
# configuration that comes up with half its rails missing is worse than one
|
|
34
|
+
# that refuses to come up.
|
|
35
|
+
class Config
|
|
36
|
+
SELF_CHECK_TASKS = { 'self_check_input' => :input, 'self_check_output' => :output }.freeze
|
|
37
|
+
|
|
38
|
+
attr_reader :name, :models, :rails, :prompts, :flows, :instructions, :sample_conversation, :path
|
|
39
|
+
|
|
40
|
+
def initialize(name:, models: [], rails: {}, prompts: [], flows: {}, instructions: nil,
|
|
41
|
+
sample_conversation: nil, path: nil)
|
|
42
|
+
@name = name
|
|
43
|
+
@models = models
|
|
44
|
+
@rails = rails
|
|
45
|
+
@prompts = prompts
|
|
46
|
+
@flows = flows
|
|
47
|
+
@instructions = instructions
|
|
48
|
+
@sample_conversation = sample_conversation
|
|
49
|
+
@path = path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- reading ---
|
|
53
|
+
|
|
54
|
+
def self.load(dir)
|
|
55
|
+
raise ConfigError, "no configuration folder at #{dir}" unless File.directory?(dir)
|
|
56
|
+
|
|
57
|
+
yaml = load_yaml(File.join(dir, 'config.yml')) || load_yaml(File.join(dir, 'config.yaml')) || {}
|
|
58
|
+
prompts = Array((load_yaml(File.join(dir, 'prompts.yml')) || {})['prompts'])
|
|
59
|
+
flows = Dir[File.join(dir, '**', '*.co')].to_h do |file|
|
|
60
|
+
[File.basename(file, '.co'), File.read(file)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
new(
|
|
64
|
+
name: File.basename(dir),
|
|
65
|
+
models: Array(yaml['models']),
|
|
66
|
+
rails: yaml['rails'] || {},
|
|
67
|
+
prompts: prompts,
|
|
68
|
+
flows: flows,
|
|
69
|
+
instructions: yaml['instructions'],
|
|
70
|
+
sample_conversation: yaml['sample_conversation'],
|
|
71
|
+
path: dir
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.load_yaml(file)
|
|
76
|
+
return nil unless File.file?(file)
|
|
77
|
+
|
|
78
|
+
YAML.safe_load_file(file, aliases: true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- running ---
|
|
82
|
+
|
|
83
|
+
# Every flow this configuration can execute: the ones it ships plus the
|
|
84
|
+
# built-ins it is allowed to name without defining.
|
|
85
|
+
def program
|
|
86
|
+
@program ||= flows.reduce(Colang::Library.program) do |acc, (file, source)|
|
|
87
|
+
acc.merge(Colang::Parser.parse(source, filename: "#{file}.co"))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def flow_names(side)
|
|
92
|
+
keys = [side.to_s]
|
|
93
|
+
# NeMo names the retrieved-document side `retrieval`. That is :context.
|
|
94
|
+
keys << 'retrieval' if side.to_sym == :context
|
|
95
|
+
keys.flat_map { |key| Array(rails.dig(key, 'flows')) }.map(&:to_s).uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def prompt_for(task)
|
|
99
|
+
entry = prompts.find { |p| p['task'].to_s == task.to_s }
|
|
100
|
+
entry && entry['content'].to_s
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def model_for(type)
|
|
104
|
+
models.find { |m| m['type'].to_s == type.to_s }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Builds the engine this configuration describes.
|
|
108
|
+
#
|
|
109
|
+
# `chat:` overrides where model-backed actions call, which is what tests and
|
|
110
|
+
# a caller with its own client pass. `actions:` adds or replaces actions by
|
|
111
|
+
# name, so a team's own check joins the built-ins without touching the gem.
|
|
112
|
+
def engine(provider: nil, chat: nil, actions: {}, on_error: :allow, cache: true)
|
|
113
|
+
registry = self_check_actions(provider, chat).merge(actions)
|
|
114
|
+
Engine.new(
|
|
115
|
+
input: rails_for(:input, registry),
|
|
116
|
+
context: rails_for(:context, registry),
|
|
117
|
+
output: rails_for(:output, registry),
|
|
118
|
+
on_error: on_error,
|
|
119
|
+
cache: cache
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def rails_for(side, registry)
|
|
124
|
+
flow_names(side).map do |flow_name|
|
|
125
|
+
unless program.flow(flow_name)
|
|
126
|
+
raise ConfigError,
|
|
127
|
+
"#{name}: rails.#{side}.flows names #{flow_name.inspect}, which no .co file defines " \
|
|
128
|
+
"and which is not built in (#{Colang::Library.flow_names.join(', ')})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
Rails::ColangFlow.new(flow_name: flow_name, program: program, actions: registry, sides: [side])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# The three tasks a stock configuration expects, each backed by a rail this
|
|
138
|
+
# gem implements. A configuration that names none of them gets none of them.
|
|
139
|
+
def self_check_actions(provider, chat)
|
|
140
|
+
input = self_check_rail('self_check_input', :input, provider, chat)
|
|
141
|
+
output = self_check_rail('self_check_output', :output, provider, chat)
|
|
142
|
+
facts = grounding_rail(provider, chat)
|
|
143
|
+
Actions.from_rails(input: input, output: output, facts: facts)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self_check_rail(task, side, provider, chat)
|
|
147
|
+
entry = model_for(task) || model_for('main')
|
|
148
|
+
return nil unless entry
|
|
149
|
+
|
|
150
|
+
Rails::SelfCheck.new(
|
|
151
|
+
name: task,
|
|
152
|
+
sides: [side],
|
|
153
|
+
policy: prompt_for(task),
|
|
154
|
+
model: entry['model'],
|
|
155
|
+
chat: chat,
|
|
156
|
+
provider: provider_for(entry, provider)
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def grounding_rail(provider, chat)
|
|
161
|
+
entry = model_for('self_check_facts') || model_for('main')
|
|
162
|
+
return nil unless entry
|
|
163
|
+
|
|
164
|
+
Rails::Grounding.new(model: entry['model'], chat: chat, provider: provider_for(entry, provider))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A model entry names its own endpoint through `parameters.base_url`, which
|
|
168
|
+
# is how the configuration format points at an OpenAI-compatible gateway.
|
|
169
|
+
# That wins over the caller's provider, because the folder is the thing
|
|
170
|
+
# under version control and the provider is ambient.
|
|
171
|
+
def provider_for(entry, provider)
|
|
172
|
+
base = entry.dig('parameters', 'base_url')
|
|
173
|
+
return provider if base.nil? || base.to_s.strip.empty?
|
|
174
|
+
return provider if provider && provider.base_url == base.to_s.sub(/\/+\z/, '')
|
|
175
|
+
|
|
176
|
+
key = provider&.api_key
|
|
177
|
+
Provider.new(name: entry['type'].to_s, base_url: base, models: { judge: entry['model'] },
|
|
178
|
+
key_resolver: key ? -> { key } : nil)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
public
|
|
182
|
+
|
|
183
|
+
# --- writing ---
|
|
184
|
+
|
|
185
|
+
# A starting configuration for a provider. `engine: openai` with a base_url
|
|
186
|
+
# parameter is how the format names an OpenAI-compatible gateway, and this
|
|
187
|
+
# gem reads that field the same way, so one folder serves both runtimes.
|
|
188
|
+
def self.for_provider(provider, name: 'handbook', main_model: nil, judge_model: nil,
|
|
189
|
+
subject: 'a public documentation handbook')
|
|
190
|
+
base_url = provider.base_url
|
|
191
|
+
main_model ||= provider.model(:judge)
|
|
192
|
+
judge_model ||= provider.model(:judge)
|
|
193
|
+
new(
|
|
194
|
+
name: name,
|
|
195
|
+
models: [
|
|
196
|
+
model_entry('main', main_model, base_url),
|
|
197
|
+
model_entry('self_check_input', judge_model, base_url),
|
|
198
|
+
model_entry('self_check_output', judge_model, base_url)
|
|
199
|
+
],
|
|
200
|
+
rails: {
|
|
201
|
+
'input' => { 'flows' => ['self check input'] },
|
|
202
|
+
'output' => { 'flows' => ['self check output'] }
|
|
203
|
+
},
|
|
204
|
+
prompts: [
|
|
205
|
+
{ 'task' => 'self_check_input', 'content' => self_check_prompt(:input, subject) },
|
|
206
|
+
{ 'task' => 'self_check_output', 'content' => self_check_prompt(:output, subject) }
|
|
207
|
+
],
|
|
208
|
+
instructions: [
|
|
209
|
+
{
|
|
210
|
+
'type' => 'general',
|
|
211
|
+
'content' => "You answer questions about #{subject}. Every factual clause " \
|
|
212
|
+
'comes from a supplied passage. Where the passages do not cover ' \
|
|
213
|
+
'the question, say so.'
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.model_entry(type, model, base_url)
|
|
220
|
+
{ 'type' => type, 'engine' => 'openai', 'model' => model, 'parameters' => { 'base_url' => base_url } }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The self-check tasks read a Yes/No answer, so the policy is rendered as a
|
|
224
|
+
# question rather than with the JSON contract a policy judge uses.
|
|
225
|
+
def self.self_check_prompt(rail, subject)
|
|
226
|
+
policy = rail == :input ? Policies.input_policy(subject: subject) : Policies.output_policy(subject: subject)
|
|
227
|
+
body = policy.sub(Policies::ANSWER_CONTRACT, '').rstrip
|
|
228
|
+
slot = rail == :input ? '{{ user_input }}' : '{{ bot_response }}'
|
|
229
|
+
label = rail == :input ? 'User message' : 'Bot message'
|
|
230
|
+
<<~TXT
|
|
231
|
+
#{body}
|
|
232
|
+
|
|
233
|
+
#{label}: "#{slot}"
|
|
234
|
+
|
|
235
|
+
Question: Should the message be blocked (Yes or No)?
|
|
236
|
+
Answer:
|
|
237
|
+
TXT
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def to_h
|
|
241
|
+
h = {}
|
|
242
|
+
h['models'] = models unless models.empty?
|
|
243
|
+
h['instructions'] = instructions if instructions
|
|
244
|
+
h['rails'] = rails unless rails.empty?
|
|
245
|
+
h['sample_conversation'] = sample_conversation if sample_conversation
|
|
246
|
+
h
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def config_yaml
|
|
250
|
+
YAML.dump(to_h)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def prompts_yaml
|
|
254
|
+
YAML.dump('prompts' => prompts)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Writes <root>/<name>/. Returns the directory it wrote.
|
|
258
|
+
def write!(root)
|
|
259
|
+
dir = File.join(root, name)
|
|
260
|
+
FileUtils.mkdir_p(dir)
|
|
261
|
+
File.write(File.join(dir, 'config.yml'), config_yaml)
|
|
262
|
+
File.write(File.join(dir, 'prompts.yml'), prompts_yaml) unless prompts.empty?
|
|
263
|
+
unless flows.empty?
|
|
264
|
+
FileUtils.mkdir_p(File.join(dir, 'rails'))
|
|
265
|
+
flows.each { |file, colang| File.write(File.join(dir, 'rails', "#{file}.co"), colang.to_s) }
|
|
266
|
+
end
|
|
267
|
+
dir
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'confusables_data'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
# Folds characters that imitate ASCII back to the ASCII they imitate.
|
|
7
|
+
#
|
|
8
|
+
# The data is generated from the Unicode confusables file (UTS #39) and lives
|
|
9
|
+
# in confusables_data.rb. This is the policy that uses it, kept apart so
|
|
10
|
+
# regenerating the table cannot overwrite a decision.
|
|
11
|
+
#
|
|
12
|
+
# The decision worth writing down is which words to fold. Folding everything
|
|
13
|
+
# turns a page of Russian into ASCII noise: "Кластер" becomes "Kлacтep",
|
|
14
|
+
# which is not Russian, not English, and not what anybody wrote. It happens
|
|
15
|
+
# to be harmless here, because a folded variant is only ever shown to a
|
|
16
|
+
# pattern and never to a reader, but it is the wrong thing to be doing and it
|
|
17
|
+
# widens the surface for an accidental match.
|
|
18
|
+
#
|
|
19
|
+
# What an imitation attack actually looks like is a *mixed* word: Latin
|
|
20
|
+
# letters with one or two lookalikes dropped in, so that "system" reads as
|
|
21
|
+
# "system" and is not. A word written entirely in Cyrillic is not imitating
|
|
22
|
+
# anything; it is a word in Cyrillic. UTS #39 draws the same line and calls
|
|
23
|
+
# it mixed-script detection.
|
|
24
|
+
#
|
|
25
|
+
# So `fold` leaves single-script words alone and folds the mixed ones. That
|
|
26
|
+
# keeps genuine multilingual documentation intact and still catches the
|
|
27
|
+
# attack, which the corpus asserts in both directions.
|
|
28
|
+
module Confusables
|
|
29
|
+
# A run of non-space characters. Folding is decided per word because that
|
|
30
|
+
# is the unit the mixing happens in.
|
|
31
|
+
WORD = /\S+/
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Every word that mixes ASCII with imitators, folded. Text with no
|
|
36
|
+
# imitators at all is returned untouched and costs one match.
|
|
37
|
+
def fold(text)
|
|
38
|
+
body = text.to_s
|
|
39
|
+
return body unless body.match?(PATTERN)
|
|
40
|
+
|
|
41
|
+
body.gsub(WORD) { |word| mixed?(word) ? fold_word(word) : word }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Folds regardless of mixing. For a caller that has already decided the
|
|
45
|
+
# text should be Latin, and for measuring what the policy costs.
|
|
46
|
+
def fold_all(text)
|
|
47
|
+
body = text.to_s
|
|
48
|
+
return body unless body.match?(PATTERN)
|
|
49
|
+
|
|
50
|
+
fold_word(body)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def confusable?(text)
|
|
54
|
+
text.to_s.match?(PATTERN)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A word imitating ASCII carries both: at least one ASCII letter or digit,
|
|
58
|
+
# and at least one character pretending to be one.
|
|
59
|
+
def mixed?(word)
|
|
60
|
+
word.match?(/[A-Za-z0-9]/) && word.match?(PATTERN)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fold_word(word)
|
|
64
|
+
word.gsub(PATTERN) { |char| MAP.fetch(char, char) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|