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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wp2txt
4
- VERSION = "2.1.2"
4
+ VERSION = "2.2.0"
5
5
  end
@@ -0,0 +1,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "json"
7
+ require_relative "support/multistream_fixture"
8
+ require_relative "../lib/wp2txt/corpus"
9
+ require_relative "../lib/wp2txt/corpus_jobs"
10
+
11
+ RSpec.describe Wp2txt::Corpus do
12
+ include MultistreamFixture
13
+
14
+ around do |example|
15
+ Dir.mktmpdir do |dir|
16
+ @dir = dir
17
+ @multistream_path, @index_path = create_fixture(dir)
18
+
19
+ ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
20
+ db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
21
+ Wp2txt::MetadataIndexBuilder.new(
22
+ @multistream_path, ms_index.stream_offsets,
23
+ db_path: db_path, num_processes: 0
24
+ ).build
25
+
26
+ @corpus = described_class.for_input(@multistream_path, cache_dir: dir)
27
+ example.run
28
+ @corpus.close
29
+ end
30
+ end
31
+
32
+ describe ".for_input" do
33
+ it "locates the index file next to the dump" do
34
+ expect(@corpus.index_path).to eq(@index_path)
35
+ end
36
+
37
+ it "raises when no index file exists" do
38
+ orphan = File.join(@dir, "orphan-multistream.xml.bz2")
39
+ FileUtils.cp(@multistream_path, orphan)
40
+ expect { described_class.for_input(orphan, cache_dir: @dir) }.to raise_error(ArgumentError, /index file not found/)
41
+ end
42
+ end
43
+
44
+ describe "#dump_info" do
45
+ it "reports dump identity, tiers, and stats" do
46
+ info = @corpus.dump_info
47
+ expect(info[:dump]).to eq("testwiki-20260101")
48
+ expect(info[:tiers]).to eq({ titles: true, metadata: true, fulltext: false })
49
+ expect(info[:metadata_current]).to be true
50
+ expect(info[:stats][:article_count]).to eq(3)
51
+ end
52
+ end
53
+
54
+ describe "#get_article" do
55
+ it "returns cleaned text by default" do
56
+ result = @corpus.get_article("Film A")
57
+ expect(result[:id]).to eq(1)
58
+ expect(result[:text]).to include("Story here")
59
+ expect(result[:text]).not_to include("[[Category:")
60
+ end
61
+
62
+ it "returns raw wikitext when requested" do
63
+ result = @corpus.get_article("Film A", format: "wikitext")
64
+ expect(result[:text]).to include("[[Category:Japanese films]]")
65
+ end
66
+
67
+ it "resolves one redirect hop" do
68
+ result = @corpus.get_article("Old Film")
69
+ expect(result[:title]).to eq("Film A")
70
+ end
71
+
72
+ it "returns nil for a missing title" do
73
+ expect(@corpus.get_article("Nope")).to be_nil
74
+ end
75
+
76
+ it "normalizes titles like MediaWiki (underscores, capitalization)" do
77
+ expect(@corpus.get_article("Film_A")[:title]).to eq("Film A")
78
+ expect(@corpus.get_article("film A")[:title]).to eq("Film A")
79
+ end
80
+
81
+ it "truncates long text at max_chars with a flag" do
82
+ result = @corpus.get_article("Film A", max_chars: 10)
83
+ expect(result[:text].length).to eq(10)
84
+ expect(result[:truncated]).to be true
85
+ expect(result[:total_chars]).to be > 10
86
+ end
87
+ end
88
+
89
+ describe "#get_categories" do
90
+ it "lists an article's categories" do
91
+ expect(@corpus.get_categories("Film A")[:categories]).to eq(["Japanese films"])
92
+ end
93
+
94
+ it "resolves normalized titles" do
95
+ expect(@corpus.get_categories("Film_A")[:categories]).to eq(["Japanese films"])
96
+ end
97
+
98
+ it "returns nil for unknown titles" do
99
+ expect(@corpus.get_categories("Nope")).to be_nil
100
+ end
101
+ end
102
+
103
+ describe "#get_sections" do
104
+ it "extracts the requested section content" do
105
+ result = @corpus.get_sections("Film A", ["Plot"])
106
+ expect(result[:sections]["Plot"]).to include("Story here")
107
+ end
108
+
109
+ it "expands names through a saved alias set" do
110
+ @corpus.save_alias_set("test-plot", [%w[Plot Synopsis]])
111
+ result = @corpus.get_sections("Film B", ["Plot"], alias_set: "test-plot")
112
+ expect(result[:resolved]).to contain_exactly("Plot", "Synopsis")
113
+ expect(result[:sections].values.compact.join).to include("Story here")
114
+ end
115
+ end
116
+
117
+ describe "#list_headings" do
118
+ it "lists headings with levels" do
119
+ result = @corpus.list_headings("Film A")
120
+ expect(result[:headings]).to eq([
121
+ { name: "Plot", level: 2 },
122
+ { name: "Reception", level: 2 }
123
+ ])
124
+ end
125
+ end
126
+
127
+ describe "#find_articles" do
128
+ it "matches any of multiple section headings (array primitive)" do
129
+ result = @corpus.find_articles(sections: %w[Plot Synopsis])
130
+ expect(result[:total]).to eq(2)
131
+ expect(result[:titles]).to contain_exactly("Film A", "Film B")
132
+ end
133
+
134
+ it "expands sections through a saved alias set" do
135
+ @corpus.save_alias_set("test-plot", [%w[Plot Synopsis]])
136
+ result = @corpus.find_articles(sections: ["Plot"], alias_set: "test-plot")
137
+ expect(result[:titles]).to contain_exactly("Film A", "Film B")
138
+ end
139
+
140
+ it "includes the dump identifier for provenance" do
141
+ expect(@corpus.find_articles(title_match: "Film")[:dump]).to eq("testwiki-20260101")
142
+ end
143
+
144
+ it "intersects multiple exact categories (AND)" do
145
+ hit = @corpus.find_articles(categories: ["Japanese films"])
146
+ expect(hit[:titles]).to eq(["Film A"])
147
+ miss = @corpus.find_articles(categories: ["Japanese films", "French films"])
148
+ expect(miss[:total]).to eq(0)
149
+ end
150
+
151
+ it "matches category names by substring" do
152
+ result = @corpus.find_articles(category_match: "films")
153
+ expect(result[:titles]).to contain_exactly("Film A", "Film B")
154
+ expect(@corpus.find_articles(category_match: "Japanese")[:titles]).to contain_exactly("Film A", "Person X")
155
+ expect(@corpus.find_articles(category_match: "Japanese f")[:titles]).to eq(["Film A"])
156
+ end
157
+ end
158
+
159
+ describe "#query_sql" do
160
+ it "runs read-only SELECT queries" do
161
+ result = @corpus.query_sql("SELECT COUNT(*) AS n FROM pages WHERE namespace = 0")
162
+ expect(result[:columns]).to eq(["n"])
163
+ expect(result[:rows].first.first).to eq(4)
164
+ end
165
+
166
+ it "caps returned rows and flags truncation" do
167
+ result = @corpus.query_sql("SELECT title FROM pages", limit: 2)
168
+ expect(result[:rows].size).to eq(2)
169
+ expect(result[:truncated]).to be true
170
+ end
171
+
172
+ it "rejects non-SELECT statements" do
173
+ expect { @corpus.query_sql("DELETE FROM pages") }.to raise_error(ArgumentError, /SELECT/)
174
+ end
175
+
176
+ it "rejects forbidden keywords inside queries" do
177
+ expect { @corpus.query_sql("SELECT 1; DROP TABLE pages") }.to raise_error(ArgumentError, /forbidden/)
178
+ end
179
+
180
+ it "cannot write even if the keyword screen were bypassed (read-only connection)" do
181
+ expect do
182
+ @corpus.send(:readonly_db).execute("UPDATE pages SET title = 'x'")
183
+ end.to raise_error(SQLite3::Exception)
184
+ end
185
+
186
+ it "reports SQL errors as ArgumentError" do
187
+ expect { @corpus.query_sql("SELECT * FROM no_such_table") }.to raise_error(ArgumentError, /SQL error/)
188
+ end
189
+
190
+ it "does not reject forbidden keywords inside string literals" do
191
+ result = @corpus.query_sql("SELECT COUNT(*) FROM pages WHERE title LIKE '%Update%'")
192
+ expect(result[:rows].first.first).to eq(0)
193
+ end
194
+
195
+ it "kills a runaway query at the timeout with a diagnostic hint" do
196
+ runaway = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT COUNT(*) FROM c"
197
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
198
+ expect { @corpus.query_sql(runaway, timeout: 1) }.to raise_error(ArgumentError, /time limit.*recursive/im)
199
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to be < 5
200
+ end
201
+
202
+ it "keeps serving queries after a timeout" do
203
+ expect do
204
+ @corpus.query_sql("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT COUNT(*) FROM c", timeout: 1)
205
+ end.to raise_error(ArgumentError)
206
+ expect(@corpus.query_sql("SELECT COUNT(*) AS n FROM pages WHERE namespace = 0")[:rows].first.first).to eq(4)
207
+ end
208
+ end
209
+
210
+ describe "#describe_schema" do
211
+ it "returns CREATE statements for the metadata DB" do
212
+ schema = @corpus.describe_schema
213
+ expect(schema[:meta].join).to include("CREATE TABLE pages")
214
+ expect(schema).not_to have_key(:fts)
215
+ end
216
+ end
217
+
218
+ describe "#section_cooccurrence" do
219
+ it "reports zero co-occurrence for headings that never share an article" do
220
+ result = @corpus.section_cooccurrence(%w[Plot Synopsis])
221
+ pair = result[:pairs].first
222
+ expect(pair[:both]).to eq(0)
223
+ expect(pair[:cooccurrence_ratio]).to eq(0.0)
224
+ end
225
+
226
+ it "reports co-occurrence for headings in the same article" do
227
+ result = @corpus.section_cooccurrence(%w[Plot Reception])
228
+ pair = result[:pairs].first
229
+ expect(pair[:both]).to eq(1)
230
+ expect(pair[:cooccurrence_ratio]).to eq(1.0)
231
+ end
232
+
233
+ it "includes per-heading article counts and positions" do
234
+ result = @corpus.section_cooccurrence(%w[Plot Reception])
235
+ plot = result[:headings].find { |h| h[:heading] == "Plot" }
236
+ expect(plot[:articles]).to eq(1)
237
+ expect(plot[:avg_position]).to eq(1.0) # lead = 0, first heading = 1
238
+ end
239
+
240
+ it "scopes to an exact category" do
241
+ result = @corpus.section_cooccurrence(%w[Plot Reception], category: "Japanese films")
242
+ expect(result[:headings].map { |h| h[:articles] }).to eq([1, 1])
243
+ expect(result[:pairs].first[:both]).to eq(1)
244
+ end
245
+
246
+ it "scopes to a recursive category (CTE placeholder ordering regression)" do
247
+ result = @corpus.section_cooccurrence(%w[Plot Synopsis], category: "Films", depth: 1)
248
+ expect(result[:headings].map { |h| h[:articles] }).to eq([1, 1])
249
+ expect(result[:pairs].first[:both]).to eq(0)
250
+ end
251
+ end
252
+
253
+ describe "alias sets" do
254
+ it "saves, retrieves, and lists alias sets" do
255
+ @corpus.save_alias_set("s1", [%w[Plot Synopsis], %w[Career Biography]])
256
+ expect(@corpus.get_alias_set("s1")[:groups]).to eq([%w[Plot Synopsis], %w[Career Biography]])
257
+ expect(@corpus.list_alias_sets.map { |s| s[:name] }).to include("s1")
258
+ end
259
+
260
+ it "rejects malformed groups" do
261
+ expect { @corpus.save_alias_set("bad", "not an array") }.to raise_error(ArgumentError)
262
+ end
263
+
264
+ it "raises when querying with an unknown alias set" do
265
+ expect { @corpus.find_articles(sections: ["Plot"], alias_set: "nope") }.to raise_error(ArgumentError, /not found/)
266
+ end
267
+ end
268
+
269
+ describe "#save_alias_set guardrail" do
270
+ it "blocks groups whose headings frequently coexist in the same articles" do
271
+ result = @corpus.save_alias_set("bad-pair", [%w[Plot Reception]], min_articles: 1)
272
+ expect(result[:saved]).to be false
273
+ expect(result[:violations].first).to include(a: "Plot", b: "Reception")
274
+ expect(@corpus.get_alias_set("bad-pair")).to be_nil
275
+ end
276
+
277
+ it "allows an override with force" do
278
+ result = @corpus.save_alias_set("forced", [%w[Plot Reception]], min_articles: 1, force: true)
279
+ expect(result[:saved]).to be true
280
+ expect(@corpus.get_alias_set("forced")).not_to be_nil
281
+ end
282
+
283
+ it "passes non-co-occurring groups without force" do
284
+ result = @corpus.save_alias_set("good", [%w[Plot Synopsis]], min_articles: 1)
285
+ expect(result[:saved]).to be true
286
+ expect(result[:violations]).to be_empty
287
+ end
288
+
289
+ it "skips pairs below the frequency threshold" do
290
+ result = @corpus.save_alias_set("rare", [%w[Plot Reception]], min_articles: 100)
291
+ expect(result[:saved]).to be true
292
+ end
293
+
294
+ it "reports the exact criteria used, on both accept and reject" do
295
+ accepted = @corpus.save_alias_set("crit-ok", [%w[Plot Synopsis]], min_articles: 1, max_ratio: 0.2)
296
+ expect(accepted[:criteria]).to eq(max_cooccurrence_ratio: 0.2, min_articles: 1)
297
+ expect(accepted[:groups]).to eq([%w[Plot Synopsis]])
298
+
299
+ rejected = @corpus.save_alias_set("crit-no", [%w[Plot Reception]], min_articles: 1, max_ratio: 0.2)
300
+ expect(rejected[:criteria]).to eq(max_cooccurrence_ratio: 0.2, min_articles: 1)
301
+ expect(rejected[:warning]).to include("20%")
302
+ end
303
+ end
304
+
305
+ describe "#extract_corpus" do
306
+ it "writes JSONL with a reproducibility sidecar and returns a summary with sample" do
307
+ @corpus.save_alias_set("test-plot", [%w[Plot Synopsis]])
308
+ out = File.join(@dir, "plots.jsonl")
309
+ result = @corpus.extract_corpus(
310
+ output_path: out, content: "sections", sections: ["Plot"],
311
+ alias_set: "test-plot", num_processes: 0
312
+ )
313
+
314
+ expect(result[:articles_extracted]).to eq(2)
315
+ expect(result[:records_written]).to eq(2)
316
+ expect(result[:truncated]).to be false
317
+ expect(result[:sample].size).to eq(2)
318
+
319
+ lines = File.readlines(out).map { |l| JSON.parse(l) }
320
+ expect(lines.size).to eq(2)
321
+ expect(lines.map { |r| r["title"] }).to contain_exactly("Film A", "Film B")
322
+ expect(lines.first["sections"].values.join).to include("Story here")
323
+
324
+ meta = JSON.parse(File.read(result[:meta_path]))
325
+ expect(meta["dump"]).to eq("testwiki-20260101")
326
+ expect(meta["alias_set_contents"]).to eq([%w[Plot Synopsis]])
327
+ expect(meta["query"]["resolved_sections"]).to contain_exactly("Plot", "Synopsis")
328
+ end
329
+
330
+ it "extracts full article text" do
331
+ out = File.join(@dir, "full.jsonl")
332
+ result = @corpus.extract_corpus(
333
+ output_path: out, content: "full", title_match: "Film A", num_processes: 0
334
+ )
335
+ expect(result[:articles_extracted]).to eq(1)
336
+ record = JSON.parse(File.readlines(out).first)
337
+ expect(record["text"]).to include("Story here")
338
+ expect(record["categories"]).to include("Japanese films")
339
+ end
340
+
341
+ it "flags truncation when matches exceed the limit" do
342
+ out = File.join(@dir, "limited.jsonl")
343
+ result = @corpus.extract_corpus(
344
+ output_path: out, content: "sections", sections: %w[Plot Synopsis],
345
+ limit: 1, num_processes: 0
346
+ )
347
+ expect(result[:articles_extracted]).to eq(1)
348
+ expect(result[:truncated]).to be true
349
+ expect(result[:total_matching]).to eq(2)
350
+ end
351
+
352
+ it "requires sections or alias_set for sections content" do
353
+ expect do
354
+ @corpus.extract_corpus(output_path: File.join(@dir, "x.jsonl"), content: "sections")
355
+ end.to raise_error(ArgumentError, /requires sections/)
356
+ end
357
+
358
+ it "extracts raw wikitext for structure mining" do
359
+ out = File.join(@dir, "wikitext.jsonl")
360
+ result = @corpus.extract_corpus(
361
+ output_path: out, content: "wikitext", title_match: "Film A", num_processes: 0
362
+ )
363
+ expect(result[:articles_extracted]).to eq(1)
364
+ record = JSON.parse(File.readlines(out).first)
365
+ expect(record["wikitext"]).to include("[[Category:Japanese films]]")
366
+ end
367
+
368
+ it "rejects chunking combined with wikitext content" do
369
+ expect do
370
+ @corpus.extract_corpus(output_path: File.join(@dir, "x.jsonl"), content: "wikitext",
371
+ chunk_size: 100)
372
+ end.to raise_error(ArgumentError, /wikitext/)
373
+ end
374
+
375
+ it "produces one RAG-ready record per chunk when chunk_size is given" do
376
+ out = File.join(@dir, "chunked.jsonl")
377
+ result = @corpus.extract_corpus(
378
+ output_path: out, content: "sections", sections: ["Plot"],
379
+ chunk_size: 5, chunk_overlap: 0, num_processes: 0
380
+ )
381
+ records = File.readlines(out).map { |l| JSON.parse(l) }
382
+ expect(result[:records_written]).to be > result[:articles_extracted]
383
+ expect(records.first).to include("section" => "Plot", "chunk_index" => 0)
384
+ expect(records.first["section_path"]).to eq("Film A > Plot")
385
+ reassembled = records.select { |r| r["title"] == "Film A" }.map { |r| r["text"] }.join
386
+ expect(reassembled).to include("Story here")
387
+ expect(records.map { |r| r["chunk_count"] }.uniq.size).to eq(1)
388
+ end
389
+
390
+ it "aborts with Cancelled when cancel_check returns true" do
391
+ out = File.join(@dir, "cancelled.jsonl")
392
+ expect do
393
+ @corpus.extract_corpus(
394
+ output_path: out, content: "full", title_match: "Film",
395
+ num_processes: 0, cancel_check: -> { true }
396
+ )
397
+ end.to raise_error(Wp2txt::Corpus::Cancelled)
398
+ end
399
+ end
400
+
401
+ describe "#chunk_text" do
402
+ it "returns the whole text when it fits" do
403
+ expect(@corpus.send(:chunk_text, "short", 100, 0)).to eq(["short"])
404
+ end
405
+
406
+ it "prefers sentence boundaries near the window end" do
407
+ text = "One two. Three four five six seven."
408
+ chunks = @corpus.send(:chunk_text, text, 10, 0)
409
+ expect(chunks.first).to eq("One two.")
410
+ expect(chunks.join).to eq(text)
411
+ end
412
+
413
+ it "applies overlap between chunks" do
414
+ chunks = @corpus.send(:chunk_text, "abcdefghij", 4, 2)
415
+ expect(chunks.first).to eq("abcd")
416
+ expect(chunks[1]).to start_with("cd")
417
+ end
418
+ end
419
+ end
420
+
421
+ RSpec.describe Wp2txt::CorpusJobManager do
422
+ include MultistreamFixture
423
+
424
+ around do |example|
425
+ Dir.mktmpdir do |dir|
426
+ @dir = dir
427
+ @multistream_path, @index_path = create_fixture(dir)
428
+ ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
429
+ db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
430
+ Wp2txt::MetadataIndexBuilder.new(
431
+ @multistream_path, ms_index.stream_offsets,
432
+ db_path: db_path, num_processes: 0
433
+ ).build
434
+ @manager = described_class.new(-> { Wp2txt::Corpus.for_input(@multistream_path, cache_dir: dir) })
435
+ example.run
436
+ end
437
+ end
438
+
439
+ def wait_for(job_id, timeout: 10, manager: @manager)
440
+ deadline = Time.now + timeout
441
+ loop do
442
+ status = manager.status(job_id)
443
+ return status if %w[completed cancelled error].include?(status[:status])
444
+ raise "job timed out: #{status.inspect}" if Time.now > deadline
445
+
446
+ sleep 0.05
447
+ end
448
+ end
449
+
450
+ it "runs an extraction to completion and reports the result" do
451
+ out = File.join(@dir, "job.jsonl")
452
+ started = @manager.start_extract(
453
+ output_path: out, content: "sections", sections: %w[Plot Synopsis], num_processes: 0
454
+ )
455
+ expect(started[:status]).to eq("running")
456
+
457
+ status = wait_for(started[:job_id])
458
+ expect(status[:status]).to eq("completed")
459
+ expect(status[:result][:articles_extracted]).to eq(2)
460
+ expect(status[:progress]).to eq(1.0)
461
+ expect(File.exist?(out)).to be true
462
+ end
463
+
464
+ it "reports errors without crashing the manager" do
465
+ started = @manager.start_extract(output_path: File.join(@dir, "x.jsonl"), content: "sections")
466
+ status = wait_for(started[:job_id])
467
+ expect(status[:status]).to eq("error")
468
+ expect(status[:error]).to match(/requires sections/)
469
+ end
470
+
471
+ it "runs at most one job at a time" do
472
+ gate = Queue.new
473
+ slow_corpus = Class.new do
474
+ define_method(:initialize) { |g| @gate = g }
475
+ define_method(:extract_corpus) { |**_kw| @gate.pop; { articles_extracted: 0 } }
476
+ define_method(:close) {}
477
+ end
478
+ manager = Wp2txt::CorpusJobManager.new(-> { slow_corpus.new(gate) })
479
+
480
+ first = manager.start_extract(output_path: "/tmp/a.jsonl", content: "full")
481
+ expect(first[:job_id]).not_to be_nil
482
+ second = manager.start_extract(output_path: "/tmp/b.jsonl", content: "full")
483
+ expect(second[:error]).to match(/already running/)
484
+
485
+ gate << :go
486
+ deadline = Time.now + 5
487
+ sleep 0.05 while manager.status(first[:job_id])[:status] == "running" && Time.now < deadline
488
+ expect(manager.status(first[:job_id])[:status]).to eq("completed")
489
+ third = manager.start_extract(output_path: "/tmp/c.jsonl", content: "full")
490
+ expect(third[:job_id]).not_to be_nil
491
+ gate << :go
492
+ wait_for(third[:job_id], manager: manager)
493
+ end
494
+
495
+ it "returns nil for unknown jobs and lists known ones" do
496
+ expect(@manager.status("job-9999")).to be_nil
497
+ started = @manager.start_extract(
498
+ output_path: File.join(@dir, "l.jsonl"), content: "full", title_match: "Film A", num_processes: 0
499
+ )
500
+ wait_for(started[:job_id])
501
+ expect(@manager.list.map { |j| j[:job_id] }).to include(started[:job_id])
502
+ end
503
+ end