ollama-ruby 0.12.1 → 0.14.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -0
  3. data/CHANGES.md +39 -0
  4. data/README.md +70 -144
  5. data/Rakefile +5 -17
  6. data/bin/ollama_cli +37 -6
  7. data/lib/ollama/client/command.rb +2 -2
  8. data/lib/ollama/dto.rb +4 -0
  9. data/lib/ollama/version.rb +1 -1
  10. data/lib/ollama.rb +0 -11
  11. data/ollama-ruby.gemspec +11 -22
  12. data/spec/ollama/message_spec.rb +9 -0
  13. metadata +25 -255
  14. data/bin/ollama_chat +0 -1248
  15. data/config/redis.conf +0 -5
  16. data/docker-compose.yml +0 -10
  17. data/lib/ollama/documents/cache/common.rb +0 -36
  18. data/lib/ollama/documents/cache/memory_cache.rb +0 -44
  19. data/lib/ollama/documents/cache/records.rb +0 -87
  20. data/lib/ollama/documents/cache/redis_backed_memory_cache.rb +0 -39
  21. data/lib/ollama/documents/cache/redis_cache.rb +0 -68
  22. data/lib/ollama/documents/cache/sqlite_cache.rb +0 -215
  23. data/lib/ollama/documents/splitters/character.rb +0 -72
  24. data/lib/ollama/documents/splitters/semantic.rb +0 -91
  25. data/lib/ollama/documents.rb +0 -184
  26. data/lib/ollama/utils/cache_fetcher.rb +0 -38
  27. data/lib/ollama/utils/chooser.rb +0 -52
  28. data/lib/ollama/utils/colorize_texts.rb +0 -65
  29. data/lib/ollama/utils/fetcher.rb +0 -175
  30. data/lib/ollama/utils/file_argument.rb +0 -34
  31. data/lib/ollama/utils/math.rb +0 -48
  32. data/lib/ollama/utils/tags.rb +0 -67
  33. data/spec/assets/embeddings.json +0 -1
  34. data/spec/assets/prompt.txt +0 -1
  35. data/spec/ollama/documents/cache/memory_cache_spec.rb +0 -97
  36. data/spec/ollama/documents/cache/redis_backed_memory_cache_spec.rb +0 -118
  37. data/spec/ollama/documents/cache/redis_cache_spec.rb +0 -121
  38. data/spec/ollama/documents/cache/sqlite_cache_spec.rb +0 -141
  39. data/spec/ollama/documents/splitters/character_spec.rb +0 -110
  40. data/spec/ollama/documents/splitters/semantic_spec.rb +0 -56
  41. data/spec/ollama/documents_spec.rb +0 -162
  42. data/spec/ollama/utils/cache_fetcher_spec.rb +0 -43
  43. data/spec/ollama/utils/colorize_texts_spec.rb +0 -13
  44. data/spec/ollama/utils/fetcher_spec.rb +0 -137
  45. data/spec/ollama/utils/file_argument_spec.rb +0 -17
  46. data/spec/ollama/utils/tags_spec.rb +0 -53
