Class: HTM::Models::Node

Inherits:
Object
  • Object
show all
Defined in:
lib/htm/models/node.rb

Overview

Node model - represents a memory node (conversation message)

Nodes are globally unique by content (via content_hash) and can be linked to multiple robots through the robot_nodes join table.

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.find_by_content(content) ⇒ Node?

Find a node by content hash, or return nil

Parameters:

  • content (String)

    The content to search for

Returns:

  • (Node, nil)

    The existing node or nil



165
166
167
168
# File 'lib/htm/models/node.rb', line 165

def self.find_by_content(content)
  hash = generate_content_hash(content)
  first(content_hash: hash)
end

.generate_content_hash(content) ⇒ String

Generate SHA-256 hash for content

Parameters:

  • content (String)

    Content to hash

Returns:

  • (String)

    64-character hex hash



175
176
177
# File 'lib/htm/models/node.rb', line 175

def self.generate_content_hash(content)
  Digest::SHA256.hexdigest(content.to_s)
end

.purge_deleted(older_than:) ⇒ Integer

Permanently delete all soft-deleted nodes older than the specified time

Parameters:

  • older_than (Time)

    Delete nodes soft-deleted before this time

Returns:

  • (Integer)

    Number of nodes permanently deleted



156
157
158
# File 'lib/htm/models/node.rb', line 156

def self.purge_deleted(older_than:)
  dataset.unfiltered.where { deleted_at < older_than }.delete
end

Instance Method Details

#add_tags(tag_names) ⇒ void

This method returns an undefined value.

Add tags to this node (creates tags and all parent tags if they don’t exist)

Parameters:

  • tag_names (Array<String>, String)

    Tag name(s) to add



276
277
278
279
280
281
282
# File 'lib/htm/models/node.rb', line 276

def add_tags(tag_names)
  Array(tag_names).each do |tag_name|
    HTM::Models::Tag.find_or_create_with_ancestors(tag_name).each do |tag|
      HTM::Models::NodeTag.find_or_create(node_id: id, tag_id: tag.id)
    end
  end
end

#before_createObject



137
138
139
140
141
142
# File 'lib/htm/models/node.rb', line 137

def before_create
  self.created_at ||= Time.now
  self.updated_at ||= Time.now
  self.last_accessed ||= Time.now
  super
end

#before_saveObject



144
145
146
147
# File 'lib/htm/models/node.rb', line 144

def before_save
  self.updated_at = Time.now if changed_columns.any?
  super
end

#before_validationObject

Hooks



130
131
132
133
134
135
# File 'lib/htm/models/node.rb', line 130

def before_validation
  if content_hash.nil? && content
    self.content_hash = self.class.generate_content_hash(content)
  end
  super
end

#deleted?Boolean

Check if node is soft-deleted

Returns:

  • (Boolean)

    true if deleted_at is set



339
340
341
# File 'lib/htm/models/node.rb', line 339

def deleted?
  !deleted_at.nil?
end

#embeddingObject

Override embedding getter to return Array instead of String pgvector stores as string format “[0.1,0.2,…]” and we need Array<Float>



30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/htm/models/node.rb', line 30

def embedding
  raw = super
  return nil if raw.nil?
  return raw if raw.is_a?(Array)

  # Parse string format: "[0.1,0.2,0.3]"
  if raw.is_a?(String)
    raw.gsub(/[\[\]]/, '').split(',').map(&:to_f)
  else
    raw.to_a
  end
end

#embedding_arrayArray<Float>?

