wp2txt 2.2.0 → 2.3.1
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/.dockerignore +2 -1
- data/.gitignore +3 -0
- data/CHANGELOG.md +19 -0
- data/DEVELOPMENT.md +2 -1
- data/DEVELOPMENT_ja.md +2 -1
- data/Dockerfile +10 -1
- data/README.md +36 -56
- data/README_ja.md +33 -1
- data/Rakefile +31 -5
- data/bin/wp2txt +1 -0
- data/bin/wp2txt-mcp +19 -20
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +43 -0
- data/lib/wp2txt/corpus.rb +385 -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/regex.rb +7 -0
- data/lib/wp2txt/text_processing.rb +8 -0
- data/lib/wp2txt/utils.rb +17 -1
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/corpus_spec.rb +25 -0
- data/spec/docs_sync_spec.rb +39 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/titles_output_path_spec.rb +338 -0
- data/spec/utils_spec.rb +129 -0
- metadata +14 -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
|
@@ -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]
|