rapitapir 0.1.1 → 2.0.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 +7 -7
- data/.rubocop_todo.yml +83 -0
- data/README.md +1319 -235
- data/RUBY_WEEKLY_LAUNCH_POST.md +219 -0
- data/docs/RAILS_INTEGRATION_IMPLEMENTATION.md +209 -0
- data/docs/SINATRA_EXTENSION.md +399 -348
- data/docs/STRICT_VALIDATION.md +229 -0
- data/docs/VALIDATION_IMPROVEMENTS.md +218 -0
- data/docs/ai-integration-plan.md +112 -0
- data/docs/auto-derivation.md +505 -92
- data/docs/endpoint-definition.md +536 -129
- data/docs/n8n-integration.md +212 -0
- data/docs/observability.md +810 -500
- data/docs/using-mcp.md +93 -0
- data/examples/ai/knowledge_base_rag.rb +83 -0
- data/examples/ai/user_management_mcp.rb +92 -0
- data/examples/ai/user_validation_llm.rb +187 -0
- data/examples/rails/RAILS_8_GUIDE.md +165 -0
- data/examples/rails/RAILS_LOADING_FIX.rb +35 -0
- data/examples/rails/README.md +497 -0
- data/examples/rails/comprehensive_test.rb +91 -0
- data/examples/rails/config/routes.rb +48 -0
- data/examples/rails/debug_controller.rb +63 -0
- data/examples/rails/detailed_test.rb +46 -0
- data/examples/rails/enhanced_users_controller.rb +278 -0
- data/examples/rails/final_server_test.rb +50 -0
- data/examples/rails/hello_world_app.rb +116 -0
- data/examples/rails/hello_world_controller.rb +186 -0
- data/examples/rails/hello_world_routes.rb +28 -0
- data/examples/rails/rails8_minimal_demo.rb +132 -0
- data/examples/rails/rails8_simple_demo.rb +140 -0
- data/examples/rails/rails8_working_demo.rb +255 -0
- data/examples/rails/real_world_blog_api.rb +510 -0
- data/examples/rails/server_test.rb +46 -0
- data/examples/rails/test_direct_processing.rb +41 -0
- data/examples/rails/test_hello_world.rb +80 -0
- data/examples/rails/test_rails_integration.rb +54 -0
- data/examples/rails/traditional_app/Gemfile +37 -0
- data/examples/rails/traditional_app/README.md +265 -0
- data/examples/rails/traditional_app/app/controllers/api/v1/posts_controller.rb +254 -0
- data/examples/rails/traditional_app/app/controllers/api/v1/users_controller.rb +220 -0
- data/examples/rails/traditional_app/app/controllers/application_controller.rb +86 -0
- data/examples/rails/traditional_app/app/controllers/application_controller_simplified.rb +87 -0
- data/examples/rails/traditional_app/app/controllers/documentation_controller.rb +149 -0
- data/examples/rails/traditional_app/app/controllers/health_controller.rb +42 -0
- data/examples/rails/traditional_app/config/routes.rb +25 -0
- data/examples/rails/traditional_app/config/routes_best_practice.rb +25 -0
- data/examples/rails/traditional_app/config/routes_simplified.rb +36 -0
- data/examples/rails/traditional_app_runnable.rb +406 -0
- data/examples/rails/users_controller.rb +4 -1
- data/examples/serverless/Gemfile +43 -0
- data/examples/serverless/QUICKSTART.md +331 -0
- data/examples/serverless/README.md +520 -0
- data/examples/serverless/aws_lambda_example.rb +307 -0
- data/examples/serverless/aws_sam_template.yaml +215 -0
- data/examples/serverless/azure_functions_example.rb +407 -0
- data/examples/serverless/deploy.rb +204 -0
- data/examples/serverless/gcp_cloud_functions_example.rb +367 -0
- data/examples/serverless/gcp_function.yaml +23 -0
- data/examples/serverless/host.json +24 -0
- data/examples/serverless/package.json +32 -0
- data/examples/serverless/spec/aws_lambda_spec.rb +196 -0
- data/examples/serverless/spec/spec_helper.rb +89 -0
- data/examples/serverless/vercel.json +31 -0
- data/examples/serverless/vercel_example.rb +404 -0
- data/examples/strict_validation_examples.rb +104 -0
- data/examples/validation_error_examples.rb +173 -0
- data/lib/rapitapir/ai/llm_instruction.rb +456 -0
- data/lib/rapitapir/ai/mcp.rb +134 -0
- data/lib/rapitapir/ai/rag.rb +287 -0
- data/lib/rapitapir/ai/rag_middleware.rb +147 -0
- data/lib/rapitapir/auth/oauth2.rb +43 -57
- data/lib/rapitapir/cli/command.rb +362 -2
- data/lib/rapitapir/cli/mcp_export.rb +18 -0
- data/lib/rapitapir/cli/validator.rb +2 -6
- data/lib/rapitapir/core/endpoint.rb +59 -6
- data/lib/rapitapir/core/enhanced_endpoint.rb +2 -6
- data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +53 -0
- data/lib/rapitapir/endpoint_registry.rb +47 -0
- data/lib/rapitapir/observability/health_check.rb +4 -4
- data/lib/rapitapir/observability/logging.rb +10 -10
- data/lib/rapitapir/schema.rb +2 -2
- data/lib/rapitapir/server/rack_adapter.rb +1 -3
- data/lib/rapitapir/server/rails/configuration.rb +77 -0
- data/lib/rapitapir/server/rails/controller_base.rb +185 -0
- data/lib/rapitapir/server/rails/documentation_helpers.rb +76 -0
- data/lib/rapitapir/server/rails/resource_builder.rb +181 -0
- data/lib/rapitapir/server/rails/routes.rb +114 -0
- data/lib/rapitapir/server/rails_adapter.rb +10 -3
- data/lib/rapitapir/server/rails_adapter_class.rb +1 -3
- data/lib/rapitapir/server/rails_controller.rb +1 -3
- data/lib/rapitapir/server/rails_integration.rb +67 -0
- data/lib/rapitapir/server/rails_response_handler.rb +16 -3
- data/lib/rapitapir/server/sinatra_adapter.rb +29 -5
- data/lib/rapitapir/server/sinatra_integration.rb +4 -4
- data/lib/rapitapir/sinatra/extension.rb +2 -2
- data/lib/rapitapir/sinatra/oauth2_helpers.rb +34 -40
- data/lib/rapitapir/types/array.rb +4 -0
- data/lib/rapitapir/types/auto_derivation.rb +4 -18
- data/lib/rapitapir/types/datetime.rb +1 -3
- data/lib/rapitapir/types/float.rb +2 -6
- data/lib/rapitapir/types/hash.rb +40 -2
- data/lib/rapitapir/types/integer.rb +4 -12
- data/lib/rapitapir/types/object.rb +6 -2
- data/lib/rapitapir/types.rb +6 -2
- data/lib/rapitapir/version.rb +1 -1
- data/lib/rapitapir.rb +5 -3
- data/rapitapir.gemspec +7 -5
- metadata +116 -16
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RapiTapir::AI::MCP
|
4
|
+
#
|
5
|
+
# Provides Model Context Protocol (MCP) export capabilities for RapiTapir endpoints.
|
6
|
+
#
|
7
|
+
# Usage:
|
8
|
+
# - Use `.mcp_export` in endpoint DSL to mark endpoints for MCP context export.
|
9
|
+
# - Use MCPExporter to generate MCP-compatible JSON for all marked endpoints.
|
10
|
+
|
11
|
+
module RapiTapir
|
12
|
+
module AI
|
13
|
+
module MCP
|
14
|
+
# Collects and serializes endpoint context for LLM/agent consumption
|
15
|
+
class Exporter
|
16
|
+
def initialize(endpoints)
|
17
|
+
@endpoints = endpoints
|
18
|
+
end
|
19
|
+
|
20
|
+
# Returns a hash representing the MCP context for all marked endpoints
|
21
|
+
def as_mcp_context
|
22
|
+
mcp_endpoints = @endpoints.select(&:mcp_export?)
|
23
|
+
|
24
|
+
context = {
|
25
|
+
service: {
|
26
|
+
name: 'RapiTapir API',
|
27
|
+
version: '1.0.0',
|
28
|
+
description: 'RapiTapir API with exported endpoints for MCP context'
|
29
|
+
},
|
30
|
+
endpoints: [],
|
31
|
+
schemas: extract_all_schemas(mcp_endpoints),
|
32
|
+
metadata: {
|
33
|
+
generated_at: Time.now.iso8601,
|
34
|
+
generator: 'RapiTapir MCP Exporter',
|
35
|
+
mcp_version: '1.0'
|
36
|
+
}
|
37
|
+
}
|
38
|
+
|
39
|
+
context[:endpoints] = mcp_endpoints.map do |ep|
|
40
|
+
{
|
41
|
+
name: endpoint_name(ep),
|
42
|
+
method: ep.method&.to_s&.upcase,
|
43
|
+
path: ep.path,
|
44
|
+
summary: ep.metadata[:summary],
|
45
|
+
description: ep.metadata[:description],
|
46
|
+
input_schema: extract_input_schema(ep),
|
47
|
+
output_schema: extract_output_schema(ep),
|
48
|
+
examples: ep.metadata[:examples] || []
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
context
|
53
|
+
end
|
54
|
+
|
55
|
+
# Test-compatible methods (aliases and wrappers)
|
56
|
+
def export_context
|
57
|
+
as_mcp_context
|
58
|
+
end
|
59
|
+
|
60
|
+
def export_json(pretty: true)
|
61
|
+
context = as_mcp_context
|
62
|
+
if pretty
|
63
|
+
JSON.pretty_generate(context)
|
64
|
+
else
|
65
|
+
JSON.generate(context)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def mcp_endpoints
|
70
|
+
@endpoints.select(&:mcp_export?)
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def endpoint_name(endpoint)
|
76
|
+
# Generate a readable name from method and path
|
77
|
+
method = endpoint.method&.to_s || 'unknown'
|
78
|
+
path = endpoint.path&.gsub(%r{[{}/]}, '_')&.gsub(/_+/, '_')&.strip || 'unknown'
|
79
|
+
"#{method}_#{path}".downcase
|
80
|
+
end
|
81
|
+
|
82
|
+
def extract_input_schema(endpoint)
|
83
|
+
return {} unless endpoint.inputs
|
84
|
+
|
85
|
+
schema = {}
|
86
|
+
endpoint.inputs.each do |input|
|
87
|
+
next unless input.respond_to?(:name)
|
88
|
+
|
89
|
+
# Handle both old and new input structures
|
90
|
+
required = if input.respond_to?(:required?)
|
91
|
+
input.required?
|
92
|
+
elsif input.respond_to?(:options) && input.options
|
93
|
+
input.options[:required] != false
|
94
|
+
else
|
95
|
+
true # default to required
|
96
|
+
end
|
97
|
+
|
98
|
+
schema[input.name] = {
|
99
|
+
type: input.type,
|
100
|
+
kind: input.kind,
|
101
|
+
required: required
|
102
|
+
}
|
103
|
+
end
|
104
|
+
schema
|
105
|
+
end
|
106
|
+
|
107
|
+
def extract_output_schema(endpoint)
|
108
|
+
return {} unless endpoint.outputs
|
109
|
+
|
110
|
+
schema = {}
|
111
|
+
endpoint.outputs.each do |output|
|
112
|
+
schema[output.kind] = {
|
113
|
+
type: output.type
|
114
|
+
}
|
115
|
+
end
|
116
|
+
schema
|
117
|
+
end
|
118
|
+
|
119
|
+
def extract_all_schemas(endpoints)
|
120
|
+
schemas = {}
|
121
|
+
endpoints.each do |endpoint|
|
122
|
+
input_schema = extract_input_schema(endpoint)
|
123
|
+
output_schema = extract_output_schema(endpoint)
|
124
|
+
|
125
|
+
schemas["#{endpoint_name(endpoint)}_input"] = input_schema unless input_schema.empty?
|
126
|
+
|
127
|
+
schemas["#{endpoint_name(endpoint)}_output"] = output_schema unless output_schema.empty?
|
128
|
+
end
|
129
|
+
schemas
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,287 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RapiTapir::AI::RAG
|
4
|
+
#
|
5
|
+
# Provides Retrieval-Augmented Generation (RAG) pipeline support for RapiTapir endpoints.
|
6
|
+
#
|
7
|
+
# Usage:
|
8
|
+
# - Use `.rag_inference(llm:, retrieval:, context_fields:)` in endpoint DSL
|
9
|
+
# - Configure LLM and retrieval backends
|
10
|
+
# - Process user queries with retrieved context
|
11
|
+
|
12
|
+
module RapiTapir
|
13
|
+
module AI
|
14
|
+
module RAG
|
15
|
+
# Base class for LLM providers
|
16
|
+
class LLMProvider
|
17
|
+
def initialize(config = {})
|
18
|
+
@config = config
|
19
|
+
end
|
20
|
+
|
21
|
+
def generate(prompt, context = {})
|
22
|
+
raise NotImplementedError, 'Subclasses must implement #generate'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# OpenAI LLM provider
|
27
|
+
class OpenAIProvider < LLMProvider
|
28
|
+
def initialize(config = {})
|
29
|
+
super
|
30
|
+
@api_key = config[:api_key] || ENV.fetch('OPENAI_API_KEY', nil)
|
31
|
+
@model = config[:model] || 'gpt-3.5-turbo'
|
32
|
+
@base_url = config[:base_url] || 'https://api.openai.com/v1'
|
33
|
+
end
|
34
|
+
|
35
|
+
def generate(prompt, context = {})
|
36
|
+
# Mock implementation for now - replace with actual OpenAI API call
|
37
|
+
if @api_key && @api_key != 'mock'
|
38
|
+
make_openai_request(prompt, context)
|
39
|
+
else
|
40
|
+
mock_response(prompt, context)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def make_openai_request(prompt, context)
|
47
|
+
# This would make actual HTTP request to OpenAI
|
48
|
+
# For now, return a mock response
|
49
|
+
mock_response(prompt, context)
|
50
|
+
end
|
51
|
+
|
52
|
+
def mock_response(prompt, context)
|
53
|
+
"AI Response: Based on the query '#{prompt}' and context #{context.keys.join(', ')}, here is a generated response."
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Base class for retrieval backends
|
58
|
+
class RetrievalBackend
|
59
|
+
def initialize(config = {})
|
60
|
+
@config = config
|
61
|
+
end
|
62
|
+
|
63
|
+
def retrieve(query, context_fields = [])
|
64
|
+
raise NotImplementedError, 'Subclasses must implement #retrieve'
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# PostgreSQL retrieval backend
|
69
|
+
class PostgresBackend < RetrievalBackend
|
70
|
+
def initialize(config = {})
|
71
|
+
super
|
72
|
+
@connection_config = config[:connection] || {}
|
73
|
+
@table = config[:table] || 'documents'
|
74
|
+
@search_column = config[:search_column] || 'content'
|
75
|
+
end
|
76
|
+
|
77
|
+
def retrieve(query, context_fields = [])
|
78
|
+
# Mock implementation - replace with actual DB query
|
79
|
+
mock_retrieval(query, context_fields)
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def mock_retrieval(query, context_fields)
|
85
|
+
[
|
86
|
+
{
|
87
|
+
content: "Sample document content related to: #{query}",
|
88
|
+
metadata: context_fields.to_h { |field| [field, "sample_#{field}_value"] },
|
89
|
+
score: 0.85
|
90
|
+
},
|
91
|
+
{
|
92
|
+
content: "Another relevant document for: #{query}",
|
93
|
+
metadata: context_fields.to_h { |field| [field, "another_#{field}_value"] },
|
94
|
+
score: 0.72
|
95
|
+
}
|
96
|
+
]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Memory/hash-based retrieval backend for testing
|
101
|
+
class MemoryBackend < RetrievalBackend
|
102
|
+
def initialize(config = {})
|
103
|
+
super
|
104
|
+
@documents = config[:documents] || []
|
105
|
+
end
|
106
|
+
|
107
|
+
def retrieve(query, _context_fields = [])
|
108
|
+
# Simple text matching for demo purposes
|
109
|
+
matching_docs = @documents.select do |doc|
|
110
|
+
doc[:content]&.downcase&.include?(query.downcase)
|
111
|
+
end
|
112
|
+
|
113
|
+
matching_docs.map.with_index do |doc, index|
|
114
|
+
{
|
115
|
+
content: doc[:content],
|
116
|
+
metadata: doc[:metadata] || {},
|
117
|
+
score: 1.0 - (index * 0.1) # Simple scoring
|
118
|
+
}
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# RAG Pipeline orchestrator
|
124
|
+
class Pipeline
|
125
|
+
attr_reader :llm_provider, :retrieval_backend
|
126
|
+
|
127
|
+
def initialize(llm:, retrieval:, config: {})
|
128
|
+
@llm_provider = create_llm_provider(llm, config[:llm] || {})
|
129
|
+
@retrieval_backend = create_retrieval_backend(retrieval, config[:retrieval] || {})
|
130
|
+
@config = config
|
131
|
+
end
|
132
|
+
|
133
|
+
def process(query, context_fields: [], user_context: {})
|
134
|
+
# Step 1: Retrieve relevant documents
|
135
|
+
retrieved_docs = @retrieval_backend.retrieve(query, context_fields)
|
136
|
+
|
137
|
+
# Step 2: Build context for LLM
|
138
|
+
llm_context = build_llm_context(retrieved_docs, user_context, context_fields)
|
139
|
+
|
140
|
+
# Step 3: Generate response using LLM
|
141
|
+
prompt = build_prompt(query, llm_context)
|
142
|
+
response = @llm_provider.generate(prompt, llm_context)
|
143
|
+
|
144
|
+
# Step 4: Return structured result
|
145
|
+
{
|
146
|
+
answer: response,
|
147
|
+
sources: retrieved_docs,
|
148
|
+
context: llm_context,
|
149
|
+
query: query
|
150
|
+
}
|
151
|
+
end
|
152
|
+
|
153
|
+
private
|
154
|
+
|
155
|
+
def create_llm_provider(type, config)
|
156
|
+
case type.to_sym
|
157
|
+
when :openai
|
158
|
+
OpenAIProvider.new(config)
|
159
|
+
else
|
160
|
+
raise ArgumentError, "Unknown LLM provider: #{type}"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def create_retrieval_backend(type, config)
|
165
|
+
case type.to_sym
|
166
|
+
when :postgres, :postgresql
|
167
|
+
PostgresBackend.new(config)
|
168
|
+
when :memory
|
169
|
+
MemoryBackend.new(config)
|
170
|
+
else
|
171
|
+
raise ArgumentError, "Unknown retrieval backend: #{type}"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def build_llm_context(retrieved_docs, user_context, context_fields)
|
176
|
+
{
|
177
|
+
retrieved_documents: retrieved_docs,
|
178
|
+
user_context: user_context,
|
179
|
+
context_fields: context_fields,
|
180
|
+
document_count: retrieved_docs.length
|
181
|
+
}
|
182
|
+
end
|
183
|
+
|
184
|
+
def build_prompt(query, context)
|
185
|
+
documents_text = context[:retrieved_documents]
|
186
|
+
.map { |doc| doc[:content] }
|
187
|
+
.join("\n\n")
|
188
|
+
|
189
|
+
<<~PROMPT
|
190
|
+
You are an AI assistant that answers questions based on the provided context.
|
191
|
+
|
192
|
+
Context Documents:
|
193
|
+
#{documents_text}
|
194
|
+
|
195
|
+
User Question: #{query}
|
196
|
+
|
197
|
+
Please provide a helpful and accurate answer based on the context provided.
|
198
|
+
If the context doesn't contain enough information, say so clearly.
|
199
|
+
PROMPT
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# Rack middleware for handling RAG inference requests
|
204
|
+
class Middleware
|
205
|
+
def initialize(app)
|
206
|
+
@app = app
|
207
|
+
end
|
208
|
+
|
209
|
+
def call(env)
|
210
|
+
endpoint = env['rapitapir.endpoint']
|
211
|
+
|
212
|
+
# Pass through if not a RAG endpoint
|
213
|
+
return @app.call(env) unless endpoint&.rag_inference?
|
214
|
+
|
215
|
+
# Process RAG request
|
216
|
+
process_rag_request(env, endpoint)
|
217
|
+
end
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
def process_rag_request(env, endpoint)
|
222
|
+
# Parse request body
|
223
|
+
request_body = env['rack.input']&.read
|
224
|
+
env['rack.input']&.rewind if env['rack.input'].respond_to?(:rewind)
|
225
|
+
|
226
|
+
query_data = request_body ? JSON.parse(request_body) : {}
|
227
|
+
question = query_data['question'] || query_data[:question]
|
228
|
+
|
229
|
+
# Get RAG config from endpoint
|
230
|
+
rag_config = endpoint.metadata[:rag_inference]
|
231
|
+
|
232
|
+
# Create RAG pipeline
|
233
|
+
pipeline = Pipeline.new(
|
234
|
+
llm: rag_config[:llm],
|
235
|
+
retrieval: rag_config[:retrieval],
|
236
|
+
config: rag_config[:config] || {}
|
237
|
+
)
|
238
|
+
|
239
|
+
# Process the query
|
240
|
+
result = pipeline.process(
|
241
|
+
question,
|
242
|
+
context_fields: rag_config[:context_fields] || [],
|
243
|
+
user_context: extract_user_context(env)
|
244
|
+
)
|
245
|
+
|
246
|
+
# Return JSON response
|
247
|
+
response_body = JSON.generate(
|
248
|
+
answer: result[:answer],
|
249
|
+
sources: result[:sources],
|
250
|
+
metadata: {
|
251
|
+
query: result[:query],
|
252
|
+
context_fields: result[:context][:context_fields],
|
253
|
+
document_count: result[:context][:document_count]
|
254
|
+
}
|
255
|
+
)
|
256
|
+
|
257
|
+
[
|
258
|
+
200,
|
259
|
+
{ 'Content-Type' => 'application/json' },
|
260
|
+
[response_body]
|
261
|
+
]
|
262
|
+
rescue StandardError => e
|
263
|
+
error_response = JSON.generate(
|
264
|
+
error: 'RAG processing failed',
|
265
|
+
message: e.message
|
266
|
+
)
|
267
|
+
|
268
|
+
[
|
269
|
+
500,
|
270
|
+
{ 'Content-Type' => 'application/json' },
|
271
|
+
[error_response]
|
272
|
+
]
|
273
|
+
end
|
274
|
+
|
275
|
+
def extract_user_context(env)
|
276
|
+
# Extract relevant context from request environment
|
277
|
+
{
|
278
|
+
user_agent: env['HTTP_USER_AGENT'],
|
279
|
+
remote_ip: env['REMOTE_ADDR'],
|
280
|
+
method: env['REQUEST_METHOD'],
|
281
|
+
path: env['PATH_INFO']
|
282
|
+
}
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'rag'
|
4
|
+
|
5
|
+
module RapiTapir
|
6
|
+
module AI
|
7
|
+
module RAG
|
8
|
+
# Middleware for handling RAG-enabled endpoints
|
9
|
+
# Integrates with RapiTapir server adapters to process RAG requests
|
10
|
+
class Middleware
|
11
|
+
def initialize(app)
|
12
|
+
@app = app
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(env)
|
16
|
+
# Check if this is a RAG-enabled endpoint
|
17
|
+
endpoint = env['rapitapir.endpoint']
|
18
|
+
if endpoint&.rag_inference?
|
19
|
+
handle_rag_request(env, endpoint)
|
20
|
+
else
|
21
|
+
@app.call(env)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def handle_rag_request(env, endpoint)
|
28
|
+
# Extract request data
|
29
|
+
request_data = extract_request_data(env)
|
30
|
+
|
31
|
+
# Get the query from the request (assuming it's in the body)
|
32
|
+
query = request_data[:question] || request_data[:query] || request_data.values.first
|
33
|
+
|
34
|
+
# Create RAG pipeline from endpoint configuration
|
35
|
+
rag_config = endpoint.rag_config
|
36
|
+
pipeline = create_rag_pipeline(rag_config)
|
37
|
+
|
38
|
+
# Process the query through RAG pipeline
|
39
|
+
result = pipeline.process(
|
40
|
+
query,
|
41
|
+
context_fields: rag_config[:context_fields],
|
42
|
+
user_context: extract_user_context(env, rag_config[:context_fields])
|
43
|
+
)
|
44
|
+
|
45
|
+
# Return RAG response
|
46
|
+
build_rag_response(result)
|
47
|
+
rescue StandardError => e
|
48
|
+
build_error_response(e)
|
49
|
+
end
|
50
|
+
|
51
|
+
def extract_request_data(env)
|
52
|
+
# Simple JSON parsing - in real implementation would be more robust
|
53
|
+
if env['CONTENT_TYPE']&.include?('application/json')
|
54
|
+
body = env['rack.input']&.read || '{}'
|
55
|
+
env['rack.input']&.rewind
|
56
|
+
JSON.parse(body, symbolize_names: true)
|
57
|
+
else
|
58
|
+
{}
|
59
|
+
end
|
60
|
+
rescue JSON::ParserError
|
61
|
+
{}
|
62
|
+
end
|
63
|
+
|
64
|
+
def extract_user_context(env, context_fields)
|
65
|
+
# Extract user context from headers, session, etc.
|
66
|
+
context = {}
|
67
|
+
|
68
|
+
context_fields.each do |field|
|
69
|
+
case field
|
70
|
+
when :user_id
|
71
|
+
context[:user_id] = env['HTTP_X_USER_ID'] || env['HTTP_AUTHORIZATION']&.split&.last
|
72
|
+
when :session_id
|
73
|
+
context[:session_id] = env['HTTP_X_SESSION_ID']
|
74
|
+
when :tenant_id
|
75
|
+
context[:tenant_id] = env['HTTP_X_TENANT_ID']
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
context
|
80
|
+
end
|
81
|
+
|
82
|
+
def create_rag_pipeline(config)
|
83
|
+
Pipeline.new(
|
84
|
+
llm: config[:llm],
|
85
|
+
retrieval: config[:retrieval],
|
86
|
+
config: config[:config] || {}
|
87
|
+
)
|
88
|
+
end
|
89
|
+
|
90
|
+
def build_rag_response(result)
|
91
|
+
response_body = {
|
92
|
+
answer: result[:answer],
|
93
|
+
sources: result[:sources],
|
94
|
+
metadata: {
|
95
|
+
query: result[:query],
|
96
|
+
source_count: result[:sources].length,
|
97
|
+
timestamp: Time.now.iso8601
|
98
|
+
}
|
99
|
+
}
|
100
|
+
|
101
|
+
[
|
102
|
+
200,
|
103
|
+
{ 'Content-Type' => 'application/json' },
|
104
|
+
[JSON.generate(response_body)]
|
105
|
+
]
|
106
|
+
end
|
107
|
+
|
108
|
+
def build_error_response(error)
|
109
|
+
response_body = {
|
110
|
+
error: 'RAG processing failed',
|
111
|
+
message: error.message,
|
112
|
+
timestamp: Time.now.iso8601
|
113
|
+
}
|
114
|
+
|
115
|
+
[
|
116
|
+
500,
|
117
|
+
{ 'Content-Type' => 'application/json' },
|
118
|
+
[JSON.generate(response_body)]
|
119
|
+
]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Helper for mounting RAG endpoints in server adapters
|
124
|
+
class EndpointHandler
|
125
|
+
def self.handle(endpoint, request_data, user_context = {})
|
126
|
+
return nil unless endpoint.rag_inference?
|
127
|
+
|
128
|
+
rag_config = endpoint.rag_config
|
129
|
+
pipeline = Pipeline.new(
|
130
|
+
llm: rag_config[:llm],
|
131
|
+
retrieval: rag_config[:retrieval],
|
132
|
+
config: rag_config[:config] || {}
|
133
|
+
)
|
134
|
+
|
135
|
+
# Extract query from request data
|
136
|
+
query = request_data[:question] || request_data[:query] || request_data.to_s
|
137
|
+
|
138
|
+
pipeline.process(
|
139
|
+
query,
|
140
|
+
context_fields: rag_config[:context_fields],
|
141
|
+
user_context: user_context
|
142
|
+
)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|