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
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
|
@@ -118,6 +118,38 @@ module Wp2txt
|
|
|
118
118
|
opt :show_matched_sections, "Include matched_sections field in JSON output (shows actual headings)",
|
|
119
119
|
default: false
|
|
120
120
|
|
|
121
|
+
# Metadata index options (offline exhaustive queries)
|
|
122
|
+
opt :build_index, "Build local metadata index (categories, sections) from a multistream dump",
|
|
123
|
+
default: false, short: :none
|
|
124
|
+
opt :find_articles, "List article titles matching index filters (requires built index)",
|
|
125
|
+
default: false, short: :none
|
|
126
|
+
opt :in_category, "Filter by category for --find-articles (recursion depth via --depth)",
|
|
127
|
+
type: String, short: :none
|
|
128
|
+
opt :has_section, "Filter by section heading for --find-articles (alias-aware)",
|
|
129
|
+
type: String, short: :none
|
|
130
|
+
opt :title_match, "Filter by title substring for --find-articles",
|
|
131
|
+
type: String, short: :none
|
|
132
|
+
opt :limit, "Maximum number of titles to output with --find-articles (0 = no limit)",
|
|
133
|
+
default: 0, type: Integer, short: :none
|
|
134
|
+
opt :fulltext, "Also build the full-text (FTS5) index with --build-index",
|
|
135
|
+
default: false, short: :none
|
|
136
|
+
opt :fts_tokenizer, "FTS tokenizer: unicode61, trigram, or porter (default: auto by language)",
|
|
137
|
+
type: String, short: :none
|
|
138
|
+
opt :search, "Full-text search query (requires --build-index --fulltext beforehand)",
|
|
139
|
+
type: String, short: :none
|
|
140
|
+
opt :skip_fts_optimize, "Skip the final optimize step of the full-text build (much faster build; queries slightly slower until --fts-optimize is run)",
|
|
141
|
+
default: false, short: :none
|
|
142
|
+
opt :fts_optimize, "Optimize an existing full-text index (merge segments; idempotent)",
|
|
143
|
+
default: false, short: :none
|
|
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
|
+
|
|
121
153
|
opt :file_size, "Approximate size (in MB) of each output file (0 for single file)",
|
|
122
154
|
default: 10, short: "-f"
|
|
123
155
|
opt :num_procs, "Number of parallel processes (auto-detected based on CPU/memory)",
|
|
@@ -294,6 +326,84 @@ module Wp2txt
|
|
|
294
326
|
if opts[:show_matched_sections] && opts[:format].to_s.downcase != "json"
|
|
295
327
|
Optimist.die "--show-matched-sections requires --format json"
|
|
296
328
|
end
|
|
329
|
+
|
|
330
|
+
# Metadata index options
|
|
331
|
+
if opts[:build_index] && opts[:find_articles]
|
|
332
|
+
Optimist.die "--build-index and --find-articles cannot be combined (build first, then query)"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
%i[in_category has_section title_match].each do |key|
|
|
336
|
+
if opts[key] && !opts[:find_articles] && !opts[:search]
|
|
337
|
+
Optimist.die "--#{key.to_s.tr('_', '-')} requires --find-articles or --search"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
Optimist.die :limit, "must be 0 or greater" if opts[:limit].negative?
|
|
342
|
+
|
|
343
|
+
if opts[:fulltext] && !opts[:build_index]
|
|
344
|
+
Optimist.die "--fulltext requires --build-index"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
if opts[:fts_tokenizer] && !%w[unicode61 trigram porter].include?(opts[:fts_tokenizer])
|
|
348
|
+
Optimist.die :fts_tokenizer, "must be unicode61, trigram, or porter"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
if opts[:skip_fts_optimize] && !opts[:fulltext]
|
|
352
|
+
Optimist.die "--skip-fts-optimize requires --build-index --fulltext"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
if opts[:fts_optimize] && (opts[:build_index] || opts[:find_articles] || opts[:search])
|
|
356
|
+
Optimist.die "--fts-optimize is a standalone mode (cannot combine with --build-index/--find-articles/--search)"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
if opts[:search] && (opts[:build_index] || opts[:find_articles])
|
|
360
|
+
Optimist.die "--search cannot be combined with --build-index/--find-articles"
|
|
361
|
+
end
|
|
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
|
+
|
|
398
|
+
if opts[:build_index] || opts[:find_articles] || opts[:search] || opts[:fts_optimize]
|
|
399
|
+
conflicts = []
|
|
400
|
+
conflicts << "--articles" if opts[:articles]
|
|
401
|
+
conflicts << "--from-category" if opts[:from_category]
|
|
402
|
+
conflicts << "--section-stats" if opts[:section_stats]
|
|
403
|
+
unless conflicts.empty?
|
|
404
|
+
Optimist.die "--build-index/--find-articles/--search cannot be combined with #{conflicts.join(', ')}"
|
|
405
|
+
end
|
|
406
|
+
end
|
|
297
407
|
end
|
|
298
408
|
|
|
299
409
|
# Parse article list from comma-separated string
|