smart_rag 0.1.0 → 0.2.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +252 -0
  3. data/.rspec +2 -0
  4. data/AGENTS.md +33 -0
  5. data/API_DOCUMENTATION.md +828 -0
  6. data/CHANGELOG.md +11 -1
  7. data/ER-diagram.mmd +144 -0
  8. data/Gemfile +50 -0
  9. data/Gemfile.lock +381 -0
  10. data/Hybrid_Reranking.md +171 -0
  11. data/README.en.md +420 -28
  12. data/README.md +534 -63
  13. data/Rakefile +268 -0
  14. data/SETUP_GUIDE.md +650 -0
  15. data/SmartChunking.md +180 -0
  16. data/USAGE_EXAMPLES.md +1002 -0
  17. data/config/llm_config.yml +4 -2
  18. data/config/smart_rag.yml +45 -1
  19. data/config.ru +15 -0
  20. data/db/migrations/006_create_text_search_configs.rb +3 -2
  21. data/db/migrations/008_create_embeddings.rb +5 -4
  22. data/db/migrations/012_add_metadata_to_source_sections.rb +11 -0
  23. data/db/migrations/013_create_media_jobs.rb +25 -0
  24. data/db/migrations/014_add_media_job_operations_indexes.rb +11 -0
  25. data/db/migrations/015_add_media_leases_and_objects.rb +80 -0
  26. data/db/migrations/016_add_document_principals_and_staging_references.rb +38 -0
  27. data/db/migrations/017_add_media_job_request_fingerprint.rb +48 -0
  28. data/db/seeds/text_search_configs.sql +3 -3
  29. data/design.md +1057 -0
  30. data/docs/API_DOCUMENTATION.md +838 -0
  31. data/docs/DOCUMENTATION_INDEX.en.md +60 -0
  32. data/docs/DOCUMENTATION_INDEX.md +65 -0
  33. data/docs/FIX_SUMMARY.md +256 -0
  34. data/docs/FIX_SUMMARY_COMPLETE.md +273 -0
  35. data/docs/Hybrid_Reranking.md +171 -0
  36. data/docs/MIGRATION_GUIDE.md +151 -0
  37. data/docs/PERFORMANCE_GUIDE.md +58 -0
  38. data/docs/SETUP_GUIDE.md +659 -0
  39. data/docs/SmartChunking.md +180 -0
  40. data/docs/USAGE_EXAMPLES.md +1008 -0
  41. data/docs/design.md +1057 -0
  42. data/docs/evidence_pack.md +211 -0
  43. data/docs/requirements.md +376 -0
  44. data/docs/retrieval_plan.md +251 -0
  45. data/docs/smartrag_improvement_plan.md +201 -0
  46. data/docs/smartrag_refactor.md +216 -0
  47. data/docs/todo.md +931 -0
  48. data/examples/common.rb +1 -1
  49. data/exe/smart-rag-db +163 -0
  50. data/exe/smart-rag-media-worker +34 -0
  51. data/lib/smart_rag/config.rb +12 -0
  52. data/lib/smart_rag/core/document_processor.rb +80 -16
  53. data/lib/smart_rag/core/local_content_store.rb +51 -0
  54. data/lib/smart_rag/core/media_extractors.rb +140 -0
  55. data/lib/smart_rag/core/media_job_queue.rb +353 -0
  56. data/lib/smart_rag/core/media_metadata_extractor.rb +188 -0
  57. data/lib/smart_rag/core/media_object_registry.rb +79 -0
  58. data/lib/smart_rag/core/media_processor.rb +228 -0
  59. data/lib/smart_rag/core/media_safety_policy.rb +61 -0
  60. data/lib/smart_rag/core/s3_content_store.rb +78 -0
  61. data/lib/smart_rag/core/transcript_normalizer.rb +44 -0
  62. data/lib/smart_rag/core/video_semantic_extractor.rb +130 -0
  63. data/lib/smart_rag/http_access_policy.rb +86 -0
  64. data/lib/smart_rag/http_app.rb +188 -0
  65. data/lib/smart_rag/models/embedding.rb +1 -1
  66. data/lib/smart_rag/models/research_topic.rb +1 -1
  67. data/lib/smart_rag/models/research_topic_section.rb +5 -0
  68. data/lib/smart_rag/models/research_topic_tag.rb +5 -0
  69. data/lib/smart_rag/models/search_log.rb +1 -1
  70. data/lib/smart_rag/models/section_fts.rb +5 -0
  71. data/lib/smart_rag/models/section_tag.rb +5 -0
  72. data/lib/smart_rag/models/source_document.rb +1 -1
  73. data/lib/smart_rag/models/source_section.rb +1 -1
  74. data/lib/smart_rag/models/tag.rb +1 -1
  75. data/lib/smart_rag/models/text_search_config.rb +5 -0
  76. data/lib/smart_rag/retrieve.rb +72 -1
  77. data/lib/smart_rag/services/embedding_service.rb +1 -1
  78. data/lib/smart_rag/services/fulltext_search_service.rb +11 -13
  79. data/lib/smart_rag/services/hybrid_search_service.rb +15 -11
  80. data/lib/smart_rag/services/summarization_service.rb +1 -1
  81. data/lib/smart_rag/services/tag_service.rb +1 -1
  82. data/lib/smart_rag/version.rb +1 -1
  83. data/lib/smart_rag.rb +264 -30
  84. data/patch_language.rb +27 -0
  85. data/requirements.md +376 -0
  86. data/source_documents_export.json +11072 -0
  87. data/todo.md +931 -0
  88. data/workers/analyze_content.rb +6 -2
  89. data/workers/get_embedding.rb +1 -1
  90. metadata +151 -38
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rack'
5
+ require 'fileutils'
6
+ require 'tmpdir'
7
+ require 'securerandom'
8
+ require_relative 'http_access_policy'
9
+ require_relative 'core/media_job_queue'
10
+
11
+ module SmartRAG
12
+ class HttpApp
13
+ OPERATIONS = %w[add_document add_media add_image add_audio add_video].freeze
14
+
15
+ def initialize(rag:, extractors: {}, access_policy: nil)
16
+ @rag = rag
17
+ @extractors = symbolize_keys(extractors)
18
+ @access_policy = access_policy
19
+ end
20
+
21
+ def call(env)
22
+ request = Rack::Request.new(env)
23
+ return health if request.get? && request.path == '/healthz'
24
+ principal = authorize(request)
25
+ request.env['smart_rag.principal'] = principal
26
+ return retrieve(request) if request.post? && request.path == '/v1/retrieve'
27
+ return ingest(request) if request.post? && request.path == '/v1/media'
28
+ return list_media_jobs(request) if request.get? && request.path == '/v1/media/jobs'
29
+ if request.get? && request.path == '/v1/media/jobs/stats'
30
+ return json(200, access_policy ? rag.media_job_statistics(principal: principal) : rag.media_job_statistics)
31
+ end
32
+ if request.post? && (match = request.path.match(%r{\A/v1/media/jobs/(\d+)/(cancel|retry)\z}))
33
+ return mutate_media_job(match[1], match[2], principal)
34
+ end
35
+ if request.get? && request.path.match?(%r{\A/v1/media/jobs/\d+\z})
36
+ job_id = request.path.split('/').last
37
+ job = access_policy ? rag.media_job(job_id, principal: principal) : rag.media_job(job_id)
38
+ return job ? json(200, job) : json(404, error: 'media job not found')
39
+ end
40
+
41
+ json(404, error: 'not found')
42
+ rescue JSON::ParserError => e
43
+ json(400, error: "invalid JSON: #{e.message}")
44
+ rescue ArgumentError => e
45
+ json(400, error: e.message)
46
+ rescue HttpAccessPolicy::Unauthorized => e
47
+ json(401, error: e.message)
48
+ rescue HttpAccessPolicy::QuotaExceeded => e
49
+ json(429, error: e.message)
50
+ rescue Core::MediaJobQueue::IdempotencyConflict => e
51
+ json(409, error: e.message, code: 'idempotency_conflict')
52
+ rescue StandardError => e
53
+ json(500, error: "#{e.class}: #{e.message}")
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :rag, :extractors, :access_policy
59
+
60
+ def authorize(request)
61
+ return 'anonymous' unless access_policy
62
+ principal = access_policy.authorize!(request)
63
+ bytes = request.post? && request.path == '/v1/media' ? request.content_length.to_i : 0
64
+ access_policy.consume!(principal, request_bytes: bytes)
65
+ principal
66
+ end
67
+
68
+ def health
69
+ payload = { status: 'ok' }
70
+ payload[:media_jobs] = rag.media_job_statistics if rag.respond_to?(:media_job_statistics)
71
+ json(200, payload)
72
+ rescue StandardError => e
73
+ json(503, status: 'degraded', error: "media job queue unavailable: #{e.message}")
74
+ end
75
+
76
+ def list_media_jobs(request)
77
+ options = { status: request.params['status'], limit: request.params.fetch('limit', 20),
78
+ offset: request.params.fetch('offset', 0) }
79
+ options[:principal] = request.env['smart_rag.principal'] if access_policy
80
+ json(200, rag.media_jobs(**options))
81
+ end
82
+
83
+ def mutate_media_job(job_id, action, principal)
84
+ result = if action == 'cancel'
85
+ access_policy ? rag.cancel_media_job(job_id, principal: principal) : rag.cancel_media_job(job_id)
86
+ else
87
+ access_policy ? rag.retry_media_job(job_id, principal: principal) : rag.retry_media_job(job_id)
88
+ end
89
+ return json(404, error: 'media job not found') unless result
90
+ return json(409, result) if result[:transition_error]
91
+
92
+ json(200, result)
93
+ end
94
+
95
+ def retrieve(request)
96
+ body = json_body(request)
97
+ plan = symbolize_keys(body.fetch('plan', body))
98
+ plan[:_principal] = request.env['smart_rag.principal'] if access_policy
99
+ json(200, rag.retrieve(plan: plan))
100
+ end
101
+
102
+ def ingest(request)
103
+ payload, source, cleanup_path = if request.media_type.to_s.start_with?('multipart/form-data')
104
+ multipart_payload(request)
105
+ else
106
+ json_payload(request)
107
+ end
108
+ operation = payload.fetch(:operation).to_s
109
+ raise ArgumentError, "unsupported operation: #{operation}" unless OPERATIONS.include?(operation)
110
+ raise ArgumentError, 'source is required' if source.to_s.empty?
111
+
112
+ options = symbolize_keys(payload[:options] || {}).merge(extractors)
113
+ options[:principal] = request.env['smart_rag.principal'] if access_policy
114
+ if options.delete(:async)
115
+ source, cleanup_path = persist_async_upload(source, cleanup_path)
116
+ staged = rag.stage_media_upload(source) if File.file?(source) && rag.respond_to?(:stage_media_upload)
117
+ if staged
118
+ File.delete(source) if File.file?(source)
119
+ source = staged[:storage_uri]
120
+ options[:staging_media_object_id] = staged[:media_object_id]
121
+ else
122
+ options[:delete_source_after] = true if File.file?(source)
123
+ end
124
+ options[:idempotency_key] ||= request.get_header('HTTP_IDEMPOTENCY_KEY')
125
+ return json(202, rag.enqueue_media(source, options.merge(operation: operation.to_sym)))
126
+ end
127
+ json(200, rag.public_send(operation, source, options))
128
+ ensure
129
+ File.delete(cleanup_path) if cleanup_path && File.exist?(cleanup_path)
130
+ end
131
+
132
+ def json_payload(request)
133
+ payload = symbolize_keys(json_body(request))
134
+ [payload, payload[:source], nil]
135
+ end
136
+
137
+ def multipart_payload(request)
138
+ params = request.params
139
+ upload = params['file']
140
+ tempfile = upload.is_a?(Hash) && (upload[:tempfile] || upload['tempfile'])
141
+ filename = upload.is_a?(Hash) && (upload[:filename] || upload['filename'])
142
+ raise ArgumentError, 'multipart file is required' unless tempfile
143
+
144
+ extension = File.extname(filename.to_s)
145
+ source = tempfile.path
146
+ unless extension.empty? || File.extname(source).downcase == extension.downcase
147
+ preserved = File.join(Dir.tmpdir, "smart-rag-upload-#{Process.pid}-#{object_id}#{extension}")
148
+ FileUtils.cp(source, preserved)
149
+ source = preserved
150
+ end
151
+
152
+ payload = {
153
+ operation: params['operation'],
154
+ options: params['options'].to_s.empty? ? {} : JSON.parse(params['options'])
155
+ }
156
+ [symbolize_keys(payload), source, source == tempfile.path ? nil : source]
157
+ end
158
+
159
+ def json_body(request)
160
+ content = request.body.read
161
+ content.empty? ? {} : JSON.parse(content)
162
+ end
163
+
164
+ def persist_async_upload(source, cleanup_path)
165
+ return [source, cleanup_path] unless File.file?(source)
166
+
167
+ directory = ENV.fetch('SMARTRAG_MEDIA_JOB_UPLOAD_DIR', File.join(Dir.tmpdir, 'smart-rag-media-jobs'))
168
+ FileUtils.mkdir_p(directory)
169
+ extension = File.extname(source)
170
+ destination = File.join(directory, "#{SecureRandom.uuid}#{extension}")
171
+ FileUtils.cp(source, destination)
172
+ [destination, cleanup_path]
173
+ end
174
+
175
+ def symbolize_keys(value)
176
+ return value.map { |item| symbolize_keys(item) } if value.is_a?(Array)
177
+ return value unless value.is_a?(Hash)
178
+
179
+ value.each_with_object({}) do |(key, item), result|
180
+ result[key.to_sym] = symbolize_keys(item)
181
+ end
182
+ end
183
+
184
+ def json(status, payload)
185
+ [status, { 'content-type' => 'application/json' }, [JSON.generate(payload)]]
186
+ end
187
+ end
188
+ end
@@ -11,7 +11,7 @@ module SmartRAG
11
11
 
