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.
- checksums.yaml +7 -0
- data/.rubocop.yml +195 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +96 -0
- data/Rakefile +23 -0
- data/db/.keep +0 -0
- data/db/kb.sqlite +0 -0
- data/exe/hwc-mcp +38 -0
- data/lib/hotwire_club/mcp/builder.rb +115 -0
- data/lib/hotwire_club/mcp/chunk.rb +45 -0
- data/lib/hotwire_club/mcp/chunker.rb +139 -0
- data/lib/hotwire_club/mcp/database/adapter.rb +324 -0
- data/lib/hotwire_club/mcp/database/relations/chunks.rb +30 -0
- data/lib/hotwire_club/mcp/database/relations/doc_tags.rb +13 -0
- data/lib/hotwire_club/mcp/database/relations/docs.rb +13 -0
- data/lib/hotwire_club/mcp/database/relations/tags.rb +13 -0
- data/lib/hotwire_club/mcp/database/repositories/chunks_repo.rb +70 -0
- data/lib/hotwire_club/mcp/database/repositories/docs_repo.rb +140 -0
- data/lib/hotwire_club/mcp/database/repositories/tags_repo.rb +33 -0
- data/lib/hotwire_club/mcp/database.rb +37 -0
- data/lib/hotwire_club/mcp/doc.rb +101 -0
- data/lib/hotwire_club/mcp/loader.rb +22 -0
- data/lib/hotwire_club/mcp/schema.rb +85 -0
- data/lib/hotwire_club/mcp/server.rb +76 -0
- data/lib/hotwire_club/mcp/tools/base_tool.rb +30 -0
- data/lib/hotwire_club/mcp/tools/get_hwc_kb_chunk_tool.rb +22 -0
- data/lib/hotwire_club/mcp/tools/list_hwc_kb_categories_tool.rb +18 -0
- data/lib/hotwire_club/mcp/tools/list_hwc_kb_docs_tool.rb +25 -0
- data/lib/hotwire_club/mcp/tools/list_hwc_kb_tags_tool.rb +18 -0
- data/lib/hotwire_club/mcp/tools/related_hwc_kb_docs_tool.rb +26 -0
- data/lib/hotwire_club/mcp/tools/search_hwc_kb_tool.rb +25 -0
- data/lib/hotwire_club/mcp/tools.rb +9 -0
- data/lib/hotwire_club/mcp/version.rb +7 -0
- data/lib/hotwire_club/mcp.rb +31 -0
- data/sig/hotwire_club/mcp.rbs +110 -0
- 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
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
|