btape 0.1.0 → 0.3.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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require_relative 'duration'
5
+ require_relative 'null_logger'
6
+
7
+ module Btape
8
+ # Performs the parsed commands against a browser, turning any failure into a
9
+ # ScriptError that points back at the line it came from.
10
+ #
11
+ # It lives apart from Runner because Runner's job is the recording session
12
+ # around the script — the browser, the frames, the GIF — while this is the
13
+ # script itself, and only this grows with each new command.
14
+ class Executor
15
+ # Commands that do something at run time, and the method that does it.
16
+ # Output, Viewport and Set are read before the run starts and have no
17
+ # behaviour of their own here.
18
+ HANDLERS = {
19
+ 'Goto' => :goto, 'Click' => :click, 'Type' => :enter, 'Sleep' => :pause,
20
+ 'Screenshot' => :screenshot, 'Evaluate' => :evaluate,
21
+ 'WaitFor' => :wait_for_element, 'WaitForJS' => :wait_for_js,
22
+ 'Frame' => :frame, 'Press' => :press
23
+ }.freeze
24
+
25
+ MAIN_FRAME = 'main'
26
+
27
+ def initialize(browser:, recorder:, settings:, lock: Monitor.new, logger: NullLogger.new)
28
+ @browser = browser
29
+ # Elements and JavaScript are looked up in the current frame, which
30
+ # starts as the page itself and moves when a Frame command says so.
31
+ @target = browser
32
+ @recorder = recorder
33
+ @settings = settings
34
+ @lock = lock
35
+ @logger = logger
36
+ end
37
+
38
+ def call(commands)
39
+ commands.each do |command|
40
+ @logger.debug("btape: line #{command.line_number}: #{command.name}")
41
+ perform(command)
42
+ # The run's own deadline arrives asynchronously and usually lands inside
43
+ # a command, which would otherwise be reported as that command failing.
44
+ # A caller has to be able to tell "the run outlasted Set Timeout" from
45
+ # "this line is broken", so it goes out as it came in.
46
+ rescue TimeoutError
47
+ raise
48
+ rescue StandardError => e
49
+ raise ScriptError.new(command.line_number, "#{command.name} failed: #{e.message}")
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # Held across each exchange with the browser, and shared with the
56
+ # recorder, so a screenshot taken on the recorder's thread is never in
57
+ # flight at the same time as a command on this one.
58
+ #
59
+ # It is deliberately not held for a whole command: Sleep and the waiting
60
+ # commands spend most of their time not talking to the browser at all,
61
+ # and holding the lock through that would leave interval recording with
62
+ # nothing to capture for the duration.
63
+ def locked(&)
64
+ @lock.synchronize(&)
65
+ end
66
+
67
+ def perform(command)
68
+ handler = HANDLERS[command.name]
69
+ send(handler, *command.arguments) if handler
70
+ end
71
+
72
+ def goto(url)
73
+ locked { @browser.go_to(url) }
74
+ # Whatever frame we were in belongs to the page we just left.
75
+ @target = @browser
76
+ end
77
+
78
+ # Points the following commands at an iframe, so a tape can reach an API
79
+ # inside it — a slide deck's own navigation, say — rather than only what
80
+ # the outer document exposes. Nesting works by switching again from
81
+ # within, and `Frame main` returns to the page.
82
+ def frame(selector)
83
+ return @target = @browser if selector == MAIN_FRAME
84
+
85
+ # Reading .frame off the node is a further exchange with the browser, so
86
+ # it belongs under the lock as much as the lookup that found the node.
87
+ node = find(selector)
88
+ @target = locked { node.frame } || raise("#{selector} is not a frame")
89
+ end
90
+
91
+ def press(key, count = '1')
92
+ Integer(count, 10).times { locked { @browser.keyboard.type(key.to_sym) } }
93
+ end
94
+
95
+ def click(selector)
96
+ locked { find(selector).click }
97
+ end
98
+
99
+ def enter(selector, text)
100
+ locked do
101
+ element = find(selector)
102
+ element.focus
103
+ element.type(text)
104
+ end
105
+ end
106
+
107
+ def pause(duration)
108
+ sleep(Duration.parse(duration))
109
+ end
110
+
111
+ def screenshot(name = nil)
112
+ @recorder.capture(name: name)
113
+ end
114
+
115
+ def evaluate(expression)
116
+ locked { @target.evaluate(expression) }
117
+ end
118
+
119
+ def wait_for_element(selector, timeout = nil)
120
+ wait_until(timeout, "#{selector} to appear") { element(selector) }
121
+ end
122
+
123
+ def wait_for_js(expression, timeout = nil)
124
+ wait_until(timeout, "#{expression} to be true") { evaluate(expression) }
125
+ end
126
+
127
+ # Polls until the block has been satisfied WaitStable times in a row. The
128
+ # streak matters for pages that report readiness before they have settled:
129
+ # a single true reading can land mid-render, several in a row cannot.
130
+ #
131
+ # A block that raises counts as not-yet-satisfied, since a page part-way
132
+ # through loading will happily throw on a property that is about to
133
+ # exist. The last error is reported if the wait times out, so a broken
134
+ # expression still surfaces rather than being silently polled forever.
135
+ def wait_until(timeout, description)
136
+ timeout = timeout ? Duration.parse(timeout) : @settings.wait_timeout
137
+ deadline = monotonic + timeout
138
+ stable = 0
139
+ failure = nil
140
+
141
+ loop do
142
+ satisfied = begin
143
+ yield
144
+ rescue StandardError => e
145
+ failure = e
146
+ false
147
+ end
148
+
149
+ stable = satisfied ? stable + 1 : 0
150
+ return if stable >= @settings.wait_stable
151
+ raise Error, timed_out(description, timeout, failure) if monotonic >= deadline
152
+
153
+ sleep(@settings.wait_interval)
154
+ end
155
+ end
156
+
157
+ def timed_out(description, timeout, failure)
158
+ message = "timed out after #{timeout}s waiting for #{description}"
159
+ failure ? "#{message} (last error: #{failure.message})" : message
160
+ end
161
+
162
+ def monotonic
163
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
164
+ end
165
+
166
+ def find(selector)
167
+ element(selector) || raise("element not found: #{selector}")
168
+ end
169
+
170
+ def element(selector)
171
+ locked do
172
+ next @target.at_css(selector) unless selector.start_with?('text=')
173
+
174
+ literal = xpath_literal(selector.delete_prefix('text='))
175
+ @target.at_xpath("//*[normalize-space(text())=#{literal}]")
176
+ end
177
+ end
178
+
179
+ def xpath_literal(text)
180
+ return %("#{text}") unless text.include?('"')
181
+
182
+ parts = text.split('"', -1).map { |part| %("#{part}") }
183
+ "concat(#{parts.join(%q(, '"', ))})"
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'optparse'
5
+ require_relative 'error'
6
+ require_relative 'llm/client'
7
+ require_relative 'llm/generator'
8
+ require_relative 'null_logger'
9
+
10
+ module Btape
11
+ # `btape generate` — asks a model running on this machine for a tape and
12
+ # writes it out, having first checked that what came back is one.
13
+ #
14
+ # It is a separate command rather than a flag on a recording because it
15
+ # records nothing: no browser is opened, and the answer is a file to read,
16
+ # edit and then run like any other tape.
17
+ class GenerateCommand
18
+ USAGE = 'Usage: btape generate [options] DESCRIPTION'
19
+
20
+ Options = Struct.new(:help, :output_path, :context_path, :client, :verbose, keyword_init: true)
21
+
22
+ def initialize(out: $stdout, err: $stderr, stdin: $stdin, generator: nil)
23
+ @out = out
24
+ @err = err
25
+ @stdin = stdin
26
+ @generator = generator
27
+ end
28
+
29
+ def run(argv)
30
+ options = Options.new(help: false, client: {}, verbose: false)
31
+ words = parse_options(argv, options)
32
+ return print_help if options.help
33
+
34
+ write(generator(options).call(description(words), context: context(options)), options)
35
+ 0
36
+ end
37
+
38
+ private
39
+
40
+ def write(script, options)
41
+ return @out.print(script) unless options.output_path
42
+
43
+ path = File.expand_path(options.output_path)
44
+ File.write(path, script, encoding: Encoding::UTF_8)
45
+ @out.puts "Wrote #{path}"
46
+ end
47
+
48
+ # The description is the words left after the flags, or standard input
49
+ # when there are none — a paragraph about what to record is easier to
50
+ # write in a file, or to pipe in, than to quote on a command line.
51
+ def description(words)
52
+ return words.join(' ') unless words.empty?
53
+ raise Error, USAGE if @stdin.tty?
54
+
55
+ piped = @stdin.read.to_s
56
+ raise Error, USAGE if piped.strip.empty?
57
+
58
+ piped
59
+ end
60
+
61
+ def context(options)
62
+ return nil unless options.context_path
63
+
64
+ File.read(File.expand_path(options.context_path), encoding: Encoding::UTF_8)
65
+ end
66
+
67
+ def generator(options)
68
+ @generator || LLM::Generator.new(client: LLM::Client.new(**options.client), logger: logger(options))
69
+ end
70
+
71
+ def logger(options)
72
+ return NullLogger.new unless options.verbose
73
+
74
+ Logger.new(@err, level: Logger::DEBUG, formatter: ->(_severity, _time, _program, message) { "#{message}\n" })
75
+ end
76
+
77
+ def parse_options(argv, options)
78
+ option_parser(options).parse(argv)
79
+ rescue OptionParser::ParseError => e
80
+ raise Error, e.message
81
+ end
82
+
83
+ def option_parser(options)
84
+ OptionParser.new do |parser|
85
+ parser.banner = USAGE
86
+ parser.on('--llm-url URL', 'The OpenAI-compatible model server to ask') do |url|
87
+ options.client[:base_url] = url
88
+ end
89
+ parser.on('--model NAME', 'Ask for this model rather than whichever one is loaded') do |name|
90
+ options.client[:model] = name
91
+ end
92
+ parser.on('--temperature N', Float, 'How freely the model writes; 0.2 by default') do |value|
93
+ options.client[:temperature] = value
94
+ end
95
+ parser.on('--context FILE', 'Give the model this file as context: selectors, notes, markup') do |path|
96
+ options.context_path = path
97
+ end
98
+ parser.on('-o', '--out FILE', 'Write the tape here rather than to standard output') do |path|
99
+ options.output_path = path
100
+ end
101
+ parser.on('--verbose', 'Report each attempt on stderr') { options.verbose = true }
102
+ parser.on('-h', '--help', 'Show this message') { options.help = true }
103
+ end
104
+ end
105
+
106
+ def print_help
107
+ @out.puts USAGE
108
+ @out.puts
109
+ @out.puts 'Options:'
110
+ @out.puts ' --llm-url URL The OpenAI-compatible model server to ask'
111
+ @out.puts ' --model NAME Ask for this model rather than whichever one is loaded'
112
+ @out.puts ' --temperature N How freely the model writes; 0.2 by default'
113
+ @out.puts ' --context FILE Give the model this file as context: selectors, notes, markup'
114
+ @out.puts ' -o, --out FILE Write the tape here rather than to standard output'
115
+ @out.puts ' --verbose Report each attempt on stderr'
116
+ @out.puts
117
+ @out.puts "Defaults to #{LLM::Client::DEFAULT_BASE_URL}, which is where LM Studio serves."
118
+ @out.puts 'Ollama serves the same API at http://localhost:11434/v1.'
119
+ @out.puts 'BTAPE_LLM_URL, BTAPE_LLM_MODEL and BTAPE_LLM_KEY are used when the flags are not given.'
120
+ 0
121
+ end
122
+ end
123
+ end
@@ -2,62 +2,123 @@
2
2
 