12
12
  # Set dataset after database is connected
13
13
  def self.set_dataset_from_db
14
- set_dataset(:embeddings)
14
+ set_dataset(Sequel::Model.db[:embeddings])
15
15
  end
16
16
 
17
17
  # Add bang methods for FactoryBot compatibility
@@ -7,7 +7,7 @@ module SmartRAG
7
7
  class ResearchTopic < Sequel::Model
8
8
  # Set dataset after database is connected
9
9
  def self.set_dataset_from_db
10
- set_dataset(:research_topics)
10
+ set_dataset(Sequel::Model.db[:research_topics])
11
11
  end
12
12
  include FactoryBotHelpers
13
13
  plugin :validation_helpers
@@ -5,6 +5,11 @@ module SmartRAG
5
5
  module Models
6
6
  # ResearchTopicSection model for many-to-many relationship
7
7
  class ResearchTopicSection < Sequel::Model(:research_topic_sections)
8
+ # Set dataset after database is connected
9
+ def self.set_dataset_from_db
10
+ set_dataset(Sequel::Model.db[:research_topic_sections])
11
+ end
12
+
8
13
  include FactoryBotHelpers
9
14
  plugin :validation_helpers
10
15
  plugin :timestamps, update_on_create: false
@@ -5,6 +5,11 @@ module SmartRAG
5
5
  module Models
