wp2txt 2.1.2 → 2.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03b8bbbab05c9ab7f56ed3f89c00ae14c3259fbb7b00d54010b04a0decb79f16
4
- data.tar.gz: dd92bacf8e6f17e9d6d5ad33bb3e9c5777cb59a53f4f150136a2143dd84f5b1d
3
+ metadata.gz: 5e54ea4f824be0f060036923759b1c6755b20ee1e721245db58a23665b9efaab
4
+ data.tar.gz: ca0a5b1ae0efae6eb4c485a6319569ea9995c662054e7e697a70429632613a25
5
5
  SHA512:
6
- metadata.gz: b4d95651f10fd30d3c0d23d2846c60b891716b2638a62151712a28bf6ea1f2a6e0da18abaa5b48553f6d736ba86bfeb8fd978ee1e07facfc45c56256734b239d
7
- data.tar.gz: e10dcbcba41561a26df4f5740b6517bc2253fe6226eb68d588c17c9bf00f2ec6b9e41ba6eceeff63be0fc742856eb20c385c290a32f5ccc8d364b110fff7fcdb
6
+ metadata.gz: 34b61e5ee404431e521e77bc1223b9b7e1e0c9f2082ae4898e6e4b9fdee9de593c9ca45d661723f8d14f5ebffd62360dfb27d3a7b0d87d2a37691bb127c9ab6f
7
+ data.tar.gz: ca002ba2fc0c5a9bc8b2ae55dbc736f154e54452af681aad6f1b9ed30ca616535db94ee8cccb069defa56aacc4feb96b306d6fdb1a0102ee20611c98a213d5d0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.2.0] - 2026-07-22
9
+
10
+ - **Index hardening (design review follow-up)**: `ord` now has identical semantics in `page_sections` and `fts_map` (lead = 0, first heading = 1; schema v2 — rebuild indexes with `--build-index -U`); section headings are normalized identically in both indexes (decorated headings like `== '''X''' ==` now match section filters); indexes record the wp2txt version (and the FTS index a rendering-config digest) so `dump_info` can flag indexes built by code whose text cleaning differs; rebuilds are atomic (built alongside, renamed on completion — a failed rebuild no longer destroys the working index)
11
+ - **MCP safety hardening**: `query_sql` runs in a killable subprocess with a 30s wall-clock cap and returns a query-plan diagnosis on timeout; the keyword screen no longer rejects legitimate values inside string literals; `extract_corpus` output paths are confined to the server output directory and refuse to overwrite existing files without `overwrite: true`; background jobs are serialized (one at a time) and all SQLite connections are closed before forking extraction workers
12
+ - **Cold-start parity for LLM clients**: closes the gaps that previously required internal knowledge to work around. `get_categories` tool; `find_articles`/`extract_corpus` accept `categories` (exact multi-category AND) and `category_match` (substring on category names); `extract_corpus` gains `content: "wikitext"` for structure mining (infoboxes/templates); `query_sql` read-only SQL escape hatch (SELECT/WITH only, keyword-screened, read-only connection, row/cell caps) with `describe_schema`; article titles are normalized like MediaWiki (underscores, capitalization); `get_article` truncates at `max_chars` (default 40k) with an explicit flag; `dump_info` reports `fulltext_current`
13
+ - **Deferred FTS optimize**: `--skip-fts-optimize` skips the single-threaded segment-merge step of the full-text build (the dominant cost on many-core machines: 40-60+ min on a full dump), leaving a fully searchable index; `--fts-optimize` runs the merge later, standalone and idempotent. Optimize state is recorded in index metadata and reported by `dump_info`
14
+ - **Full-text search (Tier 2)**: `--build-index --fulltext` builds a contentless SQLite FTS5 index over cleaned section text (tokenizer auto-selected by language: character-trigram for CJK, unicode61 for space-delimited; `--fts-tokenizer` to override, porter stemming opt-in). Search via `--search` (CLI) or the `search_text` MCP tool: literal phrase or raw FTS5 query modes, composable with category recursion and section filters, `count: "capped"|"exact"` (exact 0 = a verified absence claim for the dump version). Snippets are re-rendered from the dump on demand — the index stores no text, keeping disk cost to the inverted index only
15
+ - **Background extraction jobs**: `start_extract_job` / `job_status` / `cancel_job` / `list_jobs` MCP tools for extractions beyond the 5000-article synchronous cap. Each job runs in its own thread with isolated resources; extraction now streams to disk in batches (memory-safe at any scale)
16
+ - **RAG chunking**: `extract_corpus` accepts `chunk_size` / `chunk_overlap`, emitting one record per chunk with `section_path`, `chunk_index`, and `chunk_count`; chunk boundaries prefer sentence/paragraph breaks
17
+ - **Alias guardrail**: `save_alias_set` re-checks co-occurrence server-side and refuses groups containing frequently-coexisting heading pairs (likely distinct roles, not synonyms) unless `force` is passed — protocol compliance no longer depends on the calling model's discipline
18
+ - **`discover_aliases` MCP prompt**: bundled recipe walking any agent through the alias verification protocol (discover via `section_stats` → verify via `section_cooccurrence` → sample borderline cases → save)
19
+ - **MCP server (`wp2txt-mcp`)**: New binary exposing a local dump to LLM agents via the Model Context Protocol (stdio). Tools: `dump_info`, `get_article`, `get_sections`, `list_headings`, `find_articles`, `category_tree`, `section_stats`, `section_cooccurrence`, `save_alias_set`/`get_alias_set`/`list_alias_sets`, and `extract_corpus` (writes JSONL + reproducibility `.meta.json` sidecar; returns summary and sample only). Requires the `mcp` gem (`gem install mcp`); wp2txt itself does not depend on it
20
+ - **LLM-generated section aliases**: Instead of shipping per-language alias dictionaries, agents discover real heading usage with `section_stats`, verify synonym hypotheses with `section_cooccurrence` (synonymous headings almost never co-occur in one article), and persist named alias sets per dump with `save_alias_set`. Queries reference them via `alias_set`; extraction metadata records the exact set contents used
21
+ - **`Wp2txt::Corpus` facade**: Shared query/extraction layer used by the MCP server; lazy SQLite-backed title lookup avoids loading multi-million-entry indexes into memory
22
+ - **Local metadata index (`--build-index`)**: New offline index built by scanning a multistream dump in parallel. Stores per-article categories, section headings, redirects, and the category hierarchy in SQLite (`~/.wp2txt/cache/*_meta.sqlite3`), keyed to the dump version. No API access required
23
+ - **Offline exhaustive queries (`--find-articles`)**: List articles matching `--in-category` (recursive via `--depth`, powered by dump-derived category hierarchy), `--has-section` (alias-aware), and `--title-match` filters. Supports `--limit` and JSON output (`-j json`); redirects are excluded automatically. Enables queries like "all film articles that have a Plot section" against a version-pinned local dump
24
+
8
25
  ## [2.1.2] - 2026-07-19
