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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e54ea4f824be0f060036923759b1c6755b20ee1e721245db58a23665b9efaab
4
- data.tar.gz: ca0a5b1ae0efae6eb4c485a6319569ea9995c662054e7e697a70429632613a25
3
+ metadata.gz: 076aa3a7681c0e0e6e13948e5c66f6f9e15a0307507a944a78f29c0e7856eea0
4
+ data.tar.gz: f8f2d3a4efc82d1922f9ca2764b23bb348aa924e752439805ab61683ff66a737
5
5
  SHA512:
6
- metadata.gz: 34b61e5ee404431e521e77bc1223b9b7e1e0c9f2082ae4898e6e4b9fdee9de593c9ca45d661723f8d14f5ebffd62360dfb27d3a7b0d87d2a37691bb127c9ab6f
7
- data.tar.gz: ca002ba2fc0c5a9bc8b2ae55dbc736f154e54452af681aad6f1b9ed30ca616535db94ee8cccb069defa56aacc4feb96b306d6fdb1a0102ee20611c98a213d5d0
6
+ metadata.gz: ae377878c20dde5a376d96efec19f84a74a20e66dd8f45ff5d41c438f519ced7cf5d4e6a6102ceee5c4b59333f0b1ca42167cf5e178abf803de868855b4efa6c
7
+ data.tar.gz: c23cb4598a7afeda092d75688be12e9e777874a4b9da289aeb1d97123b5cc64b3b56337722290fcc4352fde9031a856d4ba81393055ef8596fe8818d51e258ac
data/.gitignore CHANGED
@@ -23,6 +23,9 @@ tags
23
23
  # Claude Code development files
24
24
  CLAUDE.md
25
25
 
26
+ # Private design/research notes (not for publication)
27
+ research-notes/
28
+
26
29
  # Error logs
27
30
  error_log.txt
28
31
  spec/examples.txt
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
@@ -1,5 +1,8 @@
1
1
  FROM ruby:4.0.1-alpine3.23
2
2
 
3
+ # Link the GHCR package to the source repository
4
+ LABEL org.opencontainers.image.source="https://github.com/yohasebe/wp2txt"
5
+
3
6
  WORKDIR /wp2txt
4
7
  COPY . ./
5
8
  RUN rm -f Gemfile.lock
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 metadata index** - Exhaustive, version-pinned queries over categories and section headings, no API required
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
- ## 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.
258
+ ## Research Infrastructure (Indexes, Exhaustive Queries, MCP)
280
259
 
281
- ## MCP Server (LLM Integration)
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
- `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
- }
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
- 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 |
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
- 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.
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 -t yohasebe/wp2txt:#{Wp2txt::VERSION} -t yohasebe/wp2txt:latest . --push
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 { corpus.query_sql(sql, limit: limit) }
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
- # 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,
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]