whoosh 1.2.2 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 336c26ea284a871dafa4b1d26f173293947e30470000539dbf3e0da799063bea
4
- data.tar.gz: 6acf7d5007d008070aaddb543a42de5bddf3b3aa2de838e1be5ccba5d18b9442
3
+ metadata.gz: 589d3cd63bfe7cb2a7ae32d8de43c064eb0e9dadfae68ecb1907bea9a099cd7d
4
+ data.tar.gz: a18f684c03fa83b5e7e86febd6eca0bc79d01d5c3cf719b729118eea91510e9c
5
5
  SHA512:
6
- metadata.gz: 86aebc850e904960fe87d08aa7c6e8d6aba82c5e411e66e0e9ae65fb64d5510b8ddae4125099554fecac71907c163ce854d1adc416bd8e84ee5f6f3376556809
7
- data.tar.gz: 99c067d27463fff53a3cb5818836a11e90e0e86d1f886d211770a6407725b6ca0ba72cd3e52c94bd4b204d57e5d282ff89d102863c9410c545c63e3d60bcf332
6
+ metadata.gz: 3ffb5d9e6d905e3914d9cf54738966c5d3ba625a89f33c8c2b3c98698f9a04ba1b6710584efa69917e24ef02b52b870f616a4ad6e6a7d631c4707319d1db7e47
7
+ data.tar.gz: e57044ee4ec0c6a1b872d85cd0abeb274ee3c260c46dcc38b925994a6b88c7b004188314c7b60e8e3a7ce5aef4198581c817fbd8870ceaca6ddbe20fce4abd75
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module AI
5
+ class LLM
6
+ attr_reader :provider, :model
7
+
8
+ def initialize(provider: "auto", model: nil, cache_enabled: true)
9
+ @provider = provider
10
+ @model = model
11
+ @cache_enabled = cache_enabled
12
+ @cache = cache_enabled ? {} : nil
13
+ @mutex = Mutex.new
14
+ @ruby_llm = nil
15
+ end
16
+
17
+ # Chat with an LLM — returns response text
18
+ def chat(message, model: nil, system: nil, max_tokens: nil, temperature: nil, cache: nil)
19
+ use_cache = cache.nil? ? @cache_enabled : cache
20
+ cache_key = "chat:#{model || @model}:#{message}" if use_cache
21
+
22
+ # Check cache
23
+ if use_cache && @cache && (cached = @cache[cache_key])
24
+ return cached
25
+ end
26
+
27
+ result = call_llm(
28
+ messages: [{ role: "user", content: message }],
29
+ model: model || @model,
30
+ system: system,
31
+ max_tokens: max_tokens,
32
+ temperature: temperature
33
+ )
34
+
35
+ # Cache result
36
+ if use_cache && @cache
37
+ @mutex.synchronize { @cache[cache_key] = result }
38
+ end
39
+
40
+ result
41
+ end
42
+
43
+ # Extract structured data — returns validated hash
44
+ def extract(text, schema:, model: nil, prompt: nil)
45
+ schema_desc = describe_schema(schema)
46
+ system_prompt = prompt || "Extract structured data from the text. Return ONLY valid JSON matching this schema:\n#{schema_desc}"
47
+
48
+ response = chat(text, model: model, system: system_prompt, cache: false)
49
+
50
+ # Parse JSON from LLM response
51
+ json_str = extract_json(response)
52
+ parsed = Serialization::Json.decode(json_str)
53
+
54
+ # Validate against schema
55
+ result = schema.validate(parsed)
56
+ if result.success?
57
+ result.data
58
+ else
59
+ raise Errors::ValidationError.new(result.errors)
60
+ end
61
+ end
62
+
63
+ # Stream LLM response — yields chunks
64
+ def stream(message, model: nil, system: nil, &block)
65
+ ensure_ruby_llm!
66
+
67
+ messages = [{ role: "user", content: message }]
68
+ # Delegate to ruby_llm's streaming interface
69
+ if @ruby_llm
70
+ # ruby_llm streaming would go here
71
+ # For now, fall back to non-streaming
72
+ result = chat(message, model: model, system: system, cache: false)
73
+ yield result if block_given?
74
+ result
75
+ end
76
+ end
77
+
78
+ # Check if LLM is available
79
+ def available?
80
+ ruby_llm_available?
81
+ end
82
+
83
+ private
84
+
85
+ def call_llm(messages:, model:, system: nil, max_tokens: nil, temperature: nil)
86
+ ensure_ruby_llm!
87
+
88
+ if @ruby_llm
89
+ # Use ruby_llm gem
90
+ chat = RubyLLM.chat(model: model || "claude-sonnet-4-20250514")
91
+ chat.with_instructions(system) if system
92
+ response = chat.ask(messages.last[:content])
93
+ response.content
94
+ else
95
+ raise Errors::DependencyError, "No LLM provider available. Add 'ruby_llm' to your Gemfile."
96
+ end
97
+ end
98
+
99
+ def ensure_ruby_llm!
100
+ return if @ruby_llm == false # already checked, not available
101
+
102
+ if ruby_llm_available?
103
+ @ruby_llm = true
104
+ else
105
+ @ruby_llm = false
106
+ end
107
+ end
108
+
109
+ def ruby_llm_available?
110
+ require "ruby_llm"
111
+ true
112
+ rescue LoadError
113
+ false
114
+ end
115
+
116
+ def describe_schema(schema)
117
+ return "{}" unless schema.respond_to?(:fields)
118
+ fields = schema.fields.map do |name, opts|
119
+ type = OpenAPI::SchemaConverter.type_for(opts[:type])
120
+ desc = opts[:desc] ? " — #{opts[:desc]}" : ""
121
+ required = opts[:required] ? " (required)" : ""
122
+ " #{name}: #{type}#{required}#{desc}"
123
+ end
124
+ "{\n#{fields.join(",\n")}\n}"
125
+ end
126
+
127
+ def extract_json(text)
128
+ # Try to find JSON in LLM response (may be wrapped in markdown code blocks)
129
+ if text =~ /```(?:json)?\s*\n?(.*?)\n?```/m
130
+ $1.strip
131
+ elsif text.strip.start_with?("{") || text.strip.start_with?("[")
132
+ text.strip
133
+ else
134
+ text.strip
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module AI
5
+ # Validates LLM output against a Whoosh::Schema
6
+ module StructuredOutput
7
+ def self.validate(data, schema:)
8
+ result = schema.validate(data)
9
+ return result.data if result.success?
10
+ raise Errors::ValidationError.new(result.errors)
11
+ end
12
+
13
+ def self.prompt_for(schema)
14
+ return "" unless schema.respond_to?(:fields)
15
+
16
+ lines = schema.fields.map do |name, opts|
17
+ type = OpenAPI::SchemaConverter.type_for(opts[:type])
18
+ parts = ["#{name}: #{type}"]
19
+ parts << "(required)" if opts[:required]
20
+ parts << "— #{opts[:desc]}" if opts[:desc]
21
+ parts << "[min: #{opts[:min]}]" if opts[:min]
22
+ parts << "[max: #{opts[:max]}]" if opts[:max]
23
+ parts << "[default: #{opts[:default]}]" if opts.key?(:default)
24
+ " #{parts.join(" ")}"
25
+ end
26
+
27
+ "Return ONLY valid JSON matching this schema:\n{\n#{lines.join(",\n")}\n}"
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/whoosh/ai.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module AI
5
+ autoload :LLM, "whoosh/ai/llm"
6
+ autoload :StructuredOutput, "whoosh/ai/structured_output"
7
+
8
+ # Build an AI client from config
9
+ def self.build(config_data = {})
10
+ ai_config = config_data["ai"] || {}
11
+ LLM.new(
12
+ provider: ai_config["provider"] || "auto",
13
+ model: ai_config["model"],
14
+ cache_enabled: ai_config["cache"] != false
15
+ )
16
+ end
17
+ end
18
+ end
data/lib/whoosh/app.rb CHANGED
@@ -30,6 +30,7 @@ module Whoosh
30
30
  auto_register_storage