6
6
  # ResearchTopicTag model for many-to-many relationship between topics and tags
7
7
  class ResearchTopicTag < Sequel::Model(:research_topic_tags)
8
+ # Set dataset after database is connected
9
+ def self.set_dataset_from_db
10
+ set_dataset(Sequel::Model.db[:research_topic_tags])
11
+ end
12
+
8
13
  include FactoryBotHelpers
9
14
  plugin :validation_helpers
10
15
  plugin :timestamps, update_on_create: false
@@ -7,7 +7,7 @@ module SmartRAG
7
7
  class SearchLog < Sequel::Model
8
8
  # Set dataset after database is connected
9
9
  def self.set_dataset_from_db
10
- set_dataset(:search_logs)
10
+ set_dataset(Sequel::Model.db[:search_logs])
11
11
  end
12
12
  include FactoryBotHelpers
13
13
  plugin :validation_helpers
@@ -5,6 +5,11 @@ module SmartRAG
5
5
  module Models
6
6
  # SectionFts model for full-text search optimization
7
7
  class SectionFts < Sequel::Model(:section_fts)
8
+ # Set dataset after database is connected
9
+ def self.set_dataset_from_db
10
+ set_dataset(Sequel::Model.db[:section_fts])
11
+ end
12
+
8
13
  include FactoryBotHelpers
