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
|
@@ -1,173 +1,177 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "httparty"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
module Prescient
|
|
6
|
+
module Provider
|
|
7
|
+
# Google Gemini API provider adapter.
|
|
8
|
+
class Gemini < Prescient::Base
|
|
9
|
+
include HTTParty
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
base_uri "https://generativelanguage.googleapis.com"
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# Generate an embedding through Gemini's embedContent endpoint.
|
|
18
|
-
# @param text [String] Text to embed
|
|
19
|
-
# @return [Array<Float>] Embedding vector
|
|
20
|
-
def generate_embedding(text, **options)
|
|
21
|
-
handle_errors do
|
|
22
|
-
embedding_model = options[:model] || @options[:embedding_model]
|
|
23
|
-
response = self.class.post(
|
|
24
|
-
model_endpoint(embedding_model, 'embedContent'),
|
|
25
|
-
headers: api_headers,
|
|
26
|
-
body: {
|
|
27
|
-
content: { parts: [{ text: clean_text(text) }] },
|
|
28
|
-
}.to_json,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
validate_response!(response, 'embedding generation')
|
|
32
|
-
|
|
33
|
-
embedding = response.parsed_response.dig('embedding', 'values')
|
|
34
|
-
raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding.is_a?(Array)
|
|
35
|
-
|
|
36
|
-
expected_dimensions = @options[:embedding_dimensions]
|
|
37
|
-
expected_dimensions ? validate_embedding_dimensions(embedding, expected_dimensions) : embedding
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Generate a response through Gemini's generateContent endpoint.
|
|
42
|
-
# @param prompt [String] Prompt to send
|
|
43
|
-
# @param context_items [Array<Hash, String>] Optional context items
|
|
44
|
-
# @return [Hash] Normalized response data
|
|
45
|
-
def generate_response(prompt, context_items = [], **options)
|
|
46
|
-
handle_errors do
|
|
47
|
-
model = options[:model] || @options[:chat_model]
|
|
48
|
-
response = self.class.post(
|
|
49
|
-
model_endpoint(model, 'generateContent'),
|
|
50
|
-
headers: api_headers,
|
|
51
|
-
body: {
|
|
52
|
-
contents: [{ role: 'user', parts: [{ text: build_prompt(prompt, context_items) }] }],
|
|
53
|
-
generationConfig: {
|
|
54
|
-
maxOutputTokens: options[:max_tokens] || 2000,
|
|
55
|
-
temperature: options[:temperature] || 0.7,
|
|
56
|
-
topP: options[:top_p] || 0.9,
|
|
57
|
-
},
|
|
58
|
-
}.to_json,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
validate_response!(response, 'text generation')
|
|
62
|
-
|
|
63
|
-
parsed_response = response.parsed_response
|
|
64
|
-
parts = parsed_response.dig('candidates', 0, 'content', 'parts')
|
|
65
|
-
content = Array(parts).filter_map { |part| part['text'] }.join
|
|
66
|
-
raise Prescient::InvalidResponseError, 'No response generated' if content.nil? || content.empty?
|
|
67
|
-
|
|
68
|
-
{
|
|
69
|
-
response: content.strip,
|
|
70
|
-
model: model,
|
|
71
|
-
provider: 'gemini',
|
|
72
|
-
processing_time: nil,
|
|
73
|
-
metadata: {
|
|
74
|
-
usage: parsed_response['usageMetadata'],
|
|
75
|
-
finish_reason: parsed_response.dig('candidates', 0, 'finishReason'),
|
|
76
|
-
},
|
|
77
|
-
}
|
|
78
|
-
end
|
|
79
|
-
end
|
|
13
|
+
def initialize(**options)
|
|
14
|
+
super
|
|
15
|
+
@provider_name = "Google Gemini"
|
|
16
|
+
self.class.default_timeout(@options[:timeout] || 60)
|
|
17
|
+
end
|
|
80
18
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
19
|
+
# Generate an embedding through Gemini's embedContent endpoint.
|
|
20
|
+
# @param text [String] Text to embed
|
|
21
|
+
# @return [Array<Float>] Embedding vector
|
|
22
|
+
def generate_embedding(text, **options)
|
|
23
|
+
handle_errors do
|
|
24
|
+
embedding_model = options[:model] || @options[:embedding_model]
|
|
25
|
+
response = self.class.post(
|
|
26
|
+
model_endpoint(embedding_model, "embedContent"),
|
|
27
|
+
headers: api_headers,
|
|
28
|
+
body: {
|
|
29
|
+
content: { parts: [{ text: clean_text(text) }] }
|
|
30
|
+
}.to_json
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
validate_response!(response, "embedding generation")
|
|
34
|
+
|
|
35
|
+
embedding = response.parsed_response.dig("embedding", "values")
|
|
36
|
+
raise Prescient::InvalidResponseError, "No embedding returned" unless embedding.is_a?(Array)
|
|
37
|
+
|
|
38
|
+
expected_dimensions = @options[:embedding_dimensions]
|
|
39
|
+
expected_dimensions ? validate_embedding_dimensions(embedding, expected_dimensions) : embedding
|
|
40
|
+
end
|
|
41
|
+
end
|
|
86
42
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
43
|
+
# Generate a response through Gemini's generateContent endpoint.
|
|
44
|
+
# @param prompt [String] Prompt to send
|
|
45
|
+
# @param context_items [Array<Hash, String>] Optional context items
|
|
46
|
+
# @return [Hash] Normalized response data
|
|
47
|
+
def generate_response(prompt, context_items = [], **options)
|
|
48
|
+
handle_errors do
|
|
49
|
+
model = options[:model] || @options[:chat_model]
|
|
50
|
+
response = self.class.post(
|
|
51
|
+
model_endpoint(model, "generateContent"),
|
|
52
|
+
headers: api_headers,
|
|
53
|
+
body: {
|
|
54
|
+
contents: [{ role: "user", parts: [{ text: build_prompt(prompt, context_items) }] }],
|
|
55
|
+
generationConfig: {
|
|
56
|
+
maxOutputTokens: options[:max_tokens] || 2000,
|
|
57
|
+
temperature: options[:temperature] || 0.7,
|
|
58
|
+
topP: options[:top_p] || 0.9
|
|
59
|
+
}
|
|
60
|
+
}.to_json
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
validate_response!(response, "text generation")
|
|
64
|
+
|
|
65
|
+
parsed_response = response.parsed_response
|
|
66
|
+
parts = parsed_response.dig("candidates", 0, "content", "parts")
|
|
67
|
+
content = Array(parts).filter_map { |part| part["text"] }.join
|
|
68
|
+
raise Prescient::InvalidResponseError, "No response generated" if content.nil? || content.empty?
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
response: content.strip,
|
|
72
|
+
model: model,
|
|
73
|
+
provider: "gemini",
|
|
74
|
+
processing_time: nil,
|
|
75
|
+
metadata: {
|
|
76
|
+
usage: parsed_response["usageMetadata"],
|
|
77
|
+
finish_reason: parsed_response.dig("candidates", 0, "finishReason")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
91
82
|
|
|
83
|
+
# Check whether the configured Gemini models are available.
|
|
84
|
+
# @return [Hash] Provider health information
|
|
85
|
+
def health_check
|
|
86
|
+
handle_errors do
|
|
87
|
+
response = self.class.get("/v1beta/models", headers: api_headers)
|
|
88
|
+
|
|
89
|
+
if response.success?
|
|
90
|
+
models = response.parsed_response["models"] || []
|
|
91
|
+
embedding_model = find_model(models, @options[:embedding_model], "embedContent")
|
|
92
|
+
chat_model = find_model(models, @options[:chat_model], "generateContent")
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
status: "healthy",
|
|
96
|
+
provider: "gemini",
|
|
97
|
+
reachable: true,
|
|
98
|
+
models_available: models.map { |model| model["name"].to_s.delete_prefix("models/") },
|
|
99
|
+
embedding_model: { name: @options[:embedding_model], available: embedding_model },
|
|
100
|
+
chat_model: { name: @options[:chat_model], available: chat_model },
|
|
101
|
+
ready: embedding_model && chat_model
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
{
|
|
105
|
+
status: "unhealthy",
|
|
106
|
+
provider: "gemini",
|
|
107
|
+
reachable: true,
|
|
108
|
+
error: "HTTP #{response.code}",
|
|
109
|
+
message: response.message,
|
|
110
|
+
ready: false
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
rescue Prescient::Error => e
|
|
92
115
|
{
|
|
93
|
-
status:
|
|
94
|
-
provider:
|
|
95
|
-
reachable:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
ready: embedding_model && chat_model,
|
|
100
|
-
}
|
|
101
|
-
else
|
|
102
|
-
{
|
|
103
|
-
status: 'unhealthy',
|
|
104
|
-
provider: 'gemini',
|
|
105
|
-
reachable: true,
|
|
106
|
-
error: "HTTP #{response.code}",
|
|
107
|
-
message: response.message,
|
|
108
|
-
ready: false,
|
|
116
|
+
status: "unavailable",
|
|
117
|
+
provider: "gemini",
|
|
118
|
+
reachable: false,
|
|
119
|
+
error: e.class.name,
|
|
120
|
+
message: e.message,
|
|
121
|
+
ready: false
|
|
109
122
|
}
|
|
110
123
|
end
|
|
111
|
-
end
|
|
112
|
-
rescue Prescient::Error => e
|
|
113
|
-
{
|
|
114
|
-
status: 'unavailable',
|
|
115
|
-
provider: 'gemini',
|
|
116
|
-
reachable: false,
|
|
117
|
-
error: e.class.name,
|
|
118
|
-
message: e.message,
|
|
119
|
-
ready: false,
|
|
120
|
-
}
|
|
121
|
-
end
|
|
122
124
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
125
|
+
# List models available to the configured Gemini API key.
|
|
126
|
+
# @return [Array<Hash>] Model descriptors
|
|
127
|
+
def list_models
|
|
128
|
+
handle_errors do
|
|
129
|
+
response = self.class.get("/v1beta/models", headers: api_headers)
|
|
130
|
+
validate_response!(response, "model listing")
|
|
131
|
+
|
|
132
|
+
(response.parsed_response["models"] || []).map do |model|
|
|
133
|
+
{
|
|
134
|
+
name: model["name"].to_s.delete_prefix("models/"),
|
|
135
|
+
display_name: model["displayName"],
|
|
136
|
+
supported_generation_modes: model["supportedGenerationMethods"],
|
|
137
|
+
input_token_limit: model["inputTokenLimit"],
|
|
138
|
+
output_token_limit: model["outputTokenLimit"]
|
|
139
|
+
}.compact
|
|
140
|
+
end
|
|
141
|
+
end
|
|
138
142
|
end
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
protected
|
|
143
145
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
def validate_configuration!
|
|
147
|
+
required_options = %i[api_key embedding_model chat_model]
|
|
148
|
+
missing_options = required_options.select { |option| @options[option].nil? }
|
|
147
149
|
|
|
148
|
-
|
|
150
|
+
return unless missing_options.any?
|
|
149
151
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
|
|
153
|
+
end
|
|
152
154
|
|
|
153
|
-
|
|
155
|
+
private
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
def api_headers
|
|
158
|
+
{
|
|
159
|
+
"Content-Type" => "application/json",
|
|
160
|
+
"x-goog-api-key" => @options[:api_key]
|
|
161
|
+
}
|
|
162
|
+
end
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
def model_endpoint(model, operation)
|
|
165
|
+
model_name = model.to_s.delete_prefix("models/")
|
|
166
|
+
"/v1beta/models/#{model_name}:#{operation}"
|
|
167
|
+
end
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
def find_model(models, model_name, operation)
|
|
170
|
+
models.any? do |model|
|
|
171
|
+
model["name"].to_s.delete_prefix("models/") == model_name &&
|
|
172
|
+
model.fetch("supportedGenerationMethods", []).include?(operation)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
171
175
|
end
|
|
172
176
|
end
|
|
173
177
|
end
|