prescient 0.7.0 → 0.8.1

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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +48 -0
  4. data/INTEGRATION_GUIDE.md +7 -1
  5. data/README.md +210 -1
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/examples/README.md +2 -1
  9. data/examples/custom_contexts.rb +4 -4
  10. data/exe/prescient +2 -2
  11. data/exe/prescient-mcp +7 -0
  12. data/lib/prescient/agent/audit_log.rb +37 -0
  13. data/lib/prescient/agent/cli_adapter.rb +29 -0
  14. data/lib/prescient/agent/configuration.rb +57 -0
  15. data/lib/prescient/agent/context.rb +56 -0
  16. data/lib/prescient/agent/error_serializer.rb +47 -0
  17. data/lib/prescient/agent/errors.rb +25 -0
  18. data/lib/prescient/agent/parser.rb +131 -0
  19. data/lib/prescient/agent/prompt_builder.rb +31 -0
  20. data/lib/prescient/agent/result.rb +36 -0
  21. data/lib/prescient/agent/runtime.rb +175 -0
  22. data/lib/prescient/agent/schema_validator.rb +215 -0
  23. data/lib/prescient/agent/tool_registry.rb +89 -0
  24. data/lib/prescient/agent.rb +22 -0
  25. data/lib/prescient/api.rb +337 -274
  26. data/lib/prescient/base.rb +370 -372
  27. data/lib/prescient/cli.rb +586 -526
  28. data/lib/prescient/client.rb +7 -6
  29. data/lib/prescient/configuration_loader.rb +492 -488
  30. data/lib/prescient/document_source.rb +114 -0
  31. data/lib/prescient/errors.rb +1 -3
  32. data/lib/prescient/mcp/authentication.rb +39 -0
  33. data/lib/prescient/mcp/configuration.rb +38 -0
  34. data/lib/prescient/mcp/rack.rb +243 -0
  35. data/lib/prescient/mcp/server.rb +202 -0
  36. data/lib/prescient/mcp/stdio.rb +42 -0
  37. data/lib/prescient/mcp.rb +8 -0
  38. data/lib/prescient/pgvector.rb +193 -189
  39. data/lib/prescient/provider/anthropic.rb +129 -125
  40. data/lib/prescient/provider/deepseek.rb +122 -118
  41. data/lib/prescient/provider/gemini.rb +153 -149
  42. data/lib/prescient/provider/huggingface.rb +191 -187
  43. data/lib/prescient/provider/mistral.rb +151 -147
  44. data/lib/prescient/provider/ollama.rb +168 -165
  45. data/lib/prescient/provider/openai.rb +174 -169
  46. data/lib/prescient/provider/xai.rb +122 -118
  47. data/lib/prescient/tool/search_api.rb +125 -121
  48. data/lib/prescient/tool/searxng.rb +123 -119
  49. data/lib/prescient/tool.rb +100 -98
  50. data/lib/prescient/version.rb +1 -1
  51. data/lib/prescient.rb +68 -62
  52. data/sig/prescient.rbs +176 -1
  53. metadata +23 -1
@@ -1,173 +1,177 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
3
+ require "httparty"
4
4
 
5
- # Google Gemini API provider adapter.
6
- class Prescient::Provider::Gemini < Prescient::Base
7
- include HTTParty
5
+ module Prescient
6
+ module Provider
7
+ # Google Gemini API provider adapter.
8
+ class Gemini < Prescient::Base
9
+ include HTTParty
8
10
 
9
- base_uri 'https://generativelanguage.googleapis.com'
11
+ base_uri "https://generativelanguage.googleapis.com"
10
12
 
11
- def initialize(**options)
12
- super
13
- @provider_name = 'Google Gemini'
14
- self.class.default_timeout(@options[:timeout] || 60)
15
- end
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
- # Check whether the configured Gemini models are available.
82
- # @return [Hash] Provider health information
83
- def health_check
84
- handle_errors do
85
- response = self.class.get('/v1beta/models', headers: api_headers)
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
- if response.success?
88
- models = response.parsed_response['models'] || []
89
- embedding_model = find_model(models, @options[:embedding_model], 'embedContent')
90
- chat_model = find_model(models, @options[:chat_model], 'generateContent')
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: 'healthy',
94
- provider: 'gemini',
95
- reachable: true,
96
- models_available: models.map { |model| model['name'].to_s.delete_prefix('models/') },
97
- embedding_model: { name: @options[:embedding_model], available: embedding_model },
98
- chat_model: { name: @options[:chat_model], available: chat_model },
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
- # List models available to the configured Gemini API key.
124
- # @return [Array<Hash>] Model descriptors
125
- def list_models
126
- handle_errors do
127
- response = self.class.get('/v1beta/models', headers: api_headers)
128
- validate_response!(response, 'model listing')
129
-
130
- (response.parsed_response['models'] || []).map do |model|
131
- {
132
- name: model['name'].to_s.delete_prefix('models/'),
133
- display_name: model['displayName'],
134
- supported_generation_modes: model['supportedGenerationMethods'],
135
- input_token_limit: model['inputTokenLimit'],
136
- output_token_limit: model['outputTokenLimit'],
137
- }.compact
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
- protected
144
+ protected
143
145
 
144
- def validate_configuration!
145
- required_options = [:api_key, :embedding_model, :chat_model]
146
- missing_options = required_options.select { |option| @options[option].nil? }
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
- return unless missing_options.any?
150
+ return unless missing_options.any?
149
151
 
150
- raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
151
- end
152
+ raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
153
+ end
152
154
 
153
- private
155
+ private
154
156
 
155
- def api_headers
156
- {
157
- 'Content-Type' => 'application/json',
158
- 'x-goog-api-key' => @options[:api_key],
159
- }
160
- end
157
+ def api_headers
158
+ {
159
+ "Content-Type" => "application/json",
160
+ "x-goog-api-key" => @options[:api_key]
161
+ }
162
+ end
161
163
 
162
- def model_endpoint(model, operation)
163
- model_name = model.to_s.delete_prefix('models/')
164
- "/v1beta/models/#{model_name}:#{operation}"
165
- end
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
- def find_model(models, model_name, operation)
168
- models.any? do |model|
169
- model['name'].to_s.delete_prefix('models/') == model_name &&
170
- model.fetch('supportedGenerationMethods', []).include?(operation)
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