3
3
  require 'chunky_png'
4
4
  require_relative 'lzw_compressor'
5
+ require_relative 'palette'
5
6
 
6
7
  module Btape
7
- # A deliberately small GIF89a encoder. RGB332 gives a fixed 256-colour palette,
8
- # avoiding a native image or video dependency.
8
+ # A deliberately small GIF89a encoder, so that recording needs no native
9
+ # image or video dependency.
9
10
  class GifEncoder
10
- def initialize(delay: 10)
11
- @delay = delay
11
+ # A GIF frame delay is two bytes of hundredths of a second.
12
+ MAX_DELAY = 0xffff
13
+ # Eight bits per pixel: one index into a 256-colour table.
14
+ MIN_CODE_SIZE = "\x08"
15
+
16
+ def self.for(settings)
17
+ new(
18
+ delay: (settings.frame_delay * 100).round,
19
+ loop_count: settings.loop_count,
20
+ quantizer: settings.quantizer,
21
+ scale: settings.scale,
22
+ width: settings.output_width
23
+ )
24
+ end
25
+
26
+ def initialize(delay: 10, loop_count: 0, quantizer: :adaptive, scale: 1.0, width: nil, dedupe: true)
27
+ @delay = delay.clamp(1, MAX_DELAY)
28
+ @loop_count = loop_count
29
+ @quantizer = quantizer
30
+ @scale = scale
31
+ @width = width
32
+ @dedupe = dedupe
12
33
  @compressor = LzwCompressor.new
