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
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "../wp2txt"
|
|
9
|
+
require_relative "article"
|
|
10
|
+
require_relative "utils"
|
|
11
|
+
require_relative "formatter"
|
|
12
|
+
require_relative "multistream"
|
|
13
|
+
require_relative "metadata_index"
|
|
14
|
+
require_relative "fts_index"
|
|
15
|
+
require_relative "section_extractor"
|
|
16
|
+
require_relative "version"
|
|
17
|
+
|
|
18
|
+
module Wp2txt
|
|
19
|
+
# Facade over a local dump: single-article access (Tier 0, multistream),
|
|
20
|
+
# exhaustive metadata queries (Tier 1, MetadataIndex), and corpus extraction.
|
|
21
|
+
# Shared by the CLI and the MCP server so both expose identical behavior.
|
|
22
|
+
class Corpus
|
|
23
|
+
include Wp2txt
|
|
24
|
+
include Wp2txt::Formatter
|
|
25
|
+
|
|
26
|
+
# Sync extraction cap: larger requests need the (future) job API
|
|
27
|
+
DEFAULT_MAX_SYNC_ARTICLES = 5000
|
|
28
|
+
|
|
29
|
+
RENDER_CONFIG = {
|
|
30
|
+
format: :text,
|
|
31
|
+
title: true, heading: true, list: false, table: false, pre: false,
|
|
32
|
+
ref: false, redirect: false, multiline: false,
|
|
33
|
+
category: true, category_only: false, summary_only: false, metadata_only: false,
|
|
34
|
+
marker: true, markers: true, extract_citations: false, expand_templates: true,
|
|
35
|
+
sections: nil, section_output: "structured", min_section_length: 0,
|
|
36
|
+
skip_empty: false, alias_file: nil, no_section_aliases: false,
|
|
37
|
+
show_matched_sections: false
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Duck-typed replacement for MultistreamIndex that resolves titles through
|
|
41
|
+
# the IndexCache SQLite file on demand, avoiding loading millions of index
|
|
42
|
+
# entries into memory (important for long-lived server processes).
|
|
43
|
+
class LazyTitleIndex
|
|
44
|
+
def initialize(index_cache)
|
|
45
|
+
@cache = index_cache
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find_by_title(title)
|
|
49
|
+
@cache.find_by_titles([title])[title]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stream_offset_for(title)
|
|
53
|
+
find_by_title(title)&.fetch(:offset, nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stream_offsets
|
|
57
|
+
@stream_offsets ||= @cache.stream_offsets
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :multistream_path, :index_path, :metadata
|
|
62
|
+
|
|
63
|
+
def initialize(multistream_path:, index_path:, cache_dir: nil)
|
|
64
|
+
@multistream_path = multistream_path
|
|
65
|
+
@index_path = index_path
|
|
66
|
+
@cache_dir = cache_dir
|
|
67
|
+
@metadata = MetadataIndex.new(MetadataIndex.path_for(multistream_path, cache_dir: cache_dir))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Build a Corpus from a language code using the DumpManager cache.
|
|
71
|
+
# Raises with guidance when the dump has not been downloaded yet.
|
|
72
|
+
def self.for_lang(lang, cache_dir: nil)
|
|
73
|
+
manager = DumpManager.new(lang, cache_dir: cache_dir)
|
|
74
|
+
multistream = manager.cached_multistream_path
|
|
75
|
+
index = manager.cached_index_path
|
|
76
|
+
unless File.exist?(multistream) && File.exist?(index)
|
|
77
|
+
raise ArgumentError, "No cached dump for '#{lang}'. Run: wp2txt --build-index -L #{lang}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
new(multistream_path: multistream, index_path: index, cache_dir: cache_dir)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.for_input(multistream_path, cache_dir: nil)
|
|
84
|
+
candidates = [
|
|
85
|
+
multistream_path.sub(/multistream\.xml\.bz2\z/, "multistream-index.txt.bz2"),
|
|
86
|
+
multistream_path.sub(/\.xml\.bz2\z/, "-index.txt.bz2"),
|
|
87
|
+
multistream_path.sub(/\.xml\.bz2\z/, "-index.txt")
|
|
88
|
+
].uniq
|
|
89
|
+
index = candidates.find { |c| c != multistream_path && File.exist?(c) }
|
|
90
|
+
raise ArgumentError, "Multistream index file not found next to #{multistream_path}" unless index
|
|
91
|
+
|
|
92
|
+
new(multistream_path: multistream_path, index_path: index, cache_dir: cache_dir)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Info
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def metadata_built?
|
|
100
|
+
@metadata.built?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def dump_info
|
|
104
|
+
stats = @metadata.built? ? @metadata.stats : nil
|
|
105
|
+
{
|
|
106
|
+
multistream_path: @multistream_path,
|
|
107
|
+
dump: stats&.dig(:dump_name) || File.basename(@multistream_path),
|
|
108
|
+
tiers: {
|
|
109
|
+
titles: File.exist?(@index_path),
|
|
110
|
+
metadata: @metadata.built?,
|
|
111
|
+
fulltext: fts.built?
|
|
112
|
+
},
|
|
113
|
+
metadata_current: @metadata.built? && @metadata.valid_for?(@multistream_path),
|
|
114
|
+
fulltext_current: fts.built? && fts.valid_for?(@multistream_path),
|
|
115
|
+
stats: stats,
|
|
116
|
+
fulltext: fts.built? ? fts.stats : nil,
|
|
117
|
+
langlinks: @metadata.built? ? @metadata.langlinks_provenance : nil
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fts
|
|
122
|
+
@fts ||= FtsIndex.new(
|
|
123
|
+
FtsIndex.path_for(@multistream_path, cache_dir: @cache_dir),
|
|
124
|
+
@metadata.db_path
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Tier 0: single-article access
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
# Default cap on article text returned inline (LLM context economy);
|
|
133
|
+
# callers can raise it explicitly, and truncation is always flagged
|
|
134
|
+
DEFAULT_MAX_CHARS = 40_000
|
|
135
|
+
|
|
136
|
+
# @param format [String] "text" (cleaned), "wikitext" (raw markup)
|
|
137
|
+
# @param follow_redirect [Boolean] resolve one redirect hop
|
|
138
|
+
# @param max_chars [Integer, nil] truncate text beyond this length (nil = no cap)
|
|
139
|
+
def get_article(title, format: "text", follow_redirect: true, max_chars: DEFAULT_MAX_CHARS)
|
|
140
|
+
page = fetch_page(title, follow_redirect: follow_redirect)
|
|
141
|
+
return nil unless page
|
|
142
|
+
|
|
143
|
+
body = case format.to_s
|
|
144
|
+
when "wikitext"
|
|
145
|
+
page[:text]
|
|
146
|
+
else
|
|
147
|
+
render_text(page)
|
|
148
|
+
end
|
|
149
|
+
result = { id: page[:id], title: page[:title], format: format.to_s }
|
|
150
|
+
if max_chars && body.length > max_chars
|
|
151
|
+
result.merge(text: body[0, max_chars], truncated: true, total_chars: body.length)
|
|
152
|
+
else
|
|
153
|
+
result.merge(text: body)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Categories of one article, resolved through the same title normalization
|
|
158
|
+
def get_categories(title)
|
|
159
|
+
cats = @metadata.categories_of(title)
|
|
160
|
+
if cats.nil?
|
|
161
|
+
page = fetch_page(title)
|
|
162
|
+
cats = page ? @metadata.categories_of(page[:title]) : nil
|
|
163
|
+
end
|
|
164
|
+
return nil unless cats
|
|
165
|
+
|
|
166
|
+
{ title: title, categories: cats }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Extract specific sections from one article.
|
|
170
|
+
# @param sections [Array<String>] section names ("summary" for lead text)
|
|
171
|
+
# @param alias_set [String, nil] saved alias set used to expand names
|
|
172
|
+
def get_sections(title, sections, alias_set: nil)
|
|
173
|
+
page = fetch_page(title)
|
|
174
|
+
return nil unless page
|
|
175
|
+
|
|
176
|
+
resolved = expand_with_alias_set(sections, alias_set)
|
|
177
|
+
config = RENDER_CONFIG.merge(format: :json, sections: resolved, title: page[:title])
|
|
178
|
+
article = Article.new(page[:text], page[:title], false)
|
|
179
|
+
result = format_with_sections(article, config)
|
|
180
|
+
{ id: page[:id], title: page[:title], requested: sections, resolved: resolved,
|
|
181
|
+
sections: result ? result["sections"] : {} }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def list_headings(title)
|
|
185
|
+
page = fetch_page(title)
|
|
186
|
+
return nil unless page
|
|
187
|
+
|
|
188
|
+
article = Article.new(page[:text], page[:title], false)
|
|
189
|
+
{ id: page[:id], title: page[:title],
|
|
190
|
+
headings: SectionExtractor.new.extract_headings_with_levels(article) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
# Tier 1: exhaustive queries (delegated to MetadataIndex)
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def find_articles(**filters)
|
|
198
|
+
limit = filters.delete(:limit) || 0
|
|
199
|
+
offset = filters.delete(:offset) || 0
|
|
200
|
+
total = @metadata.count_articles(**filters)
|
|
201
|
+
titles = @metadata.find_articles(**filters, limit: limit, offset: offset)
|
|
202
|
+
{ dump: dump_name, total: total, returned: titles.size, titles: titles }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def category_tree(category, depth: 2)
|
|
206
|
+
{ dump: dump_name, tree: @metadata.category_tree(category, depth: depth) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def section_stats(category: nil, depth: 0, top_n: 50)
|
|
210
|
+
{ dump: dump_name,
|
|
211
|
+
sections: @metadata.section_stats(category: category, depth: depth, top_n: top_n)
|
|
212
|
+
.map { |h, c| { heading: h, articles: c } } }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def section_cooccurrence(headings, category: nil, depth: 0)
|
|
216
|
+
@metadata.section_cooccurrence(headings, category: category, depth: depth)
|
|
217
|
+
.merge(dump: dump_name)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Guardrail thresholds: pairs whose co-occurrence ratio exceeds
|
|
221
|
+
# GUARDRAIL_MAX_RATIO (with both headings above GUARDRAIL_MIN_ARTICLES)
|
|
222
|
+
# coexist in the same articles and are likely NOT synonyms
|
|
223
|
+
GUARDRAIL_MAX_RATIO = 0.2
|
|
224
|
+
GUARDRAIL_MIN_ARTICLES = 100
|
|
225
|
+
|
|
226
|
+
# Save an alias set after mechanically verifying each group: high
|
|
227
|
+
# co-occurrence pairs block the save unless force is set, so protocol
|
|
228
|
+
# compliance does not depend on the calling model's discipline.
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Tier 2: full-text search
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
SNIPPET_CONTEXT = 80
|
|
234
|
+
|
|
235
|
+
# Exhaustive full-text search over cleaned section text.
|
|
236
|
+
# @param mode [String] "phrase" (literal, default) or "query" (raw FTS5 syntax)
|
|
237
|
+
# @param count [String] "capped" (fast, default) or "exact" (may take seconds for common terms)
|
|
238
|
+
# @param snippets [Boolean] re-render matched sections from the dump for context
|
|
239
|
+
def search_text(query, mode: "phrase", sections: nil, alias_set: nil,
|
|
240
|
+
category: nil, depth: 0, limit: 20, offset: 0,
|
|
241
|
+
count: "capped", snippets: true)
|
|
242
|
+
unless fts.built?
|
|
243
|
+
raise ArgumentError, "Full-text index not built. Run: wp2txt --build-index --fulltext"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
resolved = expand_with_alias_set(sections, alias_set)
|
|
247
|
+
resolved = nil if resolved.empty?
|
|
248
|
+
result = fts.search(query, mode: mode, sections: resolved, category: category,
|
|
249
|
+
depth: depth, limit: limit, offset: offset, count: count)
|
|
250
|
+
|
|
251
|
+
hits = result[:hits]
|
|
252
|
+
attach_snippets(hits, query, mode) if snippets && !hits.empty?
|
|
253
|
+
|
|
254
|
+
{ dump: dump_name, query: query, mode: mode,
|
|
255
|
+
total: result[:total], total_is_capped: result[:total_is_capped],
|
|
256
|
+
returned: hits.size,
|
|
257
|
+
hits: hits.map do |h|
|
|
258
|
+
{ page_id: h[:page_id], title: h[:title],
|
|
259
|
+
section: h[:heading].to_s.empty? ? nil : h[:heading],
|
|
260
|
+
section_path: h[:heading].to_s.empty? ? h[:title] : "#{h[:title]} > #{h[:heading]}",
|
|
261
|
+
snippet: h[:snippet] }.compact
|
|
262
|
+
end }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def save_alias_set(name, groups, force: false,
|
|
266
|
+
max_ratio: GUARDRAIL_MAX_RATIO, min_articles: GUARDRAIL_MIN_ARTICLES)
|
|
267
|
+
unless groups.is_a?(Array) && !groups.empty? && groups.all? { |g| g.is_a?(Array) && !g.empty? }
|
|
268
|
+
raise ArgumentError, "groups must be a non-empty array of arrays"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Report the exact thresholds used so both the model and the user can see
|
|
272
|
+
# why a group was accepted or rejected, not just that it was
|
|
273
|
+
criteria = { max_cooccurrence_ratio: max_ratio, min_articles: min_articles }
|
|
274
|
+
violations = check_alias_groups(groups, max_ratio: max_ratio, min_articles: min_articles)
|
|
275
|
+
if violations.any? && !force
|
|
276
|
+
return { saved: false, name: name, groups: groups, violations: violations, criteria: criteria,
|
|
277
|
+
warning: "These heading pairs coexist in the same article more than " \
|
|
278
|
+
"#{(max_ratio * 100).round}% of the time (both headings appear in at least " \
|
|
279
|
+
"#{min_articles} articles), so they are likely different sections, not synonyms. " \
|
|
280
|
+
"Remove them from the group, or pass force: true if you verified them another " \
|
|
281
|
+
"way (e.g., by reading section contents with get_sections)." }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
@metadata.save_alias_set(name, groups)
|
|
285
|
+
{ saved: true, name: name, groups: groups, violations: violations, criteria: criteria }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def get_alias_set(name)
|
|
289
|
+
@metadata.get_alias_set(name)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def list_alias_sets
|
|
293
|
+
@metadata.list_alias_sets
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
# Corpus extraction (synchronous; D4 pattern — results go to disk,
|
|
298
|
+
# the caller receives a summary plus a small sample)
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
# Raised via cancel_check to abort a running extraction (job cancellation)
|
|
302
|
+
class Cancelled < StandardError; end
|
|
303
|
+
|
|
304
|
+
# Titles fetched/rendered per batch while streaming to disk
|
|
305
|
+
EXTRACT_BATCH_SIZE = 200
|
|
306
|
+
|
|
307
|
+
# Max titles accepted by extract_corpus titles: (larger sets belong in
|
|
308
|
+
# filter-based extraction or a background job)
|
|
309
|
+
TITLES_MAX = 10_000
|
|
310
|
+
|
|
311
|
+
# @param output_path [String] JSONL destination (sidecar .meta.json is added)
|
|
312
|
+
# @param content [String] "sections" | "full" | "summary"
|
|
313
|
+
# @param titles [Array<String>, nil] explicit article titles to extract
|
|
314
|
+
# (e.g. a set determined via query_sql). Normalized, deduplicated, one
|
|
315
|
+
# redirect hop resolved; missing titles are reported as not_found.
|
|
316
|
+
# Mutually exclusive with the filter arguments
|
|
317
|
+
# @param chunk_size [Integer, nil] split text into ~N-char chunks (RAG-ready records)
|
|
318
|
+
# @param chunk_overlap [Integer] overlap between consecutive chunks
|
|
319
|
+
# @param max_articles [Integer, nil] sync cap (nil = unlimited, for jobs)
|
|
320
|
+
# @param progress [Proc, nil] called with (titles_done, titles_total) after each batch
|
|
321
|
+
# @param cancel_check [Proc, nil] polled between batches; truthy return aborts with Cancelled
|
|
322
|
+
def extract_corpus(output_path:, content: "sections", sections: nil, alias_set: nil,
|
|
323
|
+
category: nil, depth: 0, categories: nil, category_match: nil,
|
|
324
|
+
title_match: nil, limit: 0, titles: nil,
|
|
325
|
+
chunk_size: nil, chunk_overlap: 0,
|
|
326
|
+
max_articles: DEFAULT_MAX_SYNC_ARTICLES, num_processes: 4,
|
|
327
|
+
progress: nil, cancel_check: nil)
|
|
328
|
+
if content == "sections" && Array(sections).empty? && alias_set.nil?
|
|
329
|
+
raise ArgumentError, "content: \"sections\" requires sections or alias_set"
|
|
330
|
+
end
|
|
331
|
+
raise ArgumentError, "chunk_overlap must be smaller than chunk_size" if chunk_size && chunk_overlap >= chunk_size
|
|
332
|
+
raise ArgumentError, "chunking is not supported for content: \"wikitext\"" if chunk_size && content == "wikitext"
|
|
333
|
+
|
|
334
|
+
if titles
|
|
335
|
+
raise ArgumentError, "titles must be an array of title strings" unless titles.is_a?(Array)
|
|
336
|
+
if titles.size > TITLES_MAX
|
|
337
|
+
raise ArgumentError, "titles accepts at most #{TITLES_MAX} titles (got #{titles.size}); " \
|
|
338
|
+
"use filter-based extraction or a background job for larger sets"
|
|
339
|
+
end
|
|
340
|
+
conflicts = []
|
|
341
|
+
conflicts << "category" if category
|
|
342
|
+
conflicts << "categories" if categories
|
|
343
|
+
conflicts << "category_match" if category_match
|
|
344
|
+
conflicts << "title_match" if title_match
|
|
345
|
+
unless conflicts.empty?
|
|
346
|
+
raise ArgumentError, "titles cannot be combined with #{conflicts.join(', ')} — the article " \
|
|
347
|
+
"set would be defined twice; perform set operations in query_sql and " \
|
|
348
|
+
"pass the resulting titles via titles:"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
filters = { category: category, depth: depth, categories: categories,
|
|
353
|
+
category_match: category_match, sections: sections,
|
|
354
|
+
alias_set: alias_set, title_match: title_match }
|
|
355
|
+
not_found = nil
|
|
356
|
+
titles_record = nil
|
|
357
|
+
if titles
|
|
358
|
+
normalized = titles.map { |t| MetadataIndex.normalize_title(t) }.reject(&:empty?).uniq
|
|
359
|
+
titles_record = { titles_count: normalized.size,
|
|
360
|
+
titles_sha256: Digest::SHA256.hexdigest(normalized.sort.join("\n")) }
|
|
361
|
+
titles_record[:titles] = normalized if normalized.size <= 100
|
|
362
|
+
total = normalized.size
|
|
363
|
+
resolved, missing = resolve_explicit_titles(normalized)
|
|
364
|
+
not_found = { count: missing.size, sample: missing.first(20) }
|
|
365
|
+
cap = if limit.positive?
|
|
366
|
+
max_articles ? [limit, max_articles].min : limit
|
|
367
|
+
else
|
|
368
|
+
max_articles || resolved.size
|
|
369
|
+
end
|
|
370
|
+
titles = resolved.first(cap)
|
|
371
|
+
# Truncated means "cut by the cap" only; shortfalls from missing
|
|
372
|
+
# titles are explained by not_found, not by this flag
|
|
373
|
+
truncated = resolved.size > titles.size
|
|
374
|
+
else
|
|
375
|
+
total = @metadata.count_articles(**filters)
|
|
376
|
+
cap = if limit.positive?
|
|
377
|
+
max_articles ? [limit, max_articles].min : limit
|
|
378
|
+
else
|
|
379
|
+
max_articles || total
|
|
380
|
+
end
|
|
381
|
+
titles = @metadata.find_articles(**filters, limit: cap)
|
|
382
|
+
truncated = total > titles.size
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
resolved_sections = content == "summary" ? [SectionExtractor::SUMMARY_KEY] : expand_with_alias_set(sections, alias_set)
|
|
386
|
+
alias_contents = alias_set ? get_alias_set(alias_set)&.dig(:groups) : nil
|
|
387
|
+
|
|
388
|
+
# Close ALL SQLite connections before MultistreamReader forks workers
|
|
389
|
+
# (children must not inherit open database handles)
|
|
390
|
+
close_read_connections
|
|
391
|
+
|
|
392
|
+
articles_extracted = 0
|
|
393
|
+
records_written = 0
|
|
394
|
+
titles_done = 0
|
|
395
|
+
sample = []
|
|
396
|
+
|
|
397
|
+
File.open(output_path, "w") do |f|
|
|
398
|
+
titles.each_slice(EXTRACT_BATCH_SIZE) do |batch|
|
|
399
|
+
raise Cancelled if cancel_check&.call
|
|
400
|
+
|
|
401
|
+
pages = reader.extract_articles_parallel(batch, num_processes: num_processes)
|
|
402
|
+
batch.each do |t|
|
|
403
|
+
page = pages[t]
|
|
404
|
+
next unless page
|
|
405
|
+
|
|
406
|
+
records = build_records(page, content, resolved_sections, chunk_size, chunk_overlap)
|
|
407
|
+
next if records.empty?
|
|
408
|
+
|
|
409
|
+
articles_extracted += 1
|
|
410
|
+
records.each do |record|
|
|
411
|
+
f.puts(JSON.generate(record))
|
|
412
|
+
records_written += 1
|
|
413
|
+
sample << record if sample.size < 3
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
titles_done += batch.size
|
|
417
|
+
progress&.call(titles_done, titles.size)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
meta_path = "#{output_path}.meta.json"
|
|
422
|
+
File.write(meta_path, JSON.pretty_generate(
|
|
423
|
+
tool: "wp2txt #{Wp2txt::VERSION}",
|
|
424
|
+
dump: dump_name,
|
|
425
|
+
generated_at: Time.now.utc.iso8601,
|
|
426
|
+
query: (titles_record || filters.compact).merge(
|
|
427
|
+
content: content, resolved_sections: resolved_sections,
|
|
428
|
+
chunk_size: chunk_size, chunk_overlap: chunk_size ? chunk_overlap : nil
|
|
429
|
+
).compact,
|
|
430
|
+
alias_set_contents: alias_contents,
|
|
431
|
+
total_matching: total,
|
|
432
|
+
articles_extracted: articles_extracted,
|
|
433
|
+
records_written: records_written,
|
|
434
|
+
truncated: truncated,
|
|
435
|
+
not_found: not_found
|
|
436
|
+
))
|
|
437
|
+
|
|
438
|
+
{ output_path: output_path, meta_path: meta_path, dump: dump_name,
|
|
439
|
+
total_matching: total, articles_extracted: articles_extracted,
|
|
440
|
+
records_written: records_written, truncated: truncated,
|
|
441
|
+
bytes: File.size(output_path), sample: sample,
|
|
442
|
+
not_found: not_found }.compact
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Resolve explicit titles against the pages table: existence plus one
|
|
446
|
+
# redirect hop (same rule as get_article). Input order is preserved.
|
|
447
|
+
# @return [Array(Array<String>, Array<String>)] [found_titles, missing_titles]
|
|
448
|
+
def resolve_explicit_titles(titles)
|
|
449
|
+
map = @metadata.redirect_map(titles)
|
|
450
|
+
targets = titles.filter_map { |t| map[t] }
|
|
451
|
+
.map { |t| MetadataIndex.normalize_title(t) }.uniq
|
|
452
|
+
target_map = targets.empty? ? {} : @metadata.redirect_map(targets)
|
|
453
|
+
|
|
454
|
+
found = []
|
|
455
|
+
missing = []
|
|
456
|
+
titles.each do |t|
|
|
457
|
+
if !map.key?(t)
|
|
458
|
+
missing << t
|
|
459
|
+
elsif (target = map[t])
|
|
460
|
+
# Redirect: extract under the resolved title; a redirect whose
|
|
461
|
+
# target does not exist counts as not found
|
|
462
|
+
target = MetadataIndex.normalize_title(target)
|
|
463
|
+
if target_map.key?(target)
|
|
464
|
+
found << target
|
|
465
|
+
else
|
|
466
|
+
missing << t
|
|
467
|
+
end
|
|
468
|
+
else
|
|
469
|
+
found << t
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
# Resolution can collapse distinct inputs onto one article (two aliases
|
|
473
|
+
# redirecting to the same target, or a direct title plus its alias):
|
|
474
|
+
# dedupe so no article is extracted twice, preserving first-seen order
|
|
475
|
+
[found.uniq, missing]
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# ------------------------------------------------------------------
|
|
479
|
+
# Read-only SQL (escape hatch for queries the fixed tools cannot express)
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
SQL_ROW_LIMIT = 200
|
|
483
|
+
SQL_CELL_LIMIT = 2000
|
|
484
|
+
SQL_TIMEOUT_SECONDS = 30
|
|
485
|
+
SQL_FORBIDDEN = /\b(ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE|VACUUM|REINDEX)\b/i
|
|
486
|
+
|
|
487
|
+
# File-output mode (query_sql output_path:): hard row cap and per-cell
|
|
488
|
+
# clip (insurance against runaway blobs, not context economy)
|
|
489
|
+
SQL_FILE_ROW_LIMIT = 5_000_000
|
|
490
|
+
SQL_FILE_CELL_LIMIT = 64 * 1024
|
|
491
|
+
|
|
492
|
+
# Run a read-only SELECT against the metadata DB (with the FTS DB attached
|
|
493
|
+
# as `fts` when built). Defense layers: keyword screening (outside string
|
|
494
|
+
# literals), an SQLITE_OPEN_READONLY connection so writes are impossible at
|
|
495
|
+
# the driver level, and subprocess execution with a hard wall-clock timeout
|
|
496
|
+
# — the sqlite3 gem holds the GVL during C execution, so a runaway query
|
|
497
|
+
# can only be stopped by killing the process running it. On timeout, the
|
|
498
|
+
# error message includes an EXPLAIN QUERY PLAN diagnosis when a likely
|
|
499
|
+
# cause (nested full scans, unbounded recursion) is recognizable.
|
|
500
|
+
#
|
|
501
|
+
# @param attach [Array<String>] language codes of other locally installed
|
|
502
|
+
# dumps to ATTACH read-only as {lang}_meta / {lang}_fts (e.g. ["en"] →
|
|
503
|
+
# en_meta.pages). Codes are validated and resolved server-side; user SQL
|
|
504
|
+
# itself can never contain ATTACH (SQL_FORBIDDEN).
|
|
505
|
+
# @param output_path [String, nil] when given, write ALL rows (up to
|
|
506
|
+
# SQL_FILE_ROW_LIMIT) to a JSONL file plus a .meta.json sidecar, and
|
|
507
|
+
# return only a summary + 3-row sample; `limit` is ignored in this mode
|
|
508
|
+
# @param overwrite [Boolean] replace an existing output file (default: refuse)
|
|
509
|
+
def query_sql(sql, limit: SQL_ROW_LIMIT, timeout: SQL_TIMEOUT_SECONDS, attach: [],
|
|
510
|
+
output_path: nil, overwrite: false)
|
|
511
|
+
raise ArgumentError, "only SELECT/WITH queries are allowed" unless sql =~ /\A\s*(SELECT|WITH)\b/i
|
|
512
|
+
# Screen keywords outside string literals and quoted identifiers only, so
|
|
513
|
+
# legitimate data values (e.g. title LIKE '%Update%') are not rejected
|
|
514
|
+
screened = sql.gsub(/'(?:[^']|'')*'/m, "''").gsub(/"(?:[^"]|"")*"/m, '""')
|
|
515
|
+
raise ArgumentError, "query contains a forbidden keyword" if screened.match?(SQL_FORBIDDEN)
|
|
516
|
+
|
|
517
|
+
attachments = resolve_attachments(attach)
|
|
518
|
+
return query_sql_to_file(sql, timeout, attachments, output_path, overwrite) if output_path
|
|
519
|
+
|
|
520
|
+
limit = [[limit.to_i, 1].max, 1000].min
|
|
521
|
+
result = if Process.respond_to?(:fork)
|
|
522
|
+
run_sql_in_subprocess(sql, limit, timeout, attachments)
|
|
523
|
+
elsif attachments.empty?
|
|
524
|
+
run_sql_on(readonly_db, sql, limit)
|
|
525
|
+
else
|
|
526
|
+
db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
|
|
527
|
+
begin
|
|
528
|
+
run_sql_on(db, sql, limit)
|
|
529
|
+
ensure
|
|
530
|
+
db.close
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
result[:attached] = attachments_summary(attachments) unless attachments.empty?
|
|
534
|
+
result
|
|
535
|
+
rescue SQLite3::Exception => e
|
|
536
|
+
raise ArgumentError, "SQL error: #{e.message}"
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# CREATE statements of all tables available to query_sql
|
|
540
|
+
def describe_schema
|
|
541
|
+
db = readonly_db
|
|
542
|
+
schemas = { meta: db.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL").map(&:first) }
|
|
543
|
+
if fts.built?
|
|
544
|
+
schemas[:fts] = db.execute("SELECT sql FROM fts.sqlite_master WHERE sql IS NOT NULL").map(&:first)
|
|
545
|
+
end
|
|
546
|
+
schemas
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def close
|
|
550
|
+
close_read_connections
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
private
|
|
554
|
+
|
|
555
|
+
def close_read_connections
|
|
556
|
+
@metadata.close
|
|
557
|
+
@fts&.close
|
|
558
|
+
@readonly_db&.close
|
|
559
|
+
@readonly_db = nil
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def readonly_db
|
|
563
|
+
@readonly_db ||= build_readonly_connection(attach_fts: fts.built?)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def build_readonly_connection(attach_fts:, fts_path: fts.db_path, attachments: [])
|
|
567
|
+
db = SQLite3::Database.new(@metadata.db_path, readonly: true)
|
|
568
|
+
db.busy_timeout = 5000
|
|
569
|
+
# Attached databases inherit the main connection's read-only flag
|
|
570
|
+
db.execute("ATTACH DATABASE ? AS fts", [fts_path]) if attach_fts
|
|
571
|
+
# Cross-dump attachments: aliases and paths come only from validated
|
|
572
|
+
# language codes resolved server-side (resolve_attachments), never from
|
|
573
|
+
# user SQL; opened via mode=ro URIs (belt-and-braces with the
|
|
574
|
+
# inherited read-only flag)
|
|
575
|
+
attachments.each do |a|
|
|
576
|
+
db.execute("ATTACH DATABASE ? AS #{a[:alias]}_meta", [readonly_uri(a[:meta_path])])
|
|
577
|
+
db.execute("ATTACH DATABASE ? AS #{a[:alias]}_fts", [readonly_uri(a[:fts_path])]) if a[:fts_path]
|
|
578
|
+
end
|
|
579
|
+
db
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Language codes accepted by query_sql's attach argument
|
|
583
|
+
ATTACH_LANG_REGEX = /\A[a-z][a-z0-9-]{1,11}\z/
|
|
584
|
+
|
|
585
|
+
# Validate attach language codes and resolve them to local index DBs.
|
|
586
|
+
# The argument carries language codes only — never file paths; path
|
|
587
|
+
# resolution (glob the cache dir, inspect dump_name/built state) is
|
|
588
|
+
# server-side. Selection rule: prefer the dump with the same date as the
|
|
589
|
+
# main DB; otherwise take the most recently built one and flag the entry
|
|
590
|
+
# with dump_mismatch so the response must note it.
|
|
591
|
+
def resolve_attachments(attach)
|
|
592
|
+
Array(attach).compact.map(&:to_s).uniq.map do |lang|
|
|
593
|
+
unless lang.match?(ATTACH_LANG_REGEX)
|
|
594
|
+
raise ArgumentError, "invalid language code for attach: #{lang.inspect}"
|
|
595
|
+
end
|
|
596
|
+
if lang == own_lang
|
|
597
|
+
raise ArgumentError, "cannot attach '#{lang}': it is the language of the main database"
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
candidates = MetadataIndex.cached_candidates(lang, cache_dir: @cache_dir)
|
|
601
|
+
if candidates.empty?
|
|
602
|
+
raise ArgumentError,
|
|
603
|
+
"no installed index found for '#{lang}' (build it with: wp2txt --build-index -L #{lang})"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
main_date = dump_name[/\d{8}\z/]
|
|
607
|
+
pick = candidates.find { |c| c[:dump_name].to_s.end_with?(main_date.to_s) }
|
|
608
|
+
mismatch = pick.nil?
|
|
609
|
+
pick ||= candidates.first
|
|
610
|
+
|
|
611
|
+
fts_path = pick[:db_path].sub(/#{MetadataIndex::CACHE_SUFFIX}\z/, FtsIndex::CACHE_SUFFIX)
|
|
612
|
+
has_fts = File.exist?(fts_path) && fts_db_built?(fts_path)
|
|
613
|
+
|
|
614
|
+
{ lang: lang, alias: lang.tr("-", "_"),
|
|
615
|
+
meta_path: pick[:db_path], fts_path: has_fts ? fts_path : nil,
|
|
616
|
+
dump_name: pick[:dump_name], built_with: pick[:built_with],
|
|
617
|
+
fts: has_fts, dump_mismatch: mismatch }
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def fts_db_built?(path)
|
|
622
|
+
meta = MetadataIndex.read_metadata_file(path)
|
|
623
|
+
meta && meta[:schema_version].to_i == FtsIndex::SCHEMA_VERSION && !meta[:built_at].nil?
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# "jawiki-20260701" => "ja"
|
|
627
|
+
def own_lang
|
|
628
|
+
dump_name[/\A([a-z0-9_-]+?)wiki/, 1]
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def readonly_uri(path)
|
|
632
|
+
"file:#{URI::DEFAULT_PARSER.escape(File.expand_path(path))}?mode=ro"
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Row extraction shared by the inline (Windows fallback) and subprocess paths
|
|
636
|
+
def run_sql_on(db, sql, limit)
|
|
637
|
+
columns = nil
|
|
638
|
+
rows = []
|
|
639
|
+
db.query(sql) do |result|
|
|
640
|
+
columns = result.columns
|
|
641
|
+
result.each do |row|
|
|
642
|
+
break if rows.size >= limit
|
|
643
|
+
|
|
644
|
+
rows << row.map { |v| v.is_a?(String) && v.length > SQL_CELL_LIMIT ? "#{v[0, SQL_CELL_LIMIT]}…" : v }
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
{ columns: columns, rows: rows, row_count: rows.size, truncated: rows.size >= limit }
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Execute the query in a forked child with a hard deadline: the child opens
|
|
651
|
+
# its own read-only connection (no inherited handles), runs the query, and
|
|
652
|
+
# ships the result back over a pipe; a query that outlives the deadline is
|
|
653
|
+
# SIGKILLed — the only reliable abort while the gem holds the GVL.
|
|
654
|
+
def run_sql_in_subprocess(sql, limit, timeout, attachments = [])
|
|
655
|
+
attach_fts = fts.built?
|
|
656
|
+
fts_path = fts.db_path
|
|
657
|
+
reader_io, writer_io = IO.pipe
|
|
658
|
+
pid = Process.fork do
|
|
659
|
+
reader_io.close
|
|
660
|
+
outcome = begin
|
|
661
|
+
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
|
662
|
+
{ ok: run_sql_on(db, sql, limit) }
|
|
663
|
+
rescue SQLite3::Exception => e
|
|
664
|
+
{ err: "SQL error: #{e.message}" }
|
|
665
|
+
rescue StandardError => e
|
|
666
|
+
{ err: "#{e.class}: #{e.message}" }
|
|
667
|
+
end
|
|
668
|
+
Marshal.dump(outcome, writer_io)
|
|
669
|
+
writer_io.close
|
|
670
|
+
exit!(0)
|
|
671
|
+
end
|
|
672
|
+
writer_io.close
|
|
673
|
+
|
|
674
|
+
unless IO.select([reader_io], nil, nil, timeout)
|
|
675
|
+
Process.kill("KILL", pid)
|
|
676
|
+
Process.waitpid(pid)
|
|
677
|
+
raise ArgumentError, "query exceeded the #{timeout}s time limit#{explain_plan_hint(sql)}"
|
|
678
|
+
end
|
|
679
|
+
payload = reader_io.read
|
|
680
|
+
Process.waitpid(pid)
|
|
681
|
+
outcome = Marshal.load(payload)
|
|
682
|
+
raise ArgumentError, outcome[:err] if outcome[:err]
|
|
683
|
+
|
|
684
|
+
outcome[:ok]
|
|
685
|
+
ensure
|
|
686
|
+
reader_io&.close
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# ------------------------------------------------------------------
|
|
690
|
+
# query_sql file-output mode (D4 generalized to SQL: the full result
|
|
691
|
+
# goes to disk, the caller receives a summary plus a small sample)
|
|
692
|
+
# ------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
# Provenance summary of resolved attachments, shared by the interactive
|
|
695
|
+
# and file-output responses
|
|
696
|
+
def attachments_summary(attachments)
|
|
697
|
+
attachments.map do |a|
|
|
698
|
+
entry = { lang: a[:lang], dump_name: a[:dump_name], built_with: a[:built_with], fts: a[:fts] }
|
|
699
|
+
entry[:dump_mismatch] = true if a[:dump_mismatch]
|
|
700
|
+
entry
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Write the full query result to output_path as JSONL. Atomicity: the
|
|
705
|
+
# child (or inline fallback) writes "#{output_path}.partial"; the parent
|
|
706
|
+
# renames it into place only on success and removes it on every failure
|
|
707
|
+
# path (child crash, timeout kill, error over the pipe) — a partially
|
|
708
|
+
# written file is never presented as a result. The .meta.json sidecar is
|
|
709
|
+
# written by the parent after the rename succeeds.
|
|
710
|
+
def query_sql_to_file(sql, timeout, attachments, output_path, overwrite)
|
|
711
|
+
if File.exist?(output_path) && !overwrite
|
|
712
|
+
raise ArgumentError, "output file already exists: #{output_path} (pass overwrite: true to replace it)"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
partial = "#{output_path}.partial"
|
|
716
|
+
FileUtils.rm_f(partial)
|
|
717
|
+
outcome = begin
|
|
718
|
+
if Process.respond_to?(:fork)
|
|
719
|
+
run_sql_file_in_subprocess(sql, timeout, attachments, partial)
|
|
720
|
+
else
|
|
721
|
+
db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
|
|
722
|
+
begin
|
|
723
|
+
run_sql_file_on(db, sql, partial)
|
|
724
|
+
ensure
|
|
725
|
+
db.close
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
rescue StandardError
|
|
729
|
+
FileUtils.rm_f(partial)
|
|
730
|
+
raise
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
File.rename(partial, output_path)
|
|
734
|
+
write_sql_sidecar(output_path, sql, attachments, outcome)
|
|
735
|
+
|
|
736
|
+
result = { output_path: output_path, meta_path: "#{output_path}.meta.json",
|
|
737
|
+
columns: outcome[:columns], row_count: outcome[:row_count],
|
|
738
|
+
truncated: outcome[:truncated], cells_clipped: outcome[:cells_clipped],
|
|
739
|
+
sample: outcome[:sample], bytes: File.size(output_path) }
|
|
740
|
+
result[:attached] = attachments_summary(attachments) unless attachments.empty?
|
|
741
|
+
result
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Stream the query result into partial_path as JSONL, one object per row
|
|
745
|
+
# keyed by (deduplicated) column names. Runs in the forked child for the
|
|
746
|
+
# subprocess path: the 30s SIGKILL deadline covers the writing too.
|
|
747
|
+
def run_sql_file_on(db, sql, partial_path)
|
|
748
|
+
columns = nil
|
|
749
|
+
row_count = 0
|
|
750
|
+
cells_clipped = 0
|
|
751
|
+
truncated = false
|
|
752
|
+
sample = []
|
|
753
|
+
|
|
754
|
+
File.open(partial_path, "w") do |f|
|
|
755
|
+
db.query(sql) do |result|
|
|
756
|
+
columns = unique_columns(result.columns)
|
|
757
|
+
result.each do |row|
|
|
758
|
+
if row_count >= SQL_FILE_ROW_LIMIT
|
|
759
|
+
truncated = true
|
|
760
|
+
break
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
record = {}
|
|
764
|
+
row.each_with_index do |value, i|
|
|
765
|
+
if value.is_a?(String) && value.length > SQL_FILE_CELL_LIMIT
|
|
766
|
+
value = "#{value[0, SQL_FILE_CELL_LIMIT]}…"
|
|
767
|
+
cells_clipped += 1
|
|
768
|
+
end
|
|
769
|
+
record[columns[i]] = value
|
|
770
|
+
end
|
|
771
|
+
f.puts(JSON.generate(record))
|
|
772
|
+
sample << record if sample.size < 3
|
|
773
|
+
row_count += 1
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
{ columns: columns, row_count: row_count, truncated: truncated,
|
|
779
|
+
cells_clipped: cells_clipped, sample: sample }
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Duplicate result column names (SELECT 1 AS x, 2 AS x) are suffixed
|
|
783
|
+
# (_2, _3, ...) so every JSONL record key is unique
|
|
784
|
+
def unique_columns(columns)
|
|
785
|
+
seen = Hash.new(0)
|
|
786
|
+
columns.map do |c|
|
|
787
|
+
seen[c] += 1
|
|
788
|
+
seen[c] == 1 ? c : "#{c}_#{seen[c]}"
|
|
789
|
+
end
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Subprocess driver for file-output mode; same fork/pipe/SIGKILL
|
|
793
|
+
# structure as run_sql_in_subprocess, but the child writes the rows to
|
|
794
|
+
# partial_path and ships back only the summary
|
|
795
|
+
def run_sql_file_in_subprocess(sql, timeout, attachments, partial_path)
|
|
796
|
+
attach_fts = fts.built?
|
|
797
|
+
fts_path = fts.db_path
|
|
798
|
+
reader_io, writer_io = IO.pipe
|
|
799
|
+
pid = Process.fork do
|
|
800
|
+
reader_io.close
|
|
801
|
+
outcome = begin
|
|
802
|
+
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
|
803
|
+
{ ok: run_sql_file_on(db, sql, partial_path) }
|
|
804
|
+
rescue SQLite3::Exception => e
|
|
805
|
+
{ err: "SQL error: #{e.message}" }
|
|
806
|
+
rescue StandardError => e
|
|
807
|
+
{ err: "#{e.class}: #{e.message}" }
|
|
808
|
+
end
|
|
809
|
+
Marshal.dump(outcome, writer_io)
|
|
810
|
+
writer_io.close
|
|
811
|
+
exit!(0)
|
|
812
|
+
end
|
|
813
|
+
writer_io.close
|
|
814
|
+
|
|
815
|
+
unless IO.select([reader_io], nil, nil, timeout)
|
|
816
|
+
Process.kill("KILL", pid)
|
|
817
|
+
Process.waitpid(pid)
|
|
818
|
+
raise ArgumentError, "query exceeded the #{timeout}s time limit#{explain_plan_hint(sql)}"
|
|
819
|
+
end
|
|
820
|
+
payload = reader_io.read
|
|
821
|
+
Process.waitpid(pid)
|
|
822
|
+
raise ArgumentError, "query failed: the child process died without a result" if payload.empty?
|
|
823
|
+
|
|
824
|
+
outcome = Marshal.load(payload)
|
|
825
|
+
raise ArgumentError, outcome[:err] if outcome[:err]
|
|
826
|
+
|
|
827
|
+
outcome[:ok]
|
|
828
|
+
ensure
|
|
829
|
+
reader_io&.close
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Reproducibility sidecar, written by the parent after the atomic rename
|
|
833
|
+
def write_sql_sidecar(output_path, sql, attachments, outcome)
|
|
834
|
+
File.write("#{output_path}.meta.json", JSON.pretty_generate(
|
|
835
|
+
tool: "query_sql",
|
|
836
|
+
dump: dump_name,
|
|
837
|
+
built_with: @metadata.stats&.dig(:built_with),
|
|
838
|
+
sql: sql,
|
|
839
|
+
attached: attachments.map { |a| { lang: a[:lang], dump_name: a[:dump_name], built_with: a[:built_with] } },
|
|
840
|
+
row_count: outcome[:row_count],
|
|
841
|
+
truncated: outcome[:truncated],
|
|
842
|
+
cells_clipped: outcome[:cells_clipped],
|
|
843
|
+
generated_at: Time.now.utc.iso8601,
|
|
844
|
+
wp2txt_version: Wp2txt::VERSION
|
|
845
|
+
))
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# Best-effort post-mortem for a timed-out query: EXPLAIN QUERY PLAN is
|
|
849
|
+
# instant and safe (it never executes the query), and the plan tree makes
|
|
850
|
+
# the two common pathologies recognizable
|
|
851
|
+
def explain_plan_hint(sql)
|
|
852
|
+
rows = readonly_db.execute("EXPLAIN QUERY PLAN #{sql}")
|
|
853
|
+
details = rows.map { |r| r[3].to_s }
|
|
854
|
+
by_id = rows.to_h { |r| [r[0], { parent: r[1], detail: r[3].to_s }] }
|
|
855
|
+
|
|
856
|
+
nested_scan = rows.any? do |id, parent, _n, detail|
|
|
857
|
+
next false unless detail.to_s.start_with?("SCAN")
|
|
858
|
+
|
|
859
|
+
ancestor = parent
|
|
860
|
+
found = false
|
|
861
|
+
while ancestor && (node = by_id[ancestor])
|
|
862
|
+
found ||= node[:detail].start_with?("SCAN")
|
|
863
|
+
ancestor = node[:parent]
|
|
864
|
+
end
|
|
865
|
+
found
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
if nested_scan
|
|
869
|
+
" — the query plan shows a full table scan nested inside another full scan " \
|
|
870
|
+
"(likely a cartesian product); join the tables on an indexed key such as page_id"
|
|
871
|
+
elsif details.any? { |d| d.include?("RECURSIVE STEP") }
|
|
872
|
+
" — the query uses a recursive CTE; make sure the recursion is bounded " \
|
|
873
|
+
"(e.g. a depth column with WHERE depth < N)"
|
|
874
|
+
else
|
|
875
|
+
" — narrow the query with additional WHERE filters or aggregate in SQL instead of returning rows"
|
|
876
|
+
end
|
|
877
|
+
rescue StandardError
|
|
878
|
+
""
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Re-render the matched section of each hit from the dump and cut a window
|
|
882
|
+
# around the first occurrence of the search term (contentless FTS stores no
|
|
883
|
+
# text, so the dump is the source of truth for snippets)
|
|
884
|
+
def attach_snippets(hits, query, mode)
|
|
885
|
+
needle = mode == "query" ? query[/"([^"]+)"/, 1] || query[/\w{3,}/] || query : query
|
|
886
|
+
renderer = SectionRenderer.new
|
|
887
|
+
pages = {}
|
|
888
|
+
hits.each do |hit|
|
|
889
|
+
page = pages[hit[:page_id]] ||= reader.extract_article(hit[:title])
|
|
890
|
+
next unless page
|
|
891
|
+
|
|
892
|
+
section = renderer.render_sections(page[:title], page[:text])
|
|
893
|
+
.find { |_h, ord, _t| ord == hit[:ord] }
|
|
894
|
+
next unless section
|
|
895
|
+
|
|
896
|
+
text = section[2]
|
|
897
|
+
pos = needle ? text.downcase.index(needle.downcase) : nil
|
|
898
|
+
hit[:snippet] = if pos
|
|
899
|
+
from = [pos - SNIPPET_CONTEXT, 0].max
|
|
900
|
+
"#{'…' if from.positive?}#{text[from, needle.length + SNIPPET_CONTEXT * 2]}…"
|
|
901
|
+
else
|
|
902
|
+
"#{text[0, SNIPPET_CONTEXT * 2]}…"
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def dump_name
|
|
908
|
+
@dump_name ||= @metadata.built? ? @metadata.stats[:dump_name] : File.basename(@multistream_path)
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def reader
|
|
912
|
+
@reader ||= begin
|
|
913
|
+
cache = IndexCache.new(@index_path, cache_dir: @cache_dir)
|
|
914
|
+
index = if cache.valid?
|
|
915
|
+
LazyTitleIndex.new(cache)
|
|
916
|
+
else
|
|
917
|
+
MultistreamIndex.new(@index_path, use_cache: true, cache_dir: @cache_dir, show_progress: false)
|
|
918
|
+
end
|
|
919
|
+
# Memoize stream offsets in the parent before any Parallel fork, so
|
|
920
|
+
# workers inherit the array instead of racing on the SQLite cache
|
|
921
|
+
index.stream_offsets
|
|
922
|
+
MultistreamReader.new(@multistream_path, index)
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# Resolve a title the way MediaWiki does: try the exact form, then
|
|
927
|
+
# normalized variants (underscores to spaces, first letter capitalized).
|
|
928
|
+
# Cold-start LLM clients routinely send un-normalized titles.
|
|
929
|
+
def fetch_page(title, follow_redirect: true)
|
|
930
|
+
page = nil
|
|
931
|
+
title_variants(title).each do |t|
|
|
932
|
+
page = reader.extract_article(t)
|
|
933
|
+
break if page
|
|
934
|
+
end
|
|
935
|
+
return nil unless page
|
|
936
|
+
|
|
937
|
+
if follow_redirect && (m = REDIRECT_REGEX.match(page[:text].to_s))
|
|
938
|
+
target = m[1].split(/[#|]/).first.to_s.strip
|
|
939
|
+
redirected = target.empty? ? nil : reader.extract_article(target)
|
|
940
|
+
page = redirected if redirected
|
|
941
|
+
end
|
|
942
|
+
page
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
def title_variants(title)
|
|
946
|
+
variants = [title]
|
|
947
|
+
normalized = title.tr("_", " ").squeeze(" ").strip
|
|
948
|
+
variants << normalized
|
|
949
|
+
variants << (normalized[0].to_s.upcase + normalized[1..].to_s) unless normalized.empty?
|
|
950
|
+
variants.uniq
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def render_text(page)
|
|
954
|
+
config = RENDER_CONFIG.merge(format: :text, title: page[:title])
|
|
955
|
+
article = Article.new(page[:text], page[:title], false)
|
|
956
|
+
format_article(article, config).to_s
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# Run the co-occurrence check over every pair in every group; returns pairs
|
|
960
|
+
# that look like distinct section roles rather than synonyms
|
|
961
|
+
def check_alias_groups(groups, max_ratio:, min_articles:)
|
|
962
|
+
violations = []
|
|
963
|
+
groups.each do |group|
|
|
964
|
+
next if group.size < 2
|
|
965
|
+
|
|
966
|
+
result = @metadata.section_cooccurrence(group)
|
|
967
|
+
counts = result[:headings].to_h { |h| [h[:heading], h[:articles]] }
|
|
968
|
+
result[:pairs].each do |pair|
|
|
969
|
+
next if [counts[pair[:a]], counts[pair[:b]]].min < min_articles
|
|
970
|
+
violations << pair if pair[:cooccurrence_ratio] > max_ratio
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
violations
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def expand_with_alias_set(sections, alias_set)
|
|
977
|
+
names = Array(sections).compact
|
|
978
|
+
return names unless alias_set
|
|
979
|
+
|
|
980
|
+
set = @metadata.get_alias_set(alias_set)
|
|
981
|
+
raise ArgumentError, "alias set not found: #{alias_set}" unless set
|
|
982
|
+
|
|
983
|
+
base = names.empty? ? set[:groups].map(&:first) : names
|
|
984
|
+
base.flat_map do |n|
|
|
985
|
+
group = set[:groups].find { |g| g.any? { |x| x.downcase == n.downcase } }
|
|
986
|
+
group || [n]
|
|
987
|
+
end.uniq
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# Build the JSONL records for one page. Without chunking this is one record
|
|
991
|
+
# per article; with chunking, one RAG-ready record per (section, chunk).
|
|
992
|
+
def build_records(page, content, resolved_sections, chunk_size, chunk_overlap)
|
|
993
|
+
article = Article.new(page[:text], page[:title], false)
|
|
994
|
+
categories = article.categories.flatten
|
|
995
|
+
|
|
996
|
+
if content == "wikitext"
|
|
997
|
+
# Raw markup for structure mining (infoboxes, templates, citations)
|
|
998
|
+
[{ id: page[:id], title: page[:title], wikitext: page[:text], categories: categories }]
|
|
999
|
+
elsif content == "full"
|
|
1000
|
+
config = RENDER_CONFIG.merge(format: :text, title: page[:title], category: false)
|
|
1001
|
+
text = format_article(article, config).to_s.strip
|
|
1002
|
+
return [] if text.empty?
|
|
1003
|
+
return chunk_records(page, nil, text, categories, chunk_size, chunk_overlap) if chunk_size
|
|
1004
|
+
|
|
1005
|
+
[{ id: page[:id], title: page[:title], text: text, categories: categories }]
|
|
1006
|
+
else # "sections" / "summary"
|
|
1007
|
+
config = RENDER_CONFIG.merge(format: :json, sections: resolved_sections, title: page[:title])
|
|
1008
|
+
result = format_with_sections(article, config)
|
|
1009
|
+
return [] unless result
|
|
1010
|
+
|
|
1011
|
+
present = (result["sections"] || {}).reject { |_k, v| v.nil? || v.empty? }
|
|
1012
|
+
return [] if present.empty?
|
|
1013
|
+
|
|
1014
|
+
if chunk_size
|
|
1015
|
+
return present.flat_map do |section, text|
|
|
1016
|
+
chunk_records(page, section, text, categories, chunk_size, chunk_overlap)
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
[{ id: page[:id], title: page[:title], sections: present,
|
|
1021
|
+
section_path: present.keys.map { |k| "#{page[:title]} > #{k}" },
|
|
1022
|
+
categories: categories }]
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
def chunk_records(page, section, text, categories, chunk_size, chunk_overlap)
|
|
1027
|
+
chunks = chunk_text(text, chunk_size, chunk_overlap)
|
|
1028
|
+
path = section ? "#{page[:title]} > #{section}" : page[:title]
|
|
1029
|
+
chunks.each_with_index.map do |chunk, i|
|
|
1030
|
+
{ id: page[:id], title: page[:title], section: section, section_path: path,
|
|
1031
|
+
chunk_index: i, chunk_count: chunks.size, text: chunk, categories: categories }
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# Character-based chunking that prefers to break at a paragraph or sentence
|
|
1036
|
+
# boundary within the last quarter of the window
|
|
1037
|
+
def chunk_text(text, size, overlap)
|
|
1038
|
+
return [text] if text.length <= size
|
|
1039
|
+
|
|
1040
|
+
chunks = []
|
|
1041
|
+
start = 0
|
|
1042
|
+
while start < text.length
|
|
1043
|
+
window_end = [start + size, text.length].min
|
|
1044
|
+
if window_end < text.length
|
|
1045
|
+
slice = text[start...window_end]
|
|
1046
|
+
boundary = slice.rindex(/[\n。.!?!?]/)
|
|
1047
|
+
window_end = start + boundary + 1 if boundary && boundary >= size * 3 / 4
|
|
1048
|
+
end
|
|
1049
|
+
chunks << text[start...window_end]
|
|
1050
|
+
break if window_end >= text.length
|
|
1051
|
+
|
|
1052
|
+
start = [window_end - overlap, start + 1].max
|
|
1053
|
+
end
|
|
1054
|
+
chunks
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
end
|