htm 0.0.1 → 0.0.10
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/.aigcm_msg +1 -0
- data/.architecture/reviews/comprehensive-codebase-review.md +577 -0
- data/.claude/settings.local.json +92 -0
- data/.envrc +1 -0
- data/.irbrc +283 -80
- data/.tbls.yml +31 -0
- data/CHANGELOG.md +314 -16
- data/CLAUDE.md +603 -0
- data/README.md +76 -5
- data/Rakefile +5 -0
- data/SETUP.md +132 -101
- data/db/migrate/{20250101000001_enable_extensions.rb → 00001_enable_extensions.rb} +0 -1
- data/db/migrate/00002_create_robots.rb +11 -0
- data/db/migrate/00003_create_file_sources.rb +20 -0
- data/db/migrate/00004_create_nodes.rb +65 -0
- data/db/migrate/00005_create_tags.rb +13 -0
- data/db/migrate/00006_create_node_tags.rb +18 -0
- data/db/migrate/00007_create_robot_nodes.rb +26 -0
- data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +12 -0
- data/db/schema.sql +390 -36
- data/docs/api/database.md +19 -232
- data/docs/api/embedding-service.md +1 -7
- data/docs/api/htm.md +305 -364
- data/docs/api/index.md +1 -7
- data/docs/api/long-term-memory.md +342 -590
- data/docs/api/yard/HTM/ActiveRecordConfig.md +23 -0
- data/docs/api/yard/HTM/AuthorizationError.md +11 -0
- data/docs/api/yard/HTM/CircuitBreaker.md +92 -0
- data/docs/api/yard/HTM/CircuitBreakerOpenError.md +34 -0
- data/docs/api/yard/HTM/Configuration.md +175 -0
- data/docs/api/yard/HTM/Database.md +99 -0
- data/docs/api/yard/HTM/DatabaseError.md +14 -0
- data/docs/api/yard/HTM/EmbeddingError.md +18 -0
- data/docs/api/yard/HTM/EmbeddingService.md +58 -0
- data/docs/api/yard/HTM/Error.md +11 -0
- data/docs/api/yard/HTM/JobAdapter.md +39 -0
- data/docs/api/yard/HTM/LongTermMemory.md +342 -0
- data/docs/api/yard/HTM/NotFoundError.md +17 -0
- data/docs/api/yard/HTM/Observability.md +107 -0
- data/docs/api/yard/HTM/QueryTimeoutError.md +19 -0
- data/docs/api/yard/HTM/Railtie.md +27 -0
- data/docs/api/yard/HTM/ResourceExhaustedError.md +13 -0
- data/docs/api/yard/HTM/TagError.md +18 -0
- data/docs/api/yard/HTM/TagService.md +67 -0
- data/docs/api/yard/HTM/Timeframe/Result.md +24 -0
- data/docs/api/yard/HTM/Timeframe.md +40 -0
- data/docs/api/yard/HTM/TimeframeExtractor/Result.md +24 -0
- data/docs/api/yard/HTM/TimeframeExtractor.md +45 -0
- data/docs/api/yard/HTM/ValidationError.md +20 -0
- data/docs/api/yard/HTM/WorkingMemory.md +131 -0
- data/docs/api/yard/HTM.md +80 -0
- data/docs/api/yard/index.csv +179 -0
- data/docs/api/yard-reference.md +51 -0
- data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
- data/docs/architecture/adrs/003-ollama-embeddings.md +1 -1
- data/docs/architecture/adrs/010-redis-working-memory-rejected.md +2 -27
- data/docs/architecture/adrs/index.md +2 -13
- data/docs/architecture/hive-mind.md +165 -166
- data/docs/architecture/index.md +2 -2
- data/docs/architecture/overview.md +5 -171
- data/docs/architecture/two-tier-memory.md +1 -35
- data/docs/assets/images/adr-010-current-architecture.svg +37 -0
- data/docs/assets/images/adr-010-proposed-architecture.svg +48 -0
- data/docs/assets/images/adr-dependency-tree.svg +93 -0
- data/docs/assets/images/class-hierarchy.svg +55 -0
- data/docs/assets/images/exception-hierarchy.svg +45 -0
- data/docs/assets/images/htm-architecture-overview.svg +83 -0
- data/docs/assets/images/htm-complete-memory-flow.svg +160 -0
- data/docs/assets/images/htm-context-assembly-flow.svg +148 -0
- data/docs/assets/images/htm-eviction-process.svg +141 -0
- data/docs/assets/images/htm-memory-addition-flow.svg +138 -0
- data/docs/assets/images/htm-memory-recall-flow.svg +152 -0
- data/docs/assets/images/htm-node-states.svg +123 -0
- data/docs/assets/images/project-structure.svg +78 -0
- data/docs/assets/images/test-directory-structure.svg +38 -0
- data/{dbdoc → docs/database}/README.md +127 -125
- data/docs/database/public.file_sources.md +42 -0
- data/docs/database/public.file_sources.svg +211 -0
- data/{dbdoc → docs/database}/public.node_tags.md +7 -8
- data/docs/database/public.node_tags.svg +239 -0
- data/{dbdoc → docs/database}/public.nodes.md +22 -17
- data/docs/database/public.nodes.svg +271 -0
- data/docs/database/public.robot_nodes.md +46 -0
- data/docs/database/public.robot_nodes.svg +243 -0
- data/{dbdoc → docs/database}/public.robots.md +2 -3
- data/docs/database/public.robots.svg +161 -0
- data/docs/database/public.tags.svg +139 -0
- data/{dbdoc → docs/database}/schema.json +941 -630
- data/docs/database/schema.svg +282 -0
- data/docs/development/index.md +1 -29
- data/docs/development/schema.md +134 -309
- data/docs/development/testing.md +1 -9
- data/docs/getting-started/index.md +47 -0
- data/docs/{installation.md → getting-started/installation.md} +2 -2
- data/docs/{quick-start.md → getting-started/quick-start.md} +5 -5
- data/docs/guides/adding-memories.md +295 -643
- data/docs/guides/recalling-memories.md +36 -1
- data/docs/guides/search-strategies.md +85 -51
- data/docs/images/htm-er-diagram.svg +156 -0
- data/docs/index.md +16 -31
- data/docs/multi_framework_support.md +4 -4
- data/examples/README.md +280 -0
- data/examples/basic_usage.rb +18 -16
- data/examples/cli_app/htm_cli.rb +146 -8
- data/examples/cli_app/temp.log +93 -0
- data/examples/custom_llm_configuration.rb +1 -2
- data/examples/example_app/app.rb +11 -14
- data/examples/file_loader_usage.rb +177 -0
- data/examples/robot_groups/lib/robot_group.rb +419 -0
- data/examples/robot_groups/lib/working_memory_channel.rb +140 -0
- data/examples/robot_groups/multi_process.rb +286 -0
- data/examples/robot_groups/robot_worker.rb +136 -0
- data/examples/robot_groups/same_process.rb +229 -0
- data/examples/sinatra_app/Gemfile +1 -0
- data/examples/sinatra_app/Gemfile.lock +166 -0
- data/examples/sinatra_app/app.rb +219 -24
- data/examples/timeframe_demo.rb +276 -0
- data/lib/htm/active_record_config.rb +10 -3
- data/lib/htm/circuit_breaker.rb +202 -0
- data/lib/htm/configuration.rb +313 -80
- data/lib/htm/database.rb +67 -36
- data/lib/htm/embedding_service.rb +39 -2
- data/lib/htm/errors.rb +131 -11
- data/lib/htm/{sinatra.rb → integrations/sinatra.rb} +87 -12
- data/lib/htm/job_adapter.rb +10 -3
- data/lib/htm/jobs/generate_embedding_job.rb +5 -4
- data/lib/htm/jobs/generate_tags_job.rb +4 -0
- data/lib/htm/loaders/markdown_loader.rb +263 -0
- data/lib/htm/loaders/paragraph_chunker.rb +112 -0
- data/lib/htm/long_term_memory.rb +601 -321
- data/lib/htm/models/file_source.rb +99 -0
- data/lib/htm/models/node.rb +116 -12
- data/lib/htm/models/robot.rb +53 -4
- data/lib/htm/models/robot_node.rb +51 -0
- data/lib/htm/models/tag.rb +302 -0
- data/lib/htm/observability.rb +395 -0
- data/lib/htm/tag_service.rb +60 -3
- data/lib/htm/tasks.rb +29 -0
- data/lib/htm/timeframe.rb +194 -0
- data/lib/htm/timeframe_extractor.rb +307 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm/working_memory.rb +165 -70
- data/lib/htm.rb +352 -133
- data/lib/tasks/doc.rake +300 -0
- data/lib/tasks/files.rake +299 -0
- data/lib/tasks/htm.rake +188 -2
- data/lib/tasks/jobs.rake +10 -12
- data/lib/tasks/tags.rake +194 -0
- data/mkdocs.yml +91 -9
- data/notes/ARCHITECTURE_REVIEW.md +1167 -0
- data/notes/IMPLEMENTATION_SUMMARY.md +606 -0
- data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +451 -0
- data/notes/next_steps.md +100 -0
- data/notes/plan.md +627 -0
- data/notes/tag_ontology_enhancement_ideas.md +222 -0
- data/notes/timescaledb_removal_summary.md +200 -0
- metadata +177 -37
- data/db/migrate/20250101000002_create_robots.rb +0 -14
- data/db/migrate/20250101000003_create_nodes.rb +0 -42
- data/db/migrate/20250101000005_create_tags.rb +0 -38
- data/db/migrate/20250101000007_add_node_vector_indexes.rb +0 -30
- data/dbdoc/public.node_tags.svg +0 -112
- data/dbdoc/public.nodes.svg +0 -118
- data/dbdoc/public.robots.svg +0 -90
- data/dbdoc/public.tags.svg +0 -60
- data/dbdoc/schema.svg +0 -154
- data/{dbdoc → docs/database}/public.node_stats.md +0 -0
- data/{dbdoc → docs/database}/public.node_stats.svg +0 -0
- data/{dbdoc → docs/database}/public.nodes_tags.md +0 -0
- data/{dbdoc → docs/database}/public.nodes_tags.svg +0 -0
- data/{dbdoc → docs/database}/public.ontology_structure.md +0 -0
- data/{dbdoc → docs/database}/public.ontology_structure.svg +0 -0
- data/{dbdoc → docs/database}/public.operations_log.md +0 -0
- data/{dbdoc → docs/database}/public.operations_log.svg +0 -0
- data/{dbdoc → docs/database}/public.relationships.md +0 -0
- data/{dbdoc → docs/database}/public.relationships.svg +0 -0
- data/{dbdoc → docs/database}/public.robot_activity.md +0 -0
- data/{dbdoc → docs/database}/public.robot_activity.svg +0 -0
- data/{dbdoc → docs/database}/public.schema_migrations.md +0 -0
- data/{dbdoc → docs/database}/public.schema_migrations.svg +0 -0
- data/{dbdoc → docs/database}/public.tags.md +3 -3
- /data/{dbdoc → docs/database}/public.topic_relationships.md +0 -0
- /data/{dbdoc → docs/database}/public.topic_relationships.svg +0 -0
data/lib/htm/models/tag.rb
CHANGED
|
@@ -29,10 +29,28 @@ class HTM
|
|
|
29
29
|
scope :root_level, -> { where("name NOT LIKE '%:%'") }
|
|
30
30
|
|
|
31
31
|
# Class methods
|
|
32
|
+
|
|
33
|
+
# Find tags with a given prefix (hierarchical query)
|
|
34
|
+
#
|
|
35
|
+
# @param prefix [String] Tag prefix to match (e.g., "database" matches "database:postgresql")
|
|
36
|
+
# @return [ActiveRecord::Relation] Tags matching the prefix
|
|
37
|
+
#
|
|
38
|
+
# @example Find all database-related tags
|
|
39
|
+
# Tag.find_by_topic_prefix("database")
|
|
40
|
+
# # => [#<Tag name: "database:postgresql">, #<Tag name: "database:mysql">]
|
|
41
|
+
#
|
|
32
42
|
def self.find_by_topic_prefix(prefix)
|
|
33
43
|
where("name LIKE ?", "#{prefix}%")
|
|
34
44
|
end
|
|
35
45
|
|
|
46
|
+
# Get the most frequently used tags
|
|
47
|
+
#
|
|
48
|
+
# @param limit [Integer] Maximum number of tags to return (default: 10)
|
|
49
|
+
# @return [ActiveRecord::Relation] Tags with usage_count attribute
|
|
50
|
+
#
|
|
51
|
+
# @example Get top 5 most used tags
|
|
52
|
+
# Tag.popular_tags(5).each { |t| puts "#{t.name}: #{t.usage_count}" }
|
|
53
|
+
#
|
|
36
54
|
def self.popular_tags(limit = 10)
|
|
37
55
|
joins(:node_tags)
|
|
38
56
|
.select('tags.*, COUNT(node_tags.id) as usage_count')
|
|
@@ -41,27 +59,311 @@ class HTM
|
|
|
41
59
|
.limit(limit)
|
|
42
60
|
end
|
|
43
61
|
|
|
62
|
+
# Find or create a tag by name
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] Hierarchical tag name (e.g., "database:postgresql")
|
|
65
|
+
# @return [Tag] The found or created tag
|
|
66
|
+
#
|
|
44
67
|
def self.find_or_create_by_name(name)
|
|
45
68
|
find_or_create_by(name: name)
|
|
46
69
|
end
|
|
47
70
|
|
|
71
|
+
# Returns a nested hash tree structure from the current scope
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] Nested hash representing the tag hierarchy
|
|
74
|
+
#
|
|
75
|
+
# @example Get all tags as a tree
|
|
76
|
+
# Tag.all.tree
|
|
77
|
+
# # => { "database" => { "postgresql" => {}, "mysql" => {} }, "ai" => { "llm" => {} } }
|
|
78
|
+
#
|
|
79
|
+
# @example Get filtered tags as a tree
|
|
80
|
+
# Tag.with_prefix("database").tree
|
|
81
|
+
# # => { "database" => { "postgresql" => {} } }
|
|
82
|
+
#
|
|
83
|
+
def self.tree
|
|
84
|
+
tree = {}
|
|
85
|
+
|
|
86
|
+
all.order(:name).pluck(:name).each do |tag_name|
|
|
87
|
+
parts = tag_name.split(':')
|
|
88
|
+
current = tree
|
|
89
|
+
|
|
90
|
+
parts.each do |part|
|
|
91
|
+
current[part] ||= {}
|
|
92
|
+
current = current[part]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
tree
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns a formatted string representation of the tag tree
|
|
100
|
+
#
|
|
101
|
+
# Uses directory-style formatting with ├── and └── characters
|
|
102
|
+
#
|
|
103
|
+
# @return [String] Formatted tree string
|
|
104
|
+
#
|
|
105
|
+
# @example Display all tags as a tree
|
|
106
|
+
# puts Tag.all.tree_string
|
|
107
|
+
# # ├── ai
|
|
108
|
+
# # │ └── llm
|
|
109
|
+
# # └── database
|
|
110
|
+
# # └── postgresql
|
|
111
|
+
#
|
|
112
|
+
def self.tree_string
|
|
113
|
+
format_tree_branch(tree)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a Mermaid flowchart representation of the tag tree
|
|
117
|
+
# Example: puts Tag.all.tree_mermaid
|
|
118
|
+
# Example: Tag.all.tree_mermaid(direction: 'LR') # Left to right
|
|
119
|
+
#
|
|
120
|
+
# @param direction [String] Flow direction: 'TD' (top-down), 'LR' (left-right), 'BT', 'RL'
|
|
121
|
+
# @return [String] Mermaid flowchart syntax
|
|
122
|
+
#
|
|
123
|
+
def self.tree_mermaid(direction: 'TD')
|
|
124
|
+
tree_data = tree
|
|
125
|
+
return "flowchart #{direction}\n empty[No tags]" if tree_data.empty?
|
|
126
|
+
|
|
127
|
+
lines = ["flowchart #{direction}"]
|
|
128
|
+
node_id = 0
|
|
129
|
+
node_ids = {}
|
|
130
|
+
|
|
131
|
+
# Generate Mermaid nodes and connections
|
|
132
|
+
generate_mermaid_nodes(tree_data, nil, lines, node_ids, node_id)
|
|
133
|
+
|
|
134
|
+
lines.join("\n")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns an SVG representation of the tag tree
|
|
138
|
+
# Uses dark theme with transparent background
|
|
139
|
+
# Example: File.write('tags.svg', Tag.all.tree_svg)
|
|
140
|
+
#
|
|
141
|
+
# @param title [String] Optional title for the SVG
|
|
142
|
+
# @return [String] SVG markup
|
|
143
|
+
#
|
|
144
|
+
def self.tree_svg(title: 'HTM Tag Hierarchy')
|
|
145
|
+
tree_data = tree
|
|
146
|
+
return empty_tree_svg(title) if tree_data.empty?
|
|
147
|
+
|
|
148
|
+
# Calculate dimensions based on tree structure
|
|
149
|
+
stats = calculate_tree_stats(tree_data)
|
|
150
|
+
node_count = stats[:total_nodes]
|
|
151
|
+
max_depth = stats[:max_depth]
|
|
152
|
+
|
|
153
|
+
# Layout constants
|
|
154
|
+
node_width = 140
|
|
155
|
+
node_height = 30
|
|
156
|
+
h_spacing = 180
|
|
157
|
+
v_spacing = 50
|
|
158
|
+
padding = 40
|
|
159
|
+
|
|
160
|
+
# Calculate positions for all nodes
|
|
161
|
+
positions = {}
|
|
162
|
+
y_offset = [0] # Use array to allow mutation in closure
|
|
163
|
+
calculate_node_positions(tree_data, 0, positions, y_offset, h_spacing, v_spacing)
|
|
164
|
+
|
|
165
|
+
# Calculate SVG dimensions
|
|
166
|
+
width = (max_depth * h_spacing) + node_width + (padding * 2)
|
|
167
|
+
height = (y_offset[0] * v_spacing) + node_height + (padding * 2)
|
|
168
|
+
|
|
169
|
+
# Generate SVG
|
|
170
|
+
generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Format a tree branch recursively (internal helper)
|
|
174
|
+
def self.format_tree_branch(node, is_last_array = [])
|
|
175
|
+
result = ''
|
|
176
|
+
sorted_keys = node.keys.sort
|
|
177
|
+
|
|
178
|
+
sorted_keys.each_with_index do |key, index|
|
|
179
|
+
is_last = (index == sorted_keys.size - 1)
|
|
180
|
+
|
|
181
|
+
# Build prefix from parent branches
|
|
182
|
+
line_prefix = is_last_array.map { |was_last| was_last ? ' ' : '│ ' }.join
|
|
183
|
+
|
|
184
|
+
# Add branch character and key
|
|
185
|
+
branch = is_last ? '└── ' : '├── '
|
|
186
|
+
result += "#{line_prefix}#{branch}#{key}\n"
|
|
187
|
+
|
|
188
|
+
# Recurse into children
|
|
189
|
+
children = node[key]
|
|
190
|
+
unless children.empty?
|
|
191
|
+
result += format_tree_branch(children, is_last_array + [is_last])
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
result
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Generate Mermaid nodes recursively (internal helper)
|
|
199
|
+
def self.generate_mermaid_nodes(node, parent_path, lines, node_ids, counter)
|
|
200
|
+
node.keys.sort.each do |key|
|
|
201
|
+
current_path = parent_path ? "#{parent_path}:#{key}" : key
|
|
202
|
+
|
|
203
|
+
# Create unique node ID
|
|
204
|
+
node_id = "n#{counter}"
|
|
205
|
+
node_ids[current_path] = node_id
|
|
206
|
+
counter += 1
|
|
207
|
+
|
|
208
|
+
# Add node definition with styling
|
|
209
|
+
lines << " #{node_id}[\"#{key}\"]"
|
|
210
|
+
|
|
211
|
+
# Add connection from parent
|
|
212
|
+
if parent_path && node_ids[parent_path]
|
|
213
|
+
lines << " #{node_ids[parent_path]} --> #{node_id}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Recurse into children
|
|
217
|
+
children = node[key]
|
|
218
|
+
counter = generate_mermaid_nodes(children, current_path, lines, node_ids, counter) unless children.empty?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
counter
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Calculate tree statistics (internal helper)
|
|
225
|
+
def self.calculate_tree_stats(node, depth = 0)
|
|
226
|
+
return { total_nodes: 0, max_depth: depth } if node.empty?
|
|
227
|
+
|
|
228
|
+
total = node.keys.size
|
|
229
|
+
max = depth + 1
|
|
230
|
+
|
|
231
|
+
node.each_value do |children|
|
|
232
|
+
child_stats = calculate_tree_stats(children, depth + 1)
|
|
233
|
+
total += child_stats[:total_nodes]
|
|
234
|
+
max = [max, child_stats[:max_depth]].max
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
{ total_nodes: total, max_depth: max }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Calculate node positions for SVG layout (internal helper)
|
|
241
|
+
def self.calculate_node_positions(node, depth, positions, y_offset, h_spacing, v_spacing, parent_path = nil)
|
|
242
|
+
node.keys.sort.each do |key|
|
|
243
|
+
current_path = parent_path ? "#{parent_path}:#{key}" : key
|
|
244
|
+
|
|
245
|
+
positions[current_path] = {
|
|
246
|
+
x: depth,
|
|
247
|
+
y: y_offset[0],
|
|
248
|
+
label: key
|
|
249
|
+
}
|
|
250
|
+
y_offset[0] += 1
|
|
251
|
+
|
|
252
|
+
children = node[key]
|
|
253
|
+
calculate_node_positions(children, depth + 1, positions, y_offset, h_spacing, v_spacing, current_path) unless children.empty?
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Generate SVG for empty tree (internal helper)
|
|
258
|
+
def self.empty_tree_svg(title)
|
|
259
|
+
<<~SVG
|
|
260
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 100">
|
|
261
|
+
<rect width="100%" height="100%" fill="transparent"/>
|
|
262
|
+
<text x="150" y="30" text-anchor="middle" fill="#9CA3AF" font-family="system-ui, sans-serif" font-size="14" font-weight="bold">#{title}</text>
|
|
263
|
+
<text x="150" y="60" text-anchor="middle" fill="#6B7280" font-family="system-ui, sans-serif" font-size="12">No tags found</text>
|
|
264
|
+
</svg>
|
|
265
|
+
SVG
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Generate SVG tree visualization (internal helper)
|
|
269
|
+
def self.generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
|
|
270
|
+
# Color palette for different depths (dark theme)
|
|
271
|
+
colors = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#6366F1']
|
|
272
|
+
|
|
273
|
+
svg_lines = []
|
|
274
|
+
svg_lines << %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height + 40}">)
|
|
275
|
+
svg_lines << ' <rect width="100%" height="100%" fill="transparent"/>'
|
|
276
|
+
|
|
277
|
+
# Title
|
|
278
|
+
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>)
|
|
279
|
+
|
|
280
|
+
# Draw connections first (so they appear behind nodes)
|
|
281
|
+
positions.each do |path, pos|
|
|
282
|
+
parent_path = path.include?(':') ? path.split(':')[0..-2].join(':') : nil
|
|
283
|
+
next unless parent_path && positions[parent_path]
|
|
284
|
+
|
|
285
|
+
parent_pos = positions[parent_path]
|
|
286
|
+
x1 = padding + (parent_pos[:x] * (node_width + 40)) + node_width
|
|
287
|
+
y1 = 40 + padding + (parent_pos[:y] * (node_height + 20)) + (node_height / 2)
|
|
288
|
+
x2 = padding + (pos[:x] * (node_width + 40))
|
|
289
|
+
y2 = 40 + padding + (pos[:y] * (node_height + 20)) + (node_height / 2)
|
|
290
|
+
|
|
291
|
+
# Curved connection line
|
|
292
|
+
mid_x = (x1 + x2) / 2
|
|
293
|
+
svg_lines << %Q( <path d="M#{x1},#{y1} C#{mid_x},#{y1} #{mid_x},#{y2} #{x2},#{y2}" stroke="#4B5563" stroke-width="2" fill="none"/>)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Draw nodes
|
|
297
|
+
positions.each do |path, pos|
|
|
298
|
+
depth = path.count(':')
|
|
299
|
+
color = colors[depth % colors.size]
|
|
300
|
+
|
|
301
|
+
x = padding + (pos[:x] * (node_width + 40))
|
|
302
|
+
y = 40 + padding + (pos[:y] * (node_height + 20))
|
|
303
|
+
|
|
304
|
+
# Node rectangle with rounded corners
|
|
305
|
+
svg_lines << %Q( <rect x="#{x}" y="#{y}" width="#{node_width}" height="#{node_height}" rx="6" fill="#{color}" opacity="0.9"/>)
|
|
306
|
+
|
|
307
|
+
# Node label
|
|
308
|
+
text_x = x + (node_width / 2)
|
|
309
|
+
text_y = y + (node_height / 2) + 4
|
|
310
|
+
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>)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
svg_lines << '</svg>'
|
|
314
|
+
svg_lines.join("\n")
|
|
315
|
+
end
|
|
316
|
+
|
|
48
317
|
# Instance methods
|
|
318
|
+
|
|
319
|
+
# Get the root (top-level) topic of this tag
|
|
320
|
+
#
|
|
321
|
+
# @return [String] The first segment of the hierarchical tag
|
|
322
|
+
#
|
|
323
|
+
# @example
|
|
324
|
+
# tag = Tag.find_by(name: "database:postgresql:extensions")
|
|
325
|
+
# tag.root_topic # => "database"
|
|
326
|
+
#
|
|
49
327
|
def root_topic
|
|
50
328
|
name.split(':').first
|
|
51
329
|
end
|
|
52
330
|
|
|
331
|
+
# Get all hierarchy levels of this tag
|
|
332
|
+
#
|
|
333
|
+
# @return [Array<String>] Array of topic segments
|
|
334
|
+
#
|
|
335
|
+
# @example
|
|
336
|
+
# tag = Tag.find_by(name: "database:postgresql:extensions")
|
|
337
|
+
# tag.topic_levels # => ["database", "postgresql", "extensions"]
|
|
338
|
+
#
|
|
53
339
|
def topic_levels
|
|
54
340
|
name.split(':')
|
|
55
341
|
end
|
|
56
342
|
|
|
343
|
+
# Get the depth (number of levels) of this tag
|
|
344
|
+
#
|
|
345
|
+
# @return [Integer] Number of hierarchy levels
|
|
346
|
+
#
|
|
347
|
+
# @example
|
|
348
|
+
# Tag.find_by(name: "database").depth # => 1
|
|
349
|
+
# Tag.find_by(name: "database:postgresql").depth # => 2
|
|
350
|
+
#
|
|
57
351
|
def depth
|
|
58
352
|
topic_levels.length
|
|
59
353
|
end
|
|
60
354
|
|
|
355
|
+
# Check if this tag is hierarchical (has child levels)
|
|
356
|
+
#
|
|
357
|
+
# @return [Boolean] True if tag contains colons (hierarchy separators)
|
|
358
|
+
#
|
|
61
359
|
def hierarchical?
|
|
62
360
|
name.include?(':')
|
|
63
361
|
end
|
|
64
362
|
|
|
363
|
+
# Get the number of nodes using this tag
|
|
364
|
+
#
|
|
365
|
+
# @return [Integer] Count of nodes with this tag
|
|
366
|
+
#
|
|
65
367
|
def usage_count
|
|
66
368
|
node_tags.count
|
|
67
369
|
end
|