9
26
 
10
27
  - **Fixed gem file permissions**: The published gem contained files with owner-only (0600) permissions inherited from the build machine, making them unreadable after `sudo gem install`. A `normalize_permissions` task now runs before `rake build`, ensuring all packaged files are world-readable (0644, or 0755 for executables)
data/Gemfile CHANGED
@@ -3,3 +3,5 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
+
7
+ gem "mcp", "~> 0.25.0"
data/README.md CHANGED
@@ -34,15 +34,19 @@ WP2TXT extracts plain text and category information from Wikipedia dump files. I
34
34
  - **Multilingual support** - Category and redirect detection for 350+ Wikipedia languages
35
35
  - **Streaming processing** - Process large dumps without intermediate files
36
36
  - **JSON output** - Machine-readable JSONL format for data pipelines
37
+ - **Offline metadata index** - Exhaustive, version-pinned queries over categories and section headings, no API required
38
+ - **MCP server** - Expose a local dump to LLM agents (Claude, ChatGPT, Gemini, local models) for grounded, reproducible corpus work
37
39
 
38
40
  ## Use Cases
39
41
 
40
42
  wp2txt is particularly suited for:
41
43
 
42
- - Building domain-specific corpora using category information
44
+ - Building domain-specific corpora using category information (e.g., "the plot sections of all film articles")
43
45
  - Comparative linguistic research across topic areas
44
46
  - Extracting Wikipedia text with metadata for NLP tasks
45
47
  - Cross-linguistic studies using parallel category structures
48
+ - Version-pinned RAG knowledge bases and LLM evaluation datasets
49
+ - Exhaustive claims about a Wikipedia edition ("no article in this category mentions X") that web search cannot make
46
50
 
47
51
  ## Data Access
48
52
 
