htm 0.0.31 → 0.0.32

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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/.irbrc +2 -3
  3. data/.rubocop.yml +184 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +2 -0
  6. data/Rakefile +93 -12
  7. data/db/migrate/00008_create_node_relationships.rb +54 -0
  8. data/db/migrate/00009_fix_node_relationships_column_types.rb +17 -0
  9. data/db/schema.sql +124 -1
  10. data/docs/api/database.md +35 -57
  11. data/docs/api/embedding-service.md +1 -1
  12. data/docs/api/index.md +26 -15
  13. data/docs/api/working-memory.md +8 -8
  14. data/docs/architecture/index.md +5 -7
  15. data/docs/architecture/overview.md +5 -8
  16. data/docs/assets/images/htm-architecture-overview.svg +1 -1
  17. data/docs/assets/images/htm-context-assembly-flow.svg +2 -2
  18. data/docs/assets/images/htm-layered-architecture.svg +3 -3
  19. data/docs/assets/images/two-tier-memory-architecture.svg +1 -1
  20. data/docs/database/README.md +1 -0
  21. data/docs/database_rake_tasks.md +20 -28
  22. data/docs/development/contributing.md +5 -5
  23. data/docs/development/index.md +4 -7
  24. data/docs/development/schema.md +71 -1
  25. data/docs/development/setup.md +40 -82
  26. data/docs/development/testing.md +1 -1
  27. data/docs/examples/file-loading.md +4 -4
  28. data/docs/examples/mcp-client.md +1 -1
  29. data/docs/getting-started/quick-start.md +4 -4
  30. data/docs/guides/adding-memories.md +14 -1
  31. data/docs/guides/configuration.md +5 -5
  32. data/docs/guides/context-assembly.md +4 -4
  33. data/docs/guides/file-loading.md +12 -12
  34. data/docs/guides/getting-started.md +2 -2
  35. data/docs/guides/long-term-memory.md +7 -27
  36. data/docs/guides/propositions.md +20 -19
  37. data/docs/guides/recalling-memories.md +5 -5
  38. data/docs/guides/tags.md +18 -13
  39. data/docs/multi_framework_support.md +1 -1
  40. data/docs/robots/hive-mind.md +1 -1
  41. data/docs/robots/multi-robot.md +2 -2
  42. data/docs/robots/robot-groups.md +1 -1
  43. data/docs/robots/two-tier-memory.md +72 -94
  44. data/docs/setup_local_database.md +8 -54
  45. data/docs/using_rake_tasks_in_your_app.md +6 -6
  46. data/examples/01_basic_usage.rb +1 -0
  47. data/examples/03_custom_llm_configuration.rb +1 -0
  48. data/examples/04_file_loader_usage.rb +1 -0
  49. data/examples/05_timeframe_demo.rb +1 -0
  50. data/examples/06_example_app/app.rb +1 -0
  51. data/examples/07_cli_app/htm_cli.rb +1 -0
  52. data/examples/09_mcp_client.rb +1 -0
  53. data/examples/10_telemetry/demo.rb +1 -0
  54. data/examples/11_robot_groups/multi_process.rb +1 -0
  55. data/examples/11_robot_groups/same_process.rb +1 -0
  56. data/examples/12_rails_app/.envrc +12 -0
  57. data/examples/12_rails_app/Gemfile +8 -3
  58. data/examples/12_rails_app/Gemfile.lock +94 -89
  59. data/examples/12_rails_app/README.md +70 -19
  60. data/examples/12_rails_app/app/controllers/application_controller.rb +6 -0
  61. data/examples/12_rails_app/app/controllers/chats_controller.rb +305 -0
  62. data/examples/12_rails_app/app/controllers/dashboard_controller.rb +3 -0
  63. data/examples/12_rails_app/app/controllers/files_controller.rb +17 -2
  64. data/examples/12_rails_app/app/controllers/home_controller.rb +8 -0
  65. data/examples/12_rails_app/app/controllers/memories_controller.rb +9 -4
  66. data/examples/12_rails_app/app/controllers/messages_controller.rb +214 -0
  67. data/examples/12_rails_app/app/controllers/robots_controller.rb +11 -1
  68. data/examples/12_rails_app/app/controllers/tags_controller.rb +14 -1
  69. data/examples/12_rails_app/app/javascript/application.js +1 -1
  70. data/examples/12_rails_app/app/models/application_record.rb +5 -0
  71. data/examples/12_rails_app/app/models/chat.rb +36 -0
  72. data/examples/12_rails_app/app/models/message.rb +5 -0
  73. data/examples/12_rails_app/app/models/model.rb +5 -0
  74. data/examples/12_rails_app/app/models/tool_call.rb +5 -0
  75. data/examples/12_rails_app/app/views/chats/index.html.erb +61 -0
  76. data/examples/12_rails_app/app/views/chats/show.html.erb +213 -0
  77. data/examples/12_rails_app/app/views/dashboard/index.html.erb +3 -0
  78. data/examples/12_rails_app/app/views/files/index.html.erb +10 -5
  79. data/examples/12_rails_app/app/views/files/new.html.erb +4 -2
  80. data/examples/12_rails_app/app/views/files/show.html.erb +19 -3
  81. data/examples/12_rails_app/app/views/home/index.html.erb +45 -0
  82. data/examples/12_rails_app/app/views/layouts/application.html.erb +20 -18
  83. data/examples/12_rails_app/app/views/memories/_memory_card.html.erb +1 -1
  84. data/examples/12_rails_app/app/views/memories/deleted.html.erb +3 -1
  85. data/examples/12_rails_app/app/views/memories/edit.html.erb +2 -0
  86. data/examples/12_rails_app/app/views/memories/index.html.erb +2 -0
  87. data/examples/12_rails_app/app/views/memories/new.html.erb +2 -0
  88. data/examples/12_rails_app/app/views/memories/show.html.erb +4 -2
  89. data/examples/12_rails_app/app/views/messages/_message.html.erb +20 -0
  90. data/examples/12_rails_app/app/views/robots/index.html.erb +2 -0
  91. data/examples/12_rails_app/app/views/robots/new.html.erb +2 -0
  92. data/examples/12_rails_app/app/views/robots/show.html.erb +2 -0
  93. data/examples/12_rails_app/app/views/search/index.html.erb +59 -8
  94. data/examples/12_rails_app/app/views/shared/_navbar.html.erb +75 -29
  95. data/examples/12_rails_app/app/views/tags/index.html.erb +2 -0
  96. data/examples/12_rails_app/app/views/tags/show.html.erb +3 -1
  97. data/examples/12_rails_app/config/application.rb +1 -1
  98. data/examples/12_rails_app/config/database.yml +9 -5
  99. data/examples/12_rails_app/config/importmap.rb +1 -1
  100. data/examples/12_rails_app/config/initializers/htm.rb +9 -2
  101. data/examples/12_rails_app/config/initializers/ruby_llm.rb +33 -0
  102. data/examples/12_rails_app/config/routes.rb +39 -23
  103. data/examples/12_rails_app/db/migrate/20250124000001_create_ruby_llm_tables.rb +34 -0
  104. data/examples/12_rails_app/db/migrate/20250124000002_create_models_table.rb +28 -0
  105. data/examples/12_rails_app/db/schema.rb +67 -0
  106. data/examples/examples_helper.rb +25 -0
  107. data/lib/htm/circuit_breaker.rb +5 -6
  108. data/lib/htm/config/builder.rb +12 -12
  109. data/lib/htm/config/database.rb +21 -27
  110. data/lib/htm/config/validator.rb +12 -18
  111. data/lib/htm/config.rb +76 -65
  112. data/lib/htm/database.rb +193 -199
  113. data/lib/htm/embedding_service.rb +4 -9
  114. data/lib/htm/integrations/sinatra.rb +7 -7
  115. data/lib/htm/job_adapter.rb +14 -21
  116. data/lib/htm/jobs/generate_embedding_job.rb +28 -44
  117. data/lib/htm/jobs/generate_propositions_job.rb +29 -55
  118. data/lib/htm/jobs/generate_relationships_job.rb +137 -0
  119. data/lib/htm/jobs/generate_tags_job.rb +45 -67
  120. data/lib/htm/loaders/markdown_loader.rb +65 -112
  121. data/lib/htm/long_term_memory/fulltext_search.rb +1 -1
  122. data/lib/htm/long_term_memory/hybrid_search.rb +300 -128
  123. data/lib/htm/long_term_memory/node_operations.rb +2 -2
  124. data/lib/htm/long_term_memory/relevance_scorer.rb +100 -68
  125. data/lib/htm/long_term_memory/tag_operations.rb +87 -120
  126. data/lib/htm/long_term_memory/vector_search.rb +1 -1
  127. data/lib/htm/long_term_memory.rb +2 -1
  128. data/lib/htm/mcp/cli.rb +59 -58
  129. data/lib/htm/mcp/server.rb +5 -6
  130. data/lib/htm/mcp/tools.rb +30 -36
  131. data/lib/htm/migration.rb +10 -10
  132. data/lib/htm/models/node.rb +2 -3
  133. data/lib/htm/models/node_relationship.rb +72 -0
  134. data/lib/htm/models/node_tag.rb +2 -2
  135. data/lib/htm/models/robot_node.rb +2 -2
  136. data/lib/htm/models/tag.rb +41 -28
  137. data/lib/htm/observability.rb +45 -51
  138. data/lib/htm/proposition_service.rb +3 -7
  139. data/lib/htm/query_cache.rb +13 -15
  140. data/lib/htm/railtie.rb +1 -2
  141. data/lib/htm/robot_group.rb +9 -9
  142. data/lib/htm/sequel_config.rb +1 -0
  143. data/lib/htm/sql_builder.rb +1 -1
  144. data/lib/htm/tag_service.rb +2 -6
  145. data/lib/htm/timeframe.rb +4 -5
  146. data/lib/htm/timeframe_extractor.rb +42 -83
  147. data/lib/htm/version.rb +1 -1
  148. data/lib/htm/workflows/remember_workflow.rb +112 -115
  149. data/lib/htm/working_memory.rb +21 -26
  150. data/lib/htm.rb +103 -116
  151. data/lib/tasks/db.rake +0 -2
  152. data/lib/tasks/doc.rake +14 -13
  153. data/lib/tasks/files.rake +5 -12
  154. data/lib/tasks/htm.rake +70 -71
  155. data/lib/tasks/jobs.rake +41 -47
  156. data/lib/tasks/tags.rake +3 -8
  157. metadata +25 -100