@@ -1,184 +0,0 @@
1
- require 'numo/narray'
2
- require 'digest'
3
- require 'kramdown/ansi'
4
-
5
- class Ollama::Documents
6
- end
7
- module Ollama::Documents::Cache
8
- end
9
- require 'ollama/documents/cache/records'
10
- require 'ollama/documents/cache/memory_cache'
11
- require 'ollama/documents/cache/redis_cache'
12
- require 'ollama/documents/cache/redis_backed_memory_cache'
13
- require 'ollama/documents/cache/sqlite_cache'
14
- module Ollama::Documents::Splitters
15
- end
16
- require 'ollama/documents/splitters/character'
17
- require 'ollama/documents/splitters/semantic'
18
-
19
- class Ollama::Documents
20
- include Kramdown::ANSI::Width
21
- include Ollama::Documents::Cache
22
-
23
- Record = Class.new Ollama::Documents::Cache::Records::Record
24
-
25
- def initialize(ollama:, model:, model_options: nil, collection: nil, embedding_length: 1_024, cache: MemoryCache, database_filename: nil, redis_url: nil, debug: false)
26
- collection ||= default_collection
27
- @ollama, @model, @model_options, @collection, @debug =
28
- ollama, model, model_options, collection.to_sym, debug
29
- database_filename ||= ':memory:'
30
- @cache = connect_cache(cache, redis_url, embedding_length, database_filename)
31
- end
32
-
33
- def default_collection
34
- :default
35
- end
36
-
37
- attr_reader :ollama, :model, :collection, :cache
38
-
39
- def collection=(new_collection)
40
- @collection = new_collection.to_sym
41
- @cache.prefix = prefix
42
- end
43
-
44
- def add(inputs, batch_size: nil, source: nil, tags: [])
45
- inputs = Array(inputs)
46
- batch_size ||= 10
47
- tags = Ollama::Utils::Tags.new(tags, source:)
48
- if source
49
- tags.add(File.basename(source).gsub(/\?.*/, ''), source:)
50
- end
51
- inputs.map! { |i|
52
- text = i.respond_to?(:read) ? i.read : i.to_s
53
- text
54
- }
55
- inputs.reject! { |i| exist?(i) }
56
- inputs.empty? and return self
57
- if @debug
58
- puts Ollama::Utils::ColorizeTexts.new(inputs)
59
- end
60
- batches = inputs.each_slice(batch_size).
61
- with_infobar(
62
- label: "Add #{truncate(tags.to_s(link: false), percentage: 25)}",
63
- total: inputs.size
64
- )
65
- batches.each do |batch|
66
- embeddings = fetch_embeddings(model:, options: @model_options, input: batch)
67
- batch.zip(embeddings) do |text, embedding|
68
- norm = @cache.norm(embedding)
69
- self[text] = Record[text:, embedding:, norm:, source:, tags: tags.to_a]
70
- end
71
- infobar.progress by: batch.size
72
- end
73
- infobar.newline
74
- self
75
- end
76
- alias << add
77
-
78
- def [](text)
79
- @cache[key(text)]
80
- end
81
-
82
- def []=(text, record)
83
- @cache[key(text)] = record
84
- end
85
-
86
- def exist?(text)
87
- @cache.key?(key(text))
88
- end
89
-
90
- def delete(text)
91
- @cache.delete(key(text))
92
- end
93
-
94
- def size
95
- @cache.size
96
- end
97
-
98
- def clear(tags: nil)
99
- @cache.clear(tags:)
100
- self
101
- end
102
-
103
- def find(string, tags: nil, prompt: nil, max_records: nil)
104
- needle = convert_to_vector(string, prompt:)
105
- @cache.find_records(needle, tags:, max_records: nil)
106
- end
107
-
108
- def find_where(string, text_size: nil, text_count: nil, **opts)
109
- if text_count
110
- opts[:max_records] = text_count
111
- end
112
- records = find(string, **opts)
113
- size, count = 0, 0
114
- records.take_while do |record|
115
- if text_size and (size += record.text.size) > text_size
116
- next false
117
- end
118
- if text_count and (count += 1) > text_count
119
- next false
120
- end
121
- true
122
- end
123
- end
124
-
125
- def collections
126
- ([ default_collection ] + @cache.collections('%s-' % self.class)).uniq
127
- end
128
-
129
- def tags
130
- @cache.tags
131
- end
132
-
133
- private
134
-
135
- def connect_cache(cache_class, redis_url, embedding_length, database_filename)
136
- cache = nil
137
- if (cache_class.instance_method(:redis) rescue nil)
138
- begin
139
- cache = cache_class.new(prefix:, url: redis_url, object_class: Record)
140
- cache.size
141
- rescue Redis::CannotConnectError
142
- STDERR.puts(
143
- "Cannot connect to redis URL #{redis_url.inspect}, "\
144
- "falling back to MemoryCache."
145
- )
146
- end
147
- elsif cache_class == SQLiteCache
148
- cache = cache_class.new(
149
- prefix:,
150
- embedding_length:,
151
- filename: database_filename,
152
- debug: @debug
153
- )
154
- end
155
- ensure
156
- cache ||= MemoryCache.new(prefix:,)
157
- cache.respond_to?(:find_records) or cache.extend(Records::FindRecords)
158
- cache.extend(Records::Tags)
159
- if cache.respond_to?(:redis)
160
- cache.extend(Records::RedisFullEach)
161
- end
162
- return cache
163
- end
164
-
165
- def convert_to_vector(input, prompt: nil)
166
- if prompt
167
- input = prompt % input
168
- end
169
- input.is_a?(String) and input = fetch_embeddings(model:, input:).first
170
- @cache.convert_to_vector(input)
171
- end
172
-
173
- def fetch_embeddings(model:, input:, options: nil)
174
- @ollama.embed(model:, input:, options:).embeddings
175
- end
176
-
177
- def prefix
178
- '%s-%s-' % [ self.class, @collection ]
179
- end
180
-
181
- def key(input)
182
- Digest::SHA256.hexdigest(input)
183
- end
184
- end
@@ -1,38 +0,0 @@
1
- require 'digest/md5'
2
-
3
- class Ollama::Utils::CacheFetcher
4
- def initialize(cache)
5
- @cache = cache
6
- end
7
-
8
- def get(url, &block)
9
- block or raise ArgumentError, 'require block argument'
10
- body = @cache[key(:body, url)]
11
- content_type = @cache[key(:content_type, url)]
12
- content_type = MIME::Types[content_type].first
13
- if body && content_type
14
- io = StringIO.new(body)
15
- io.rewind
16
- io.extend(Ollama::Utils::Fetcher::HeaderExtension)
17
- io.content_type = content_type
18
- block.(io)
19
- end
20
- end
21
-
22
- def put(url, io)
23
- io.rewind
24
- body = io.read
25
- body.empty? and return
26
- content_type = io.content_type
27
- content_type.nil? and return
28
- @cache.set(key(:body, url), body, ex: io.ex)
29
- @cache.set(key(:content_type, url), content_type.to_s, ex: io.ex)
30
- self
31
- end
32
-
33
- private
34
-
35
- def key(type, url)
36
- [ type, Digest::MD5.hexdigest(url) ] * ?-
37
- end
38
- end
@@ -1,52 +0,0 @@
1
- require 'amatch'
2
- require 'search_ui'
3
- require 'term/ansicolor'
4
-
5
- module Ollama::Utils::Chooser
6
- include SearchUI
7
- include Term::ANSIColor
8
-
9
- module_function
10
-
11
- # The choose method presents a list of entries and prompts the user
12
- # for input, allowing them to select one entry based on their input.
13
- #
14
- # @param entries [Array] the list of entries to present to the user
15
- # @param prompt [String] the prompt message to display when asking for input (default: 'Search? %s')
16
- # @param return_immediately [Boolean] whether to immediately return the first entry if there is only one or nil when there is none (default: false)
17
- #
18
- # @return [Object] the selected entry, or nil if no entry was chosen
19
- #
20
- # @example
21
- # choose(['entry1', 'entry2'], prompt: 'Choose an option:')
22
- def choose(entries, prompt: 'Search? %s', return_immediately: false)
23
- if return_immediately && entries.size <= 1
24
- return entries.first
25
- end
26
- entry = Search.new(
27
- prompt:,
28
- match: -> answer {
29
- matcher = Amatch::PairDistance.new(answer.downcase)
30
- matches = entries.map { |n| [ n, -matcher.similar(n.to_s.downcase) ] }.
31
- select { |_, s| s < 0 }.sort_by(&:last).map(&:first)
32
- matches.empty? and matches = entries
33
- matches.first(Tins::Terminal.lines - 1)
34
- },
35
- query: -> _answer, matches, selector {
36
- matches.each_with_index.map { |m, i|
37
- i == selector ? "#{blue{?⮕}} #{on_blue{m}}" : " #{m.to_s}"
38
- } * ?\n
39
- },
40
- found: -> _answer, matches, selector {
41
- matches[selector]
42
- },
43
- output: STDOUT
44
- ).start
45
- if entry
46
- entry
47
- else
48
- print clear_screen, move_home
49
- nil
50
- end
51
- end
52
- end
@@ -1,65 +0,0 @@
1
- require 'term/ansicolor'
2
- require 'kramdown/ansi'
3
-
4
- class Ollama::Utils::ColorizeTexts
5
- include Math
6
- include Term::ANSIColor
7
- include Kramdown::ANSI::Width
8
-
9
- # Initializes a new instance of Ollama::Utils::ColorizeTexts
10
- #
11
- # @param [Array<String>] texts the array of strings to be displayed with colors
12
- #
13
- # @return [Ollama::Utils::ColorizeTexts] an instance of Ollama::Utils::ColorizeTexts
14
- def initialize(*texts)
15
- texts = texts.map(&:to_a)
16
- @texts = Array(texts.flatten)
17
- end
18
-
19
- # Returns a string representation of the object, including all texts content,
20
- # colored differently and their sizes.
21
- #
22
- # @return [String] The formatted string.
23
- def to_s
24
- result = +''
25
- @texts.each_with_index do |t, i|
26
- color = colors[(t.hash ^ i.hash) % colors.size]
27
- wrap(t, percentage: 90).each_line { |l|
28
- result << on_color(color) { color(text_color(color)) { l } }
29
- }
30
- result << "\n##{bold{t.size.to_s}} \n\n"
31
- end
32
- result
33
- end
34
-
35
- private
36
-
37
- # Returns the nearest RGB color to the given ANSI color
38
- #
39
- # @param [color] color The ANSI color attribute
40
- #
41
- # @return [Array<RGBTriple>] An array containing two RGB colors, one for black and
42
- # one for white text, where the first is the closest match to the input color
43
- # when printed on a black background, and the second is the closest match
44
- # when printed on a white background.
45
- def text_color(color)
46
- color = Term::ANSIColor::Attribute[color]
47
- [
48
- Attribute.nearest_rgb_color('#000'),
49
- Attribute.nearest_rgb_color('#fff'),
50
- ].max_by { |t| t.distance_to(color) }
51
- end
52
-
53
- # Returns an array of colors for each step in the gradient
54
- #
55
- # @return [Array<Array<Integer>>] An array of RGB color arrays
56
- def colors
57
- @colors ||= (0..255).map { |i|
58
- [
59
- 128 + 128 * sin(PI * i / 32.0),
60
- 128 + 128 * sin(PI * i / 64.0),
61
- 128 + 128 * sin(PI * i / 128.0),
62
- ].map { _1.clamp(0, 255).round }
63
- }
64
- end
65
- end
@@ -1,175 +0,0 @@
1
- require 'tempfile'
2
- require 'tins/unit'
3
- require 'infobar'
4
- require 'mime-types'
5
- require 'stringio'
6
- require 'ollama/utils/cache_fetcher'
7
-
8
- class Ollama::Utils::Fetcher
9
- module HeaderExtension
10
- attr_accessor :content_type
11
-
12
- attr_accessor :ex
13
-
14
- def self.failed
15
- object = StringIO.new.extend(self)
16
- object.content_type = MIME::Types['text/plain'].first
17
- object
18
- end
19
- end
20
-
21
- class RetryWithoutStreaming < StandardError; end
22
-
23
- def self.get(url, **options, &block)
24
- cache = options.delete(:cache) and
25
- cache = Ollama::Utils::CacheFetcher.new(cache)
26
- if result = cache&.get(url, &block)
27
- infobar.puts "Getting #{url.to_s.inspect} from cache."
28
- return result
29
- else
30
- new(**options).send(:get, url) do |tmp|
31
- result = block.(tmp)
32
- if cache && !tmp.is_a?(StringIO)
33
- tmp.rewind
34
- cache.put(url, tmp)
35
- end
36
- result
37
- end
38
- end
39
- end
40
-
41
- def self.normalize_url(url)
42
- url = url.to_s
43
- url = URI.decode_uri_component(url)
44
- url = url.sub(/#.*/, '')
45
- URI::Parser.new.escape(url).to_s
46
- end
47
-
48
- def self.read(filename, &block)
49
- if File.exist?(filename)
50
- File.open(filename) do |file|
51
- file.extend(Ollama::Utils::Fetcher::HeaderExtension)
52
- file.content_type = MIME::Types.type_for(filename).first
53
- block.(file)
54
- end
55
- else
56
- STDERR.puts "File #{filename.to_s.inspect} doesn't exist."
57
- end
58
- end
59
-
60
- def self.execute(command, &block)
61
- Tempfile.open do |tmp|
62
- IO.popen(command) do |command|
63
- until command.eof?
64
- tmp.write command.read(1 << 14)
65
- end
66
- tmp.rewind
67
- tmp.extend(Ollama::Utils::Fetcher::HeaderExtension)
68
- tmp.content_type = MIME::Types['text/plain'].first
69
- block.(tmp)
70
- end
71
- end
72
- rescue => e
73
- STDERR.puts "Cannot execute #{command.inspect} (#{e})"
74
- if @debug && !e.is_a?(RuntimeError)
75
- STDERR.puts "#{e.backtrace * ?\n}"
76
- end
77
- yield HeaderExtension.failed
78
- end
79
-
80
- def initialize(debug: false, http_options: {})
81
- @debug = debug
82
- @started = false
83
- @streaming = true
84
- @http_options = http_options
85
- end
86
-
87
- private
88
-
89
- def excon(url, **options)
90
- url = self.class.normalize_url(url)
91
- Excon.new(url, options.merge(@http_options))
92
- end
93
-
94
- def get(url, &block)
95
- response = nil
96
- Tempfile.open do |tmp|
97
- infobar.label = 'Getting'
98
- if @streaming
99
- response = excon(url, headers:, response_block: callback(tmp)).request(method: :get)
100
- response.status != 200 || !@started and raise RetryWithoutStreaming
101
- decorate_io(tmp, response)
102
- infobar.finish
103
- block.(tmp)
104
- else
105
- response = excon(url, headers:, middlewares:).request(method: :get)
106
- if response.status != 200
107
- raise "invalid response status code"
108
- end
109
- body = response.body
110
- tmp.print body
111
- infobar.update(message: message(body.size, body.size), force: true)
112
- decorate_io(tmp, response)
113
- infobar.finish
114
- block.(tmp)
115
- end
116
- end
117
- rescue RetryWithoutStreaming
118
- @streaming = false
119
- retry
120
- rescue => e
121
- STDERR.puts "Cannot get #{url.to_s.inspect} (#{e}): #{response&.status_line || 'n/a'}"
122
- if @debug && !e.is_a?(RuntimeError)
123
- STDERR.puts "#{e.backtrace * ?\n}"
124
- end
125
- yield HeaderExtension.failed
126
- end
127
-
128
- def headers
129
- {
130
- 'User-Agent' => Ollama::Client.user_agent,
131
- }
132
- end
133
-
134
- def middlewares
135
- (Excon.defaults[:middlewares] + [ Excon::Middleware::RedirectFollower ]).uniq
136
- end
137
-
138
- private
139
-
140
- def decorate_io(tmp, response)
141
- tmp.rewind
142
- tmp.extend(HeaderExtension)
143
- if content_type = MIME::Types[response.headers['content-type']].first
144
- tmp.content_type = content_type
145
- end
146
- if cache_control = response.headers['cache-control'] and
147
- cache_control !~ /no-store|no-cache/ and
148
- ex = cache_control[/s-maxage\s*=\s*(\d+)/, 1] || cache_control[/max-age\s*=\s*(\d+)/, 1]
149
- then
150
- tmp.ex = ex.to_i
151
- end
152
- end
153
-
154
- def callback(tmp)
155
- -> chunk, remaining_bytes, total_bytes do
156
- total = total_bytes or next
157
- current = total_bytes - remaining_bytes
158
- if @started
159
- infobar.counter.progress(by: total - current)
160
- else
161
- @started = true
162
- infobar.counter.reset(total:, current:)
163
- end
164
- infobar.update(message: message(current, total), force: true)
165
- tmp.print(chunk)
166
- end
167
- end
168
-
169
- def message(current, total)
170
- progress = '%s/%s' % [ current, total ].map {
171
- Tins::Unit.format(_1, format: '%.2f %U')
172
- }
173
- '%l ' + progress + ' in %te, ETA %e @%E'
174
- end
175
- end
@@ -1,34 +0,0 @@
1
- module Ollama::Utils::FileArgument
2
- module_function
3
-
4
- # Returns the contents of a file or string, or a default value if neither is provided.
5
- #
6
- # @param [String] path_or_content The path to a file or a string containing
7
- # the content.
8
- #
9
- # @param [String] default The default value to return if no valid input is
10
- # given. Defaults to nil.
11
- #
12
- # @return [String] The contents of the file, the string, or the default value.
13
- #
14
- # @example Get the contents of a file
15
- # get_file_argument('path/to/file')
16
- #
17
- # @example Use a string as content
18
- # get_file_argument('string content')
19
- #
20
- # @example Return a default value if no valid input is given
21
- # get_file_argument(nil, default: 'default content')
22
- def get_file_argument(path_or_content, default: nil)
23
- if path_or_content.present? && path_or_content.size < 2 ** 15 &&
24
- File.basename(path_or_content).size < 2 ** 8 &&
25
- File.exist?(path_or_content)
26
- then
27
- File.read(path_or_content)
28
- elsif path_or_content.present?
29
- path_or_content
30
- else
31
- default
32
- end
33
- end
34
- end
@@ -1,48 +0,0 @@
1
- module Ollama::Utils::Math
2
- # Returns the cosine similarity between two vectors +a+ and +b+, 1.0 is
3
- # exactly the same, 0.0 means decorrelated.
4
- #
5
- # @param [Vector] a The first vector
6
- # @param [Vector] b The second vector
7
- # @option a_norm [Float] a The Euclidean norm of vector a (default: calculated from a)
8
- # @option b_norm [Float] b The Euclidean norm of vector b (default: calculated from b)
9
- #
10
- # @return [Float] The cosine similarity between the two vectors
11
- #
12
- # @example Calculate the cosine similarity between two vectors
13
- # cosine_similarity(a: [1, 2], b: [3, 4])
14
- #
15
- # @see #convert_to_vector
16
- # @see #norm
17
- def cosine_similarity(a:, b:, a_norm: norm(a), b_norm: norm(b))
18
- a, b = convert_to_vector(a), convert_to_vector(b)
19
- a.dot(b) / (a_norm * b_norm)
20
- end
21
-
22
- # Returns the Euclidean norm (magnitude) of a vector.
23
- #
24
- # @param vector [Array] The input vector.
25
- #
26
- # @return [Float] The magnitude of the vector.
27
- #
28
- # @example
29
- # norm([3, 4]) # => 5.0
30
- def norm(vector)
31
- s = 0.0
32
- vector.each { s += _1.abs2 }
33
- Math.sqrt(s)
34
- end
35
-
36
- # Converts an array to a Numo NArray.
37
- #
38
- # @param [Array] vector The input array to be converted.
39
- #
40
- # @return [Numo::NArray] The converted NArray, or the original if it's already a Numo NArray.
41
- #
42
- # @example Convert an array to a Numo NArray
43
- # convert_to_vector([1, 2, 3]) # => Numo::NArray[1, 2, 3]
44
- def convert_to_vector(vector)
45
- vector.is_a?(Numo::NArray) and return vector
46
- Numo::NArray[*vector]
47
- end
48
- end
@@ -1,67 +0,0 @@
1
- class Ollama::Utils::Tags
2
- class Tag < String
3
- include Term::ANSIColor
4
-
5
- def initialize(tag, source: nil)
6
- super(tag.to_s.gsub(/\A#+/, ''))
7
- self.source = source
8
- end
9
-
10
- attr_accessor :source
11
-
12
- alias_method :internal, :to_s
13
-
14
- def to_s(link: true)
15
- tag_string = start_with?(?#) ? super() : ?# + super()
16
- my_source = source
17
- if link && my_source
18
- unless my_source =~ %r(\A(https?|file)://)
19
- my_source = 'file://%s' % File.expand_path(my_source)
20
- end
21
- hyperlink(my_source) { tag_string }
22
- else
23
- tag_string
24
- end
25
- end
26
- end
27
-
28
- def initialize(tags = [], source: nil)
29
- tags = Array(tags)
30
- @set = []
31
- tags.each { |tag| add(tag, source:) }
32
- end
33
-
34
- def add(tag, source: nil)
35
- unless tag.is_a?(Tag)
36
- tag = Tag.new(tag, source:)
37
- end
38
- index = @set.bsearch_index { _1 >= tag }
39
- if index == nil
40
- @set.push(tag)
41
- elsif @set.at(index) != tag
42
- @set.insert(index, tag)
43
- end
44
- self
45
- end
46
-
47
- def empty?
48
- @set.empty?
49
- end
50
-
51
- def size
52
- @set.size
53
- end
54
-
55
- def clear
56
- @set.clear
57
- end
58
-
59
- def each(&block)
60
- @set.each(&block)
61
- end
62
- include Enumerable
63
-
64
- def to_s(link: true)
65
- @set.map { |tag| tag.to_s(link:) } * ' '
66
- end
67
- end