@@ -70,12 +74,28 @@ On Windows: Install [Bzip2 for Windows](http://gnuwin32.sourceforge.net/packages
70
74
 
71
75
  ### Docker (Alternative)
72
76
 
77
+ The Docker image bundles everything — Ruby, decompression tools, and the MCP server — so no local Ruby setup is needed.
78
+
79
+ **Interactive / batch CLI:**
80
+
73
81
  ```shell
74
82
  docker run -it -v /path/to/localdata:/data yohasebe/wp2txt
75
83
  ```
76
84
 
77
85
  The `wp2txt` command is available inside the container. Use `/data` for input/output files.
78
86
 
87
+ **MCP server (no Ruby required on the host):**
88
+
89
+ ```shell
90
+ # Build the indexes once (cached in a named volume so they persist)
91
+ docker run -it -v wp2txt:/root/.wp2txt yohasebe/wp2txt wp2txt --build-index --fulltext -L ja
92
+
93
+ # Register with an MCP client, e.g. Claude Code
94
+ claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt yohasebe/wp2txt wp2txt-mcp -L ja
95
+ ```
96
+
97
+ Note: use `-i` (not `-it`) when running the MCP server — a TTY would corrupt the JSON-RPC stream. The named volume (`wp2txt:`) holds downloaded dumps and indexes; without it they are lost when the container exits.
98
+
79
99
  ## Basic Usage
80
100
 
81
101
  ### Auto-download and process (Recommended)
@@ -233,6 +253,64 @@ By default, citation templates are removed. Use `--extract-citations` to extract
233
253
 
234
254
  Supported: `{{cite book}}`, `{{cite web}}`, `{{cite news}}`, `{{cite journal}}`, `{{Citation}}`, etc.
235
255
 
256
+ ## Offline Metadata Index
257
+
258
+ Build a local SQLite index of a dump — per-article categories, section headings, redirects, and the category hierarchy — extracted from the dump itself (no API access, no rate limits, version-pinned for reproducibility):
259
+
260
+ $ wp2txt --build-index --lang=ja # downloads dump if needed; ja: ~15 min, ~1.7 GB index
261
+
262
+ Then run exhaustive queries entirely offline:
263
+
264
+ # All film articles (recursing 3 levels of subcategories) that have a plot section
265
+ $ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
266
+
267
+ # Machine-readable output with total count
268
+ $ wp2txt --find-articles --in-category "Films" -D 2 -j json --limit 100 --lang=en
269
+
270
+ Unlike web/API access, these queries are *exhaustive* (they scan every article, not search-ranked results) and *reproducible* (pinned to a specific dump version). Answering "which of the 1.5M articles have X" takes milliseconds once the index is built.
271
+
272
+ ### Full-Text Search
273
+
274
+ Add `--fulltext` to also build an FTS5 index over the article text itself (tokenizer auto-selected: character-trigram for Japanese/Chinese/Korean, word-based for space-delimited languages):
275
+
276
+ $ wp2txt --build-index --fulltext --lang=ja
277
+ $ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
278
+
279
+ Search hits report the article, section, and a snippet; totals are exhaustive counts, so `0 matches` is a verifiable absence claim for that dump version. The index is contentless (stores only the inverted index; snippets are re-rendered from the dump), keeping disk overhead moderate.
280
+
281
+ ## MCP Server (LLM Integration)
282
+
283
+ `wp2txt-mcp` exposes a local dump to LLM agents (Claude, ChatGPT, Gemini, local models — any MCP-capable client) via the Model Context Protocol:
284
+
285
+ $ gem install mcp # optional dependency, needed only for the server
286
+ $ wp2txt --build-index --lang=ja # prerequisite
287
+ $ wp2txt-mcp --lang=ja # stdio MCP server
288
+
289
+ Example client configuration (e.g., Claude Desktop / Claude Code):
290
+
291
+ ```json
292
+ {
293
+ "mcpServers": {
294
+ "wp2txt": { "command": "wp2txt-mcp", "args": ["--lang", "ja"] }
295
+ }
296
+ }
297
+ ```
298
+
299
+ Tools provided:
300
+
301
+ | Tool | Purpose |
302
+ |------|---------|
303
+ | `dump_info` | Dump version, index tiers, corpus statistics |
304
+ | `get_article` / `get_sections` / `list_headings` | Single-article access (redirect-aware) |
305
+ | `find_articles` | Exhaustive filtered listing (category recursion, section headings, title match) |
306
+ | `category_tree` / `section_stats` | Scope exploration and heading frequency discovery |
307
+ | `section_cooccurrence` | Verify section-alias hypotheses (synonyms rarely co-occur in one article) |
308
+ | `save_alias_set` etc. | Persist verified per-dump alias groups (server-side co-occurrence guardrail) |
309
+ | `extract_corpus` | Filtered extraction to JSONL + reproducibility sidecar; optional RAG chunking |
310
+ | `start_extract_job` / `job_status` / `cancel_job` | Background jobs for large extractions |
311
+
312
+ Design principles: large results go to disk (the model receives a summary plus a 3-record sample, never the full corpus); every output records the dump version and query in a `.meta.json` sidecar; section aliases are discovered from real dump statistics by the LLM and mechanically verified — no hand-maintained per-language dictionaries. The bundled `discover_aliases` prompt walks any agent through the verification protocol.
313
+
236
314
  ## Command Line Options
237
315
 
238
316
  Usage: wp2txt [options]
data/bin/wp2txt CHANGED
@@ -14,6 +14,7 @@ require_relative "../lib/wp2txt/cli_ui"
14
14
  require_relative "../lib/wp2txt/formatter"
15
15
  require_relative "../lib/wp2txt/extractor"
16
16
  require_relative "../lib/wp2txt/ractor_worker"
17
+ require_relative "../lib/wp2txt/index_commands"
17
18
 
18
19
  require "etc"
19
20
  require "json"
@@ -28,6 +29,7 @@ class WpApp
28
29
  include Wp2txt::CliUI
29
30
  include Wp2txt::Formatter
30
31
  include Wp2txt::Extractor
32
+ include Wp2txt::IndexCommands
31
33
 
32
34
  # Debug mode flag
33
35
  DEBUG_MODE = false
@@ -704,6 +706,12 @@ class WpApp
704
706
  return EXIT_SUCCESS
705
707
  end
706
708
 
709
+ # Metadata index operations (offline exhaustive queries)
710
+ return run_build_index(opts) if opts[:build_index]
711
+ return run_find_articles(opts) if opts[:find_articles]
712
+ return run_search(opts) if opts[:search]
713
+ return run_fts_optimize(opts) if opts[:fts_optimize]
714
+
707
715
  # Determine input source
708
716
  if opts[:from_category] && opts[:lang]
709
717
  # Category extraction mode
data/bin/wp2txt-mcp ADDED
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ # MCP (Model Context Protocol) server exposing a local Wikipedia dump for
6
+ # exhaustive, version-pinned queries and corpus extraction.
7
+ #
8
+ # Usage:
9
+ # wp2txt-mcp --lang ja # use the cached dump for a language
10
+ # wp2txt-mcp --input dump.xml.bz2 # use an explicit multistream file
11
+ #
12
+ # Prerequisite: wp2txt --build-index -L ja (downloads dump + builds metadata index)
13
+ #
14
+ # All logging goes to stderr; stdout carries JSON-RPC only.
15
+
16
+ begin
17
+ require "mcp"
18
+ rescue LoadError
19
+ abort "wp2txt-mcp requires the 'mcp' gem (not installed by default).\n" \
20
+ "Install it with: gem install mcp"
21
+ end
22
+
23
+ require "optimist"
24
+ require "json"
25
+ require_relative "../lib/wp2txt/corpus"
26
+ require_relative "../lib/wp2txt/corpus_jobs"
27
+ require_relative "../lib/wp2txt/version"
28
+
29
+ opts = Optimist.options do
30
+ version Wp2txt::VERSION
31
+ banner "wp2txt-mcp: MCP server for offline Wikipedia dump queries\n\nOptions:"
32
+ opt :lang, "Wikipedia language code of a cached dump (e.g., ja, en)", type: String, short: "-L"
33
+ opt :input, "Path to a multistream .xml.bz2 file", type: String, short: "-i"
34
+ opt :cache_dir, "Cache directory", type: String, default: File.expand_path("~/.wp2txt/cache")
35
+ opt :output_dir, "Default directory for extract_corpus output files", type: String, default: Dir.pwd
36
+ end
37
+
38
+ Optimist.die "Either --lang or --input is required" if opts[:lang].nil? && opts[:input].nil?
39
+ Optimist.die "Cannot specify both --lang and --input" if opts[:lang] && opts[:input]
40
+
41
+ corpus_factory = lambda do
42
+ if opts[:lang]
43
+ Wp2txt::Corpus.for_lang(opts[:lang], cache_dir: opts[:cache_dir])
44
+ else
45
+ Wp2txt::Corpus.for_input(File.expand_path(opts[:input]), cache_dir: opts[:cache_dir])
46
+ end
47
+ end
48
+
49
+ begin
50
+ corpus = corpus_factory.call
51
+ rescue ArgumentError => e
52
+ abort e.message
53
+ end
54
+
55
+ job_manager = Wp2txt::CorpusJobManager.new(corpus_factory)
56
+
57
+ unless corpus.metadata_built?
58
+ abort "Metadata index not built for this dump.\n" \
59
+ "Build it first with: wp2txt --build-index #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}"
60
+ end
61
+
62
+ info = corpus.dump_info
63
+ warn "wp2txt-mcp #{Wp2txt::VERSION} | dump: #{info[:dump]} | articles: #{info[:stats]&.dig(:article_count)}"
64
+ warn "NOTE: metadata index is stale (dump file changed); consider re-running --build-index" unless info[:metadata_current]
65
+
66
+ OUTPUT_DIR = opts[:output_dir]
67
+
68
+ server = MCP::Server.new(name: "wp2txt", version: Wp2txt::VERSION)
69
+
70
+ # Wrap a tool body: render result as JSON text, convert errors to tool errors
71
+ def respond(&block)
72
+ result = block.call
73
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate(result) }])
74
+ rescue ArgumentError => e
75
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate({ error: e.message }) }], error: true)
76
+ rescue StandardError => e
77
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate({ error: "#{e.class}: #{e.message}" }) }], error: true)
78
+ end
79
+
80
+ server.define_tool(
81
+ name: "dump_info",
82
+ description: "Dump identity (language/date), available index tiers, and article/category/section counts. Call first to learn what this corpus contains.",
83
+ input_schema: { properties: {}, required: [] }
84
+ ) do |server_context:|
85
+ respond { corpus.dump_info }
86
+ end
87
+
88
+ server.define_tool(
89
+ name: "get_article",
90
+ description: "Fetch one article by title from the local dump (titles are normalized: underscores, capitalization; one redirect hop is followed). format: 'text' (cleaned plain text, default) or 'wikitext' (raw markup, includes infoboxes/templates). Long articles are truncated at max_chars (default 40000) with truncated:true and total_chars — raise max_chars only when you need more. If not found, retry via find_articles with title_match to locate the exact title.",
91
+ input_schema: {
92
+ properties: {
93
+ title: { type: "string" },
94
+ format: { type: "string", enum: ["text", "wikitext"] },
95
+ max_chars: { type: "integer", minimum: 1000 }
96
+ },
97
+ required: ["title"]
98
+ }
99
+ ) do |title:, format: "text", max_chars: 40_000, server_context:|
100
+ respond { corpus.get_article(title, format: format, max_chars: max_chars) || { error: "article not found: #{title}" } }
101
+ end
102
+
103
+ server.define_tool(
104
+ name: "get_categories",
105
+ description: "List the categories of one article (from the local index). Useful for building precise filters: inspect a few known-good articles' categories, then query find_articles with categories/category_match.",
106
+ input_schema: { properties: { title: { type: "string" } }, required: ["title"] }
107
+ ) do |title:, server_context:|
108
+ respond { corpus.get_categories(title) || { error: "article not found: #{title}" } }
109
+ end
110
+
111
+ server.define_tool(
112
+ name: "get_sections",
113
+ description: "Extract specific sections from one article by heading names. Use 'summary' for the lead text before the first heading. Optionally expand names through a saved alias_set.",
114
+ input_schema: {
115
+ properties: {
116
+ title: { type: "string" },
117
+ sections: { type: "array", items: { type: "string" } },
118
+ alias_set: { type: "string" }
119
+ },
120
+ required: ["title", "sections"]
121
+ }
122
+ ) do |title:, sections:, alias_set: nil, server_context:|
123
+ respond { corpus.get_sections(title, sections, alias_set: alias_set) || { error: "article not found: #{title}" } }
124
+ end
125
+
126
+ server.define_tool(
127
+ name: "list_headings",
128
+ description: "List the section heading tree (name + level) of one article. Useful before get_sections to see what headings exist.",
129
+ input_schema: { properties: { title: { type: "string" } }, required: ["title"] }
130
+ ) do |title:, server_context:|
131
+ respond { corpus.list_headings(title) || { error: "article not found: #{title}" } }
132
+ end
133
+
134
+ server.define_tool(
135
+ name: "find_articles",
136
+ description: "Exhaustively list articles matching filters, all AND-combined: category (single, with subcategory recursion via depth; NOTE category trees over-include, so dry-run with a small limit first), categories (exact multi-category intersection, e.g. year AND country), category_match (substring on category names, e.g. 'アメリカ合衆国の' matched against every category of the article), sections (article contains at least one of these headings; expanded via alias_set if given), title_match (title substring). Returns total count plus up to `limit` titles. Redirects are excluded.",
137
+ input_schema: {
138
+ properties: {
139
+ category: { type: "string" },
140
+ depth: { type: "integer", minimum: 0, maximum: 5 },
141
+ categories: { type: "array", items: { type: "string" } },
142
+ category_match: { type: "string" },
143
+ sections: { type: "array", items: { type: "string" } },
144
+ alias_set: { type: "string" },
145
+ title_match: { type: "string" },
146
+ limit: { type: "integer", minimum: 0 },
147
+ offset: { type: "integer", minimum: 0 }
148
+ },
149
+ required: []
150
+ }
151
+ ) do |category: nil, depth: 0, categories: nil, category_match: nil, sections: nil, alias_set: nil, title_match: nil, limit: 100, offset: 0, server_context:|
152
+ respond do
153
+ corpus.find_articles(category: category, depth: depth, categories: categories,
154
+ category_match: category_match, sections: sections,
155
+ alias_set: alias_set, title_match: title_match,
156
+ limit: limit, offset: offset)
157
+ end
158
+ end
159
+
160
+ server.define_tool(
161
+ name: "query_sql",
162
+ description: "Read-only SQL (SELECT/WITH only) against the index databases — the escape hatch for questions the fixed tools cannot express (multi-way intersections, aggregations, joins). Call describe_schema first to see the tables. Main DB: pages(page_id, title, namespace, redirect_to, text_length), page_categories(page_id, category), page_sections(page_id, heading, level, ord), category_hierarchy(child, parent), alias_sets. When the fulltext tier exists, the FTS DB is attached as `fts` (fts.fts_sections MATCH ..., fts.fts_map). Filter articles with namespace = 0 AND redirect_to IS NULL. Rows are capped by `limit` (default 200, max 1000) with truncated flag; long cells are clipped. Queries are killed after 30 seconds (the error explains the likely cause); the tables are large (millions of rows), so constrain joins with indexed keys (page_id) and add WHERE filters.",
163
+ input_schema: {
164
+ properties: {
165
+ sql: { type: "string" },
166
+ limit: { type: "integer", minimum: 1, maximum: 1000 }
167
+ },
168
+ required: ["sql"]
169
+ }
170
+ ) do |sql:, limit: 200, server_context:|
171
+ respond { corpus.query_sql(sql, limit: limit) }
172
+ end
173
+
174
+ server.define_tool(
175
+ name: "describe_schema",
176
+ description: "CREATE statements of all tables available to query_sql (metadata DB, plus the attached fts DB when built).",
177
+ input_schema: { properties: {}, required: [] }
178
+ ) do |server_context:|
179
+ respond { corpus.describe_schema }
180
+ end
181
+
182
+ server.define_tool(
183
+ name: "category_tree",
184
+ description: "Subcategory tree under a category (names + depth). Use to understand scope before recursive find_articles.",
185
+ input_schema: {
186
+ properties: { category: { type: "string" }, depth: { type: "integer", minimum: 1, maximum: 5 } },
187
+ required: ["category"]
188
+ }
189
+ ) do |category:, depth: 2, server_context:|
190
+ respond { corpus.category_tree(category, depth: depth) }
191
+ end
192
+
193
+ server.define_tool(
194
+ name: "section_stats",
195
+ description: "Section heading frequencies across articles, optionally scoped to a category (+depth). This is the primary tool for discovering how sections are actually named in this language edition — use it before assuming heading names, and as the raw material for building alias sets.",
196
+ input_schema: {
197
+ properties: {
198
+ category: { type: "string" },
199
+ depth: { type: "integer", minimum: 0, maximum: 5 },
200
+ top_n: { type: "integer", minimum: 1, maximum: 500 }
201
+ },
202
+ required: []
203
+ }
204
+ ) do |category: nil, depth: 0, top_n: 50, server_context:|
205
+ respond { corpus.section_stats(category: category, depth: depth, top_n: top_n) }
206
+ end
207
+
208
+ server.define_tool(
209
+ name: "section_cooccurrence",
210
+ description: "Test whether headings are plausible aliases: for each heading, article count and average position; for each pair, how many articles contain BOTH. Synonymous headings almost never co-occur in one article, so a high cooccurrence_ratio is evidence AGAINST grouping them as aliases. Use to verify a hypothesis before save_alias_set.",
211
+ input_schema: {
212
+ properties: {
213
+ headings: { type: "array", items: { type: "string" }, minItems: 2 },
214
+ category: { type: "string" },
215
+ depth: { type: "integer", minimum: 0, maximum: 5 }
216
+ },
217
+ required: ["headings"]
218
+ }
219
+ ) do |headings:, category: nil, depth: 0, server_context:|
220
+ respond { corpus.section_cooccurrence(headings, category: category, depth: depth) }
221
+ end
222
+
223
+ server.define_tool(
224
+ name: "search_text",
225
+ description: "Exhaustive full-text search over cleaned article text (requires the fulltext index tier — check dump_info). mode: 'phrase' (literal string, default; 3+ chars for CJK dumps) or 'query' (raw FTS5 syntax: AND/OR/NEAR/quotes). Composable with category (recursive via depth) and sections filters (alias_set-aware). count: 'capped' (fast, default; total_is_capped signals more matches exist) or 'exact' (may take seconds for very common terms, but gives the true exhaustive count — required for absence/quantification claims). Snippets are re-rendered from the dump. Total of 0 with count 'exact' is a verified absence claim for this dump version.",
226
+ input_schema: {
227
+ properties: {
228
+ query: { type: "string" },
229
+ mode: { type: "string", enum: ["phrase", "query"] },
230
+ sections: { type: "array", items: { type: "string" } },
231
+ alias_set: { type: "string" },
232
+ category: { type: "string" },
233
+ depth: { type: "integer", minimum: 0, maximum: 5 },
234
+ limit: { type: "integer", minimum: 1, maximum: 100 },
235
+ offset: { type: "integer", minimum: 0 },
236
+ count: { type: "string", enum: ["capped", "exact"] }
237
+ },
238
+ required: ["query"]
239
+ }
240
+ ) do |query:, mode: "phrase", sections: nil, alias_set: nil, category: nil, depth: 0, limit: 20, offset: 0, count: "capped", server_context:|
241
+ respond do
242
+ corpus.search_text(query, mode: mode, sections: sections, alias_set: alias_set,
243
+ category: category, depth: depth, limit: limit, offset: offset, count: count)
244
+ end
245
+ end
246
+
247
+ server.define_tool(
248
+ name: "save_alias_set",
249
+ description: "Persist a named set of section-heading alias groups for this dump (e.g. [[\"あらすじ\",\"ストーリー\",\"物語\"]]). A server-side guardrail re-checks co-occurrence: a pair that appears in the same article more than 20% of the time (when both headings occur in at least 100 articles) is treated as distinct sections, not synonyms, and blocks the save with saved:false and the offending pairs listed — fix the groups, or pass force:true only if you verified them another way (e.g., by reading section contents with get_sections). The response echoes the saved groups and the exact criteria used. Saved sets are referenced via alias_set in find_articles / get_sections / extract_corpus and recorded in extraction metadata for reproducibility.",
250
+ input_schema: {
251
+ properties: {
252
+ name: { type: "string" },
253
+ groups: { type: "array", items: { type: "array", items: { type: "string" } } },
254
+ force: { type: "boolean" }
255
+ },
256
+ required: ["name", "groups"]
257
+ }
258
+ ) do |name:, groups:, force: false, server_context:|
259
+ respond { corpus.save_alias_set(name, groups, force: force) }
260
+ end
261
+
262
+ server.define_tool(
263
+ name: "get_alias_set",
264
+ description: "Retrieve a saved alias set by name (groups + creation time).",
265
+ input_schema: { properties: { name: { type: "string" } }, required: ["name"] }
266
+ ) do |name:, server_context:|
267
+ respond { corpus.get_alias_set(name) || { error: "alias set not found: #{name}" } }
268
+ end
269
+
270
+ server.define_tool(
271
+ name: "list_alias_sets",
272
+ description: "List all alias sets saved for this dump.",
273
+ input_schema: { properties: {}, required: [] }
274
+ ) do |server_context:|
275
+ respond { { alias_sets: corpus.list_alias_sets } }
276
+ end
277
+
278
+ EXTRACT_SCHEMA = {
279
+ properties: {
280
+ output_path: { type: "string", description: "JSONL output path; relative paths resolve under the server's output dir" },
281
+ content: { type: "string", enum: ["sections", "summary", "full", "wikitext"] },
282
+ sections: { type: "array", items: { type: "string" } },
283
+ alias_set: { type: "string" },
284
+ category: { type: "string" },
285
+ depth: { type: "integer", minimum: 0, maximum: 5 },
286
+ categories: { type: "array", items: { type: "string" } },
287
+ category_match: { type: "string" },
288
+ title_match: { type: "string" },
289
+ limit: { type: "integer", minimum: 0 },
290
+ chunk_size: { type: "integer", minimum: 100, description: "Split text into ~N-char chunks (one RAG-ready record per chunk)" },
291
+ chunk_overlap: { type: "integer", minimum: 0 },
292
+ overwrite: { type: "boolean", description: "Replace an existing output file (default: refuse)" }
293
+ },
294
+ required: ["output_path", "content"]
295
+ }.freeze
296
+
297
+ def extract_params(output_path:, content:, sections: nil, alias_set: nil, category: nil,
298
+ depth: 0, categories: nil, category_match: nil,
299
+ title_match: nil, limit: 0, chunk_size: nil, chunk_overlap: 0,
300
+ overwrite: false)
301
+ # Confine writes to the server's output directory (an agent mixing up paths
302
+ # must not be able to clobber arbitrary user files), and refuse to silently
303
+ # replace existing outputs
304
+ path = File.expand_path(output_path, OUTPUT_DIR)
305
+ base = File.expand_path(OUTPUT_DIR)
306
+ unless path == base || path.start_with?(base + File::SEPARATOR)
307
+ raise ArgumentError, "output_path must stay within the server output directory (#{base})"
308
+ end
309
+ if File.exist?(path) && !overwrite
310
+ raise ArgumentError, "output file already exists: #{path} (pass overwrite: true to replace it)"
311
+ end
312
+
313
+ { output_path: path, content: content,
314
+ sections: sections, alias_set: alias_set, category: category, depth: depth,
315
+ categories: categories, category_match: category_match,
316
+ title_match: title_match, limit: limit, chunk_size: chunk_size, chunk_overlap: chunk_overlap }
317
+ end
318
+
319
+ server.define_tool(
320
+ name: "extract_corpus",
321
+ description: "Extract matching articles to a JSONL file on disk (with a .meta.json sidecar recording dump version and query for reproducibility). Returns a summary and the first 3 records only — the full corpus stays on disk; process it with your own tools. content: 'sections' (named sections only), 'summary' (lead text), 'full' (whole cleaned article), or 'wikitext' (raw markup, for mining infoboxes/templates that cleaning replaces with markers). Filters are the same as find_articles (category/depth, categories, category_match, title_match). Optional chunk_size/chunk_overlap produce one RAG-ready record per chunk with section_path and chunk_index (not with wikitext). Synchronous, capped at 5000 articles (result flags truncation) — use start_extract_job for larger extractions. Dry-run the filters with find_articles first.",
322
+ input_schema: EXTRACT_SCHEMA
323
+ ) do |server_context:, **args|
324
+ respond { corpus.extract_corpus(**extract_params(**args)) }
325
+ end
326
+
327
+ server.define_tool(
328
+ name: "start_extract_job",
329
+ description: "Start extract_corpus as a background job (same parameters, no article cap). Returns job_id immediately; poll with job_status. Use for extractions larger than the 5000-article synchronous cap. Jobs do not survive server restart.",
330
+ input_schema: EXTRACT_SCHEMA
331
+ ) do |server_context:, **args|
332
+ respond { job_manager.start_extract(extract_params(**args)) }
333
+ end
334
+
335
+ server.define_tool(
336
+ name: "job_status",
337
+ description: "Status of a background extraction job: running/completed/cancelled/error, progress (0-1), and on completion the same summary extract_corpus returns (output path, counts, sample).",
338
+ input_schema: { properties: { job_id: { type: "string" } }, required: ["job_id"] }
339
+ ) do |job_id:, server_context:|
340
+ respond { job_manager.status(job_id) || { error: "unknown job: #{job_id}" } }
341
+ end
342
+
343
+ server.define_tool(
344
+ name: "cancel_job",
345
+ description: "Request cancellation of a running job (takes effect at the next batch boundary; the partial output file remains on disk).",
346
+ input_schema: { properties: { job_id: { type: "string" } }, required: ["job_id"] }
347
+ ) do |job_id:, server_context:|
348
+ respond { job_manager.cancel(job_id) || { error: "unknown job: #{job_id}" } }
349
+ end
350
+
351
+ server.define_tool(
352
+ name: "list_jobs",
353
+ description: "List all extraction jobs started in this server session.",
354
+ input_schema: { properties: {}, required: [] }
355
+ ) do |server_context:|
356
+ respond { { jobs: job_manager.list } }
357
+ end
358
+
359
+ server.define_prompt(
360
+ name: "discover_aliases",
361
+ description: "Recipe for building a verified section-alias set for a concept (e.g. plot sections of film articles) in this dump's language, using section_stats, section_cooccurrence, and content sampling.",
362
+ arguments: [
363
+ MCP::Prompt::Argument.new(name: "concept", description: "The section concept to cover (e.g. 'plot/story summary of a film')", required: true),
364
+ MCP::Prompt::Argument.new(name: "category", description: "Category scope (e.g. '映画作品')", required: false),
365
+ MCP::Prompt::Argument.new(name: "depth", description: "Category recursion depth (default 2)", required: false)
366
+ ]
367
+ ) do |args, server_context:|
368
+ concept = args[:concept] || args["concept"]
369
+ category = args[:category] || args["category"]
370
+ depth = args[:depth] || args["depth"] || 2
371
+ scope = category ? "category \"#{category}\" (depth #{depth})" : "the whole dump"
372
+ MCP::Prompt::Result.new(
373
+ description: "Alias discovery protocol for: #{concept}",
374
+ messages: [
375
+ MCP::Prompt::Message.new(
376
+ role: "user",
377
+ content: MCP::Content::Text.new(<<~TEXT)
378
+ Build a verified section-heading alias set for the concept "#{concept}" in #{scope}, using this protocol:
379
+
380
+ 1. DISCOVER: Call section_stats#{category ? " with category=\"#{category}\", depth=#{depth}" : ""} (top_n 50-100). From the real heading frequencies, shortlist headings that could denote "#{concept}" based on their meaning. Note each candidate's article count — it tells you how much coverage each adds.
381
+ 2. VERIFY: Call section_cooccurrence with the shortlisted headings (same scope). Interpret:
382
+ - ratio below ~0.05 between two FREQUENT headings: strong evidence they fill the same slot (valid aliases) — frequent headings would co-occur by chance if they were independent.
383
+ - ratio above ~0.15: they coexist in the same articles, so they are different section roles — do NOT group them.
384
+ - avg_position should also be similar for true aliases.
385
+ 3. SAMPLE (only for borderline ratios of 0.05-0.2 or rare headings where statistics are weak): pick 2-3 articles per heading via find_articles(sections: [heading], limit: 3) and read the content with get_sections. Judge whether the text under each heading is the same KIND of content.
386
+ 4. DECIDE: Groups must contain only verified same-role headings. For middling evidence, present the numbers to the user and ask.
387
+ 5. SAVE: Call save_alias_set with a descriptive versioned name (e.g. "#{category ? category.downcase.gsub(/\s+/, '-') : 'dump'}-#{concept.split.first(2).join('-').downcase}-v1"). The server re-checks co-occurrence and will refuse groups with high-ratio pairs unless you pass force:true with justification.
388
+ 6. REPORT: Tell the user which headings you included and excluded, with the co-occurrence numbers that justified each decision.
389
+ TEXT
390
+ )
391
+ ]
392
+ )
393
+ end
394
+
395
+ transport = MCP::Server::Transports::StdioTransport.new(server)
396
+ transport.open