31
31
  auto_register_http
32
32
  auto_register_vectors
33
+ auto_register_ai
33
34
  auto_configure_jobs
34
35
  @metrics = Metrics.new
35
36
  auto_register_metrics
@@ -307,6 +308,10 @@ module Whoosh
307
308
  @di.provide(:vectors) { VectorStore.build(@config.data) }
308
309
  end
309
310
 
311
+ def auto_register_ai
312
+ @di.provide(:llm) { AI.build(@config.data) }
313
+ end
314
+
310
315
  def auto_configure_jobs
311
316
  backend = Jobs.build_backend(@config.data)
312
317
  Jobs.configure(backend: backend, di: @di)
@@ -448,8 +453,12 @@ module Whoosh
448
453
  end
449
454
 
450
455
  def register_mcp_tools
456
+ internal_paths = %w[/openapi.json /docs /redoc /metrics /healthz]
457
+
451
458
  @router.routes.each do |route|
452
- next unless route[:metadata] && route[:metadata][:mcp]
459
+ # Auto-expose all routes as MCP tools (opt-out with mcp: false)
460
+ next if route[:metadata] && route[:metadata][:mcp] == false
461
+ next if internal_paths.include?(route[:path])
453
462
 
454
463
  tool_name = "#{route[:method]} #{route[:path]}"
