htm 0.0.18 → 0.0.30
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/CHANGELOG.md +119 -1
- data/README.md +12 -0
- data/Rakefile +104 -18
- data/db/migrate/00001_enable_extensions.rb +9 -5
- data/db/migrate/00002_create_robots.rb +18 -6
- data/db/migrate/00003_create_file_sources.rb +30 -17
- data/db/migrate/00004_create_nodes.rb +60 -48
- data/db/migrate/00005_create_tags.rb +24 -12
- data/db/migrate/00006_create_node_tags.rb +28 -13
- data/db/migrate/00007_create_robot_nodes.rb +40 -26
- data/db/schema.sql +17 -1
- data/db/seeds.rb +34 -34
- data/docs/api/embedding-service.md +140 -110
- data/docs/api/yard/HTM/ActiveRecordConfig.md +6 -0
- data/docs/api/yard/HTM/Config.md +173 -0
- data/docs/api/yard/HTM/ConfigSection.md +28 -0
- data/docs/api/yard/HTM/Database.md +1 -1
- data/docs/api/yard/HTM/Railtie.md +2 -2
- data/docs/api/yard/HTM.md +0 -57
- data/docs/api/yard/index.csv +76 -61
- data/docs/api/yard-reference.md +2 -1
- data/docs/architecture/adrs/003-ollama-embeddings.md +45 -36
- data/docs/architecture/adrs/004-hive-mind.md +1 -1
- data/docs/architecture/adrs/008-robot-identification.md +1 -1
- data/docs/architecture/index.md +11 -9
- data/docs/architecture/overview.md +11 -7
- data/docs/assets/images/balanced-strategy-decay.svg +41 -0
- data/docs/assets/images/class-hierarchy.svg +1 -1
- data/docs/assets/images/eviction-priority.svg +43 -0
- data/docs/assets/images/exception-hierarchy.svg +2 -2
- data/docs/assets/images/hive-mind-shared-memory.svg +52 -0
- data/docs/assets/images/htm-architecture-overview.svg +3 -3
- data/docs/assets/images/htm-core-components.svg +4 -4
- data/docs/assets/images/htm-layered-architecture.svg +1 -1
- data/docs/assets/images/htm-memory-addition-flow.svg +2 -2
- data/docs/assets/images/htm-memory-recall-flow.svg +2 -2
- data/docs/assets/images/memory-topology.svg +53 -0
- data/docs/assets/images/two-tier-memory-architecture.svg +55 -0
- data/docs/database/naming-convention.md +244 -0
- data/docs/database_rake_tasks.md +31 -0
- data/docs/development/rake-tasks.md +80 -35
- data/docs/development/setup.md +76 -44
- data/docs/examples/basic-usage.md +133 -0
- data/docs/examples/config-files.md +170 -0
- data/docs/examples/file-loading.md +208 -0
- data/docs/examples/index.md +116 -0
- data/docs/examples/llm-configuration.md +168 -0
- data/docs/examples/mcp-client.md +172 -0
- data/docs/examples/rails-integration.md +173 -0
- data/docs/examples/robot-groups.md +210 -0
- data/docs/examples/sinatra-integration.md +218 -0
- data/docs/examples/standalone-app.md +216 -0
- data/docs/examples/telemetry.md +224 -0
- data/docs/examples/timeframes.md +143 -0
- data/docs/getting-started/installation.md +97 -40
- data/docs/getting-started/quick-start.md +28 -11
- data/docs/guides/configuration.md +515 -0
- data/docs/guides/file-loading.md +322 -0
- data/docs/guides/getting-started.md +40 -9
- data/docs/guides/index.md +3 -3
- data/docs/guides/mcp-server.md +100 -13
- data/docs/guides/propositions.md +264 -0
- data/docs/guides/recalling-memories.md +4 -4
- data/docs/guides/search-strategies.md +3 -3
- data/docs/guides/tags.md +318 -0
- data/docs/guides/telemetry.md +229 -0
- data/docs/index.md +8 -16
- data/docs/{architecture → robots}/hive-mind.md +8 -111
- data/docs/robots/index.md +73 -0
- data/docs/{guides → robots}/multi-robot.md +3 -3
- data/docs/{guides → robots}/robot-groups.md +8 -7
- data/docs/{architecture → robots}/two-tier-memory.md +13 -149
- data/docs/robots/why-robots.md +85 -0
- data/examples/.envrc +6 -0
- data/examples/.gitignore +2 -0
- data/examples/00_create_examples_db.rb +94 -0
- data/examples/{basic_usage.rb → 01_basic_usage.rb} +12 -16
- data/examples/{custom_llm_configuration.rb → 03_custom_llm_configuration.rb} +13 -3
- data/examples/{file_loader_usage.rb → 04_file_loader_usage.rb} +11 -14
- data/examples/{timeframe_demo.rb → 05_timeframe_demo.rb} +10 -3
- data/examples/{example_app → 06_example_app}/app.rb +15 -15
- data/examples/{cli_app → 07_cli_app}/htm_cli.rb +15 -22
- data/examples/08_sinatra_app/Gemfile.lock +241 -0
- data/examples/{sinatra_app → 08_sinatra_app}/app.rb +19 -18
- data/examples/{mcp_client.rb → 09_mcp_client.rb} +5 -8
- data/examples/{telemetry → 10_telemetry}/SETUP_README.md +1 -1
- data/examples/{telemetry → 10_telemetry}/demo.rb +14 -10
- data/examples/11_robot_groups/README.md +335 -0
- data/examples/{robot_groups → 11_robot_groups/lib}/robot_worker.rb +17 -3
- data/examples/{robot_groups → 11_robot_groups}/multi_process.rb +9 -9
- data/examples/{robot_groups → 11_robot_groups}/same_process.rb +9 -12
- data/examples/{rails_app → 12_rails_app}/Gemfile +3 -0
- data/examples/{rails_app → 12_rails_app}/Gemfile.lock +87 -58
- data/examples/{rails_app → 12_rails_app}/app/controllers/dashboard_controller.rb +10 -6
- data/examples/{rails_app → 12_rails_app}/app/controllers/files_controller.rb +5 -5
- data/examples/{rails_app → 12_rails_app}/app/controllers/memories_controller.rb +11 -7
- data/examples/{rails_app → 12_rails_app}/app/controllers/robots_controller.rb +8 -8
- data/examples/12_rails_app/app/controllers/tags_controller.rb +36 -0
- data/examples/{rails_app → 12_rails_app}/app/views/dashboard/index.html.erb +2 -2
- data/examples/{rails_app → 12_rails_app}/app/views/files/new.html.erb +5 -2
- data/examples/{rails_app → 12_rails_app}/app/views/memories/_memory_card.html.erb +3 -3
- data/examples/{rails_app → 12_rails_app}/app/views/memories/deleted.html.erb +3 -3
- data/examples/{rails_app → 12_rails_app}/app/views/memories/edit.html.erb +3 -3
- data/examples/{rails_app → 12_rails_app}/app/views/memories/show.html.erb +4 -4
- data/examples/{rails_app → 12_rails_app}/app/views/robots/index.html.erb +2 -2
- data/examples/{rails_app → 12_rails_app}/app/views/robots/show.html.erb +4 -4
- data/examples/{rails_app → 12_rails_app}/app/views/search/index.html.erb +1 -1
- data/examples/{rails_app → 12_rails_app}/app/views/tags/index.html.erb +2 -2
- data/examples/{rails_app → 12_rails_app}/app/views/tags/show.html.erb +1 -1
- data/examples/12_rails_app/config/initializers/htm.rb +7 -0
- data/examples/12_rails_app/config/initializers/rack.rb +5 -0
- data/examples/README.md +230 -211
- data/examples/examples_helper.rb +138 -0
- data/lib/htm/config/builder.rb +167 -0
- data/lib/htm/config/database.rb +317 -0
- data/lib/htm/config/defaults.yml +41 -13
- data/lib/htm/config/section.rb +74 -0
- data/lib/htm/config/validator.rb +83 -0
- data/lib/htm/config.rb +65 -361
- data/lib/htm/database.rb +85 -127
- data/lib/htm/errors.rb +14 -0
- data/lib/htm/integrations/sinatra.rb +13 -44
- data/lib/htm/job_adapter.rb +75 -1
- data/lib/htm/jobs/generate_embedding_job.rb +3 -4
- data/lib/htm/jobs/generate_propositions_job.rb +4 -5
- data/lib/htm/jobs/generate_tags_job.rb +16 -15
- data/lib/htm/loaders/defaults_loader.rb +23 -0
- data/lib/htm/loaders/markdown_loader.rb +17 -15
- data/lib/htm/loaders/xdg_config_loader.rb +9 -9
- data/lib/htm/long_term_memory/fulltext_search.rb +14 -14
- data/lib/htm/long_term_memory/hybrid_search.rb +396 -229
- data/lib/htm/long_term_memory/node_operations.rb +24 -23
- data/lib/htm/long_term_memory/relevance_scorer.rb +23 -20
- data/lib/htm/long_term_memory/robot_operations.rb +4 -4
- data/lib/htm/long_term_memory/tag_operations.rb +91 -77
- data/lib/htm/long_term_memory/vector_search.rb +4 -5
- data/lib/htm/long_term_memory.rb +13 -13
- data/lib/htm/mcp/cli.rb +115 -8
- data/lib/htm/mcp/resources.rb +4 -3
- data/lib/htm/mcp/server.rb +5 -4
- data/lib/htm/mcp/tools.rb +37 -28
- data/lib/htm/migration.rb +72 -0
- data/lib/htm/models/file_source.rb +52 -31
- data/lib/htm/models/node.rb +224 -108
- data/lib/htm/models/node_tag.rb +49 -28
- data/lib/htm/models/robot.rb +38 -27
- data/lib/htm/models/robot_node.rb +63 -35
- data/lib/htm/models/tag.rb +126 -123
- data/lib/htm/observability.rb +45 -41
- data/lib/htm/proposition_service.rb +76 -7
- data/lib/htm/railtie.rb +2 -2
- data/lib/htm/robot_group.rb +30 -18
- data/lib/htm/sequel_config.rb +215 -0
- data/lib/htm/sql_builder.rb +14 -16
- data/lib/htm/tag_service.rb +78 -0
- data/lib/htm/tasks.rb +3 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm/workflows/remember_workflow.rb +213 -0
- data/lib/htm.rb +27 -22
- data/lib/tasks/db.rake +0 -2
- data/lib/tasks/doc.rake +2 -2
- data/lib/tasks/files.rake +11 -18
- data/lib/tasks/htm.rake +190 -62
- data/lib/tasks/jobs.rake +179 -54
- data/lib/tasks/tags.rake +8 -13
- data/mkdocs.yml +33 -8
- data/scripts/backfill_parent_tags.rb +376 -0
- data/scripts/normalize_plural_tags.rb +335 -0
- metadata +168 -86
- data/docs/api/yard/HTM/Configuration.md +0 -240
- data/docs/telemetry.md +0 -391
- data/examples/rails_app/app/controllers/tags_controller.rb +0 -30
- data/examples/sinatra_app/Gemfile.lock +0 -166
- data/lib/htm/active_record_config.rb +0 -104
- /data/examples/{config_file_example → 02_config_file_example}/README.md +0 -0
- /data/examples/{config_file_example → 02_config_file_example}/config/htm.local.yml +0 -0
- /data/examples/{config_file_example → 02_config_file_example}/custom_config.yml +0 -0
- /data/examples/{config_file_example → 02_config_file_example}/show_config.rb +0 -0
- /data/examples/{example_app → 06_example_app}/Rakefile +0 -0
- /data/examples/{cli_app → 07_cli_app}/README.md +0 -0
- /data/examples/{sinatra_app → 08_sinatra_app}/Gemfile +0 -0
- /data/examples/{telemetry → 10_telemetry}/README.md +0 -0
- /data/examples/{telemetry → 10_telemetry}/grafana/dashboards/htm-metrics.json +0 -0
- /data/examples/{rails_app → 12_rails_app}/.gitignore +0 -0
- /data/examples/{rails_app → 12_rails_app}/Procfile.dev +0 -0
- /data/examples/{rails_app → 12_rails_app}/README.md +0 -0
- /data/examples/{rails_app → 12_rails_app}/Rakefile +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/assets/stylesheets/application.css +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/assets/stylesheets/inter-font.css +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/controllers/application_controller.rb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/controllers/search_controller.rb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/javascript/application.js +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/javascript/controllers/application.js +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/javascript/controllers/index.js +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/files/index.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/files/show.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/layouts/application.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/memories/index.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/memories/new.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/robots/new.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/shared/_navbar.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/app/views/shared/_stat_card.html.erb +0 -0
- /data/examples/{rails_app → 12_rails_app}/bin/dev +0 -0
- /data/examples/{rails_app → 12_rails_app}/bin/rails +0 -0
- /data/examples/{rails_app → 12_rails_app}/bin/rake +0 -0
- /data/examples/{rails_app → 12_rails_app}/config/application.rb +0 -0
- /data/examples/{rails_app → 12_rails_app}/config/boot.rb +0 -0
- /data/examples/{rails_app → 12_rails_app}/config/database.yml +0 -0
- /data/examples/{rails_app → 12_rails_app}/config/environment.rb +0 -0
- /data/examples/{rails_app → 12_rails_app}/config/importmap.rb +0 -0
- /data/examples/{rails_app → 12_rails_app}/config/routes.rb +0 -0
- /data/examples/{rails_app → 12_rails_app}/config/tailwind.config.js +0 -0
- /data/examples/{rails_app → 12_rails_app}/config.ru +0 -0
- /data/examples/{rails_app → 12_rails_app}/log/.keep +0 -0
- /data/examples/{rails_app → 12_rails_app}/tmp/local_secret.txt +0 -0
|
@@ -7,48 +7,67 @@ class HTM
|
|
|
7
7
|
# This model represents the relationship between a robot and a node,
|
|
8
8
|
# tracking when and how many times a robot has "remembered" a piece of content.
|
|
9
9
|
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
10
|
+
class RobotNode < Sequel::Model(:robot_nodes)
|
|
11
|
+
# Associations
|
|
12
|
+
many_to_one :robot, class: 'HTM::Models::Robot', key: :robot_id
|
|
13
|
+
many_to_one :node, class: 'HTM::Models::Node', key: :node_id
|
|
14
|
+
|
|
15
|
+
# Plugins
|
|
16
|
+
plugin :validation_helpers
|
|
17
|
+
plugin :timestamps, update_on_create: true
|
|
18
|
+
|
|
19
|
+
# Validations
|
|
20
|
+
def validate
|
|
21
|
+
super
|
|
22
|
+
validates_presence [:robot_id, :node_id]
|
|
23
|
+
validates_unique [:robot_id, :node_id], message: 'already linked to this node'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Dataset methods (scopes)
|
|
27
|
+
dataset_module do
|
|
28
|
+
def active
|
|
29
|
+
where(deleted_at: nil)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def recent
|
|
33
|
+
order(Sequel.desc(:last_remembered_at))
|
|
34
|
+
end
|
|
24
35
|
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
def by_robot(robot_id)
|
|
37
|
+
where(robot_id: robot_id)
|
|
38
|
+
end
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
def by_node(node_id)
|
|
41
|
+
where(node_id: node_id)
|
|
42
|
+
end
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
def frequently_remembered
|
|
45
|
+
where { remember_count > 1 }.order(Sequel.desc(:remember_count))
|
|
46
|
+
end
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
scope :frequently_remembered, -> { where('remember_count > 1').order(remember_count: :desc) }
|
|
40
|
-
scope :in_working_memory, -> { where(working_memory: true) }
|
|
48
|
+
def in_working_memory
|
|
49
|
+
where(working_memory: true)
|
|
50
|
+
end
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
def deleted
|
|
53
|
+
exclude(deleted_at: nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def with_deleted
|
|
57
|
+
unfiltered
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Apply default scope for active records
|
|
62
|
+
set_dataset(dataset.where(Sequel[:robot_nodes][:deleted_at] => nil))
|
|
45
63
|
|
|
46
64
|
# Soft delete - mark as deleted without removing from database
|
|
47
65
|
#
|
|
48
66
|
# @return [Boolean] true if soft deleted successfully
|
|
49
67
|
#
|
|
50
68
|
def soft_delete!
|
|
51
|
-
update
|
|
69
|
+
update(deleted_at: Time.now)
|
|
70
|
+
true
|
|
52
71
|
end
|
|
53
72
|
|
|
54
73
|
# Restore a soft-deleted entry
|
|
@@ -56,7 +75,8 @@ class HTM
|
|
|
56
75
|
# @return [Boolean] true if restored successfully
|
|
57
76
|
#
|
|
58
77
|
def restore!
|
|
59
|
-
update
|
|
78
|
+
update(deleted_at: nil)
|
|
79
|
+
true
|
|
60
80
|
end
|
|
61
81
|
|
|
62
82
|
# Check if entry is soft-deleted
|
|
@@ -64,7 +84,15 @@ class HTM
|
|
|
64
84
|
# @return [Boolean] true if deleted_at is set
|
|
65
85
|
#
|
|
66
86
|
def deleted?
|
|
67
|
-
deleted_at.
|
|
87
|
+
!deleted_at.nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if this node is in working memory
|
|
91
|
+
#
|
|
92
|
+
# @return [Boolean] true if working_memory is set
|
|
93
|
+
#
|
|
94
|
+
def working_memory?
|
|
95
|
+
!!working_memory
|
|
68
96
|
end
|
|
69
97
|
|
|
70
98
|
# Record that a robot remembered this content again
|
|
@@ -73,8 +101,8 @@ class HTM
|
|
|
73
101
|
#
|
|
74
102
|
def record_remember!
|
|
75
103
|
self.remember_count += 1
|
|
76
|
-
self.last_remembered_at = Time.
|
|
77
|
-
save
|
|
104
|
+
self.last_remembered_at = Time.now
|
|
105
|
+
save
|
|
78
106
|
self
|
|
79
107
|
end
|
|
80
108
|
end
|
data/lib/htm/models/tag.rb
CHANGED
|
@@ -4,79 +4,117 @@ class HTM
|
|
|
4
4
|
module Models
|
|
5
5
|
# Tag model - represents unique tag names
|
|
6
6
|
# Tags have a many-to-many relationship with nodes through node_tags
|
|
7
|
-
class Tag <
|
|
8
|
-
self.table_name = 'tags'
|
|
9
|
-
|
|
7
|
+
class Tag < Sequel::Model(:tags)
|
|
10
8
|
# Associations
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
one_to_many :node_tags, class: 'HTM::Models::NodeTag', key: :tag_id
|
|
10
|
+
many_to_many :nodes, class: 'HTM::Models::Node',
|
|
11
|
+
join_table: :node_tags, left_key: :tag_id, right_key: :node_id
|
|
12
|
+
|
|
13
|
+
# Plugins
|
|
14
|
+
plugin :validation_helpers
|
|
15
|
+
plugin :timestamps, update_on_create: true
|
|
16
|
+
|
|
17
|
+
# Tag name format regex
|
|
18
|
+
TAG_FORMAT = /\A[a-z0-9\-]+(:[a-z0-9\-]+)*\z/
|
|
13
19
|
|
|
14
20
|
# Validations
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
21
|
+
def validate
|
|
22
|
+
super
|
|
23
|
+
validates_presence :name
|
|
24
|
+
validates_format TAG_FORMAT, :name,
|
|
25
|
+
message: "must be lowercase with hyphens, using colons for hierarchy (e.g., 'database:postgresql:performance')"
|
|
26
|
+
validates_unique :name, message: "already exists"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Dataset methods (scopes)
|
|
30
|
+
dataset_module do
|
|
31
|
+
# Soft delete - by default, only show non-deleted tags
|
|
32
|
+
def active
|
|
33
|
+
where(deleted_at: nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def by_name(name)
|
|
37
|
+
where(name: name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def with_prefix(prefix)
|
|
41
|
+
where(Sequel.like(:name, "#{prefix}%"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def hierarchical
|
|
45
|
+
where(Sequel.like(:name, '%:%'))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def root_level
|
|
49
|
+
exclude(Sequel.like(:name, '%:%'))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def deleted
|
|
53
|
+
exclude(deleted_at: nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def with_deleted
|
|
57
|
+
unfiltered
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Orphaned tags - tags with no active (non-deleted) node associations
|
|
61
|
+
def orphaned
|
|
62
|
+
where(
|
|
63
|
+
Sequel.lit(
|
|
64
|
+
"NOT EXISTS (
|
|
65
|
+
SELECT 1 FROM node_tags
|
|
66
|
+
JOIN nodes ON nodes.id = node_tags.node_id
|
|
67
|
+
WHERE node_tags.tag_id = tags.id
|
|
68
|
+
AND node_tags.deleted_at IS NULL
|
|
69
|
+
AND nodes.deleted_at IS NULL
|
|
70
|
+
)"
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Apply default scope for active records
|
|
77
|
+
set_dataset(dataset.where(Sequel[:tags][:deleted_at] => nil))
|
|
78
|
+
|
|
79
|
+
# Hooks
|
|
80
|
+
def before_create
|
|
81
|
+
self.created_at ||= Time.now
|
|
82
|
+
super
|
|
83
|
+
end
|
|
50
84
|
|
|
51
85
|
# Class methods
|
|
52
86
|
|
|
87
|
+
# Check if a tag exists with the given conditions
|
|
88
|
+
#
|
|
89
|
+
# @param conditions [Hash] Conditions to check
|
|
90
|
+
# @return [Boolean] true if a matching tag exists
|
|
91
|
+
#
|
|
92
|
+
def self.exists?(conditions = {})
|
|
93
|
+
where(conditions).any?
|
|
94
|
+
end
|
|
95
|
+
|
|
53
96
|
# Find tags with a given prefix (hierarchical query)
|
|
54
97
|
#
|
|
55
98
|
# @param prefix [String] Tag prefix to match (e.g., "database" matches "database:postgresql")
|
|
56
|
-
# @return [
|
|
57
|
-
#
|
|
58
|
-
# @example Find all database-related tags
|
|
59
|
-
# Tag.find_by_topic_prefix("database")
|
|
60
|
-
# # => [#<Tag name: "database:postgresql">, #<Tag name: "database:mysql">]
|
|
99
|
+
# @return [Sequel::Dataset] Tags matching the prefix
|
|
61
100
|
#
|
|
62
101
|
def self.find_by_topic_prefix(prefix)
|
|
63
|
-
|
|
102
|
+
dataset.with_prefix(prefix)
|
|
64
103
|
end
|
|
65
104
|
|
|
66
105
|
# Get the most frequently used tags
|
|
67
106
|
#
|
|
68
107
|
# @param limit [Integer] Maximum number of tags to return (default: 10)
|
|
69
|
-
# @return [
|
|
70
|
-
#
|
|
71
|
-
# @example Get top 5 most used tags
|
|
72
|
-
# Tag.popular_tags(5).each { |t| puts "#{t.name}: #{t.usage_count}" }
|
|
108
|
+
# @return [Array<Tag>] Tags with usage_count attribute
|
|
73
109
|
#
|
|
74
110
|
def self.popular_tags(limit = 10)
|
|
75
|
-
|
|
76
|
-
.
|
|
77
|
-
.
|
|
78
|
-
.
|
|
111
|
+
dataset
|
|
112
|
+
.select_append { count(node_tags[:id]).as(usage_count) }
|
|
113
|
+
.join(:node_tags, tag_id: :id)
|
|
114
|
+
.group(:id)
|
|
115
|
+
.order(Sequel.desc(:usage_count))
|
|
79
116
|
.limit(limit)
|
|
117
|
+
.all
|
|
80
118
|
end
|
|
81
119
|
|
|
82
120
|
# Find or create a tag by name
|
|
@@ -85,25 +123,40 @@ class HTM
|
|
|
85
123
|
# @return [Tag] The found or created tag
|
|
86
124
|
#
|
|
87
125
|
def self.find_or_create_by_name(name)
|
|
88
|
-
|
|
126
|
+
find_or_create(name: name)
|
|
89
127
|
end
|
|
90
128
|
|
|
91
|
-
#
|
|
129
|
+
# Expand a hierarchical tag name into all ancestor paths
|
|
92
130
|
#
|
|
93
|
-
# @
|
|
131
|
+
# @param tag_name [String] Hierarchical tag (e.g., "a:b:c:d")
|
|
132
|
+
# @return [Array<String>] All paths from root to leaf
|
|
94
133
|
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
def self.expand_hierarchy(tag_name)
|
|
135
|
+
return [] if tag_name.nil? || tag_name.empty?
|
|
136
|
+
|
|
137
|
+
levels = tag_name.split(':')
|
|
138
|
+
(1..levels.size).map { |i| levels[0, i].join(':') }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Find or create a tag and all its ancestor tags
|
|
98
142
|
#
|
|
99
|
-
# @
|
|
100
|
-
#
|
|
101
|
-
#
|
|
143
|
+
# @param name [String] Hierarchical tag name (e.g., "database:postgresql:extensions")
|
|
144
|
+
# @return [Array<Tag>] All created/found tags from root to leaf
|
|
145
|
+
#
|
|
146
|
+
def self.find_or_create_with_ancestors(name)
|
|
147
|
+
expand_hierarchy(name).map do |tag_name|
|
|
148
|
+
find_or_create(name: tag_name)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Returns a nested hash tree structure from the current scope
|
|
153
|
+
#
|
|
154
|
+
# @return [Hash] Nested hash representing the tag hierarchy
|
|
102
155
|
#
|
|
103
156
|
def self.tree
|
|
104
157
|
tree = {}
|
|
105
158
|
|
|
106
|
-
|
|
159
|
+
order(:name).select_map(:name).each do |tag_name|
|
|
107
160
|
parts = tag_name.split(':')
|
|
108
161
|
current = tree
|
|
109
162
|
|
|
@@ -118,24 +171,13 @@ class HTM
|
|
|
118
171
|
|
|
119
172
|
# Returns a formatted string representation of the tag tree
|
|
120
173
|
#
|
|
121
|
-
# Uses directory-style formatting with ├── and └── characters
|
|
122
|
-
#
|
|
123
174
|
# @return [String] Formatted tree string
|
|
124
175
|
#
|
|
125
|
-
# @example Display all tags as a tree
|
|
126
|
-
# puts Tag.all.tree_string
|
|
127
|
-
# # ├── ai
|
|
128
|
-
# # │ └── llm
|
|
129
|
-
# # └── database
|
|
130
|
-
# # └── postgresql
|
|
131
|
-
#
|
|
132
176
|
def self.tree_string
|
|
133
177
|
format_tree_branch(tree)
|
|
134
178
|
end
|
|
135
179
|
|
|
136
180
|
# Returns a Mermaid flowchart representation of the tag tree
|
|
137
|
-
# Example: puts Tag.all.tree_mermaid
|
|
138
|
-
# Example: Tag.all.tree_mermaid(direction: 'LR') # Left to right
|
|
139
181
|
#
|
|
140
182
|
# @param direction [String] Flow direction: 'TD' (top-down), 'LR' (left-right), 'BT', 'RL'
|
|
141
183
|
# @return [String] Mermaid flowchart syntax
|
|
@@ -148,15 +190,12 @@ class HTM
|
|
|
148
190
|
node_id = 0
|
|
149
191
|
node_ids = {}
|
|
150
192
|
|
|
151
|
-
# Generate Mermaid nodes and connections
|
|
152
193
|
generate_mermaid_nodes(tree_data, nil, lines, node_ids, node_id)
|
|
153
194
|
|
|
154
195
|
lines.join("\n")
|
|
155
196
|
end
|
|
156
197
|
|
|
157
198
|
# Returns an SVG representation of the tag tree
|
|
158
|
-
# Uses dark theme with transparent background
|
|
159
|
-
# Example: File.write('tags.svg', Tag.all.tree_svg)
|
|
160
199
|
#
|
|
161
200
|
# @param title [String] Optional title for the SVG
|
|
162
201
|
# @return [String] SVG markup
|
|
@@ -165,28 +204,22 @@ class HTM
|
|
|
165
204
|
tree_data = tree
|
|
166
205
|
return empty_tree_svg(title) if tree_data.empty?
|
|
167
206
|
|
|
168
|
-
# Calculate dimensions based on tree structure
|
|
169
207
|
stats = calculate_tree_stats(tree_data)
|
|
170
|
-
node_count = stats[:total_nodes]
|
|
171
208
|
max_depth = stats[:max_depth]
|
|
172
209
|
|
|
173
|
-
# Layout constants
|
|
174
210
|
node_width = 140
|
|
175
211
|
node_height = 30
|
|
176
212
|
h_spacing = 180
|
|
177
213
|
v_spacing = 50
|
|
178
214
|
padding = 40
|
|
179
215
|
|
|
180
|
-
# Calculate positions for all nodes
|
|
181
216
|
positions = {}
|
|
182
|
-
y_offset = [0]
|
|
217
|
+
y_offset = [0]
|
|
183
218
|
calculate_node_positions(tree_data, 0, positions, y_offset, h_spacing, v_spacing)
|
|
184
219
|
|
|
185
|
-
# Calculate SVG dimensions
|
|
186
220
|
width = (max_depth * h_spacing) + node_width + (padding * 2)
|
|
187
221
|
height = (y_offset[0] * v_spacing) + node_height + (padding * 2)
|
|
188
222
|
|
|
189
|
-
# Generate SVG
|
|
190
223
|
generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
|
|
191
224
|
end
|
|
192
225
|
|
|
@@ -198,14 +231,11 @@ class HTM
|
|
|
198
231
|
sorted_keys.each_with_index do |key, index|
|
|
199
232
|
is_last = (index == sorted_keys.size - 1)
|
|
200
233
|
|
|
201
|
-
|
|
202
|
-
line_prefix = is_last_array.map { |was_last| was_last ? ' ' : '│ ' }.join
|
|
234
|
+
line_prefix = is_last_array.map { |was_last| was_last ? ' ' : '| ' }.join
|
|
203
235
|
|
|
204
|
-
|
|
205
|
-
branch = is_last ? '└── ' : '├── '
|
|
236
|
+
branch = is_last ? '+-- ' : '+-- '
|
|
206
237
|
result += "#{line_prefix}#{branch}#{key}\n"
|
|
207
238
|
|
|
208
|
-
# Recurse into children
|
|
209
239
|
children = node[key]
|
|
210
240
|
unless children.empty?
|
|
211
241
|
result += format_tree_branch(children, is_last_array + [is_last])
|
|
@@ -220,20 +250,16 @@ class HTM
|
|
|
220
250
|
node.keys.sort.each do |key|
|
|
221
251
|
current_path = parent_path ? "#{parent_path}:#{key}" : key
|
|
222
252
|
|
|
223
|
-
# Create unique node ID
|
|
224
253
|
node_id = "n#{counter}"
|
|
225
254
|
node_ids[current_path] = node_id
|
|
226
255
|
counter += 1
|
|
227
256
|
|
|
228
|
-
# Add node definition with styling
|
|
229
257
|
lines << " #{node_id}[\"#{key}\"]"
|
|
230
258
|
|
|
231
|
-
# Add connection from parent
|
|
232
259
|
if parent_path && node_ids[parent_path]
|
|
233
260
|
lines << " #{node_ids[parent_path]} --> #{node_id}"
|
|
234
261
|
end
|
|
235
262
|
|
|
236
|
-
# Recurse into children
|
|
237
263
|
children = node[key]
|
|
238
264
|
counter = generate_mermaid_nodes(children, current_path, lines, node_ids, counter) unless children.empty?
|
|
239
265
|
end
|
|
@@ -287,17 +313,14 @@ class HTM
|
|
|
287
313
|
|
|
288
314
|
# Generate SVG tree visualization (internal helper)
|
|
289
315
|
def self.generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
|
|
290
|
-
# Color palette for different depths (dark theme)
|
|
291
316
|
colors = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#6366F1']
|
|
292
317
|
|
|
293
318
|
svg_lines = []
|
|
294
319
|
svg_lines << %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height + 40}">)
|
|
295
320
|
svg_lines << ' <rect width="100%" height="100%" fill="transparent"/>'
|
|
296
321
|
|
|
297
|
-
# Title
|
|
298
322
|
svg_lines << %Q( <text x="#{width / 2}" y="25" text-anchor="middle" fill="#F3F4F6" font-family="system-ui, sans-serif" font-size="16" font-weight="bold">#{title}</text>)
|
|
299
323
|
|
|
300
|
-
# Draw connections first (so they appear behind nodes)
|
|
301
324
|
positions.each do |path, pos|
|
|
302
325
|
parent_path = path.include?(':') ? path.split(':')[0..-2].join(':') : nil
|
|
303
326
|
next unless parent_path && positions[parent_path]
|
|
@@ -308,12 +331,10 @@ class HTM
|
|
|
308
331
|
x2 = padding + (pos[:x] * (node_width + 40))
|
|
309
332
|
y2 = 40 + padding + (pos[:y] * (node_height + 20)) + (node_height / 2)
|
|
310
333
|
|
|
311
|
-
# Curved connection line
|
|
312
334
|
mid_x = (x1 + x2) / 2
|
|
313
335
|
svg_lines << %Q( <path d="M#{x1},#{y1} C#{mid_x},#{y1} #{mid_x},#{y2} #{x2},#{y2}" stroke="#4B5563" stroke-width="2" fill="none"/>)
|
|
314
336
|
end
|
|
315
337
|
|
|
316
|
-
# Draw nodes
|
|
317
338
|
positions.each do |path, pos|
|
|
318
339
|
depth = path.count(':')
|
|
319
340
|
color = colors[depth % colors.size]
|
|
@@ -321,10 +342,8 @@ class HTM
|
|
|
321
342
|
x = padding + (pos[:x] * (node_width + 40))
|
|
322
343
|
y = 40 + padding + (pos[:y] * (node_height + 20))
|
|
323
344
|
|
|
324
|
-
# Node rectangle with rounded corners
|
|
325
345
|
svg_lines << %Q( <rect x="#{x}" y="#{y}" width="#{node_width}" height="#{node_height}" rx="6" fill="#{color}" opacity="0.9"/>)
|
|
326
346
|
|
|
327
|
-
# Node label
|
|
328
347
|
text_x = x + (node_width / 2)
|
|
329
348
|
text_y = y + (node_height / 2) + 4
|
|
330
349
|
svg_lines << %Q( <text x="#{text_x}" y="#{text_y}" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, sans-serif" font-size="11" font-weight="500">#{pos[:label]}</text>)
|
|
@@ -340,10 +359,6 @@ class HTM
|
|
|
340
359
|
#
|
|
341
360
|
# @return [String] The first segment of the hierarchical tag
|
|
342
361
|
#
|
|
343
|
-
# @example
|
|
344
|
-
# tag = Tag.find_by(name: "database:postgresql:extensions")
|
|
345
|
-
# tag.root_topic # => "database"
|
|
346
|
-
#
|
|
347
362
|
def root_topic
|
|
348
363
|
name.split(':').first
|
|
349
364
|
end
|
|
@@ -352,10 +367,6 @@ class HTM
|
|
|
352
367
|
#
|
|
353
368
|
# @return [Array<String>] Array of topic segments
|
|
354
369
|
#
|
|
355
|
-
# @example
|
|
356
|
-
# tag = Tag.find_by(name: "database:postgresql:extensions")
|
|
357
|
-
# tag.topic_levels # => ["database", "postgresql", "extensions"]
|
|
358
|
-
#
|
|
359
370
|
def topic_levels
|
|
360
371
|
name.split(':')
|
|
361
372
|
end
|
|
@@ -364,10 +375,6 @@ class HTM
|
|
|
364
375
|
#
|
|
365
376
|
# @return [Integer] Number of hierarchy levels
|
|
366
377
|
#
|
|
367
|
-
# @example
|
|
368
|
-
# Tag.find_by(name: "database").depth # => 1
|
|
369
|
-
# Tag.find_by(name: "database:postgresql").depth # => 2
|
|
370
|
-
#
|
|
371
378
|
def depth
|
|
372
379
|
topic_levels.length
|
|
373
380
|
end
|
|
@@ -385,7 +392,7 @@ class HTM
|
|
|
385
392
|
# @return [Integer] Count of nodes with this tag
|
|
386
393
|
#
|
|
387
394
|
def usage_count
|
|
388
|
-
|
|
395
|
+
node_tags_dataset.count
|
|
389
396
|
end
|
|
390
397
|
|
|
391
398
|
# Soft delete - mark tag as deleted without removing from database
|
|
@@ -393,7 +400,8 @@ class HTM
|
|
|
393
400
|
# @return [Boolean] true if soft deleted successfully
|
|
394
401
|
#
|
|
395
402
|
def soft_delete!
|
|
396
|
-
update
|
|
403
|
+
update(deleted_at: Time.now)
|
|
404
|
+
true
|
|
397
405
|
end
|
|
398
406
|
|
|
399
407
|
# Restore a soft-deleted tag
|
|
@@ -401,7 +409,8 @@ class HTM
|
|
|
401
409
|
# @return [Boolean] true if restored successfully
|
|
402
410
|
#
|
|
403
411
|
def restore!
|
|
404
|
-
update
|
|
412
|
+
update(deleted_at: nil)
|
|
413
|
+
true
|
|
405
414
|
end
|
|
406
415
|
|
|
407
416
|
# Check if tag is soft-deleted
|
|
@@ -409,13 +418,7 @@ class HTM
|
|
|
409
418
|
# @return [Boolean] true if deleted_at is set
|
|
410
419
|
#
|
|
411
420
|
def deleted?
|
|
412
|
-
deleted_at.
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
private
|
|
416
|
-
|
|
417
|
-
def set_created_at
|
|
418
|
-
self.created_at ||= Time.current
|
|
421
|
+
!deleted_at.nil?
|
|
419
422
|
end
|
|
420
423
|
end
|
|
421
424
|
end
|