13
34
  end
14
35
 
15
- def write(png_paths, output)
16
- raise Error, 'no screenshots were captured' if png_paths.empty?
36
+ # Returns the GIF as a binary String. Frames are PNG paths or ChunkyPNG
37
+ # canvases. Callers that are not writing to the filesystem — attaching the
38
+ # GIF to a record, say — want this rather than a file to read back.
39
+ def encode(frames)
40
+ raise Error, 'no screenshots were captured' if frames.empty?
17
41
 
18
- images = png_paths.map { |path| ChunkyPNG::Image.from_file(path) }
42
+ images = frames.map { |frame| resize(image(frame)) }
19
43
  width, height = images.first.dimension.to_a
20
44
  raise Error, 'captured screenshots have different dimensions' unless images.all? do |image|
21
45
  image.dimension.to_a == [width, height]
22
46
  end
23
47
 
24
- File.binwrite(output, gif(images, width, height))
48
+ palette = Palette.build(@quantizer, images)
49
+ gif(collapse(images.map { |image| index(image, palette) }), width, height, palette)
50
+ end
51
+
52
+ # Writes to a path, or to anything that responds to write.
53
+ def write(frames, output)
54
+ data = encode(frames)
55
+ return output.write(data) if output.respond_to?(:write)
56
+
57
+ File.binwrite(output, data)
25
58
  end
