wp2txt 2.1.2 → 2.2.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,722 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../wp2txt"
6
+ require_relative "article"
7
+ require_relative "utils"
8
+ require_relative "formatter"
9
+ require_relative "multistream"
10
+ require_relative "metadata_index"
11
+ require_relative "fts_index"
12
+ require_relative "section_extractor"
13
+ require_relative "version"
14
+
15
+ module Wp2txt
16
+ # Facade over a local dump: single-article access (Tier 0, multistream),
17
+ # exhaustive metadata queries (Tier 1, MetadataIndex), and corpus extraction.
18
+ # Shared by the CLI and the MCP server so both expose identical behavior.
19
+ class Corpus
20
+ include Wp2txt
21
+ include Wp2txt::Formatter
22
+
23
+ # Sync extraction cap: larger requests need the (future) job API
24
+ DEFAULT_MAX_SYNC_ARTICLES = 5000
25
+
26
+ RENDER_CONFIG = {
27
+ format: :text,
28
+ title: true, heading: true, list: false, table: false, pre: false,
29
+ ref: false, redirect: false, multiline: false,
30
+ category: true, category_only: false, summary_only: false, metadata_only: false,
31
+ marker: true, markers: true, extract_citations: false, expand_templates: true,
32
+ sections: nil, section_output: "structured", min_section_length: 0,
33
+ skip_empty: false, alias_file: nil, no_section_aliases: false,
34
+ show_matched_sections: false
35
+ }.freeze
36
+
37
+ # Duck-typed replacement for MultistreamIndex that resolves titles through
38
+ # the IndexCache SQLite file on demand, avoiding loading millions of index
39
+ # entries into memory (important for long-lived server processes).
40
+ class LazyTitleIndex
41
+ def initialize(index_cache)
42
+ @cache = index_cache
43
+ end
44
+
45
+ def find_by_title(title)
46
+ @cache.find_by_titles([title])[title]
47
+ end
48
+
49
+ def stream_offset_for(title)
50
+ find_by_title(title)&.fetch(:offset, nil)
51
+ end
52
+
53
+ def stream_offsets
54
+ @stream_offsets ||= @cache.stream_offsets
55
+ end
56
+ end
57
+
58
+ attr_reader :multistream_path, :index_path, :metadata
59
+
60
+ def initialize(multistream_path:, index_path:, cache_dir: nil)
61
+ @multistream_path = multistream_path
62
+ @index_path = index_path
63
+ @cache_dir = cache_dir
64
+ @metadata = MetadataIndex.new(MetadataIndex.path_for(multistream_path, cache_dir: cache_dir))
65
+ end
66
+
67
+ # Build a Corpus from a language code using the DumpManager cache.
68
+ # Raises with guidance when the dump has not been downloaded yet.
69
+ def self.for_lang(lang, cache_dir: nil)
70
+ manager = DumpManager.new(lang, cache_dir: cache_dir)
71
+ multistream = manager.cached_multistream_path
72
+ index = manager.cached_index_path
73
+ unless File.exist?(multistream) && File.exist?(index)
74
+ raise ArgumentError, "No cached dump for '#{lang}'. Run: wp2txt --build-index -L #{lang}"
75
+ end
76
+
77
+ new(multistream_path: multistream, index_path: index, cache_dir: cache_dir)
78
+ end
79
+
80
+ def self.for_input(multistream_path, cache_dir: nil)
81
+ candidates = [
82
+ multistream_path.sub(/multistream\.xml\.bz2\z/, "multistream-index.txt.bz2"),
83
+ multistream_path.sub(/\.xml\.bz2\z/, "-index.txt.bz2"),
84
+ multistream_path.sub(/\.xml\.bz2\z/, "-index.txt")
85
+ ].uniq
86
+ index = candidates.find { |c| c != multistream_path && File.exist?(c) }
87
+ raise ArgumentError, "Multistream index file not found next to #{multistream_path}" unless index
88
+
89
+ new(multistream_path: multistream_path, index_path: index, cache_dir: cache_dir)
90
+ end
91
+
92
+ # ------------------------------------------------------------------
93
+ # Info
94
+ # ------------------------------------------------------------------
95
+
96
+ def metadata_built?
97
+ @metadata.built?
98
+ end
99
+
100
+ def dump_info
101
+ stats = @metadata.built? ? @metadata.stats : nil
102
+ {
103
+ multistream_path: @multistream_path,
104
+ dump: stats&.dig(:dump_name) || File.basename(@multistream_path),
105
+ tiers: {
106
+ titles: File.exist?(@index_path),
107
+ metadata: @metadata.built?,
108
+ fulltext: fts.built?
109
+ },
110
+ metadata_current: @metadata.built? && @metadata.valid_for?(@multistream_path),
111
+ fulltext_current: fts.built? && fts.valid_for?(@multistream_path),
112
+ stats: stats,
113
+ fulltext: fts.built? ? fts.stats : nil
114
+ }
115
+ end
116
+
117
+ def fts
118
+ @fts ||= FtsIndex.new(
119
+ FtsIndex.path_for(@multistream_path, cache_dir: @cache_dir),
120
+ @metadata.db_path
121
+ )
122
+ end
123
+
124
+ # ------------------------------------------------------------------
125
+ # Tier 0: single-article access
126
+ # ------------------------------------------------------------------
127
+
128
+ # Default cap on article text returned inline (LLM context economy);
129
+ # callers can raise it explicitly, and truncation is always flagged
130
+ DEFAULT_MAX_CHARS = 40_000
131
+
132
+ # @param format [String] "text" (cleaned), "wikitext" (raw markup)
133
+ # @param follow_redirect [Boolean] resolve one redirect hop
134
+ # @param max_chars [Integer, nil] truncate text beyond this length (nil = no cap)
135
+ def get_article(title, format: "text", follow_redirect: true, max_chars: DEFAULT_MAX_CHARS)
136
+ page = fetch_page(title, follow_redirect: follow_redirect)
137
+ return nil unless page
138
+
139
+ body = case format.to_s
140
+ when "wikitext"
141
+ page[:text]
142
+ else
143
+ render_text(page)
144
+ end
145
+ result = { id: page[:id], title: page[:title], format: format.to_s }
146
+ if max_chars && body.length > max_chars
147
+ result.merge(text: body[0, max_chars], truncated: true, total_chars: body.length)
148
+ else
149
+ result.merge(text: body)
150
+ end
151
+ end
152
+
153
+ # Categories of one article, resolved through the same title normalization
154
+ def get_categories(title)
155
+ cats = @metadata.categories_of(title)
156
+ if cats.nil?
157
+ page = fetch_page(title)
158
+ cats = page ? @metadata.categories_of(page[:title]) : nil
159
+ end
160
+ return nil unless cats
161
+
162
+ { title: title, categories: cats }
163
+ end
164
+
165
+ # Extract specific sections from one article.
166
+ # @param sections [Array<String>] section names ("summary" for lead text)
167
+ # @param alias_set [String, nil] saved alias set used to expand names
168
+ def get_sections(title, sections, alias_set: nil)
169
+ page = fetch_page(title)
170
+ return nil unless page
171
+
172
+ resolved = expand_with_alias_set(sections, alias_set)
173
+ config = RENDER_CONFIG.merge(format: :json, sections: resolved, title: page[:title])
174
+ article = Article.new(page[:text], page[:title], false)
175
+ result = format_with_sections(article, config)
176
+ { id: page[:id], title: page[:title], requested: sections, resolved: resolved,
177
+ sections: result ? result["sections"] : {} }
178
+ end
179
+
180
+ def list_headings(title)
181
+ page = fetch_page(title)
182
+ return nil unless page
183
+
184
+ article = Article.new(page[:text], page[:title], false)
185
+ { id: page[:id], title: page[:title],
186
+ headings: SectionExtractor.new.extract_headings_with_levels(article) }
187
+ end
188
+
189
+ # ------------------------------------------------------------------
190
+ # Tier 1: exhaustive queries (delegated to MetadataIndex)
191
+ # ------------------------------------------------------------------
192
+
193
+ def find_articles(**filters)
194
+ limit = filters.delete(:limit) || 0
195
+ offset = filters.delete(:offset) || 0
196
+ total = @metadata.count_articles(**filters)
197
+ titles = @metadata.find_articles(**filters, limit: limit, offset: offset)
198
+ { dump: dump_name, total: total, returned: titles.size, titles: titles }
199
+ end
200
+
201
+ def category_tree(category, depth: 2)
202
+ { dump: dump_name, tree: @metadata.category_tree(category, depth: depth) }
203
+ end
204
+
205
+ def section_stats(category: nil, depth: 0, top_n: 50)
206
+ { dump: dump_name,
207
+ sections: @metadata.section_stats(category: category, depth: depth, top_n: top_n)
208
+ .map { |h, c| { heading: h, articles: c } } }
209
+ end
210
+
211
+ def section_cooccurrence(headings, category: nil, depth: 0)
212
+ @metadata.section_cooccurrence(headings, category: category, depth: depth)
213
+ .merge(dump: dump_name)
214
+ end
215
+
216
+ # Guardrail thresholds: pairs whose co-occurrence ratio exceeds
217
+ # GUARDRAIL_MAX_RATIO (with both headings above GUARDRAIL_MIN_ARTICLES)
218
+ # coexist in the same articles and are likely NOT synonyms
219
+ GUARDRAIL_MAX_RATIO = 0.2
220
+ GUARDRAIL_MIN_ARTICLES = 100
221
+
222
+ # Save an alias set after mechanically verifying each group: high
223
+ # co-occurrence pairs block the save unless force is set, so protocol
224
+ # compliance does not depend on the calling model's discipline.
225
+ # ------------------------------------------------------------------
226
+ # Tier 2: full-text search
227
+ # ------------------------------------------------------------------
228
+
229
+ SNIPPET_CONTEXT = 80
230
+
231
+ # Exhaustive full-text search over cleaned section text.
232
+ # @param mode [String] "phrase" (literal, default) or "query" (raw FTS5 syntax)
233
+ # @param count [String] "capped" (fast, default) or "exact" (may take seconds for common terms)
234
+ # @param snippets [Boolean] re-render matched sections from the dump for context
235
+ def search_text(query, mode: "phrase", sections: nil, alias_set: nil,
236
+ category: nil, depth: 0, limit: 20, offset: 0,
237
+ count: "capped", snippets: true)
238
+ unless fts.built?
239
+ raise ArgumentError, "Full-text index not built. Run: wp2txt --build-index --fulltext"
240
+ end
241
+
242
+ resolved = expand_with_alias_set(sections, alias_set)
243
+ resolved = nil if resolved.empty?
244
+ result = fts.search(query, mode: mode, sections: resolved, category: category,
245
+ depth: depth, limit: limit, offset: offset, count: count)
246
+
247
+ hits = result[:hits]
248
+ attach_snippets(hits, query, mode) if snippets && !hits.empty?
249
+
250
+ { dump: dump_name, query: query, mode: mode,
251
+ total: result[:total], total_is_capped: result[:total_is_capped],
252
+ returned: hits.size,
253
+ hits: hits.map do |h|
254
+ { page_id: h[:page_id], title: h[:title],
255
+ section: h[:heading].to_s.empty? ? nil : h[:heading],
256
+ section_path: h[:heading].to_s.empty? ? h[:title] : "#{h[:title]} > #{h[:heading]}",
257
+ snippet: h[:snippet] }.compact
258
+ end }
259
+ end
260
+
261
+ def save_alias_set(name, groups, force: false,
262
+ max_ratio: GUARDRAIL_MAX_RATIO, min_articles: GUARDRAIL_MIN_ARTICLES)
263
+ unless groups.is_a?(Array) && !groups.empty? && groups.all? { |g| g.is_a?(Array) && !g.empty? }
264
+ raise ArgumentError, "groups must be a non-empty array of arrays"
265
+ end
266
+
267
+ # Report the exact thresholds used so both the model and the user can see
268
+ # why a group was accepted or rejected, not just that it was
269
+ criteria = { max_cooccurrence_ratio: max_ratio, min_articles: min_articles }
270
+ violations = check_alias_groups(groups, max_ratio: max_ratio, min_articles: min_articles)
271
+ if violations.any? && !force
272
+ return { saved: false, name: name, groups: groups, violations: violations, criteria: criteria,
273
+ warning: "These heading pairs coexist in the same article more than " \
274
+ "#{(max_ratio * 100).round}% of the time (both headings appear in at least " \
275
+ "#{min_articles} articles), so they are likely different sections, not synonyms. " \
276
+ "Remove them from the group, or pass force: true if you verified them another " \
277
+ "way (e.g., by reading section contents with get_sections)." }
278
+ end
279
+
280
+ @metadata.save_alias_set(name, groups)
281
+ { saved: true, name: name, groups: groups, violations: violations, criteria: criteria }
282
+ end
283
+
284
+ def get_alias_set(name)
285
+ @metadata.get_alias_set(name)
286
+ end
287
+
288
+ def list_alias_sets
289
+ @metadata.list_alias_sets
290
+ end
291
+
292
+ # ------------------------------------------------------------------
293
+ # Corpus extraction (synchronous; D4 pattern — results go to disk,
294
+ # the caller receives a summary plus a small sample)
295
+ # ------------------------------------------------------------------
296
+
297
+ # Raised via cancel_check to abort a running extraction (job cancellation)
298
+ class Cancelled < StandardError; end
299
+
300
+ # Titles fetched/rendered per batch while streaming to disk
301
+ EXTRACT_BATCH_SIZE = 200
302
+
303
+ # @param output_path [String] JSONL destination (sidecar .meta.json is added)
304
+ # @param content [String] "sections" | "full" | "summary"
305
+ # @param chunk_size [Integer, nil] split text into ~N-char chunks (RAG-ready records)
306
+ # @param chunk_overlap [Integer] overlap between consecutive chunks
307
+ # @param max_articles [Integer, nil] sync cap (nil = unlimited, for jobs)
308
+ # @param progress [Proc, nil] called with (titles_done, titles_total) after each batch
309
+ # @param cancel_check [Proc, nil] polled between batches; truthy return aborts with Cancelled
310
+ def extract_corpus(output_path:, content: "sections", sections: nil, alias_set: nil,
311
+ category: nil, depth: 0, categories: nil, category_match: nil,
312
+ title_match: nil, limit: 0,
313
+ chunk_size: nil, chunk_overlap: 0,
314
+ max_articles: DEFAULT_MAX_SYNC_ARTICLES, num_processes: 4,
315
+ progress: nil, cancel_check: nil)
316
+ if content == "sections" && Array(sections).empty? && alias_set.nil?
317
+ raise ArgumentError, "content: \"sections\" requires sections or alias_set"
318
+ end
319
+ raise ArgumentError, "chunk_overlap must be smaller than chunk_size" if chunk_size && chunk_overlap >= chunk_size
320
+ raise ArgumentError, "chunking is not supported for content: \"wikitext\"" if chunk_size && content == "wikitext"
321
+
322
+ filters = { category: category, depth: depth, categories: categories,
323
+ category_match: category_match, sections: sections,
324
+ alias_set: alias_set, title_match: title_match }
325
+ total = @metadata.count_articles(**filters)
326
+ cap = if limit.positive?
327
+ max_articles ? [limit, max_articles].min : limit
328
+ else
329
+ max_articles || total
330
+ end
331
+ titles = @metadata.find_articles(**filters, limit: cap)
332
+ truncated = total > titles.size
333
+
334
+ resolved_sections = content == "summary" ? [SectionExtractor::SUMMARY_KEY] : expand_with_alias_set(sections, alias_set)
335
+ alias_contents = alias_set ? get_alias_set(alias_set)&.dig(:groups) : nil
336
+
337
+ # Close ALL SQLite connections before MultistreamReader forks workers
338
+ # (children must not inherit open database handles)
339
+ close_read_connections
340
+
341
+ articles_extracted = 0
342
+ records_written = 0
343
+ titles_done = 0
344
+ sample = []
345
+
346
+ File.open(output_path, "w") do |f|
347
+ titles.each_slice(EXTRACT_BATCH_SIZE) do |batch|
348
+ raise Cancelled if cancel_check&.call
349
+
350
+ pages = reader.extract_articles_parallel(batch, num_processes: num_processes)
351
+ batch.each do |t|
352
+ page = pages[t]
353
+ next unless page
354
+
355
+ records = build_records(page, content, resolved_sections, chunk_size, chunk_overlap)
356
+ next if records.empty?
357
+
358
+ articles_extracted += 1
359
+ records.each do |record|
360
+ f.puts(JSON.generate(record))
361
+ records_written += 1
362
+ sample << record if sample.size < 3
363
+ end
364
+ end
365
+ titles_done += batch.size
366
+ progress&.call(titles_done, titles.size)
367
+ end
368
+ end
369
+
370
+ meta_path = "#{output_path}.meta.json"
371
+ File.write(meta_path, JSON.pretty_generate(
372
+ tool: "wp2txt #{Wp2txt::VERSION}",
373
+ dump: dump_name,
374
+ generated_at: Time.now.utc.iso8601,
375
+ query: filters.compact.merge(content: content, resolved_sections: resolved_sections,
376
+ chunk_size: chunk_size, chunk_overlap: chunk_size ? chunk_overlap : nil).compact,
377
+ alias_set_contents: alias_contents,
378
+ total_matching: total,
379
+ articles_extracted: articles_extracted,
380
+ records_written: records_written,
381
+ truncated: truncated
382
+ ))
383
+
384
+ { output_path: output_path, meta_path: meta_path, dump: dump_name,
385
+ total_matching: total, articles_extracted: articles_extracted,
386
+ records_written: records_written, truncated: truncated,
387
+ bytes: File.size(output_path), sample: sample }
388
+ end
389
+
390
+ # ------------------------------------------------------------------
391
+ # Read-only SQL (escape hatch for queries the fixed tools cannot express)
392
+ # ------------------------------------------------------------------
393
+
394
+ SQL_ROW_LIMIT = 200
395
+ SQL_CELL_LIMIT = 2000
396
+ SQL_TIMEOUT_SECONDS = 30
397
+ SQL_FORBIDDEN = /\b(ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE|VACUUM|REINDEX)\b/i
398
+
399
+ # Run a read-only SELECT against the metadata DB (with the FTS DB attached
400
+ # as `fts` when built). Defense layers: keyword screening (outside string
401
+ # literals), an SQLITE_OPEN_READONLY connection so writes are impossible at
402
+ # the driver level, and subprocess execution with a hard wall-clock timeout
403
+ # — the sqlite3 gem holds the GVL during C execution, so a runaway query
404
+ # can only be stopped by killing the process running it. On timeout, the
405
+ # error message includes an EXPLAIN QUERY PLAN diagnosis when a likely
406
+ # cause (nested full scans, unbounded recursion) is recognizable.
407
+ def query_sql(sql, limit: SQL_ROW_LIMIT, timeout: SQL_TIMEOUT_SECONDS)
408
+ raise ArgumentError, "only SELECT/WITH queries are allowed" unless sql =~ /\A\s*(SELECT|WITH)\b/i
409
+ # Screen keywords outside string literals and quoted identifiers only, so
410
+ # legitimate data values (e.g. title LIKE '%Update%') are not rejected
411
+ screened = sql.gsub(/'(?:[^']|'')*'/m, "''").gsub(/"(?:[^"]|"")*"/m, '""')
412
+ raise ArgumentError, "query contains a forbidden keyword" if screened.match?(SQL_FORBIDDEN)
413
+
414
+ limit = [[limit.to_i, 1].max, 1000].min
415
+ if Process.respond_to?(:fork)
416
+ run_sql_in_subprocess(sql, limit, timeout)
417
+ else
418
+ run_sql_on(readonly_db, sql, limit)
419
+ end
420
+ rescue SQLite3::Exception => e
421
+ raise ArgumentError, "SQL error: #{e.message}"
422
+ end
423
+
424
+ # CREATE statements of all tables available to query_sql
425
+ def describe_schema
426
+ db = readonly_db
427
+ schemas = { meta: db.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL").map(&:first) }
428
+ if fts.built?
429
+ schemas[:fts] = db.execute("SELECT sql FROM fts.sqlite_master WHERE sql IS NOT NULL").map(&:first)
430
+ end
431
+ schemas
432
+ end
433
+
434
+ def close
435
+ close_read_connections
436
+ end
437
+
438
+ private
439
+
440
+ def close_read_connections
441
+ @metadata.close
442
+ @fts&.close
443
+ @readonly_db&.close
444
+ @readonly_db = nil
445
+ end
446
+
447
+ def readonly_db
448
+ @readonly_db ||= build_readonly_connection(attach_fts: fts.built?)
449
+ end
450
+
451
+ def build_readonly_connection(attach_fts:, fts_path: fts.db_path)
452
+ db = SQLite3::Database.new(@metadata.db_path, readonly: true)
453
+ db.busy_timeout = 5000
454
+ # Attached databases inherit the main connection's read-only flag
455
+ db.execute("ATTACH DATABASE ? AS fts", [fts_path]) if attach_fts
456
+ db
457
+ end
458
+
459
+ # Row extraction shared by the inline (Windows fallback) and subprocess paths
460
+ def run_sql_on(db, sql, limit)
461
+ columns = nil
462
+ rows = []
463
+ db.query(sql) do |result|
464
+ columns = result.columns
465
+ result.each do |row|
466
+ break if rows.size >= limit
467
+
468
+ rows << row.map { |v| v.is_a?(String) && v.length > SQL_CELL_LIMIT ? "#{v[0, SQL_CELL_LIMIT]}…" : v }
469
+ end
470
+ end
471
+ { columns: columns, rows: rows, row_count: rows.size, truncated: rows.size >= limit }
472
+ end
473
+
474
+ # Execute the query in a forked child with a hard deadline: the child opens
475
+ # its own read-only connection (no inherited handles), runs the query, and
476
+ # ships the result back over a pipe; a query that outlives the deadline is
477
+ # SIGKILLed — the only reliable abort while the gem holds the GVL.
478
+ def run_sql_in_subprocess(sql, limit, timeout)
479
+ attach_fts = fts.built?
480
+ fts_path = fts.db_path
481
+ reader_io, writer_io = IO.pipe
482
+ pid = Process.fork do
483
+ reader_io.close
484
+ outcome = begin
485
+ db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path)
486
+ { ok: run_sql_on(db, sql, limit) }
487
+ rescue SQLite3::Exception => e
488
+ { err: "SQL error: #{e.message}" }
489
+ rescue StandardError => e
490
+ { err: "#{e.class}: #{e.message}" }
491
+ end
492
+ Marshal.dump(outcome, writer_io)
493
+ writer_io.close
494
+ exit!(0)
495
+ end
496
+ writer_io.close
497
+
498
+ unless IO.select([reader_io], nil, nil, timeout)
499
+ Process.kill("KILL", pid)
500
+ Process.waitpid(pid)
501
+ raise ArgumentError, "query exceeded the #{timeout}s time limit#{explain_plan_hint(sql)}"
502
+ end
503
+ payload = reader_io.read
504
+ Process.waitpid(pid)
505
+ outcome = Marshal.load(payload)
506
+ raise ArgumentError, outcome[:err] if outcome[:err]
507
+
508
+ outcome[:ok]
509
+ ensure
510
+ reader_io&.close
511
+ end
512
+
513
+ # Best-effort post-mortem for a timed-out query: EXPLAIN QUERY PLAN is
514
+ # instant and safe (it never executes the query), and the plan tree makes
515
+ # the two common pathologies recognizable
516
+ def explain_plan_hint(sql)
517
+ rows = readonly_db.execute("EXPLAIN QUERY PLAN #{sql}")
518
+ details = rows.map { |r| r[3].to_s }
519
+ by_id = rows.to_h { |r| [r[0], { parent: r[1], detail: r[3].to_s }] }
520
+
521
+ nested_scan = rows.any? do |id, parent, _n, detail|
522
+ next false unless detail.to_s.start_with?("SCAN")
523
+
524
+ ancestor = parent
525
+ found = false
526
+ while ancestor && (node = by_id[ancestor])
527
+ found ||= node[:detail].start_with?("SCAN")
528
+ ancestor = node[:parent]
529
+ end
530
+ found
531
+ end
532
+
533
+ if nested_scan
534
+ " — the query plan shows a full table scan nested inside another full scan " \
535
+ "(likely a cartesian product); join the tables on an indexed key such as page_id"
536
+ elsif details.any? { |d| d.include?("RECURSIVE STEP") }
537
+ " — the query uses a recursive CTE; make sure the recursion is bounded " \
538
+ "(e.g. a depth column with WHERE depth < N)"
539
+ else
540
+ " — narrow the query with additional WHERE filters or aggregate in SQL instead of returning rows"
541
+ end
542
+ rescue StandardError
543
+ ""
544
+ end
545
+
546
+ # Re-render the matched section of each hit from the dump and cut a window
547
+ # around the first occurrence of the search term (contentless FTS stores no
548
+ # text, so the dump is the source of truth for snippets)
549
+ def attach_snippets(hits, query, mode)
550
+ needle = mode == "query" ? query[/"([^"]+)"/, 1] || query[/\w{3,}/] || query : query
551
+ renderer = SectionRenderer.new
552
+ pages = {}
553
+ hits.each do |hit|
554
+ page = pages[hit[:page_id]] ||= reader.extract_article(hit[:title])
555
+ next unless page
556
+
557
+ section = renderer.render_sections(page[:title], page[:text])
558
+ .find { |_h, ord, _t| ord == hit[:ord] }
559
+ next unless section
560
+
561
+ text = section[2]
562
+ pos = needle ? text.downcase.index(needle.downcase) : nil
563
+ hit[:snippet] = if pos
564
+ from = [pos - SNIPPET_CONTEXT, 0].max
565
+ "#{'…' if from.positive?}#{text[from, needle.length + SNIPPET_CONTEXT * 2]}…"
566
+ else
567
+ "#{text[0, SNIPPET_CONTEXT * 2]}…"
568
+ end
569
+ end
570
+ end
571
+
572
+ def dump_name
573
+ @dump_name ||= @metadata.built? ? @metadata.stats[:dump_name] : File.basename(@multistream_path)
574
+ end
575
+
576
+ def reader
577
+ @reader ||= begin
578
+ cache = IndexCache.new(@index_path, cache_dir: @cache_dir)
579
+ index = if cache.valid?
580
+ LazyTitleIndex.new(cache)
581
+ else
582
+ MultistreamIndex.new(@index_path, use_cache: true, cache_dir: @cache_dir, show_progress: false)
583
+ end
584
+ # Memoize stream offsets in the parent before any Parallel fork, so
585
+ # workers inherit the array instead of racing on the SQLite cache
586
+ index.stream_offsets
587
+ MultistreamReader.new(@multistream_path, index)
588
+ end
589
+ end
590
+
591
+ # Resolve a title the way MediaWiki does: try the exact form, then
592
+ # normalized variants (underscores to spaces, first letter capitalized).
593
+ # Cold-start LLM clients routinely send un-normalized titles.
594
+ def fetch_page(title, follow_redirect: true)
595
+ page = nil
596
+ title_variants(title).each do |t|
597
+ page = reader.extract_article(t)
598
+ break if page
599
+ end
600
+ return nil unless page
601
+
602
+ if follow_redirect && (m = REDIRECT_REGEX.match(page[:text].to_s))
603
+ target = m[1].split(/[#|]/).first.to_s.strip
604
+ redirected = target.empty? ? nil : reader.extract_article(target)
605
+ page = redirected if redirected
606
+ end
607
+ page
608
+ end
609
+
610
+ def title_variants(title)
611
+ variants = [title]
612
+ normalized = title.tr("_", " ").squeeze(" ").strip
613
+ variants << normalized
614
+ variants << (normalized[0].to_s.upcase + normalized[1..].to_s) unless normalized.empty?
615
+ variants.uniq
616
+ end
617
+
618
+ def render_text(page)
619
+ config = RENDER_CONFIG.merge(format: :text, title: page[:title])
620
+ article = Article.new(page[:text], page[:title], false)
621
+ format_article(article, config).to_s
622
+ end
623
+
624
+ # Run the co-occurrence check over every pair in every group; returns pairs
625
+ # that look like distinct section roles rather than synonyms
626
+ def check_alias_groups(groups, max_ratio:, min_articles:)
627
+ violations = []
628
+ groups.each do |group|
629
+ next if group.size < 2
630
+
631
+ result = @metadata.section_cooccurrence(group)
632
+ counts = result[:headings].to_h { |h| [h[:heading], h[:articles]] }
633
+ result[:pairs].each do |pair|
634
+ next if [counts[pair[:a]], counts[pair[:b]]].min < min_articles
635
+ violations << pair if pair[:cooccurrence_ratio] > max_ratio
636
+ end
637
+ end
638
+ violations
639
+ end
640
+
641
+ def expand_with_alias_set(sections, alias_set)
642
+ names = Array(sections).compact
643
+ return names unless alias_set
644
+
645
+ set = @metadata.get_alias_set(alias_set)
646
+ raise ArgumentError, "alias set not found: #{alias_set}" unless set
647
+
648
+ base = names.empty? ? set[:groups].map(&:first) : names
649
+ base.flat_map do |n|
650
+ group = set[:groups].find { |g| g.any? { |x| x.downcase == n.downcase } }
651
+ group || [n]
652
+ end.uniq
653
+ end
654
+
655
+ # Build the JSONL records for one page. Without chunking this is one record
656
+ # per article; with chunking, one RAG-ready record per (section, chunk).
657
+ def build_records(page, content, resolved_sections, chunk_size, chunk_overlap)
658
+ article = Article.new(page[:text], page[:title], false)
659
+ categories = article.categories.flatten
660
+
661
+ if content == "wikitext"
662
+ # Raw markup for structure mining (infoboxes, templates, citations)
663
+ [{ id: page[:id], title: page[:title], wikitext: page[:text], categories: categories }]
664
+ elsif content == "full"
665
+ config = RENDER_CONFIG.merge(format: :text, title: page[:title], category: false)
666
+ text = format_article(article, config).to_s.strip
667
+ return [] if text.empty?
668
+ return chunk_records(page, nil, text, categories, chunk_size, chunk_overlap) if chunk_size
669
+
670
+ [{ id: page[:id], title: page[:title], text: text, categories: categories }]
671
+ else # "sections" / "summary"
672
+ config = RENDER_CONFIG.merge(format: :json, sections: resolved_sections, title: page[:title])
673
+ result = format_with_sections(article, config)
674
+ return [] unless result
675
+
676
+ present = (result["sections"] || {}).reject { |_k, v| v.nil? || v.empty? }
677
+ return [] if present.empty?
678
+
679
+ if chunk_size
680
+ return present.flat_map do |section, text|
681
+ chunk_records(page, section, text, categories, chunk_size, chunk_overlap)
682
+ end
683
+ end
684
+
685
+ [{ id: page[:id], title: page[:title], sections: present,
686
+ section_path: present.keys.map { |k| "#{page[:title]} > #{k}" },
687
+ categories: categories }]
688
+ end
689
+ end
690
+
691
+ def chunk_records(page, section, text, categories, chunk_size, chunk_overlap)
692
+ chunks = chunk_text(text, chunk_size, chunk_overlap)
693
+ path = section ? "#{page[:title]} > #{section}" : page[:title]
694
+ chunks.each_with_index.map do |chunk, i|
695
+ { id: page[:id], title: page[:title], section: section, section_path: path,
696
+ chunk_index: i, chunk_count: chunks.size, text: chunk, categories: categories }
697
+ end
698
+ end
699
+
700
+ # Character-based chunking that prefers to break at a paragraph or sentence
701
+ # boundary within the last quarter of the window
702
+ def chunk_text(text, size, overlap)
703
+ return [text] if text.length <= size
704
+
705
+ chunks = []
706
+ start = 0
707
+ while start < text.length
708
+ window_end = [start + size, text.length].min
709
+ if window_end < text.length
710
+ slice = text[start...window_end]
711
+ boundary = slice.rindex(/[\n。.!?!?]/)
712
+ window_end = start + boundary + 1 if boundary && boundary >= size * 3 / 4
713
+ end
714
+ chunks << text[start...window_end]
715
+ break if window_end >= text.length
716
+
717
+ start = [window_end - overlap, start + 1].max
718
+ end
719
+ chunks
720
+ end
721
+ end
722
+ end