455
464
  match = @router.match(route[:method], route[:path])
@@ -92,17 +92,166 @@ module Whoosh
92
92
 
93
93
  desc "routes", "List all registered routes"
94
94
  def routes
95
+ app = load_app
96
+ return unless app
97
+ app.routes.each { |r| puts " #{r[:method].ljust(8)} #{r[:path]}" }
98
+ end
99
+
100
+ desc "describe", "Dump app structure as JSON (AI-friendly introspection)"
101
+ option :routes, type: :boolean, default: false, desc: "Routes only"
102
+ option :schemas, type: :boolean, default: false, desc: "Schemas only"
103
+ def describe
104
+ app = load_app
105
+ return unless app
106
+ app.to_rack # ensure everything is built
107
+
108
+ output = {}
109
+
110
+ unless options[:schemas]
111
+ output[:routes] = app.routes.map do |r|
112
+ match = app.instance_variable_get(:@router).match(r[:method], r[:path])
113
+ handler = match[:handler] if match
114
+ route_info = {
115
+ method: r[:method],
116
+ path: r[:path],
117
+ auth: r[:metadata]&.dig(:auth),
118
+ mcp: r[:metadata]&.dig(:mcp) || false
119
+ }
120
+ if handler && handler[:request_schema]
121
+ route_info[:request_schema] = OpenAPI::SchemaConverter.convert(handler[:request_schema])
122
+ end
123
+ if handler && handler[:response_schema]
124
+ route_info[:response_schema] = OpenAPI::SchemaConverter.convert(handler[:response_schema])
125
+ end
126
+ route_info
127
+ end
128
+ end
129
+
130
+ unless options[:routes]
131
+ # Collect all Schema subclasses
132
+ schemas = {}
133
+ ObjectSpace.each_object(Class).select { |k| k < Schema && k != Schema }.each do |klass|
134
+ schemas[klass.name] = OpenAPI::SchemaConverter.convert(klass) if klass.name
135
+ end
136
+ output[:schemas] = schemas unless schemas.empty?
137
+ end
138
+
139
+ output[:config] = {
140
+ app_name: app.config.app_name,
141
+ port: app.config.port,
142
+ env: app.config.env,
143
+ docs_enabled: app.config.docs_enabled?,
144
+ auth_configured: !!app.authenticator,
145
+ rate_limit_configured: !!app.rate_limiter_instance,
146
+ mcp_tools: app.mcp_server.list_tools.map { |t| t[:name] }
147
+ }
148
+
149
+ output[:framework] = {
150
+ version: Whoosh::VERSION,
151
+ ruby: RUBY_VERSION,
152
+ yjit: Performance.yjit_enabled?,
153
+ json_engine: Serialization::Json.engine
154
+ }
155
+
156
+ puts JSON.pretty_generate(output)
157
+ end
158
+
159
+ desc "check", "Validate app configuration and catch common mistakes"
160
+ def check
95
161
  app_file = File.join(Dir.pwd, "app.rb")
