@polycode-projects/the-mechanical-code-talker 6.0.13 → 6.0.15
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 +1 -1
- package/src/adapters/corpus/news-sources.mjs +59 -4
- package/src/adapters/memory/core.mjs +192 -39
- package/src/adapters/memory/shacl.mjs +2 -2
- package/src/domain/memory/trust.mjs +78 -17
- package/src/domain/news-feed.mjs +74 -10
- package/src/domain/term-ledger.mjs +29 -1
- package/src/services/chat.mjs +18 -10
- package/src/services/extract-facts.mjs +53 -7
- package/src/services/news.mjs +2 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +122 -122
package/src/domain/news-feed.mjs
CHANGED
|
@@ -240,6 +240,19 @@ const ENTITY_FRAGMENT_LEAD_WORDS = new Set([
|
|
|
240
240
|
// term; "back" alone is a fine noun.
|
|
241
241
|
const ENTITY_PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
|
|
242
242
|
|
|
243
|
+
// A pronoun points back at whatever the last clause named, so a multi-word
|
|
244
|
+
// term opening with one is a clause the split lost the subject of. A term
|
|
245
|
+
// ending in a bare auxiliary is the front half of one. Both mirror
|
|
246
|
+
// extract-facts.mjs's own lexical rules, for the same reason the sets above do.
|
|
247
|
+
const ENTITY_PRONOUN_LEAD_WORDS = new Set([
|
|
248
|
+
"i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
|
|
249
|
+
"his", "her", "its", "their", "our", "your", "my",
|
|
250
|
+
]);
|
|
251
|
+
const ENTITY_CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
|
|
252
|
+
const ENTITY_TRAILING_AUXILIARY_WORDS = new Set([
|
|
253
|
+
"is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
|
|
254
|
+
]);
|
|
255
|
+
|
|
243
256
|
/** Does `term` read as a thing's name rather than a clause fragment? Bounds
|
|
244
257
|
* the word count and rejects a leading conjunction, auxiliary or
|
|
245
258
|
* preposition (test E's condition 3, PLAN_NEWSWORTHINESS.md section 2), plus
|
|
@@ -251,7 +264,10 @@ function looksLikeEntityTerm(term) {
|
|
|
251
264
|
if (words.length > ENTITY_TERM_MAX_WORDS) return false;
|
|
252
265
|
const first = words[0].toLowerCase().replace(/^[^a-z0-9]+/, "");
|
|
253
266
|
if (!first || ENTITY_FRAGMENT_LEAD_WORDS.has(first)) return false;
|
|
254
|
-
if (words.length
|
|
267
|
+
if (words.length === 1) return true;
|
|
268
|
+
if (ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
|
|
269
|
+
if (ENTITY_PRONOUN_LEAD_WORDS.has(first.replace(ENTITY_CLITIC_SUFFIX_RE, ""))) return false;
|
|
270
|
+
if (ENTITY_TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
|
|
255
271
|
return true;
|
|
256
272
|
}
|
|
257
273
|
|
|
@@ -425,21 +441,29 @@ export function buildTermAdjacency(rows) {
|
|
|
425
441
|
}
|
|
426
442
|
|
|
427
443
|
/** Breadth-first over subject/object adjacency from `hub`, exactly `hops`
|
|
428
|
-
* levels deep, then capped
|
|
429
|
-
* depends on `rows`' own order, only
|
|
430
|
-
* actually reaches
|
|
431
|
-
|
|
444
|
+
* levels deep, then capped: a `priorityIds` row first, then the nearer hop,
|
|
445
|
+
* then content-addressed id. The cap never depends on `rows`' own order, only
|
|
446
|
+
* on which rows the hop-bounded walk actually reaches and how far out each
|
|
447
|
+
* one sits.
|
|
448
|
+
*
|
|
449
|
+
* `priorityIds` is what keeps a card about a term the graph already knows
|
|
450
|
+
* thousands of things about from being built out of an arbitrary slice of
|
|
451
|
+
* them: a hub like "france" reaches far more rows than the cap, and the one
|
|
452
|
+
* report that made it news would otherwise be the row that fell out. */
|
|
453
|
+
export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null } = {}) {
|
|
432
454
|
const adj = adjacency ?? buildTermAdjacency(rows);
|
|
433
455
|
const hubTerm = normFactTerm(hub);
|
|
434
456
|
const visited = new Set([hubTerm]);
|
|
435
457
|
let frontier = [hubTerm];
|
|
436
458
|
const collected = new Map();
|
|
459
|
+
const hopOf = new Map();
|
|
437
460
|
for (let hop = 0; hop < hops; hop += 1) {
|
|
438
461
|
const nextFrontier = new Set();
|
|
439
462
|
for (const term of [...frontier].sort()) {
|
|
440
463
|
for (const idx of adj.byTerm.get(term) ?? []) {
|
|
441
464
|
const row = rows[idx];
|
|
442
465
|
collected.set(row.id, row);
|
|
466
|
+
if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
|
|
443
467
|
const [s, o] = adj.terms[idx];
|
|
444
468
|
if (!visited.has(s)) nextFrontier.add(s);
|
|
445
469
|
if (!visited.has(o)) nextFrontier.add(o);
|
|
@@ -448,7 +472,12 @@ export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adja
|
|
|
448
472
|
for (const term of nextFrontier) visited.add(term);
|
|
449
473
|
frontier = [...nextFrontier].sort();
|
|
450
474
|
}
|
|
451
|
-
|
|
475
|
+
const isPriority = (id) => (priorityIds instanceof Set ? priorityIds.has(id) : Boolean(priorityIds?.includes?.(id)));
|
|
476
|
+
return [...collected.values()]
|
|
477
|
+
.sort((a, b) => (isPriority(b.id) - isPriority(a.id))
|
|
478
|
+
|| (hopOf.get(a.id) - hopOf.get(b.id))
|
|
479
|
+
|| byId(a, b))
|
|
480
|
+
.slice(0, cap);
|
|
452
481
|
}
|
|
453
482
|
|
|
454
483
|
/** The strongest prior kind among `rows`, for the item's trust chip — read
|
|
@@ -487,6 +516,28 @@ function joinWithAnd(items) {
|
|
|
487
516
|
|
|
488
517
|
const IDENTITY_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
|
|
489
518
|
const SENTENCE_CAP = 5;
|
|
519
|
+
// How many objects one sentence names before it counts the rest. A live source
|
|
520
|
+
// reports the same relation over and over inside one window — every quake of
|
|
521
|
+
// the day strikes near somewhere — and an unbounded list turns a card into a
|
|
522
|
+
// wall of text.
|
|
523
|
+
const OBJECTS_PER_SENTENCE = 6;
|
|
524
|
+
|
|
525
|
+
function joinObjects(objects) {
|
|
526
|
+
if (objects.length <= OBJECTS_PER_SENTENCE) return joinWithAnd(objects);
|
|
527
|
+
const shown = objects.slice(0, OBJECTS_PER_SENTENCE);
|
|
528
|
+
return `${shown.join(", ")} and ${objects.length - OBJECTS_PER_SENTENCE} more`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The predicates `rows` carry, curated-table order first and then whatever
|
|
532
|
+
* is left, sorted. A relation minted from a source's own verb ("mgx:hit",
|
|
533
|
+
* "mgx:strike-near") has no curated entry, and reading the table alone left
|
|
534
|
+
* every card built from live headlines with an empty paragraph. */
|
|
535
|
+
function predicatesInRenderOrder(rows) {
|
|
536
|
+
const present = new Set(rows.map((r) => r.predicate));
|
|
537
|
+
const curated = Object.keys(FACT_PREDICATE_PHRASES).filter((predicate) => present.has(predicate));
|
|
538
|
+
const rest = [...present].filter((predicate) => !Object.hasOwn(FACT_PREDICATE_PHRASES, predicate)).sort();
|
|
539
|
+
return [...curated, ...rest];
|
|
540
|
+
}
|
|
490
541
|
|
|
491
542
|
/** The fixed five-sentence paraphrase template (PLAN_NEWS_FEED.md section
|
|
492
543
|
* 8.3): identity first, then the hub's own relations grouped by predicate in
|
|
@@ -519,17 +570,30 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
|
|
|
519
570
|
.sort();
|
|
520
571
|
if (identityObjects.length) {
|
|
521
572
|
const withArticles = identityObjects.map((object) => `${articleFor(object)} ${object}`);
|
|
522
|
-
sentences.push(`${hub} is ${
|
|
573
|
+
sentences.push(`${hub} is ${joinObjects(withArticles)}`);
|
|
523
574
|
}
|
|
524
575
|
|
|
525
|
-
for (const predicate of
|
|
576
|
+
for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
|
|
526
577
|
if (IDENTITY_PREDICATES.has(predicate) || sentences.length >= SENTENCE_CAP) continue;
|
|
527
578
|
const objects = reportedHubRows
|
|
528
579
|
.filter((r) => r.predicate === predicate)
|
|
529
580
|
.map((r) => r.object)
|
|
530
581
|
.sort();
|
|
531
582
|
if (!objects.length) continue;
|
|
532
|
-
sentences.push(`${hub} ${predicatePhrase(predicate)} ${
|
|
583
|
+
sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinObjects(objects)}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// A hub that only ever appears as an OBJECT — the place a quake struck, the
|
|
587
|
+
// story a site discussed — has no subject-side row to build a sentence from,
|
|
588
|
+
// and its card came out blank. What was reported about it still says
|
|
589
|
+
// something, so those rows render whole, subject and all.
|
|
590
|
+
if (!sentences.length) {
|
|
591
|
+
const aboutHub = subgraphRows
|
|
592
|
+
.filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
|
|
593
|
+
.sort(byId)
|
|
594
|
+
.slice(0, OBJECTS_PER_SENTENCE)
|
|
595
|
+
.map((r) => factSentence(r));
|
|
596
|
+
if (aboutHub.length) sentences.push(aboutHub.join("; "));
|
|
533
597
|
}
|
|
534
598
|
|
|
535
599
|
if (sentences.length < SENTENCE_CAP && secondHopRows.length) {
|
|
@@ -564,7 +628,7 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
|
|
|
564
628
|
if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
|
|
565
629
|
const hubs = newsworthyHubs(rows, reported, hubOptions);
|
|
566
630
|
const items = hubs.map(({ term, changed }) => {
|
|
567
|
-
const subgraphRows = subgraphAround(rows, term, { adjacency });
|
|
631
|
+
const subgraphRows = subgraphAround(rows, term, { adjacency, priorityIds: reportedIds });
|
|
568
632
|
const factIds = subgraphRows.map((r) => r.id).sort();
|
|
569
633
|
const { background } = splitCardRows(subgraphRows, reportedIds);
|
|
570
634
|
return {
|
|
@@ -8,6 +8,33 @@ import { normFactTerm } from "./hash.mjs";
|
|
|
8
8
|
|
|
9
9
|
const ITEM_IDS_CAP = 12;
|
|
10
10
|
|
|
11
|
+
// A preposition, conjunction or degree/frequency adverb scaffolds a
|
|
12
|
+
// sentence; it never names the thing the sentence is about, so no
|
|
13
|
+
// occurrence count makes one a useful ledger term. Closed set, checked
|
|
14
|
+
// against the already-normalized (lowercased) term. bumpTerms uses this to
|
|
15
|
+
// keep a function word from ever being admitted; ledgerFromPayload uses it
|
|
16
|
+
// to drop one that reached a persisted payload before this filter existed.
|
|
17
|
+
const FUNCTION_WORD_TERMS = new Set([
|
|
18
|
+
// prepositions
|
|
19
|
+
"about", "above", "across", "after", "against", "along", "among", "around",
|
|
20
|
+
"at", "before", "behind", "below", "beneath", "beside", "between", "beyond",
|
|
21
|
+
"by", "despite", "down", "during", "except", "for", "from", "in", "into",
|
|
22
|
+
"near", "of", "off", "on", "onto", "out", "over", "since", "through",
|
|
23
|
+
"throughout", "to", "toward", "towards", "under", "underneath", "until",
|
|
24
|
+
"up", "upon", "with", "within", "without",
|
|
25
|
+
// conjunctions
|
|
26
|
+
"and", "or", "nor", "but", "so", "yet", "although", "because", "if",
|
|
27
|
+
"though", "unless", "when", "whenever", "whereas", "while", "than",
|
|
28
|
+
// degree and frequency adverbs
|
|
29
|
+
"very", "quite", "rather", "too", "just", "only", "even", "also", "still",
|
|
30
|
+
"already", "almost", "always", "never", "ever", "often", "sometimes",
|
|
31
|
+
"usually", "indeed", "however", "therefore", "thus", "hence", "meanwhile",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function isFunctionWordTerm(term) {
|
|
35
|
+
return FUNCTION_WORD_TERMS.has(term);
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
/** Ledger entry field order fixed once here so `ledgerPayload` serializes
|
|
12
39
|
* byte-identically across peers regardless of insertion order elsewhere. */
|
|
13
40
|
function newEntry(term, vocabGrounded, now) {
|
|
@@ -35,7 +62,7 @@ export function createTermLedger() {
|
|
|
35
62
|
export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
|
|
36
63
|
for (const [rawTerm, occurrences] of termCounts) {
|
|
37
64
|
const term = normFactTerm(rawTerm);
|
|
38
|
-
if (!term) continue;
|
|
65
|
+
if (!term || isFunctionWordTerm(term)) continue;
|
|
39
66
|
let entry = ledger.terms.get(term);
|
|
40
67
|
if (!entry) {
|
|
41
68
|
const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
|
|
@@ -112,6 +139,7 @@ export function ledgerPayload(ledger) {
|
|
|
112
139
|
export function ledgerFromPayload(payload) {
|
|
113
140
|
const ledger = createTermLedger();
|
|
114
141
|
for (const entry of payload?.terms ?? []) {
|
|
142
|
+
if (isFunctionWordTerm(entry.term)) continue;
|
|
115
143
|
ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
|
|
116
144
|
}
|
|
117
145
|
return ledger;
|
package/src/services/chat.mjs
CHANGED
|
@@ -18487,14 +18487,15 @@ async function factRowSnapshot(memoryDir) {
|
|
|
18487
18487
|
try { return readStoredFactRows(await loadMemoryStore(memoryDir)); } catch { return null; }
|
|
18488
18488
|
}
|
|
18489
18489
|
|
|
18490
|
-
/** The Fact rows this turn wrote, diffed against the snapshot taken before it
|
|
18491
|
-
*
|
|
18490
|
+
/** The Fact rows this turn wrote, diffed against the snapshot taken before it,
|
|
18491
|
+
* with the after-snapshot handed back beside them. Empty when the turn had no
|
|
18492
|
+
* store to write to, or wrote nothing. */
|
|
18492
18493
|
async function factsTouchedSince(memoryDir, before) {
|
|
18493
|
-
if (!before) return [];
|
|
18494
|
+
if (!before) return { factsTouched: [], factRowsAfter: null };
|
|
18494
18495
|
const after = await factRowSnapshot(memoryDir);
|
|
18495
|
-
if (!after) return [];
|
|
18496
|
+
if (!after) return { factsTouched: [], factRowsAfter: null };
|
|
18496
18497
|
const { touchedFactRows } = await import("../domain/memory/touched-facts.mjs");
|
|
18497
|
-
return touchedFactRows(before, after);
|
|
18498
|
+
return { factsTouched: touchedFactRows(before, after), factRowsAfter: after };
|
|
18498
18499
|
}
|
|
18499
18500
|
|
|
18500
18501
|
/** A whole-line miss whose only problem is a closed filler clause in front of a
|
|
@@ -18519,12 +18520,19 @@ async function answerWithoutFillerPrefix(input, options, missed) {
|
|
|
18519
18520
|
}
|
|
18520
18521
|
|
|
18521
18522
|
/** Run one turn and report which Fact rows it wrote, as `factsTouched` beside
|
|
18522
|
-
* the answer/record/logLines every caller already reads
|
|
18523
|
-
*
|
|
18524
|
-
*
|
|
18523
|
+
* the answer/record/logLines every caller already reads, with the after-fold
|
|
18524
|
+
* the diff was taken against as `factRowsAfter`. The dispatch itself is
|
|
18525
|
+
* dispatchTurn, below; this wrapper exists so the field lands on EVERY return
|
|
18526
|
+
* path (dispatched, conversational, multi-sentence) from one place.
|
|
18527
|
+
*
|
|
18528
|
+
* `options.factRowsBefore` is the caller's own already-folded view of the
|
|
18529
|
+
* store, standing in for the before-snapshot. A caller running many turns over
|
|
18530
|
+
* one document folds once and threads it; folding a seed-sized graph again per
|
|
18531
|
+
* turn, to read back the rows the caller just handed over, is the most
|
|
18532
|
+
* expensive thing an ingest does. */
|
|
18525
18533
|
export async function runTurn(input, options = {}) {
|
|
18526
18534
|
const memoryDir = options?.memoryDir ?? null;
|
|
18527
|
-
const before = await factRowSnapshot(memoryDir);
|
|
18535
|
+
const before = options?.factRowsBefore || await factRowSnapshot(memoryDir);
|
|
18528
18536
|
let result;
|
|
18529
18537
|
try {
|
|
18530
18538
|
result = await dispatchTurn(input, options);
|
|
@@ -18541,7 +18549,7 @@ export async function runTurn(input, options = {}) {
|
|
|
18541
18549
|
});
|
|
18542
18550
|
}
|
|
18543
18551
|
if (!result || typeof result !== "object") return result;
|
|
18544
|
-
return { ...result,
|
|
18552
|
+
return { ...result, ...(await factsTouchedSince(memoryDir, before)) };
|
|
18545
18553
|
}
|
|
18546
18554
|
|
|
18547
18555
|
async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, researchSource = null, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, newsState = null, newsConfig = null, newsProviders = null, discourse = null, _noSplit = false, actingSubject = "player", codeDomainActive = null, laneVocab = null, domainPacks = null, retrieval = null } = {}) {
|
|
@@ -66,6 +66,7 @@ import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences
|
|
|
66
66
|
import { loadMemory, readFactRows, appendFacts, removeFacts } from "../adapters/memory/core.mjs";
|
|
67
67
|
import { loadConfig } from "../adapters/config.mjs";
|
|
68
68
|
import { touchedFactRows } from "../domain/memory/touched-facts.mjs";
|
|
69
|
+
import { INGEST_SESSION_MARKER } from "../domain/memory/trust.mjs";
|
|
69
70
|
import { normFactTerm } from "../domain/hash.mjs";
|
|
70
71
|
import { splitIdentifierWords } from "../domain/prose.mjs";
|
|
71
72
|
import { winkInstance } from "../adapters/wink-model.mjs";
|
|
@@ -104,18 +105,22 @@ export function parseArgs(argv) {
|
|
|
104
105
|
* on a browser-sized graph — so the caller threads one fold from sentence to
|
|
105
106
|
* sentence instead of paying a fresh one per candidate.
|
|
106
107
|
*/
|
|
107
|
-
async function runSentence(sentence, { config, memoryDir, env, beforeRows }) {
|
|
108
|
+
async function runSentence(sentence, { config, memoryDir, env, beforeRows, sessionId = "" }) {
|
|
108
109
|
const before = beforeRows || readFactRows(await loadMemory(memoryDir));
|
|
109
110
|
if (ingestYield) await ingestYield();
|
|
110
|
-
|
|
111
|
+
// The turn takes the caller's fold as its own before-view and hands back the
|
|
112
|
+
// after-fold it already had to take, so one sentence costs one fold rather
|
|
113
|
+
// than three of the same graph.
|
|
114
|
+
const { record, answer, factsTouched, factRowsAfter } = await runTurn(sentence, {
|
|
115
|
+
config, memoryDir, sessionId: sessionId || uuidv7(), env, factRowsBefore: before,
|
|
116
|
+
});
|
|
117
|
+
const after = factRowsAfter || before;
|
|
111
118
|
// Only an assert turn can have written a Fact, so only an assert turn earns
|
|
112
|
-
//
|
|
113
|
-
// back untouched.
|
|
119
|
+
// a fresh view; every other turn hands the caller's own straight back.
|
|
114
120
|
if (record?.via !== "assert") return { recognized: false, rows: [], afterRows: before, decline: String(answer || "") };
|
|
115
121
|
if (ingestYield) await ingestYield();
|
|
116
|
-
const after = readFactRows(await loadMemory(memoryDir));
|
|
117
122
|
if (record?.miss) return { recognized: false, rows: [], afterRows: after, decline: String(answer || "") };
|
|
118
|
-
return { recognized: true, rows: touchedFactRows(before, after), afterRows: after };
|
|
123
|
+
return { recognized: true, rows: factsTouched || touchedFactRows(before, after), afterRows: after };
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
/** The recognizer's own words for why it turned a sentence down, when it named
|
|
@@ -668,6 +673,32 @@ const FRAGMENT_LEAD_TAGS = new Set(["VERB", "AUX", "ADP", "CCONJ", "SCONJ", "PAR
|
|
|
668
673
|
// which names nothing. Read lexically so a checkout with no wink model catches
|
|
669
674
|
// it too, and only for a multi-word term — "back" alone is a fine noun.
|
|
670
675
|
const PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
|
|
676
|
+
// A pronoun names nothing on its own — it points back at whatever the last
|
|
677
|
+
// clause named — so a multi-word term opening with one is a clause the split
|
|
678
|
+
// lost the subject of ("he's also destroyed the city's soul"), never a name. A
|
|
679
|
+
// one-word term is exempt for the same reason the particle rule exempts one:
|
|
680
|
+
// "us" is also how a headline writes the United States.
|
|
681
|
+
const PRONOUN_LEAD_WORDS = new Set([
|
|
682
|
+
"i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
|
|
683
|
+
"his", "her", "its", "their", "our", "your", "my",
|
|
684
|
+
]);
|
|
685
|
+
const CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
|
|
686
|
+
// A term ending in a bare auxiliary is the front half of a clause the split cut
|
|
687
|
+
// ("rooms were"), never the whole of a name.
|
|
688
|
+
const TRAILING_AUXILIARY_WORDS = new Set([
|
|
689
|
+
"is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
|
|
690
|
+
]);
|
|
691
|
+
// A compass word opening a place name is a modifier, not a clause lead —
|
|
692
|
+
// "north korea", "south sandwich islands". A tagger reading the LOWERCASED
|
|
693
|
+
// term has no capital left to tell the place from the direction and tags
|
|
694
|
+
// "north"/"south" as an adverb, so the POS rule below would turn every one of
|
|
695
|
+
// them down. Followed by "of" the word really is heading a prepositional
|
|
696
|
+
// phrase ("north of the border"), and that stays declined.
|
|
697
|
+
const COMPASS_LEAD_WORDS = new Set([
|
|
698
|
+
"north", "south", "east", "west",
|
|
699
|
+
"northeast", "northwest", "southeast", "southwest",
|
|
700
|
+
"northern", "southern", "eastern", "western",
|
|
701
|
+
]);
|
|
671
702
|
|
|
672
703
|
/** Does `term` read as a thing's name rather than a clause fragment? Bounds
|
|
673
704
|
* the word count and rejects a leading conjunction, auxiliary, preposition,
|
|
@@ -684,6 +715,9 @@ export function readsAsEntityTerm(term, nlp) {
|
|
|
684
715
|
if (FRAGMENT_LEAD_WORDS.has(first)) return false;
|
|
685
716
|
if (words.length === 1) return true;
|
|
686
717
|
if (PARTICLE_LEAD_WORDS.has(first)) return false;
|
|
718
|
+
if (PRONOUN_LEAD_WORDS.has(first.replace(CLITIC_SUFFIX_RE, ""))) return false;
|
|
719
|
+
if (TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
|
|
720
|
+
if (COMPASS_LEAD_WORDS.has(first) && words[1].toLowerCase() !== "of") return true;
|
|
687
721
|
const engine = nlp === undefined ? winkInstance() : nlp;
|
|
688
722
|
if (!engine) return true;
|
|
689
723
|
try {
|
|
@@ -800,6 +834,13 @@ function canonicalLines(facts, storeRows) {
|
|
|
800
834
|
* are the only output.
|
|
801
835
|
* sourceTag the label the audit provenance carries (extracted:<tag> /
|
|
802
836
|
* optimistic-extract:<tag>). Default "text".
|
|
837
|
+
* attributeToSource
|
|
838
|
+
* file the recognizer's own assertion under `sourceTag`'s
|
|
839
|
+
* publication instead of a fresh chat session per sentence.
|
|
840
|
+
* Off by default: an operator running `tmct extract` over their
|
|
841
|
+
* own notes IS the asserting party, so that lane keeps minting
|
|
842
|
+
* a session. A feed or a reference work is not, and one
|
|
843
|
+
* publication's sentences must never corroborate each other.
|
|
803
844
|
* optimistic also run the fuzzy tier over strict-skipped sentences.
|
|
804
845
|
* canonical include a `canonical` array: one enriched triple line per
|
|
805
846
|
* ingested fact.
|
|
@@ -830,7 +871,12 @@ function canonicalLines(facts, storeRows) {
|
|
|
830
871
|
export async function ingestText(text, {
|
|
831
872
|
memoryDir = null, sourceTag = "text", optimistic = false,
|
|
832
873
|
canonical = false, config = null, lexicon = null, observedAt = "", findings = false,
|
|
874
|
+
attributeToSource = false,
|
|
833
875
|
} = {}) {
|
|
876
|
+
// The session id every sentence's recognizer turn runs under. Stable and
|
|
877
|
+
// derived from the publication when the caller attributes to it, so the whole
|
|
878
|
+
// run lands on one Source; a fresh uuid per sentence otherwise (runSentence).
|
|
879
|
+
const recognizerSessionId = attributeToSource ? `${INGEST_SESSION_MARKER}${sourceTag.split("@")[0]}` : "";
|
|
834
880
|
// Paragraphs first (blank-line separated), so the pronoun carry never bridges
|
|
835
881
|
// a topic break: a fresh paragraph clears the last-subject it would resolve
|
|
836
882
|
// "they"/"it" against. Each paragraph then splits into sentences the shared
|
|
@@ -900,7 +946,7 @@ export async function ingestText(text, {
|
|
|
900
946
|
if (ingestYield) await ingestYield();
|
|
901
947
|
const knownIds = new Set(currentRows.map((r) => r.id));
|
|
902
948
|
const { recognized, rows, afterRows, decline } = await runSentence(form, {
|
|
903
|
-
config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows,
|
|
949
|
+
config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows, sessionId: recognizerSessionId,
|
|
904
950
|
});
|
|
905
951
|
currentRows = afterRows;
|
|
906
952
|
if (!recognized) { lastDecline = decline || lastDecline; return null; }
|
package/src/services/news.mjs
CHANGED
|
@@ -338,6 +338,7 @@ async function ingestSnapshotFacts(ctx, snapshot) {
|
|
|
338
338
|
const sourceTag = `news:${snapshot.sourceId}@${snapshot.id}`;
|
|
339
339
|
const result = await ingestText(text, {
|
|
340
340
|
memoryDir, sourceTag, optimistic: true, lexicon: lex, observedAt: nowVal, findings: true,
|
|
341
|
+
attributeToSource: true,
|
|
341
342
|
});
|
|
342
343
|
invalidateCache(cache);
|
|
343
344
|
|
|
@@ -662,6 +663,7 @@ async function ingestResearchArticle(ctx, term, provider, article) {
|
|
|
662
663
|
ingested = await ingestText(prose, {
|
|
663
664
|
memoryDir, sourceTag: provenance, optimistic: true,
|
|
664
665
|
lexicon: lexicon || loadLexicon(), observedAt: resolveNow(now), findings: true,
|
|
666
|
+
attributeToSource: true,
|
|
665
667
|
});
|
|
666
668
|
invalidateCache(ctx.cache);
|
|
667
669
|
}
|