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
|
@@ -10,62 +10,55 @@ module Vangrail
|
|
|
10
10
|
# The mapping onto rail statuses is the whole design:
|
|
11
11
|
#
|
|
12
12
|
# `bot <message>` then `stop` -> blocked, with the message as content
|
|
13
|
-
#
|
|
14
|
-
# $bot_message -> modified, with the new value
|
|
13
|
+
# a change to a content binding -> modified, with the new value
|
|
15
14
|
# falls off the end -> passed
|
|
16
15
|
#
|
|
16
|
+
# `$user_input` is a rewrite alias of `$user_message`; `$bot_response` is
|
|
17
|
+
# a rewrite alias of `$bot_message`. Input and context seed the user pair
|
|
18
|
+
# from the turn text; output seeds the bot pair. The other pair is left
|
|
19
|
+
# unset so an output flow that reads `$user_message` is not reading the
|
|
20
|
+
# answer unless the caller put it there.
|
|
21
|
+
#
|
|
17
22
|
# A `stop` without a preceding `bot` still blocks; it just has no refusal
|
|
18
23
|
# text to show. Actions are plain Ruby callables, so the flow decides the
|
|
19
24
|
# control shape and Ruby does the work.
|
|
20
25
|
class Interpreter
|
|
21
|
-
#
|
|
22
|
-
#
|
|
26
|
+
# The four names a flow assigns to when it rewrites the turn. Order is
|
|
27
|
+
# the last-write winner when more than one of them changed.
|
|
23
28
|
CONTENT_VARS = %w[user_message bot_message user_input bot_response].freeze
|
|
24
29
|
|
|
25
30
|
Outcome = Struct.new(:status, :content, :reason, :variables, keyword_init: true)
|
|
26
31
|
|
|
27
|
-
# Raised through the interpreter to unwind a flow on `stop`.
|
|
28
|
-
class Stopped < StandardError
|
|
29
|
-
attr_reader :message_text
|
|
30
|
-
|
|
31
|
-
def initialize(message_text)
|
|
32
|
-
@message_text = message_text
|
|
33
|
-
super('flow stopped')
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
32
|
attr_reader :program, :actions
|
|
38
33
|
|
|
39
34
|
def initialize(program:, actions:)
|
|
40
|
-
@program = program
|
|
35
|
+
@program = program.check!
|
|
41
36
|
@actions = actions
|
|
42
37
|
end
|
|
43
38
|
|
|
44
39
|
def run(flow_name, context = {})
|
|
45
40
|
flow = program.flow(flow_name)
|
|
46
|
-
raise
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
rescue Stopped => e
|
|
53
|
-
return Outcome.new(status: :blocked, content: e.message_text, reason: flow_name, variables: state)
|
|
54
|
-
end
|
|
41
|
+
raise ColangError, "no flow named #{flow_name.inspect}" unless flow
|
|
42
|
+
|
|
43
|
+
seeds = seed_bindings(context)
|
|
44
|
+
state = { 'context' => context }.merge(seeds)
|
|
45
|
+
result = execute(flow.body, state, context)
|
|
46
|
+
return stop_outcome(result.last, flow_name, state) if stop_tag?(result)
|
|
55
47
|
|
|
56
|
-
rewrite =
|
|
48
|
+
rewrite = rewritten_content(state, seeds)
|
|
57
49
|
if rewrite
|
|
58
50
|
return Outcome.new(status: :modified, content: rewrite.to_s, reason: flow_name,
|
|
59
51
|
variables: state)
|
|
60
52
|
end
|
|
61
53
|
|
|
62
|
-
Outcome.new(status: :passed, content:
|
|
54
|
+
Outcome.new(status: :passed, content: result, reason: nil, variables: state)
|
|
63
55
|
end
|
|
64
56
|
|
|
65
57
|
private
|
|
66
58
|
|
|
67
59
|
# Returns the text of the last `bot` statement reached, so a flow that
|
|
68
|
-
# says something without stopping still hands that text back.
|
|
60
|
+
# says something without stopping still hands that text back. `stop`
|
|
61
|
+
# returns `[:stop, said]` rather than raising.
|
|
69
62
|
def execute(body, state, context)
|
|
70
63
|
said = nil
|
|
71
64
|
body.each do |node|
|
|
@@ -73,14 +66,45 @@ module Vangrail
|
|
|
73
66
|
when Assign then state[node.variable] = evaluate(node.expression, state, context)
|
|
74
67
|
when Execute then evaluate(node, state, context)
|
|
75
68
|
when Bot then said = bot_text(node.message)
|
|
76
|
-
when Stop then
|
|
77
|
-
when If
|
|
69
|
+
when Stop then return [:stop, said]
|
|
70
|
+
when If
|
|
71
|
+
result = branch(node, state, context)
|
|
72
|
+
return result if stop_tag?(result)
|
|
73
|
+
|
|
74
|
+
said = result || said
|
|
78
75
|
else raise ColangError, "cannot execute #{node.class}"
|
|
79
76
|
end
|
|
80
77
|
end
|
|
81
78
|
said
|
|
82
79
|
end
|
|
83
80
|
|
|
81
|
+
def stop_tag?(result)
|
|
82
|
+
result.is_a?(Array) && result.first == :stop
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stop_outcome(content, flow_name, state)
|
|
86
|
+
Outcome.new(status: :blocked, content: content, reason: flow_name, variables: state)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def seed_bindings(context)
|
|
90
|
+
text = context[:text]
|
|
91
|
+
if context[:side] == :output
|
|
92
|
+
{ 'bot_message' => text, 'bot_response' => text }
|
|
93
|
+
else
|
|
94
|
+
{ 'user_message' => text, 'user_input' => text }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rewritten_content(state, seeds)
|
|
99
|
+
rewritten = CONTENT_VARS.filter_map do |name|
|
|
100
|
+
next unless state.key?(name)
|
|
101
|
+
next if seeds.key?(name) && state[name] == seeds[name]
|
|
102
|
+
|
|
103
|
+
state[name]
|
|
104
|
+
end
|
|
105
|
+
rewritten.last
|
|
106
|
+
end
|
|
107
|
+
|
|
84
108
|
def branch(node, state, context)
|
|
85
109
|
taken = truthy?(evaluate(node.condition, state, context)) ? node.then_body : node.else_body
|
|
86
110
|
execute(Array(taken), state, context)
|
|
@@ -107,13 +131,13 @@ module Vangrail
|
|
|
107
131
|
action = actions[node.action]
|
|
108
132
|
raise UnknownAction, "no action registered for #{node.action.inspect}" unless action
|
|
109
133
|
|
|
110
|
-
args = node.arguments.transform_values { |v| v
|
|
134
|
+
args = node.arguments.transform_values { |v| evaluate(v, state, context) }
|
|
111
135
|
action.call(args, context)
|
|
112
136
|
end
|
|
113
137
|
|
|
114
138
|
def bot_text(message)
|
|
115
139
|
alternatives = program.bot_message(message)
|
|
116
|
-
raise
|
|
140
|
+
raise ColangError, "no `define bot #{message}` for this flow" unless alternatives
|
|
117
141
|
|
|
118
142
|
# Deterministic: a guardrail refusal that varies between runs makes an
|
|
119
143
|
# incident report harder to read for no benefit.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../errors'
|
|
4
4
|
require_relative 'ast'
|
|
5
|
+
require_relative 'value_parser'
|
|
5
6
|
|
|
6
7
|
module Vangrail
|
|
7
8
|
module Colang
|
|
@@ -22,8 +23,13 @@ module Vangrail
|
|
|
22
23
|
# matching quietly missing: a guardrail that half-loads is a guardrail that
|
|
23
24
|
# reports checks it is not running.
|
|
24
25
|
#
|
|
25
|
-
# Indentation defines blocks.
|
|
26
|
-
# and spaces would otherwise parse into a
|
|
26
|
+
# Indentation defines blocks. A tab anywhere in the indent run is refused,
|
|
27
|
+
# because a file mixing tabs and spaces would otherwise parse into a
|
|
28
|
+
# different program than it looks.
|
|
29
|
+
#
|
|
30
|
+
# Assignments, `if` conditions, and action arguments share one value
|
|
31
|
+
# grammar: string, int, and bool literals, `$vars`, `execute`, `not`, and
|
|
32
|
+
# `==` / `!=`.
|
|
27
33
|
class Parser
|
|
28
34
|
Line = Struct.new(:indent, :text, :number, keyword_init: true)
|
|
29
35
|
|
|
@@ -51,7 +57,8 @@ module Vangrail
|
|
|
51
57
|
def significant_lines
|
|
52
58
|
@source.lines.each_with_index.filter_map do |raw, i|
|
|
53
59
|
number = i + 1
|
|
54
|
-
|
|
60
|
+
indent_run = raw[/\A[ \t]*/]
|
|
61
|
+
raise error('tabs are not allowed in Colang indentation', number) if indent_run.include?("\t")
|
|
55
62
|
|
|
56
63
|
text = raw.rstrip
|
|
57
64
|
stripped = text.strip
|
|
@@ -107,10 +114,13 @@ module Vangrail
|
|
|
107
114
|
line = lines[index]
|
|
108
115
|
case line.text
|
|
109
116
|
when /\A\$(\w+)\s*=\s*(.+)\z/
|
|
110
|
-
[Assign.new(variable: Regexp.last_match(1), expression:
|
|
117
|
+
[Assign.new(variable: Regexp.last_match(1), expression: value(Regexp.last_match(2), line)),
|
|
111
118
|
index + 1]
|
|
112
|
-
when /\Aexecute\s
|
|
113
|
-
|
|
119
|
+
when /\Aexecute\s+/
|
|
120
|
+
node = value(line.text, line)
|
|
121
|
+
raise error("unsupported statement #{line.text.inspect}", line.number) unless node.is_a?(Execute)
|
|
122
|
+
|
|
123
|
+
[node, index + 1]
|
|
114
124
|
when /\Abot\s+(.+)\z/
|
|
115
125
|
[Bot.new(message: Regexp.last_match(1).strip), index + 1]
|
|
116
126
|
when /\Astop\z/
|
|
@@ -127,7 +137,7 @@ module Vangrail
|
|
|
127
137
|
end
|
|
128
138
|
|
|
129
139
|
def conditional(lines, index, line, condition_text)
|
|
130
|
-
condition =
|
|
140
|
+
condition = value(condition_text, line)
|
|
131
141
|
then_lines, index = block(lines, index + 1, line.indent)
|
|
132
142
|
else_lines = []
|
|
133
143
|
if index < lines.length && lines[index].text == 'else' && lines[index].indent == line.indent
|
|
@@ -137,60 +147,8 @@ module Vangrail
|
|
|
137
147
|
index]
|
|
138
148
|
end
|
|
139
149
|
|
|
140
|
-
def
|
|
141
|
-
|
|
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)
|
|
150
|
+
def value(text, line)
|
|
151
|
+
ValueParser.parse(text, line, method(:error))
|
|
194
152
|
end
|
|
195
153
|
|
|
196
154
|
def strings(lines)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ast'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Colang
|
|
7
|
+
# One grammar for assign RHS, `if` conditions, and action arguments.
|
|
8
|
+
#
|
|
9
|
+
# Strings are tokenized before `==` / `!=` is considered, so
|
|
10
|
+
# `"a == b" == $x` is a compare of a literal against a variable.
|
|
11
|
+
class ValueParser
|
|
12
|
+
def self.parse(text, line, error)
|
|
13
|
+
new(text, line, error).parse
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(text, line, error)
|
|
17
|
+
@text = text
|
|
18
|
+
@line = line
|
|
19
|
+
@error = error
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse
|
|
23
|
+
tokens = tokenize
|
|
24
|
+
node, index = parse_value(tokens, 0)
|
|
25
|
+
raise fail_value(@text) unless tokens[index][0] == :eof
|
|
26
|
+
|
|
27
|
+
node
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def tokenize
|
|
33
|
+
tokens = []
|
|
34
|
+
index = 0
|
|
35
|
+
while index < @text.length
|
|
36
|
+
if @text[index] == ' '
|
|
37
|
+
index += 1
|
|
38
|
+
else
|
|
39
|
+
token, index = next_token(index)
|
|
40
|
+
tokens << token
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
tokens << [:eof]
|
|
44
|
+
tokens
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def next_token(index)
|
|
48
|
+
char = @text[index]
|
|
49
|
+
if char == '"' || char == "'"
|
|
50
|
+
read_string(index)
|
|
51
|
+
elsif char == '$' && (name = @text[(index + 1)..][/\A\w+/])
|
|
52
|
+
[[:var, name], index + 1 + name.length]
|
|
53
|
+
elsif @text[index, 2] == '=='
|
|
54
|
+
[[:eqeq, '=='], index + 2]
|
|
55
|
+
elsif @text[index, 2] == '!='
|
|
56
|
+
[[:neq, '!='], index + 2]
|
|
57
|
+
elsif char == '='
|
|
58
|
+
[[:eq, '='], index + 1]
|
|
59
|
+
elsif char == '('
|
|
60
|
+
[[:lparen], index + 1]
|
|
61
|
+
elsif char == ')'
|
|
62
|
+
[[:rparen], index + 1]
|
|
63
|
+
elsif char == ','
|
|
64
|
+
[[:comma], index + 1]
|
|
65
|
+
elsif (num = @text[index..][/\A-?\d+/])
|
|
66
|
+
[[:int, num.to_i], index + num.length]
|
|
67
|
+
elsif (word = @text[index..][/\A[\w.]+/])
|
|
68
|
+
[[:ident, word], index + word.length]
|
|
69
|
+
else
|
|
70
|
+
raise fail_value(@text)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_string(start)
|
|
75
|
+
quote = @text[start]
|
|
76
|
+
index = start + 1
|
|
77
|
+
while index < @text.length
|
|
78
|
+
return [[:string, unquote(@text[start..index])], index + 1] if @text[index] == quote
|
|
79
|
+
|
|
80
|
+
index += @text[index] == '\\' && index + 1 < @text.length ? 2 : 1
|
|
81
|
+
end
|
|
82
|
+
raise @error.call("unterminated string in #{@text.inspect}", @line.number)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_value(tokens, index)
|
|
86
|
+
tok = tokens[index]
|
|
87
|
+
if tok[0] == :ident && tok[1] == 'not'
|
|
88
|
+
expr, index = parse_value(tokens, index + 1)
|
|
89
|
+
return [Not.new(expression: expr), index]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
left, index = parse_atom(tokens, index)
|
|
93
|
+
op = tokens[index]
|
|
94
|
+
return [left, index] unless %i[eqeq neq].include?(op[0])
|
|
95
|
+
|
|
96
|
+
right, index = parse_value(tokens, index + 1)
|
|
97
|
+
[Compare.new(left: left, operator: op[1], right: right), index]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_atom(tokens, index)
|
|
101
|
+
tok = tokens[index]
|
|
102
|
+
case tok[0]
|
|
103
|
+
when :string then [Literal.new(value: tok[1]), index + 1]
|
|
104
|
+
when :int then [Literal.new(value: tok[1]), index + 1]
|
|
105
|
+
when :var then [Var.new(name: tok[1]), index + 1]
|
|
106
|
+
when :ident
|
|
107
|
+
word = tok[1]
|
|
108
|
+
return parse_execute(tokens, index + 1) if word == 'execute'
|
|
109
|
+
return [Literal.new(value: true), index + 1] if %w[True true].include?(word)
|
|
110
|
+
return [Literal.new(value: false), index + 1] if %w[False false].include?(word)
|
|
111
|
+
|
|
112
|
+
raise fail_value(word)
|
|
113
|
+
else
|
|
114
|
+
raise fail_value(tok)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# execute name / execute name(key=value, ...). Positional arguments
|
|
119
|
+
# are refused: an action here is a Ruby method taking a keyword hash.
|
|
120
|
+
def parse_execute(tokens, index)
|
|
121
|
+
name_tok = tokens[index]
|
|
122
|
+
raise fail_value(name_tok) unless name_tok[0] == :ident
|
|
123
|
+
|
|
124
|
+
name = name_tok[1]
|
|
125
|
+
index += 1
|
|
126
|
+
return [Execute.new(action: name, arguments: {}), index] unless tokens[index][0] == :lparen
|
|
127
|
+
|
|
128
|
+
args, index = parse_arguments(tokens, index + 1)
|
|
129
|
+
[Execute.new(action: name, arguments: args), index]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_arguments(tokens, index)
|
|
133
|
+
return [{}, index + 1] if tokens[index][0] == :rparen
|
|
134
|
+
|
|
135
|
+
args = {}
|
|
136
|
+
loop do
|
|
137
|
+
key = tokens[index]
|
|
138
|
+
unless key[0] == :ident && tokens[index + 1][0] == :eq
|
|
139
|
+
raise @error.call("argument #{key[1].inspect} needs a name", @line.number)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
val, index = parse_value(tokens, index + 2)
|
|
143
|
+
args[key[1]] = val
|
|
144
|
+
case tokens[index][0]
|
|
145
|
+
when :comma then index += 1
|
|
146
|
+
when :rparen then return [args, index + 1]
|
|
147
|
+
else raise fail_value(@text)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def unquote(text)
|
|
153
|
+
text[1..-2].gsub('\\"', '"').gsub("\\'", "'")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def fail_value(what)
|
|
157
|
+
@error.call("unsupported value #{what.inspect}", @line.number)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'http'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
# The other OpenAI-compatible endpoint: /completions, asked to score text
|
|
8
|
+
# rather than to write any.
|
|
9
|
+
#
|
|
10
|
+
# It exists for one job. A chat endpoint returns what a model would say next
|
|
11
|
+
# and never says how surprising the text it was given was, and that number is
|
|
12
|
+
# what the published perplexity detectors are built on. The legacy completions
|
|
13
|
+
# endpoint answers it directly: echo the prompt, generate nothing, and return
|
|
14
|
+
# the log probability the model assigned each token of it.
|
|
15
|
+
#
|
|
16
|
+
# Support is genuinely uneven. Local servers built on llama.cpp and vLLM
|
|
17
|
+
# answer it; several hosted APIs removed the endpoint, and a proxy in front of
|
|
18
|
+
# a chat-only model cannot synthesise it. So `supported?` is a question worth
|
|
19
|
+
# asking rather than an assumption, and everything here raises ProtocolError
|
|
20
|
+
# rather than inventing a score when the fields are missing. A rail reading
|
|
21
|
+
# this turns that into an uncertain result, which is the truth: the check did
|
|
22
|
+
# not run.
|
|
23
|
+
class Completion
|
|
24
|
+
PATH = '/completions'
|
|
25
|
+
|
|
26
|
+
attr_reader :model, :http
|
|
27
|
+
|
|
28
|
+
def initialize(model:, http: nil, base_url: nil, api_key: nil,
|
|
29
|
+
open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
|
|
30
|
+
read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
|
|
31
|
+
@model = model
|
|
32
|
+
@http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
|
|
33
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
34
|
+
missing: 'a Completion needs a base_url or an http client')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The log probability of each token of `text`, under this model.
|
|
38
|
+
#
|
|
39
|
+
# The first token has none, by construction: nothing preceded it. It is
|
|
40
|
+
# dropped rather than counted as zero, because zero is a log probability of
|
|
41
|
+
# one and would report the opening word as perfectly predicted.
|
|
42
|
+
def token_logprobs(text)
|
|
43
|
+
body = http.post_json(PATH, {
|
|
44
|
+
'model' => model,
|
|
45
|
+
'prompt' => text.to_s,
|
|
46
|
+
'max_tokens' => 0,
|
|
47
|
+
'echo' => true,
|
|
48
|
+
'logprobs' => 0,
|
|
49
|
+
'temperature' => 0,
|
|
50
|
+
})
|
|
51
|
+
values = logprobs_in(body)
|
|
52
|
+
raise ProtocolError, 'completions endpoint returned no token logprobs' if values.empty?
|
|
53
|
+
|
|
54
|
+
values.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether this endpoint can score at all. ProtocolError and HTTP 404 mean
|
|
58
|
+
# it cannot; TransportError means it is down, which is a different
|
|
59
|
+
# question. Asked once and kept, so a rail does not spend a scoring
|
|
60
|
+
# request on a boolean every check.
|
|
61
|
+
def supported?
|
|
62
|
+
return @supported unless @supported.nil?
|
|
63
|
+
|
|
64
|
+
@supported = token_logprobs('the quick brown fox').size > 1
|
|
65
|
+
rescue ProtocolError
|
|
66
|
+
@supported = false
|
|
67
|
+
rescue HTTPError => e
|
|
68
|
+
raise unless e.status == 404
|
|
69
|
+
|
|
70
|
+
@supported = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def logprobs_in(body)
|
|
76
|
+
choices = body.is_a?(Hash) ? body['choices'] : nil
|
|
77
|
+
logprobs = choices.is_a?(Array) ? choices.dig(0, 'logprobs') : nil
|
|
78
|
+
raise ProtocolError, 'completions endpoint returned no logprobs field' unless logprobs.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
values = logprobs['token_logprobs']
|
|
81
|
+
raise ProtocolError, 'completions endpoint returned no token_logprobs array' unless values.is_a?(Array)
|
|
82
|
+
|
|
83
|
+
values.map { |value| value&.to_f }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/vangrail/config.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'fileutils'
|
|
|
4
4
|
require 'yaml'
|
|
5
5
|
require_relative 'actions'
|
|
6
6
|
require_relative 'chat'
|
|
7
|
+
require_relative 'conversation'
|
|
7
8
|
require_relative 'colang/library'
|
|
8
9
|
require_relative 'colang/parser'
|
|
9
10
|
require_relative 'engine'
|
|
@@ -68,7 +69,7 @@ module Vangrail
|
|
|
68
69
|
flows: flows,
|
|
69
70
|
instructions: yaml['instructions'],
|
|
70
71
|
sample_conversation: yaml['sample_conversation'],
|
|
71
|
-
path: dir
|
|
72
|
+
path: dir,
|
|
72
73
|
)
|
|
73
74
|
end
|
|
74
75
|
|
|
@@ -85,7 +86,7 @@ module Vangrail
|
|
|
85
86
|
def program
|
|
86
87
|
@program ||= flows.reduce(Colang::Library.program) do |acc, (file, source)|
|
|
87
88
|
acc.merge(Colang::Parser.parse(source, filename: "#{file}.co"))
|
|
88
|
-
end
|
|
89
|
+
end.tap(&:check!)
|
|
89
90
|
end
|
|
90
91
|
|
|
91
92
|
def flow_names(side)
|
|
@@ -96,12 +97,12 @@ module Vangrail
|
|
|
96
97
|
end
|
|
97
98
|
|
|
98
99
|
def prompt_for(task)
|
|
99
|
-
entry = prompts.
|
|
100
|
+
entry = prompts.detect { |p| p['task'].to_s == task.to_s }
|
|
100
101
|
entry && entry['content'].to_s
|
|
101
102
|
end
|
|
102
103
|
|
|
103
104
|
def model_for(type)
|
|
104
|
-
models.
|
|
105
|
+
models.detect { |m| m['type'].to_s == type.to_s }
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
# Builds the engine this configuration describes.
|
|
@@ -109,17 +110,30 @@ module Vangrail
|
|
|
109
110
|
# `chat:` overrides where model-backed actions call, which is what tests and
|
|
110
111
|
# a caller with its own client pass. `actions:` adds or replaces actions by
|
|
111
112
|
# name, so a team's own check joins the built-ins without touching the gem.
|
|
112
|
-
|
|
113
|
+
#
|
|
114
|
+
# `stdlib:` prepends the deterministic input and context rails this gem
|
|
115
|
+
# ships (patterns, paraphrase, language posture). Off by default so a
|
|
116
|
+
# folder still describes one set of rails on either runtime; on so a
|
|
117
|
+
# folder whose judge is down still refuses a reworded injection and
|
|
118
|
+
# still refuses to call an unread language a clean pass.
|
|
119
|
+
def engine(provider: nil, chat: nil, actions: {}, on_error: :allow, cache: true, stdlib: false)
|
|
113
120
|
registry = self_check_actions(provider, chat).merge(actions)
|
|
114
121
|
Engine.new(
|
|
115
|
-
input: rails_for(:input, registry),
|
|
116
|
-
context: rails_for(:context, registry),
|
|
122
|
+
input: compose(:input, rails_for(:input, registry), stdlib),
|
|
123
|
+
context: compose(:context, rails_for(:context, registry), stdlib),
|
|
117
124
|
output: rails_for(:output, registry),
|
|
118
125
|
on_error: on_error,
|
|
119
|
-
cache: cache
|
|
126
|
+
cache: cache,
|
|
120
127
|
)
|
|
121
128
|
end
|
|
122
129
|
|
|
130
|
+
# Same engine, already carrying a session and an admission gate, so a
|
|
131
|
+
# retrieved page enters as data rather than as a raw string a caller
|
|
132
|
+
# might paste into the question.
|
|
133
|
+
def conversation(prior:, stdlib: true, **kwargs)
|
|
134
|
+
Conversation.new(engine(stdlib: stdlib), prior: prior, **kwargs)
|
|
135
|
+
end
|
|
136
|
+
|
|
123
137
|
def rails_for(side, registry)
|
|
124
138
|
flow_names(side).map do |flow_name|
|
|
125
139
|
unless program.flow(flow_name)
|
|
@@ -134,6 +148,12 @@ module Vangrail
|
|
|
134
148
|
|
|
135
149
|
private
|
|
136
150
|
|
|
151
|
+
def compose(side, flows, stdlib)
|
|
152
|
+
return flows unless stdlib
|
|
153
|
+
|
|
154
|
+
Builder.deterministic(side) + flows
|
|
155
|
+
end
|
|
156
|
+
|
|
137
157
|
# The three tasks a stock configuration expects, each backed by a rail this
|
|
138
158
|
# gem implements. A configuration that names none of them gets none of them.
|
|
139
159
|
def self_check_actions(provider, chat)
|
|
@@ -153,7 +173,7 @@ module Vangrail
|
|
|
153
173
|
policy: prompt_for(task),
|
|
154
174
|
model: entry['model'],
|
|
155
175
|
chat: chat,
|
|
156
|
-
provider: provider_for(entry, provider)
|
|
176
|
+
provider: provider_for(entry, provider),
|
|
157
177
|
)
|
|
158
178
|
end
|
|
159
179
|
|
|
@@ -195,24 +215,24 @@ module Vangrail
|
|
|
195
215
|
models: [
|
|
196
216
|
model_entry('main', main_model, base_url),
|
|
197
217
|
model_entry('self_check_input', judge_model, base_url),
|
|
198
|
-
model_entry('self_check_output', judge_model, base_url)
|
|
218
|
+
model_entry('self_check_output', judge_model, base_url),
|
|
199
219
|
],
|
|
200
220
|
rails: {
|
|
201
221
|
'input' => { 'flows' => ['self check input'] },
|
|
202
|
-
'output' => { 'flows' => ['self check output'] }
|
|
222
|
+
'output' => { 'flows' => ['self check output'] },
|
|
203
223
|
},
|
|
204
224
|
prompts: [
|
|
205
225
|
{ 'task' => 'self_check_input', 'content' => self_check_prompt(:input, subject) },
|
|
206
|
-
{ 'task' => 'self_check_output', 'content' => self_check_prompt(:output, subject) }
|
|
226
|
+
{ 'task' => 'self_check_output', 'content' => self_check_prompt(:output, subject) },
|
|
207
227
|
],
|
|
208
228
|
instructions: [
|
|
209
229
|
{
|
|
210
230
|
'type' => 'general',
|
|
211
231
|
'content' => "You answer questions about #{subject}. Every factual clause " \
|
|
212
232
|
'comes from a supplied passage. Where the passages do not cover ' \
|
|
213
|
-
'the question, say so.'
|
|
214
|
-
}
|
|
215
|
-
]
|
|
233
|
+
'the question, say so.',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
216
236
|
)
|
|
217
237
|
end
|
|
218
238
|
|