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 +4 -4
- data/CHANGELOG.md +17 -0
- data/Gemfile +2 -0
- data/README.md +79 -1
- data/bin/wp2txt +8 -0
- data/bin/wp2txt-mcp +396 -0
- data/lib/wp2txt/cli.rb +67 -0
- data/lib/wp2txt/corpus.rb +722 -0
- data/lib/wp2txt/corpus_jobs.rb +106 -0
- data/lib/wp2txt/fts_index.rb +445 -0
- data/lib/wp2txt/index_cache.rb +17 -0
- data/lib/wp2txt/index_commands.rb +344 -0
- data/lib/wp2txt/metadata_index.rb +672 -0
- data/lib/wp2txt/multistream.rb +9 -5
- data/lib/wp2txt/version.rb +1 -1
- data/spec/corpus_spec.rb +503 -0
- data/spec/fts_index_spec.rb +245 -0
- data/spec/metadata_index_spec.rb +208 -0
- data/spec/support/multistream_fixture.rb +66 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e54ea4f824be0f060036923759b1c6755b20ee1e721245db58a23665b9efaab
|
|
4
|
+
data.tar.gz: ca0a5b1ae0efae6eb4c485a6319569ea9995c662054e7e697a70429632613a25
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|