9
14
  plugin :validation_helpers
10
15
  plugin :timestamps, update_on_create: true
@@ -5,6 +5,11 @@ module SmartRAG
5
5
  module Models
6
6
  # SectionTag model for many-to-many relationship between sections and tags
7
7
  class SectionTag < Sequel::Model(:section_tags)
8
+ # Set dataset after database is connected
9
+ def self.set_dataset_from_db
10
+ set_dataset(Sequel::Model.db[:section_tags])
11
+ end
12
+
8
13
  include FactoryBotHelpers
9
14
  plugin :validation_helpers
10
15
  plugin :timestamps, update_on_create: false
@@ -7,7 +7,7 @@ module SmartRAG
7
7
  class SourceDocument < Sequel::Model
8
8
  # Set dataset after database is connected
9
9
  def self.set_dataset_from_db
10
- set_dataset(:source_documents)
10
+ set_dataset(Sequel::Model.db[:source_documents])
11
11
  end
12
12
  plugin :validation_helpers
13
13
  plugin :timestamps, update_on_create: true
@@ -7,7 +7,7 @@ module SmartRAG
7
7
  class SourceSection < Sequel::Model
8
8
  # Set dataset after database is connected
9
9
  def self.set_dataset_from_db
10
- set_dataset(:source_sections)
10
+ set_dataset(Sequel::Model.db[:source_sections])
11
11
  end