96
162
  unless File.exist?(app_file)
97
- puts "Error: app.rb not found in #{Dir.pwd}"
163
+ puts " No app.rb found"
98
164
  exit 1
99
165
  end
100
- require app_file
101
- app = ObjectSpace.each_object(Whoosh::App).first
102
- if app
103
- app.routes.each { |r| puts " #{r[:method].ljust(8)} #{r[:path]}" }
166
+
167
+ puts "=> Whoosh App Check"
168
+ puts ""
169
+
170
+ issues = []
171
+ warnings = []
172
+
173
+ # Check .env
174
+ if File.exist?(".env")
175
+ env_content = File.read(".env")
176
+ if env_content.include?("change_me") || env_content.include?("CHANGE_ME")
177
+ issues << "JWT_SECRET in .env still has default value — run: ruby -e \"puts SecureRandom.hex(32)\""
178
+ end
104
179
  else
105
- puts "No Whoosh::App instance found"
180
+ warnings << "No .env file — copy .env.example to .env"
181
+ end
182
+
183
+ # Check .gitignore includes .env
184
+ if File.exist?(".gitignore")
185
+ unless File.read(".gitignore").include?(".env")
186
+ issues << ".gitignore does not exclude .env — secrets may be committed"
187
+ end
188
+ else
189
+ warnings << "No .gitignore file"
190
+ end
191
+
192
+ # Load and validate the app
193
+ begin
194
+ require app_file
195
+ app = ObjectSpace.each_object(Whoosh::App).first
196
+ if app
197
+ puts " ✓ App loads successfully"
198
+
199
+ # Check auth
200
+ if app.authenticator
201
+ puts " ✓ Auth configured"
202
+ else
203
+ warnings << "No auth configured — API is open to everyone"
204
+ end
205
+
206
+ # Check rate limiting
207
+ if app.rate_limiter_instance
208
+ puts " ✓ Rate limiting configured"
209
+ else
210
+ warnings << "No rate limiting — vulnerable to abuse"
211
+ end
212
+
213
+ # Check routes
214
+ route_count = app.routes.length
215
+ puts " ✓ #{route_count} routes registered"
216
+
217
+ # Check MCP
218
+ app.to_rack
219
+ mcp_count = app.mcp_server.list_tools.length
220
+ puts " ✓ #{mcp_count} MCP tools"
221
+
222
+ else
223
+ issues << "No Whoosh::App instance found in app.rb"
224
+ end
225
+ rescue => e
226
+ issues << "App failed to load: #{e.message}"
227
+ end
228
+
229
+ # Check dependencies
230
+ puts ""
231
+ %w[falcon oj sequel].each do |gem_name|
232
+ begin
233
+ require gem_name
234
+ puts " ✓ #{gem_name} available"
235
+ rescue LoadError
236
+ label = case gem_name
237
+ when "falcon" then "(recommended server)"
238
+ when "oj" then "(5-10x faster JSON)"
239
+ when "sequel" then "(database)"
240
+ end
241
+ warnings << "#{gem_name} not installed #{label}"
242
+ end
243
+ end
244
+
245
+ # Report
246
+ puts ""
247
+ if issues.empty? && warnings.empty?
248
+ puts "=> All checks passed! ✓"
249
+ else
250
+ issues.each { |i| puts " ✗ #{i}" }
251
+ warnings.each { |w| puts " ⚠ #{w}" }
252
+ puts ""
253
+ puts issues.empty? ? "=> #{warnings.length} warning(s)" : "=> #{issues.length} issue(s), #{warnings.length} warning(s)"
254
+ exit 1 unless issues.empty?
106
255
  end
