wp2txt 2.2.0 → 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 +10 -0
- data/Dockerfile +3 -0
- data/README.md +36 -56
- data/Rakefile +4 -1
- data/bin/wp2txt +1 -0
- data/bin/wp2txt-mcp +18 -19
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +43 -0
- data/lib/wp2txt/corpus.rb +358 -23
- data/lib/wp2txt/index_commands.rb +83 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +68 -2
- data/lib/wp2txt/multistream.rb +29 -0
- 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/langlinks_importer_spec.rb +308 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +12 -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,16 @@ 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
|
+
|
|
8
18
|
## [2.2.0] - 2026-07-22
|
|
9
19
|
|
|
10
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)
|
data/Dockerfile
CHANGED
data/README.md
CHANGED
|
@@ -34,7 +34,7 @@ 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
|
|
37
|
+
- **Offline research indexes** - Exhaustive, version-pinned queries over categories, section headings, and full text; interlanguage links for cross-edition SQL
|
|
38
38
|
- **MCP server** - Expose a local dump to LLM agents (Claude, ChatGPT, Gemini, local models) for grounded, reproducible corpus work
|
|
39
39
|
|
|
40
40
|
## Use Cases
|
|
@@ -79,19 +79,21 @@ The Docker image bundles everything — Ruby, decompression tools, and the MCP s
|
|
|
79
79
|
**Interactive / batch CLI:**
|
|
80
80
|
|
|
81
81
|
```shell
|
|
82
|
-
docker run -it -v /path/to/localdata:/data yohasebe/wp2txt
|
|
82
|
+
docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
The `wp2txt` command is available inside the container. Use `/data` for input/output files.
|
|
86
86
|
|
|
87
|
+
Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`); Docker Hub (`yohasebe/wp2txt`) is maintained as a mirror.
|
|
88
|
+
|
|
87
89
|
**MCP server (no Ruby required on the host):**
|
|
88
90
|
|
|
89
91
|
```shell
|
|
90
92
|
# 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
|
|
93
|
+
docker run -it -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt --build-index --fulltext -L ja
|
|
92
94
|
|
|
93
95
|
# 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
|
|
96
|
+
claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt-mcp -L ja
|
|
95
97
|
```
|
|
96
98
|
|
|
97
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.
|
|
@@ -253,63 +255,28 @@ By default, citation templates are removed. Use `--extract-citations` to extract
|
|
|
253
255
|
|
|
254
256
|
Supported: `{{cite book}}`, `{{cite web}}`, `{{cite news}}`, `{{cite journal}}`, `{{Citation}}`, etc.
|
|
255
257
|
|
|
256
|
-
##
|
|
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.
|
|
258
|
+
## Research Infrastructure (Indexes, Exhaustive Queries, MCP)
|
|
280
259
|
|
|
281
|
-
|
|
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.
|
|
282
264
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
}
|
|
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
|
|
297
271
|
```
|
|
298
272
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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 |
|
|
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.
|
|
311
276
|
|
|
312
|
-
|
|
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.
|
|
313
280
|
|
|
314
281
|
## Command Line Options
|
|
315
282
|
|
|
@@ -384,6 +351,15 @@ Design principles: large results go to disk (the model receives a summary plus a
|
|
|
384
351
|
-v, --version Print version
|
|
385
352
|
-h, --help Show help
|
|
386
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
|
+
|
|
387
363
|
## Configuration File
|
|
388
364
|
|
|
389
365
|
Create persistent settings with:
|
|
@@ -425,6 +401,10 @@ Turbo mode (default) splits bz2 into XML chunks first, then processes in paralle
|
|
|
425
401
|
|
|
426
402
|
See [CHANGELOG.md](CHANGELOG.md) for detailed release notes.
|
|
427
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
|
+
|
|
428
408
|
**v2.1.0 (February 2026)**: SQLite caching, Ractor parallelism (Ruby 4.0+), template expansion, content markers, Docker image update.
|
|
429
409
|
|
|
430
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
|
@@ -711,6 +711,7 @@ class WpApp
|
|
|
711
711
|
return run_find_articles(opts) if opts[:find_articles]
|
|
712
712
|
return run_search(opts) if opts[:search]
|
|
713
713
|
return run_fts_optimize(opts) if opts[:fts_optimize]
|
|
714
|
+
return run_import_langlinks(opts) if opts[:import_langlinks]
|
|
714
715
|
|
|
715
716
|
# Determine input source
|
|
716
717
|
if opts[:from_category] && opts[:lang]
|
data/bin/wp2txt-mcp
CHANGED
|
@@ -24,6 +24,7 @@ require "optimist"
|
|
|
24
24
|
require "json"
|
|
25
25
|
require_relative "../lib/wp2txt/corpus"
|
|
26
26
|
require_relative "../lib/wp2txt/corpus_jobs"
|
|
27
|
+
require_relative "../lib/wp2txt/output_path"
|
|
27
28
|
require_relative "../lib/wp2txt/version"
|
|
28
29
|
|
|
29
30
|
opts = Optimist.options do
|
|
@@ -159,16 +160,23 @@ end
|
|
|
159
160
|
|
|
160
161
|
server.define_tool(
|
|
161
162
|
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
|
+
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.",
|
|
163
164
|
input_schema: {
|
|
164
165
|
properties: {
|
|
165
166
|
sql: { type: "string" },
|
|
166
|
-
limit: { type: "integer", minimum: 1, maximum: 1000 }
|
|
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)" }
|
|
167
172
|
},
|
|
168
173
|
required: ["sql"]
|
|
169
174
|
}
|
|
170
|
-
) do |sql:, limit: 200, server_context:|
|
|
171
|
-
respond
|
|
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
|
|
172
180
|
end
|
|
173
181
|
|
|
174
182
|
server.define_tool(
|
|
@@ -279,6 +287,8 @@ EXTRACT_SCHEMA = {
|
|
|
279
287
|
properties: {
|
|
280
288
|
output_path: { type: "string", description: "JSONL output path; relative paths resolve under the server's output dir" },
|
|
281
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)" },
|
|
282
292
|
sections: { type: "array", items: { type: "string" } },
|
|
283
293
|
alias_set: { type: "string" },
|
|
284
294
|
category: { type: "string" },
|
|
@@ -294,23 +304,12 @@ EXTRACT_SCHEMA = {
|
|
|
294
304
|
required: ["output_path", "content"]
|
|
295
305
|
}.freeze
|
|
296
306
|
|
|
297
|
-
def extract_params(output_path:, content:, sections: nil, alias_set: nil, category: nil,
|
|
307
|
+
def extract_params(output_path:, content:, titles: nil, sections: nil, alias_set: nil, category: nil,
|
|
298
308
|
depth: 0, categories: nil, category_match: nil,
|
|
299
309
|
title_match: nil, limit: 0, chunk_size: nil, chunk_overlap: 0,
|
|
300
310
|
overwrite: false)
|
|
301
|
-
|
|
302
|
-
|
|
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,
|
|
311
|
+
{ output_path: Wp2txt::OutputPath.confine(output_path, OUTPUT_DIR, overwrite: overwrite),
|
|
312
|
+
content: content, titles: titles,
|
|
314
313
|
sections: sections, alias_set: alias_set, category: category, depth: depth,
|
|
315
314
|
categories: categories, category_match: category_match,
|
|
316
315
|
title_match: title_match, limit: limit, chunk_size: chunk_size, chunk_overlap: chunk_overlap }
|
|
@@ -318,7 +317,7 @@ end
|
|
|
318
317
|
|
|
319
318
|
server.define_tool(
|
|
320
319
|
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.",
|
|
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.",
|
|
322
321
|
input_schema: EXTRACT_SCHEMA
|
|
323
322
|
) do |server_context:, **args|
|
|
324
323
|
respond { corpus.extract_corpus(**extract_params(**args)) }
|
data/docs/RESEARCH.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# wp2txt Research Infrastructure Guide
|
|
2
|
+
|
|
3
|
+
This guide covers the research-oriented layer of wp2txt: local indexes over Wikipedia
|
|
4
|
+
dumps, exhaustive offline queries, full-text search, cross-language SQL, and the MCP
|
|
5
|
+
server that exposes all of this to LLM agents.
|
|
6
|
+
|
|
7
|
+
For plain-text extraction (the classic wp2txt), see the [README](../README.md).
|
|
8
|
+
|
|
9
|
+
## Concept
|
|
10
|
+
|
|
11
|
+
Web search and the Wikipedia API operate on ranked, paginated, ever-changing data. They
|
|
12
|
+
can show that something *exists*, but they cannot make **exhaustive** claims ("342 of the
|
|
13
|
+
11,486 film articles with a plot section mention X — and none of the others do"), and
|
|
14
|
+
their answers change from day to day.
|
|
15
|
+
|
|
16
|
+
wp2txt takes the opposite approach: build local indexes over an official dump file, so that
|
|
17
|
+
every query is
|
|
18
|
+
|
|
19
|
+
- **exhaustive** — it scans every article, not search-ranked results;
|
|
20
|
+
- **version-pinned** — results are tied to one dump (e.g. `jawiki-20260701`) and
|
|
21
|
+
reproducible later;
|
|
22
|
+
- **agent-operable** — the MCP server exposes the whole layer to LLM agents, with
|
|
23
|
+
guardrails and provenance records designed for autonomous use.
|
|
24
|
+
|
|
25
|
+
## 1. Building the indexes
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
# Tier 1: metadata index (categories, section headings, redirects, category hierarchy)
|
|
29
|
+
$ wp2txt --build-index --lang=ja
|
|
30
|
+
|
|
31
|
+
# Tier 1 + Tier 2: add an FTS5 full-text index over the cleaned article text
|
|
32
|
+
$ wp2txt --build-index --fulltext --lang=ja
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The dump is downloaded automatically if needed and everything is cached under
|
|
36
|
+
`~/.wp2txt/cache/`. Ballpark figures (Apple Silicon laptop): Japanese Wikipedia ~15 min /
|
|
37
|
+
~1.7 GB for the metadata index, ~1.5 h / ~12 GB with full text; English roughly 4× the
|
|
38
|
+
time, ~10 GB / ~12 GB respectively.
|
|
39
|
+
|
|
40
|
+
The full-text tokenizer is selected per language: character trigrams for Japanese,
|
|
41
|
+
Chinese, and Korean; word-based (`unicode61`, no stemming) for space-delimited languages.
|
|
42
|
+
The choice is recorded in the index metadata.
|
|
43
|
+
|
|
44
|
+
Indexes are rebuilt atomically (a failed rebuild never destroys the working index), and
|
|
45
|
+
each index records the wp2txt version that built it — `dump_info` can flag indexes whose
|
|
46
|
+
text-cleaning code differs from the running version.
|
|
47
|
+
|
|
48
|
+
## 2. Exhaustive queries from the CLI
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
# All film articles (recursing 3 subcategory levels) that have a plot section
|
|
52
|
+
$ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
|
|
53
|
+
|
|
54
|
+
# Machine-readable output with total count
|
|
55
|
+
$ wp2txt --find-articles --in-category "Films" -D 2 -j json --limit 100 --lang=en
|
|
56
|
+
|
|
57
|
+
# Full-text search composed with metadata filters
|
|
58
|
+
$ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Search totals are exhaustive counts, so `0 matches` is a verifiable **absence claim** for
|
|
62
|
+
that dump version — something ranked web search cannot provide.
|
|
63
|
+
|
|
64
|
+
## 3. Interlanguage links (langlinks)
|
|
65
|
+
|
|
66
|
+
Import the official `langlinks` dump into the metadata index to map articles across
|
|
67
|
+
language editions:
|
|
68
|
+
|
|
69
|
+
```console
|
|
70
|
+
$ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- **Version pinning is enforced**: the langlinks file must carry the same dump date as
|
|
74
|
+
the built index; a mismatch is rejected with no override.
|
|
75
|
+
- Import provenance (source file, time, tool version, row count, rows skipped for
|
|
76
|
+
invalid encoding) is stamped into the index and reported by `dump_info`.
|
|
77
|
+
- A post-import sanity check joins a per-language sample against locally installed
|
|
78
|
+
editions and reports the match rate.
|
|
79
|
+
|
|
80
|
+
This adds a `langlinks` table (`ll_from` = source page_id, `ll_lang`, `ll_title`) that can
|
|
81
|
+
be joined in SQL. Tip: filter `ll_title != ''` — real dumps contain a few empty-title rows.
|
|
82
|
+
|
|
83
|
+
## 4. The MCP server
|
|
84
|
+
|
|
85
|
+
`wp2txt-mcp` exposes a local dump to any MCP-capable LLM client (Claude, ChatGPT, Gemini,
|
|
86
|
+
local models):
|
|
87
|
+
|
|
88
|
+
```console
|
|
89
|
+
$ gem install mcp # optional dependency, needed only for the server
|
|
90
|
+
$ wp2txt --build-index --lang=ja # prerequisite
|
|
91
|
+
$ wp2txt-mcp --lang=ja # stdio MCP server
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Example client configuration (e.g., Claude Desktop / Claude Code):
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcpServers": {
|
|
99
|
+
"wp2txt": { "command": "wp2txt-mcp", "args": ["--lang", "ja"] }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or run everything from the container image (no Ruby required on the host):
|
|
105
|
+
|
|
106
|
+
```console
|
|
107
|
+
$ docker run -it -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt --build-index --fulltext -L ja
|
|
108
|
+
$ claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt-mcp -L ja
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
(Use `-i`, not `-it`, for the MCP server — a TTY would corrupt the JSON-RPC stream.)
|
|
112
|
+
|
|
113
|
+
### Tools
|
|
114
|
+
|
|
115
|
+
| Tool | Purpose |
|
|
116
|
+
|------|---------|
|
|
117
|
+
| `dump_info` | Dump identity, index tiers, corpus statistics, langlinks provenance |
|
|
118
|
+
| `get_article` / `get_sections` / `list_headings` / `get_categories` | Single-article access (redirect-aware) |
|
|
119
|
+
| `find_articles` | Exhaustive filtered listing (category recursion, category AND / pattern match, section headings, title match) |
|
|
120
|
+
| `category_tree` / `section_stats` | Scope exploration and heading-frequency discovery |
|
|
121
|
+
| `search_text` | Full-text search composed with metadata filters; exact or capped exhaustive counts |
|
|
122
|
+
| `query_sql` | Read-only SQL (SELECT/WITH) over the index databases — the escape hatch for queries the fixed tools cannot express; supports cross-language `attach` and file output |
|
|
123
|
+
| `describe_schema` | Table/column introspection for query_sql |
|
|
124
|
+
| `section_cooccurrence` | Verify section-alias hypotheses (synonymous headings rarely co-occur in one article) |
|
|
125
|
+
| `save_alias_set` / `get_alias_set` / `list_alias_sets` | Persist verified per-dump alias groups (server-side co-occurrence guardrail) |
|
|
126
|
+
| `extract_corpus` | Filtered or explicit-title extraction to JSONL + reproducibility sidecar; optional RAG chunking |
|
|
127
|
+
| `start_extract_job` / `job_status` / `cancel_job` / `list_jobs` | Background jobs for large extractions |
|
|
128
|
+
|
|
129
|
+
### Design principles
|
|
130
|
+
|
|
131
|
+
- **Division of labor**: the tool does mechanical, exhaustive narrowing and counting;
|
|
132
|
+
semantic judgment is left to the LLM. The LLM never has to count.
|
|
133
|
+
- **Context economy**: large results go to disk; the model receives a summary plus a
|
|
134
|
+
3-record sample, never the full corpus.
|
|
135
|
+
- **Reproducibility**: every extraction and file-writing query records the dump version,
|
|
136
|
+
the query, and any alias sets in a `.meta.json` sidecar.
|
|
137
|
+
- **Guardrails**: SQL is screened and executed read-only in a killable subprocess;
|
|
138
|
+
alias sets are re-verified server-side before saving; output paths are confined to the
|
|
139
|
+
server's output directory.
|
|
140
|
+
|
|
141
|
+
## 5. Cross-language SQL
|
|
142
|
+
|
|
143
|
+
With more than one language installed, `query_sql` can ATTACH other editions read-only:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
query_sql(
|
|
147
|
+
attach: ["en"],
|
|
148
|
+
sql: "SELECT p.title AS ja_title, ll.ll_title AS en_title,
|
|
149
|
+
(SELECT COUNT(*) FROM page_sections s WHERE s.page_id = p.page_id) AS ja_secs,
|
|
150
|
+
(SELECT COUNT(*) FROM en_meta.page_sections s2
|
|
151
|
+
JOIN en_meta.pages p2 ON p2.page_id = s2.page_id
|
|
152
|
+
WHERE p2.title = ll.ll_title) AS en_secs
|
|
153
|
+
FROM pages p
|
|
154
|
+
JOIN langlinks ll ON ll.ll_from = p.page_id AND ll.ll_lang = 'en'
|
|
155
|
+
WHERE p.namespace = 0 AND p.redirect_to IS NULL AND ll.ll_title != ''"
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Attached databases appear as `{lang}_meta` (and `{lang}_fts` when that language has a
|
|
160
|
+
full-text tier) and share the main database's schema. Language codes are validated and
|
|
161
|
+
resolved server-side; user SQL can never contain ATTACH itself. The response records what
|
|
162
|
+
was attached (dump names included), and flags date mismatches between editions.
|
|
163
|
+
|
|
164
|
+
To the best of our knowledge no other system offers version-pinned, cross-edition SQL over
|
|
165
|
+
both metadata **and** article text, fully offline.
|
|
166
|
+
|
|
167
|
+
## 6. Large results, explicit sets, and reproducibility
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
# Write ALL rows of a query to disk; receive a summary + 3-row sample
|
|
171
|
+
query_sql(sql: "...", attach: ["en"], output_path: "pairs_ja_en.jsonl")
|
|
172
|
+
|
|
173
|
+
# Extract an explicit article set (e.g., determined via query_sql)
|
|
174
|
+
extract_corpus(titles: ["東京物語", "羅生門", ...], content: "summary",
|
|
175
|
+
output_path: "screen.jsonl")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- `query_sql` + `output_path` streams every row to JSONL (atomically — a partial file is
|
|
179
|
+
never left behind) and writes a `.meta.json` sidecar recording the SQL, the dump
|
|
180
|
+
versions of every attached edition, and row counts.
|
|
181
|
+
- `extract_corpus` + `titles:` accepts up to 10,000 explicit titles, resolves one
|
|
182
|
+
redirect hop, and reports unmatched titles in `not_found` — closing the loop
|
|
183
|
+
*SQL decides the set → the tool materializes it → the LLM reads it*.
|
|
184
|
+
|
|
185
|
+
## 7. The alias discovery loop
|
|
186
|
+
|
|
187
|
+
Section headings vary ("Plot" vs "Synopsis"; 「あらすじ」 vs 「ストーリー」). wp2txt ships
|
|
188
|
+
no per-language dictionaries. Instead, agents discover aliases from the dump itself:
|
|
189
|
+
|
|
190
|
+
1. `section_stats` — find the actual headings used in a scope
|
|
191
|
+
2. LLM proposes synonym groups
|
|
192
|
+
3. `section_cooccurrence` — verify mechanically (true synonyms almost never co-occur in
|
|
193
|
+
the same article; a high co-occurrence ratio is evidence *against* the hypothesis)
|
|
194
|
+
4. `save_alias_set` — persist the verified groups, re-checked server-side, and recorded
|
|
195
|
+
in every extraction that uses them
|
|
196
|
+
|
|
197
|
+
The bundled `discover_aliases` MCP prompt walks any agent through this protocol.
|
|
198
|
+
|
|
199
|
+
## 8. Honest limitations
|
|
200
|
+
|
|
201
|
+
- Queries operate on the **cleaned-text space**: content replaced by markers
|
|
202
|
+
(`[MATH]`, `[CODE]`, `[TABLE]`, …) is not searchable.
|
|
203
|
+
- Trigram languages (ja/zh/ko) cannot match queries shorter than 3 characters;
|
|
204
|
+
word-based languages have no stemming (`run` ≠ `running`). Both are deliberate:
|
|
205
|
+
exact counts and absence claims require predictable matching.
|
|
206
|
+
- Categories and links come from the dump itself, as written by editors — they inherit
|
|
207
|
+
Wikipedia's own inconsistencies, which is precisely what makes them worth studying.
|
data/lib/wp2txt/cli.rb
CHANGED
|
@@ -142,6 +142,14 @@ module Wp2txt
|
|
|
142
142
|
opt :fts_optimize, "Optimize an existing full-text index (merge segments; idempotent)",
|
|
143
143
|
default: false, short: :none
|
|
144
144
|
|
|
145
|
+
# Langlinks import (interlanguage links into the metadata index)
|
|
146
|
+
opt :import_langlinks, "Import interlanguage links (langlinks dump) into the metadata index (requires --lang)",
|
|
147
|
+
default: false, short: :none
|
|
148
|
+
opt :langlinks_file, "Use a local langlinks .sql(.gz) file instead of downloading (with --import-langlinks)",
|
|
149
|
+
type: String, short: :none
|
|
150
|
+
opt :langlinks_langs, "Comma-separated target languages to import with --import-langlinks (default: all)",
|
|
151
|
+
type: String, short: :none
|
|
152
|
+
|
|
145
153
|
opt :file_size, "Approximate size (in MB) of each output file (0 for single file)",
|
|
146
154
|
default: 10, short: "-f"
|
|
147
155
|
opt :num_procs, "Number of parallel processes (auto-detected based on CPU/memory)",
|
|
@@ -352,6 +360,41 @@ module Wp2txt
|
|
|
352
360
|
Optimist.die "--search cannot be combined with --build-index/--find-articles"
|
|
353
361
|
end
|
|
354
362
|
|
|
363
|
+
# Langlinks import is a standalone mode requiring --lang
|
|
364
|
+
if opts[:import_langlinks]
|
|
365
|
+
Optimist.die "--import-langlinks requires --lang" if opts[:lang].nil?
|
|
366
|
+
|
|
367
|
+
conflicts = []
|
|
368
|
+
conflicts << "--build-index" if opts[:build_index]
|
|
369
|
+
conflicts << "--find-articles" if opts[:find_articles]
|
|
370
|
+
conflicts << "--search" if opts[:search]
|
|
371
|
+
conflicts << "--fts-optimize" if opts[:fts_optimize]
|
|
372
|
+
conflicts << "--articles" if opts[:articles]
|
|
373
|
+
conflicts << "--from-category" if opts[:from_category]
|
|
374
|
+
conflicts << "--section-stats" if opts[:section_stats]
|
|
375
|
+
unless conflicts.empty?
|
|
376
|
+
Optimist.die "--import-langlinks cannot be combined with #{conflicts.join(', ')}"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if opts[:langlinks_file] && !opts[:import_langlinks]
|
|
381
|
+
Optimist.die "--langlinks-file requires --import-langlinks"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
if opts[:langlinks_file] && !File.exist?(opts[:langlinks_file])
|
|
385
|
+
Optimist.die :langlinks_file, "file does not exist"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
if opts[:langlinks_langs] && !opts[:import_langlinks]
|
|
389
|
+
Optimist.die "--langlinks-langs requires --import-langlinks"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
if opts[:langlinks_langs]
|
|
393
|
+
invalid = opts[:langlinks_langs].split(",").map(&:strip).reject(&:empty?) -
|
|
394
|
+
opts[:langlinks_langs].split(",").map(&:strip).grep(/\A[a-z][a-z0-9-]{1,11}\z/)
|
|
395
|
+
Optimist.die :langlinks_langs, "invalid language code(s): #{invalid.join(', ')}" unless invalid.empty?
|
|
396
|
+
end
|
|
397
|
+
|
|
355
398
|
if opts[:build_index] || opts[:find_articles] || opts[:search] || opts[:fts_optimize]
|
|
356
399
|
conflicts = []
|
|
357
400
|
conflicts << "--articles" if opts[:articles]
|