26
59
 
27
60
  private
28
61
 
29
- def gif(images, width, height)
62
+ def image(frame)
63
+ frame.is_a?(ChunkyPNG::Canvas) ? frame : ChunkyPNG::Image.from_file(frame)
64
+ end
65
+
66
+ def resize(image)
67
+ target = @width || (image.width * @scale).round
68
+ return image if target == image.width || target < 1
69
+
70
+ # A wide enough source rounds its scaled height down to nothing, and a
71
+ # zero-height canvas becomes a GIF no decoder can show.
72
+ height = [(image.height * target.to_f / image.width).round, 1].max
73
+ image.resample_bilinear(target, height)
74
+ end
75
+
76
+ def index(image, palette)
77
+ image.pixels.map { |pixel| palette.index_for(pixel) }
78
+ end
79
+
80
+ # A run that sits on one page for a second is a dozen identical frames.
81
+ # Holding the first for longer says the same thing in a fraction of the
82
+ # bytes, and spares a decoder the redundant frames.
83
+ def collapse(frames)
84
+ frames.each_with_object([]) do |pixels, collapsed|
85
+ previous = collapsed.last
86
+ if @dedupe && previous && previous.first == pixels
87
+ previous[1] = [previous[1] + @delay, MAX_DELAY].min
88
+ else
89
+ collapsed << [pixels, @delay]
90
+ end
91
+ end
92
+ end
93
+
94
+ def gif(frames, width, height, palette)
30
95
  data = +'GIF89a'.b
