prescient 0.7.0 → 0.8.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/.rubocop.yml +21 -268
- data/CHANGELOG.md +37 -0
- data/INTEGRATION_GUIDE.md +7 -1
- data/README.md +210 -1
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/examples/README.md +2 -1
- data/examples/custom_contexts.rb +4 -4
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +337 -274
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +586 -526
- data/lib/prescient/client.rb +7 -6
- data/lib/prescient/configuration_loader.rb +492 -488
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +1 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -169
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +125 -121
- data/lib/prescient/tool/searxng.rb +123 -119
- data/lib/prescient/tool.rb +100 -98
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -62
- data/sig/prescient.rbs +176 -1
- metadata +23 -1
data/lib/prescient/pgvector.rb
CHANGED
|
@@ -1,194 +1,198 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Prescient
|
|
6
|
+
module Pgvector
|
|
7
|
+
# PostgreSQL pgvector integration.
|
|
8
|
+
#
|
|
9
|
+
# The integration accepts a PG-compatible connection object, so applications
|
|
10
|
+
# choose and manage their own PostgreSQL driver and connection lifecycle.
|
|
11
|
+
# Stores provider embeddings and performs nearest-neighbor searches.
|
|
12
|
+
class Store
|
|
13
|
+
# @return [Hash<Symbol, String>] Supported pgvector distance operators
|
|
14
|
+
METRICS = {
|
|
15
|
+
cosine: "<=>",
|
|
16
|
+
euclidean: "<->",
|
|
17
|
+
inner_product: "<#>"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# @return [Integer] Required vector dimensions
|
|
21
|
+
attr_reader :dimensions
|
|
22
|
+
|
|
23
|
+
# @return [String] Embeddings table name
|
|
24
|
+
attr_reader :table
|
|
25
|
+
|
|
26
|
+
# @param connection [Object] PG-compatible object responding to `exec`
|
|
27
|
+
# and `exec_params`
|
|
28
|
+
# @param dimensions [Integer] Required dimensions for every embedding
|
|
29
|
+
# @param table [String, Symbol] Safe PostgreSQL table identifier
|
|
30
|
+
def initialize(connection:, dimensions:, table: "prescient_embeddings")
|
|
31
|
+
@connection = connection
|
|
32
|
+
@dimensions = validate_dimensions(dimensions)
|
|
33
|
+
@table = validate_table(table)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Create the pgvector extension and the embeddings table.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def install!
|
|
40
|
+
@connection.exec("CREATE EXTENSION IF NOT EXISTS vector")
|
|
41
|
+
@connection.exec(<<~SQL)
|
|
42
|
+
CREATE TABLE IF NOT EXISTS #{table} (
|
|
43
|
+
id text PRIMARY KEY,
|
|
44
|
+
provider text NOT NULL,
|
|
45
|
+
model text NOT NULL,
|
|
46
|
+
dimensions integer NOT NULL CHECK (dimensions = #{dimensions}),
|
|
47
|
+
embedding vector(#{dimensions}) NOT NULL,
|
|
48
|
+
content text,
|
|
49
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
50
|
+
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
51
|
+
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
52
|
+
)
|
|
53
|
+
SQL
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Create an HNSW index for the selected distance metric.
|
|
57
|
+
#
|
|
58
|
+
# @param metric [Symbol] `:cosine`, `:euclidean`, or `:inner_product`
|
|
59
|
+
# @return [void]
|
|
60
|
+
def create_index!(metric: :cosine)
|
|
61
|
+
metric_name = validate_metric(metric)
|
|
62
|
+
@connection.exec(<<~SQL)
|
|
63
|
+
CREATE INDEX IF NOT EXISTS #{table}_#{metric_name}_embedding_idx
|
|
64
|
+
ON #{table} USING hnsw (embedding #{metric_operator_class(metric_name)})
|
|
65
|
+
SQL
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Insert or replace an embedding record.
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash] Stored record metadata
|
|
71
|
+
def upsert(id:, embedding:, provider:, model:, content: nil, metadata: {})
|
|
72
|
+
vector = serialize_vector(embedding)
|
|
73
|
+
parameters = [id.to_s, provider.to_s, model.to_s, dimensions, vector, content, JSON.generate(metadata)]
|
|
74
|
+
result = @connection.exec_params(<<~SQL, parameters)
|
|
75
|
+
INSERT INTO #{table} (id, provider, model, dimensions, embedding, content, metadata)
|
|
76
|
+
VALUES ($1, $2, $3, $4, $5::vector, $6, $7::jsonb)
|
|
77
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
78
|
+
provider = EXCLUDED.provider,
|
|
79
|
+
model = EXCLUDED.model,
|
|
80
|
+
dimensions = EXCLUDED.dimensions,
|
|
81
|
+
embedding = EXCLUDED.embedding,
|
|
82
|
+
content = EXCLUDED.content,
|
|
83
|
+
metadata = EXCLUDED.metadata,
|
|
84
|
+
updated_at = CURRENT_TIMESTAMP
|
|
85
|
+
RETURNING id, provider, model, dimensions, content, metadata
|
|
86
|
+
SQL
|
|
87
|
+
|
|
88
|
+
record_from(result.first)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Find the nearest stored embeddings.
|
|
92
|
+
#
|
|
93
|
+
# @param embedding [Array<Numeric>] Query vector
|
|
94
|
+
# @param limit [Integer] Maximum result count
|
|
95
|
+
# @param metric [Symbol] Distance metric
|
|
96
|
+
# @param provider [String, Symbol, nil] Optional provider filter
|
|
97
|
+
# @param model [String, nil] Optional model filter
|
|
98
|
+
# @return [Array<Hash>] Records ordered by ascending distance
|
|
99
|
+
def search(embedding:, limit: 10, metric: :cosine, provider: nil, model: nil)
|
|
100
|
+
vector = serialize_vector(embedding)
|
|
101
|
+
limit = validate_limit(limit)
|
|
102
|
+
metric = validate_metric(metric)
|
|
103
|
+
filters, parameters = search_filters(provider, model)
|
|
104
|
+
result = @connection.exec_params(search_query(metric, filters), [vector, limit, *parameters])
|
|
105
|
+
|
|
106
|
+
result.map { |row| record_from(row) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def validate_dimensions(value)
|
|
112
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "dimensions must be a positive integer"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_table(value)
|
|
118
|
+
table = value.to_s
|
|
119
|
+
return table if /\A[a-z_][a-z0-9_]*\z/.match?(table)
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "table must be a lowercase PostgreSQL identifier"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def serialize_vector(embedding)
|
|
125
|
+
unless embedding.is_a?(Array) && embedding.length == dimensions
|
|
126
|
+
raise Prescient::InvalidVectorError, "embedding must contain exactly #{dimensions} values"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
values = embedding.map { |value| Float(value) }
|
|
130
|
+
raise Prescient::InvalidVectorError, "embedding values must be finite" unless values.all?(&:finite?)
|
|
131
|
+
|
|
132
|
+
"[#{values.join(",")}]"
|
|
133
|
+
rescue ArgumentError, TypeError
|
|
134
|
+
raise Prescient::InvalidVectorError, "embedding values must be numeric"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_limit(value)
|
|
138
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
139
|
+
|
|
140
|
+
raise ArgumentError, "limit must be a positive integer"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_metric(value)
|
|
144
|
+
return value if METRICS.key?(value)
|
|
145
|
+
|
|
146
|
+
raise ArgumentError, "unsupported distance metric: #{value}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def metric_operator_class(metric)
|
|
150
|
+
{
|
|
151
|
+
cosine: "vector_cosine_ops",
|
|
152
|
+
euclidean: "vector_l2_ops",
|
|
153
|
+
inner_product: "vector_ip_ops"
|
|
154
|
+
}.fetch(metric)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def search_filters(provider, model)
|
|
158
|
+
filters = []
|
|
159
|
+
parameters = []
|
|
160
|
+
if provider
|
|
161
|
+
filters << "provider = $#{parameters.length + 3}"
|
|
162
|
+
parameters << provider.to_s
|
|
163
|
+
end
|
|
164
|
+
if model
|
|
165
|
+
filters << "model = $#{parameters.length + 3}"
|
|
166
|
+
parameters << model
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
[filters, parameters]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def search_query(metric, filters)
|
|
173
|
+
where = filters.empty? ? "" : "WHERE #{filters.join(" AND ")}"
|
|
174
|
+
|
|
175
|
+
<<~SQL
|
|
176
|
+
SELECT id, provider, model, dimensions, content, metadata,
|
|
177
|
+
embedding #{METRICS.fetch(metric)} $1::vector AS distance
|
|
178
|
+
FROM #{table}
|
|
179
|
+
#{where}
|
|
180
|
+
ORDER BY embedding #{METRICS.fetch(metric)} $1::vector
|
|
181
|
+
LIMIT $2
|
|
182
|
+
SQL
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def record_from(row)
|
|
186
|
+
{
|
|
187
|
+
id: row.fetch("id"),
|
|
188
|
+
provider: row.fetch("provider"),
|
|
189
|
+
model: row.fetch("model"),
|
|
190
|
+
dimensions: Integer(row.fetch("dimensions")),
|
|
191
|
+
content: row.fetch("content"),
|
|
192
|
+
metadata: JSON.parse(row.fetch("metadata")),
|
|
193
|
+
distance: row["distance"]&.to_f
|
|
194
|
+
}
|
|
195
|
+
end
|
|
161
196
|
end
|
|
162
|
-
if model
|
|
163
|
-
filters << "model = $#{parameters.length + 3}"
|
|
164
|
-
parameters << model
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
[filters, parameters]
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def search_query(metric, filters)
|
|
171
|
-
where = filters.empty? ? '' : "WHERE #{filters.join(' AND ')}"
|
|
172
|
-
|
|
173
|
-
<<~SQL
|
|
174
|
-
SELECT id, provider, model, dimensions, content, metadata,
|
|
175
|
-
embedding #{METRICS.fetch(metric)} $1::vector AS distance
|
|
176
|
-
FROM #{table}
|
|
177
|
-
#{where}
|
|
178
|
-
ORDER BY embedding #{METRICS.fetch(metric)} $1::vector
|
|
179
|
-
LIMIT $2
|
|
180
|
-
SQL
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def record_from(row)
|
|
184
|
-
{
|
|
185
|
-
id: row.fetch('id'),
|
|
186
|
-
provider: row.fetch('provider'),
|
|
187
|
-
model: row.fetch('model'),
|
|
188
|
-
dimensions: Integer(row.fetch('dimensions')),
|
|
189
|
-
content: row.fetch('content'),
|
|
190
|
-
metadata: JSON.parse(row.fetch('metadata')),
|
|
191
|
-
distance: row['distance']&.to_f,
|
|
192
|
-
}
|
|
193
197
|
end
|
|
194
198
|
end
|