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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vangrail
|
|
4
|
+
class Client
|
|
5
|
+
# A guardrailed chat completion, read out of either server response shape.
|
|
6
|
+
#
|
|
7
|
+
# The OpenAI-compatible shape puts the answer in choices[0].message.content
|
|
8
|
+
# and rail bookkeeping under a top-level `guardrails` object. The older shape
|
|
9
|
+
# answers with a bare {role, content} message (or a list of them) and puts
|
|
10
|
+
# bookkeeping at the top level. Both appear in the wild depending on the
|
|
11
|
+
# server version, so this reads whichever is present.
|
|
12
|
+
class Completion
|
|
13
|
+
# Server-side names for the variables holding the rail that stopped a turn.
|
|
14
|
+
INPUT_RAIL_VAR = 'triggered_input_rail'
|
|
15
|
+
OUTPUT_RAIL_VAR = 'triggered_output_rail'
|
|
16
|
+
|
|
17
|
+
attr_reader :raw
|
|
18
|
+
|
|
19
|
+
def initialize(raw)
|
|
20
|
+
@raw = raw.is_a?(Hash) ? raw : {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def content
|
|
24
|
+
choice = choices.first
|
|
25
|
+
if choice.is_a?(Hash)
|
|
26
|
+
msg = choice['message'] || choice['delta'] || {}
|
|
27
|
+
return msg['content'].to_s if msg.is_a?(Hash) && msg.key?('content')
|
|
28
|
+
end
|
|
29
|
+
return raw['content'].to_s if raw.key?('content')
|
|
30
|
+
|
|
31
|
+
messages = raw['messages']
|
|
32
|
+
if messages.is_a?(Array)
|
|
33
|
+
last = messages.reverse.find { |m| m.is_a?(Hash) && m['role'].to_s == 'assistant' }
|
|
34
|
+
return last['content'].to_s if last
|
|
35
|
+
end
|
|
36
|
+
''
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def config_id
|
|
40
|
+
guardrails['config_id']
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The rail that stopped this turn, or nil. Reported only when the request
|
|
44
|
+
# asked for the output variables that carry it.
|
|
45
|
+
def triggered_input_rail
|
|
46
|
+
output_data[INPUT_RAIL_VAR]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def triggered_output_rail
|
|
50
|
+
output_data[OUTPUT_RAIL_VAR]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def triggered_rail
|
|
54
|
+
triggered_input_rail || triggered_output_rail
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Rails that ran, from options.log.activated_rails. Empty unless logging
|
|
58
|
+
# was requested; an empty list is not evidence that no rail ran.
|
|
59
|
+
def activated_rails
|
|
60
|
+
entries = log['activated_rails']
|
|
61
|
+
entries.is_a?(Array) ? entries : []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stopped_rails
|
|
65
|
+
activated_rails.select { |r| r.is_a?(Hash) && r['stop'] == true }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# True when a rail is known to have stopped the turn. Absent explicit
|
|
69
|
+
# signals this stays false, so a refusal the model wrote itself is never
|
|
70
|
+
# reported as a rail decision.
|
|
71
|
+
def blocked?
|
|
72
|
+
return true unless triggered_rail.nil?
|
|
73
|
+
|
|
74
|
+
!stopped_rails.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def allowed?
|
|
78
|
+
!blocked?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def model
|
|
82
|
+
raw['model']
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def guardrails
|
|
86
|
+
g = raw['guardrails']
|
|
87
|
+
g.is_a?(Hash) ? g : {}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def output_data
|
|
91
|
+
d = guardrails['output_data'] || raw['output_data']
|
|
92
|
+
d.is_a?(Hash) ? d : {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def log
|
|
96
|
+
l = guardrails['log'] || raw['log']
|
|
97
|
+
l.is_a?(Hash) ? l : {}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def llm_calls
|
|
101
|
+
calls = log['llm_calls']
|
|
102
|
+
calls.is_a?(Array) ? calls : []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Total tokens the rails plus the answer spent, when the server reports it.
|
|
106
|
+
def total_tokens
|
|
107
|
+
usage = raw['usage']
|
|
108
|
+
return usage['total_tokens'] if usage.is_a?(Hash) && usage['total_tokens']
|
|
109
|
+
|
|
110
|
+
sums = llm_calls.filter_map { |c| c['total_tokens'] if c.is_a?(Hash) }
|
|
111
|
+
sums.empty? ? nil : sums.sum
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def choices
|
|
117
|
+
c = raw['choices']
|
|
118
|
+
c.is_a?(Array) ? c : []
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'client/completion'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
require_relative 'http'
|
|
6
|
+
require_relative 'result'
|
|
7
|
+
|
|
8
|
+
module Vangrail
|
|
9
|
+
# Interop with a NeMo Guardrails server that already exists.
|
|
10
|
+
#
|
|
11
|
+
# Nothing in this gem needs one. It is here for the case where a team already
|
|
12
|
+
# runs the Python service, wants its configs to stay the source of truth, and
|
|
13
|
+
# wants Ruby to call rather than reimplement. Reach for Config#engine first:
|
|
14
|
+
# it runs the same folder in this process with nothing to deploy.
|
|
15
|
+
#
|
|
16
|
+
# `/v1/checks` is the endpoint that matches what a rail actually wants, and it
|
|
17
|
+
# answers in the same three states this gem models: passed, modified, blocked.
|
|
18
|
+
# Older servers do not have it, so `check` falls back to a chat completion with
|
|
19
|
+
# generation switched off and reads the rail-tracking variables out of that.
|
|
20
|
+
class Client
|
|
21
|
+
CONFIGS_PATH = '/v1/rails/configs'
|
|
22
|
+
CHECKS_PATH = '/v1/checks'
|
|
23
|
+
COMPLETIONS_PATH = '/v1/chat/completions'
|
|
24
|
+
PROTOCOLS = %i[auto nested flat].freeze
|
|
25
|
+
|
|
26
|
+
RAIL_VARS = [Completion::INPUT_RAIL_VAR, Completion::OUTPUT_RAIL_VAR].freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :config_id, :model, :protocol, :http
|
|
29
|
+
|
|
30
|
+
# True once /v1/checks has answered, false once it has 404ed, nil until one
|
|
31
|
+
# of those happens, so a caller can report "not yet known" honestly.
|
|
32
|
+
attr_reader :checks_supported
|
|
33
|
+
|
|
34
|
+
def initialize(base_url:, config_id: nil, model: nil, api_key: nil, protocol: :auto,
|
|
35
|
+
open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT, read_timeout: HTTP::DEFAULT_READ_TIMEOUT,
|
|
36
|
+
http: nil)
|
|
37
|
+
unless PROTOCOLS.include?(protocol)
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"protocol must be one of #{PROTOCOLS.join(', ')}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@config_id = config_id
|
|
43
|
+
@model = model
|
|
44
|
+
@protocol = protocol
|
|
45
|
+
@checks_supported = nil
|
|
46
|
+
@http = http || HTTP.new(
|
|
47
|
+
base_url: base_url, api_key: api_key,
|
|
48
|
+
open_timeout: open_timeout, read_timeout: read_timeout
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def base_url
|
|
53
|
+
http.base_url
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def configs
|
|
57
|
+
body = http.get_json(CONFIGS_PATH)
|
|
58
|
+
list = body.is_a?(Array) ? body : Array(body['configs'])
|
|
59
|
+
list.filter_map { |entry| entry.is_a?(Hash) ? entry['id'] : entry.to_s }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def available?
|
|
63
|
+
http.reachable?(CONFIGS_PATH)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def check_input(text, config_id: nil)
|
|
67
|
+
check([{ 'role' => 'user', 'content' => text.to_s }], rail: :input, config_id: config_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_output(text, user_input: nil, config_id: nil)
|
|
71
|
+
messages = []
|
|
72
|
+
messages << { 'role' => 'user', 'content' => user_input.to_s } unless user_input.to_s.strip.empty?
|
|
73
|
+
messages << { 'role' => 'assistant', 'content' => text.to_s }
|
|
74
|
+
check(messages, rail: :output, config_id: config_id)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Runs rails without generation and returns a Result.
|
|
78
|
+
def check(messages, rail:, config_id: nil)
|
|
79
|
+
chosen = config_id || @config_id
|
|
80
|
+
if @checks_supported != false
|
|
81
|
+
begin
|
|
82
|
+
return from_checks(http.post_json(CHECKS_PATH, checks_payload(messages, rail, chosen)), rail)
|
|
83
|
+
rescue HTTPError => e
|
|
84
|
+
raise unless e.status == 404
|
|
85
|
+
|
|
86
|
+
@checks_supported = false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
from_completion(chat(messages: messages, config_id: chosen, options: check_options(rail)), rail)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A full guardrailed completion, for the case where the server generates the
|
|
93
|
+
# answer as well as checking it.
|
|
94
|
+
def chat(messages:, config_id: nil, config_ids: nil, options: nil, context: nil,
|
|
95
|
+
thread_id: nil, model: nil, **extra)
|
|
96
|
+
opts = merge_options(options)
|
|
97
|
+
body = extra.merge(messages: normalize(messages))
|
|
98
|
+
chosen = { config_id: config_id || @config_id, config_ids: config_ids }
|
|
99
|
+
Completion.new(send_payload(body, chosen, opts, context, thread_id, model))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def checks_payload(messages, rail, config_id)
|
|
105
|
+
payload = { 'messages' => normalize(messages), 'rail_types' => [rail.to_s] }
|
|
106
|
+
payload['config_id'] = config_id if config_id
|
|
107
|
+
payload
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# {"status": "passed"|"modified"|"blocked", "content": "...", "rail": "..."}
|
|
111
|
+
def from_checks(body, rail)
|
|
112
|
+
@checks_supported = true
|
|
113
|
+
status = body['status'].to_s
|
|
114
|
+
unless Result::STATUSES.map(&:to_s).include?(status)
|
|
115
|
+
raise ProtocolError, "/v1/checks answered status #{status.inspect}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Result.new(status: status.to_sym, rail: body['rail'] || rail.to_s,
|
|
119
|
+
content: body['content'], raw: body)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def from_completion(completion, rail)
|
|
123
|
+
return Result.passed(rail: rail.to_s, raw: completion.raw) if completion.allowed?
|
|
124
|
+
|
|
125
|
+
reason = completion.triggered_rail || completion.stopped_rails.first&.dig('name')
|
|
126
|
+
Result.blocked(rail: reason || rail.to_s, content: completion.content, reason: reason,
|
|
127
|
+
raw: completion.raw)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def check_options(rail)
|
|
131
|
+
{ 'rails' => { 'input' => rail == :input, 'output' => rail == :output, 'dialog' => false } }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def merge_options(options)
|
|
135
|
+
base = { 'output_vars' => RAIL_VARS.dup, 'log' => { 'activated_rails' => true } }
|
|
136
|
+
return base unless options.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
stringified = deep_stringify(options)
|
|
139
|
+
merged = base.merge(stringified)
|
|
140
|
+
merged['output_vars'] = (RAIL_VARS + Array(stringified['output_vars'])).uniq
|
|
141
|
+
merged['log'] = base['log'].merge(stringified['log']) if stringified['log'].is_a?(Hash)
|
|
142
|
+
merged
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def send_payload(body, chosen, opts, context, thread_id, model)
|
|
146
|
+
case protocol
|
|
147
|
+
when :flat
|
|
148
|
+
http.post_json(COMPLETIONS_PATH, flat_payload(body, chosen, opts, context, thread_id, model))
|
|
149
|
+
when :nested
|
|
150
|
+
http.post_json(COMPLETIONS_PATH, nested_payload(body, chosen, opts, context, thread_id, model))
|
|
151
|
+
else
|
|
152
|
+
try_nested_then_flat(body, chosen, opts, context, thread_id, model)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def try_nested_then_flat(body, chosen, opts, context, thread_id, model)
|
|
157
|
+
answer = http.post_json(COMPLETIONS_PATH, nested_payload(body, chosen, opts, context, thread_id, model))
|
|
158
|
+
@protocol = :nested
|
|
159
|
+
answer
|
|
160
|
+
rescue HTTPError => e
|
|
161
|
+
raise unless schema_rejection?(e)
|
|
162
|
+
|
|
163
|
+
answer = http.post_json(COMPLETIONS_PATH, flat_payload(body, chosen, opts, context, thread_id, model))
|
|
164
|
+
@protocol = :flat
|
|
165
|
+
answer
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A 400/422 naming a field is the server saying it speaks the other shape.
|
|
169
|
+
# Any other status is a real failure and stays raised.
|
|
170
|
+
def schema_rejection?(error)
|
|
171
|
+
return false unless [400, 422].include?(error.status)
|
|
172
|
+
|
|
173
|
+
error.body.match?(/extra fields not permitted|unexpected keyword|field required|guardrails|config_id/i)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def nested_payload(body, chosen, opts, context, thread_id, model)
|
|
177
|
+
guardrails = {}
|
|
178
|
+
guardrails['config_id'] = chosen[:config_id] if chosen[:config_id]
|
|
179
|
+
guardrails['config_ids'] = Array(chosen[:config_ids]) if chosen[:config_ids]
|
|
180
|
+
guardrails['options'] = opts if opts
|
|
181
|
+
guardrails['context'] = deep_stringify(context) if context
|
|
182
|
+
guardrails['thread_id'] = thread_id if thread_id
|
|
183
|
+
|
|
184
|
+
payload = body.transform_keys(&:to_s)
|
|
185
|
+
payload['model'] = model || @model if model || @model
|
|
186
|
+
payload['guardrails'] = guardrails unless guardrails.empty?
|
|
187
|
+
payload
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def flat_payload(body, chosen, opts, context, thread_id, model)
|
|
191
|
+
payload = body.transform_keys(&:to_s)
|
|
192
|
+
payload['config_id'] = chosen[:config_id] if chosen[:config_id]
|
|
193
|
+
payload['config_ids'] = Array(chosen[:config_ids]) if chosen[:config_ids]
|
|
194
|
+
payload['options'] = opts if opts
|
|
195
|
+
payload['context'] = deep_stringify(context) if context
|
|
196
|
+
payload['thread_id'] = thread_id if thread_id
|
|
197
|
+
payload['model'] = model || @model if model || @model
|
|
198
|
+
payload
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def normalize(messages)
|
|
202
|
+
Array(messages).map do |m|
|
|
203
|
+
if m.is_a?(Hash)
|
|
204
|
+
{ 'role' => (m['role'] || m[:role]).to_s, 'content' => (m['content'] || m[:content]).to_s }
|
|
205
|
+
else
|
|
206
|
+
{ 'role' => 'user', 'content' => m.to_s }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def deep_stringify(value)
|
|
212
|
+
case value
|
|
213
|
+
when Hash then value.to_h { |k, v| [k.to_s, deep_stringify(v)] }
|
|
214
|
+
when Array then value.map { |v| deep_stringify(v) }
|
|
215
|
+
else value
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vangrail
|
|
4
|
+
module Colang
|
|
5
|
+
# The whole grammar this gem executes, as data. Everything the parser can
|
|
6
|
+
# produce is one of these, which is also the honest statement of what a
|
|
7
|
+
# Colang file may contain here: anything else is refused at parse time
|
|
8
|
+
# rather than skipped at run time.
|
|
9
|
+
Program = Struct.new(:flows, :bot_messages, :user_messages, keyword_init: true) do
|
|
10
|
+
def flow(name)
|
|
11
|
+
flows[name.to_s]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def flow_names
|
|
15
|
+
flows.keys
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def bot_message(name)
|
|
19
|
+
bot_messages[name.to_s]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def merge(other)
|
|
23
|
+
Program.new(
|
|
24
|
+
flows: flows.merge(other.flows),
|
|
25
|
+
bot_messages: bot_messages.merge(other.bot_messages),
|
|
26
|
+
user_messages: user_messages.merge(other.user_messages)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Flow = Struct.new(:name, :body, :subflow, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# $var = execute action(key="value")
|
|
34
|
+
Assign = Struct.new(:variable, :expression, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
# execute action(key="value")
|
|
37
|
+
Execute = Struct.new(:action, :arguments, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
# bot <message name>
|
|
40
|
+
Bot = Struct.new(:message, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
# stop
|
|
43
|
+
Stop = Struct.new(:reason, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
If = Struct.new(:condition, :then_body, :else_body, keyword_init: true)
|
|
46
|
+
|
|
47
|
+
# Conditions
|
|
48
|
+
Var = Struct.new(:name, keyword_init: true)
|
|
49
|
+
Not = Struct.new(:expression, keyword_init: true)
|
|
50
|
+
Compare = Struct.new(:left, :operator, :right, keyword_init: true)
|
|
51
|
+
Literal = Struct.new(:value, keyword_init: true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
require_relative 'ast'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Colang
|
|
8
|
+
# Runs one flow and reports what it decided.
|
|
9
|
+
#
|
|
10
|
+
# The mapping onto rail statuses is the whole design:
|
|
11
|
+
#
|
|
12
|
+
# `bot <message>` then `stop` -> blocked, with the message as content
|
|
13
|
+
# assignment to $user_message or
|
|
14
|
+
# $bot_message -> modified, with the new value
|
|
15
|
+
# falls off the end -> passed
|
|
16
|
+
#
|
|
17
|
+
# A `stop` without a preceding `bot` still blocks; it just has no refusal
|
|
18
|
+
# text to show. Actions are plain Ruby callables, so the flow decides the
|
|
19
|
+
# control shape and Ruby does the work.
|
|
20
|
+
class Interpreter
|
|
21
|
+
# Variables a flow assigns to when it rewrites the turn rather than
|
|
22
|
+
# refusing it, matching the names the toolkit's own flows use.
|
|
23
|
+
CONTENT_VARS = %w[user_message bot_message user_input bot_response].freeze
|
|
24
|
+
|
|
25
|
+
Outcome = Struct.new(:status, :content, :reason, :variables, keyword_init: true)
|
|
26
|
+
|
|
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
|
+
attr_reader :program, :actions
|
|
38
|
+
|
|
39
|
+
def initialize(program:, actions:)
|
|
40
|
+
@program = program
|
|
41
|
+
@actions = actions
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run(flow_name, context = {})
|
|
45
|
+
flow = program.flow(flow_name)
|
|
46
|
+
raise UnknownAction, "no flow named #{flow_name.inspect}" unless flow
|
|
47
|
+
|
|
48
|
+
state = { 'context' => context }
|
|
49
|
+
last_bot = nil
|
|
50
|
+
begin
|
|
51
|
+
last_bot = execute(flow.body, state, context)
|
|
52
|
+
rescue Stopped => e
|
|
53
|
+
return Outcome.new(status: :blocked, content: e.message_text, reason: flow_name, variables: state)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
rewrite = CONTENT_VARS.filter_map { |v| state[v] if state.key?(v) }.last
|
|
57
|
+
if rewrite
|
|
58
|
+
return Outcome.new(status: :modified, content: rewrite.to_s, reason: flow_name,
|
|
59
|
+
variables: state)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Outcome.new(status: :passed, content: last_bot, reason: nil, variables: state)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Returns the text of the last `bot` statement reached, so a flow that
|
|
68
|
+
# says something without stopping still hands that text back.
|
|
69
|
+
def execute(body, state, context)
|
|
70
|
+
said = nil
|
|
71
|
+
body.each do |node|
|
|
72
|
+
case node
|
|
73
|
+
when Assign then state[node.variable] = evaluate(node.expression, state, context)
|
|
74
|
+
when Execute then evaluate(node, state, context)
|
|
75
|
+
when Bot then said = bot_text(node.message)
|
|
76
|
+
when Stop then raise Stopped, said
|
|
77
|
+
when If then said = branch(node, state, context) || said
|
|
78
|
+
else raise ColangError, "cannot execute #{node.class}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
said
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def branch(node, state, context)
|
|
85
|
+
taken = truthy?(evaluate(node.condition, state, context)) ? node.then_body : node.else_body
|
|
86
|
+
execute(Array(taken), state, context)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def evaluate(node, state, context)
|
|
90
|
+
case node
|
|
91
|
+
when Execute then call_action(node, state, context)
|
|
92
|
+
when Var then state[node.name]
|
|
93
|
+
when Literal then node.value
|
|
94
|
+
when Not then !truthy?(evaluate(node.expression, state, context))
|
|
95
|
+
when Compare then compare(node, state, context)
|
|
96
|
+
else node
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def compare(node, state, context)
|
|
101
|
+
left = evaluate(node.left, state, context)
|
|
102
|
+
right = evaluate(node.right, state, context)
|
|
103
|
+
node.operator == '==' ? left == right : left != right
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def call_action(node, state, context)
|
|
107
|
+
action = actions[node.action]
|
|
108
|
+
raise UnknownAction, "no action registered for #{node.action.inspect}" unless action
|
|
109
|
+
|
|
110
|
+
args = node.arguments.transform_values { |v| v.is_a?(Var) ? state[v.name] : v }
|
|
111
|
+
action.call(args, context)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def bot_text(message)
|
|
115
|
+
alternatives = program.bot_message(message)
|
|
116
|
+
raise UnknownAction, "no `define bot #{message}` for this flow" unless alternatives
|
|
117
|
+
|
|
118
|
+
# Deterministic: a guardrail refusal that varies between runs makes an
|
|
119
|
+
# incident report harder to read for no benefit.
|
|
120
|
+
alternatives.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def truthy?(value)
|
|
124
|
+
return false if value.nil? || value == false
|
|
125
|
+
return false if value.respond_to?(:empty?) && value.empty?
|
|
126
|
+
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'parser'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Colang
|
|
7
|
+
# The flows a configuration can name without defining.
|
|
8
|
+
#
|
|
9
|
+
# A config folder that lists `self check input` under `rails.input.flows`
|
|
10
|
+
# and ships no `.co` file is the ordinary case: those flows are built into
|
|
11
|
+
# the toolkit. They are written out here as Colang rather than hand-built
|
|
12
|
+
# AST, so the parser this gem ships is the same one that reads them, and a
|
|
13
|
+
# bug in it fails loudly on the built-ins instead of hiding until someone
|
|
14
|
+
# writes their own flow.
|
|
15
|
+
module Library
|
|
16
|
+
SOURCE = <<~COLANG
|
|
17
|
+
define flow self check input
|
|
18
|
+
$allowed = execute self_check_input
|
|
19
|
+
if not $allowed
|
|
20
|
+
bot refuse to respond
|
|
21
|
+
stop
|
|
22
|
+
|
|
23
|
+
define flow self check output
|
|
24
|
+
$allowed = execute self_check_output
|
|
25
|
+
if not $allowed
|
|
26
|
+
bot refuse to respond
|
|
27
|
+
stop
|
|
28
|
+
|
|
29
|
+
define flow self check facts
|
|
30
|
+
$accurate = execute self_check_facts
|
|
31
|
+
if not $accurate
|
|
32
|
+
bot inform answer unknown
|
|
33
|
+
stop
|
|
34
|
+
|
|
35
|
+
define bot refuse to respond
|
|
36
|
+
"I'm sorry, I can't respond to that."
|
|
37
|
+
|
|
38
|
+
define bot inform answer unknown
|
|
39
|
+
"I don't know the answer to that."
|
|
40
|
+
COLANG
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def program
|
|
45
|
+
@program ||= Parser.parse(SOURCE, filename: 'built-in')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def flow_names
|
|
49
|
+
program.flow_names
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|