Get embedding as an Array (handles both String and Array storage) Note: The ‘embedding` getter already returns Array, this is an alias for compatibility

Returns:

  • (Array<Float>, nil)

    The embedding vector as an array



231
232
233
# File 'lib/htm/models/node.rb', line 231

def embedding_array
  embedding
end

#nearest_neighbors(limit: 10, distance: "cosine") ⇒ Array<Node>

Find nearest neighbors to this node’s embedding

Parameters:

  • limit (Integer) (defaults to: 10)

    number of neighbors to return (default: 10)

  • distance (String) (defaults to: "cosine")

    distance metric (default: “cosine”)

Returns:

  • (Array<Node>)

    ordered by distance (closest first)



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/htm/models/node.rb', line 187

def nearest_neighbors(limit: 10, distance: "cosine")
  return [] unless embedding

  # Use raw SQL for vector similarity search
  db = self.class.db

  # Handle embedding - might be String or Array depending on Sequel pg extension
  emb = embedding_array
  return [] if emb.nil? || emb.empty?

  vector_str = "[#{emb.join(',')}]"

  sql = <<-SQL
    SELECT nodes.*, (embedding <=> '#{vector_str}'::vector) AS neighbor_distance
    FROM nodes
    WHERE embedding IS NOT NULL
      AND deleted_at IS NULL
      AND id != #{id}
    ORDER BY embedding <=> '#{vector_str}'::vector
    LIMIT #{limit}
  SQL

  # Use call() to create instances from raw hashes without mass assignment restrictions
  db.fetch(sql).all.map do |row|
    node = self.class.call(row)
    # Store neighbor_distance as an instance variable
    node.instance_variable_set(:@neighbor_distance, row[:neighbor_distance])
    node
  end
end

#neighbor_distanceObject

Accessor for neighbor_distance from nearest_neighbors query Works with both:

  • Instance method (stores in @neighbor_distance)

  • Dataset method (stores in values hash from SELECT)



222
223
224
# File 'lib/htm/models/node.rb', line 222

def neighbor_distance
  @neighbor_distance || values[:neighbor_distance]
end

#proposition?Boolean

Check if node is a proposition (extracted atomic fact)

Returns:

  • (Boolean)

    true if metadata is true



347
348
349
# File 'lib/htm/models/node.rb', line 347

def proposition?
  &.dig('is_proposition') == true
end

#remove_tag(tag_name) ⇒ void

This method returns an undefined value.

Remove a tag from this node

Parameters:

  • tag_name (String)

    Tag name to remove



289
290
291
292
293
294
# File 'lib/htm/models/node.rb', line 289

def remove_tag(tag_name)
  tag = HTM::Models::Tag.first(name: tag_name)
  return unless tag

  node_tags_dataset.where(tag_id: tag.id).delete
end

#restore!Boolean

Restore a soft-deleted node

Returns:

  • (Boolean)

    true if restored successfully



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/htm/models/node.rb', line 318

def restore!
  db.transaction do
    # Use unfiltered dataset to bypass the default scope that excludes deleted records
    self.class.dataset.unfiltered.where(id: id).update(deleted_at: nil)

    # Cascade restoration to associated robot_nodes
    HTM::Models::RobotNode.dataset.unfiltered.where(node_id: id).update(deleted_at: nil)

    # Cascade restoration to associated node_tags
    HTM::Models::NodeTag.dataset.unfiltered.where(node_id: id).update(deleted_at: nil)

    # Refresh this instance to reflect the change
    self.deleted_at = nil
  end
  true
end

#similarity_to(other) ⇒ Float

Calculate cosine similarity to another embedding or node

Parameters:

  • other (Array, Node)

    query embedding vector or another Node

Returns:

  • (Float)

    similarity score (0.0 to 1.0, higher is more similar)



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/htm/models/node.rb', line 240

def similarity_to(other)
  query_embedding = other.is_a?(Node) ? other.embedding_array : other
  return nil unless embedding_array && query_embedding

  # Handle query_embedding that might be a String
  if query_embedding.is_a?(String)
    query_embedding = query_embedding.gsub(/[\[\]]/, '').split(',').map(&:to_f)
  end

  unless query_embedding.is_a?(Array) && query_embedding.all? { |v| v.is_a?(Numeric) && v.finite? }
    return nil
  end

  vector_str = "[#{query_embedding.map(&:to_f).join(',')}]"

  result = self.class.db.fetch(
    "SELECT 1 - (embedding <=> ?::vector) AS similarity FROM nodes WHERE id = ?",
    vector_str, id
  ).first

  result&.[](:similarity)&.to_f
end

#soft_delete!Boolean

Soft delete - mark node as deleted without removing from database

Returns:

  • (Boolean)

    true if soft deleted successfully



300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/htm/models/node.rb', line 300

def soft_delete!
  db.transaction do
    now = Time.now
    update(deleted_at: now)

    # Cascade soft delete to associated robot_nodes
    HTM::Models::RobotNode.where(node_id: id).update(deleted_at: now)

    # Cascade soft delete to associated node_tags
    HTM::Models::NodeTag.where(node_id: id).update(deleted_at: now)
  end
  true
end

#tag_namesArray<String>

Get all tag names associated with this node

Returns:

  • (Array<String>)

    Array of hierarchical tag names



267
268
269
# File 'lib/htm/models/node.rb', line 267

def tag_names
  tags_dataset.select_map(:name)
end

#to_hashHash Also known as: attributes

Convert to hash (for compatibility with existing code)

Returns:

  • (Hash)

    Hash representation of the node



355
356
357
# File 'lib/htm/models/node.rb', line 355

def to_hash
  values.transform_keys(&:to_s)
end

#validateObject

Validations



44
45
46
47
48
# File 'lib/htm/models/node.rb', line 44

def validate
  super
  validates_presence %i[content content_hash]
  validates_unique :content_hash
end