31
- data << [width, height, 0b11110111, 0, 0].pack('vvCCC') << palette
32
- data << "!\xFF\x0BNETSCAPE2.0\x03\x01\x00\x00\x00".b
33
- images.each { |image| write_frame(data, image, width, height) }
96
+ data << [width, height, 0b11110111, 0, 0].pack('vvCCC') << palette.to_gct
97
+ data << netscape
98
+ frames.each { |pixels, delay| write_frame(data, pixels, delay, width, height) }
34
99
  data << ';'.b
35
100
  end
36
101
 
37
- def write_frame(data, image, width, height)
38
- data << "!\xF9\x04\x00".b << [@delay].pack('v') << "\x00\x00".b
39
- data << ','.b << [0, 0, width, height, 0].pack('vvvvC')
40
- compressed = lzw(image)
41
- data << "\x08".b
42
- compressed.bytes.each_slice(255) { |slice| data << slice.length.chr << slice.pack('C*') }
43
- data << "\x00".b
102
+ # The application extension that carries the loop count. Zero loops
103
+ # forever, which is what an unattended recording usually wants.
104
+ def netscape
105
+ +"!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop_count].pack('v') << "\x00".b
44
106
  end
45
107
 
46
- def palette
47
- (0..255).map { |i| [((i >> 5) & 7) * 255 / 7, ((i >> 2) & 7) * 255 / 7, (i & 3) * 255 / 3].pack('C3') }.join.b
108
+ def write_frame(data, pixels, delay, width, height)
109
+ data << "!\xF9\x04\x00".b << [delay].pack('v') << "\x00\x00".b
110
+ data << ','.b << [0, 0, width, height, 0].pack('vvvvC')
111
+ data << MIN_CODE_SIZE.b
112
+ lzw(pixels).bytes.each_slice(255) { |slice| data << slice.length.chr << slice.pack('C*') }
113
+ data << "\x00".b
48
114
  end
49
115
 
50
- def lzw(image)
51
- pixels = image.pixels.map { |pixel| rgb332(pixel) }
116
+ def lzw(pixels)
52
117
  codes = []
53
118
  @compressor.call(pixels) { |code, width| codes << [code, width] }
54
119
  pack_codes(codes)
55
120
  end
56
121
 
57
- def rgb332(pixel)
58
- (ChunkyPNG::Color.r(pixel) & 0xe0) | ((ChunkyPNG::Color.g(pixel) & 0xe0) >> 3) | (ChunkyPNG::Color.b(pixel) >> 6)
59
- end
60
-
61
122
  def pack_codes(codes)
62
123
  buffer = 0
