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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/Gemfile +2 -0
- data/README.md +79 -1
- data/bin/wp2txt +8 -0
- data/bin/wp2txt-mcp +396 -0
- data/lib/wp2txt/cli.rb +67 -0
- data/lib/wp2txt/corpus.rb +722 -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 +344 -0
- data/lib/wp2txt/metadata_index.rb +672 -0
- data/lib/wp2txt/multistream.rb +9 -5
- data/lib/wp2txt/version.rb +1 -1
- data/spec/corpus_spec.rb +503 -0
- data/spec/fts_index_spec.rb +245 -0
- data/spec/metadata_index_spec.rb +208 -0
- data/spec/support/multistream_fixture.rb +66 -0
- metadata +16 -1
|
@@ -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
|