htm 0.0.11 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.dictate.toml +46 -0
- data/.envrc +2 -0
- data/CHANGELOG.md +52 -2
- data/README.md +79 -0
- data/Rakefile +14 -2
- data/bin/htm_mcp.rb +94 -0
- data/config/database.yml +20 -13
- data/db/migrate/00010_add_soft_delete_to_associations.rb +29 -0
- data/db/migrate/00011_add_performance_indexes.rb +21 -0
- data/db/migrate/00012_add_tags_trigram_index.rb +18 -0
- data/db/migrate/00013_enable_lz4_compression.rb +43 -0
- data/db/schema.sql +49 -92
- data/docs/api/index.md +1 -1
- data/docs/api/yard/HTM.md +2 -4
- data/docs/architecture/index.md +1 -1
- data/docs/development/index.md +1 -1
- data/docs/getting-started/index.md +1 -1
- data/docs/guides/index.md +1 -1
- data/docs/images/telemetry-architecture.svg +153 -0
- data/docs/telemetry.md +391 -0
- data/examples/README.md +46 -1
- data/examples/cli_app/README.md +1 -1
- data/examples/cli_app/htm_cli.rb +1 -1
- data/examples/sinatra_app/app.rb +1 -1
- data/examples/telemetry/README.md +147 -0
- data/examples/telemetry/SETUP_README.md +169 -0
- data/examples/telemetry/demo.rb +498 -0
- data/examples/telemetry/grafana/dashboards/htm-metrics.json +457 -0
- data/lib/htm/configuration.rb +261 -70
- data/lib/htm/database.rb +46 -22
- data/lib/htm/embedding_service.rb +24 -14
- data/lib/htm/errors.rb +15 -1
- data/lib/htm/jobs/generate_embedding_job.rb +19 -0
- data/lib/htm/jobs/generate_propositions_job.rb +103 -0
- data/lib/htm/jobs/generate_tags_job.rb +24 -0
- data/lib/htm/loaders/markdown_chunker.rb +79 -0
- data/lib/htm/loaders/markdown_loader.rb +41 -15
- data/lib/htm/long_term_memory/fulltext_search.rb +138 -0
- data/lib/htm/long_term_memory/hybrid_search.rb +324 -0
- data/lib/htm/long_term_memory/node_operations.rb +209 -0
- data/lib/htm/long_term_memory/relevance_scorer.rb +355 -0
- data/lib/htm/long_term_memory/robot_operations.rb +34 -0
- data/lib/htm/long_term_memory/tag_operations.rb +428 -0
- data/lib/htm/long_term_memory/vector_search.rb +109 -0
- data/lib/htm/long_term_memory.rb +51 -1153
- data/lib/htm/models/node.rb +35 -2
- data/lib/htm/models/node_tag.rb +31 -0
- data/lib/htm/models/robot_node.rb +31 -0
- data/lib/htm/models/tag.rb +44 -0
- data/lib/htm/proposition_service.rb +169 -0
- data/lib/htm/query_cache.rb +214 -0
- data/lib/htm/sql_builder.rb +178 -0
- data/lib/htm/tag_service.rb +16 -6
- data/lib/htm/tasks.rb +8 -2
- data/lib/htm/telemetry.rb +224 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm.rb +64 -3
- data/lib/tasks/doc.rake +1 -1
- data/lib/tasks/htm.rake +259 -13
- data/mkdocs.yml +96 -96
- metadata +42 -16
- data/.aigcm_msg +0 -1
- data/.claude/settings.local.json +0 -95
- data/CLAUDE.md +0 -603
- data/examples/cli_app/temp.log +0 -93
- data/lib/htm/loaders/paragraph_chunker.rb +0 -112
- data/notes/ARCHITECTURE_REVIEW.md +0 -1167
- data/notes/IMPLEMENTATION_SUMMARY.md +0 -606
- data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +0 -451
- data/notes/next_steps.md +0 -100
- data/notes/plan.md +0 -627
- data/notes/tag_ontology_enhancement_ideas.md +0 -222
- 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
|