@jestek-dev/scripture-engine 0.7.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/dist/config/engineVersion.d.ts +19 -0
- package/dist/config/engineVersion.js +19 -0
- package/dist/corpus/repository.d.ts +193 -0
- package/dist/corpus/repository.js +466 -0
- package/dist/createEngine.d.ts +58 -0
- package/dist/createEngine.js +338 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +18 -0
- package/dist/intents/concept.d.ts +69 -0
- package/dist/intents/concept.js +143 -0
- package/dist/intents/lexical.d.ts +51 -0
- package/dist/intents/lexical.js +136 -0
- package/dist/ranking/budgets.d.ts +65 -0
- package/dist/ranking/budgets.js +149 -0
- package/dist/ranking/rank.d.ts +42 -0
- package/dist/ranking/rank.js +71 -0
- package/dist/reasons/types.d.ts +55 -0
- package/dist/reasons/types.js +20 -0
- package/dist/reference/reference.d.ts +31 -0
- package/dist/reference/reference.js +130 -0
- package/dist/reference/verseId.d.ts +12 -0
- package/dist/reference/verseId.js +36 -0
- package/dist/tokenizer/index.d.ts +39 -0
- package/dist/tokenizer/index.js +207 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +11 -0
- package/package.json +27 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corpus access. The only module that knows SQL.
|
|
3
|
+
*
|
|
4
|
+
* Ported from Maskil's `ScriptureRepository` and extended with the token
|
|
5
|
+
* postings this engine adds. Everything here returns plain data; scoring
|
|
6
|
+
* lives in `ranking/`, so the ranker stays pure and unit-testable without a
|
|
7
|
+
* database.
|
|
8
|
+
*/
|
|
9
|
+
import { normalizeBookAlias, resolveReferenceAttempt, } from '../reference/reference.js';
|
|
10
|
+
export const MAX_PHRASE_LENGTH = 500;
|
|
11
|
+
export const MAX_CANDIDATES = 200;
|
|
12
|
+
function num(row, key) {
|
|
13
|
+
const value = row[key];
|
|
14
|
+
if (typeof value !== 'number')
|
|
15
|
+
throw new Error(`Corpus returned invalid numeric ${key}`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function str(row, key) {
|
|
19
|
+
const value = row[key];
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
throw new Error(`Corpus returned invalid text ${key}`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function mapVerse(row) {
|
|
25
|
+
return {
|
|
26
|
+
id: num(row, 'id'),
|
|
27
|
+
verseId: num(row, 'verseId'),
|
|
28
|
+
translationId: num(row, 'translationId'),
|
|
29
|
+
translationCode: str(row, 'translationCode'),
|
|
30
|
+
bookId: num(row, 'bookId'),
|
|
31
|
+
bookName: str(row, 'bookName'),
|
|
32
|
+
chapter: num(row, 'chapter'),
|
|
33
|
+
verse: num(row, 'verse'),
|
|
34
|
+
text: str(row, 'text'),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const VERSE_PROJECTION = `
|
|
38
|
+
SELECT v.id AS id, v.verse_id AS verseId,
|
|
39
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
40
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
41
|
+
b.chapter_count AS chapterCount,
|
|
42
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text
|
|
43
|
+
FROM verses v
|
|
44
|
+
JOIN translations t ON t.id = v.translation_id
|
|
45
|
+
JOIN books b ON b.id = v.book_id`;
|
|
46
|
+
export class CorpusRepository {
|
|
47
|
+
database;
|
|
48
|
+
constructor(database) {
|
|
49
|
+
this.database = database;
|
|
50
|
+
}
|
|
51
|
+
async close() {
|
|
52
|
+
await this.database.close();
|
|
53
|
+
}
|
|
54
|
+
async readMeta() {
|
|
55
|
+
const result = await this.database.execute('SELECT key, value FROM meta');
|
|
56
|
+
const map = new Map(result.rows.map((row) => [str(row, 'key'), str(row, 'value')]));
|
|
57
|
+
const required = (key) => {
|
|
58
|
+
const value = map.get(key);
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
throw new Error(`Corpus meta is missing ${key}`);
|
|
61
|
+
return value;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
schemaVersion: required('schema_version'),
|
|
65
|
+
tokenizerVersion: required('tokenizer_version'),
|
|
66
|
+
corpusFingerprint: required('corpus_fingerprint'),
|
|
67
|
+
verseCount: Number(required('verse_count')),
|
|
68
|
+
avgVerseTokens: Number(required('avg_verse_tokens')),
|
|
69
|
+
layerFingerprint: map.get('layer_fingerprint') ?? '',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Total indexed verses per translation — the N in IDF. */
|
|
73
|
+
async documentCount() {
|
|
74
|
+
const result = await this.database.execute('SELECT COUNT(*) AS n FROM verses');
|
|
75
|
+
return num(result.rows[0] ?? {}, 'n');
|
|
76
|
+
}
|
|
77
|
+
async resolveBookAlias(aliasKey) {
|
|
78
|
+
const result = await this.database.execute(`SELECT b.id AS id, b.name AS name, b.chapter_count AS chapterCount
|
|
79
|
+
FROM book_aliases a JOIN books b ON b.id = a.book_id
|
|
80
|
+
WHERE a.alias_key = ? LIMIT 1`, [normalizeBookAlias(aliasKey)]);
|
|
81
|
+
const row = result.rows[0];
|
|
82
|
+
return row
|
|
83
|
+
? { id: num(row, 'id'), name: str(row, 'name'), chapterCount: num(row, 'chapterCount') }
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
async getChapterVerseCount(bookId, chapter) {
|
|
87
|
+
const result = await this.database.execute('SELECT max(verse) AS verseCount FROM verses WHERE book_id = ? AND chapter = ?', [bookId, chapter]);
|
|
88
|
+
const value = result.rows[0]?.verseCount;
|
|
89
|
+
return typeof value === 'number' && value > 0 ? value : null;
|
|
90
|
+
}
|
|
91
|
+
async verseExists(bookId, chapter, verse) {
|
|
92
|
+
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
|
+
return result.rows.length > 0;
|
|
94
|
+
}
|
|
95
|
+
async resolveReference(input) {
|
|
96
|
+
return await resolveReferenceAttempt(input, this);
|
|
97
|
+
}
|
|
98
|
+
async loadPassage(resolved) {
|
|
99
|
+
const result = await this.database.execute(`${VERSE_PROJECTION}
|
|
100
|
+
WHERE v.book_id = ? AND v.verse_id BETWEEN ? AND ?
|
|
101
|
+
ORDER BY v.verse_id, t.code`, [resolved.book.id, resolved.startId, resolved.endId]);
|
|
102
|
+
return {
|
|
103
|
+
reference: resolved.label,
|
|
104
|
+
bookId: resolved.book.id,
|
|
105
|
+
chapterCount: resolved.book.chapterCount,
|
|
106
|
+
startChapter: resolved.startChapter,
|
|
107
|
+
endChapter: resolved.endChapter,
|
|
108
|
+
verses: result.rows.map(mapVerse),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Exact phrase via FTS5, ranked by bm25.
|
|
113
|
+
*
|
|
114
|
+
* Runs against the RAW verse text, not our token stream, because "exact"
|
|
115
|
+
* must mean exact — a user asking for a verbatim phrase does not want
|
|
116
|
+
* stemming or archaic folding applied behind their back. The token intent
|
|
117
|
+
* is where fuzziness is allowed, and it says so in its reason label.
|
|
118
|
+
*/
|
|
119
|
+
async searchPhrase(phrase, limit = MAX_CANDIDATES) {
|
|
120
|
+
const normalized = phrase.trim().replace(/\s+/g, ' ');
|
|
121
|
+
if (!normalized || normalized.length > MAX_PHRASE_LENGTH)
|
|
122
|
+
return [];
|
|
123
|
+
// Double-quote escaping makes the whole query one FTS5 string literal, so
|
|
124
|
+
// user input can never be interpreted as FTS operators (NEAR, OR, *).
|
|
125
|
+
const ftsPhrase = `"${normalized.replaceAll('"', '""')}"`;
|
|
126
|
+
const result = await this.database.execute(`SELECT v.id AS id, v.verse_id AS verseId,
|
|
127
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
128
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
129
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text,
|
|
130
|
+
bm25(verses_fts) AS bm25
|
|
131
|
+
FROM verses_fts f
|
|
132
|
+
JOIN verses v ON v.id = f.rowid
|
|
133
|
+
JOIN translations t ON t.id = v.translation_id
|
|
134
|
+
JOIN books b ON b.id = v.book_id
|
|
135
|
+
WHERE verses_fts MATCH ?
|
|
136
|
+
ORDER BY bm25(verses_fts), v.verse_id, t.code
|
|
137
|
+
LIMIT ?`, [ftsPhrase, limit]);
|
|
138
|
+
return result.rows.map((row) => ({ ...mapVerse(row), bm25: num(row, 'bm25') }));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Token search over precomputed postings.
|
|
142
|
+
*
|
|
143
|
+
* This is the intent that makes theme-ish queries work before any concept
|
|
144
|
+
* layer exists: query tokens are folded by the same tokenizer the corpus
|
|
145
|
+
* was indexed with, so "hearing and doing" reaches "heareth ... doeth".
|
|
146
|
+
*
|
|
147
|
+
* IDF is computed from stored document counts, which is why keeping common
|
|
148
|
+
* words as tokens is safe — "do" earns a near-zero weight automatically
|
|
149
|
+
* instead of needing a stopword list to predict its unimportance.
|
|
150
|
+
*/
|
|
151
|
+
async searchTokens(tokens, documentCount, limit = MAX_CANDIDATES) {
|
|
152
|
+
const unique = [...new Set(tokens)];
|
|
153
|
+
if (unique.length === 0)
|
|
154
|
+
return [];
|
|
155
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
156
|
+
const params = [...unique, ...unique, limit];
|
|
157
|
+
const result = await this.database.execute(`WITH matched AS (
|
|
158
|
+
SELECT vt.verse_row_id AS rowId, vt.token AS token,
|
|
159
|
+
MIN(vt.position) AS firstPos, MAX(vt.position) AS lastPos
|
|
160
|
+
FROM verse_tokens vt
|
|
161
|
+
WHERE vt.token IN (${placeholders})
|
|
162
|
+
GROUP BY vt.verse_row_id, vt.token
|
|
163
|
+
),
|
|
164
|
+
scored AS (
|
|
165
|
+
SELECT m.rowId AS rowId,
|
|
166
|
+
COUNT(*) AS tokenCount,
|
|
167
|
+
MIN(m.firstPos) AS spanStart,
|
|
168
|
+
MAX(m.lastPos) AS spanEnd,
|
|
169
|
+
group_concat(m.token, ' ') AS tokens
|
|
170
|
+
FROM matched m
|
|
171
|
+
GROUP BY m.rowId
|
|
172
|
+
)
|
|
173
|
+
SELECT v.id AS id, v.verse_id AS verseId,
|
|
174
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
175
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
176
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text,
|
|
177
|
+
v.token_count AS verseTokenCount,
|
|
178
|
+
v.distinct_token_count AS verseDistinctTokenCount,
|
|
179
|
+
s.tokenCount AS tokenCount, s.tokens AS tokens,
|
|
180
|
+
s.spanStart AS spanStart, s.spanEnd AS spanEnd,
|
|
181
|
+
(SELECT COALESCE(SUM(ts.document_count), 0)
|
|
182
|
+
FROM token_stats ts
|
|
183
|
+
WHERE ts.translation_id = v.translation_id
|
|
184
|
+
AND ts.token IN (${placeholders})) AS dfSum
|
|
185
|
+
FROM scored s
|
|
186
|
+
JOIN verses v ON v.id = s.rowId
|
|
187
|
+
JOIN translations t ON t.id = v.translation_id
|
|
188
|
+
JOIN books b ON b.id = v.book_id
|
|
189
|
+
ORDER BY s.tokenCount DESC, v.verse_id, t.code
|
|
190
|
+
LIMIT ?`, params);
|
|
191
|
+
// IDF is applied here rather than in SQL: it needs per-token document
|
|
192
|
+
// counts, and doing the arithmetic in TypeScript keeps the weighting
|
|
193
|
+
// formula visible and testable instead of buried in a query.
|
|
194
|
+
const stats = await this.tokenDocumentCounts(unique);
|
|
195
|
+
return result.rows.map((row) => {
|
|
196
|
+
const matchedTokens = str(row, 'tokens').split(' ').filter(Boolean);
|
|
197
|
+
const distinct = [...new Set(matchedTokens)].sort();
|
|
198
|
+
const idfSum = distinct.reduce((sum, token) => {
|
|
199
|
+
const df = stats.get(token) ?? 0;
|
|
200
|
+
// Smoothed IDF; +1 keeps a token appearing in every verse at weight 0
|
|
201
|
+
// rather than negative, so ubiquity is worthless, never harmful.
|
|
202
|
+
return sum + Math.log(1 + documentCount / Math.max(1, df));
|
|
203
|
+
}, 0);
|
|
204
|
+
const spanStart = num(row, 'spanStart');
|
|
205
|
+
const spanEnd = num(row, 'spanEnd');
|
|
206
|
+
return {
|
|
207
|
+
...mapVerse(row),
|
|
208
|
+
matchedTokens: distinct,
|
|
209
|
+
idfSum,
|
|
210
|
+
minSpan: distinct.length > 1 ? spanEnd - spanStart : null,
|
|
211
|
+
tokenCount: num(row, 'verseTokenCount'),
|
|
212
|
+
distinctTokenCount: num(row, 'verseDistinctTokenCount'),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async tokenDocumentCounts(tokens) {
|
|
217
|
+
const unique = [...new Set(tokens)];
|
|
218
|
+
if (unique.length === 0)
|
|
219
|
+
return new Map();
|
|
220
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
221
|
+
const result = await this.database.execute(`SELECT token, SUM(document_count) AS df
|
|
222
|
+
FROM token_stats WHERE token IN (${placeholders}) GROUP BY token`, unique);
|
|
223
|
+
return new Map(result.rows.map((row) => [str(row, 'token'), num(row, 'df')]));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Longest fragment length worth searching; below this, phrases are noise. */
|
|
227
|
+
const MIN_FRAGMENT_WORDS = 3;
|
|
228
|
+
/** Queries longer than this skip fragment search to bound query cost. */
|
|
229
|
+
const MAX_FRAGMENT_QUERY_WORDS = 14;
|
|
230
|
+
/**
|
|
231
|
+
* Finds the LONGEST verbatim fragment of the query present in the corpus.
|
|
232
|
+
*
|
|
233
|
+
* Users paraphrase. "be doers of the word not hearers only" is nobody's
|
|
234
|
+
* translation verbatim, but "doers of the word" is exactly James 1:22 — and
|
|
235
|
+
* a search that only tries the whole query throws that away, leaving the
|
|
236
|
+
* decision to weaker signals.
|
|
237
|
+
*
|
|
238
|
+
* Longest-first with early exit: the first fragment length that matches wins,
|
|
239
|
+
* so we never pay for shorter, vaguer fragments once a strong one is found.
|
|
240
|
+
* Strength is computed by the caller as fragmentWords / queryWords, which
|
|
241
|
+
* makes partial verbatim evidence proportional to how much of the question it
|
|
242
|
+
* actually answers — a full verbatim match still earns full authority.
|
|
243
|
+
*/
|
|
244
|
+
export async function searchLongestFragment(repository, query, limit = MAX_CANDIDATES) {
|
|
245
|
+
const words = query.trim().split(/\s+/).filter(Boolean);
|
|
246
|
+
if (words.length < MIN_FRAGMENT_WORDS || words.length > MAX_FRAGMENT_QUERY_WORDS)
|
|
247
|
+
return null;
|
|
248
|
+
for (let size = words.length; size >= MIN_FRAGMENT_WORDS; size -= 1) {
|
|
249
|
+
for (let start = 0; start + size <= words.length; start += 1) {
|
|
250
|
+
const fragment = words.slice(start, start + size).join(' ');
|
|
251
|
+
const matches = await repository.searchPhrase(fragment, limit);
|
|
252
|
+
if (matches.length > 0) {
|
|
253
|
+
return { fragment, fragmentWords: size, queryWords: words.length, matches };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Concept lookup and anchor expansion — Layer A at query time.
|
|
261
|
+
*
|
|
262
|
+
* Kept in the repository (not the intents module) because it is SQL; the
|
|
263
|
+
* scoring decisions live in `intents/concept.ts` so they stay pure.
|
|
264
|
+
*/
|
|
265
|
+
export class ConceptRepository {
|
|
266
|
+
database;
|
|
267
|
+
constructor(database) {
|
|
268
|
+
this.database = database;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Concepts whose lexicon phrase is fully contained in the query's tokens.
|
|
272
|
+
*
|
|
273
|
+
* Containment, not similarity: "hearing and doing" (tokens hear, do) fires
|
|
274
|
+
* the concept because every token of the lexicon phrase is present. A
|
|
275
|
+
* fuzzy threshold here would be a second, hidden ranking system competing
|
|
276
|
+
* with the real one — the lexicon is curated precisely so matching can be
|
|
277
|
+
* exact and explainable.
|
|
278
|
+
*/
|
|
279
|
+
async matchConcepts(queryTokens) {
|
|
280
|
+
const tokens = [...new Set(queryTokens)];
|
|
281
|
+
if (tokens.length === 0)
|
|
282
|
+
return [];
|
|
283
|
+
const result = await this.database.execute(`SELECT cl.concept_id AS conceptId, c.label AS label,
|
|
284
|
+
cl.phrase AS phrase, cl.normalized AS normalized, cl.token_count AS tokenCount
|
|
285
|
+
FROM concept_lexicon cl
|
|
286
|
+
JOIN concepts c ON c.id = cl.concept_id`);
|
|
287
|
+
const present = new Set(tokens);
|
|
288
|
+
const best = new Map();
|
|
289
|
+
for (const row of result.rows) {
|
|
290
|
+
const phraseTokens = str(row, 'normalized').split(' ').filter(Boolean);
|
|
291
|
+
if (phraseTokens.length === 0)
|
|
292
|
+
continue;
|
|
293
|
+
if (!phraseTokens.every((token) => present.has(token)))
|
|
294
|
+
continue;
|
|
295
|
+
const candidate = {
|
|
296
|
+
conceptId: str(row, 'conceptId'),
|
|
297
|
+
label: str(row, 'label'),
|
|
298
|
+
matchedPhrase: str(row, 'phrase'),
|
|
299
|
+
matchedTokenCount: num(row, 'tokenCount'),
|
|
300
|
+
};
|
|
301
|
+
// Keep the most specific matching phrase per concept: a three-token
|
|
302
|
+
// phrase matching is stronger evidence than a one-token one.
|
|
303
|
+
const existing = best.get(candidate.conceptId);
|
|
304
|
+
if (!existing || candidate.matchedTokenCount > existing.matchedTokenCount) {
|
|
305
|
+
best.set(candidate.conceptId, candidate);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return [...best.values()].sort((a, b) => a.conceptId < b.conceptId ? -1 : a.conceptId > b.conceptId ? 1 : 0);
|
|
309
|
+
}
|
|
310
|
+
/** Verses anchored by the given concepts. */
|
|
311
|
+
async anchorVerses(conceptIds) {
|
|
312
|
+
const unique = [...new Set(conceptIds)];
|
|
313
|
+
if (unique.length === 0)
|
|
314
|
+
return [];
|
|
315
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
316
|
+
const result = await this.database.execute(`SELECT v.id AS id, v.verse_id AS verseId,
|
|
317
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
318
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
319
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text,
|
|
320
|
+
a.concept_id AS conceptId, c.label AS conceptLabel,
|
|
321
|
+
a.source_id AS sourceId, a.weight AS weight, a.locator AS locator
|
|
322
|
+
FROM concept_anchors a
|
|
323
|
+
JOIN concepts c ON c.id = a.concept_id
|
|
324
|
+
JOIN verses v ON v.verse_id BETWEEN a.start_verse_id AND a.end_verse_id
|
|
325
|
+
JOIN translations t ON t.id = v.translation_id
|
|
326
|
+
JOIN books b ON b.id = v.book_id
|
|
327
|
+
WHERE a.concept_id IN (${placeholders})
|
|
328
|
+
ORDER BY a.concept_id, v.verse_id, t.code`, unique);
|
|
329
|
+
return result.rows.map((row) => ({
|
|
330
|
+
...mapVerse(row),
|
|
331
|
+
conceptId: str(row, 'conceptId'),
|
|
332
|
+
conceptLabel: str(row, 'conceptLabel'),
|
|
333
|
+
sourceId: str(row, 'sourceId'),
|
|
334
|
+
weight: num(row, 'weight'),
|
|
335
|
+
locator: typeof row['locator'] === 'string' ? row['locator'] : null,
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The reverse of anchorVerses: which curated concepts name THIS passage?
|
|
340
|
+
*
|
|
341
|
+
* Powers `related()`, whose contract is "what did a human connect to this
|
|
342
|
+
* text", not "what resembles it". An anchor overlapping the passage at all
|
|
343
|
+
* counts, because a concept anchored to James 1:22-25 is about James 1:23
|
|
344
|
+
* even though it does not name that verse alone.
|
|
345
|
+
*/
|
|
346
|
+
async conceptsAnchoring(startVerseId, endVerseId) {
|
|
347
|
+
const result = await this.database.execute(`SELECT DISTINCT a.concept_id AS conceptId, c.label AS label
|
|
348
|
+
FROM concept_anchors a
|
|
349
|
+
JOIN concepts c ON c.id = a.concept_id
|
|
350
|
+
WHERE a.start_verse_id <= ? AND a.end_verse_id >= ?
|
|
351
|
+
ORDER BY a.concept_id`, [endVerseId, startVerseId]);
|
|
352
|
+
return result.rows.map((row) => ({
|
|
353
|
+
conceptId: str(row, 'conceptId'),
|
|
354
|
+
label: str(row, 'label'),
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
/** Concepts one hop away in the curated graph. */
|
|
358
|
+
async relatedConcepts(conceptIds) {
|
|
359
|
+
const unique = [...new Set(conceptIds)];
|
|
360
|
+
if (unique.length === 0)
|
|
361
|
+
return [];
|
|
362
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
363
|
+
const result = await this.database.execute(`SELECT DISTINCT related_id AS relatedId FROM concept_related
|
|
364
|
+
WHERE concept_id IN (${placeholders}) ORDER BY related_id`, unique);
|
|
365
|
+
return result.rows
|
|
366
|
+
.map((row) => str(row, 'relatedId'))
|
|
367
|
+
.filter((id) => !unique.includes(id));
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Verses reached by cross-reference from the given seed verses.
|
|
371
|
+
*
|
|
372
|
+
* Bounded per seed: an unbounded expansion would let one well-connected
|
|
373
|
+
* verse flood the candidate set, which is precision erosion by another
|
|
374
|
+
* name.
|
|
375
|
+
*/
|
|
376
|
+
async expandCrossReferences(fromVerseIds, perSeedLimit = 5) {
|
|
377
|
+
const unique = [...new Set(fromVerseIds)];
|
|
378
|
+
if (unique.length === 0)
|
|
379
|
+
return [];
|
|
380
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
381
|
+
const result = await this.database.execute(`WITH ranked AS (
|
|
382
|
+
SELECT x.from_verse_id AS fromVerseId, x.to_start_verse_id AS toStart,
|
|
383
|
+
x.to_end_verse_id AS toEnd, x.source_id AS sourceId, x.votes AS votes,
|
|
384
|
+
ROW_NUMBER() OVER (
|
|
385
|
+
PARTITION BY x.from_verse_id ORDER BY x.votes DESC, x.to_start_verse_id
|
|
386
|
+
) AS rn
|
|
387
|
+
FROM cross_references x
|
|
388
|
+
WHERE x.from_verse_id IN (${placeholders})
|
|
389
|
+
)
|
|
390
|
+
SELECT v.id AS id, v.verse_id AS verseId,
|
|
391
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
392
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
393
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text,
|
|
394
|
+
r.fromVerseId AS fromVerseId, r.sourceId AS sourceId, r.votes AS votes
|
|
395
|
+
FROM ranked r
|
|
396
|
+
JOIN verses v ON v.verse_id BETWEEN r.toStart AND r.toEnd
|
|
397
|
+
JOIN translations t ON t.id = v.translation_id
|
|
398
|
+
JOIN books b ON b.id = v.book_id
|
|
399
|
+
WHERE r.rn <= ?
|
|
400
|
+
ORDER BY r.votes DESC, v.verse_id, t.code`, [...unique, perSeedLimit]);
|
|
401
|
+
return result.rows.map((row) => ({
|
|
402
|
+
...mapVerse(row),
|
|
403
|
+
fromVerseId: num(row, 'fromVerseId'),
|
|
404
|
+
sourceId: str(row, 'sourceId'),
|
|
405
|
+
votes: num(row, 'votes'),
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
/** Highest observed vote count, used to normalize vote-derived strength. */
|
|
409
|
+
async maxCrossReferenceVotes() {
|
|
410
|
+
const result = await this.database.execute('SELECT COALESCE(MAX(votes), 0) AS maxVotes FROM cross_references');
|
|
411
|
+
return num(result.rows[0] ?? { maxVotes: 0 }, 'maxVotes');
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Verses whose pericope profile contains query terms (Layer B).
|
|
415
|
+
*
|
|
416
|
+
* Weak evidence by design and by budget: a preacher using a word while
|
|
417
|
+
* expounding a passage is real signal about what the passage is ABOUT, but
|
|
418
|
+
* it is a long way from the passage saying it. G6 caps it so no volume of
|
|
419
|
+
* homiletical vocabulary can outrank a curated anchor or a verbatim quote.
|
|
420
|
+
*/
|
|
421
|
+
async searchPassageTerms(terms, limit = 60) {
|
|
422
|
+
const unique = [...new Set(terms)];
|
|
423
|
+
if (unique.length === 0)
|
|
424
|
+
return [];
|
|
425
|
+
const placeholders = unique.map(() => '?').join(', ');
|
|
426
|
+
const result = await this.database.execute(`WITH hits AS (
|
|
427
|
+
SELECT vt.verse_id AS vid,
|
|
428
|
+
group_concat(vt.term, ' ') AS terms,
|
|
429
|
+
SUM(vt.pmi) AS pmiSum,
|
|
430
|
+
MIN(vt.min_span_verses) AS minSpan,
|
|
431
|
+
MIN(vt.source_ids) AS sourceIds,
|
|
432
|
+
MIN(vt.locator) AS locator
|
|
433
|
+
FROM verse_terms vt
|
|
434
|
+
WHERE vt.term IN (${placeholders})
|
|
435
|
+
GROUP BY vt.verse_id
|
|
436
|
+
)
|
|
437
|
+
SELECT v.id AS id, v.verse_id AS verseId,
|
|
438
|
+
v.translation_id AS translationId, t.code AS translationCode,
|
|
439
|
+
v.book_id AS bookId, b.name AS bookName,
|
|
440
|
+
v.chapter AS chapter, v.verse AS verse, v.text AS text,
|
|
441
|
+
h.terms AS terms, h.pmiSum AS pmiSum, h.minSpan AS minSpan,
|
|
442
|
+
h.sourceIds AS sourceIds, h.locator AS locator
|
|
443
|
+
FROM hits h
|
|
444
|
+
JOIN verses v ON v.verse_id = h.vid
|
|
445
|
+
JOIN translations t ON t.id = v.translation_id
|
|
446
|
+
JOIN books b ON b.id = v.book_id
|
|
447
|
+
ORDER BY h.pmiSum DESC, v.verse_id, t.code
|
|
448
|
+
LIMIT ?`, [...unique, limit]);
|
|
449
|
+
return result.rows.map((row) => ({
|
|
450
|
+
...mapVerse(row),
|
|
451
|
+
matchedTerms: [...new Set(str(row, 'terms').split(' ').filter(Boolean))].sort(),
|
|
452
|
+
pmiSum: num(row, 'pmiSum'),
|
|
453
|
+
sourceIds: str(row, 'sourceIds'),
|
|
454
|
+
minSpanVerses: num(row, 'minSpan'),
|
|
455
|
+
locator: str(row, 'locator'),
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
async hasPassageTerms() {
|
|
459
|
+
const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='verse_terms'");
|
|
460
|
+
return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
|
|
461
|
+
}
|
|
462
|
+
async hasConceptLayer() {
|
|
463
|
+
const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='concepts'");
|
|
464
|
+
return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The orchestrator — the only place in the engine that does I/O, and it does
|
|
3
|
+
* it through `ContentQueryPort` alone.
|
|
4
|
+
*
|
|
5
|
+
* Intent order follows the 2026-07-20 plan exactly:
|
|
6
|
+
* 1. explicit reference lookup
|
|
7
|
+
* 2. exact normalized phrase
|
|
8
|
+
* 3. distinctive tokens with proximity preference
|
|
9
|
+
* 4. conservative normalization (inflection + archaic forms)
|
|
10
|
+
* Curated expansion (concepts, cross-references) attaches at step 5 in Phase
|
|
11
|
+
* 2 without changing anything above it.
|
|
12
|
+
*/
|
|
13
|
+
import { type RankOptions } from './ranking/rank.js';
|
|
14
|
+
import type { ConceptMatch, ContentQueryPort, PassageResult, RelatedResult, ResearchResult, SongInput } from './types.js';
|
|
15
|
+
export interface EngineOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Throw if the artifact was tokenized by a different tokenizer version.
|
|
18
|
+
* Defaults true, and should stay true outside diagnostics: precomputed
|
|
19
|
+
* postings from another tokenizer describe a vocabulary this runtime
|
|
20
|
+
* cannot reproduce, which yields quietly wrong rankings rather than errors.
|
|
21
|
+
*/
|
|
22
|
+
readonly enforceTokenizerVersion?: boolean;
|
|
23
|
+
readonly rankOptions?: RankOptions;
|
|
24
|
+
}
|
|
25
|
+
export interface ScriptureEngine {
|
|
26
|
+
/** The full ladder with auto-detected intent: reference, phrase, tokens, concepts. */
|
|
27
|
+
research(query: string): Promise<ResearchResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Concept resolution only — "what themes does this text name?" — with no
|
|
30
|
+
* ranking and no verse retrieval.
|
|
31
|
+
*
|
|
32
|
+
* Separate from research() because consumers need the concepts themselves,
|
|
33
|
+
* not passages: Versed builds memorization packs per theme, Setlist shows
|
|
34
|
+
* which themes a sermon note resolved to before any song is scored.
|
|
35
|
+
*/
|
|
36
|
+
themes(query: string): Promise<readonly ConceptMatch[]>;
|
|
37
|
+
/** Parse and fetch a passage. Invalid references are typed, never thrown. */
|
|
38
|
+
passage(reference: string): Promise<PassageResult>;
|
|
39
|
+
/**
|
|
40
|
+
* What curated sources connect to a passage: cross-reference edges, and the
|
|
41
|
+
* concepts whose anchors include it. Not similarity — every entry exists
|
|
42
|
+
* because a human recorded the link.
|
|
43
|
+
*/
|
|
44
|
+
related(reference: string): Promise<RelatedResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Multi-field discovery for song and sermon workflows.
|
|
47
|
+
*
|
|
48
|
+
* Setlist matches a sermon theme to songs; Maskil starts from a song being
|
|
49
|
+
* written. Both have several fields of differing evidential weight, and
|
|
50
|
+
* neither should have to flatten them into one string and lose that.
|
|
51
|
+
*/
|
|
52
|
+
forSong(input: SongInput): Promise<ResearchResult>;
|
|
53
|
+
close(): Promise<void>;
|
|
54
|
+
readonly corpusFingerprint: string;
|
|
55
|
+
readonly layerFingerprint: string;
|
|
56
|
+
readonly engineVersion: string;
|
|
57
|
+
}
|
|
58
|
+
export declare function createEngine(database: ContentQueryPort, options?: EngineOptions): Promise<ScriptureEngine>;
|