12
12
  include FactoryBotHelpers
13
13
  plugin :validation_helpers
@@ -7,7 +7,7 @@ module SmartRAG
7
7
  class Tag < Sequel::Model
8
8
  # Set dataset after database is connected
9
9
  def self.set_dataset_from_db
10
- set_dataset(:tags)
10
+ set_dataset(Sequel::Model.db[:tags])
11
11
  end
12
12
  include FactoryBotHelpers
13
13
  plugin :validation_helpers
@@ -5,6 +5,11 @@ module SmartRAG
5
5
  module Models
6
6
  # TextSearchConfig model for language-specific search configurations
7
7
  class TextSearchConfig < Sequel::Model(:text_search_configs)
8
+ # Set dataset after database is connected
9
+ def self.set_dataset_from_db
10
+ set_dataset(Sequel::Model.db[:text_search_configs])
11
+ end
12
+
8
13
  include FactoryBotHelpers
9
14
  plugin :validation_helpers
10
15
  # This model uses text_search_configs table with language_code as PK
@@ -17,8 +17,12 @@ module SmartRAG
17
17
  end
18
18
 
19
19
  def execute(plan:)
20
+ @document_metadata_cache = {}
21
+ @section_topic_cache = {}
20
22
  normalized_plan = normalize_plan(plan)
21
23
  validate_plan!(normalized_plan)
24
+ principal = normalized_plan.delete(:_principal)
25
+ normalized_plan = scope_plan_to_principal(normalized_plan, principal) if principal
22
26
 
23
27
  started_at = monotonic_now
24
28
  generated_at = Time.now.utc.iso8601
@@ -57,7 +61,7 @@ module SmartRAG
57
61
 