@@ -93,14 +93,14 @@ class HTM
93
93
  # - Pool is healthy if no threads are waiting
94
94
  # - Pool is critical only if threads are waiting for connections
95
95
  # - size == max_size is normal (pre-allocated pool), not a problem
96
- status = if waiting > 0
97
- waiting > max_size / 2 ? :exhausted : :critical
98
- else
99
- :healthy
100
- end
96
+ status = if waiting.positive?
97
+ waiting > max_size / 2 ? :exhausted : :critical
98
+ else
99
+ :healthy
100
+ end
101
101
 
102
102
  # Utilization based on waiting threads (pool stress indicator)
103
- utilization = waiting > 0 ? ((waiting.to_f / max_size) * 100).round(2) : 0.0
103
+ utilization = waiting.positive? ? ((waiting.to_f / max_size) * 100).round(2) : 0.0
104
104
 
105
105
  stats = {
106
106
  size: max_size,
@@ -252,47 +252,11 @@ class HTM
252
252
  def health_check
253
253
  checks = {}
254
254
  issues = []
255
-
256
- # Check database connection
257
- checks[:database] = connected?
258
- issues << "Database not connected" unless checks[:database]
259
-
260
- # Check connection pool
261
- pool_stats = connection_pool_stats
262
- checks[:connection_pool] = pool_stats[:status] == :healthy || pool_stats[:status] == :warning
263
- issues << "Connection pool #{pool_stats[:status]}" if [:critical, :exhausted].include?(pool_stats[:status])
264
-
265
- # Check circuit breakers
266
- cb_stats = circuit_breaker_stats
267
- if cb_stats[:embedding_service]
268
- checks[:embedding_circuit] = cb_stats[:embedding_service][:state] != :open
269
- issues << "Embedding service circuit breaker open" unless checks[:embedding_circuit]
270
- end
271
- if cb_stats[:tag_service]
272
- checks[:tag_circuit] = cb_stats[:tag_service][:state] != :open
273
- issues << "Tag service circuit breaker open" unless checks[:tag_circuit]
274
- end
275
-
276
- # Check required extensions
277
- if connected?
278
- begin
279
- checks[:pgvector] = extension_installed?('vector')
280
- issues << "pgvector extension not installed" unless checks[:pgvector]
281
-
282
- checks[:pg_trgm] = extension_installed?('pg_trgm')
283
- issues << "pg_trgm extension not installed" unless checks[:pg_trgm]
284
- rescue StandardError => e
285
- checks[:extensions] = false
286
- issues << "Failed to check extensions: #{e.message}"
287
- end
288
- end
289
-
290
- {
291
- healthy: issues.empty?,
292
- checks: checks,
293
- issues: issues,
294
- checked_at: Time.now
295
- }
255
+ check_database(checks, issues)
256
+ check_pool(checks, issues)
257
+ check_circuit_breakers(checks, issues)
258
+ check_extensions(checks, issues) if connected?
259
+ { healthy: issues.empty?, checks: checks, issues: issues, checked_at: Time.now }
296
260
  end
297
261
 
298
262
  # Quick health check - returns boolean
@@ -317,6 +281,38 @@ class HTM
317
281
 
318
282
  private
319
283
 
284
+ def check_database(checks, issues)
285
+ checks[:database] = connected?
286
+ issues << "Database not connected" unless checks[:database]
287
+ end
288
+
289
+ def check_pool(checks, issues)
290
+ pool_stats = connection_pool_stats
291
+ checks[:connection_pool] = %i[healthy warning].include?(pool_stats[:status])
292
+ issues << "Connection pool #{pool_stats[:status]}" if %i[critical exhausted].include?(pool_stats[:status])
293
+ end
294
+
295
+ def check_circuit_breakers(checks, issues)
296
+ cb_stats = circuit_breaker_stats
297
+ if cb_stats[:embedding_service]
298
+ checks[:embedding_circuit] = cb_stats[:embedding_service][:state] != :open
299
+ issues << "Embedding service circuit breaker open" unless checks[:embedding_circuit]
300
+ end
301
+ return unless cb_stats[:tag_service]
302
+ checks[:tag_circuit] = cb_stats[:tag_service][:state] != :open
303
+ issues << "Tag service circuit breaker open" unless checks[:tag_circuit]
304
+ end
305
+
306
+ def check_extensions(checks, issues)
307
+ checks[:pgvector] = extension_installed?('vector')
308
+ issues << "pgvector extension not installed" unless checks[:pgvector]
309
+ checks[:pg_trgm] = extension_installed?('pg_trgm')
310
+ issues << "pg_trgm extension not installed" unless checks[:pg_trgm]
311
+ rescue StandardError => e
312
+ checks[:extensions] = false
313
+ issues << "Failed to check extensions: #{e.message}"
314
+ end
315
+
320
316
  # Check if Sequel database is connected
321
317
  def connected?
322
318
  return false unless defined?(HTM) && HTM.respond_to?(:db)
@@ -332,7 +328,7 @@ class HTM
332
328
  result = HTM.db.fetch(
333
329
  "SELECT COUNT(*) AS cnt FROM pg_extension WHERE extname = ?", name
334
330
  ).first
335
- result[:cnt].to_i > 0
331
+ result[:cnt].to_i.positive?
336
332
  end
337
333
 
338
334
  # Calculate timing statistics from samples
@@ -365,7 +361,7 @@ class HTM
365
361
 
366
362
  return sorted_array[f] if f == c
367
363
 
368
- sorted_array[f] * (c - k) + sorted_array[c] * (k - f)
364
+ (sorted_array[f] * (c - k)) + (sorted_array[c] * (k - f))
369
365
  end
370
366
 
371
367
  # Get process memory in MB
@@ -376,8 +372,6 @@ class HTM
376
372
  elsif File.exist?('/proc/self/status')
377
373
  # Linux: Read from proc
378
374
  File.read('/proc/self/status').match(/VmRSS:\s+(\d+)/)[1].to_i / 1024.0
379
- else
380
- nil
381
375
  end
382
376
  rescue StandardError
383
377
  nil
@@ -89,11 +89,7 @@ class HTM
89
89
 
90
90
  # Validate and filter propositions
91
91
  validate_and_filter_propositions(parsed_propositions)
92
-
93
- rescue HTM::CircuitBreakerOpenError
94
- # Re-raise circuit breaker errors without wrapping
95
- raise
96
- rescue HTM::PropositionError
92
+ rescue HTM::CircuitBreakerOpenError, HTM::PropositionError
97
93
  raise
98
94
  rescue StandardError => e
99
95
  HTM.logger.error "PropositionService: Failed to extract propositions: #{e.message}"
@@ -149,9 +145,9 @@ class HTM
149
145
  # @return [Integer] Minimum word count for valid propositions
150
146
  #
151
147
  def self.min_words
152
- HTM.config.proposition.min_words || 5
148
+ HTM.config.proposition.min_words || 3
153
149
  rescue
154
- 5
150
+ 3
155
151
  end
156
152
 
157
153
  # Check if proposition is a meta-response (LLM asking for input)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'lru_redux'
4
- require 'set'
5
4
 
6
5
  class HTM
7
6
  # Thread-safe query result cache with TTL and statistics
@@ -31,7 +30,7 @@ class HTM
31
30
  attr_reader :enabled
32
31
 
33
32
  # Cache key prefix for method-based invalidation
34
- METHOD_PREFIX = "m:".freeze
33
+ METHOD_PREFIX = "m:"
35
34
 
36
35
  # Initialize a new query cache
37
36
  #
@@ -39,16 +38,15 @@ class HTM
39
38
  # @param ttl [Integer] Time-to-live in seconds (default: 300)
40
39
  #
41
40
  def initialize(size: 1000, ttl: 300)
42
- @enabled = size > 0
43
-
44
- if @enabled
45
- @cache = LruRedux::TTL::ThreadSafeCache.new(size, ttl)
46
- @hits = 0
47
- @misses = 0
48
- @mutex = Mutex.new
49
- # Track keys by method for selective invalidation
50
- @keys_by_method = Hash.new { |h, k| h[k] = Set.new }
51
- end
41
+ @enabled = size.positive?
42
+
43
+ return unless @enabled
44
+ @cache = LruRedux::TTL::ThreadSafeCache.new(size, ttl)
45
+ @hits = 0
46
+ @misses = 0
47
+ @mutex = Mutex.new
48
+ # Track keys by method for selective invalidation
49
+ @keys_by_method = Hash.new { |h, k| h[k] = Set.new }
52
50
  end
53
51
 
54
52
  # Fetch a value from cache or execute block
@@ -58,10 +56,10 @@ class HTM
58
56
  # @yield Block that computes the value if not cached
59
57
  # @return [Object] Cached or computed value
60
58
  #
61
- def fetch(method, *args, &block)
59
+ def fetch(method, *, &)
62
60
  return yield unless @enabled
63
61
 
64
- key = cache_key(method, *args)
62
+ key = cache_key(method, *)
65
63
 
66
64
  if (cached = @cache[key])
67
65
  @mutex.synchronize { @hits += 1 }
@@ -131,7 +129,7 @@ class HTM
131
129
  return nil unless @enabled
132
130
 
133
131
  total = @hits + @misses
134
- hit_rate = total > 0 ? (@hits.to_f / total * 100).round(2) : 0.0
132
+ hit_rate = total.positive? ? (@hits.to_f / total * 100).round(2) : 0.0
135
133
 
136
134
  {
137
135
  hits: @hits,
data/lib/htm/railtie.rb CHANGED
@@ -26,7 +26,7 @@ class HTM
26
26
  railtie_name :htm
27
27
 
28
28
  # Configure HTM before Rails initializers run
29
- initializer "htm.configure" do |app|
29
+ initializer "htm.configure" do |_app|
30
30
  HTM.configure do |config|
31
31
  # Use Rails logger
32
32
  config.logger = Rails.logger
@@ -66,6 +66,5 @@ class HTM
66
66
  end
67
67
  end
68
68
  end
69
-
70
69
  end
71
70
  end
@@ -320,7 +320,7 @@ class HTM
320
320
  # originator: 'agent-2'
321
321
  # )
322
322
  #
323
- def remember(content, originator: nil, **options)
323
+ def remember(content, originator: nil, **)
324
324
  raise 'No active robots in group' if @active_robots.empty?
325
325
 
326
326
  # Use first active robot (or specified originator) to create the memory
@@ -330,7 +330,7 @@ class HTM
330
330
  @active_robots.values.first
331
331
  end
332
332
 
333
- node_id = primary.remember(content, **options)
333
+ node_id = primary.remember(content, **)
334
334
 
335
335
  # Sync to database (robot_nodes table) for all other members
336
336
  sync_node_to_members(node_id, exclude: primary.robot_id)
@@ -356,11 +356,11 @@ class HTM
356
356
  # @example
357
357
  # results = group.recall('billing issue', limit: 5, strategy: :fulltext)
358
358
  #
359
- def recall(query, **options)
359
+ def recall(query, **)
360
360
  raise 'No active robots in group' if @active_robots.empty?
361
361
 
362
362
  primary = @active_robots.values.first
363
- primary.recall(query, **options)
363
+ primary.recall(query, **)
364
364
  end
365
365
 
366
366
  # Returns all nodes currently in shared working memory.
@@ -476,7 +476,7 @@ class HTM
476
476
 
477
477
  all_robots.each_key do |robot_name|
478
478
  synced = sync_robot(robot_name)
479
- if synced > 0
479
+ if synced.positive?
480
480
  members_updated += 1
481
481
  total_synced += synced
482
482
  end
@@ -634,7 +634,7 @@ class HTM
634
634
  working_memory_nodes: wm_contents.count,
635
635
  working_memory_tokens: token_count,
636
636
  max_tokens: @max_tokens,
637
- token_utilization: @max_tokens > 0 ? (token_count.to_f / @max_tokens).round(2) : 0,
637
+ token_utilization: @max_tokens.positive? ? (token_count.to_f / @max_tokens).round(2) : 0,
638
638
  in_sync: in_sync?
639
639
  }
640
640
  end
@@ -685,7 +685,7 @@ class HTM
685
685
  node = HTM::Models::Node.first(id: node_id)
686
686
  return unless node
687
687
 
688
- all_robots.each do |_name, htm|
688
+ all_robots.each_value do |htm|
689
689
  next if htm.robot_id == origin_robot_id
690
690
 
691
691
  htm.working_memory.add_from_sync(
@@ -698,7 +698,7 @@ class HTM
698
698
  end
699
699
 
700
700
  def evict_from_in_memory_caches(node_id, origin_robot_id)
701
- all_robots.each do |_name, htm|
701
+ all_robots.each_value do |htm|
702
702
  next if htm.robot_id == origin_robot_id
703
703
 
704
704
  htm.working_memory.remove_from_sync(node_id)
@@ -706,7 +706,7 @@ class HTM
706
706
  end
707
707
 
708
708
  def clear_all_in_memory_caches(origin_robot_id)
709
- all_robots.each do |_name, htm|
709
+ all_robots.each_value do |htm|
710
710
  next if htm.robot_id == origin_robot_id
711
711
 
712
712
  htm.working_memory.clear_from_sync
@@ -198,6 +198,7 @@ class HTM
198
198
  require_relative 'models/robot_node'
199
199
  require_relative 'models/tag'
200
200
  require_relative 'models/node_tag'
201
+ require_relative 'models/node_relationship'
201
202
  require_relative 'models/file_source'
202
203
 
203
204
  @models_loaded = true
@@ -63,7 +63,7 @@ class HTM
63
63
  raise ArgumentError, "Embedding contains invalid values at #{sample}"
64
64
  end
65
65
 
66
- "[#{embedding.map { |v| v.to_f }.join(',')}]"
66
+ "[#{embedding.map(&:to_f).join(',')}]"
67
67
  end
68
68
 
69
69
  # Pad embedding to target dimension
@@ -16,7 +16,7 @@ class HTM
16
16
  # The actual LLM call is delegated to HTM.configuration.tag_extractor
17
17
  #
18
18
  class TagService
19
- TAG_FORMAT = /^[a-z0-9\-]+(:[a-z0-9\-]+)*$/ # Validation regex
19
+ TAG_FORMAT = /^[a-z0-9-]+(:[a-z0-9-]+)*$/ # Validation regex
20
20
 
21
21
  # Circuit breaker for tag extraction API calls
22
22
  @circuit_breaker = nil
@@ -76,11 +76,7 @@ class HTM
76
76
 
77
77
  # Validate and filter tags
78
78
  validate_and_filter_tags(parsed_tags)
79
-
80
- rescue HTM::CircuitBreakerOpenError
81
- # Re-raise circuit breaker errors without wrapping
82
- raise
83
- rescue HTM::TagError
79
+ rescue HTM::CircuitBreakerOpenError, HTM::TagError
84
80
  raise
85
81
  rescue StandardError => e
86
82
  HTM.logger.error "TagService: Failed to extract tags: #{e.message}"
data/lib/htm/timeframe.rb CHANGED
@@ -61,7 +61,7 @@ class HTM
61
61
 
62
62
  else
63
63
  raise ArgumentError, "Unsupported timeframe type: #{input.class}. " \
64
- "Expected nil, Range, Array<Range>, Date, DateTime, Time, String, or :auto"
64
+ "Expected nil, Range, Array<Range>, Date, DateTime, Time, String, or :auto"
65
65
  end
66
66
  end
67
67
 
@@ -75,7 +75,7 @@ class HTM
75
75
  when nil, :auto, Range, Date, DateTime, Time, String
76
76
  true
77
77
  when Array
78
- input.all? { |r| r.is_a?(Range) }
78
+ input.all?(Range)
79
79
  else
80
80
  false
81
81
  end
@@ -185,9 +185,8 @@ class HTM
185
185
  # @raise [ArgumentError] If range is invalid
186
186
  #
187
187
  def validate_range!(range)
188
- unless range.begin.respond_to?(:to_time) && range.end.respond_to?(:to_time)
189
- raise ArgumentError, "Range must have Time-compatible begin and end values"
190
- end
188
+ return if range.begin.respond_to?(:to_time) && range.end.respond_to?(:to_time)
189
+ raise ArgumentError, "Range must have Time-compatible begin and end values"
191
190
  end
192
191
  end
193
192
  end
@@ -44,6 +44,17 @@ class HTM
44
44
  'six' => 6, 'seven' => 7, 'eight' => 8, 'nine' => 9, 'ten' => 10
45
45
  }.freeze
46
46
 
47
+ # Seconds per singular time unit (used by parse_last_x and parse_recent)
48
+ UNIT_SECONDS = {
49
+ 'second' => 1,
50
+ 'minute' => 60,
51
+ 'hour' => 3_600,
52
+ 'day' => 86_400,
53
+ 'week' => 604_800,
54
+ 'month' => 2_592_000,
55
+ 'year' => 31_536_000
56
+ }.freeze
57
+
47
58
  # Patterns for temporal expressions (order matters - more specific first)
48
59
  # Each pattern should match ORIGINAL text (including "few", "a few")
49
60
  TEMPORAL_PATTERNS = [
@@ -84,7 +95,7 @@ class HTM
84
95
  /\b(?:recently|recent)\b/i,
85
96
 
86
97
  # Standard time words
87
- /\b(?:yesterday|today|tonight|this\s+morning|this\s+afternoon|this\s+evening|last\s+night)\b/i,
98
+ /\b(?:yesterday|today|tonight|this\s+morning|this\s+afternoon|this\s+evening|last\s+night)\b/i
88
99
  ].freeze
89
100
 
90
101
  # Result structure for extracted timeframe
@@ -153,49 +164,21 @@ class HTM
153
164
  # @return [Time, Range, nil] Parsed timeframe
154
165
  #
155
166
  def parse_expression(expression)
156
- # Handle "recently/recent" specially - default to FEW days
157
- if expression.match?(/\b(?:recently|recent)\b/i)
158
- return parse_recent
159
- end
160
-
161
- # Handle "weekend before last" - 2 weekends ago
162
- if expression.match?(/\bweekend\s+before\s+last\b/i)
163
- return parse_weekends_ago(2)
164
- end
167
+ return parse_recent if expression.match?(/\b(?:recently|recent)\b/i)
168
+ return parse_weekends_ago(2) if expression.match?(/\bweekend\s+before\s+last\b/i)
165
169
 
166
- # Handle "N weekends ago" (numeric or written)
167
- if match = expression.match(/\b(\d+|one|two|three|four|five|six|seven|eight|nine|ten|few|a\s+few|several)\s+weekends?\s+ago\b/i)
168
- count = parse_number(match[1])
169
- return parse_weekends_ago(count)
170
+ if (match = expression.match(/\b(\d+|one|two|three|four|five|six|seven|eight|nine|ten|few|a\s+few|several)\s+weekends?\s+ago\b/i))
171
+ return parse_weekends_ago(parse_number(match[1]))
170
172
  end
171
173
 
172
- # Normalize "few" to numeric value for Chronic
173
- normalized = normalize_few_keywords(expression)
174
+ normalized = normalize_few_keywords(expression)
175
+ chronic_expr = normalized.gsub(/\bin\s+the\s+/i, '')
174
176
 
175
- # Handle "in the last/past X units" - create range from X ago to now
176
- if match = normalized.match(/(?:in\s+the\s+)?(?:last|past)\s+(\d+)\s+(#{TIME_UNITS})/i)
177
+ if (match = normalized.match(/(?:in\s+the\s+)?(?:last|past)\s+(\d+)\s+(#{TIME_UNITS})/i))
177
178
  return parse_last_x(match[1].to_i, match[2])
178
179
  end
179
180
 
180
- # Strip "in the" prefix for Chronic
181
- chronic_expr = normalized.gsub(/\bin\s+the\s+/i, '')
182
-
183
- # Get week_start from HTM configuration (default: :sunday)
184
- week_start = :sunday
185
- if defined?(HTM) && HTM.respond_to?(:configuration)
186
- week_start = HTM.configuration.week_start
187
- end
188
-
189
- # Try to get a span/range first
190
- result = Chronic.parse(chronic_expr, guess: false, week_start: week_start)
191
-
192
- # Convert Chronic::Span to Range if needed
193
- if result.respond_to?(:begin) && result.respond_to?(:end)
194
- return result.begin..result.end
195
- end
196
-
197
- # Fall back to point in time
198
- Chronic.parse(chronic_expr, week_start: week_start)
181
+ chronic_parse_expression(chronic_expr)
199
182
  end
200
183
 
201
184
  # Parse a number from string (numeric or written word)
@@ -205,7 +188,7 @@ class HTM
205
188
  #
206
189
  def parse_number(str)
207
190
  normalized = str.downcase.strip
208
- return FEW if normalized == 'few' || normalized == 'a few' || normalized == 'several'
191
+ return FEW if ['few', 'a few', 'several'].include?(normalized)
209
192
  return WORD_NUMBERS[normalized] if WORD_NUMBERS.key?(normalized)
210
193
  normalized.to_i
211
194
  end
@@ -217,23 +200,26 @@ class HTM
217
200
  #
218
201
  def parse_weekends_ago(count)
219
202
  now = Time.now
203
+ target_saturday = last_saturday_before(now) - ((count - 1) * 7 * 86_400)
204
+ target_saturday..(target_saturday + (2 * 86_400))
205
+ end
220
206
 
221
- # Find last Saturday (most recent Saturday before or equal to today)
222
- days_since_saturday = (now.wday - 6) % 7
223
- days_since_saturday = 7 if days_since_saturday == 0 && now.wday != 6 # If today is Sunday, last Saturday was yesterday
224
-
225
- last_saturday = Time.new(now.year, now.month, now.day, 0, 0, 0) - (days_since_saturday * 24 * 60 * 60)
226
-
227
- # Go back (count - 1) more weeks to get to the target weekend
228
- # count=1 means "last weekend" = the most recent past weekend
229
- # count=2 means "weekend before last" = 2 weekends ago
230
- target_saturday = last_saturday - ((count - 1) * 7 * 24 * 60 * 60)
207
+ def chronic_parse_expression(expr)
208
+ week_start = fetch_week_start
209
+ result = Chronic.parse(expr, guess: false, week_start: week_start)
210
+ return result.begin..result.end if result.respond_to?(:begin) && result.respond_to?(:end)
211
+ Chronic.parse(expr, week_start: week_start)
212
+ end
231
213
 
232
- # Weekend spans Saturday 00:00 to Monday 00:00
233
- weekend_start = target_saturday
234
- weekend_end = target_saturday + (2 * 24 * 60 * 60) # Monday 00:00
214
+ def fetch_week_start
215
+ return HTM.configuration.week_start if defined?(HTM) && HTM.respond_to?(:configuration)
216
+ :sunday
217
+ end
235
218
 
236
- weekend_start..weekend_end
219
+ def last_saturday_before(now)
220
+ days_since = (now.wday - 6) % 7
221
+ days_since = 7 if days_since.zero? && now.wday != 6
222
+ Time.new(now.year, now.month, now.day, 0, 0, 0) - (days_since * 86_400)
237
223
  end
238
224
 
239
225
  # Parse "last X units" or "past X units" to a proper range
@@ -244,19 +230,8 @@ class HTM
244
230
  #
245
231
  def parse_last_x(count, unit)
246
232
  now = Time.now
247
- unit_normalized = unit.downcase.sub(/s$/, '') # Remove trailing 's'
248
-
249
- seconds = case unit_normalized
250
- when 'second' then count
251
- when 'minute' then count * 60
252
- when 'hour' then count * 60 * 60
253
- when 'day' then count * 24 * 60 * 60
254
- when 'week' then count * 7 * 24 * 60 * 60
255
- when 'month' then count * 30 * 24 * 60 * 60
256
- when 'year' then count * 365 * 24 * 60 * 60
257
- else count * 24 * 60 * 60 # Default to days
258
- end
259
-
233
+ unit_singular = unit.downcase.sub(/s$/, '')
234
+ seconds = count * (UNIT_SECONDS[unit_singular] || UNIT_SECONDS['day'])
260
235
  (now - seconds)..now
261
236
  end
262
237
 
@@ -266,24 +241,8 @@ class HTM
266
241
  #
267
242
  def parse_recent
268
243
  now = Time.now
269
- case DEFAULT_RECENT_UNIT
270
- when :seconds
271
- (now - FEW)..now
272
- when :minutes
273
- (now - (FEW * 60))..now
274
- when :hours
275
- (now - (FEW * 60 * 60))..now
276
- when :days
277
- (now - (FEW * 24 * 60 * 60))..now
278
- when :weeks
279
- (now - (FEW * 7 * 24 * 60 * 60))..now
280
- when :months
281
- (now - (FEW * 30 * 24 * 60 * 60))..now
282
- when :years
283
- (now - (FEW * 365 * 24 * 60 * 60))..now
284
- else
285
- (now - (FEW * 24 * 60 * 60))..now
286
- end
244
+ seconds = FEW * (UNIT_SECONDS[DEFAULT_RECENT_UNIT.to_s] || UNIT_SECONDS['day'])
245
+ (now - seconds)..now
287
246
  end
288
247
 
289
248
  # Clean the query by removing the temporal expression
data/lib/htm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class HTM
4
- VERSION = '0.0.31'
4
+ VERSION = '0.0.32'
5
5
  end