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
data/lib/wp2txt/version.rb
CHANGED
data/spec/corpus_spec.rb
ADDED
|
@@ -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
|