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