sage-rails 0.0.3
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 +7 -0
- data/README.md +202 -0
- data/app/assets/images/chevron-down-zinc-500.svg +1 -0
- data/app/assets/images/chevron-right.svg +1 -0
- data/app/assets/images/loading.svg +4 -0
- data/app/assets/images/sage/chevron-down-zinc-500.svg +1 -0
- data/app/assets/images/sage/chevron-right.svg +1 -0
- data/app/assets/images/sage/loading.svg +4 -0
- data/app/assets/javascripts/sage/application.js +18 -0
- data/app/assets/stylesheets/sage/application.css +308 -0
- data/app/controllers/sage/actions_controller.rb +5 -0
- data/app/controllers/sage/application_controller.rb +4 -0
- data/app/controllers/sage/base_controller.rb +10 -0
- data/app/controllers/sage/checks_controller.rb +65 -0
- data/app/controllers/sage/dashboards_controller.rb +130 -0
- data/app/controllers/sage/queries/messages_controller.rb +62 -0
- data/app/controllers/sage/queries_controller.rb +596 -0
- data/app/helpers/sage/application_helper.rb +30 -0
- data/app/helpers/sage/queries_helper.rb +23 -0
- data/app/javascript/controllers/element_removal_controller.js +7 -0
- data/app/javascript/sage/controllers/clipboard_controller.js +26 -0
- data/app/javascript/sage/controllers/dashboard_controller.js +132 -0
- data/app/javascript/sage/controllers/reverse_infinite_scroll_controller.js +146 -0
- data/app/javascript/sage/controllers/search_controller.js +47 -0
- data/app/javascript/sage/controllers/select_controller.js +215 -0
- data/app/javascript/sage.js +19 -0
- data/app/jobs/sage/application_job.rb +4 -0
- data/app/jobs/sage/process_report_job.rb +80 -0
- data/app/mailers/sage/application_mailer.rb +6 -0
- data/app/models/sage/application_record.rb +5 -0
- data/app/models/sage/message.rb +8 -0
- data/app/schemas/sage/report_response_schema.rb +8 -0
- data/app/views/layouts/application.html.erb +34 -0
- data/app/views/layouts/sage/application.html.erb +94 -0
- data/app/views/sage/checks/_form.html.erb +81 -0
- data/app/views/sage/checks/_search.html.erb +8 -0
- data/app/views/sage/checks/edit.html.erb +10 -0
- data/app/views/sage/checks/index.html.erb +58 -0
- data/app/views/sage/checks/new.html.erb +8 -0
- data/app/views/sage/dashboards/_form.html.erb +50 -0
- data/app/views/sage/dashboards/_search.html.erb +8 -0
- data/app/views/sage/dashboards/index.html.erb +58 -0
- data/app/views/sage/dashboards/new.html.erb +8 -0
- data/app/views/sage/dashboards/show.html.erb +58 -0
- data/app/views/sage/messages/_form.html.erb +14 -0
- data/app/views/sage/queries/_caching.html.erb +17 -0
- data/app/views/sage/queries/_form.html.erb +72 -0
- data/app/views/sage/queries/_input.html.erb +17 -0
- data/app/views/sage/queries/_message.html.erb +25 -0
- data/app/views/sage/queries/_message.turbo_stream.erb +10 -0
- data/app/views/sage/queries/_new_form.html.erb +43 -0
- data/app/views/sage/queries/_run.html.erb +232 -0
- data/app/views/sage/queries/_search.html.erb +8 -0
- data/app/views/sage/queries/_statement_box.html.erb +241 -0
- data/app/views/sage/queries/_streaming_message.html.erb +14 -0
- data/app/views/sage/queries/create.turbo_stream.erb +114 -0
- data/app/views/sage/queries/edit.html.erb +48 -0
- data/app/views/sage/queries/index.html.erb +59 -0
- data/app/views/sage/queries/messages/create.turbo_stream.erb +22 -0
- data/app/views/sage/queries/messages/index.html.erb +44 -0
- data/app/views/sage/queries/messages/index.turbo_stream.erb +15 -0
- data/app/views/sage/queries/new.html.erb +195 -0
- data/app/views/sage/queries/run.html.erb +1 -0
- data/app/views/sage/queries/run.turbo_stream.erb +3 -0
- data/app/views/sage/queries/show.html.erb +49 -0
- data/app/views/sage/queries/table_schema.html.erb +77 -0
- data/app/views/sage/shared/_navigation.html.erb +26 -0
- data/app/views/sage/shared/_overlay.html.erb +11 -0
- data/config/importmap.rb +11 -0
- data/config/initializers/pagy.rb +2 -0
- data/config/initializers/ransack.rb +152 -0
- data/config/routes.rb +31 -0
- data/lib/generators/sage/USAGE +13 -0
- data/lib/generators/sage/install/install_generator.rb +128 -0
- data/lib/generators/sage/install/templates/sage.rb +22 -0
- data/lib/sage/database_schema_context.rb +56 -0
- data/lib/sage/engine.rb +260 -0
- data/lib/sage/model_scopes_context.rb +185 -0
- data/lib/sage/report_processor.rb +263 -0
- data/lib/sage/version.rb +3 -0
- data/lib/sage.rb +25 -0
- data/lib/tasks/sage_tasks.rake +4 -0
- metadata +245 -0
@@ -0,0 +1,185 @@
|
|
1
|
+
module Sage
|
2
|
+
class ModelScopesContext
|
3
|
+
def initialize
|
4
|
+
# Nothing to initialize for now
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.call
|
8
|
+
new.build_context
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_context
|
12
|
+
context_parts = []
|
13
|
+
context_parts << "\n\n## AVAILABLE SCOPES → SQL MAPPINGS\n"
|
14
|
+
context_parts << "CRITICAL: Use these scopes to understand how to query the data!"
|
15
|
+
context_parts << "Each scope name shows the SQL conditions it generates."
|
16
|
+
context_parts << "When a user's request matches a scope's intent, use that scope's SQL pattern.\n"
|
17
|
+
|
18
|
+
# Get all ActiveRecord models from the host application
|
19
|
+
# Safely attempt to eager load, but continue if there are issues
|
20
|
+
begin
|
21
|
+
Rails.application.eager_load! if Rails.env.development?
|
22
|
+
rescue Zeitwerk::NameError => e
|
23
|
+
Rails.logger.warn "Could not eager load all files: #{e.message}"
|
24
|
+
end
|
25
|
+
|
26
|
+
models_with_scopes = collect_models_with_scopes
|
27
|
+
|
28
|
+
# Format the model scopes nicely
|
29
|
+
if models_with_scopes.any?
|
30
|
+
models_with_scopes.each do |model_info|
|
31
|
+
context_parts << "\n### #{model_info[:name]} (table: `#{model_info[:table]}`)"
|
32
|
+
context_parts << "Scopes and their SQL equivalents:"
|
33
|
+
model_info[:scopes].each do |scope|
|
34
|
+
context_parts << scope
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context_parts.join("\n")
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def collect_models_with_scopes
|
45
|
+
models_with_scopes = []
|
46
|
+
|
47
|
+
# Find all model files in the app/models directory
|
48
|
+
model_files = Dir.glob(Rails.root.join("app/models/**/*.rb"))
|
49
|
+
|
50
|
+
model_files.each do |file_path|
|
51
|
+
# Skip concern files and other non-model files
|
52
|
+
next if file_path.include?("/concerns/")
|
53
|
+
|
54
|
+
# Read the file content
|
55
|
+
file_content = File.read(file_path)
|
56
|
+
|
57
|
+
# Extract model name from file path
|
58
|
+
model_name = File.basename(file_path, ".rb").camelize
|
59
|
+
|
60
|
+
# Find all scope definitions using regex
|
61
|
+
# Match various scope patterns:
|
62
|
+
# scope :active, -> { where(active: true) }
|
63
|
+
# scope :recent, lambda { where("created_at > ?", 1.week.ago) }
|
64
|
+
# scope :by_role, ->(role) { where(role: role) }
|
65
|
+
scope_patterns = [
|
66
|
+
# Pattern 1: scope :name, -> { ... } or -> (...) { ... }
|
67
|
+
/scope\s+:(\w+)\s*,\s*->\s*(?:\([^)]*\))?\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/m,
|
68
|
+
# Pattern 2: scope :name, lambda { ... }
|
69
|
+
/scope\s+:(\w+)\s*,\s*lambda\s*(?:\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\})/m,
|
70
|
+
# Pattern 3: Simple one-liner scopes
|
71
|
+
/scope\s+:(\w+)\s*,\s*(.+?)$/
|
72
|
+
]
|
73
|
+
|
74
|
+
scope_matches = []
|
75
|
+
scope_patterns.each do |pattern|
|
76
|
+
matches = file_content.scan(pattern)
|
77
|
+
matches.each do |match|
|
78
|
+
scope_name = match[0]
|
79
|
+
scope_body = match[1] || ""
|
80
|
+
# Avoid duplicate entries
|
81
|
+
unless scope_matches.any? { |s| s[0] == scope_name }
|
82
|
+
scope_matches << [scope_name, scope_body]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
if scope_matches.any?
|
88
|
+
# Try to get the actual model class and table name
|
89
|
+
begin
|
90
|
+
model_class = model_name.constantize
|
91
|
+
table_name = model_class.table_name rescue model_name.tableize
|
92
|
+
rescue => e
|
93
|
+
table_name = model_name.tableize
|
94
|
+
model_class = nil
|
95
|
+
end
|
96
|
+
|
97
|
+
model_info = {
|
98
|
+
name: model_name,
|
99
|
+
table: table_name,
|
100
|
+
scopes: []
|
101
|
+
}
|
102
|
+
|
103
|
+
scope_matches.each do |match|
|
104
|
+
scope_name = match[0]
|
105
|
+
scope_body = match[1]
|
106
|
+
|
107
|
+
if scope_body
|
108
|
+
# Try to extract SQL-like patterns from the scope body
|
109
|
+
# Look for where conditions, joins, etc.
|
110
|
+
sql_hint = extract_sql_from_scope_body(scope_body)
|
111
|
+
model_info[:scopes] << " • `#{scope_name}` → SQL: `#{sql_hint}`"
|
112
|
+
else
|
113
|
+
# Scope might be using a lambda with parameters or complex logic
|
114
|
+
model_info[:scopes] << " • `#{scope_name}` → (check model file for implementation)"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
models_with_scopes << model_info if model_info[:scopes].any?
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
models_with_scopes
|
123
|
+
end
|
124
|
+
|
125
|
+
def extract_sql_from_scope_body(scope_body)
|
126
|
+
# Clean up the scope body
|
127
|
+
cleaned = scope_body.strip
|
128
|
+
|
129
|
+
sql_parts = []
|
130
|
+
|
131
|
+
# Extract WHERE conditions
|
132
|
+
if cleaned =~ /where\s*\(["']([^"']+)["'](?:,\s*(.+?))?\)/
|
133
|
+
# String SQL with potential parameters
|
134
|
+
sql_parts << "WHERE #{$1}"
|
135
|
+
elsif cleaned =~ /where\s*\(([^)]+)\)/
|
136
|
+
# Hash or conditions
|
137
|
+
where_conditions = $1.strip
|
138
|
+
# Convert Ruby hash syntax to SQL-like
|
139
|
+
where_conditions = where_conditions.gsub(/(\w+):\s*(\w+)/, '\1 = \2')
|
140
|
+
where_conditions = where_conditions.gsub(/(\w+):\s*["']([^"']+)["']/, '\1 = "\2"')
|
141
|
+
where_conditions = where_conditions.gsub(/(\w+):\s*(true|false|nil)/, '\1 = \2')
|
142
|
+
sql_parts << "WHERE #{where_conditions}"
|
143
|
+
elsif cleaned =~ /where\.not\s*\(([^)]+)\)/
|
144
|
+
# WHERE NOT conditions
|
145
|
+
not_conditions = $1.strip
|
146
|
+
not_conditions = not_conditions.gsub(/(\w+):\s*/, '\1 != ')
|
147
|
+
sql_parts << "WHERE NOT (#{not_conditions})"
|
148
|
+
end
|
149
|
+
|
150
|
+
# Extract JOINs
|
151
|
+
if cleaned =~ /joins?\s*\(:?(\w+)\)/
|
152
|
+
sql_parts << "JOIN #{$1}"
|
153
|
+
elsif cleaned =~ /includes?\s*\(:?(\w+)\)/
|
154
|
+
sql_parts << "LEFT JOIN #{$1}"
|
155
|
+
end
|
156
|
+
|
157
|
+
# Extract ORDER
|
158
|
+
if cleaned =~ /order\s*\(["']([^"']+)["']\)/
|
159
|
+
sql_parts << "ORDER BY #{$1}"
|
160
|
+
elsif cleaned =~ /order\s*\(([^)]+)\)/
|
161
|
+
order_clause = $1.strip
|
162
|
+
order_clause = order_clause.gsub(/(\w+):\s*:?(asc|desc)/i, '\1 \2')
|
163
|
+
sql_parts << "ORDER BY #{order_clause}"
|
164
|
+
end
|
165
|
+
|
166
|
+
# Extract LIMIT
|
167
|
+
if cleaned =~ /limit\s*\((\d+)\)/
|
168
|
+
sql_parts << "LIMIT #{$1}"
|
169
|
+
end
|
170
|
+
|
171
|
+
# If we found SQL parts, join them
|
172
|
+
if sql_parts.any?
|
173
|
+
sql_parts.join(" ")
|
174
|
+
else
|
175
|
+
# Check if it's a simple scope referencing another scope
|
176
|
+
if cleaned =~ /^(\w+)$/
|
177
|
+
"(uses #{$1} scope)"
|
178
|
+
else
|
179
|
+
# Return a truncated version if we can't parse it
|
180
|
+
cleaned.length > 60 ? "#{cleaned[0..60]}..." : cleaned
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
require "ruby_llm"
|
2
|
+
require_relative "database_schema_context"
|
3
|
+
require_relative "model_scopes_context"
|
4
|
+
|
5
|
+
module Sage
|
6
|
+
class ReportProcessor
|
7
|
+
include ActionView::RecordIdentifier
|
8
|
+
|
9
|
+
attr_reader :query, :prompt, :stream_target_id, :raw_response_content
|
10
|
+
|
11
|
+
def initialize(query:, prompt:, stream_target_id:)
|
12
|
+
@query = query
|
13
|
+
@prompt = prompt
|
14
|
+
@stream_target_id = stream_target_id
|
15
|
+
end
|
16
|
+
|
17
|
+
def process
|
18
|
+
response = generate_llm_response
|
19
|
+
Rails.logger.info "LLM Response: #{response.inspect}"
|
20
|
+
Rails.logger.info "LLM Response content: #{response.content.inspect}"
|
21
|
+
@raw_response_content = response.content
|
22
|
+
parsed_response = parse_response(response)
|
23
|
+
Rails.logger.info "Parsed response: #{parsed_response.inspect}"
|
24
|
+
|
25
|
+
{
|
26
|
+
summary: parsed_response[:summary],
|
27
|
+
sql: parsed_response[:sql]
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def system_prompt
|
32
|
+
build_system_prompt
|
33
|
+
end
|
34
|
+
|
35
|
+
def database_schema_context
|
36
|
+
Sage::DatabaseSchemaContext.new.build_context
|
37
|
+
end
|
38
|
+
|
39
|
+
def model_scopes_context
|
40
|
+
Sage::ModelScopesContext.new.build_context
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def generate_llm_response
|
46
|
+
RubyLLM.chat
|
47
|
+
.with_instructions(system_prompt)
|
48
|
+
.with_schema(Sage::ReportResponseSchema)
|
49
|
+
.ask(prompt + ". #{structured_output}")
|
50
|
+
end
|
51
|
+
|
52
|
+
def parse_response(response)
|
53
|
+
if response.content.is_a?(Hash) && response.content.key?("sql") && response.content.key?("summary")
|
54
|
+
# Direct hash with sql and summary keys
|
55
|
+
{
|
56
|
+
summary: response.content["summary"],
|
57
|
+
sql: response.content["sql"]
|
58
|
+
}
|
59
|
+
elsif response.content.is_a?(String)
|
60
|
+
parse_json_response(response.content)
|
61
|
+
else
|
62
|
+
# Fallback for unexpected response format
|
63
|
+
Rails.logger.warn "Unexpected response format: #{response.content.class}"
|
64
|
+
{
|
65
|
+
summary: "Unexpected response format. Please try again.",
|
66
|
+
sql: nil
|
67
|
+
}
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def parse_json_response(content)
|
72
|
+
begin
|
73
|
+
# First attempt: direct JSON parsing
|
74
|
+
parsed_response = JSON.parse(content)
|
75
|
+
{
|
76
|
+
summary: parsed_response["summary"],
|
77
|
+
sql: parsed_response["sql"]
|
78
|
+
}
|
79
|
+
rescue JSON::ParserError
|
80
|
+
# Second attempt: fix malformed JSON by properly escaping newlines within quoted strings
|
81
|
+
begin
|
82
|
+
fixed_json = content.gsub(/"([^"]*)"/) do |match|
|
83
|
+
# Escape newlines, tabs, and other control characters within the quoted string
|
84
|
+
match.gsub(/\n/, '\\n').gsub(/\t/, '\\t').gsub(/\r/, '\\r')
|
85
|
+
end
|
86
|
+
|
87
|
+
parsed_response = JSON.parse(fixed_json)
|
88
|
+
{
|
89
|
+
summary: parsed_response["summary"],
|
90
|
+
sql: parsed_response["sql"]
|
91
|
+
}
|
92
|
+
rescue JSON::ParserError => e
|
93
|
+
# Final fallback: extract using regex patterns
|
94
|
+
Rails.logger.warn "Failed to parse JSON even after fixing newlines: #{e.message}"
|
95
|
+
|
96
|
+
# Extract SQL value (everything between "sql": " and the closing quote before comma or brace)
|
97
|
+
sql_match = content.match(/"sql"\s*:\s*"((?:[^"\\]|\\.)*)"/m)
|
98
|
+
sql = sql_match[1] if sql_match
|
99
|
+
|
100
|
+
# Extract summary value
|
101
|
+
summary_match = content.match(/"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/m)
|
102
|
+
summary = summary_match[1] if summary_match
|
103
|
+
|
104
|
+
if sql.nil? || summary.nil?
|
105
|
+
Rails.logger.error "Could not extract sql and summary from response"
|
106
|
+
summary = "Failed to parse response. Please try again." if summary.nil?
|
107
|
+
end
|
108
|
+
|
109
|
+
{
|
110
|
+
summary: summary || "Failed to parse response. Please try again.",
|
111
|
+
sql: sql
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def structured_output
|
118
|
+
<<~INSTRUCTION
|
119
|
+
Return as a JSON object with sql and summary keys and no additional commentary.
|
120
|
+
INSTRUCTION
|
121
|
+
end
|
122
|
+
|
123
|
+
def build_system_prompt
|
124
|
+
prompt_parts = []
|
125
|
+
|
126
|
+
# Detect database type
|
127
|
+
database_type = detect_database_type
|
128
|
+
|
129
|
+
# Base instruction optimized for LLM
|
130
|
+
prompt_parts << <<~INSTRUCTION
|
131
|
+
You are an expert SQL analyst helping users iteratively refine their database queries.
|
132
|
+
|
133
|
+
DATABASE TYPE: #{database_type}
|
134
|
+
|
135
|
+
Your task:
|
136
|
+
1. Analyze the user's natural language request
|
137
|
+
2. Determine if you should:
|
138
|
+
a) Modify the most recent SQL query (from Previous Context if available)
|
139
|
+
b) Modify the baseline query (from Current Query section)
|
140
|
+
c) Create an entirely new query if the request is unrelated
|
141
|
+
3. Generate the appropriate SQL query for #{database_type}
|
142
|
+
4. Provide a clear explanation of what changed and why
|
143
|
+
|
144
|
+
Response format (STRICT JSON):
|
145
|
+
{
|
146
|
+
"summary": "Explain what this query does and what changes were made from the previous version (if any)",
|
147
|
+
"sql": "The complete SQL query statement"
|
148
|
+
}
|
149
|
+
|
150
|
+
CLARIFICATION REQUIRED: If you're unsure how to query based on certain criteria:
|
151
|
+
- Return a summary asking for clarification
|
152
|
+
- Set sql to null
|
153
|
+
- Example: {"summary": "I need clarification on what you mean by 'activated accounts'. Do you mean users with a specific status, users who have logged in, or users with a certain field set?", "sql": null}
|
154
|
+
|
155
|
+
IMPORTANT:#{' '}
|
156
|
+
- Always return the COMPLETE query, not just the changes
|
157
|
+
- When producing SQL, exclude ALL comments and extraneous characters - the SQL will be immediately executed against a database
|
158
|
+
- Format SQL to be human readable, per SQL writing best practices, but still executable
|
159
|
+
- When modifying existing queries, preserve the original intent while incorporating the requested changes
|
160
|
+
- If the user asks for adjustments (e.g., "add a filter", "group by X", "sort differently"), modify the most recent query
|
161
|
+
- If the user asks something completely new, create a fresh query
|
162
|
+
|
163
|
+
Guidelines:
|
164
|
+
- Write efficient, readable SQL using #{database_type}-specific syntax
|
165
|
+
- Use meaningful table aliases and column names
|
166
|
+
- Do NOT include comments in SQL queries - they will be executed directly
|
167
|
+
- Prefer JOINs over subqueries when appropriate
|
168
|
+
- Consider performance implications for large datasets
|
169
|
+
- ROLE HANDLING: When dealing with "roles" (e.g., "candidates", "employers", synonyms of "users"):
|
170
|
+
* ALWAYS check the users model/table/scopes first to understand how roles are established
|
171
|
+
* Look for role-related columns, scopes, or associations in the users table
|
172
|
+
* Map role-related terms to the actual implementation in the database
|
173
|
+
- SCOPE PRIORITIZATION: ALWAYS prioritize matching user requests to available model scopes
|
174
|
+
* FIRST check if any existing scopes match the user's intent
|
175
|
+
* Use scopes as the PRIMARY source for query patterns
|
176
|
+
* Only write custom SQL when no appropriate scope exists
|
177
|
+
* Analyze the intent behind user queries and map them to corresponding scopes
|
178
|
+
* When users describe filters or conditions, identify matching scope patterns
|
179
|
+
* Example: if user asks for "recent items", look for scopes like "recent", "latest", or time-based scopes
|
180
|
+
- JSONB COLUMNS: NEVER guess at JSONB column keys or values
|
181
|
+
* Only query JSONB fields that are explicitly defined in scopes or schema documentation
|
182
|
+
* If JSONB structure is unknown, do NOT attempt to query specific keys
|
183
|
+
* Avoid assumptions about JSONB content unless explicitly documented
|
184
|
+
- PRESENCE CHECKS: For presence/existence checks:
|
185
|
+
* Use "IS NOT NULL" or "IS NULL" for presence/absence checks
|
186
|
+
* Avoid using literal values like 'true' or specific strings unless explicitly required
|
187
|
+
* For boolean presence, check for NOT NULL rather than = true
|
188
|
+
* Example: Use "activated_at IS NOT NULL" instead of "activated = 'true'"
|
189
|
+
- Ensure all table and column names match the schema exactly
|
190
|
+
- Handle NULL values appropriately (prefer IS NULL/IS NOT NULL for presence checks)
|
191
|
+
- Use proper data type casting when needed
|
192
|
+
- Follow #{database_type} best practices and syntax conventions
|
193
|
+
- NEVER make assumptions about data structure - use only what's documented in schema and scopes
|
194
|
+
INSTRUCTION
|
195
|
+
|
196
|
+
# Add current query as baseline context
|
197
|
+
if query.statement.present?
|
198
|
+
prompt_parts << "\n\n## CURRENT QUERY (BASELINE)\n"
|
199
|
+
prompt_parts << "The currently saved query that we're working with:\n"
|
200
|
+
prompt_parts << "```sql\n#{query.statement}\n```"
|
201
|
+
prompt_parts << "\nThis is the baseline query. You may modify or completely replace it based on the user's request.\n"
|
202
|
+
end
|
203
|
+
|
204
|
+
# Add latest message as context
|
205
|
+
latest_message = query.messages.order(:created_at).last
|
206
|
+
if latest_message
|
207
|
+
prompt_parts << "\n\n## PREVIOUS CONTEXT\n"
|
208
|
+
prompt_parts << "The most recent message from this conversation:\n"
|
209
|
+
prompt_parts << "\nPrevious response: #{latest_message.body}" if latest_message.body.present?
|
210
|
+
prompt_parts << "\nPrevious SQL: #{latest_message.statement}" if latest_message.statement.present?
|
211
|
+
prompt_parts << "\n\nConsider this context when generating your response.\n"
|
212
|
+
end
|
213
|
+
|
214
|
+
# Add database schema
|
215
|
+
schema_context = database_schema_context
|
216
|
+
prompt_parts << schema_context if schema_context.present?
|
217
|
+
|
218
|
+
# Add model scopes from host application
|
219
|
+
scopes_context = model_scopes_context
|
220
|
+
prompt_parts << scopes_context if scopes_context.present?
|
221
|
+
|
222
|
+
prompt_parts << "\n\n## QUERY GENERATION RULES"
|
223
|
+
prompt_parts << "1. Match table and column names EXACTLY as shown in the schema"
|
224
|
+
prompt_parts << "2. NO COMMENTS OR NEWLINES in SQL - output will be executed directly against database"
|
225
|
+
prompt_parts << "3. SCOPE FIRST: ALWAYS prioritize using available scopes over custom SQL"
|
226
|
+
prompt_parts << " - Check ALL available scopes before writing custom conditions"
|
227
|
+
prompt_parts << " - Map user language directly to scope names when possible"
|
228
|
+
prompt_parts << "4. JSONB HANDLING: NEVER guess at JSONB structure"
|
229
|
+
prompt_parts << " - Only use JSONB keys that are explicitly documented in scopes or schema"
|
230
|
+
prompt_parts << " - If unsure about JSONB structure, avoid querying it"
|
231
|
+
prompt_parts << "5. PRESENCE/ABSENCE CHECKS:"
|
232
|
+
prompt_parts << " - Use IS NOT NULL for presence (not = 'true' or = true)"
|
233
|
+
prompt_parts << " - Use IS NULL for absence"
|
234
|
+
prompt_parts << " - Example: 'activated users' → 'activated_at IS NOT NULL'"
|
235
|
+
prompt_parts << "6. ROLE HANDLING: When users mention roles like 'candidates' or 'employers':"
|
236
|
+
prompt_parts << " - Check the users table/model/scopes first to understand role implementation"
|
237
|
+
prompt_parts << " - Map role terms to actual database structure (columns, associations, etc.)"
|
238
|
+
prompt_parts << "7. Generate ONE query that best answers the user's request"
|
239
|
+
prompt_parts << "8. NEVER make assumptions - use only documented schema and scopes"
|
240
|
+
|
241
|
+
prompt_parts.join("\n")
|
242
|
+
end
|
243
|
+
|
244
|
+
|
245
|
+
def detect_database_type
|
246
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
|
247
|
+
case adapter_name
|
248
|
+
when /postgresql/, /postgis/
|
249
|
+
"PostgreSQL"
|
250
|
+
when /mysql/, /mysql2/
|
251
|
+
"MySQL"
|
252
|
+
when /sqlite/
|
253
|
+
"SQLite3"
|
254
|
+
when /sqlserver/, /mssql/
|
255
|
+
"SQL Server"
|
256
|
+
when /oracle/
|
257
|
+
"Oracle"
|
258
|
+
else
|
259
|
+
adapter_name.capitalize
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
data/lib/sage/version.rb
ADDED
data/lib/sage.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require "sage/version"
|
2
|
+
require "sage/engine"
|
3
|
+
require "blazer"
|
4
|
+
require "pagy"
|
5
|
+
|
6
|
+
module Sage
|
7
|
+
class << self
|
8
|
+
attr_accessor :configuration
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.configure
|
12
|
+
self.configuration ||= Configuration.new
|
13
|
+
yield(configuration)
|
14
|
+
end
|
15
|
+
|
16
|
+
class Configuration
|
17
|
+
attr_accessor :anthropic_api_key, :anthropic_model, :open_ai_key, :open_ai_model, :provider
|
18
|
+
|
19
|
+
def initialize
|
20
|
+
@provider = :anthropic
|
21
|
+
@anthropic_model = "claude-3-opus-20240229"
|
22
|
+
@open_ai_model = "gpt-4"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|