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.
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