107
256
  end
108
257
 
@@ -306,6 +455,21 @@ module Whoosh
306
455
 
307
456
  private
308
457
 
458
+ def load_app
459
+ app_file = File.join(Dir.pwd, "app.rb")
460
+ unless File.exist?(app_file)
461
+ puts "Error: app.rb not found in #{Dir.pwd}"
462
+ return nil
463
+ end
464
+ require app_file
465
+ app = ObjectSpace.each_object(Whoosh::App).first
466
+ unless app
467
+ puts "Error: No Whoosh::App instance found"
468
+ return nil
469
+ end
470
+ app
471
+ end
472
+
309
473
  def run_secret_scan
310
474
  patterns = [
311
475
  /(?:api[_-]?key|secret|password|token)\s*[:=]\s*["'][A-Za-z0-9+\/=]{8,}["']/i,
@@ -33,6 +33,7 @@ module Whoosh
33
33
  write(dir, ".dockerignore", dockerignore)
34
34
  write(dir, ".rubocop.yml", rubocop_config)
35
35
  write(dir, "README.md", readme(name))
36
+ write(dir, "CLAUDE.md", claude_md(name))
36
37
 
37
38
  # Create empty SQLite DB directory
38
39
  FileUtils.mkdir_p(File.join(dir, "db"))
@@ -406,6 +407,180 @@ module Whoosh
406
407
  IGNORE
407
408
  end
408
409
 
410
+ def claude_md(name)
411
+ title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
412
+ <<~MD
413
+ # #{title}
414
+
415
+ ## Framework
416
+
417
+ Built with [Whoosh](https://github.com/johannesdwicahyo/whoosh) — AI-first Ruby API framework.
418
+
419
+ ## Commands
420
+
421
+ ```bash
422
+ whoosh s # start server (http://localhost:9292)
423
+ whoosh s --reload # hot reload on file changes
424
+ whoosh ci # run lint + security + tests
425
+ whoosh routes # list all routes
426
+ whoosh describe # dump app structure as JSON
427
+ whoosh check # validate configuration
428
+ whoosh console # IRB with app loaded
429
+ whoosh mcp --list # list MCP tools
430
+ whoosh worker # start background job worker
431
+ bundle exec rspec # run tests
432
+ ```
433
+
434
+ ## Project Structure
435
+
436
+ ```
437
+ app.rb # main app — routes, auth, config
438
+ config.ru # Rack entry point
439
+ config/app.yml # configuration (database, cache, jobs, logging)
440
+ config/plugins.yml # plugin configuration
441
+ endpoints/ # class-based endpoints (auto-loaded)
442
+ schemas/ # request/response schemas (dry-schema)
443
+ models/ # Sequel models
444
+ db/migrations/ # database migrations
445
+ middleware/ # custom middleware
446
+ test/ # RSpec tests
447
+ .env # secrets (never commit)
448
+ ```
449
+
450
+ ## Patterns
451
+
452
+ ### Adding an endpoint
453
+
454
+ ```bash
455
+ whoosh generate endpoint users name:string email:string
456
+ ```
457
+
458
+ Or manually:
459
+
460
+ ```ruby
461
+ # endpoints/users.rb
462
+ class UsersEndpoint < Whoosh::Endpoint
463
+ get "/users"
464
+ post "/users", request: CreateUserSchema
465
+
466
+ def call(req)
467
+ case req.method
468
+ when "GET" then { users: [] }
469
+ when "POST" then { created: true }
470
+ end
471
+ end
472
+ end
473
+ ```
474
+
475
+ ### Adding a schema
476
+
477
+ ```ruby
478
+ # schemas/user.rb
479
+ class CreateUserSchema < Whoosh::Schema
480
+ field :name, String, required: true, desc: "User name"
481
+ field :email, String, required: true, desc: "Email"
482
+ field :age, Integer, min: 0, max: 150
483
+ end
484
+ ```
485
+
486
+ ### Inline routes (in app.rb)
487
+
488
+ ```ruby
489
+ App.get "/health" do
490
+ { status: "ok" }
491
+ end
492
+
493
+ App.post "/items", request: ItemSchema, auth: :api_key do |req|
494
+ { created: req.body[:name] }
495
+ end
496
+ ```
497
+
498
+ ### Auth
499
+
500
+ Protected routes use `auth: :api_key` or `auth: :jwt`:
501
+ ```ruby
502
+ App.get "/protected", auth: :api_key do |req|
503
+ { user: req.env["whoosh.auth"][:key] }
504
+ end
505
+ ```
506
+
507
+ ### Background jobs
508
+
509
+ ```ruby
510
+ class MyJob < Whoosh::Job
511
+ inject :db # DI injection
512
+ queue :default
513
+ retry_limit 3
514
+ retry_backoff :exponential
515
+
516
+ def perform(user_id:)
517
+ { done: true }
518
+ end
519
+ end
520
+
521
+ MyJob.perform_async(user_id: 42)
522
+ MyJob.perform_in(3600, user_id: 42) # 1 hour delay
523
+ ```
524
+
525
+ ### Streaming (SSE/LLM)
526
+
527
+ ```ruby
528
+ App.post "/chat" do |req|
529
+ stream_llm do |out|
530
+ out << "Hello "
531
+ out << "World"
532
+ out.finish
533
+ end
534
+ end
535
+ ```
536
+
537
+ ### MCP tools
538
+
539
+ Routes with `mcp: true` are auto-exposed as MCP tools:
540
+ ```ruby
541
+ App.post "/analyze", mcp: true, request: AnalyzeSchema do |req|
542
+ { result: "analyzed" }
543
+ end
544
+ ```
545
+
546
+ ### Testing
547
+
548
+ ```ruby
549
+ require "whoosh/test"
550
+ RSpec.describe "API" do
551
+ include Whoosh::Test
552
+ def app = App.to_rack
553
+
554
+ it "works" do
555
+ post_json "/items", { name: "test" }
556
+ assert_response 200
557
+ assert_json(name: "test")
558
+ end
559
+ end
560
+ ```
561
+
562
+ ## AI Introspection
563
+
564
+ ```bash
565
+ whoosh describe # full app structure as JSON
566
+ whoosh describe --routes # routes with schemas
567
+ whoosh describe --schemas # all schema definitions
568
+ ```
569
+
570
+ ## Configuration
571
+
572
+ All config in `config/app.yml`. Environment variables override YAML.
573
+ Secrets in `.env` (auto-loaded at boot, never committed).
574
+
575
+ ## Response Format
576
+
577
+ Simple flat JSON. Not JSON:API.
578
+ - Success: `{"name": "Alice"}`
579
+ - Error: `{"error": "validation_failed", "details": [...]}`
580
+ - Pagination: `{"data": [...], "pagination": {"page": 1}}`
581
+ MD
582
+ end
583
+
409
584
  def readme(name)
410
585
  title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
411
586
  <<~MD
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whoosh
4
- VERSION = "1.2.2"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/whoosh.rb CHANGED
@@ -28,6 +28,7 @@ module Whoosh
28
28
  autoload :Jobs, "whoosh/jobs"
29
29
  autoload :Metrics, "whoosh/metrics"
30
30
  autoload :Paginate, "whoosh/paginate"
31
+ autoload :AI, "whoosh/ai"
31
32
  autoload :VectorStore, "whoosh/vector_store"
32
33
 
33
34
  module Auth
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whoosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo
@@ -174,6 +174,9 @@ files:
174
174
  - README.md
175
175
  - exe/whoosh
176
176
  - lib/whoosh.rb
177
+ - lib/whoosh/ai.rb
178
+ - lib/whoosh/ai/llm.rb
179
+ - lib/whoosh/ai/structured_output.rb
177
180
  - lib/whoosh/app.rb
178
181
  - lib/whoosh/auth/access_control.rb
179
182
  - lib/whoosh/auth/api_key.rb