@polycode-projects/the-mechanical-code-talker 6.0.18 → 6.0.19
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/package.json +3 -1
- package/src/adapters/corpus/research-source.mjs +6 -2
- package/src/adapters/corpus/wikidata-live.mjs +92 -51
- package/src/adapters/memory/core.mjs +53 -5
- package/src/adapters/memory/rows.mjs +253 -21
- package/src/domain/news-feed.mjs +472 -68
- package/src/domain/sense-gate.mjs +220 -0
- package/src/domain/syllogise.mjs +39 -8
- package/src/domain/term-ledger.mjs +16 -1
- package/src/services/chat.mjs +17 -12
- package/src/services/extract-facts.mjs +284 -19
- package/src/services/news.mjs +51 -12
- package/src/surfaces/web/memory-ask-browser.bundle.js +142 -142
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
// full NLU: nothing here ever paraphrases or invents a fact the recognizer
|
|
19
19
|
// itself didn't produce.
|
|
20
20
|
//
|
|
21
|
-
// --optimistic ALSO run a bounded
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
21
|
+
// --optimistic ALSO run a bounded fuzzy tier over the sentences the strict
|
|
22
|
+
// recognizer skipped (optimisticTriples below): a copula, a
|
|
23
|
+
// lexicon relation verb, or one of the closed newswire event
|
|
24
|
+
// verbs read in a tighter frame, flanked by two resolvable
|
|
25
|
+
// entities, becomes a candidate triple, stored under its OWN
|
|
25
26
|
// low-trust source kind (optimistic-extract:<source>, prior 0.35 —
|
|
26
27
|
// below every curated pack, memory/trust.mjs) with NO operator or
|
|
27
28
|
// teach tag riding alongside, so a fuzzy candidate can never
|
|
@@ -174,12 +175,66 @@ const MAX_TRIPLES_PER_SENTENCE = 4;
|
|
|
174
175
|
// abstains rather than guess, so a long noun pile never mints a stray class.
|
|
175
176
|
const ATTRIBUTIVE_CHAIN_MAX_HOPS = 8;
|
|
176
177
|
|
|
178
|
+
// The verbs a news report states an event with. The relation arm above reads a
|
|
179
|
+
// verb only when the lexicon declares one, and the lexicon's verb list is a
|
|
180
|
+
// software vocabulary — so a whole newswire paragraph ("the moon will
|
|
181
|
+
// completely block the sun") carries no relation under it at all. This closed
|
|
182
|
+
// band sits beside it: transitive event verbs, each of which takes a direct
|
|
183
|
+
// object naming the thing the event happened to, so the frame below can demand
|
|
184
|
+
// an adjacent noun on each side and get the actor and the affected thing.
|
|
185
|
+
//
|
|
186
|
+
// Verbs of speech and attribution are deliberately absent — "say", "tell",
|
|
187
|
+
// "add", "report", "accuse", "claim" each open a reported clause, and the noun
|
|
188
|
+
// after one is the subject of what was said, never the object of the saying.
|
|
189
|
+
// So are verbs whose everyday reading swamps their news one ("hold", "face",
|
|
190
|
+
// "follow", "lead", "reach", "back", "pass", "cut"): a band that admits those
|
|
191
|
+
// buys a handful of events and pays for it in nonsense.
|
|
192
|
+
const NEWSWIRE_RELATION_VERBS = new Set([
|
|
193
|
+
"hit", "strike", "kill", "injure", "wound", "damage", "destroy", "devastate",
|
|
194
|
+
"ban", "halt", "block", "bar", "suspend", "impose",
|
|
195
|
+
"arrest", "detain", "jail", "charge", "convict", "sentence", "deport", "release", "free",
|
|
196
|
+
"elect", "appoint", "oust", "overthrow",
|
|
197
|
+
"sign", "adopt", "approve", "reject", "veto",
|
|
198
|
+
"launch", "unveil", "seize", "capture", "invade", "attack", "bomb", "target",
|
|
199
|
+
"discover", "uncover", "rescue", "evacuate",
|
|
200
|
+
"spark", "trigger", "cause", "force", "deploy", "restore", "expand",
|
|
201
|
+
]);
|
|
202
|
+
// The of-frame heads a newswire event reads THROUGH to what it really touched:
|
|
203
|
+
// "discovers hundreds of ancient amphorae" is a fact about the amphorae, and
|
|
204
|
+
// "charged a group of Cuban men" about the men. Only counting and container
|
|
205
|
+
// heads qualify (OF_PARTITIVE_HEADS and the bare numerals below, plus the
|
|
206
|
+
// classifier heads a class rewrite already reads through) — every other of-
|
|
207
|
+
// chain names its own head, so "restore the sacred glow of fireflies" restores
|
|
208
|
+
// the glow, not the fireflies.
|
|
209
|
+
const OF_COUNT_HEADS = new Set(["hundred", "hundreds", "thousand", "thousands", "million", "millions", "dozen", "dozens", "score", "scores", "handful"]);
|
|
210
|
+
// A verb whose own auxiliary is a be-form heads a passive or a progressive
|
|
211
|
+
// ("was arrested by ICE", "are disappearing"), and there the noun on the
|
|
212
|
+
// subject side is what the event happened TO, not who did it — an active read
|
|
213
|
+
// of one states the reverse of the sentence. The scan crosses adverbs only, so
|
|
214
|
+
// a modal chain that is still active ("will completely block") reads on.
|
|
215
|
+
const BE_AUXILIARIES = new Set(["is", "are", "was", "were", "be", "been", "being", "am"]);
|
|
216
|
+
|
|
217
|
+
// wink's tokenizer keeps a sentence-final full stop glued to the word before
|
|
218
|
+
// it when that word ends the text ("… block the sun." tokenizes as one PROPN
|
|
219
|
+
// "sun."), so a term read off the last token would otherwise be stored with
|
|
220
|
+
// the sentence's own punctuation in its key. Only a LONE trailing stop comes
|
|
221
|
+
// off: an abbreviation carries interior stops too ("U.S.", "P.K.K.") and keeps
|
|
222
|
+
// every one of them.
|
|
223
|
+
const stripSentenceFinalStop = (word) => {
|
|
224
|
+
const text = String(word ?? "");
|
|
225
|
+
return text.endsWith(".") && !text.slice(0, -1).includes(".") ? text.slice(0, -1) : text;
|
|
226
|
+
};
|
|
227
|
+
|
|
177
228
|
/** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
|
|
178
229
|
* word's own normFactTerm (the optimistic tier mints unlisted content nouns
|
|
179
230
|
* the way the strict teach lane already mints "redis"). */
|
|
180
231
|
function foldEntity(word, lexicon) {
|
|
181
|
-
const
|
|
182
|
-
|
|
232
|
+
const surface = stripSentenceFinalStop(word);
|
|
233
|
+
// A multi-word name is stored exactly as it reads. "United States" is the
|
|
234
|
+
// name; "united state" is a lemma fold of a word that was never on its own.
|
|
235
|
+
if (surface.includes(" ")) return normFactTerm(surface);
|
|
236
|
+
const noun = lookupNoun(lexicon, surface.toLowerCase());
|
|
237
|
+
return normFactTerm(noun ? noun.lemma : surface);
|
|
183
238
|
}
|
|
184
239
|
|
|
185
240
|
// The shortest word ingestText's fact-degree scan treats as a content-noun
|
|
@@ -187,10 +242,40 @@ function foldEntity(word, lexicon) {
|
|
|
187
242
|
// lexical fallback's stopword set doesn't already carry.
|
|
188
243
|
const CANDIDATE_TERM_MIN_LENGTH = 3;
|
|
189
244
|
|
|
245
|
+
// An abbreviated personal title carries a trailing stop, which is why wink
|
|
246
|
+
// tags it PROPN and glues it to the surname ("Mr./PROPN Gilman/PROPN"). A
|
|
247
|
+
// title addresses a person; it never names one, so it comes off the front of a
|
|
248
|
+
// name run however short the run is.
|
|
249
|
+
const HONORIFIC_NAME_PREFIXES = new Set([
|
|
250
|
+
"mr", "mrs", "ms", "mx", "dr", "prof", "rev",
|
|
251
|
+
"sen", "rep", "gov", "gen", "capt", "col", "lt", "sgt", "maj",
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
/** The name a run of capitalized tokens states, front-trimmed. A run of three
|
|
255
|
+
* or more sheds a leading role noun the lexicon knows ("Prime Minister Keir
|
|
256
|
+
* Starmer" → "Keir Starmer") or a hyphenated compound that only Title Case
|
|
257
|
+
* lifted to a proper noun ("Ex-Marine Robert Gilman" → "Robert Gilman"). The
|
|
258
|
+
* trim stops at two tokens, so "Count Binface" and "Lake Kariba" keep the word
|
|
259
|
+
* that belongs to the name. An honorific comes off at any length. */
|
|
260
|
+
function trimNameRun(words, lexicon) {
|
|
261
|
+
const bare = (word) => stripSentenceFinalStop(word).toLowerCase();
|
|
262
|
+
let start = 0;
|
|
263
|
+
while (start < words.length - 1 && HONORIFIC_NAME_PREFIXES.has(bare(words[start]))) start += 1;
|
|
264
|
+
while (words.length - start >= 3) {
|
|
265
|
+
const first = bare(words[start]);
|
|
266
|
+
if (!first.includes("-") && !lookupNoun(lexicon, first)) break;
|
|
267
|
+
start += 1;
|
|
268
|
+
}
|
|
269
|
+
return words.slice(start);
|
|
270
|
+
}
|
|
271
|
+
|
|
190
272
|
/** Every NOUN/PROPN token `sentences` names, surface-form occurrence-counted —
|
|
191
273
|
* a POS tagger reads unknown words by context, so an unlisted noun ("wombat")
|
|
192
|
-
* counts exactly like a lexicon-known one.
|
|
193
|
-
|
|
274
|
+
* counts exactly like a lexicon-known one. A contiguous run of two or more
|
|
275
|
+
* PROPN tokens counts ONCE, as the whole name it spells ("Robert Gilman",
|
|
276
|
+
* "United States"); the run's own words never count beside it, because half a
|
|
277
|
+
* name is half a lookup. */
|
|
278
|
+
function candidateTermOccurrencesPos(sentences, nlp, lexicon) {
|
|
194
279
|
const counts = new Map();
|
|
195
280
|
for (const sentence of sentences) {
|
|
196
281
|
let values;
|
|
@@ -202,21 +287,77 @@ function candidateTermOccurrencesPos(sentences, nlp) {
|
|
|
202
287
|
} catch { continue; }
|
|
203
288
|
for (let i = 0; i < values.length; i += 1) {
|
|
204
289
|
if (pos[i] !== "NOUN" && pos[i] !== "PROPN") continue;
|
|
290
|
+
let hi = i;
|
|
291
|
+
if (pos[i] === "PROPN") while (hi + 1 < values.length && pos[hi + 1] === "PROPN") hi += 1;
|
|
292
|
+
if (hi > i) {
|
|
293
|
+
// A Title Case headline lifts its verbs to PROPN too ("Thailand Halts
|
|
294
|
+
// New Gun Licenses…"), gluing a clause into one run. A token the verb
|
|
295
|
+
// tables know splits the run: what stands before it is the name.
|
|
296
|
+
const verbShaped = (w) => {
|
|
297
|
+
const word = stripSentenceFinalStop(String(w)).toLowerCase();
|
|
298
|
+
const lemma = word.endsWith("s") ? word.slice(0, -1) : word;
|
|
299
|
+
return NEWSWIRE_RELATION_VERBS.has(word) || NEWSWIRE_RELATION_VERBS.has(lemma)
|
|
300
|
+
|| Boolean(lookupVerb(lexicon, word));
|
|
301
|
+
};
|
|
302
|
+
let cut = -1;
|
|
303
|
+
for (let k = i + 1; k <= hi; k += 1) if (verbShaped(values[k])) { cut = k; break; }
|
|
304
|
+
const runEnd = cut === -1 ? hi : cut - 1;
|
|
305
|
+
if (runEnd > i) {
|
|
306
|
+
const name = trimNameRun(values.slice(i, runEnd + 1), lexicon).join(" ");
|
|
307
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
308
|
+
} else {
|
|
309
|
+
counts.set(values[i], (counts.get(values[i]) || 0) + 1);
|
|
310
|
+
}
|
|
311
|
+
i = cut === -1 ? hi : cut;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
205
314
|
counts.set(values[i], (counts.get(values[i]) || 0) + 1);
|
|
206
315
|
}
|
|
207
316
|
}
|
|
208
317
|
return counts;
|
|
209
318
|
}
|
|
210
319
|
|
|
320
|
+
/** A sentence whose substantial words are nearly all capitalized is a headline
|
|
321
|
+
* set in Title Case, where a capital says nothing about which words spell a
|
|
322
|
+
* name. The POS tier reads such a sentence by tag and is unaffected; the
|
|
323
|
+
* lexical fallback has only the capitals, so it reads no name runs there. */
|
|
324
|
+
function readsAsTitleCase(words) {
|
|
325
|
+
const substantial = words.filter((word) => word.length >= 4);
|
|
326
|
+
if (substantial.length < 4) return false;
|
|
327
|
+
const capitalized = substantial.filter((word) => /^[A-Z]/.test(word)).length;
|
|
328
|
+
return capitalized / substantial.length >= 0.8;
|
|
329
|
+
}
|
|
330
|
+
|
|
211
331
|
/** The no-wink-model fallback: every word that is neither a closed-class
|
|
212
332
|
* scaffolding token nor a lexicon-known verb/adjective counts as a candidate
|
|
213
333
|
* noun — narrower than the POS tier (no context to lean on), but the same
|
|
214
|
-
* "an unlisted word can still be a content noun" posture.
|
|
334
|
+
* "an unlisted word can still be a content noun" posture. Capitalization
|
|
335
|
+
* stands in for the missing tags when it carries information: two or more
|
|
336
|
+
* capitalized words separated by nothing but spaces count once, as one name. */
|
|
215
337
|
function candidateTermOccurrencesLexical(sentences, lexicon) {
|
|
216
338
|
const counts = new Map();
|
|
217
339
|
for (const sentence of sentences) {
|
|
218
|
-
const
|
|
219
|
-
|
|
340
|
+
const text = String(sentence || "");
|
|
341
|
+
const matches = [...text.matchAll(/[A-Za-z][A-Za-z'-]*/g)];
|
|
342
|
+
const words = matches.map((match) => match[0]);
|
|
343
|
+
const titleCase = readsAsTitleCase(words);
|
|
344
|
+
const spacedRunEnd = (start) => {
|
|
345
|
+
let hi = start;
|
|
346
|
+
while (hi + 1 < matches.length && /^[A-Z]/.test(words[hi + 1])
|
|
347
|
+
&& !text.slice(matches[hi].index + words[hi].length, matches[hi + 1].index).trim()) hi += 1;
|
|
348
|
+
return hi;
|
|
349
|
+
};
|
|
350
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
351
|
+
const word = words[i];
|
|
352
|
+
if (!titleCase && /^[A-Z]/.test(word)) {
|
|
353
|
+
const hi = spacedRunEnd(i);
|
|
354
|
+
if (hi > i) {
|
|
355
|
+
const name = trimNameRun(words.slice(i, hi + 1), lexicon).join(" ");
|
|
356
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
357
|
+
i = hi;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
220
361
|
const lower = word.toLowerCase();
|
|
221
362
|
if (lower.length < CANDIDATE_TERM_MIN_LENGTH) continue;
|
|
222
363
|
if (OPTIMISTIC_SKIP.has(lower)) continue;
|
|
@@ -227,14 +368,47 @@ function candidateTermOccurrencesLexical(sentences, lexicon) {
|
|
|
227
368
|
return counts;
|
|
228
369
|
}
|
|
229
370
|
|
|
371
|
+
/** A single-word term that is one word of exactly one multi-word name the same
|
|
372
|
+
* text captured is that name's fragment, not a term of its own: "Gilman"
|
|
373
|
+
* beside "Robert Gilman" is the same person, and only the whole name is a
|
|
374
|
+
* question a reference lookup can answer. Its occurrences move onto the name.
|
|
375
|
+
* A word no captured name holds ("Russia", standing alone) is left where it
|
|
376
|
+
* is, and a word two names share is dropped rather than guessed onto one. */
|
|
377
|
+
function foldNameFragments(counts) {
|
|
378
|
+
const namesByWord = new Map();
|
|
379
|
+
for (const term of counts.keys()) {
|
|
380
|
+
if (!term.includes(" ")) continue;
|
|
381
|
+
for (const word of term.split(" ")) {
|
|
382
|
+
if (!namesByWord.has(word)) namesByWord.set(word, new Set());
|
|
383
|
+
namesByWord.get(word).add(term);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const [term, occurrences] of [...counts]) {
|
|
387
|
+
if (term.includes(" ")) continue;
|
|
388
|
+
const names = namesByWord.get(term);
|
|
389
|
+
if (!names) continue;
|
|
390
|
+
counts.delete(term);
|
|
391
|
+
if (names.size !== 1) continue;
|
|
392
|
+
const [name] = names;
|
|
393
|
+
counts.set(name, (counts.get(name) || 0) + occurrences);
|
|
394
|
+
}
|
|
395
|
+
return counts;
|
|
396
|
+
}
|
|
397
|
+
|
|
230
398
|
/** Every fact-ungrounded term `sentences` names: a candidate noun folded to
|
|
231
399
|
* its stored term key (`foldEntity`, so a ledger entry and a stored fact key
|
|
232
400
|
* the same term identically) that `rows` holds zero fact rows for — the
|
|
233
401
|
* fact-degree rule an ungrounded-term ledger admits by, independent of
|
|
234
402
|
* whether the lexicon happens to know the word. Occurrence-counted, so a
|
|
235
|
-
* term named three times outranks one named once.
|
|
236
|
-
|
|
237
|
-
|
|
403
|
+
* term named three times outranks one named once.
|
|
404
|
+
*
|
|
405
|
+
* `nlp` follows this module's own convention: absent means the shared wink
|
|
406
|
+
* instance, and an explicit null forces the lexical fallback. */
|
|
407
|
+
export function ungroundedTermOccurrences(sentences, rows, { lexicon = loadLexicon(), nlp } = {}) {
|
|
408
|
+
const engine = nlp === undefined ? winkInstance() : nlp;
|
|
409
|
+
const raw = engine
|
|
410
|
+
? candidateTermOccurrencesPos(sentences, engine, lexicon)
|
|
411
|
+
: candidateTermOccurrencesLexical(sentences, lexicon);
|
|
238
412
|
const grounded = new Set();
|
|
239
413
|
for (const row of rows) {
|
|
240
414
|
grounded.add(normFactTerm(row.subject));
|
|
@@ -243,9 +417,13 @@ function ungroundedTermOccurrences(sentences, rows, { lexicon, nlp }) {
|
|
|
243
417
|
const counts = new Map();
|
|
244
418
|
for (const [word, n] of raw) {
|
|
245
419
|
const term = foldEntity(word, lexicon);
|
|
246
|
-
if (!term
|
|
420
|
+
if (!term) continue;
|
|
247
421
|
counts.set(term, (counts.get(term) || 0) + n);
|
|
248
422
|
}
|
|
423
|
+
// Fragments fold onto their whole name BEFORE the fact-degree filter, so a
|
|
424
|
+
// name a fact already grounds takes its own fragments out with it.
|
|
425
|
+
foldNameFragments(counts);
|
|
426
|
+
for (const term of [...counts.keys()]) if (grounded.has(term)) counts.delete(term);
|
|
249
427
|
return counts;
|
|
250
428
|
}
|
|
251
429
|
|
|
@@ -257,10 +435,12 @@ function ungroundedTermOccurrences(sentences, rows, { lexicon, nlp }) {
|
|
|
257
435
|
function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false } = {}) {
|
|
258
436
|
let values;
|
|
259
437
|
let pos;
|
|
438
|
+
let lemmas;
|
|
260
439
|
try {
|
|
261
440
|
const doc = nlp.readDoc(String(sentence || ""));
|
|
262
441
|
values = doc.tokens().out(nlp.its.value);
|
|
263
442
|
pos = doc.tokens().out(nlp.its.pos);
|
|
443
|
+
lemmas = doc.tokens().out(nlp.its.lemma);
|
|
264
444
|
} catch { return { triples: [], declined: [], minted: [] }; }
|
|
265
445
|
// A found noun is read as its whole contiguous NOUN/PROPN run, head-lemma
|
|
266
446
|
// folded — "a string instrument" is the class "string instrument", never
|
|
@@ -273,8 +453,9 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
|
|
|
273
453
|
while (lo - 1 >= 0 && isNounish(lo - 1)) lo -= 1;
|
|
274
454
|
while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
|
|
275
455
|
if (lo === hi) return foldEntity(values[i], lexicon);
|
|
276
|
-
const
|
|
277
|
-
|
|
456
|
+
const last = stripSentenceFinalStop(values[hi]);
|
|
457
|
+
const head = lookupNoun(lexicon, last.toLowerCase());
|
|
458
|
+
return normFactTerm([...values.slice(lo, hi), head ? head.lemma : last].join(" "));
|
|
278
459
|
};
|
|
279
460
|
const nearestEntityIndex = (idx, step, blocked = null) => {
|
|
280
461
|
for (let i = idx + step; i >= 0 && i < values.length; i += step) {
|
|
@@ -346,7 +527,9 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
|
|
|
346
527
|
// class ("a type of mammal" → mammal); a partitive container head states
|
|
347
528
|
// composition, never a class ("a large body of ice" — no isa at all).
|
|
348
529
|
const copulaObjectAt = (i) => {
|
|
530
|
+
let sawDeterminer = false;
|
|
349
531
|
for (let j = i + 1; j < values.length; j += 1) {
|
|
532
|
+
if (pos[j] === "DET" || pos[j] === "NUM") sawDeterminer = true;
|
|
350
533
|
// A naming periphrasis ("… termed as …", "… known as …") keeps the
|
|
351
534
|
// frame copular: skip the participle and its "as" and read on.
|
|
352
535
|
if ((pos[j] === "VERB" || pos[j] === "AUX") && COPULA_NAMING_PARTICIPLES.has(values[j]?.toLowerCase())
|
|
@@ -378,6 +561,12 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
|
|
|
378
561
|
while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
|
|
379
562
|
}
|
|
380
563
|
const headWord = String(values[hi]).toLowerCase();
|
|
564
|
+
// A bare "-ed" complement straight after the copula is a predicative
|
|
565
|
+
// participle the tagger mis-read as a noun ("dozens have been rescued"),
|
|
566
|
+
// never a class: a real class complement carries a determiner or number
|
|
567
|
+
// ("has been a doctor"). Short true nouns in -ed (bed, seed, need) stay
|
|
568
|
+
// under the length bound.
|
|
569
|
+
if (!sawDeterminer && headWord.length >= 5 && headWord.endsWith("ed")) return null;
|
|
381
570
|
const nextWord = values[hi + 1]?.toLowerCase();
|
|
382
571
|
// "latency is the name for the time period …" defines latency; it does not
|
|
383
572
|
// put latency under the class "name". The isa is declined and the object
|
|
@@ -418,6 +607,17 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
|
|
|
418
607
|
return { label: entityRunAt(climbed), hi };
|
|
419
608
|
};
|
|
420
609
|
|
|
610
|
+
// Does the verb complex headed at `i` open with a be-form auxiliary? Adverbs
|
|
611
|
+
// in between are crossed; anything else ends the complex.
|
|
612
|
+
const beAuxiliaryBefore = (i) => {
|
|
613
|
+
for (let k = i - 1; k >= 0; k -= 1) {
|
|
614
|
+
if (pos[k] === "ADV" || pos[k] === "PART") continue;
|
|
615
|
+
if (pos[k] !== "AUX") return false;
|
|
616
|
+
if (BE_AUXILIARIES.has(String(values[k]).toLowerCase())) return true;
|
|
617
|
+
}
|
|
618
|
+
return false;
|
|
619
|
+
};
|
|
620
|
+
|
|
421
621
|
const triples = [];
|
|
422
622
|
const declined = [];
|
|
423
623
|
const minted = [];
|
|
@@ -433,6 +633,65 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
|
|
|
433
633
|
};
|
|
434
634
|
const decline = (finding, candidate) => { declined.push({ finding, candidate }); };
|
|
435
635
|
|
|
636
|
+
// The newswire event frame — a closed band of transitive event verbs
|
|
637
|
+
// (NEWSWIRE_RELATION_VERBS) read in a much tighter frame than the lexicon
|
|
638
|
+
// arm's. The lexicon's own verbs keep their loose scans; a verb only this
|
|
639
|
+
// band knows has to earn its triple:
|
|
640
|
+
//
|
|
641
|
+
// - wink must have tagged the token VERB, and its LEMMA must be in the
|
|
642
|
+
// band, so a past tense ("released", "adopted") reads where the
|
|
643
|
+
// lexicon's -s-only fold cannot;
|
|
644
|
+
// - the verb complex must not open with a be-form, so a passive or a
|
|
645
|
+
// progressive never mints its own reverse;
|
|
646
|
+
// - it must not sit in a relative clause, which has no subject of its own
|
|
647
|
+
// here;
|
|
648
|
+
// - subject and object are each the NEAREST noun run on their side with
|
|
649
|
+
// the copula frame's blockers applied, so neither scan crosses a verb, a
|
|
650
|
+
// preposition or a conjunction into another clause. "resigned from
|
|
651
|
+
// Cambridge" yields no object at all rather than "resign Cambridge".
|
|
652
|
+
// The subject scan starts left of the verb's OWN modal chain ("will
|
|
653
|
+
// completely block"), which is one verb complex rather than a crossing.
|
|
654
|
+
// A counting of-chain on either side reads through to what the event
|
|
655
|
+
// really touched ("hundreds of ancient amphorae" → amphorae).
|
|
656
|
+
//
|
|
657
|
+
// A lemma the lexicon itself declares keeps the lexicon's predicate, so
|
|
658
|
+
// "releases" (the lexicon arm) and "released" (this one) land on one edge.
|
|
659
|
+
const verbComplexStart = (i) => {
|
|
660
|
+
let k = i - 1;
|
|
661
|
+
while (k >= 0 && (pos[k] === "ADV" || pos[k] === "AUX" || pos[k] === "PART")) k -= 1;
|
|
662
|
+
return k + 1;
|
|
663
|
+
};
|
|
664
|
+
const readsThroughOf = (word) => {
|
|
665
|
+
const w = String(word ?? "").toLowerCase();
|
|
666
|
+
return OF_COUNT_HEADS.has(w) || OF_PARTITIVE_HEADS.has(w) || OF_CLASSIFIER_HEADS.has(w);
|
|
667
|
+
};
|
|
668
|
+
const countChainEntity = (idx, step) => {
|
|
669
|
+
let at = nearestEntityIndex(idx, step, COPULA_FRAME_BLOCKERS);
|
|
670
|
+
for (let hop = 0; at !== null && hop < 2; hop += 1) {
|
|
671
|
+
let hi = at;
|
|
672
|
+
while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
|
|
673
|
+
if (values[hi + 1]?.toLowerCase() !== "of" || !readsThroughOf(values[hi])) break;
|
|
674
|
+
const inner = nearestEntityIndex(hi + 1, +1);
|
|
675
|
+
if (inner === null) break;
|
|
676
|
+
at = inner;
|
|
677
|
+
}
|
|
678
|
+
return at === null ? null : entityRunAt(at);
|
|
679
|
+
};
|
|
680
|
+
const readNewswireFrame = () => {
|
|
681
|
+
for (let i = 1; i < values.length - 1; i += 1) {
|
|
682
|
+
if (pos[i] !== "VERB") continue;
|
|
683
|
+
if (lookupVerb(lexicon, String(values[i]).toLowerCase())) continue;
|
|
684
|
+
const lemma = String(lemmas?.[i] ?? values[i]).toLowerCase();
|
|
685
|
+
if (!NEWSWIRE_RELATION_VERBS.has(lemma)) continue;
|
|
686
|
+
if (beAuxiliaryBefore(i) || relativePronounBefore(i) >= 0) continue;
|
|
687
|
+
const subject = countChainEntity(verbComplexStart(i), -1);
|
|
688
|
+
const object = countChainEntity(i, +1);
|
|
689
|
+
if (!subject || !object) continue;
|
|
690
|
+
const declared = lookupVerb(lexicon, lemma);
|
|
691
|
+
push(subject, declared ? predicateOf(declared) : `mgx:${lemma}`, object);
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
436
695
|
// Pass 1 — the first clean copula frame yields the isa (all guards unchanged);
|
|
437
696
|
// its subject and object-run end anchor the relative-clause continuation.
|
|
438
697
|
let copulaSubject = null;
|
|
@@ -494,6 +753,7 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
|
|
|
494
753
|
if (subject === null) continue;
|
|
495
754
|
push(subject, predicateOf(verb), nearestEntity(i, +1));
|
|
496
755
|
}
|
|
756
|
+
readNewswireFrame();
|
|
497
757
|
return { triples, declined, minted };
|
|
498
758
|
}
|
|
499
759
|
|
|
@@ -516,6 +776,7 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
|
|
|
516
776
|
if (subject === null) continue;
|
|
517
777
|
push(subject, predicateOf(verb), nearestEntity(i, +1));
|
|
518
778
|
}
|
|
779
|
+
readNewswireFrame();
|
|
519
780
|
return { triples, declined, minted };
|
|
520
781
|
}
|
|
521
782
|
|
|
@@ -559,8 +820,9 @@ function optimisticTriplesLexical(sentence, lexicon) {
|
|
|
559
820
|
|
|
560
821
|
/**
|
|
561
822
|
* The bounded triple candidates from a sentence the strict recognizer skipped:
|
|
562
|
-
* a copula (→ rdfs:subClassOf)
|
|
563
|
-
*
|
|
823
|
+
* a copula (→ rdfs:subClassOf), past its object the relation verbs it grounds
|
|
824
|
+
* (→ their predicates), and the closed newswire event band in its own tighter
|
|
825
|
+
* frame, so one sentence contributes every fact it holds
|
|
564
826
|
* ("a volcano is a mountain that has lava" → volcano ⊑ mountain AND volcano has
|
|
565
827
|
* lava). Every triple passes the same entity/guard checks on its own, deduped,
|
|
566
828
|
* capped at MAX_TRIPLES_PER_SENTENCE so a run-on never shatters into noise; []
|
|
@@ -1093,6 +1355,9 @@ export async function ingestText(text, {
|
|
|
1093
1355
|
const key = normFactTerm(term);
|
|
1094
1356
|
if (key && !ungroundedCounts.has(key)) ungroundedCounts.set(key, 1);
|
|
1095
1357
|
}
|
|
1358
|
+
// A decline names the word it tripped over, so the legacy set carries name
|
|
1359
|
+
// fragments too ("Gilman" out of "Robert Gilman"). Same fold, same reason.
|
|
1360
|
+
foldNameFragments(ungroundedCounts);
|
|
1096
1361
|
|
|
1097
1362
|
const result = {
|
|
1098
1363
|
sentences: sentenceCount,
|
package/src/services/news.mjs
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
import { DEFAULT_MIN_INTERVAL_MS } from "../adapters/corpus/courtesy.mjs";
|
|
61
61
|
import { researchFacts } from "../adapters/corpus/research-source.mjs";
|
|
62
62
|
import { throughSourceBreaker, sourceSkipStatusLine } from "../domain/source-breaker.mjs";
|
|
63
|
-
import { ingestText, readsAsEntityTerm } from "./extract-facts.mjs";
|
|
63
|
+
import { ingestText, readsAsEntityTerm, ungroundedTermOccurrences } from "./extract-facts.mjs";
|
|
64
64
|
|
|
65
65
|
export { NEWS_SOURCE_RECORDS, DEFAULT_NEWS_SOURCE_IDS, DEFAULT_NEWS_KB_IDS };
|
|
66
66
|
|
|
@@ -77,6 +77,9 @@ export const NEWS_DEFAULTS = Object.freeze({
|
|
|
77
77
|
enrichTermsPerCycle: 3,
|
|
78
78
|
negativeCacheTtlHours: 24,
|
|
79
79
|
syllogismsPerIngest: 12,
|
|
80
|
+
// Per source, not across the whole poll: each enabled source keeps its own
|
|
81
|
+
// window of up to this many snapshots, so one prolific source can never
|
|
82
|
+
// crowd another out of the window before either has been read.
|
|
80
83
|
itemCap: 30,
|
|
81
84
|
newsFactCap: 4000,
|
|
82
85
|
feedTop: 3,
|
|
@@ -246,18 +249,19 @@ function abortSignalOf(ctx) {
|
|
|
246
249
|
return typeof ctx?.shouldAbort === "function" ? ctx.shouldAbort : () => false;
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
252
|
+
// The headline and the item's own description are two separate texts, so they
|
|
253
|
+
// reach the ingest as two PARAGRAPHS. Run together on one line they merge into
|
|
254
|
+
// one sentence whenever the headline ends on something the sentence splitter
|
|
255
|
+
// will not break after — an abbreviation ("… Arrives in the U.S." + "Russia
|
|
256
|
+
// released …" reads as one 26-word sentence, and the fact that falls out of it
|
|
257
|
+
// has "u.s. russia" for a subject) or a description that opens on a quotation.
|
|
258
|
+
// A blank line between them is the boundary ingestText already splits on.
|
|
255
259
|
function joinTitleAndSummary(title, summary) {
|
|
256
260
|
const t = String(title || "").trim();
|
|
257
261
|
const s = String(summary || "").trim();
|
|
258
262
|
if (!t) return s;
|
|
259
263
|
if (!s) return t;
|
|
260
|
-
return `${t}${
|
|
264
|
+
return `${t}\n\n${s}`;
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
function toMs(value) {
|
|
@@ -305,6 +309,23 @@ function buildSourcesByFactId(items) {
|
|
|
305
309
|
return map;
|
|
306
310
|
}
|
|
307
311
|
|
|
312
|
+
/** The entity names a card's own article text carries, read through the same
|
|
313
|
+
* capture the enrichment queue is fed from (`ungroundedTermOccurrences`), so a
|
|
314
|
+
* name reaches a card's background under exactly the key enrichment stored it
|
|
315
|
+
* under. The fact set handed in is empty on purpose: that call's own filter
|
|
316
|
+
* drops a term the graph already holds facts about, and a card wants precisely
|
|
317
|
+
* those. A single word the lexicon reads as an everyday noun drops out here —
|
|
318
|
+
* "developer" names nothing a lookup could define — while a name run keeps its
|
|
319
|
+
* whole spelling, "tim king" and "amigados" alike. */
|
|
320
|
+
export function articleEntityNames(texts, { lexicon } = {}) {
|
|
321
|
+
const lex = lexicon || loadLexicon();
|
|
322
|
+
const names = [];
|
|
323
|
+
for (const term of ungroundedTermOccurrences(texts, [], { lexicon: lex }).keys()) {
|
|
324
|
+
if (term.includes(" ") || !isVocabGroundedTerm(lex, term)) names.push(term);
|
|
325
|
+
}
|
|
326
|
+
return names.sort();
|
|
327
|
+
}
|
|
328
|
+
|
|
308
329
|
// ---------------------------------------------------------------------------
|
|
309
330
|
// grounding definitions (10.2)
|
|
310
331
|
// ---------------------------------------------------------------------------
|
|
@@ -639,10 +660,15 @@ export async function pollNewsSources(ctx) {
|
|
|
639
660
|
recordSuccess(health, nowVal, "not-modified");
|
|
640
661
|
} else {
|
|
641
662
|
recordSuccess(health, nowVal, "ok");
|
|
642
|
-
|
|
663
|
+
// itemCap bounds THIS source's own window: mergeSnapshots sees only
|
|
664
|
+
// sourceId's existing snapshots, so one prolific source's window
|
|
665
|
+
// never crowds out another's before either has been read.
|
|
666
|
+
const ownItems = (state.items || []).filter((snap) => snap?.sourceId === sourceId);
|
|
667
|
+
const otherItems = (state.items || []).filter((snap) => snap?.sourceId !== sourceId);
|
|
668
|
+
const merged = mergeSnapshots(ownItems, result.items, {
|
|
643
669
|
cap: config.itemCap, seen: state.seenItemKeys,
|
|
644
670
|
});
|
|
645
|
-
state.items = merged.items;
|
|
671
|
+
state.items = [...otherItems, ...merged.items].sort(byFetchedAtThenId);
|
|
646
672
|
added = merged.added;
|
|
647
673
|
newItemsTotal += added.length;
|
|
648
674
|
}
|
|
@@ -875,7 +901,15 @@ export async function enrichTopTerms(ctx, { limit } = {}) {
|
|
|
875
901
|
*
|
|
876
902
|
* `newName` is a display-only badge (never a gate): true when the lexicon
|
|
877
903
|
* has no everyday-noun reading for the hub, computed here rather than in
|
|
878
|
-
* buildNewsItems because the domain layer carries no lexicon.
|
|
904
|
+
* buildNewsItems because the domain layer carries no lexicon.
|
|
905
|
+
*
|
|
906
|
+
* `articleEntityNames` is wired in for the same reason: reading the entity
|
|
907
|
+
* names out of a card's own article text takes the lexicon and the wink
|
|
908
|
+
* tagger, so the domain asks for them through a seam and this layer answers
|
|
909
|
+
* with the capture the enrichment queue already uses. It is what puts a
|
|
910
|
+
* definition and the card that needed it on the same page — a lookup on
|
|
911
|
+
* "amigados" reaches a card whose only fact is that a site discussed the
|
|
912
|
+
* headline the name sits inside. */
|
|
879
913
|
export async function buildFeed(ctx) {
|
|
880
914
|
const { memoryDir, store, config, state, now, lexicon } = ctx;
|
|
881
915
|
const nowVal = resolveNow(now);
|
|
@@ -886,7 +920,12 @@ export async function buildFeed(ctx) {
|
|
|
886
920
|
|
|
887
921
|
const lex = lexicon || loadLexicon();
|
|
888
922
|
const items = buildNewsItems(rows, {
|
|
889
|
-
now: nowVal,
|
|
923
|
+
now: nowVal,
|
|
924
|
+
windowMs,
|
|
925
|
+
limit: config.itemCap,
|
|
926
|
+
sourcesByFactId,
|
|
927
|
+
readsAsEntityTerm,
|
|
928
|
+
articleEntityNames: (texts) => articleEntityNames(texts, { lexicon: lex }),
|
|
890
929
|
}).map((item) => ({ ...item, newName: !isVocabGroundedTerm(lex, item.hub) }));
|
|
891
930
|
return { items, seedFallback: false, builtAt: nowVal };
|
|
892
931
|
}
|