hotwire_club-mcp 0.1.0

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +195 -0
  3. data/.standard.yml +3 -0
  4. data/CHANGELOG.md +22 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +96 -0
  7. data/Rakefile +23 -0
  8. data/db/.keep +0 -0
  9. data/db/kb.sqlite +0 -0
  10. data/exe/hwc-mcp +38 -0
  11. data/lib/hotwire_club/mcp/builder.rb +115 -0
  12. data/lib/hotwire_club/mcp/chunk.rb +45 -0
  13. data/lib/hotwire_club/mcp/chunker.rb +139 -0
  14. data/lib/hotwire_club/mcp/database/adapter.rb +324 -0
  15. data/lib/hotwire_club/mcp/database/relations/chunks.rb +30 -0
  16. data/lib/hotwire_club/mcp/database/relations/doc_tags.rb +13 -0
  17. data/lib/hotwire_club/mcp/database/relations/docs.rb +13 -0
  18. data/lib/hotwire_club/mcp/database/relations/tags.rb +13 -0
  19. data/lib/hotwire_club/mcp/database/repositories/chunks_repo.rb +70 -0
  20. data/lib/hotwire_club/mcp/database/repositories/docs_repo.rb +140 -0
  21. data/lib/hotwire_club/mcp/database/repositories/tags_repo.rb +33 -0
  22. data/lib/hotwire_club/mcp/database.rb +37 -0
  23. data/lib/hotwire_club/mcp/doc.rb +101 -0
  24. data/lib/hotwire_club/mcp/loader.rb +22 -0
  25. data/lib/hotwire_club/mcp/schema.rb +85 -0
  26. data/lib/hotwire_club/mcp/server.rb +76 -0
  27. data/lib/hotwire_club/mcp/tools/base_tool.rb +30 -0
  28. data/lib/hotwire_club/mcp/tools/get_hwc_kb_chunk_tool.rb +22 -0
  29. data/lib/hotwire_club/mcp/tools/list_hwc_kb_categories_tool.rb +18 -0
  30. data/lib/hotwire_club/mcp/tools/list_hwc_kb_docs_tool.rb +25 -0
  31. data/lib/hotwire_club/mcp/tools/list_hwc_kb_tags_tool.rb +18 -0
  32. data/lib/hotwire_club/mcp/tools/related_hwc_kb_docs_tool.rb +26 -0
  33. data/lib/hotwire_club/mcp/tools/search_hwc_kb_tool.rb +25 -0
  34. data/lib/hotwire_club/mcp/tools.rb +9 -0
  35. data/lib/hotwire_club/mcp/version.rb +7 -0
  36. data/lib/hotwire_club/mcp.rb +31 -0
  37. data/sig/hotwire_club/mcp.rbs +110 -0
  38. metadata +237 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 57446ec06e4bcaf140824c61878fb0722ebde8a71bababe3eb55eab7d79e1a61