58
62
  pack = {
59
63
  version: normalized_plan[:version] || '0.1',
60
- plan: normalized_plan,
64
+ plan: public_plan(normalized_plan),
61
65
  plan_id: plan_id,
62
66
  request_id: request_id,
63
67
  generated_at: generated_at,
@@ -345,6 +349,9 @@ module SmartRAG
345
349
  if filters[:source_type]
346
350
  applied[:source_type] = Array(filters[:source_type]).map(&:to_s)
347
351
  end
352
+ if filters[:media_type]
353
+ applied[:media_type] = Array(filters[:media_type]).map(&:to_s)
354
+ end
348
355
  if filters[:source_uri_prefix]
349
356
  applied[:source_uri_prefix] = Array(filters[:source_uri_prefix]).map(&:to_s)
350
357
  end
@@ -479,6 +486,12 @@ module SmartRAG
479
486
  return false unless allowed.include?(actual)
480
487
  end
481
488
 
489
+ if filters[:media_type]
490
+ allowed = Array(filters[:media_type]).map { |value| value.to_s.downcase }
491
+ actual = candidate.dig(:metadata, :media_type) || candidate.dig(:metadata, 'media_type')
492
+ return false unless allowed.include?(actual.to_s.downcase)
493
+ end
494
+
482
495
  if filters[:source_uri_prefix]
483
496
  prefixes = Array(filters[:source_uri_prefix]).map(&:to_s)
484
497
  uri = candidate[:source_uri].to_s
@@ -491,6 +504,11 @@ module SmartRAG
491
504
  return false if required_topic_ids.any? && (required_topic_ids & section_topics).empty?
492
505
  end
493
506
 
507
+ if filters[:principal]
508
+ actual = document_principal_for(candidate[:document_id])
509
+ return false unless actual == filters[:principal].to_s
510
+ end
511
+
494
512
  true
495
513
  end
496
514
 
@@ -520,9 +538,46 @@ module SmartRAG
520
538
 
521
539
  metadata[:section_id] ||= section_id if section_id
522
540
  metadata[:document_id] ||= document_id if document_id
541
+ section_metadata = extract_section_metadata(section)
542
+ metadata = document_metadata_for(document_id).merge(metadata).merge(section_metadata)
523
543
  metadata
524
544
  end
525
545
 
546
+ def extract_section_metadata(section)
547
+ raw = if section.is_a?(Hash)
548
+ section[:metadata] || section['metadata']
549
+ elsif section.respond_to?(:metadata)
550
+ section.metadata
551
+ end
552
+ parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
553
+ parsed.is_a?(Hash) ? symbolize_keys(parsed) : {}
554
+ rescue JSON::ParserError
555
+ {}
556
+ end
557
+
558
+ def document_metadata_for(document_id)
559
+ return {} if document_id.nil?
560
+ return {} unless defined?(::SmartRAG) && ::SmartRAG.respond_to?(:db) && ::SmartRAG.db
561
+
562
+ @document_metadata_cache ||= {}
563
+ return @document_metadata_cache[document_id] if @document_metadata_cache.key?(document_id)
564
+
565
+ raw = ::SmartRAG.db[:source_documents].where(id: document_id).get(:metadata)
566
+ parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
567
+ @document_metadata_cache[document_id] = parsed.is_a?(Hash) ? symbolize_keys(parsed) : {}
568
+ rescue StandardError
569
+ {}
570
+ end
571
+
572
+ def document_principal_for(document_id)
573
+ return nil if document_id.nil? || !::SmartRAG.db
574
+ @document_principal_cache ||= {}
575
+ return @document_principal_cache[document_id] if @document_principal_cache.key?(document_id)
576
+ @document_principal_cache[document_id] = ::SmartRAG.db[:source_documents].where(id: document_id).get(:principal)
577
+ rescue StandardError
578
+ nil
579
+ end
580
+
526
581
  def extract_vector_score(result, mode)
527
582
  return numeric_or_nil(result[:vector_score]) if result.is_a?(Hash) && result.key?(:vector_score)
528
583
  return numeric_or_nil(result[:similarity]) if mode == 'semantic' && result.is_a?(Hash) && result.key?(:similarity)
@@ -575,6 +630,22 @@ module SmartRAG
575
630
  deep_symbolize(plan)
576
631
  end
577
632
 
633
+ def scope_plan_to_principal(plan, principal)
634
+ allowed_ids = ::SmartRAG.db[:source_documents].where(principal: principal.to_s).select_map(:id)
635
+ requested = Array(plan.dig(:global_filters, :document_ids)).map(&:to_i)
636
+ allowed_ids &= requested unless requested.empty?
637
+ filters = symbolize_keys(plan[:global_filters] || {}).merge(
638
+ document_ids: allowed_ids,
639
+ principal: principal.to_s
640
+ )
641
+ plan.merge(global_filters: filters)
642
+ end
643
+
644
+ def public_plan(plan)
645
+ filters = symbolize_keys(plan[:global_filters] || {}).reject { |key, _| key == :principal }
646
+ plan.merge(global_filters: filters)
647
+ end
648
+
578
649
  def validate_plan!(plan)
579
650
  raise ArgumentError, 'Retrieval plan must be a hash' unless plan.is_a?(Hash)
580
651
  raise ArgumentError, 'Retrieval plan requires queries' unless plan[:queries].is_a?(Array) && !plan[:queries].empty?
@@ -24,7 +24,7 @@ module SmartRAG
24
24
  Dir.glob(File.join(workers_dir, '*.rb')).each { |file| require file }
25
25
 
26
26
  # Initialize SmartPrompt engine
27
- config_path = @config[:config_path] || 'config/llm_config.yml'
27
+ config_path = @config[:config_path] || File.expand_path('../../../config/llm_config.yml', __dir__)
28
28
  @smart_prompt_engine = SmartPrompt::Engine.new(config_path)
29
29
  rescue StandardError => e
30
30
  log_error('Failed to initialize SmartPrompt engine', e)
@@ -319,7 +319,8 @@ module SmartRAG
319
319
  Sequel[:source_documents][:author],
320
320
  Sequel[:source_documents][:publication_date],
321
321
  Sequel[:source_sections][:section_number],
322
- Sequel[:source_documents][:metadata]
322
+ Sequel[:source_documents][:metadata].as(:document_metadata),
323
+ Sequel[:source_sections][:metadata].as(:section_metadata)
323
324
  )
324
325
 