63
124
  count = 0
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require_relative '../error'
7
+
8
+ module Btape
9
+ # Talking to a language model, which btape does for one purpose: writing a
10
+ # tape from a description of the recording someone wants.
11
+ module LLM
12
+ # Raised when the model, or the server in front of it, could not answer.
13
+ class Error < Btape::Error; end
14
+
15
+ # A chat client for an OpenAI-compatible server. Nothing here knows which
16
+ # one it is talking to: LM Studio, Ollama, llama.cpp's server and vLLM all
17
+ # answer `/v1/chat/completions` in the same shape, so pointing `--llm-url`
18
+ # at one of them is the whole of the configuration.
19
+ #
20
+ # The defaults assume the model is running on this machine, where there is
21
+ # usually no key to send and no reason for the request to leave it.
22
+ class Client
23
+ # LM Studio's; Ollama serves the same API on 11434.
24
+ DEFAULT_BASE_URL = 'http://localhost:1234/v1'
25
+ DEFAULT_TEMPERATURE = 0.2
26
+ # A local model on a CPU answers in tens of seconds rather than the
27
+ # hundreds of milliseconds a hosted one would take.
28
+ DEFAULT_TIMEOUT = 300
29
+
30
+ # How much of a server's error body to quote back. Enough to name the
31
+ # problem, not so much that a stack trace fills the terminal.
32
+ ERROR_BODY_LIMIT = 500
33
+
34
+ attr_reader :base_url
35
+
36
+ def initialize(base_url: nil, model: nil, api_key: nil, temperature: nil, timeout: nil)
37
+ @base_url = (base_url || ENV.fetch('BTAPE_LLM_URL', DEFAULT_BASE_URL)).chomp('/')
38
+ @model = model || ENV.fetch('BTAPE_LLM_MODEL', nil)
39
+ @api_key = api_key || ENV.fetch('BTAPE_LLM_KEY', nil)
40
+ @temperature = temperature || DEFAULT_TEMPERATURE
41
+ @timeout = timeout || DEFAULT_TIMEOUT
42
+ end
43
+
44
+ # Sends the conversation and returns the reply's text.
45
+ def complete(messages)
46
+ payload = { model: model, messages: messages, temperature: @temperature, stream: false }
47
+ body = post('/chat/completions', payload)
48
+ content = body.dig('choices', 0, 'message', 'content')
49
+ # Not every server answers with a String there: some hand back the
50
+ # content as a list of parts, and a reasoning model may answer with
51
+ # nothing but its thoughts. Both are this client's error to report,
52
+ # rather than a NoMethodError from inside it.
53
+ raise Error, "#{@base_url} answered without a message" unless content.is_a?(String) && !content.strip.empty?
54
+
55
+ content
56
+ end
57
+
58
+ # The model to ask for. A server hosting one model still wants to be
59
+ # told which, and the name differs with every download — so when nobody
60
+ # has said, the loaded one is asked for by name.
61
+ def model
62
+ @model ||= first_loaded_model
63
+ end
64
+
65
+ private
66
+
67
+ def first_loaded_model
68
+ entry = get('/models')['data']&.first
69
+ name = entry['id'] if entry.is_a?(Hash)
70
+ raise Error, "no model is loaded at #{@base_url}; load one, or name it with --model" unless name
71
+
72
+ name
73
+ end
74
+
75
+ def post(path, payload)
76
+ request = Net::HTTP::Post.new(url_for(path))
77
+ request['content-type'] = 'application/json'
78
+ request.body = JSON.generate(payload)
79
+ send_request(request)
80
+ end
81
+
82
+ def get(path)
83
+ send_request(Net::HTTP::Get.new(url_for(path)))
84
+ end
85
+
86
+ def url_for(path)
87
+ URI.parse("#{@base_url}#{path}")
88
+ end
89
+
90
+ def send_request(request)
91
+ request['authorization'] = "Bearer #{@api_key}" if @api_key
92
+ response = transport(request.uri).request(request)
93
+ parse(response)
94
+ # A local server generating a long answer is the one most likely to be
95
+ # killed part way through it, and the connection dropping is how that
96
+ # arrives here.
97
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Net::HTTPBadResponse
98
+ raise Error, "#{@base_url} closed the connection before answering; did the model run out of memory?"
99
+ # Everything else the network can say — refused, unreachable, no such
100
+ # host, a route that went away — is the same thing to whoever ran the
101
+ # command, and it is worth naming the address they can go and check.
102
+ rescue SocketError, SystemCallError => e
103
+ raise Error, "could not reach a model server at #{@base_url} (#{e.message}); is it running?"
104
+ # Net::OpenTimeout and Net::ReadTimeout are both Timeout::Errors, so
105
+ # this covers a server that accepted the connection and then thought
106
+ # about it for too long as well as one that never accepted it.
107
+ rescue Timeout::Error
108
+ raise Error, "#{@base_url} did not answer within #{@timeout}s"
109
+ end
110
+
111
+ def transport(uri)
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = uri.scheme == 'https'
114
+ http.open_timeout = @timeout
115
+ http.read_timeout = @timeout
116
+ http
117
+ end
118
+
119
+ def parse(response)
120
+ body = begin
121
+ JSON.parse(response.body.to_s)
122
+ rescue JSON::ParserError
123
+ nil
124
+ end
125
+ raise Error, failure_message(response, body) unless response.is_a?(Net::HTTPSuccess)
126
+ raise Error, "#{@base_url} answered with something that is not JSON" if body.nil?
127
+
128
+ body
129
+ end
130
+
131
+ # An OpenAI-compatible server reports its own failures as
132
+ # `{"error": {"message": ...}}`, and the ones that do not are quoted as
133
+ # they came so the reason is not lost to the status code alone.
134
+ def failure_message(response, body)
135
+ detail = body.is_a?(Hash) ? (body.dig('error', 'message') || body['error']) : nil
136
+ detail = response.body.to_s.strip[0, ERROR_BODY_LIMIT] if detail.nil? || detail.to_s.empty?
137
+ "#{@base_url} answered #{response.code}#{": #{detail}" unless detail.to_s.empty?}"
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../error'
4
+ require_relative '../null_logger'
5
+ require_relative '../parser'
6
+ require_relative 'client'
7
+ require_relative 'prompt'
8
+
9
+ module Btape
10
+ module LLM
11
+ # Turns a description of a recording into a tape, by asking a model for
12
+ # one and then holding it to the language: what comes back is parsed
13
+ # before it is handed on, and a reply that does not parse goes back with
14
+ # the parser's complaint attached.
15
+ #
16
+ # That loop is the point of generating a tape rather than pasting one out
17
+ # of a chat window. A small local model reliably invents a command or
18
+ # forgets a quote; it just as reliably fixes it when told which line.
19
+ class Generator
20
+ ATTEMPTS = 3
21
+
22
+ # Models are told not to fence their answer, and fence it anyway.
23
+ FENCED = /```[\w+-]*\n(.*?)```/m
24
+
25
+ def initialize(client: Client.new, attempts: ATTEMPTS, logger: NullLogger.new)
26
+ @client = client
27
+ @attempts = attempts
28
+ @logger = logger
29
+ end
30
+
31
+ # Returns the tape as a String. Raises LLM::Error if the model could not
32
+ # be reached, or would not produce a tape that parses.
33
+ def call(description, context: nil)
34
+ raise Error, 'nothing was said about what to record' if description.to_s.strip.empty?
35
+
36
+ messages = [
37
+ { role: 'system', content: Prompt.system },
38
+ { role: 'user', content: Prompt.user(description.strip, context: context) }
39
+ ]
40
+ attempt(messages)
41
+ end
42
+
43
+ private
44
+
45
+ def attempt(messages)
46
+ reason = nil
47
+ @attempts.times do |index|
48
+ @logger.debug("btape: asking #{@client.model} for a tape (attempt #{index + 1} of #{@attempts})")
49
+ script = extract(@client.complete(messages))
50
+ reason = fault(script)
51
+ return script if reason.nil?
52
+
53
+ @logger.debug("btape: the tape did not parse (#{reason}); asking again")
54
+ messages += [{ role: 'assistant', content: script }, { role: 'user', content: Prompt.repair(reason) }]
55
+ end
56
+ raise Error, "the model did not write a tape that parses, after #{@attempts} attempts: #{reason}"
57
+ end
58
+
59
+ # Why this is not a tape, or nil when it is one. The parser answers most
60
+ # of it; Output is checked here because it is the one requirement that
61
+ # belongs to the script as a whole rather than to any line of it, and a
62
+ # tape without it fails at the start of a recording instead.
63
+ #
64
+ # A second Output is worth another round too: the runner takes the first
65
+ # and says nothing about the rest, so a model that wrote two would
66
+ # otherwise be told it had got it right while half of what it wrote was
67
+ # quietly dropped.
68
+ def fault(script)
69
+ commands = Parser.new.parse(script)
70
+ return 'it contained no commands' if commands.empty?
71
+
72
+ outputs = commands.count { |command| command.name == 'Output' }
73
+ return 'it has no Output line, so there is nowhere for the GIF to go' if outputs.zero?
74
+ return "it has #{outputs} Output lines, and a run writes one GIF" if outputs > 1
75
+
76
+ nil
77
+ rescue ScriptError => e
78
+ e.message
79
+ end
80
+
81
+ # The tape out of the reply. A fenced block is taken as the answer and
82
+ # any prose around it dropped; everything else is passed through whole,
83
+ # so that a stray sentence reaches the parser and comes back as
84
+ # something the model is asked to fix.
85
+ def extract(reply)
86
+ match = FENCED.match(reply)
87
+ "#{(match ? match[1] : reply).strip}\n"
88
+ end
89
+ end
90
+ end
91
+ end