ruby_llm-registry 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.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyLLM
6
+ module Registry
7
+ module Adapters
8
+ # MongoDB-backed prompt repository.
9
+ class MongoDB < Base
10
+ def initialize(collection: nil, database: nil, collection_name: "ruby_llm_registry_prompts")
11
+ @collection = collection || resolve_collection(database, collection_name)
12
+ end
13
+
14
+ def get(path, version: nil, label: nil)
15
+ docs = collection.find(path: path).to_a
16
+ raise PromptNotFoundError, "Prompt path not found: #{path}" if docs.empty?
17
+
18
+ doc = if version
19
+ docs.find { |record| record["version"] == Version.parse(version).to_s }
20
+ elsif label
21
+ label = label.to_sym
22
+ docs.find { |record| labels_for(record).include?(label) } || docs.find { |record| record["version"] == label.to_s }
23
+ else
24
+ docs.max_by { |record| Version.parse(record["version"]) }
25
+ end
26
+
27
+ raise PromptNotFoundError, "Prompt not found: #{path}" unless doc
28
+
29
+ prompt_from_document(doc)
30
+ end
31
+
32
+ def available_versions(path)
33
+ collection.find(path: path).map { |doc| Version.parse(doc["version"]) }.sort
34
+ end
35
+
36
+ def store(prompt, overwrite: false)
37
+ existing = collection.find(path: prompt.path, version: prompt.version.to_s).first
38
+ if existing && !overwrite
39
+ raise Error, "Prompt #{prompt.path}@#{prompt.version} already exists"
40
+ end
41
+
42
+ doc = serialize_prompt(prompt)
43
+ existing ? collection.replace_one({ path: prompt.path, version: prompt.version.to_s }, doc) : collection.insert_one(doc)
44
+ prompt
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :collection
50
+
51
+ def resolve_collection(database, collection_name)
52
+ raise ArgumentError, "Provide a collection or database" unless database
53
+
54
+ database[collection_name]
55
+ end
56
+
57
+ def serialize_prompt(prompt)
58
+ {
59
+ "path" => prompt.path,
60
+ "namespace" => prompt.namespace,
61
+ "name" => prompt.name,
62
+ "version" => prompt.version.to_s,
63
+ "body" => prompt.body,
64
+ "labels" => prompt.labels.map(&:to_s),
65
+ "metadata" => prompt.metadata,
66
+ "required_vars" => prompt.required_vars.map(&:to_s),
67
+ "source_path" => prompt.source_path
68
+ }
69
+ end
70
+
71
+ def prompt_from_document(doc)
72
+ Prompt.new(
73
+ name: doc["name"],
74
+ namespace: doc["namespace"],
75
+ version: doc["version"],
76
+ body: doc["body"],
77
+ source_path: doc["source_path"],
78
+ labels: doc["labels"] || [],
79
+ metadata: doc["metadata"] || {},
80
+ required_vars: doc["required_vars"] || []
81
+ )
82
+ end
83
+
84
+ def labels_for(doc)
85
+ Array(doc["labels"]).map(&:to_sym)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyLLM
6
+ module Registry
7
+ module Adapters
8
+ # S3-backed prompt repository.
9
+ class S3 < Base
10
+ def initialize(client:, bucket:, prefix: "prompts")
11
+ @client = client
12
+ @bucket = bucket
13
+ @prefix = prefix.to_s.sub(%r{\A/+|/+$}, "")
14
+ end
15
+
16
+ def get(path, version: nil, label: nil)
17
+ key = object_key(path, version: version, label: label)
18
+ raise PromptNotFoundError, "Prompt not found: #{path}" unless key
19
+
20
+ payload = client.get_object(bucket: bucket, key: key).body.read
21
+ Importer.new(payload, format: :json, path: path).to_prompt
22
+ rescue NoMethodError
23
+ raise PromptNotFoundError, "Prompt not found: #{path}"
24
+ end
25
+
26
+ def available_versions(path)
27
+ prefix_key = object_prefix(path)
28
+ response = client.list_objects_v2(bucket: bucket, prefix: prefix_key)
29
+ Array(response.contents).map do |object|
30
+ version_from_key(object.key)
31
+ end.compact.map { |value| Version.parse(value) }.sort
32
+ end
33
+
34
+ def store(prompt, overwrite: false)
35
+ key = object_key(prompt.path, version: prompt.version.to_s)
36
+ if !overwrite && object_exists?(key)
37
+ raise Error, "Prompt #{prompt.path}@#{prompt.version} already exists"
38
+ end
39
+
40
+ client.put_object(
41
+ bucket: bucket,
42
+ key: key,
43
+ body: Exporter.new(prompt).to_json,
44
+ content_type: "application/json"
45
+ )
46
+ prompt
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :client, :bucket, :prefix
52
+
53
+ def object_key(path, version: nil, label: nil)
54
+ if version
55
+ "#{object_prefix(path)}/v#{Version.parse(version)}.json"
56
+ elsif label
57
+ label = label.to_sym
58
+ manifest_key = "#{object_prefix(path)}/#{label}.json"
59
+ object_exists?(manifest_key) ? manifest_key : nil
60
+ else
61
+ latest_key(path)
62
+ end
63
+ end
64
+
65
+ def latest_key(path)
66
+ keys = list_keys(path)
67
+ versioned = keys.map { |key| [version_from_key(key), key] }.compact
68
+ versioned.max_by { |version, _key| Version.parse(version) }&.last
69
+ end
70
+
71
+ def list_keys(path)
72
+ response = client.list_objects_v2(bucket: bucket, prefix: object_prefix(path))
73
+ Array(response.contents).map(&:key)
74
+ end
75
+
76
+ def object_exists?(key)
77
+ client.head_object(bucket: bucket, key: key)
78
+ true
79
+ rescue StandardError
80
+ false
81
+ end
82
+
83
+ def object_prefix(path)
84
+ [prefix, path].reject(&:empty?).join("/")
85
+ end
86
+
87
+ def version_from_key(key)
88
+ basename = key.split("/").last
89
+ basename.sub(/\.(json|yaml|yml)\z/, "").sub(/\Av/, "")
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module RubyLLM
7
+ module Registry
8
+ module Adapters
9
+ # SQLite-backed prompt repository.
10
+ class SQLite < Base
11
+ def initialize(path:, table_name: "ruby_llm_registry_prompts")
12
+ require_sqlite3!
13
+ @database = SQLite3::Database.new(path)
14
+ @database.results_as_hash = true
15
+ @table_name = table_name
16
+ ensure_schema!
17
+ end
18
+
19
+ def get(path, version: nil, label: nil)
20
+ rows = rows_for_path(path)
21
+ raise PromptNotFoundError, "Prompt path not found: #{path}" if rows.empty?
22
+
23
+ row = if version
24
+ rows.find { |record| record["version"] == Version.parse(version).to_s }
25
+ elsif label
26
+ label = label.to_sym
27
+ rows.find { |record| labels_for(record).include?(label) } || rows.find { |record| record["version"] == label.to_s }
28
+ else
29
+ rows.max_by { |record| Version.parse(record["version"]) }
30
+ end
31
+
32
+ raise PromptNotFoundError, "Prompt not found: #{path}" unless row
33
+
34
+ prompt_from_row(row)
35
+ end
36
+
37
+ def available_versions(path)
38
+ rows_for_path(path).map { |row| Version.parse(row["version"]) }.sort
39
+ end
40
+
41
+ def store(prompt, overwrite: false)
42
+ existing = rows_for_path(prompt.path).find { |row| row["version"] == prompt.version.to_s }
43
+ if existing && !overwrite
44
+ raise Error, "Prompt #{prompt.path}@#{prompt.version} already exists"
45
+ end
46
+
47
+ data = serialize_prompt(prompt)
48
+ if existing
49
+ update_row(data)
50
+ else
51
+ insert_row(data)
52
+ end
53
+ prompt
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :database, :table_name
59
+
60
+ def require_sqlite3!
61
+ require "sqlite3"
62
+ rescue LoadError => e
63
+ raise LoadError, "sqlite3 gem is required for the SQLite adapter"
64
+ end
65
+
66
+ def ensure_schema!
67
+ database.execute <<~SQL
68
+ CREATE TABLE IF NOT EXISTS #{table_name} (
69
+ path TEXT NOT NULL,
70
+ namespace TEXT NOT NULL,
71
+ name TEXT NOT NULL,
72
+ version TEXT NOT NULL,
73
+ body TEXT NOT NULL,
74
+ labels_json TEXT NOT NULL,
75
+ metadata_json TEXT NOT NULL,
76
+ required_vars_json TEXT NOT NULL,
77
+ source_path TEXT,
78
+ created_at TEXT NOT NULL,
79
+ updated_at TEXT NOT NULL,
80
+ UNIQUE(path, version)
81
+ )
82
+ SQL
83
+ end
84
+
85
+ def rows_for_path(path)
86
+ database.execute("SELECT * FROM #{table_name} WHERE path = ?", [path])
87
+ end
88
+
89
+ def serialize_prompt(prompt)
90
+ {
91
+ path: prompt.path,
92
+ namespace: prompt.namespace,
93
+ name: prompt.name,
94
+ version: prompt.version.to_s,
95
+ body: prompt.body,
96
+ labels_json: JSON.generate(prompt.labels),
97
+ metadata_json: JSON.generate(prompt.metadata),
98
+ required_vars_json: JSON.generate(prompt.required_vars),
99
+ source_path: prompt.source_path,
100
+ created_at: Time.now.utc.iso8601,
101
+ updated_at: Time.now.utc.iso8601
102
+ }
103
+ end
104
+
105
+ def insert_row(data)
106
+ database.execute(
107
+ <<~SQL,
108
+ INSERT INTO #{table_name}
109
+ (path, namespace, name, version, body, labels_json, metadata_json, required_vars_json, source_path, created_at, updated_at)
110
+ VALUES
111
+ (:path, :namespace, :name, :version, :body, :labels_json, :metadata_json, :required_vars_json, :source_path, :created_at, :updated_at)
112
+ SQL
113
+ data
114
+ )
115
+ end
116
+
117
+ def update_row(data)
118
+ database.execute(
119
+ <<~SQL,
120
+ UPDATE #{table_name}
121
+ SET namespace = :namespace,
122
+ name = :name,
123
+ body = :body,
124
+ labels_json = :labels_json,
125
+ metadata_json = :metadata_json,
126
+ required_vars_json = :required_vars_json,
127
+ source_path = :source_path,
128
+ updated_at = :updated_at
129
+ WHERE path = :path AND version = :version
130
+ SQL
131
+ data
132
+ )
133
+ end
134
+
135
+ def prompt_from_row(row)
136
+ Prompt.new(
137
+ name: row["name"],
138
+ namespace: row["namespace"],
139
+ version: row["version"],
140
+ body: row["body"],
141
+ source_path: row["source_path"],
142
+ labels: JSON.parse(row["labels_json"] || "[]"),
143
+ metadata: JSON.parse(row["metadata_json"] || "{}"),
144
+ required_vars: JSON.parse(row["required_vars_json"] || "[]")
145
+ )
146
+ end
147
+
148
+ def labels_for(row)
149
+ JSON.parse(row["labels_json"] || "[]").map(&:to_sym)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapters/base"
4
+ require_relative "adapters/sqlite"
5
+ require_relative "adapters/active_record"
6
+ require_relative "adapters/mongo"
7
+ require_relative "adapters/s3"
8
+
9
+ module RubyLLM
10
+ module Registry
11
+ module Adapters
12
+ module_function
13
+
14
+ def build(type = :filesystem, **options)
15
+ case type.to_sym
16
+ when :filesystem then FilesystemBackend.new(**options)
17
+ when :sqlite then SQLite.new(**options)
18
+ when :active_record, :ar then ActiveRecord.new(**options)
19
+ when :mongo, :mongodb then MongoDB.new(**options)
20
+ when :s3 then S3.new(**options)
21
+ else
22
+ raise ArgumentError, "Unknown adapter type: #{type.inspect}"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Registry
5
+ # Represents a comparison between two prompt revisions.
6
+ class Comparison
7
+ attr_reader :left, :right, :changes
8
+
9
+ def initialize(left, right)
10
+ @left = left
11
+ @right = right
12
+ @changes = build_changes
13
+ end
14
+
15
+ def changed?
16
+ changes.any?
17
+ end
18
+
19
+ def changed_fields
20
+ changes.keys
21
+ end
22
+
23
+ def body_diff
24
+ DiffLines.new(left.body.to_s, right.body.to_s).to_s
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ left: snapshot(left),
30
+ right: snapshot(right),
31
+ changes: changes,
32
+ body_diff: body_diff
33
+ }
34
+ end
35
+
36
+ def to_s
37
+ return "No changes" unless changed?
38
+
39
+ parts = changes.map do |field, change|
40
+ "#{field}: #{change[:from].inspect} -> #{change[:to].inspect}"
41
+ end
42
+ "#{parts.join('; ')}\n#{body_diff}"
43
+ end
44
+
45
+ private
46
+
47
+ def build_changes
48
+ fields = %i[version labels metadata required_vars body]
49
+ fields.each_with_object({}) do |field, hash|
50
+ left_value = normalize_field(left.public_send(field))
51
+ right_value = normalize_field(right.public_send(field))
52
+ hash[field] = { from: left_value, to: right_value } unless left_value == right_value
53
+ end
54
+ end
55
+
56
+ def normalize_field(value)
57
+ case value
58
+ when Version
59
+ value.to_s
60
+ when Array
61
+ value.map { |item| normalize_field(item) }
62
+ when Hash
63
+ value.each_with_object({}) do |(key, item), hash|
64
+ hash[key] = normalize_field(item)
65
+ end
66
+ else
67
+ value
68
+ end
69
+ end
70
+
71
+ def snapshot(prompt)
72
+ prompt.respond_to?(:to_h) ? prompt.to_h : prompt
73
+ end
74
+ end
75
+
76
+ # Minimal line-oriented diff representation for prompt bodies.
77
+ class DiffLines
78
+ def initialize(left, right)
79
+ @left = left.to_s.split("\n")
80
+ @right = right.to_s.split("\n")
81
+ end
82
+
83
+ def to_s
84
+ build.join("\n")
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :left, :right
90
+
91
+ def build
92
+ output = []
93
+ max = [left.length, right.length].max
94
+
95
+ max.times do |index|
96
+ l = left[index]
97
+ r = right[index]
98
+
99
+ if l == r
100
+ output << " #{l}" if l
101
+ else
102
+ output << "-#{l}" if l
103
+ output << "+#{r}" if r
104
+ end
105
+ end
106
+
107
+ output
108
+ end
109
+ end
110
+ end
111
+ end
112
+
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Registry
5
+ # Context object used while rendering ERB prompts.
6
+ class RenderContext
7
+ def initialize(values = {})
8
+ @values = normalize(values)
9
+ end
10
+
11
+ def method_missing(name, *args)
12
+ raise NoMethodError, "undefined method `#{name}` for #{self}" unless args.empty?
13
+
14
+ @values[name]
15
+ end
16
+
17
+ def respond_to_missing?(name, _include_private = false)
18
+ @values.key?(name)
19
+ end
20
+
21
+ def current_date
22
+ Date.today
23
+ end
24
+
25
+ def current_time
26
+ Time.now
27
+ end
28
+
29
+ alias today current_date
30
+
31
+ private
32
+
33
+ def normalize(values)
34
+ values.each_with_object({}) do |(key, value), hash|
35
+ hash[key.to_sym] = value
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Registry
5
+ class Error < StandardError; end
6
+ class PromptNotFoundError < Error; end
7
+ class InvalidVersionError < Error; end
8
+ class MissingVariableError < Error; end
9
+ class UnknownLabelError < Error; end
10
+ end
11
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module RubyLLM
7
+ module Registry
8
+ # Serializes prompt objects into portable formats.
9
+ class Exporter
10
+ def initialize(prompt)
11
+ @prompt = prompt
12
+ end
13
+
14
+ def to_h
15
+ prompt.to_h
16
+ end
17
+
18
+ def to_yaml
19
+ YAML.dump(to_h)
20
+ end
21
+
22
+ def to_json(*args)
23
+ JSON.pretty_generate(to_h, *args)
24
+ end
25
+
26
+ def to_markdown
27
+ <<~MARKDOWN.chomp
28
+ ---
29
+ #{YAML.dump(front_matter).sub(/\A---\s*\n/, "").sub(/\n\z/, "")}
30
+ ---
31
+
32
+ #{prompt.body}
33
+ MARKDOWN
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :prompt
39
+
40
+ def front_matter
41
+ {
42
+ version: prompt.version.to_s,
43
+ labels: prompt.labels,
44
+ required_vars: prompt.required_vars,
45
+ metadata: prompt.metadata,
46
+ name: prompt.name,
47
+ namespace: prompt.namespace,
48
+ source_path: prompt.source_path
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+