325
326
  result = dataset.first
@@ -333,21 +334,18 @@ module SmartRAG
333
334
  section_number: result[:section_number]
334
335
  }
335
336
 
336
- if result[:metadata]
337
- if result[:metadata].is_a?(String)
338
- begin
339
- parsed = JSON.parse(result[:metadata])
340
- metadata.merge!(parsed) if parsed.is_a?(Hash)
341
- rescue JSON::ParserError
342
- # Ignore malformed metadata strings
343
- end
344
- elsif result[:metadata].is_a?(Hash)
345
- metadata.merge!(result[:metadata])
346
- end
347
- end
337
+ metadata.merge!(parse_metadata(result[:document_metadata]))
338
+ metadata.merge!(parse_metadata(result[:section_metadata]))
348
339
  metadata
349
340
  end
350
341
 
342
+ def parse_metadata(value)
343
+ parsed = value.is_a?(String) ? JSON.parse(value) : value
344
+ parsed.is_a?(Hash) ? parsed : {}
345
+ rescue JSON::ParserError
346
+ {}
347
+ end
348
+
351
349
  # Simplify result for quick search
352
350
  def simplify_result(result)
353
351
  {
@@ -702,6 +702,12 @@ module SmartRAG
702
702
  document_id: document_id
703
703
  }
704
704
  end
705
+ section_metadata = if section.is_a?(Hash)
706
+ section[:metadata] || section['metadata']
707
+ elsif section.respond_to?(:metadata)
708
+ section.metadata
709
+ end
710
+ base_metadata.merge!(parse_metadata(section_metadata))
705
711
 
706
712
  if document_id && document_id != ''
707
713
  begin
@@ -715,18 +721,9 @@ module SmartRAG
715
721
  # Merge document metadata (may contain category, author, etc.)
716
722
  if doc[:metadata]
717
723
  @logger.debug "Document metadata found: #{doc[:metadata].inspect}"
718
- parsed_metadata = if doc[:metadata].is_a?(String)
719
- begin
720
- JSON.parse(doc[:metadata])
721
- rescue StandardError
722
- {}
723
- end
724
- else
725
- doc[:metadata]
726
- end
727
- parsed_metadata = symbolize_keys(parsed_metadata) if parsed_metadata.is_a?(Hash)
724
+ parsed_metadata = parse_metadata(doc[:metadata])
728
725
  @logger.debug "Parsed metadata: #{parsed_metadata.inspect}"
729
- base_metadata.merge!(parsed_metadata) if parsed_metadata.is_a?(Hash)
726
+ base_metadata = parsed_metadata.merge(base_metadata)
730
727
  else
731
728
  @logger.debug 'Document has no metadata field or is nil'
732
729
  end
@@ -753,6 +750,13 @@ module SmartRAG
753
750
  end
754
751
  end
755
752
 
753
+ def parse_metadata(value)
754
+ parsed = value.is_a?(String) ? JSON.parse(value) : value
755
+ parsed.is_a?(Hash) ? symbolize_keys(parsed) : {}
756
+ rescue JSON::ParserError
757
+ {}
758
+ end
759
+
756
760
  def calculate_score_stats(results)
757
761
  return {} if results.empty?
758
762
 
@@ -26,7 +26,7 @@ module SmartRAG
26
26
  Dir.glob(File.join(workers_dir, '*.rb')).each { |file| require file }
27
27
 
28
28
  # Initialize SmartPrompt engine
29
- config_path = @config[:config_path] || "config/llm_config.yml"
29
+ config_path = @config[:config_path] || File.expand_path("../../../config/llm_config.yml", __dir__)
30
30
  @smart_prompt_engine = SmartPrompt::Engine.new(config_path)
31
31
 
32
32
  @logger.info "SummarizationService initialized"
@@ -27,7 +27,7 @@ module SmartRAG
27
27
  Dir.glob(File.join(workers_dir, "*.rb")).each { |file| require file }
28
28
 
29
29
  # Initialize SmartPrompt engine
30
- config_path = @config[:config_path] || "config/llm_config.yml"
30
+ config_path = @config[:config_path] || File.expand_path("../../../config/llm_config.yml", __dir__)
31
31
  @smart_prompt_engine = SmartPrompt::Engine.new(config_path)
32
32
  rescue StandardError => e
33
33
  log_error("Failed to initialize TagService", e)
@@ -1,3 +1,3 @@
1
1
  module SmartRAG
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end