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,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class HTM
|
|
4
|
+
class LongTermMemory
|
|
5
|
+
# Hybrid search combining full-text and vector similarity
|
|
6
|
+
#
|
|
7
|
+
# Performs combined search using:
|
|
8
|
+
# 1. Full-text search for content matching
|
|
9
|
+
# 2. Tag matching for categorical relevance
|
|
10
|
+
# 3. Vector similarity for semantic ranking
|
|
11
|
+
#
|
|
12
|
+
# Nodes without embeddings are included with a default similarity score,
|
|
13
|
+
# allowing newly created nodes to appear immediately before background
|
|
14
|
+
# jobs complete their embedding generation.
|
|
15
|
+
#
|
|
16
|
+
# Results are cached for performance.
|
|
17
|
+
#
|
|
18
|
+
# Security: All queries use parameterized placeholders to prevent SQL injection.
|
|
19
|
+
#
|
|
20
|
+
module HybridSearch
|
|
21
|
+
# Maximum results to prevent DoS via unbounded queries
|
|
22
|
+
MAX_HYBRID_LIMIT = 1000
|
|
23
|
+
MAX_PREFILTER_LIMIT = 5000
|
|
24
|
+
|
|
25
|
+
# Hybrid search (full-text + vector)
|
|
26
|
+
#
|
|
27
|
+
# @param timeframe [Range] Time range to search
|
|
28
|
+
# @param query [String] Search query
|
|
29
|
+
# @param limit [Integer] Maximum results (capped at MAX_HYBRID_LIMIT)
|
|
30
|
+
# @param embedding_service [Object] Service to generate embeddings
|
|
31
|
+
# @param prefilter_limit [Integer] Candidates to consider (default: 100, capped at MAX_PREFILTER_LIMIT)
|
|
32
|
+
# @param metadata [Hash] Filter by metadata fields (default: {})
|
|
33
|
+
# @return [Array<Hash>] Matching nodes
|
|
34
|
+
#
|
|
35
|
+
def search_hybrid(timeframe:, query:, limit:, embedding_service:, prefilter_limit: 100, metadata: {})
|
|
36
|
+
# Enforce limits to prevent DoS
|
|
37
|
+
safe_limit = [[limit.to_i, 1].max, MAX_HYBRID_LIMIT].min
|
|
38
|
+
safe_prefilter = [[prefilter_limit.to_i, 1].max, MAX_PREFILTER_LIMIT].min
|
|
39
|
+
|
|
40
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
|
+
result = @cache.fetch(:hybrid, timeframe, query, safe_limit, safe_prefilter, metadata) do
|
|
42
|
+
search_hybrid_uncached(
|
|
43
|
+
timeframe: timeframe,
|
|
44
|
+
query: query,
|
|
45
|
+
limit: safe_limit,
|
|
46
|
+
embedding_service: embedding_service,
|
|
47
|
+
prefilter_limit: safe_prefilter,
|
|
48
|
+
metadata: metadata
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
52
|
+
HTM::Telemetry.search_latency.record(elapsed_ms, attributes: { 'strategy' => 'hybrid' })
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Threshold for skipping tag extraction (as ratio of limit)
|
|
59
|
+
# If fulltext returns >= this ratio of requested results, skip expensive tag extraction
|
|
60
|
+
TAG_EXTRACTION_THRESHOLD = 0.5
|
|
61
|
+
|
|
62
|
+
# Uncached hybrid search
|
|
63
|
+
#
|
|
64
|
+
# Generates query embedding client-side, then combines:
|
|
65
|
+
# 1. Full-text search for content matching
|
|
66
|
+
# 2. Tag matching for categorical relevance (lazy - skipped if fulltext sufficient)
|
|
67
|
+
# 3. Vector similarity for semantic ranking
|
|
68
|
+
#
|
|
69
|
+
# @param timeframe [nil, Range, Array<Range>] Time range(s) to search (nil = no filter)
|
|
70
|
+
# @param query [String] Search query
|
|
71
|
+
# @param limit [Integer] Maximum results
|
|
72
|
+
# @param embedding_service [Object] Service to generate query embedding
|
|
73
|
+
# @param prefilter_limit [Integer] Candidates to consider
|
|
74
|
+
# @param metadata [Hash] Filter by metadata fields (default: {})
|
|
75
|
+
# @return [Array<Hash>] Matching nodes with similarity and tag_boost scores
|
|
76
|
+
#
|
|
77
|
+
def search_hybrid_uncached(timeframe:, query:, limit:, embedding_service:, prefilter_limit:, metadata: {})
|
|
78
|
+
# Generate query embedding client-side
|
|
79
|
+
query_embedding = embedding_service.embed(query)
|
|
80
|
+
|
|
81
|
+
# Validate embedding before use
|
|
82
|
+
unless query_embedding.is_a?(Array) && query_embedding.any?
|
|
83
|
+
HTM.logger.error("Invalid embedding returned from embedding service")
|
|
84
|
+
return []
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Pad embedding to 2000 dimensions if needed
|
|
88
|
+
padded_embedding = HTM::SqlBuilder.pad_embedding(query_embedding)
|
|
89
|
+
|
|
90
|
+
# Sanitize embedding for safe SQL use (validates all values are numeric)
|
|
91
|
+
embedding_str = HTM::SqlBuilder.sanitize_embedding(padded_embedding)
|
|
92
|
+
|
|
93
|
+
# Build filter conditions (with table alias for CTEs)
|
|
94
|
+
timeframe_condition = HTM::SqlBuilder.timeframe_condition(timeframe, table_alias: 'n')
|
|
95
|
+
metadata_condition = HTM::SqlBuilder.metadata_condition(metadata, table_alias: 'n')
|
|
96
|
+
|
|
97
|
+
additional_conditions = []
|
|
98
|
+
additional_conditions << timeframe_condition if timeframe_condition
|
|
99
|
+
additional_conditions << metadata_condition if metadata_condition
|
|
100
|
+
additional_sql = additional_conditions.any? ? "AND #{additional_conditions.join(' AND ')}" : ""
|
|
101
|
+
|
|
102
|
+
# Same for non-aliased queries
|
|
103
|
+
timeframe_condition_bare = HTM::SqlBuilder.timeframe_condition(timeframe)
|
|
104
|
+
metadata_condition_bare = HTM::SqlBuilder.metadata_condition(metadata)
|
|
105
|
+
|
|
106
|
+
additional_conditions_bare = []
|
|
107
|
+
additional_conditions_bare << timeframe_condition_bare if timeframe_condition_bare
|
|
108
|
+
additional_conditions_bare << metadata_condition_bare if metadata_condition_bare
|
|
109
|
+
additional_sql_bare = additional_conditions_bare.any? ? "AND #{additional_conditions_bare.join(' AND ')}" : ""
|
|
110
|
+
|
|
111
|
+
# OPTIMIZATION: Lazy tag extraction
|
|
112
|
+
# Only extract tags if fulltext results are insufficient.
|
|
113
|
+
# This skips the expensive LLM call (~500-3000ms) when fulltext alone
|
|
114
|
+
# provides enough results.
|
|
115
|
+
fulltext_count = count_fulltext_matches(
|
|
116
|
+
query: query,
|
|
117
|
+
additional_sql_bare: additional_sql_bare,
|
|
118
|
+
limit: prefilter_limit
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Only call expensive tag extraction if fulltext results are below threshold
|
|
122
|
+
matching_tags = if fulltext_count < (limit * TAG_EXTRACTION_THRESHOLD)
|
|
123
|
+
find_query_matching_tags(query)
|
|
124
|
+
else
|
|
125
|
+
[]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Build the hybrid query
|
|
129
|
+
# NOTE: Hybrid search includes nodes without embeddings using a default
|
|
130
|
+
# similarity score of 0.5. This allows newly created nodes to appear in
|
|
131
|
+
# search results immediately (via fulltext matching) before their embeddings
|
|
132
|
+
# are generated by background jobs.
|
|
133
|
+
|
|
134
|
+
result = if matching_tags.any?
|
|
135
|
+
search_hybrid_with_tags(
|
|
136
|
+
query: query,
|
|
137
|
+
embedding_str: embedding_str,
|
|
138
|
+
matching_tags: matching_tags,
|
|
139
|
+
additional_sql: additional_sql,
|
|
140
|
+
prefilter_limit: prefilter_limit,
|
|
141
|
+
limit: limit
|
|
142
|
+
)
|
|
143
|
+
else
|
|
144
|
+
search_hybrid_without_tags(
|
|
145
|
+
query: query,
|
|
146
|
+
embedding_str: embedding_str,
|
|
147
|
+
additional_sql_bare: additional_sql_bare,
|
|
148
|
+
prefilter_limit: prefilter_limit,
|
|
149
|
+
limit: limit
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Track access for retrieved nodes
|
|
154
|
+
node_ids = result.map { |r| r['id'] }
|
|
155
|
+
track_access(node_ids)
|
|
156
|
+
|
|
157
|
+
result.to_a
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Count fulltext matches quickly (for lazy tag extraction decision)
|
|
161
|
+
#
|
|
162
|
+
# @param query [String] Search query
|
|
163
|
+
# @param additional_sql_bare [String] Additional SQL conditions
|
|
164
|
+
# @param limit [Integer] Maximum to count up to
|
|
165
|
+
# @return [Integer] Number of fulltext matches (capped at limit)
|
|
166
|
+
#
|
|
167
|
+
def count_fulltext_matches(query:, additional_sql_bare:, limit:)
|
|
168
|
+
sql = <<~SQL
|
|
169
|
+
SELECT COUNT(*) FROM (
|
|
170
|
+
SELECT 1 FROM nodes
|
|
171
|
+
WHERE deleted_at IS NULL
|
|
172
|
+
AND to_tsvector('english', content) @@ plainto_tsquery('english', ?)
|
|
173
|
+
#{additional_sql_bare}
|
|
174
|
+
LIMIT ?
|
|
175
|
+
) AS limited_count
|
|
176
|
+
SQL
|
|
177
|
+
|
|
178
|
+
result = ActiveRecord::Base.connection.select_value(
|
|
179
|
+
ActiveRecord::Base.sanitize_sql_array([sql, query, limit])
|
|
180
|
+
)
|
|
181
|
+
result.to_i
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Hybrid search with tag matching
|
|
185
|
+
#
|
|
186
|
+
# Uses parameterized queries and LEFT JOIN for efficient tag boosting.
|
|
187
|
+
#
|
|
188
|
+
# @param query [String] Search query
|
|
189
|
+
# @param embedding_str [String] Sanitized embedding string
|
|
190
|
+
# @param matching_tags [Array<String>] Tags matching the query
|
|
191
|
+
# @param additional_sql [String] Additional SQL conditions
|
|
192
|
+
# @param prefilter_limit [Integer] Candidates to consider
|
|
193
|
+
# @param limit [Integer] Maximum results
|
|
194
|
+
# @return [ActiveRecord::Result] Query results
|
|
195
|
+
#
|
|
196
|
+
def search_hybrid_with_tags(query:, embedding_str:, matching_tags:, additional_sql:, prefilter_limit:, limit:)
|
|
197
|
+
# Build tag placeholders for parameterized query
|
|
198
|
+
tag_placeholders = matching_tags.map { '?' }.join(', ')
|
|
199
|
+
tag_count = matching_tags.length.to_f
|
|
200
|
+
|
|
201
|
+
# Use parameterized query with proper placeholder binding
|
|
202
|
+
# LEFT JOIN replaces correlated subquery for O(n) instead of O(n²)
|
|
203
|
+
sql = <<~SQL
|
|
204
|
+
WITH fulltext_candidates AS (
|
|
205
|
+
-- Nodes matching full-text search (with or without embeddings)
|
|
206
|
+
SELECT n.id, n.content, n.access_count, n.created_at, n.token_count, n.embedding
|
|
207
|
+
FROM nodes n
|
|
208
|
+
WHERE n.deleted_at IS NULL
|
|
209
|
+
AND to_tsvector('english', n.content) @@ plainto_tsquery('english', ?)
|
|
210
|
+
#{additional_sql}
|
|
211
|
+
LIMIT ?
|
|
212
|
+
),
|
|
213
|
+
tag_candidates AS (
|
|
214
|
+
-- Nodes matching relevant tags (with or without embeddings)
|
|
215
|
+
SELECT n.id, n.content, n.access_count, n.created_at, n.token_count, n.embedding
|
|
216
|
+
FROM nodes n
|
|
217
|
+
JOIN node_tags nt ON nt.node_id = n.id
|
|
218
|
+
JOIN tags t ON t.id = nt.tag_id
|
|
219
|
+
WHERE n.deleted_at IS NULL
|
|
220
|
+
AND t.name IN (#{tag_placeholders})
|
|
221
|
+
#{additional_sql}
|
|
222
|
+
LIMIT ?
|
|
223
|
+
),
|
|
224
|
+
all_candidates AS (
|
|
225
|
+
SELECT * FROM fulltext_candidates
|
|
226
|
+
UNION
|
|
227
|
+
SELECT * FROM tag_candidates
|
|
228
|
+
),
|
|
229
|
+
tag_counts AS (
|
|
230
|
+
-- Pre-compute tag counts using JOIN instead of correlated subquery
|
|
231
|
+
SELECT nt.node_id, COUNT(DISTINCT t.name)::float AS matched_tags
|
|
232
|
+
FROM node_tags nt
|
|
233
|
+
JOIN tags t ON t.id = nt.tag_id
|
|
234
|
+
WHERE t.name IN (#{tag_placeholders})
|
|
235
|
+
GROUP BY nt.node_id
|
|
236
|
+
),
|
|
237
|
+
scored AS (
|
|
238
|
+
SELECT
|
|
239
|
+
ac.id, ac.content, ac.access_count, ac.created_at, ac.token_count,
|
|
240
|
+
CASE
|
|
241
|
+
WHEN ac.embedding IS NOT NULL THEN 1 - (ac.embedding <=> ?::vector)
|
|
242
|
+
ELSE 0.5
|
|
243
|
+
END as similarity,
|
|
244
|
+
COALESCE(tc.matched_tags / ?, 0) as tag_boost
|
|
245
|
+
FROM all_candidates ac
|
|
246
|
+
LEFT JOIN tag_counts tc ON tc.node_id = ac.id
|
|
247
|
+
)
|
|
248
|
+
SELECT id, content, access_count, created_at, token_count,
|
|
249
|
+
similarity, tag_boost,
|
|
250
|
+
(similarity * 0.7 + tag_boost * 0.3) as combined_score
|
|
251
|
+
FROM scored
|
|
252
|
+
ORDER BY combined_score DESC
|
|
253
|
+
LIMIT ?
|
|
254
|
+
SQL
|
|
255
|
+
|
|
256
|
+
# Build parameter array: query, prefilter, tags (first IN), prefilter, tags (second IN), embedding, tag_count, limit
|
|
257
|
+
params = [
|
|
258
|
+
query,
|
|
259
|
+
prefilter_limit,
|
|
260
|
+
*matching_tags,
|
|
261
|
+
prefilter_limit,
|
|
262
|
+
*matching_tags,
|
|
263
|
+
embedding_str,
|
|
264
|
+
tag_count,
|
|
265
|
+
limit
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
ActiveRecord::Base.connection.select_all(
|
|
269
|
+
ActiveRecord::Base.sanitize_sql_array([sql, *params])
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Hybrid search without tag matching (fallback)
|
|
274
|
+
#
|
|
275
|
+
# @param query [String] Search query
|
|
276
|
+
# @param embedding_str [String] Sanitized embedding string
|
|
277
|
+
# @param additional_sql_bare [String] Additional SQL conditions (no alias)
|
|
278
|
+
# @param prefilter_limit [Integer] Candidates to consider
|
|
279
|
+
# @param limit [Integer] Maximum results
|
|
280
|
+
# @return [ActiveRecord::Result] Query results
|
|
281
|
+
#
|
|
282
|
+
def search_hybrid_without_tags(query:, embedding_str:, additional_sql_bare:, prefilter_limit:, limit:)
|
|
283
|
+
# No matching tags, fall back to standard hybrid (fulltext + vector)
|
|
284
|
+
# Include nodes without embeddings with a default similarity score
|
|
285
|
+
# Optimized: compute similarity once in CTE, reuse for combined_score
|
|
286
|
+
sql = <<~SQL
|
|
287
|
+
WITH candidates AS (
|
|
288
|
+
SELECT id, content, access_count, created_at, token_count, embedding
|
|
289
|
+
FROM nodes
|
|
290
|
+
WHERE deleted_at IS NULL
|
|
291
|
+
AND to_tsvector('english', content) @@ plainto_tsquery('english', ?)
|
|
292
|
+
#{additional_sql_bare}
|
|
293
|
+
LIMIT ?
|
|
294
|
+
),
|
|
295
|
+
scored AS (
|
|
296
|
+
SELECT id, content, access_count, created_at, token_count,
|
|
297
|
+
CASE
|
|
298
|
+
WHEN embedding IS NOT NULL THEN 1 - (embedding <=> ?::vector)
|
|
299
|
+
ELSE 0.5
|
|
300
|
+
END as similarity
|
|
301
|
+
FROM candidates
|
|
302
|
+
)
|
|
303
|
+
SELECT id, content, access_count, created_at, token_count,
|
|
304
|
+
similarity,
|
|
305
|
+
0.0 as tag_boost,
|
|
306
|
+
similarity as combined_score
|
|
307
|
+
FROM scored
|
|
308
|
+
ORDER BY combined_score DESC
|
|
309
|
+
LIMIT ?
|
|
310
|
+
SQL
|
|
311
|
+
|
|
312
|
+
ActiveRecord::Base.connection.select_all(
|
|
313
|
+
ActiveRecord::Base.sanitize_sql_array([
|
|
314
|
+
sql,
|
|
315
|
+
query,
|
|
316
|
+
prefilter_limit,
|
|
317
|
+
embedding_str,
|
|
318
|
+
limit
|
|
319
|
+
])
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class HTM
|
|
4
|
+
class LongTermMemory
|
|
5
|
+
# Node CRUD operations for LongTermMemory
|
|
6
|
+
#
|
|
7
|
+
# Handles creating, reading, updating, and deleting memory nodes with:
|
|
8
|
+
# - Content deduplication via SHA-256 hash
|
|
9
|
+
# - Soft delete restoration on duplicate content
|
|
10
|
+
# - Robot-node linking with remember tracking
|
|
11
|
+
# - Bulk access tracking
|
|
12
|
+
#
|
|
13
|
+
module NodeOperations
|
|
14
|
+
# Add a node to long-term memory (with deduplication)
|
|
15
|
+
#
|
|
16
|
+
# If content already exists (by content_hash), links the robot to the existing
|
|
17
|
+
# node and updates timestamps. Otherwise creates a new node.
|
|
18
|
+
#
|
|
19
|
+
# @param content [String] Conversation message/utterance
|
|
20
|
+
# @param token_count [Integer] Token count
|
|
21
|
+
# @param robot_id [Integer] Robot identifier
|
|
22
|
+
# @param embedding [Array<Float>, nil] Pre-generated embedding vector
|
|
23
|
+
# @param metadata [Hash] Flexible metadata for the node (default: {})
|
|
24
|
+
# @return [Hash] { node_id:, is_new:, robot_node: }
|
|
25
|
+
# @raise [ArgumentError] If metadata is not a Hash
|
|
26
|
+
#
|
|
27
|
+
def add(content:, token_count: 0, robot_id:, embedding: nil, metadata: {})
|
|
28
|
+
# Validate metadata parameter
|
|
29
|
+
unless metadata.is_a?(Hash)
|
|
30
|
+
raise ArgumentError, "metadata must be a Hash, got #{metadata.class}"
|
|
31
|
+
end
|
|
32
|
+
content_hash = HTM::Models::Node.generate_content_hash(content)
|
|
33
|
+
|
|
34
|
+
# Wrap in transaction to ensure data consistency
|
|
35
|
+
ActiveRecord::Base.transaction do
|
|
36
|
+
# Check for existing node with same content (including soft-deleted)
|
|
37
|
+
# This avoids unique constraint violations on content_hash
|
|
38
|
+
existing_node = HTM::Models::Node.with_deleted.find_by(content_hash: content_hash)
|
|
39
|
+
|
|
40
|
+
# If found but soft-deleted, restore it
|
|
41
|
+
if existing_node&.deleted?
|
|
42
|
+
existing_node.restore!
|
|
43
|
+
HTM.logger.info "Restored soft-deleted node #{existing_node.id} for content match"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if existing_node
|
|
47
|
+
# Link robot to existing node (or update if already linked)
|
|
48
|
+
robot_node = link_robot_to_node(robot_id: robot_id, node: existing_node)
|
|
49
|
+
|
|
50
|
+
# Update the node's updated_at timestamp
|
|
51
|
+
existing_node.touch
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
node_id: existing_node.id,
|
|
55
|
+
is_new: false,
|
|
56
|
+
robot_node: robot_node
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
# Prepare embedding if provided
|
|
60
|
+
embedding_str = nil
|
|
61
|
+
if embedding
|
|
62
|
+
# Use centralized padding and sanitization
|
|
63
|
+
padded_embedding = HTM::SqlBuilder.pad_embedding(embedding)
|
|
64
|
+
embedding_str = HTM::SqlBuilder.sanitize_embedding(padded_embedding)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Create new node
|
|
68
|
+
node = HTM::Models::Node.create!(
|
|
69
|
+
content: content,
|
|
70
|
+
content_hash: content_hash,
|
|
71
|
+
token_count: token_count,
|
|
72
|
+
embedding: embedding_str,
|
|
73
|
+
metadata: metadata
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Link robot to new node
|
|
77
|
+
robot_node = link_robot_to_node(robot_id: robot_id, node: node)
|
|
78
|
+
|
|
79
|
+
# Selectively invalidate search-related cache entries only
|
|
80
|
+
# (preserves unrelated cached data like tag queries)
|
|
81
|
+
@cache&.invalidate_methods!(:search, :fulltext, :hybrid)
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
node_id: node.id,
|
|
85
|
+
is_new: true,
|
|
86
|
+
robot_node: robot_node
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Link a robot to a node (create or update robot_node record)
|
|
93
|
+
#
|
|
94
|
+
# @param robot_id [Integer] Robot ID
|
|
95
|
+
# @param node [HTM::Models::Node] Node to link
|
|
96
|
+
# @param working_memory [Boolean] Whether node is in working memory (default: false)
|
|
97
|
+
# @return [HTM::Models::RobotNode] The robot_node link record
|
|
98
|
+
#
|
|
99
|
+
def link_robot_to_node(robot_id:, node:, working_memory: false)
|
|
100
|
+
robot_node = HTM::Models::RobotNode.find_by(robot_id: robot_id, node_id: node.id)
|
|
101
|
+
|
|
102
|
+
if robot_node
|
|
103
|
+
# Existing link - record that robot remembered this again
|
|
104
|
+
robot_node.record_remember!
|
|
105
|
+
robot_node.update!(working_memory: working_memory) if working_memory
|
|
106
|
+
else
|
|
107
|
+
# New link
|
|
108
|
+
robot_node = HTM::Models::RobotNode.create!(
|
|
109
|
+
robot_id: robot_id,
|
|
110
|
+
node_id: node.id,
|
|
111
|
+
first_remembered_at: Time.current,
|
|
112
|
+
last_remembered_at: Time.current,
|
|
113
|
+
remember_count: 1,
|
|
114
|
+
working_memory: working_memory
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
robot_node
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Retrieve a node by ID
|
|
122
|
+
#
|
|
123
|
+
# Automatically tracks access by incrementing access_count and updating last_accessed.
|
|
124
|
+
# Uses a single UPDATE query instead of separate increment! and touch calls.
|
|
125
|
+
#
|
|
126
|
+
# @param node_id [Integer] Node database ID
|
|
127
|
+
# @return [Hash, nil] Node data or nil
|
|
128
|
+
#
|
|
129
|
+
def retrieve(node_id)
|
|
130
|
+
node = HTM::Models::Node.find_by(id: node_id)
|
|
131
|
+
return nil unless node
|
|
132
|
+
|
|
133
|
+
# Track access in a single UPDATE query (instead of separate increment! and touch)
|
|
134
|
+
node.update_columns(
|
|
135
|
+
access_count: node.access_count + 1,
|
|
136
|
+
last_accessed: Time.current
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Reload to get updated values
|
|
140
|
+
node.reload.attributes
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Update last_accessed timestamp
|
|
144
|
+
#
|
|
145
|
+
# @param node_id [Integer] Node database ID
|
|
146
|
+
# @return [void]
|
|
147
|
+
#
|
|
148
|
+
def update_last_accessed(node_id)
|
|
149
|
+
node = HTM::Models::Node.find_by(id: node_id)
|
|
150
|
+
node&.update(last_accessed: Time.current)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Delete a node
|
|
154
|
+
#
|
|
155
|
+
# @param node_id [Integer] Node database ID
|
|
156
|
+
# @return [void]
|
|
157
|
+
#
|
|
158
|
+
def delete(node_id)
|
|
159
|
+
node = HTM::Models::Node.find_by(id: node_id)
|
|
160
|
+
node&.destroy
|
|
161
|
+
|
|
162
|
+
# Selectively invalidate search-related cache entries only
|
|
163
|
+
@cache&.invalidate_methods!(:search, :fulltext, :hybrid)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Check if a node exists
|
|
167
|
+
#
|
|
168
|
+
# @param node_id [Integer] Node database ID
|
|
169
|
+
# @return [Boolean] True if node exists
|
|
170
|
+
#
|
|
171
|
+
def exists?(node_id)
|
|
172
|
+
HTM::Models::Node.exists?(node_id)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Mark nodes as evicted from working memory
|
|
176
|
+
#
|
|
177
|
+
# Sets working_memory = false on the robot_nodes join table for the specified
|
|
178
|
+
# robot and node IDs.
|
|
179
|
+
#
|
|
180
|
+
# @param robot_id [Integer] Robot ID whose working memory is being evicted
|
|
181
|
+
# @param node_ids [Array<Integer>] Node IDs to mark as evicted
|
|
182
|
+
# @return [void]
|
|
183
|
+
#
|
|
184
|
+
def mark_evicted(robot_id:, node_ids:)
|
|
185
|
+
return if node_ids.empty?
|
|
186
|
+
|
|
187
|
+
HTM::Models::RobotNode
|
|
188
|
+
.where(robot_id: robot_id, node_id: node_ids)
|
|
189
|
+
.update_all(working_memory: false)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Track access for multiple nodes (bulk operation)
|
|
193
|
+
#
|
|
194
|
+
# Updates access_count and last_accessed for all nodes in the array
|
|
195
|
+
#
|
|
196
|
+
# @param node_ids [Array<Integer>] Node IDs that were accessed
|
|
197
|
+
# @return [void]
|
|
198
|
+
#
|
|
199
|
+
def track_access(node_ids)
|
|
200
|
+
return if node_ids.empty?
|
|
201
|
+
|
|
202
|
+
# Atomic batch update
|
|
203
|
+
HTM::Models::Node.where(id: node_ids).update_all(
|
|
204
|
+
"access_count = access_count + 1, last_accessed = NOW()"
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|