4
+ data.tar.gz: a59948add0b83f1eca7a95179c3dd8fc3e9a812082b60039803c8877a8daca40
5
+ SHA512:
6
+ metadata.gz: bc49883004dbefadacdc9b573cd0ed1795732306ea22edcfcd4c49a806235d52484c1feb38fb6b43d33990a105397515313ff882b25268a923da316adf11a3a6
7
+ data.tar.gz: 794e37882e4d1114561cac68373d1725a9327c063cd418cd55ab56ce400ba509e3f42702efe7f1e803239b0f940d27fb0ab6b06f6e9e88a9223befc593c64494
data/.rubocop.yml ADDED
@@ -0,0 +1,195 @@
1
+ plugins:
2
+ - rubocop-minitest
3
+ - rubocop-rake
4
+
5
+ AllCops:
6
+ TargetRubyVersion: 3.1
7
+ NewCops: enable
8
+ Exclude:
9
+ - 'vendor/**/*'
10
+ - 'tmp/**/*'
11
+ - 'db/**/*'
12
+ - 'bin/**/*'
13
+ - 'exe/**/*'
14
+ - '*.gemspec'
15
+
16
+ Style/Documentation:
17
+ Enabled: false
18
+
19
+ Style/FrozenStringLiteralComment:
20
+ Enabled: true
21
+ EnforcedStyle: always
22
+
23
+ Layout/LineLength:
24
+ Max: 120
25
+ Exclude:
26
+ - '*.gemspec'
27
+
28
+ Metrics/BlockLength:
29
+ Exclude:
30
+ - 'test/**/*'
31
+ - 'spec/**/*'
32
+ - '*.gemspec'
33
+
34
+ Metrics/MethodLength:
35
+ Max: 30
36
+ Exclude:
37
+ - 'test/**/*'
38
+ - 'spec/**/*'
39
+
40
+ Metrics/AbcSize:
41
+ Max: 20
42
+ Exclude:
43
+ - 'test/**/*'
44
+ - 'spec/**/*'
45
+
46
+ Metrics/ClassLength:
47
+ Max: 200
48
+ Exclude:
49
+ - 'test/**/*'
50
+ - 'spec/**/*'
51
+
52
+ Metrics/ModuleLength:
53
+ Max: 200
54
+
55
+ Metrics/CyclomaticComplexity:
56
+ Max: 10
57
+
58
+ Metrics/ParameterLists:
59
+ Max: 6
60
+
61
+ Naming/FileName:
62
+ Exclude:
63
+ - '*.gemspec'
64
+
65
+ Style/StringLiterals:
66
+ EnforcedStyle: double_quotes
67
+
68
+ Style/HashSyntax:
69
+ EnforcedStyle: ruby19
70
+
71
+ Layout/MultilineMethodCallIndentation:
72
+ EnforcedStyle: aligned
73
+
74
+ Layout/FirstHashElementIndentation:
75
+ EnforcedStyle: consistent
76
+
77
+ Layout/HashAlignment:
78
+ EnforcedColonStyle: table
79
+ EnforcedHashRocketStyle: table
80
+
81
+ Style/TrailingCommaInArrayLiteral:
82
+ EnforcedStyleForMultiline: comma
83
+
84
+ Style/TrailingCommaInHashLiteral:
85
+ EnforcedStyleForMultiline: comma
86
+
87
+ Style/TrailingCommaInArguments:
88
+ EnforcedStyleForMultiline: comma
89
+
90
+ Style/ClassAndModuleChildren:
91
+ EnforcedStyle: nested
92
+
93
+ Style/EmptyMethod:
94
+ EnforcedStyle: expanded
95
+
96
+
97
+ Style/GuardClause:
98
+ MinBodyLength: 3
99
+
100
+ Style/MultilineBlockChain:
101
+ Enabled: false
102
+
103
+ Layout/EmptyLinesAroundBlockBody:
104
+ Enabled: false
105
+
106
+ Layout/EmptyLinesAroundClassBody:
107
+ Enabled: false
108
+
109
+ Layout/EmptyLinesAroundModuleBody:
110
+ Enabled: false
111
+
112
+ Layout/EmptyLinesAroundMethodBody:
113
+ Enabled: false
114
+
115
+ Layout/SpaceInsideHashLiteralBraces:
116
+ EnforcedStyle: no_space
117
+
118
+ Layout/SpaceInsideBlockBraces:
119
+ EnforcedStyle: space
120
+
121
+ Style/BlockDelimiters:
122
+ EnforcedStyle: semantic
123
+
124
+ Style/SymbolArray:
125
+ EnforcedStyle: brackets
126
+
127
+ Style/WordArray:
128
+ EnforcedStyle: brackets
129
+
130
+ Style/PercentLiteralDelimiters:
131
+ PreferredDelimiters:
132
+ '%': '()'
133
+ '%i': '()'
134
+ '%q': '()'
135
+ '%Q': '()'
136
+ '%r': '{}'
137
+ '%s': '()'
138
+ '%w': '()'
139
+ '%W': '()'
140
+ '%x': '()'
141
+
142
+ Style/RegexpLiteral:
143
+ EnforcedStyle: percent_r
144
+
145
+ Style/RedundantReturn:
146
+ AllowMultipleReturnValues: true
147
+
148
+ Style/RedundantSelf:
149
+ Enabled: true
150
+
151
+ Style/RescueModifier:
152
+ Enabled: false
153
+
154
+ Style/SafeNavigation:
155
+ Enabled: true
156
+
157
+ Style/Semicolon:
158
+ AllowAsExpressionSeparator: true
159
+
160
+ Style/TrivialAccessors:
161
+ Enabled: false
162
+
163
+ Style/YodaCondition:
164
+ Enabled: false
165
+
166
+ Lint/AmbiguousBlockAssociation:
167
+ Enabled: false
168
+
169
+ Lint/SuppressedException:
170
+ Enabled: false
171
+
172
+ Lint/UselessAssignment:
173
+ Enabled: true
174
+
175
+ Lint/UnusedMethodArgument:
176
+ AllowUnusedKeywordArguments: true
177
+
178
+ Lint/UnusedBlockArgument:
179
+ AllowUnusedKeywordArguments: true
180
+
181
+ Naming/MethodParameterName:
182
+ AllowedNames:
183
+ - id
184
+ - to
185
+ - ok
186
+ - op
187
+ - io
188
+ - db
189
+
190
+ Naming/VariableNumber:
191
+ Enabled: false
192
+
193
+ Naming/BinaryOperatorParameterName:
194
+ Enabled: false
195
+
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-01-17
9
+
10
+ ### Added
11
+ - Initial release of HotwireClub::MCP server
12
+ - MCP tools for searching and browsing the Hotwire Club knowledge base:
13
+ - `SearchHwcKbTool` - Search the knowledge base for chunks matching a query
14
+ - `GetHwcKbChunkTool` - Get a single knowledge base chunk by its chunk_id
15
+ - `ListHwcKbCategoriesTool` - List all unique categories from the knowledge base
16
+ - `ListHwcKbTagsTool` - List all tags from the knowledge base
17
+ - `ListHwcKbDocsTool` - List documents from the knowledge base with optional filters
18
+ - `RelatedHwcKbDocsTool` - Find documents related to a given document or chunk based on category and tag overlap
19
+ - SQLite database builder for converting markdown documents into a searchable knowledge base
20
+ - Pre-built knowledge base database included in the gem
21
+ - Support for Claude Desktop and Cursor MCP configuration
22
+ - Full-text search using SQLite FTS5 with Porter stemming
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Julian Rubisch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # HotwireClub::Mcp
2
+
3
+ MCP server for Hotwire Club knowledge base - provides tools for searching, browsing, and discovering documentation from the Hotwire Club knowledge base.
4
+
5
+ A Model Context Protocol (MCP) server that provides access to the Hotwire Club knowledge base. Builds a searchable SQLite database from markdown documents and exposes MCP tools for searching and browsing documentation, categories, tags, and documents.
6
+
7
+ ## Features
8
+
9
+ The server provides the following MCP tools:
10
+
11
+ - **SearchHwcKbTool** - Search the knowledge base for chunks matching a query with optional category and tag filters
12
+ - **GetHwcKbChunkTool** - Retrieve a single knowledge base chunk by its chunk_id
13
+ - **ListHwcKbCategoriesTool** - List all unique categories available in the knowledge base
14
+ - **ListHwcKbTagsTool** - List all tags available in the knowledge base
15
+ - **ListHwcKbDocsTool** - List documents with optional filtering by category and tags, with pagination support
16
+ - **RelatedHwcKbDocsTool** - Find documents related to a given document or chunk based on shared categories and tags
17
+
18
+ The knowledge base is pre-built and included in the gem, so no additional setup is required after installation.
19
+
20
+ ## Installation
21
+
22
+ Install the gem:
23
+
24
+ ```bash
25
+ gem install hotwire_club-mcp
26
+ ```
27
+
28
+ **Important:** After installing, if you're using `rbenv`, you might need to regenerate the shims so the `hwc-mcp` executable is available:
29
+
30
+ ```bash
31
+ rbenv rehash
32
+ ```
33
+
34
+ If you encounter an error like "cannot rehash: /Users/username/.rbenv/shims/.rbenv-shim exists", remove the lock file and try again:
35
+
36
+ ```bash
37
+ rm -f ~/.rbenv/shims/.rbenv-shim
38
+ rbenv rehash
39
+ ```
40
+
41
+ ## Requirements
42
+
43
+ - Ruby 3.1.0 or higher
44
+ - SQLite3
45
+
46
+ ## Usage
47
+
48
+ Run the MCP server:
49
+
50
+ ```bash
51
+ hwc-mcp
52
+ ```
53
+
54
+ The server uses a pre-built SQLite database (`db/kb.sqlite`) that is included with the gem. No additional configuration or database setup is required.
55
+
56
+ ### Configuration
57
+
58
+ #### Claude
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "hotwire-club-mcp": {
64
+ "command": "hwc-mcp",
65
+ "args": []
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ #### Cursor
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "hotwire-club-mcp": {
77
+ "command": "hwc-mcp",
78
+ "args": []
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## Development
85
+
86
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
87
+
88
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
89
+
90
+ ## Contributing
91
+
92
+ Bug reports and pull requests are welcome on GitHub at https://github.com/julianrubisch/hotwire_club-mcp.
93
+
94
+ ## License
95
+
96
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: [:test, :standard]
11
+
12
+ namespace :kb do
13
+ desc "Build the knowledge base from corpus directory"
14
+ task :build do
15
+ require_relative "lib/hotwire_club/mcp"
16
+
17
+ HotwireClub::MCP::Builder.run("corpus", "db/kb.sqlite")
18
+ puts "Knowledge base built successfully!"
19
+ end
20
+ end
21
+
22
+ # Hook kb:build into the build task
23
+ Rake::Task[:build].enhance(["kb:build"])
data/db/.keep ADDED
File without changes
data/db/kb.sqlite ADDED
Binary file
data/exe/hwc-mcp ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Only use bundler/setup in development (when Gemfile exists in parent directory)
5
+ # When installed as a gem, the Gemfile won't exist, so just require the gem directly
6
+ gemfile_path = File.expand_path(File.join(__dir__, "..", "Gemfile"))
7
+ if File.exist?(gemfile_path)
8
+ require "bundler/setup"
9
+ end
10
+
11
+ require "hotwire_club/mcp"
12
+
13
+ # Find the database path:
14
+ # 1. In development: look in current directory's db/ folder
15
+ # 2. When installed as gem: look in gem's installation directory
16
+ def find_database_path
17
+ # Check if we're in development (Gemfile exists)
18
+ gemfile_path = File.expand_path(File.join(__dir__, "..", "Gemfile"))
19
+ if File.exist?(gemfile_path)
20
+ # Development: use current directory
21
+ dev_db_path = File.join(Dir.pwd, "db", "kb.sqlite")
22
+ return dev_db_path if File.exist?(dev_db_path)
23
+ end
24
+
25
+ # Installed as gem: find gem root directory
26
+ # The executable is in exe/, so go up to gem root
27
+ gem_root = File.expand_path(File.join(__dir__, ".."))
28
+ gem_db_path = File.join(gem_root, "db", "kb.sqlite")
29
+ return gem_db_path if File.exist?(gem_db_path)
30
+
31
+ # Fallback to current directory (for backward compatibility)
32
+ File.join(Dir.pwd, "db", "kb.sqlite")
33
+ end
34
+
35
+ db_path = find_database_path
36
+ database = HotwireClub::MCP::Database::Adapter.new(db_path: db_path)
37
+ server = HotwireClub::MCP::Server.new(adapter: database)
38
+ server.run
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "date"
5
+ require_relative "schema"
6
+ require_relative "loader"
7
+ require_relative "chunker"
8
+
9
+ module HotwireClub
10
+ module MCP
11
+ # Builder class for knowledge base
12
+ class Builder
13
+ # Build the knowledge base from a corpus directory
14
+ #
15
+ # @param corpus_path [String] Path to the corpus directory
16
+ # @param db_path [String, nil] Optional database path (defaults to Schema::DB_PATH)
17
+ def self.run(corpus_path, db_path = nil)
18
+ db_path ||= Schema::DB_PATH
19
+
20
+ # 1. Create fresh database
21
+ Schema.create!(db_path)
22
+
23
+ # 2. Load documents
24
+ docs = Loader.load_docs(corpus_path)
25
+
26
+ # 3. Chunk documents
27
+ chunks = Chunker.chunk_docs(docs)
28
+
29
+ # 4. Insert into DB in one transaction
30
+ db = SQLite3::Database.new(db_path)
31
+
32
+ db.transaction do
33
+ insert_docs(db, docs)
34
+ insert_tags(db, docs)
35
+ insert_doc_tags(db, docs)
36
+ insert_chunks(db, chunks)
37
+ end
38
+
39
+ db.close
40
+ end
41
+
42
+ # Convert date to string format for database storage
43
+ #
44
+ # @param date [Date, String, nil] Date value to convert
45
+ # @return [String, nil] ISO8601 formatted date string or original string/nil
46
+ def self.format_date_for_db(date)
47
+ return nil if date.nil?
48
+
49
+ if date.is_a?(Date)
50
+ date.iso8601
51
+ elsif date.is_a?(String)
52
+ date
53
+ else
54
+ date.to_s
55
+ end
56
+ end
57
+
58
+ # Insert documents into database
59
+ #
60
+ # @param db [SQLite3::Database] Database connection
61
+ # @param docs [Array<Doc>] Documents to insert
62
+ def self.insert_docs(db, docs)
63
+ docs.each do |doc|
64
+ date_value = format_date_for_db(doc.date)
65
+
66
+ db.execute(
67
+ "INSERT INTO docs (id, title, category, summary, body, date) VALUES (?, ?, ?, ?, ?, ?)",
68
+ [doc.id, doc.title, doc.category, doc.summary, doc.body, date_value],
69
+ )
70
+ end
71
+ end
72
+
73
+ # Insert unique tags into database
74
+ #
75
+ # @param db [SQLite3::Database] Database connection
76
+ # @param docs [Array<Doc>] Documents to extract tags from
77
+ def self.insert_tags(db, docs)
78
+ all_tags = docs.flat_map(&:tags).uniq
79
+
80
+ all_tags.each do |tag|
81
+ db.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", [tag])
82
+ end
83
+ end
84
+
85
+ # Insert document-tag relationships into database
86
+ #
87
+ # @param db [SQLite3::Database] Database connection
88
+ # @param docs [Array<Doc>] Documents to extract relationships from
89
+ def self.insert_doc_tags(db, docs)
90
+ docs.each do |doc|
91
+ doc.tags.each do |tag|
92
+ db.execute("INSERT INTO doc_tags (doc_id, tag) VALUES (?, ?)", [doc.id, tag])
93
+ end
94
+ end
95
+ end
96
+
97
+ # Insert chunks into database with comma-joined tags
98
+ #
99
+ # @param db [SQLite3::Database] Database connection
100
+ # @param chunks [Array<Chunk>] Chunks to insert
101
+ def self.insert_chunks(db, chunks)
102
+ chunks.each do |chunk|
103
+ comma_joined_tags = chunk.tags.join(",")
104
+ insert_sql = "INSERT INTO chunks (chunk_id, doc_id, title, text, category, tags, position) " \
105
+ "VALUES (?, ?, ?, ?, ?, ?, ?)"
106
+
107
+ db.execute(
108
+ insert_sql,
109
+ [chunk.id, chunk.doc_id, chunk.title, chunk.text, chunk.category, comma_joined_tags, chunk.position],
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireClub
4
+ module MCP
5
+ # Data class representing a single chunk of a document
6
+ Chunk = Data.define(:id, :doc_id, :title, :category, :tags, :position, :text) {
7
+ # Create a chunk from a document section
8
+ #
9
+ # @param doc [Doc] The document this chunk belongs to
10
+ # @param section_idx [Integer] The index of the section within the document
11
+ # @param part_idx [Integer] The index of the part within the section (0 for first part)
12
+ # @param section_title [String, nil] The title of the section
13
+ # @param text [String] The text content of the chunk
14
+ # @param position [Integer] The position of the chunk within the document
15
+ # @return [Chunk] A new Chunk instance with generated ID
16
+ def self.create_from_section(doc:, section_idx:, part_idx:, section_title:, text:, position:)
17
+ chunk_id = build_chunk_id(doc.id, section_idx, part_idx)
18
+
19
+ new(
20
+ id: chunk_id,
21
+ doc_id: doc.id,
22
+ title: section_title,
23
+ category: doc.category,
24
+ tags: doc.tags,
25
+ position: position,
26
+ text: text,
27
+ )
28
+ end
29
+
30
+ # Build chunk ID from document ID, section index, and part index
31
+ #
32
+ # @param doc_id [String] Document ID
33
+ # @param section_idx [Integer] Section index
34
+ # @param part_idx [Integer] Part index
35
+ # @return [String] Chunk ID
36
+ def self.build_chunk_id(doc_id, section_idx, part_idx)
37
+ if part_idx.zero?
38
+ "#{doc_id}#s#{section_idx}"
39
+ else
40
+ "#{doc_id}#s#{section_idx}-#{part_idx}"
41
+ end
42
+ end
43
+ }
44
+ end
45
+ end