wp2txt 2.1.2 → 2.3.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/.gitignore +3 -0
- data/CHANGELOG.md +27 -0
- data/Dockerfile +3 -0
- data/Gemfile +2 -0
- data/README.md +60 -2
- data/Rakefile +4 -1
- data/bin/wp2txt +9 -0
- data/bin/wp2txt-mcp +395 -0
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +110 -0
- data/lib/wp2txt/corpus.rb +1057 -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 +427 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +738 -0
- data/lib/wp2txt/multistream.rb +38 -5
- data/lib/wp2txt/output_path.rb +27 -0
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/corpus_spec.rb +503 -0
- data/spec/fts_index_spec.rb +245 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/metadata_index_spec.rb +208 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/support/multistream_fixture.rb +66 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +27 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 076aa3a7681c0e0e6e13948e5c66f6f9e15a0307507a944a78f29c0e7856eea0
|
|
4
|
+
data.tar.gz: f8f2d3a4efc82d1922f9ca2764b23bb348aa924e752439805ab61683ff66a737
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae377878c20dde5a376d96efec19f84a74a20e66dd8f45ff5d41c438f519ced7cf5d4e6a6102ceee5c4b59333f0b1ca42167cf5e178abf803de868855b4efa6c
|
|
7
|
+
data.tar.gz: c23cb4598a7afeda092d75688be12e9e777874a4b9da289aeb1d97123b5cc64b3b56337722290fcc4352fde9031a856d4ba81393055ef8596fe8818d51e258ac
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.3.0] - 2026-07-24
|
|
9
|
+
|
|
10
|
+
- **Documentation split**: README now focuses on text extraction; the research layer (indexes, exhaustive queries, full-text search, langlinks, cross-language SQL, MCP server) is documented in the new [Research Infrastructure Guide](docs/RESEARCH.md), including the complete MCP tool table
|
|
11
|
+
- **Container images on GHCR**: images are now published to `ghcr.io/yohasebe/wp2txt` (Docker Hub `yohasebe/wp2txt` is maintained as a mirror)
|
|
12
|
+
|
|
13
|
+
- **`extract_corpus` `titles:` argument**: Extract an explicit set of article titles (e.g. a set determined via `query_sql`) — titles are normalized (MediaWiki rules), deduplicated preserving input order, and one redirect hop is resolved; missing titles (including redirects to nowhere) are skipped and reported as `not_found` (count + 20-title sample, also in the `.meta.json` sidecar). Mutually exclusive with the filter arguments (set operations belong in SQL); capped at 10,000 titles. The sidecar records `titles_count` + `titles_sha256` (order-independent) for reproducibility, enumerating the full list when ≤100 titles. Also available via `start_extract_job`
|
|
14
|
+
- **`query_sql` `output_path:` argument**: Write ALL rows of a large result to a JSONL file (with a `.meta.json` sidecar recording the SQL, dump version, attach configuration, row count, and tool version) and return only a summary + 3-row sample — the extract_corpus D4 pattern generalized to SQL. Writes happen in the forked child (so the 30s SIGKILL deadline covers them) to a `.partial` file that the parent atomically renames on success and removes on every failure path (child crash, timeout kill, query error). Hard cap `SQL_FILE_ROW_LIMIT` = 5M rows (`truncated` flag), cells clipped at 64KB (`cells_clipped` count), duplicate column names are made unique (`_2` suffix), `limit` is ignored in this mode, and existing files require `overwrite: true`. The MCP layer's output-dir confinement is now a shared helper (`Wp2txt::OutputPath.confine`) used by extract_corpus, start_extract_job, and query_sql alike
|
|
15
|
+
- **Interlanguage links (`--import-langlinks`)**: Import the official `{lang}wiki-{date}-langlinks.sql.gz` dump into the Tier 1 metadata index as a `langlinks` table (`ll_from` = source page_id, `ll_lang` = target language, `ll_title` = normalized target title). Version pinning is enforced: the langlinks file's dump name must equal the built index's dump version — a mismatch is rejected with no override. Streams the MySQL dump without loading it whole (escape-safe tuple parser, 10k-row transaction batches, indexes created after the load). `--langlinks-langs` restricts imported target languages (e.g. `en,de,fr,zh,ko`); re-import requires `-U` (otherwise a no-op reporting the previous import time). Provenance (source file, size, import time, wp2txt version, language filter, row count) is stamped into the index and reported by `dump_info`; a post-import sanity check samples titles per language and warns when the join rate against a locally installed target edition falls below 90%
|
|
16
|
+
- **Multi-dump ATTACH in `query_sql`**: The MCP `query_sql` tool gains an optional `attach` argument (language codes only, never paths): `attach: ["en"]` read-only ATTACHes that language's locally installed metadata DB as `en_meta` and its FTS DB (when built) as `en_fts`, both sharing the main DB's schema. Codes are validated, only installed indexes are accepted, path resolution is server-side (same-date dump preferred; otherwise the latest build with a `dump_mismatch` note in the response's `attached` metadata). User SQL still cannot contain ATTACH/DETACH — attachments are issued by server code only, via `mode=ro` URIs on a read-only connection. Combined with the langlinks table, this enables single-query cross-language comparisons (e.g. section-structure diffs of article pairs)
|
|
17
|
+
|
|
18
|
+
## [2.2.0] - 2026-07-22
|
|
19
|
+
|
|
20
|
+
- **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)
|
|
21
|
+
- **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
|
|
22
|
+
- **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`
|
|
23
|
+
- **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`
|
|
24
|
+
- **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
|
|
25
|
+
- **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)
|
|
26
|
+
- **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
|
|
27
|
+
- **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
|
|
28
|
+
- **`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)
|
|
29
|
+
- **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
|
|
30
|
+
- **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
|
|
31
|
+
- **`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
|
|
32
|
+
- **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
|
|
33
|
+
- **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
|
|
34
|
+
|
|
8
35
|
## [2.1.2] - 2026-07-19
|
|
9
36
|
|
|
10
37
|
- **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/Dockerfile
CHANGED
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 research indexes** - Exhaustive, version-pinned queries over categories, section headings, and full text; interlanguage links for cross-edition SQL
|
|
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,30 @@ 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
|
-
docker run -it -v /path/to/localdata:/data yohasebe/wp2txt
|
|
82
|
+
docker run -it -v /path/to/localdata:/data ghcr.io/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
|
+
Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`); Docker Hub (`yohasebe/wp2txt`) is maintained as a mirror.
|
|
88
|
+
|
|
89
|
+
**MCP server (no Ruby required on the host):**
|
|
90
|
+
|
|
91
|
+
```shell
|
|
92
|
+
# Build the indexes once (cached in a named volume so they persist)
|
|
93
|
+
docker run -it -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt --build-index --fulltext -L ja
|
|
94
|
+
|
|
95
|
+
# Register with an MCP client, e.g. Claude Code
|
|
96
|
+
claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt-mcp -L ja
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
79
101
|
## Basic Usage
|
|
80
102
|
|
|
81
103
|
### Auto-download and process (Recommended)
|
|
@@ -233,6 +255,29 @@ By default, citation templates are removed. Use `--extract-citations` to extract
|
|
|
233
255
|
|
|
234
256
|
Supported: `{{cite book}}`, `{{cite web}}`, `{{cite news}}`, `{{cite journal}}`, `{{Citation}}`, etc.
|
|
235
257
|
|
|
258
|
+
## Research Infrastructure (Indexes, Exhaustive Queries, MCP)
|
|
259
|
+
|
|
260
|
+
Beyond text extraction, wp2txt can turn a dump into a **local, version-pinned research
|
|
261
|
+
database**: SQLite indexes over categories, section headings, redirects, and (optionally)
|
|
262
|
+
the full article text, plus interlanguage links for cross-edition comparison — all
|
|
263
|
+
queryable offline, exhaustively, and exposed to LLM agents via an MCP server.
|
|
264
|
+
|
|
265
|
+
```console
|
|
266
|
+
$ wp2txt --build-index --fulltext --lang=ja # build the indexes
|
|
267
|
+
$ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
|
|
268
|
+
$ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
|
|
269
|
+
$ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
|
|
270
|
+
$ wp2txt-mcp --lang=ja # stdio MCP server for LLM agents
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Unlike web/API access, these queries scan every article (a `0 matches` result is a
|
|
274
|
+
verifiable absence claim for that dump version) and are reproducible: extractions record
|
|
275
|
+
the dump version and query in a `.meta.json` sidecar.
|
|
276
|
+
|
|
277
|
+
**→ See the [Research Infrastructure Guide](docs/RESEARCH.md)** for index building,
|
|
278
|
+
exhaustive queries, full-text search, interlanguage links, cross-language SQL, the full
|
|
279
|
+
MCP tool list, and design principles.
|
|
280
|
+
|
|
236
281
|
## Command Line Options
|
|
237
282
|
|
|
238
283
|
Usage: wp2txt [options]
|
|
@@ -306,6 +351,15 @@ Supported: `{{cite book}}`, `{{cite web}}`, `{{cite news}}`, `{{cite journal}}`,
|
|
|
306
351
|
-v, --version Print version
|
|
307
352
|
-h, --help Show help
|
|
308
353
|
|
|
354
|
+
### Research infrastructure options
|
|
355
|
+
|
|
356
|
+
--build-index Build the metadata index (add --fulltext for FTS)
|
|
357
|
+
--find-articles / --search Exhaustive offline queries (see docs/RESEARCH.md)
|
|
358
|
+
--import-langlinks Import interlanguage links (version-matched)
|
|
359
|
+
--fts-optimize Optimize an existing full-text index
|
|
360
|
+
|
|
361
|
+
See the [Research Infrastructure Guide](docs/RESEARCH.md) for details.
|
|
362
|
+
|
|
309
363
|
## Configuration File
|
|
310
364
|
|
|
311
365
|
Create persistent settings with:
|
|
@@ -347,6 +401,10 @@ Turbo mode (default) splits bz2 into XML chunks first, then processes in paralle
|
|
|
347
401
|
|
|
348
402
|
See [CHANGELOG.md](CHANGELOG.md) for detailed release notes.
|
|
349
403
|
|
|
404
|
+
**v2.3.0 (July 2026)**: Interlanguage links import, cross-language SQL (multi-dump ATTACH), explicit-title extraction, SQL results to file with reproducibility sidecars, GHCR image publishing.
|
|
405
|
+
|
|
406
|
+
**v2.2.0 (July 2026)**: Offline metadata index, FTS5 full-text search, MCP server, query_sql escape hatch, extraction jobs.
|
|
407
|
+
|
|
350
408
|
**v2.1.0 (February 2026)**: SQLite caching, Ractor parallelism (Ruby 4.0+), template expansion, content markers, Docker image update.
|
|
351
409
|
|
|
352
410
|
**v2.0.0 (January 2026)**: Auto-download mode, category-based extraction, article extraction by title, JSON output, streaming processing, Ruby 4.0 support.
|
data/Rakefile
CHANGED
|
@@ -39,7 +39,10 @@ task :push do
|
|
|
39
39
|
# docker buildx create --name mybuilder
|
|
40
40
|
# docker buildx use mybuilder
|
|
41
41
|
# docker buildx inspect --bootstrap
|
|
42
|
-
docker buildx build --platform linux/amd64,linux/arm64
|
|
42
|
+
docker buildx build --platform linux/amd64,linux/arm64 \
|
|
43
|
+
-t ghcr.io/yohasebe/wp2txt:#{Wp2txt::VERSION} -t ghcr.io/yohasebe/wp2txt:latest \
|
|
44
|
+
-t yohasebe/wp2txt:#{Wp2txt::VERSION} -t yohasebe/wp2txt:latest \
|
|
45
|
+
. --push
|
|
43
46
|
BASH
|
|
44
47
|
SCRIPT
|
|
45
48
|
end
|
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,13 @@ 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
|
+
return run_import_langlinks(opts) if opts[:import_langlinks]
|
|
715
|
+
|
|
707
716
|
# Determine input source
|
|
708
717
|
if opts[:from_category] && opts[:lang]
|
|
709
718
|
# Category extraction mode
|
data/bin/wp2txt-mcp
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
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/output_path"
|
|
28
|
+
require_relative "../lib/wp2txt/version"
|
|
29
|
+
|
|
30
|
+
opts = Optimist.options do
|
|
31
|
+
version Wp2txt::VERSION
|
|
32
|
+
banner "wp2txt-mcp: MCP server for offline Wikipedia dump queries\n\nOptions:"
|
|
33
|
+
opt :lang, "Wikipedia language code of a cached dump (e.g., ja, en)", type: String, short: "-L"
|
|
34
|
+
opt :input, "Path to a multistream .xml.bz2 file", type: String, short: "-i"
|
|
35
|
+
opt :cache_dir, "Cache directory", type: String, default: File.expand_path("~/.wp2txt/cache")
|
|
36
|
+
opt :output_dir, "Default directory for extract_corpus output files", type: String, default: Dir.pwd
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Optimist.die "Either --lang or --input is required" if opts[:lang].nil? && opts[:input].nil?
|
|
40
|
+
Optimist.die "Cannot specify both --lang and --input" if opts[:lang] && opts[:input]
|
|
41
|
+
|
|
42
|
+
corpus_factory = lambda do
|
|
43
|
+
if opts[:lang]
|
|
44
|
+
Wp2txt::Corpus.for_lang(opts[:lang], cache_dir: opts[:cache_dir])
|
|
45
|
+
else
|
|
46
|
+
Wp2txt::Corpus.for_input(File.expand_path(opts[:input]), cache_dir: opts[:cache_dir])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
corpus = corpus_factory.call
|
|
52
|
+
rescue ArgumentError => e
|
|
53
|
+
abort e.message
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
job_manager = Wp2txt::CorpusJobManager.new(corpus_factory)
|
|
57
|
+
|
|
58
|
+
unless corpus.metadata_built?
|
|
59
|
+
abort "Metadata index not built for this dump.\n" \
|
|
60
|
+
"Build it first with: wp2txt --build-index #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
info = corpus.dump_info
|
|
64
|
+
warn "wp2txt-mcp #{Wp2txt::VERSION} | dump: #{info[:dump]} | articles: #{info[:stats]&.dig(:article_count)}"
|
|
65
|
+
warn "NOTE: metadata index is stale (dump file changed); consider re-running --build-index" unless info[:metadata_current]
|
|
66
|
+
|
|
67
|
+
OUTPUT_DIR = opts[:output_dir]
|
|
68
|
+
|
|
69
|
+
server = MCP::Server.new(name: "wp2txt", version: Wp2txt::VERSION)
|
|
70
|
+
|
|
71
|
+
# Wrap a tool body: render result as JSON text, convert errors to tool errors
|
|
72
|
+
def respond(&block)
|
|
73
|
+
result = block.call
|
|
74
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(result) }])
|
|
75
|
+
rescue ArgumentError => e
|
|
76
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate({ error: e.message }) }], error: true)
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate({ error: "#{e.class}: #{e.message}" }) }], error: true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
server.define_tool(
|
|
82
|
+
name: "dump_info",
|
|
83
|
+
description: "Dump identity (language/date), available index tiers, and article/category/section counts. Call first to learn what this corpus contains.",
|
|
84
|
+
input_schema: { properties: {}, required: [] }
|
|
85
|
+
) do |server_context:|
|
|
86
|
+
respond { corpus.dump_info }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
server.define_tool(
|
|
90
|
+
name: "get_article",
|
|
91
|
+
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.",
|
|
92
|
+
input_schema: {
|
|
93
|
+
properties: {
|
|
94
|
+
title: { type: "string" },
|
|
95
|
+
format: { type: "string", enum: ["text", "wikitext"] },
|
|
96
|
+
max_chars: { type: "integer", minimum: 1000 }
|
|
97
|
+
},
|
|
98
|
+
required: ["title"]
|
|
99
|
+
}
|
|
100
|
+
) do |title:, format: "text", max_chars: 40_000, server_context:|
|
|
101
|
+
respond { corpus.get_article(title, format: format, max_chars: max_chars) || { error: "article not found: #{title}" } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
server.define_tool(
|
|
105
|
+
name: "get_categories",
|
|
106
|
+
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.",
|
|
107
|
+
input_schema: { properties: { title: { type: "string" } }, required: ["title"] }
|
|
108
|
+
) do |title:, server_context:|
|
|
109
|
+
respond { corpus.get_categories(title) || { error: "article not found: #{title}" } }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
server.define_tool(
|
|
113
|
+
name: "get_sections",
|
|
114
|
+
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.",
|
|
115
|
+
input_schema: {
|
|
116
|
+
properties: {
|
|
117
|
+
title: { type: "string" },
|
|
118
|
+
sections: { type: "array", items: { type: "string" } },
|
|
119
|
+
alias_set: { type: "string" }
|
|
120
|
+
},
|
|
121
|
+
required: ["title", "sections"]
|
|
122
|
+
}
|
|
123
|
+
) do |title:, sections:, alias_set: nil, server_context:|
|
|
124
|
+
respond { corpus.get_sections(title, sections, alias_set: alias_set) || { error: "article not found: #{title}" } }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
server.define_tool(
|
|
128
|
+
name: "list_headings",
|
|
129
|
+
description: "List the section heading tree (name + level) of one article. Useful before get_sections to see what headings exist.",
|
|
130
|
+
input_schema: { properties: { title: { type: "string" } }, required: ["title"] }
|
|
131
|
+
) do |title:, server_context:|
|
|
132
|
+
respond { corpus.list_headings(title) || { error: "article not found: #{title}" } }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
server.define_tool(
|
|
136
|
+
name: "find_articles",
|
|
137
|
+
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.",
|
|
138
|
+
input_schema: {
|
|
139
|
+
properties: {
|
|
140
|
+
category: { type: "string" },
|
|
141
|
+
depth: { type: "integer", minimum: 0, maximum: 5 },
|
|
142
|
+
categories: { type: "array", items: { type: "string" } },
|
|
143
|
+
category_match: { type: "string" },
|
|
144
|
+
sections: { type: "array", items: { type: "string" } },
|
|
145
|
+
alias_set: { type: "string" },
|
|
146
|
+
title_match: { type: "string" },
|
|
147
|
+
limit: { type: "integer", minimum: 0 },
|
|
148
|
+
offset: { type: "integer", minimum: 0 }
|
|
149
|
+
},
|
|
150
|
+
required: []
|
|
151
|
+
}
|
|
152
|
+
) do |category: nil, depth: 0, categories: nil, category_match: nil, sections: nil, alias_set: nil, title_match: nil, limit: 100, offset: 0, server_context:|
|
|
153
|
+
respond do
|
|
154
|
+
corpus.find_articles(category: category, depth: depth, categories: categories,
|
|
155
|
+
category_match: category_match, sections: sections,
|
|
156
|
+
alias_set: alias_set, title_match: title_match,
|
|
157
|
+
limit: limit, offset: offset)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
server.define_tool(
|
|
162
|
+
name: "query_sql",
|
|
163
|
+
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; a langlinks table (ll_from = source page_id, ll_lang, ll_title) is also present when interlanguage links were imported via wp2txt --import-langlinks. When the fulltext tier exists, the FTS DB is attached as `fts` (fts.fts_sections MATCH ..., fts.fts_map). Cross-language: pass attach: [\"en\",\"de\"] to read-only ATTACH other locally installed languages' indexes, referenced as {lang}_meta.pages / {lang}_meta.page_sections / ... and {lang}_fts when that language has a fulltext tier — attached DBs share the main DB's schema. Hyphenated language codes map to underscored aliases (e.g. zh-yue → zh_yue_meta). Only installed languages are accepted (the main language itself cannot be attached); the response's `attached` field lists what was attached (dump_name, fts, dump_mismatch when the dates differ). Join langlinks (ll_from = page_id) to map articles across languages. 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. output_path: write ALL rows to a JSONL file (with a .meta.json recording the SQL, dump versions, and attach configuration) and return only a summary + 3-row sample — use this whenever the result is large; `limit` is ignored in this mode. 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.",
|
|
164
|
+
input_schema: {
|
|
165
|
+
properties: {
|
|
166
|
+
sql: { type: "string" },
|
|
167
|
+
limit: { type: "integer", minimum: 1, maximum: 1000 },
|
|
168
|
+
attach: { type: "array", items: { type: "string" },
|
|
169
|
+
description: "Language codes of other locally installed dumps to ATTACH read-only as {lang}_meta / {lang}_fts (default: none)" },
|
|
170
|
+
output_path: { type: "string", description: "JSONL output path for ALL rows; relative paths resolve under the server's output dir" },
|
|
171
|
+
overwrite: { type: "boolean", description: "Replace an existing output file (default: refuse)" }
|
|
172
|
+
},
|
|
173
|
+
required: ["sql"]
|
|
174
|
+
}
|
|
175
|
+
) do |sql:, limit: 200, attach: [], output_path: nil, overwrite: false, server_context:|
|
|
176
|
+
respond do
|
|
177
|
+
path = output_path && Wp2txt::OutputPath.confine(output_path, OUTPUT_DIR, overwrite: overwrite)
|
|
178
|
+
corpus.query_sql(sql, limit: limit, attach: attach, output_path: path, overwrite: overwrite)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
server.define_tool(
|
|
183
|
+
name: "describe_schema",
|
|
184
|
+
description: "CREATE statements of all tables available to query_sql (metadata DB, plus the attached fts DB when built).",
|
|
185
|
+
input_schema: { properties: {}, required: [] }
|
|
186
|
+
) do |server_context:|
|
|
187
|
+
respond { corpus.describe_schema }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
server.define_tool(
|
|
191
|
+
name: "category_tree",
|
|
192
|
+
description: "Subcategory tree under a category (names + depth). Use to understand scope before recursive find_articles.",
|
|
193
|
+
input_schema: {
|
|
194
|
+
properties: { category: { type: "string" }, depth: { type: "integer", minimum: 1, maximum: 5 } },
|
|
195
|
+
required: ["category"]
|
|
196
|
+
}
|
|
197
|
+
) do |category:, depth: 2, server_context:|
|
|
198
|
+
respond { corpus.category_tree(category, depth: depth) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
server.define_tool(
|
|
202
|
+
name: "section_stats",
|
|
203
|
+
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.",
|
|
204
|
+
input_schema: {
|
|
205
|
+
properties: {
|
|
206
|
+
category: { type: "string" },
|
|
207
|
+
depth: { type: "integer", minimum: 0, maximum: 5 },
|
|
208
|
+
top_n: { type: "integer", minimum: 1, maximum: 500 }
|
|
209
|
+
},
|
|
210
|
+
required: []
|
|
211
|
+
}
|
|
212
|
+
) do |category: nil, depth: 0, top_n: 50, server_context:|
|
|
213
|
+
respond { corpus.section_stats(category: category, depth: depth, top_n: top_n) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
server.define_tool(
|
|
217
|
+
name: "section_cooccurrence",
|
|
218
|
+
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.",
|
|
219
|
+
input_schema: {
|
|
220
|
+
properties: {
|
|
221
|
+
headings: { type: "array", items: { type: "string" }, minItems: 2 },
|
|
222
|
+
category: { type: "string" },
|
|
223
|
+
depth: { type: "integer", minimum: 0, maximum: 5 }
|
|
224
|
+
},
|
|
225
|
+
required: ["headings"]
|
|
226
|
+
}
|
|
227
|
+
) do |headings:, category: nil, depth: 0, server_context:|
|
|
228
|
+
respond { corpus.section_cooccurrence(headings, category: category, depth: depth) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
server.define_tool(
|
|
232
|
+
name: "search_text",
|
|
233
|
+
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.",
|
|
234
|
+
input_schema: {
|
|
235
|
+
properties: {
|
|
236
|
+
query: { type: "string" },
|
|
237
|
+
mode: { type: "string", enum: ["phrase", "query"] },
|
|
238
|
+
sections: { type: "array", items: { type: "string" } },
|
|
239
|
+
alias_set: { type: "string" },
|
|
240
|
+
category: { type: "string" },
|
|
241
|
+
depth: { type: "integer", minimum: 0, maximum: 5 },
|
|
242
|
+
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
243
|
+
offset: { type: "integer", minimum: 0 },
|
|
244
|
+
count: { type: "string", enum: ["capped", "exact"] }
|
|
245
|
+
},
|
|
246
|
+
required: ["query"]
|
|
247
|
+
}
|
|
248
|
+
) do |query:, mode: "phrase", sections: nil, alias_set: nil, category: nil, depth: 0, limit: 20, offset: 0, count: "capped", server_context:|
|
|
249
|
+
respond do
|
|
250
|
+
corpus.search_text(query, mode: mode, sections: sections, alias_set: alias_set,
|
|
251
|
+
category: category, depth: depth, limit: limit, offset: offset, count: count)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
server.define_tool(
|
|
256
|
+
name: "save_alias_set",
|
|
257
|
+
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.",
|
|
258
|
+
input_schema: {
|
|
259
|
+
properties: {
|
|
260
|
+
name: { type: "string" },
|
|
261
|
+
groups: { type: "array", items: { type: "array", items: { type: "string" } } },
|
|
262
|
+
force: { type: "boolean" }
|
|
263
|
+
},
|
|
264
|
+
required: ["name", "groups"]
|
|
265
|
+
}
|
|
266
|
+
) do |name:, groups:, force: false, server_context:|
|
|
267
|
+
respond { corpus.save_alias_set(name, groups, force: force) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
server.define_tool(
|
|
271
|
+
name: "get_alias_set",
|
|
272
|
+
description: "Retrieve a saved alias set by name (groups + creation time).",
|
|
273
|
+
input_schema: { properties: { name: { type: "string" } }, required: ["name"] }
|
|
274
|
+
) do |name:, server_context:|
|
|
275
|
+
respond { corpus.get_alias_set(name) || { error: "alias set not found: #{name}" } }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
server.define_tool(
|
|
279
|
+
name: "list_alias_sets",
|
|
280
|
+
description: "List all alias sets saved for this dump.",
|
|
281
|
+
input_schema: { properties: {}, required: [] }
|
|
282
|
+
) do |server_context:|
|
|
283
|
+
respond { { alias_sets: corpus.list_alias_sets } }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
EXTRACT_SCHEMA = {
|
|
287
|
+
properties: {
|
|
288
|
+
output_path: { type: "string", description: "JSONL output path; relative paths resolve under the server's output dir" },
|
|
289
|
+
content: { type: "string", enum: ["sections", "summary", "full", "wikitext"] },
|
|
290
|
+
titles: { type: "array", items: { type: "string" }, maxItems: 10_000,
|
|
291
|
+
description: "Explicit article titles to extract (mutually exclusive with the filter arguments)" },
|
|
292
|
+
sections: { type: "array", items: { type: "string" } },
|
|
293
|
+
alias_set: { type: "string" },
|
|
294
|
+
category: { type: "string" },
|
|
295
|
+
depth: { type: "integer", minimum: 0, maximum: 5 },
|
|
296
|
+
categories: { type: "array", items: { type: "string" } },
|
|
297
|
+
category_match: { type: "string" },
|
|
298
|
+
title_match: { type: "string" },
|
|
299
|
+
limit: { type: "integer", minimum: 0 },
|
|
300
|
+
chunk_size: { type: "integer", minimum: 100, description: "Split text into ~N-char chunks (one RAG-ready record per chunk)" },
|
|
301
|
+
chunk_overlap: { type: "integer", minimum: 0 },
|
|
302
|
+
overwrite: { type: "boolean", description: "Replace an existing output file (default: refuse)" }
|
|
303
|
+
},
|
|
304
|
+
required: ["output_path", "content"]
|
|
305
|
+
}.freeze
|
|
306
|
+
|
|
307
|
+
def extract_params(output_path:, content:, titles: nil, sections: nil, alias_set: nil, category: nil,
|
|
308
|
+
depth: 0, categories: nil, category_match: nil,
|
|
309
|
+
title_match: nil, limit: 0, chunk_size: nil, chunk_overlap: 0,
|
|
310
|
+
overwrite: false)
|
|
311
|
+
{ output_path: Wp2txt::OutputPath.confine(output_path, OUTPUT_DIR, overwrite: overwrite),
|
|
312
|
+
content: content, titles: titles,
|
|
313
|
+
sections: sections, alias_set: alias_set, category: category, depth: depth,
|
|
314
|
+
categories: categories, category_match: category_match,
|
|
315
|
+
title_match: title_match, limit: limit, chunk_size: chunk_size, chunk_overlap: chunk_overlap }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
server.define_tool(
|
|
319
|
+
name: "extract_corpus",
|
|
320
|
+
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). titles: extract an explicit list of articles (e.g. a set determined via query_sql); mutually exclusive with the filter arguments; redirects are resolved; missing titles are reported in not_found. 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.",
|
|
321
|
+
input_schema: EXTRACT_SCHEMA
|
|
322
|
+
) do |server_context:, **args|
|
|
323
|
+
respond { corpus.extract_corpus(**extract_params(**args)) }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
server.define_tool(
|
|
327
|
+
name: "start_extract_job",
|
|
328
|
+
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.",
|
|
329
|
+
input_schema: EXTRACT_SCHEMA
|
|
330
|
+
) do |server_context:, **args|
|
|
331
|
+
respond { job_manager.start_extract(extract_params(**args)) }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
server.define_tool(
|
|
335
|
+
name: "job_status",
|
|
336
|
+
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).",
|
|
337
|
+
input_schema: { properties: { job_id: { type: "string" } }, required: ["job_id"] }
|
|
338
|
+
) do |job_id:, server_context:|
|
|
339
|
+
respond { job_manager.status(job_id) || { error: "unknown job: #{job_id}" } }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
server.define_tool(
|
|
343
|
+
name: "cancel_job",
|
|
344
|
+
description: "Request cancellation of a running job (takes effect at the next batch boundary; the partial output file remains on disk).",
|
|
345
|
+
input_schema: { properties: { job_id: { type: "string" } }, required: ["job_id"] }
|
|
346
|
+
) do |job_id:, server_context:|
|
|
347
|
+
respond { job_manager.cancel(job_id) || { error: "unknown job: #{job_id}" } }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
server.define_tool(
|
|
351
|
+
name: "list_jobs",
|
|
352
|
+
description: "List all extraction jobs started in this server session.",
|
|
353
|
+
input_schema: { properties: {}, required: [] }
|
|
354
|
+
) do |server_context:|
|
|
355
|
+
respond { { jobs: job_manager.list } }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
server.define_prompt(
|
|
359
|
+
name: "discover_aliases",
|
|
360
|
+
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.",
|
|
361
|
+
arguments: [
|
|
362
|
+
MCP::Prompt::Argument.new(name: "concept", description: "The section concept to cover (e.g. 'plot/story summary of a film')", required: true),
|
|
363
|
+
MCP::Prompt::Argument.new(name: "category", description: "Category scope (e.g. '映画作品')", required: false),
|
|
364
|
+
MCP::Prompt::Argument.new(name: "depth", description: "Category recursion depth (default 2)", required: false)
|
|
365
|
+
]
|
|
366
|
+
) do |args, server_context:|
|
|
367
|
+
concept = args[:concept] || args["concept"]
|
|
368
|
+
category = args[:category] || args["category"]
|
|
369
|
+
depth = args[:depth] || args["depth"] || 2
|
|
370
|
+
scope = category ? "category \"#{category}\" (depth #{depth})" : "the whole dump"
|
|
371
|
+
MCP::Prompt::Result.new(
|
|
372
|
+
description: "Alias discovery protocol for: #{concept}",
|
|
373
|
+
messages: [
|
|
374
|
+
MCP::Prompt::Message.new(
|
|
375
|
+
role: "user",
|
|
376
|
+
content: MCP::Content::Text.new(<<~TEXT)
|
|
377
|
+
Build a verified section-heading alias set for the concept "#{concept}" in #{scope}, using this protocol:
|
|
378
|
+
|
|
379
|
+
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.
|
|
380
|
+
2. VERIFY: Call section_cooccurrence with the shortlisted headings (same scope). Interpret:
|
|
381
|
+
- 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.
|
|
382
|
+
- ratio above ~0.15: they coexist in the same articles, so they are different section roles — do NOT group them.
|
|
383
|
+
- avg_position should also be similar for true aliases.
|
|
384
|
+
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.
|
|
385
|
+
4. DECIDE: Groups must contain only verified same-role headings. For middling evidence, present the numbers to the user and ask.
|
|
386
|
+
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.
|
|
387
|
+
6. REPORT: Tell the user which headings you included and excluded, with the co-occurrence numbers that justified each decision.
|
|
388
|
+
TEXT
|
|
389
|
+
)
|
|
390
|
+
]
|
|
391
|
+
)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
395
|
+
transport.open
|