htm 0.0.11 → 0.0.15

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.dictate.toml +46 -0
  3. data/.envrc +2 -0
  4. data/CHANGELOG.md +85 -2
  5. data/README.md +348 -79
  6. data/Rakefile +14 -2
  7. data/bin/htm_mcp.rb +94 -0
  8. data/config/database.yml +20 -13
  9. data/db/migrate/00003_create_file_sources.rb +5 -0
  10. data/db/migrate/00004_create_nodes.rb +17 -0
  11. data/db/migrate/00005_create_tags.rb +7 -0
  12. data/db/migrate/00006_create_node_tags.rb +2 -0
  13. data/db/migrate/00007_create_robot_nodes.rb +7 -0
  14. data/db/schema.sql +69 -100
  15. data/docs/api/index.md +1 -1
  16. data/docs/api/yard/HTM/Configuration.md +54 -0
  17. data/docs/api/yard/HTM/Database.md +13 -10
  18. data/docs/api/yard/HTM/EmbeddingService.md +5 -1
  19. data/docs/api/yard/HTM/LongTermMemory.md +18 -277
  20. data/docs/api/yard/HTM/PropositionError.md +18 -0
  21. data/docs/api/yard/HTM/PropositionService.md +66 -0
  22. data/docs/api/yard/HTM/QueryCache.md +88 -0
  23. data/docs/api/yard/HTM/RobotGroup.md +481 -0
  24. data/docs/api/yard/HTM/SqlBuilder.md +108 -0
  25. data/docs/api/yard/HTM/TagService.md +4 -0
  26. data/docs/api/yard/HTM/Telemetry/NullInstrument.md +13 -0
  27. data/docs/api/yard/HTM/Telemetry/NullMeter.md +15 -0
  28. data/docs/api/yard/HTM/Telemetry.md +109 -0
  29. data/docs/api/yard/HTM/WorkingMemoryChannel.md +176 -0
  30. data/docs/api/yard/HTM.md +8 -22
  31. data/docs/api/yard/index.csv +102 -25
  32. data/docs/api/yard-reference.md +8 -0
  33. data/docs/architecture/index.md +1 -1
  34. data/docs/assets/images/multi-provider-failover.svg +51 -0
  35. data/docs/assets/images/robot-group-architecture.svg +65 -0
  36. data/docs/database/README.md +3 -3
  37. data/docs/database/public.file_sources.svg +29 -21
  38. data/docs/database/public.node_tags.md +2 -0
  39. data/docs/database/public.node_tags.svg +53 -41
  40. data/docs/database/public.nodes.md +2 -0
  41. data/docs/database/public.nodes.svg +52 -40
  42. data/docs/database/public.robot_nodes.md +2 -0
  43. data/docs/database/public.robot_nodes.svg +30 -22
  44. data/docs/database/public.robots.svg +16 -12
  45. data/docs/database/public.tags.md +3 -0
  46. data/docs/database/public.tags.svg +41 -33
  47. data/docs/database/schema.json +66 -0
  48. data/docs/database/schema.svg +60 -48
  49. data/docs/development/index.md +14 -1
  50. data/docs/development/rake-tasks.md +1068 -0
  51. data/docs/getting-started/index.md +1 -1
  52. data/docs/getting-started/quick-start.md +144 -155
  53. data/docs/guides/adding-memories.md +2 -3
  54. data/docs/guides/context-assembly.md +185 -184
  55. data/docs/guides/getting-started.md +154 -148
  56. data/docs/guides/index.md +8 -1
  57. data/docs/guides/long-term-memory.md +60 -92
  58. data/docs/guides/mcp-server.md +617 -0
  59. data/docs/guides/multi-robot.md +249 -345
  60. data/docs/guides/recalling-memories.md +153 -163
  61. data/docs/guides/robot-groups.md +604 -0
  62. data/docs/guides/search-strategies.md +61 -58
  63. data/docs/guides/working-memory.md +103 -136
  64. data/docs/images/telemetry-architecture.svg +153 -0
  65. data/docs/index.md +30 -26
  66. data/docs/telemetry.md +391 -0
  67. data/examples/README.md +46 -1
  68. data/examples/cli_app/README.md +1 -1
  69. data/examples/cli_app/htm_cli.rb +1 -1
  70. data/examples/robot_groups/robot_worker.rb +1 -2
  71. data/examples/robot_groups/same_process.rb +1 -4
  72. data/examples/sinatra_app/app.rb +1 -1
  73. data/examples/telemetry/README.md +147 -0
  74. data/examples/telemetry/SETUP_README.md +169 -0
  75. data/examples/telemetry/demo.rb +498 -0
  76. data/examples/telemetry/grafana/dashboards/htm-metrics.json +457 -0
  77. data/lib/htm/configuration.rb +261 -70
  78. data/lib/htm/database.rb +46 -22
  79. data/lib/htm/embedding_service.rb +24 -14
  80. data/lib/htm/errors.rb +15 -1
  81. data/lib/htm/jobs/generate_embedding_job.rb +19 -0
  82. data/lib/htm/jobs/generate_propositions_job.rb +103 -0
  83. data/lib/htm/jobs/generate_tags_job.rb +24 -0
  84. data/lib/htm/loaders/markdown_chunker.rb +79 -0
  85. data/lib/htm/loaders/markdown_loader.rb +41 -15
  86. data/lib/htm/long_term_memory/fulltext_search.rb +138 -0
  87. data/lib/htm/long_term_memory/hybrid_search.rb +324 -0
  88. data/lib/htm/long_term_memory/node_operations.rb +209 -0
  89. data/lib/htm/long_term_memory/relevance_scorer.rb +355 -0
  90. data/lib/htm/long_term_memory/robot_operations.rb +34 -0
  91. data/lib/htm/long_term_memory/tag_operations.rb +428 -0
  92. data/lib/htm/long_term_memory/vector_search.rb +109 -0
  93. data/lib/htm/long_term_memory.rb +51 -1153
  94. data/lib/htm/models/node.rb +35 -2
  95. data/lib/htm/models/node_tag.rb +31 -0
  96. data/lib/htm/models/robot_node.rb +31 -0
  97. data/lib/htm/models/tag.rb +44 -0
  98. data/lib/htm/proposition_service.rb +169 -0
  99. data/lib/htm/query_cache.rb +214 -0
  100. data/lib/htm/robot_group.rb +721 -0
  101. data/lib/htm/sql_builder.rb +178 -0
  102. data/lib/htm/tag_service.rb +16 -6
  103. data/lib/htm/tasks.rb +8 -2
  104. data/lib/htm/telemetry.rb +224 -0
  105. data/lib/htm/version.rb +1 -1
  106. data/lib/htm/working_memory_channel.rb +250 -0
  107. data/lib/htm.rb +66 -3
  108. data/lib/tasks/doc.rake +1 -1
  109. data/lib/tasks/htm.rake +259 -13
  110. data/mkdocs.yml +98 -96
  111. metadata +55 -20
  112. data/.aigcm_msg +0 -1
  113. data/.claude/settings.local.json +0 -95
  114. data/CLAUDE.md +0 -603
  115. data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +0 -12
  116. data/examples/cli_app/temp.log +0 -93
  117. data/examples/robot_groups/lib/robot_group.rb +0 -419
  118. data/examples/robot_groups/lib/working_memory_channel.rb +0 -140
  119. data/lib/htm/loaders/paragraph_chunker.rb +0 -112
  120. data/notes/ARCHITECTURE_REVIEW.md +0 -1167
  121. data/notes/IMPLEMENTATION_SUMMARY.md +0 -606
  122. data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +0 -451
  123. data/notes/next_steps.md +0 -100
  124. data/notes/plan.md +0 -627
  125. data/notes/tag_ontology_enhancement_ideas.md +0 -222
  126. data/notes/timescaledb_removal_summary.md +0 -200
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ class LongTermMemory
5
+ # Tag management operations for LongTermMemory
6
+ #
7
+ # Handles hierarchical tag operations including:
8
+ # - Adding tags to nodes
9
+ # - Querying nodes by topic/tag
10
+ # - Tag relationship analysis
11
+ # - Batch tag loading (N+1 prevention)
12
+ # - Query-to-tag matching
13
+ #
14
+ # Security: All queries use parameterized placeholders and LIKE patterns
15
+ # are sanitized to prevent SQL injection.
16
+ #
17
+ module TagOperations
18
+ # Maximum results to prevent DoS via unbounded queries
19
+ MAX_TAG_QUERY_LIMIT = 1000
20
+ MAX_TAG_SAMPLE_SIZE = 50
21
+
22
+ # Default trigram similarity threshold for fuzzy tag search (0.0-1.0)
23
+ # Lower = more fuzzy matches, higher = stricter matching
24
+ DEFAULT_TAG_SIMILARITY_THRESHOLD = 0.3
25
+
26
+ # Cache TTL for popular tags (5 minutes)
27
+ # This eliminates expensive RANDOM() queries on every tag extraction
28
+ POPULAR_TAGS_CACHE_TTL = 300
29
+
30
+ # Thread-safe cache for popular tags
31
+ @popular_tags_cache = nil
32
+ @popular_tags_cache_expires_at = nil
33
+ @popular_tags_mutex = Mutex.new
34
+
35
+ class << self
36
+ attr_accessor :popular_tags_cache, :popular_tags_cache_expires_at, :popular_tags_mutex
37
+ end
38
+
39
+ # Add a tag to a node
40
+ #
41
+ # @param node_id [Integer] Node database ID
42
+ # @param tag [String] Tag name
43
+ # @return [void]
44
+ #
45
+ def add_tag(node_id:, tag:)
46
+ tag_record = HTM::Models::Tag.find_or_create_by(name: tag)
47
+ HTM::Models::NodeTag.create(
48
+ node_id: node_id,
49
+ tag_id: tag_record.id
50
+ )
51
+ rescue ActiveRecord::RecordNotUnique
52
+ # Tag association already exists, ignore
53
+ end
54
+
55
+ # Retrieve nodes by ontological topic
56
+ #
57
+ # @param topic_path [String] Topic hierarchy path
58
+ # @param exact [Boolean] Exact match only (highest priority)
59
+ # @param fuzzy [Boolean] Use trigram similarity for typo-tolerant search
60
+ # @param min_similarity [Float] Minimum similarity for fuzzy mode (0.0-1.0)
61
+ # @param limit [Integer] Maximum results (capped at MAX_TAG_QUERY_LIMIT)
62
+ # @return [Array<Hash>] Matching nodes
63
+ #
64
+ # Matching modes (in order of precedence):
65
+ # - exact: true - Only exact tag name match
66
+ # - fuzzy: true - Trigram similarity search (typo-tolerant)
67
+ # - default - LIKE prefix match (e.g., "database" matches "database:postgresql")
68
+ #
69
+ def nodes_by_topic(topic_path, exact: false, fuzzy: false, min_similarity: DEFAULT_TAG_SIMILARITY_THRESHOLD, limit: 50)
70
+ # Enforce limit to prevent DoS
71
+ safe_limit = [[limit.to_i, 1].max, MAX_TAG_QUERY_LIMIT].min
72
+
73
+ if exact
74
+ nodes = HTM::Models::Node
75
+ .joins(:tags)
76
+ .where(tags: { name: topic_path })
77
+ .distinct
78
+ .order(created_at: :desc)
79
+ .limit(safe_limit)
80
+ elsif fuzzy
81
+ # Trigram similarity search - tolerates typos and partial matches
82
+ safe_similarity = [[min_similarity.to_f, 0.0].max, 1.0].min
83
+ nodes = HTM::Models::Node
84
+ .joins(:tags)
85
+ .where("similarity(tags.name, ?) >= ?", topic_path, safe_similarity)
86
+ .distinct
87
+ .order(created_at: :desc)
88
+ .limit(safe_limit)
89
+ else
90
+ # Sanitize LIKE pattern to prevent wildcard injection
91
+ safe_pattern = HTM::SqlBuilder.sanitize_like_pattern(topic_path)
92
+ nodes = HTM::Models::Node
93
+ .joins(:tags)
94
+ .where("tags.name LIKE ?", "#{safe_pattern}%")
95
+ .distinct
96
+ .order(created_at: :desc)
97
+ .limit(safe_limit)
98
+ end
99
+
100
+ nodes.map(&:attributes)
101
+ end
102
+
103
+ # Get ontology structure view
104
+ #
105
+ # @return [Array<Hash>] Ontology structure
106
+ #
107
+ def ontology_structure
108
+ result = ActiveRecord::Base.connection.select_all(
109
+ "SELECT * FROM ontology_structure WHERE root_topic IS NOT NULL ORDER BY root_topic, level1_topic, level2_topic"
110
+ )
111
+ result.to_a
112
+ end
113
+
114
+ # Get topic relationships (co-occurrence)
115
+ #
116
+ # @param min_shared_nodes [Integer] Minimum shared nodes
117
+ # @param limit [Integer] Maximum relationships (capped at MAX_TAG_QUERY_LIMIT)
118
+ # @return [Array<Hash>] Topic relationships
119
+ #
120
+ def topic_relationships(min_shared_nodes: 2, limit: 50)
121
+ # Enforce limit to prevent DoS
122
+ safe_limit = [[limit.to_i, 1].max, MAX_TAG_QUERY_LIMIT].min
123
+ safe_min = [min_shared_nodes.to_i, 1].max
124
+
125
+ # Use parameterized query to prevent SQL injection
126
+ sql = <<~SQL
127
+ SELECT t1.name AS topic1, t2.name AS topic2, COUNT(DISTINCT nt1.node_id) AS shared_nodes
128
+ FROM tags t1
129
+ JOIN node_tags nt1 ON t1.id = nt1.tag_id
130
+ JOIN node_tags nt2 ON nt1.node_id = nt2.node_id
131
+ JOIN tags t2 ON nt2.tag_id = t2.id
132
+ WHERE t1.name < t2.name
133
+ GROUP BY t1.name, t2.name
134
+ HAVING COUNT(DISTINCT nt1.node_id) >= $1
135
+ ORDER BY shared_nodes DESC
136
+ LIMIT $2
137
+ SQL
138
+
139
+ result = ActiveRecord::Base.connection.exec_query(
140
+ sql,
141
+ 'topic_relationships',
142
+ [[nil, safe_min], [nil, safe_limit]]
143
+ )
144
+ result.to_a
145
+ end
146
+
147
+ # Get topics for a specific node
148
+ #
149
+ # @param node_id [Integer] Node database ID
150
+ # @return [Array<String>] Topic paths
151
+ #
152
+ def node_topics(node_id)
153
+ HTM::Models::Tag
154
+ .joins(:node_tags)
155
+ .where(node_tags: { node_id: node_id })
156
+ .order(:name)
157
+ .pluck(:name)
158
+ end
159
+
160
+ # Get tags for a specific node
161
+ #
162
+ # @param node_id [Integer] Node database ID
163
+ # @return [Array<String>] Tag names
164
+ #
165
+ def get_node_tags(node_id)
166
+ HTM::Models::Tag
167
+ .joins(:node_tags)
168
+ .where(node_tags: { node_id: node_id })
169
+ .pluck(:name)
170
+ rescue ActiveRecord::ActiveRecordError => e
171
+ HTM.logger.error("Failed to retrieve tags for node #{node_id}: #{e.message}")
172
+ []
173
+ end
174
+
175
+ # Batch load tags for multiple nodes (avoids N+1 queries)
176
+ #
177
+ # @param node_ids [Array<Integer>] Node database IDs
178
+ # @return [Hash<Integer, Array<String>>] Map of node_id to array of tag names
179
+ #
180
+ def batch_load_node_tags(node_ids)
181
+ return {} if node_ids.empty?
182
+
183
+ # Single query to get all tags for all nodes
184
+ results = HTM::Models::NodeTag
185
+ .joins(:tag)
186
+ .where(node_id: node_ids)
187
+ .pluck(:node_id, 'tags.name')
188
+
189
+ # Group by node_id
190
+ results.group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
191
+ rescue ActiveRecord::ActiveRecordError => e
192
+ HTM.logger.error("Failed to batch load tags: #{e.message}")
193
+ {}
194
+ end
195
+
196
+ # Get most popular tags
197
+ #
198
+ # @param limit [Integer] Number of tags to return (capped at MAX_TAG_QUERY_LIMIT)
199
+ # @param timeframe [Range, nil] Optional time range filter
200
+ # @return [Array<Hash>] Tags with usage counts
201
+ #
202
+ def popular_tags(limit: 20, timeframe: nil)
203
+ # Enforce limit to prevent DoS
204
+ safe_limit = [[limit.to_i, 1].max, MAX_TAG_QUERY_LIMIT].min
205
+
206
+ query = HTM::Models::Tag
207
+ .joins(:node_tags)
208
+ .joins('INNER JOIN nodes ON nodes.id = node_tags.node_id')
209
+ .group('tags.id', 'tags.name')
210
+ .select('tags.name, COUNT(node_tags.id) as usage_count')
211
+
212
+ query = query.where('nodes.created_at >= ? AND nodes.created_at <= ?', timeframe.begin, timeframe.end) if timeframe
213
+
214
+ query
215
+ .order('usage_count DESC')
216
+ .limit(safe_limit)
217
+ .map { |tag| { name: tag.name, usage_count: tag.usage_count } }
218
+ end
219
+
220
+ # Fuzzy search for tags using trigram similarity
221
+ #
222
+ # Uses PostgreSQL pg_trgm extension to find tags that are similar
223
+ # to the query string, tolerating typos and partial matches.
224
+ #
225
+ # @param query [String] Search query (tag name or partial)
226
+ # @param limit [Integer] Maximum results (capped at MAX_TAG_QUERY_LIMIT)
227
+ # @param min_similarity [Float] Minimum similarity threshold (0.0-1.0)
228
+ # @return [Array<Hash>] Matching tags with similarity scores
229
+ # Each hash contains: { name: String, similarity: Float }
230
+ #
231
+ def search_tags(query, limit: 20, min_similarity: DEFAULT_TAG_SIMILARITY_THRESHOLD)
232
+ return [] if query.nil? || query.strip.empty?
233
+
234
+ # Enforce limits
235
+ safe_limit = [[limit.to_i, 1].max, MAX_TAG_QUERY_LIMIT].min
236
+ safe_similarity = [[min_similarity.to_f, 0.0].max, 1.0].min
237
+
238
+ sql = <<~SQL
239
+ SELECT name, similarity(name, ?) as similarity
240
+ FROM tags
241
+ WHERE similarity(name, ?) >= ?
242
+ ORDER BY similarity DESC, name
243
+ LIMIT ?
244
+ SQL
245
+
246
+ result = ActiveRecord::Base.connection.select_all(
247
+ ActiveRecord::Base.sanitize_sql_array([sql, query, query, safe_similarity, safe_limit])
248
+ )
249
+
250
+ result.map { |r| { name: r['name'], similarity: r['similarity'].to_f } }
251
+ rescue ActiveRecord::ActiveRecordError => e
252
+ HTM.logger.error("Failed to search tags: #{e.message}")
253
+ []
254
+ end
255
+
256
+ # Find tags that match terms in the query
257
+ #
258
+ # Searches the tags table for tags where any hierarchy level matches
259
+ # query words. Uses semantic extraction via LLM to find relevant tags.
260
+ #
261
+ # Performance: Uses a single UNION query instead of multiple sequential queries.
262
+ #
263
+ # @param query [String] Search query
264
+ # @param include_extracted [Boolean] If true, returns hash with :extracted and :matched keys
265
+ # @return [Array<String>] Matching tag names (default)
266
+ # @return [Hash] If include_extracted: { extracted: [...], matched: [...] }
267
+ #
268
+ def find_query_matching_tags(query, include_extracted: false)
269
+ empty_result = include_extracted ? { extracted: [], matched: [] } : []
270
+ return empty_result if query.nil? || query.strip.empty?
271
+
272
+ # OPTIMIZATION: Use cached popular tags instead of expensive RANDOM() query
273
+ # This saves 50-300ms per call by avoiding a full table sort
274
+ existing_tags = cached_popular_tags
275
+
276
+ # Use the tag extractor to generate semantic tags from the query
277
+ extracted_tags = HTM::TagService.extract(query, existing_ontology: existing_tags)
278
+
279
+ if extracted_tags.empty?
280
+ return include_extracted ? { extracted: [], matched: [] } : []
281
+ end
282
+
283
+ # Build prefix candidates from extracted tags
284
+ prefix_candidates = extracted_tags.flat_map do |tag|
285
+ levels = tag.split(':')
286
+ (1...levels.size).map { |i| levels[0, i].join(':') }
287
+ end.uniq
288
+
289
+ # Get all components for component matching
290
+ all_components = extracted_tags.flat_map { |tag| tag.split(':') }.uniq
291
+
292
+ # Build UNION query to find matches in a single database round-trip
293
+ matched_tags = find_matching_tags_unified(
294
+ exact_candidates: extracted_tags,
295
+ prefix_candidates: prefix_candidates,
296
+ component_candidates: all_components
297
+ )
298
+
299
+ if include_extracted
300
+ { extracted: extracted_tags, matched: matched_tags }
301
+ else
302
+ matched_tags
303
+ end
304
+ end
305
+
306
+ private
307
+
308
+ # Get cached popular tags for ontology context
309
+ #
310
+ # Uses TTL cache to avoid expensive repeated queries.
311
+ # Returns array of tag names for the TagService to use as ontology context.
312
+ #
313
+ # @return [Array<String>] Popular tag names
314
+ #
315
+ def cached_popular_tags
316
+ cache = TagOperations
317
+ cache.popular_tags_mutex.synchronize do
318
+ now = Time.now
319
+ if cache.popular_tags_cache.nil? || cache.popular_tags_cache_expires_at.nil? || now > cache.popular_tags_cache_expires_at
320
+ # Fetch popular tags and extract just the names
321
+ cache.popular_tags_cache = popular_tags(limit: MAX_TAG_SAMPLE_SIZE).map { |t| t[:name] }
322
+ cache.popular_tags_cache_expires_at = now + POPULAR_TAGS_CACHE_TTL
323
+ end
324
+ cache.popular_tags_cache
325
+ end
326
+ rescue StandardError => e
327
+ HTM.logger.error("Failed to fetch cached popular tags: #{e.message}")
328
+ []
329
+ end
330
+
331
+ # Find matching tags using a single unified query
332
+ #
333
+ # Uses UNION to combine exact, prefix, component, and trigram matching
334
+ # in a single database round-trip.
335
+ #
336
+ # Matching strategies (in priority order):
337
+ # 1. Exact matches - tag name exactly equals candidate
338
+ # 2. Prefix matches - tag name equals parent path component
339
+ # 3. Component matches - tag contains component at any hierarchy level
340
+ # 4. Trigram matches - fuzzy similarity search (typo-tolerant fallback)
341
+ #
342
+ # @param exact_candidates [Array<String>] Tags to match exactly
343
+ # @param prefix_candidates [Array<String>] Prefixes to match
344
+ # @param component_candidates [Array<String>] Components to search for
345
+ # @param fuzzy_fallback [Boolean] Include trigram fuzzy matching (default: true)
346
+ # @param min_similarity [Float] Minimum similarity for trigram matching
347
+ # @return [Array<String>] Matched tag names
348
+ #
349
+ def find_matching_tags_unified(exact_candidates:, prefix_candidates:, component_candidates:, fuzzy_fallback: true, min_similarity: DEFAULT_TAG_SIMILARITY_THRESHOLD)
350
+ return [] if exact_candidates.empty? && prefix_candidates.empty? && component_candidates.empty?
351
+
352
+ conditions = []
353
+ params = []
354
+
355
+ # Exact matches (highest priority)
356
+ if exact_candidates.any?
357
+ placeholders = exact_candidates.map { '?' }.join(', ')
358
+ conditions << "(SELECT name, 1 as priority FROM tags WHERE name IN (#{placeholders}))"
359
+ params.concat(exact_candidates)
360
+ end
361
+
362
+ # Prefix matches
363
+ if prefix_candidates.any?
364
+ placeholders = prefix_candidates.map { '?' }.join(', ')
365
+ conditions << "(SELECT name, 2 as priority FROM tags WHERE name IN (#{placeholders}))"
366
+ params.concat(prefix_candidates)
367
+ end
368
+
369
+ # Component matches
370
+ # Pre-sanitize components once to avoid duplicate processing
371
+ if component_candidates.any?
372
+ # Pre-compute sanitized components for efficiency
373
+ sanitized_components = component_candidates.map do |component|
374
+ [component, HTM::SqlBuilder.sanitize_like_pattern(component)]
375
+ end
376
+
377
+ component_conditions = sanitized_components.map do |_component, _safe|
378
+ # Match: exact, starts with, ends with, or middle
379
+ "(name = ? OR name LIKE ? OR name LIKE ? OR name LIKE ?)"
380
+ end
381
+
382
+ component_params = sanitized_components.flat_map do |component, safe_component|
383
+ [
384
+ component, # exact match
385
+ "#{safe_component}:%", # starts with
386
+ "%:#{safe_component}", # ends with
387
+ "%:#{safe_component}:%" # in middle
388
+ ]
389
+ end
390
+
391
+ conditions << "(SELECT name, 3 as priority FROM tags WHERE #{component_conditions.join(' OR ')})"
392
+ params.concat(component_params)
393
+ end
394
+
395
+ # Trigram fuzzy matches (lowest priority - fallback for typos)
396
+ # Uses pg_trgm similarity to find tags even with spelling errors
397
+ if fuzzy_fallback && component_candidates.any?
398
+ safe_similarity = [[min_similarity.to_f, 0.0].max, 1.0].min
399
+ trigram_conditions = component_candidates.map { "similarity(name, ?) >= ?" }
400
+ trigram_params = component_candidates.flat_map { |c| [c, safe_similarity] }
401
+
402
+ conditions << "(SELECT name, 4 as priority FROM tags WHERE #{trigram_conditions.join(' OR ')})"
403
+ params.concat(trigram_params)
404
+ end
405
+
406
+ return [] if conditions.empty?
407
+
408
+ # Combine with UNION and order by priority
409
+ sql = <<~SQL
410
+ SELECT DISTINCT name FROM (
411
+ #{conditions.join(' UNION ')}
412
+ ) AS matches
413
+ ORDER BY name
414
+ LIMIT ?
415
+ SQL
416
+ params << MAX_TAG_QUERY_LIMIT
417
+
418
+ result = ActiveRecord::Base.connection.select_all(
419
+ ActiveRecord::Base.sanitize_sql_array([sql, *params])
420
+ )
421
+ result.map { |r| r['name'] }
422
+ rescue ActiveRecord::ActiveRecordError => e
423
+ HTM.logger.error("Failed to find matching tags: #{e.message}")
424
+ []
425
+ end
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ class LongTermMemory
5
+ # Vector similarity search using pgvector
6
+ #
7
+ # Performs semantic search by:
8
+ # 1. Generating query embedding client-side
9
+ # 2. Using pgvector cosine distance for similarity ranking
10
+ # 3. Supporting timeframe and metadata filtering
11
+ #
12
+ # Results are cached for performance.
13
+ #
14
+ # Security: All queries use parameterized placeholders to prevent SQL injection.
15
+ #
16
+ module VectorSearch
17
+ # Maximum results to prevent DoS via unbounded queries
18
+ MAX_VECTOR_LIMIT = 1000
19
+
20
+ # Vector similarity search
21
+ #
22
+ # @param timeframe [nil, Range, Array<Range>] Time range(s) to search (nil = no filter)
23
+ # @param query [String] Search query
24
+ # @param limit [Integer] Maximum results (capped at MAX_VECTOR_LIMIT)
25
+ # @param embedding_service [Object] Service to generate embeddings
26
+ # @param metadata [Hash] Filter by metadata fields (default: {})
27
+ # @return [Array<Hash>] Matching nodes
28
+ #
29
+ def search(timeframe:, query:, limit:, embedding_service:, metadata: {})
30
+ # Enforce limit to prevent DoS
31
+ safe_limit = [[limit.to_i, 1].max, MAX_VECTOR_LIMIT].min
32
+
33
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ result = @cache.fetch(:search, timeframe, query, safe_limit, metadata) do
35
+ search_uncached(
36
+ timeframe: timeframe,
37
+ query: query,
38
+ limit: safe_limit,
39
+ embedding_service: embedding_service,
40
+ metadata: metadata
41
+ )
42
+ end
43
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
44
+ HTM::Telemetry.search_latency.record(elapsed_ms, attributes: { 'strategy' => 'vector' })
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ # Uncached vector similarity search
51
+ #
52
+ # Generates query embedding client-side and performs vector search in database.
53
+ #
54
+ # @param timeframe [nil, Range, Array<Range>] Time range(s) to search (nil = no filter)
55
+ # @param query [String] Search query
56
+ # @param limit [Integer] Maximum results
57
+ # @param embedding_service [Object] Service to generate query embedding
58
+ # @param metadata [Hash] Filter by metadata fields (default: {})
59
+ # @return [Array<Hash>] Matching nodes
60
+ #
61
+ def search_uncached(timeframe:, query:, limit:, embedding_service:, metadata: {})
62
+ # Generate query embedding client-side
63
+ query_embedding = embedding_service.embed(query)
64
+
65
+ # Validate embedding before use
66
+ unless query_embedding.is_a?(Array) && query_embedding.any?
67
+ HTM.logger.error("Invalid embedding returned from embedding service")
68
+ return []
69
+ end
70
+
71
+ # Pad embedding to target dimension
72
+ padded_embedding = HTM::SqlBuilder.pad_embedding(query_embedding)
73
+
74
+ # Sanitize embedding for safe SQL use (validates all values are numeric)
75
+ embedding_str = HTM::SqlBuilder.sanitize_embedding(padded_embedding)
76
+
77
+ # Build filter conditions
78
+ timeframe_condition = HTM::SqlBuilder.timeframe_condition(timeframe)
79
+ metadata_condition = HTM::SqlBuilder.metadata_condition(metadata)
80
+
81
+ conditions = ["embedding IS NOT NULL", "deleted_at IS NULL"]
82
+ conditions << timeframe_condition if timeframe_condition
83
+ conditions << metadata_condition if metadata_condition
84
+
85
+ where_clause = "WHERE #{conditions.join(' AND ')}"
86
+
87
+ # Use parameterized query for embedding
88
+ sql = <<~SQL
89
+ SELECT id, content, access_count, created_at, token_count,
90
+ 1 - (embedding <=> ?::vector) as similarity
91
+ FROM nodes
92
+ #{where_clause}
93
+ ORDER BY embedding <=> ?::vector
94
+ LIMIT ?
95
+ SQL
96
+
97
+ result = ActiveRecord::Base.connection.select_all(
98
+ ActiveRecord::Base.sanitize_sql_array([sql, embedding_str, embedding_str, limit])
99
+ )
100
+
101
+ # Track access for retrieved nodes
102
+ node_ids = result.map { |r| r['id'] }
103
+ track_access(node_ids)
104
+
105
+ result.to_a
106
+ end
107
+ end
108
+ end
109
+ end