@jestek-dev/scripture-engine 0.7.1 → 0.14.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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/config/engineVersion.d.ts +1 -1
- package/dist/config/engineVersion.js +47 -1
- package/dist/corpus/repository.d.ts +189 -2
- package/dist/corpus/repository.js +332 -1
- package/dist/createEngine.d.ts +76 -1
- package/dist/createEngine.js +716 -52
- package/dist/index.d.ts +20 -14
- package/dist/index.js +15 -13
- package/dist/intents/concept.d.ts +115 -2
- package/dist/intents/concept.js +323 -6
- package/dist/intents/lexical.d.ts +42 -3
- package/dist/intents/lexical.js +111 -11
- package/dist/intents/spelling.d.ts +93 -0
- package/dist/intents/spelling.js +124 -0
- package/dist/internal.d.ts +31 -0
- package/dist/internal.js +31 -0
- package/dist/ranking/budgets.d.ts +12 -0
- package/dist/ranking/budgets.js +39 -0
- package/dist/ranking/rank.js +20 -3
- package/dist/reasons/display.d.ts +86 -0
- package/dist/reasons/display.js +105 -0
- package/dist/reasons/types.d.ts +1 -1
- package/dist/reference/reference.d.ts +57 -0
- package/dist/reference/reference.js +210 -48
- package/dist/tokenizer/index.d.ts +35 -0
- package/dist/tokenizer/index.js +37 -1
- package/dist/types.d.ts +104 -0
- package/package.json +7 -1
|
@@ -48,6 +48,15 @@ export class CorpusRepository {
|
|
|
48
48
|
constructor(database) {
|
|
49
49
|
this.database = database;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The alias vocabulary for the reference did-you-mean (0.11.0/QR-4),
|
|
53
|
+
* fetched through the port once per repository instance and cached: ~270
|
|
54
|
+
* rows that cannot change under a running engine (the artifact is
|
|
55
|
+
* immutable), so re-reading them per query would be waste, and caching
|
|
56
|
+
* keeps the engine's no-I/O covenant intact — the ONE read still goes
|
|
57
|
+
* through ContentQueryPort.
|
|
58
|
+
*/
|
|
59
|
+
bookAliasCache = null;
|
|
51
60
|
async close() {
|
|
52
61
|
await this.database.close();
|
|
53
62
|
}
|
|
@@ -92,6 +101,21 @@ export class CorpusRepository {
|
|
|
92
101
|
const result = await this.database.execute('SELECT 1 AS present FROM verses WHERE book_id = ? AND chapter = ? AND verse = ? LIMIT 1', [bookId, chapter, verse]);
|
|
93
102
|
return result.rows.length > 0;
|
|
94
103
|
}
|
|
104
|
+
async listBookAliases() {
|
|
105
|
+
if (this.bookAliasCache)
|
|
106
|
+
return this.bookAliasCache;
|
|
107
|
+
const result = await this.database.execute(`SELECT a.alias_key AS aliasKey, b.id AS bookId, b.name AS bookName,
|
|
108
|
+
b.chapter_count AS chapterCount
|
|
109
|
+
FROM book_aliases a JOIN books b ON b.id = a.book_id
|
|
110
|
+
ORDER BY a.alias_key`);
|
|
111
|
+
this.bookAliasCache = result.rows.map((row) => ({
|
|
112
|
+
aliasKey: str(row, 'aliasKey'),
|
|
113
|
+
bookId: num(row, 'bookId'),
|
|
114
|
+
bookName: str(row, 'bookName'),
|
|
115
|
+
chapterCount: num(row, 'chapterCount'),
|
|
116
|
+
}));
|
|
117
|
+
return this.bookAliasCache;
|
|
118
|
+
}
|
|
95
119
|
async resolveReference(input) {
|
|
96
120
|
return await resolveReferenceAttempt(input, this);
|
|
97
121
|
}
|
|
@@ -222,6 +246,176 @@ export class CorpusRepository {
|
|
|
222
246
|
FROM token_stats WHERE token IN (${placeholders}) GROUP BY token`, unique);
|
|
223
247
|
return new Map(result.rows.map((row) => [str(row, 'token'), num(row, 'df')]));
|
|
224
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Whether this artifact carries the precomputed spelling index
|
|
251
|
+
* (schema v7, 0.12.0/QR-5). Presence-probed like the other optional
|
|
252
|
+
* layers: a v6 artifact simply has no tables, the probe returns false, and
|
|
253
|
+
* the engine gracefully does not correct — behaving exactly as the
|
|
254
|
+
* pre-spelling engine did. That probe IS the rollback story: rebuild the
|
|
255
|
+
* artifact without the tables and behavior reverts with no engine change.
|
|
256
|
+
*/
|
|
257
|
+
async hasSpellingIndex() {
|
|
258
|
+
try {
|
|
259
|
+
const result = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'spelling_terms'");
|
|
260
|
+
return result.rows.length > 0;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Whether this artifact carries the derived pericope tiling (schema v8,
|
|
268
|
+
* CO-3 PR 1). Presence-and-rows probed like the other optional layers: a
|
|
269
|
+
* v7 artifact has no table, an emptied table disables the (future)
|
|
270
|
+
* grouping step silently, and behavior reverts to pre-pericope output
|
|
271
|
+
* with no engine change — the probe IS the rollback story.
|
|
272
|
+
*/
|
|
273
|
+
async hasPericopes() {
|
|
274
|
+
try {
|
|
275
|
+
const table = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pericopes'");
|
|
276
|
+
if (table.rows.length === 0)
|
|
277
|
+
return false;
|
|
278
|
+
const rows = await this.database.execute('SELECT 1 AS present FROM pericopes LIMIT 1');
|
|
279
|
+
return rows.rows.length > 0;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The pericopes containing any of the given verse ids, batched as ONE
|
|
287
|
+
* bounded query over the ranked window (G11): the window's min..max verse
|
|
288
|
+
* span overlaps few pericopes, and the caller maps verses to rows. Rows
|
|
289
|
+
* come back ordered by start verse for platform-stable iteration.
|
|
290
|
+
*
|
|
291
|
+
* NO CALL SITES in discover() yet (CO-3 PR 1 capability): the grouping
|
|
292
|
+
* behavior that consumes this lands with the PR 2 ENGINE_VERSION bump.
|
|
293
|
+
* boundaryVotes is the summed boundary vote at the pericope's start verse
|
|
294
|
+
* — the countable fact the artifact stores, so a future explanation and
|
|
295
|
+
* the shipped data cannot disagree.
|
|
296
|
+
*/
|
|
297
|
+
async pericopesContaining(verseIds) {
|
|
298
|
+
const unique = [...new Set(verseIds)];
|
|
299
|
+
if (unique.length === 0)
|
|
300
|
+
return [];
|
|
301
|
+
const result = await this.database.execute(`SELECT start_verse_id AS startVerseId, end_verse_id AS endVerseId,
|
|
302
|
+
boundary_votes AS boundaryVotes, source_id AS sourceId
|
|
303
|
+
FROM pericopes
|
|
304
|
+
WHERE end_verse_id >= ? AND start_verse_id <= ?
|
|
305
|
+
ORDER BY start_verse_id`, [Math.min(...unique), Math.max(...unique)]);
|
|
306
|
+
const spans = result.rows.map((row) => ({
|
|
307
|
+
startVerseId: num(row, 'startVerseId'),
|
|
308
|
+
endVerseId: num(row, 'endVerseId'),
|
|
309
|
+
boundaryVotes: num(row, 'boundaryVotes'),
|
|
310
|
+
sourceId: str(row, 'sourceId'),
|
|
311
|
+
}));
|
|
312
|
+
// The min..max window can overlap pericopes containing none of the
|
|
313
|
+
// asked-for verses; keep only real containers so the caller's mapping
|
|
314
|
+
// stays honest.
|
|
315
|
+
return spans.filter((span) => unique.some((verseId) => verseId >= span.startVerseId && verseId <= span.endVerseId));
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether this artifact carries the mined TSK cross-reference phrase keys
|
|
319
|
+
* (schema v9, B3 Phase A). Presence-and-rows probed like pericopes: a v8
|
|
320
|
+
* artifact has no table, an emptied table reads false, and (future)
|
|
321
|
+
* phrase-labeled behavior reverts to plain cross_references output with no
|
|
322
|
+
* engine change — the probe IS the rollback story.
|
|
323
|
+
*/
|
|
324
|
+
async hasCrossReferencePhrases() {
|
|
325
|
+
try {
|
|
326
|
+
const table = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cross_reference_phrases'");
|
|
327
|
+
if (table.rows.length === 0)
|
|
328
|
+
return false;
|
|
329
|
+
const rows = await this.database.execute('SELECT 1 AS present FROM cross_reference_phrases LIMIT 1');
|
|
330
|
+
return rows.rows.length > 0;
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The cross-reference phrase triples whose FROM verse is one of the given
|
|
338
|
+
* verse ids, batched as ONE bounded query over the window's min..max span
|
|
339
|
+
* (G11) and filtered back to the asked-for verses. Rows come back ordered
|
|
340
|
+
* by (from, phrase, start, end, source) for platform-stable iteration —
|
|
341
|
+
* sorted HERE, in engine code, by UTF-16 code units (the same comparison
|
|
342
|
+
* buildConceptLayer's fingerprint feed uses), never by the port's SQL
|
|
343
|
+
* collation: SQLite's BINARY collation compares UTF-8 bytes, which
|
|
344
|
+
* disagrees with JS on some non-ASCII strings, and the ordering contract
|
|
345
|
+
* must not depend on which side compares.
|
|
346
|
+
*
|
|
347
|
+
* NO CALL SITES in discover() yet (B3 Phase A capability): the labeling
|
|
348
|
+
* and off-phrase-discount behavior that consumes this lands with the
|
|
349
|
+
* Phase B ENGINE_VERSION bump behind J26/J55.
|
|
350
|
+
*/
|
|
351
|
+
async crossReferencePhrasesFor(verseIds) {
|
|
352
|
+
const unique = [...new Set(verseIds)];
|
|
353
|
+
if (unique.length === 0)
|
|
354
|
+
return [];
|
|
355
|
+
const asked = new Set(unique);
|
|
356
|
+
const result = await this.database.execute(`SELECT from_verse_id AS fromVerseId, normalized_phrase AS normalizedPhrase,
|
|
357
|
+
to_start_verse_id AS toStartVerseId, to_end_verse_id AS toEndVerseId,
|
|
358
|
+
source_id AS sourceId
|
|
359
|
+
FROM cross_reference_phrases
|
|
360
|
+
WHERE from_verse_id >= ? AND from_verse_id <= ?
|
|
361
|
+
ORDER BY from_verse_id`, [Math.min(...unique), Math.max(...unique)]);
|
|
362
|
+
// The min..max window can include from-verses nobody asked about; keep
|
|
363
|
+
// only the asked-for ones so the caller's mapping stays honest.
|
|
364
|
+
return result.rows
|
|
365
|
+
.map((row) => ({
|
|
366
|
+
fromVerseId: num(row, 'fromVerseId'),
|
|
367
|
+
normalizedPhrase: str(row, 'normalizedPhrase'),
|
|
368
|
+
toStartVerseId: num(row, 'toStartVerseId'),
|
|
369
|
+
toEndVerseId: num(row, 'toEndVerseId'),
|
|
370
|
+
sourceId: str(row, 'sourceId'),
|
|
371
|
+
}))
|
|
372
|
+
.filter((row) => asked.has(row.fromVerseId))
|
|
373
|
+
// source_id joins the tie-break so two sources naming the same
|
|
374
|
+
// (from, phrase, target) triple can never come back in
|
|
375
|
+
// platform-unspecified order once a second phrase source exists.
|
|
376
|
+
.sort((a, b) => a.fromVerseId - b.fromVerseId ||
|
|
377
|
+
(a.normalizedPhrase < b.normalizedPhrase ? -1 : a.normalizedPhrase > b.normalizedPhrase ? 1 : 0) ||
|
|
378
|
+
a.toStartVerseId - b.toStartVerseId ||
|
|
379
|
+
a.toEndVerseId - b.toEndVerseId ||
|
|
380
|
+
(a.sourceId < b.sourceId ? -1 : a.sourceId > b.sourceId ? 1 : 0));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Which of the given tokens exist in the artifact's spelling vocabulary
|
|
384
|
+
* (corpus tokens ∪ book aliases ∪ lexicon tokens ∪ translation tokens ∪
|
|
385
|
+
* Layer B verse terms).
|
|
386
|
+
* This is the OOV gate's second half: a token with corpus df 0 that is
|
|
387
|
+
* still a known name or curated word is IN vocabulary and never corrected.
|
|
388
|
+
*/
|
|
389
|
+
async spellingTermsPresent(tokens) {
|
|
390
|
+
const unique = [...new Set(tokens)];
|
|
391
|
+
if (unique.length === 0)
|
|
392
|
+
return new Set();
|
|
393
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
394
|
+
const result = await this.database.execute(`SELECT term FROM spelling_terms WHERE term IN (${placeholders})`, unique);
|
|
395
|
+
return new Set(result.rows.map((row) => str(row, 'term')));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Dictionary terms whose precomputed delete variants intersect the given
|
|
399
|
+
* keys — the SymSpell candidate lookup (0.12.0/QR-5). Proposes only: every
|
|
400
|
+
* candidate is re-verified with the bounded Damerau DP before it may win
|
|
401
|
+
* (see intents/spelling.ts). ORDER BY term for a platform-stable row order,
|
|
402
|
+
* though the picker is proven row-order independent anyway.
|
|
403
|
+
*/
|
|
404
|
+
async spellingCandidates(deleteKeys) {
|
|
405
|
+
const unique = [...new Set(deleteKeys)];
|
|
406
|
+
if (unique.length === 0)
|
|
407
|
+
return [];
|
|
408
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
409
|
+
const result = await this.database.execute(`SELECT DISTINCT d.term AS term, t.document_count AS documentCount
|
|
410
|
+
FROM spelling_deletes d
|
|
411
|
+
JOIN spelling_terms t ON t.term = d.term
|
|
412
|
+
WHERE d.delete_key IN (${placeholders})
|
|
413
|
+
ORDER BY d.term`, unique);
|
|
414
|
+
return result.rows.map((row) => ({
|
|
415
|
+
term: str(row, 'term'),
|
|
416
|
+
documentCount: num(row, 'documentCount'),
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
225
419
|
}
|
|
226
420
|
/** Longest fragment length worth searching; below this, phrases are noise. */
|
|
227
421
|
const MIN_FRAGMENT_WORDS = 3;
|
|
@@ -318,7 +512,8 @@ export class ConceptRepository {
|
|
|
318
512
|
v.book_id AS bookId, b.name AS bookName,
|
|
319
513
|
v.chapter AS chapter, v.verse AS verse, v.text AS text,
|
|
320
514
|
a.concept_id AS conceptId, c.label AS conceptLabel,
|
|
321
|
-
a.source_id AS sourceId, a.weight AS weight, a.locator AS locator
|
|
515
|
+
a.source_id AS sourceId, a.weight AS weight, a.locator AS locator,
|
|
516
|
+
a.start_verse_id AS anchorStartVerseId, a.end_verse_id AS anchorEndVerseId
|
|
322
517
|
FROM concept_anchors a
|
|
323
518
|
JOIN concepts c ON c.id = a.concept_id
|
|
324
519
|
JOIN verses v ON v.verse_id BETWEEN a.start_verse_id AND a.end_verse_id
|
|
@@ -333,6 +528,8 @@ export class ConceptRepository {
|
|
|
333
528
|
sourceId: str(row, 'sourceId'),
|
|
334
529
|
weight: num(row, 'weight'),
|
|
335
530
|
locator: typeof row['locator'] === 'string' ? row['locator'] : null,
|
|
531
|
+
anchorStartVerseId: num(row, 'anchorStartVerseId'),
|
|
532
|
+
anchorEndVerseId: num(row, 'anchorEndVerseId'),
|
|
336
533
|
}));
|
|
337
534
|
}
|
|
338
535
|
/**
|
|
@@ -418,6 +615,70 @@ export class ConceptRepository {
|
|
|
418
615
|
* it is a long way from the passage saying it. G6 caps it so no volume of
|
|
419
616
|
* homiletical vocabulary can outrank a curated anchor or a verbatim quote.
|
|
420
617
|
*/
|
|
618
|
+
/**
|
|
619
|
+
* Verses whose CROSS-TRANSLATION vocabulary matches the query.
|
|
620
|
+
*
|
|
621
|
+
* This is what lets someone search in the translation they learned a verse
|
|
622
|
+
* in. The stems here appear in some English translation of the verse but not
|
|
623
|
+
* in the one shipped, so a query using that wording reaches the verse
|
|
624
|
+
* anyway. See pipeline/src/schema.ts for what is and is not stored.
|
|
625
|
+
*
|
|
626
|
+
* Ranked by how MANY query stems a verse accounts for, then by verse id. No
|
|
627
|
+
* IDF weighting: these stems are already the residue after the shipped
|
|
628
|
+
* wording is subtracted, so a stem appearing here is by construction
|
|
629
|
+
* something the shipped text does not say.
|
|
630
|
+
*/
|
|
631
|
+
async searchTranslationTokens(tokens, limit = 60) {
|
|
632
|
+
const unique = [...new Set(tokens)];
|
|
633
|
+
if (unique.length === 0)
|
|
634
|
+
return [];
|
|
635
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
636
|
+
const result = await this.database.execute(`WITH hits AS (
|
|
637
|
+
SELECT vtt.verse_id AS vid,
|
|
638
|
+
group_concat(vtt.token, ' ') AS tokens,
|
|
639
|
+
COUNT(*) AS matched
|
|
640
|
+
FROM verse_translation_tokens vtt
|
|
641
|
+
WHERE vtt.token IN (${placeholders})
|
|
642
|
+
GROUP BY vtt.verse_id
|
|
643
|
+
)
|
|
644
|
+
SELECT v.id AS id, v.verse_id AS verseId,
|
|
645
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
646
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
647
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text,
|
|
648
|
+
h.tokens AS tokens, h.matched AS matched
|
|
649
|
+
FROM hits h
|
|
650
|
+
JOIN verses v ON v.verse_id = h.vid
|
|
651
|
+
JOIN translations t ON t.id = v.translation_id
|
|
652
|
+
JOIN books b ON b.id = v.book_id
|
|
653
|
+
ORDER BY h.matched DESC, v.verse_id, t.code
|
|
654
|
+
LIMIT ?`, [...unique, limit]);
|
|
655
|
+
return result.rows.map((row) => ({
|
|
656
|
+
...mapVerse(row),
|
|
657
|
+
matchedTokens: [...new Set(str(row, 'tokens').split(' ').filter(Boolean))].sort(),
|
|
658
|
+
}));
|
|
659
|
+
}
|
|
660
|
+
/** How many verses carry each stem — the df for weighting alternate wording. */
|
|
661
|
+
async translationTokenDocumentCounts(tokens) {
|
|
662
|
+
const unique = [...new Set(tokens)];
|
|
663
|
+
if (unique.length === 0)
|
|
664
|
+
return new Map();
|
|
665
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
666
|
+
const result = await this.database.execute(`SELECT token, COUNT(DISTINCT verse_id) AS n
|
|
667
|
+
FROM verse_translation_tokens
|
|
668
|
+
WHERE token IN (${placeholders})
|
|
669
|
+
GROUP BY token`, unique);
|
|
670
|
+
return new Map(result.rows.map((row) => [str(row, 'token'), num(row, 'n')]));
|
|
671
|
+
}
|
|
672
|
+
/** Whether the artifact carries cross-translation vocabulary at all. */
|
|
673
|
+
async hasTranslationTokens() {
|
|
674
|
+
try {
|
|
675
|
+
const result = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'verse_translation_tokens'");
|
|
676
|
+
return result.rows.length > 0;
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
421
682
|
async searchPassageTerms(terms, limit = 60) {
|
|
422
683
|
const unique = [...new Set(terms)];
|
|
423
684
|
if (unique.length === 0)
|
|
@@ -459,6 +720,76 @@ export class ConceptRepository {
|
|
|
459
720
|
const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='verse_terms'");
|
|
460
721
|
return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
|
|
461
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* Whether this artifact carries any curated phrase/hymn aliases
|
|
725
|
+
* (0.13.0/QR-6). Presence-AND-ROWS probed, deliberately stricter than the
|
|
726
|
+
* other layer probes: schema v7 ships the table EMPTY (QR-5), and an
|
|
727
|
+
* engine that ran the alias step against an empty table would pay a query
|
|
728
|
+
* per research() call for nothing — and, more importantly, the rollback
|
|
729
|
+
* story is "rebuild without alias rows", which must restore pre-QR-6
|
|
730
|
+
* behavior exactly. No table, or an empty one, and 0.13.0 behaves as
|
|
731
|
+
* 0.12.0 did.
|
|
732
|
+
*/
|
|
733
|
+
async hasCuratedAliases() {
|
|
734
|
+
try {
|
|
735
|
+
const table = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'curated_aliases'");
|
|
736
|
+
if (table.rows.length === 0)
|
|
737
|
+
return false;
|
|
738
|
+
const rows = await this.database.execute('SELECT 1 AS present FROM curated_aliases LIMIT 1');
|
|
739
|
+
return rows.rows.length > 0;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* The curated aliases whose whole-query key equals the given normalized
|
|
747
|
+
* phrase. EQUALITY, never containment — the line that keeps a curated
|
|
748
|
+
* phrase table from becoming a hidden second ranking system; brittleness
|
|
749
|
+
* to extra words is accepted BY DESIGN. `normalized_raw` is UNIQUE, so
|
|
750
|
+
* this returns at most one row; it is typed as a list so the caller does
|
|
751
|
+
* not encode that schema fact.
|
|
752
|
+
*/
|
|
753
|
+
async matchAliases(normalizedQuery) {
|
|
754
|
+
if (!normalizedQuery)
|
|
755
|
+
return [];
|
|
756
|
+
const result = await this.database.execute(`SELECT a.id AS id, a.title AS title, a.concept_id AS conceptId,
|
|
757
|
+
c.label AS conceptLabel,
|
|
758
|
+
a.start_verse_id AS startVerseId, a.end_verse_id AS endVerseId,
|
|
759
|
+
a.source_id AS sourceId, a.weight AS weight, a.locator AS locator
|
|
760
|
+
FROM curated_aliases a
|
|
761
|
+
LEFT JOIN concepts c ON c.id = a.concept_id
|
|
762
|
+
WHERE a.normalized_raw = ?
|
|
763
|
+
ORDER BY a.id`, [normalizedQuery]);
|
|
764
|
+
return result.rows.map((row) => ({
|
|
765
|
+
id: num(row, 'id'),
|
|
766
|
+
title: str(row, 'title'),
|
|
767
|
+
conceptId: typeof row['conceptId'] === 'string' ? row['conceptId'] : null,
|
|
768
|
+
conceptLabel: typeof row['conceptLabel'] === 'string' ? row['conceptLabel'] : null,
|
|
769
|
+
startVerseId: typeof row['startVerseId'] === 'number' ? row['startVerseId'] : null,
|
|
770
|
+
endVerseId: typeof row['endVerseId'] === 'number' ? row['endVerseId'] : null,
|
|
771
|
+
sourceId: str(row, 'sourceId'),
|
|
772
|
+
weight: num(row, 'weight'),
|
|
773
|
+
locator: typeof row['locator'] === 'string' ? row['locator'] : null,
|
|
774
|
+
}));
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Verses of an explicit alias verse range (the XOR's other arm). A range
|
|
778
|
+
* absent from this corpus returns no rows — the alias then contributes
|
|
779
|
+
* nothing, honestly, rather than being guessed at.
|
|
780
|
+
*/
|
|
781
|
+
async aliasRangeVerses(startVerseId, endVerseId) {
|
|
782
|
+
const result = await this.database.execute(`SELECT v.id AS id, v.verse_id AS verseId,
|
|
783
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
784
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
785
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text
|
|
786
|
+
FROM verses v
|
|
787
|
+
JOIN translations t ON t.id = v.translation_id
|
|
788
|
+
JOIN books b ON b.id = v.book_id
|
|
789
|
+
WHERE v.verse_id BETWEEN ? AND ?
|
|
790
|
+
ORDER BY v.verse_id, t.code`, [startVerseId, endVerseId]);
|
|
791
|
+
return result.rows.map(mapVerse);
|
|
792
|
+
}
|
|
462
793
|
async hasConceptLayer() {
|
|
463
794
|
const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='concepts'");
|
|
464
795
|
return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
|
package/dist/createEngine.d.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* Curated expansion (concepts, cross-references) attaches at step 5 in Phase
|
|
11
11
|
* 2 without changing anything above it.
|
|
12
12
|
*/
|
|
13
|
+
import { type PericopeRow } from './corpus/repository.js';
|
|
13
14
|
import { type RankOptions } from './ranking/rank.js';
|
|
14
|
-
import type { ConceptMatch, ContentQueryPort, PassageResult, RelatedResult, ResearchResult, SongInput } from './types.js';
|
|
15
|
+
import type { ConceptMatch, ContentQueryPort, DiscoveryResult, PassageResult, RelatedResult, ResearchResult, ScriptureVerse, SongInput } from './types.js';
|
|
15
16
|
export interface EngineOptions {
|
|
16
17
|
/**
|
|
17
18
|
* Throw if the artifact was tokenized by a different tokenizer version.
|
|
@@ -56,3 +57,77 @@ export interface ScriptureEngine {
|
|
|
56
57
|
readonly engineVersion: string;
|
|
57
58
|
}
|
|
58
59
|
export declare function createEngine(database: ContentQueryPort, options?: EngineOptions): Promise<ScriptureEngine>;
|
|
60
|
+
/** A grouping span's own extent and the source(s) that named it. */
|
|
61
|
+
export interface GroupingSpanInfo {
|
|
62
|
+
readonly startVerseId: number;
|
|
63
|
+
readonly endVerseId: number;
|
|
64
|
+
readonly sourceIds: ReadonlySet<string>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Collapse the surfaced verses of ONE grouping unit into a single
|
|
68
|
+
* passage-level row. Two mechanisms feed it, in FIXED authority order:
|
|
69
|
+
*
|
|
70
|
+
* 1. Curated anchor spans (0.10.0 stage 7 semantics, unchanged: span
|
|
71
|
+
* MEMBERSHIP, not rank adjacency) — a passage a human named for a theme.
|
|
72
|
+
* Checked first: a verse any anchor span claims belongs to the anchor
|
|
73
|
+
* path and is never considered for pericope runs, so pericope provenance
|
|
74
|
+
* can never usurp anchor provenance (the anchor-grouping-explained
|
|
75
|
+
* fixture pins this).
|
|
76
|
+
* 2. Derived pericopes (0.14.0/CO-3 PR 2) — a structural sectioning fact
|
|
77
|
+
* (OpenBible section counts). Deliberately MORE conservative than the
|
|
78
|
+
* anchor path, because nobody named THIS passage for THIS query: members
|
|
79
|
+
* must be consecutive in rank AND verseId-consecutive AND share one
|
|
80
|
+
* pericope. A boundary is never crossed (the Terah fixture pins this),
|
|
81
|
+
* and ±1 verse-id adjacency structurally cannot cross a chapter — the
|
|
82
|
+
* documented v1 limitation that grouping never crosses a chapter even
|
|
83
|
+
* when a pericope does.
|
|
84
|
+
*
|
|
85
|
+
* Since 0.14.0 a merged row also SAYS why its verses travel together: it
|
|
86
|
+
* carries `verses[]` (each member's own evidence, uncollapsed) and a typed
|
|
87
|
+
* `grouping` naming the section span and the source that drew it — for
|
|
88
|
+
* anchor runs the anchor's own source(s), for pericope runs
|
|
89
|
+
* 'openbible-sections' plus the summed boundary vote at the section's start
|
|
90
|
+
* verse, read from the same artifact row the derivation stored. Grouping
|
|
91
|
+
* contributes ZERO points: the merged score is the max of the members,
|
|
92
|
+
* never a sum — a passage must not outrank by having more mediocre verses —
|
|
93
|
+
* and the row's `reference` spans the HITS, never the whole section.
|
|
94
|
+
*
|
|
95
|
+
* Why this exists: a ranged anchor emits one candidate per verse, and results
|
|
96
|
+
* carrying authoritative evidence are deliberately exempt from group
|
|
97
|
+
* diversification — a genuine multi-verse hit must never be thinned for the
|
|
98
|
+
* sake of variety. Correct for exact-phrase matches; wrong for a curated span,
|
|
99
|
+
* where the results ARE one passage. `communion` returned 1 Corinthians
|
|
100
|
+
* 11:23, :24, :25 and :26 at identical scores, spending the whole top of the
|
|
101
|
+
* list on a passage a human had already grouped.
|
|
102
|
+
*
|
|
103
|
+
* Until 0.10.0 this required RANK adjacency, which made the collapse depend
|
|
104
|
+
* on what happened to rank in between: `praise` filled five slots with
|
|
105
|
+
* individual verses of Psalm 150 because they ranked non-adjacently, and one
|
|
106
|
+
* differently-scored verse of a span broke the whole merge. The span is the
|
|
107
|
+
* unit because a person chose it — whether its verses rank consecutively is
|
|
108
|
+
* an accident of the other evidence. Now every surfaced member of a span
|
|
109
|
+
* merges into one row at the position of its best-ranked member; the members
|
|
110
|
+
* below drop and the results shift up. That is the point: the passage
|
|
111
|
+
* occupies one slot, not N.
|
|
112
|
+
*
|
|
113
|
+
* Determinism: pass 1 assigns each result a governing span — the span, among
|
|
114
|
+
* the spans it belongs to, covering the most surfaced results (ties broken by
|
|
115
|
+
* span key ascending) — and pass 2 emits in rank order, so the output is a
|
|
116
|
+
* pure function of the ranker's total order and data already in the artifact.
|
|
117
|
+
* Nothing is inferred, and equal inputs collapse identically on every
|
|
118
|
+
* platform.
|
|
119
|
+
*
|
|
120
|
+
* The merged row is honest about what surfaced: its reference spans the
|
|
121
|
+
* surfaced members (canonical min..max), the excerpt is their texts in
|
|
122
|
+
* canonical verse order, the score is the best member's (existing policy),
|
|
123
|
+
* and reasons merge strongest-per-label so the chips explain the passage
|
|
124
|
+
* rather than an arbitrary one of its verses.
|
|
125
|
+
*/
|
|
126
|
+
export declare function collapseRuns(results: readonly DiscoveryResult[], verses: ReadonlyMap<string, ScriptureVerse>, anchorSpans: ReadonlyMap<string, ReadonlySet<string>>, spanInfo: ReadonlyMap<string, GroupingSpanInfo>, pericopeOf: ReadonlyMap<number, PericopeRow>): readonly DiscoveryResult[];
|
|
127
|
+
/**
|
|
128
|
+
* The 0.13.0 entry point, kept for compatibility: anchor-span collapse only,
|
|
129
|
+
* no pericope runs, merged rows in their exact pre-0.14.0 shape (no
|
|
130
|
+
* `verses`/`grouping` — those need the span provenance the 5-argument
|
|
131
|
+
* `collapseRuns` receives). `discover()` no longer calls this.
|
|
132
|
+
*/
|
|
133
|
+
export declare function collapseAnchorRuns(results: readonly DiscoveryResult[], verses: ReadonlyMap<string, ScriptureVerse>, anchorSpans: ReadonlyMap<string, ReadonlySet<string>>): readonly DiscoveryResult[];
|