@polycode-projects/the-mechanical-code-talker 6.0.14 → 6.0.16
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/news-sources.mjs +59 -4
- package/src/domain/fact-phrase.mjs +65 -8
- package/src/domain/news-feed.mjs +119 -10
- package/src/domain/term-ledger.mjs +63 -1
- package/src/services/extract-facts.mjs +29 -0
- package/src/services/news.mjs +98 -35
- package/src/surfaces/web/memory-ask-browser.bundle.js +74 -74
package/src/services/news.mjs
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
// (the same invalidation convention chat.mjs's own caches use).
|
|
18
18
|
// lexicon a loaded lexicon (loadLexicon() when absent).
|
|
19
19
|
// config resolveNewsConfig()'s shape.
|
|
20
|
-
// state the news-store shape (news-store.mjs): { items,
|
|
21
|
-
// (ledgerPayload form), health, requestLog, metrics,
|
|
22
|
-
// lastEnrichAt } — always JSON-plain; a live term
|
|
23
|
-
// built from state.ledger via ledgerFromPayload only
|
|
24
|
-
// span of one call, then folded back with ledgerPayload.
|
|
20
|
+
// state the news-store shape (news-store.mjs): { items, seenItemKeys,
|
|
21
|
+
// ledger (ledgerPayload form), health, requestLog, metrics,
|
|
22
|
+
// lastPollAt, lastEnrichAt } — always JSON-plain; a live term
|
|
23
|
+
// ledger is built from state.ledger via ledgerFromPayload only
|
|
24
|
+
// for the span of one call, then folded back with ledgerPayload.
|
|
25
25
|
// providers { newsFetchers: Map<sourceId, { id, fetchItems }>,
|
|
26
26
|
// getResearchProvider({ source }), preflightNewsUrl?(url) } —
|
|
27
27
|
// every fetcher and provider this session may call, already
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
import { normFactTerm, normFactPredicate, factIdFor } from "../domain/hash.mjs";
|
|
45
45
|
import {
|
|
46
46
|
newsWindowRows, renderNewsParagraph, buildNewsItems, evictNewsFacts,
|
|
47
|
-
conceptTerms, isQuantityTerm,
|
|
47
|
+
conceptTerms, isQuantityTerm, newsItemKeys,
|
|
48
48
|
} from "../domain/news-feed.mjs";
|
|
49
49
|
import {
|
|
50
50
|
createTermLedger, bumpTerms, rankedTerms, markTerm, groundedSweep, ledgerPayload, ledgerFromPayload,
|
|
@@ -473,38 +473,90 @@ function recordSuccess(health, nowVal, status) {
|
|
|
473
473
|
health.autoDisabled = false;
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
-
/** Merges `incoming` snapshots into `existing` by id (an already-seen id is
|
|
477
|
-
* never re-added or re-ingested), then enforces `cap` by dropping the
|
|
478
|
-
* oldest-by-fetchedAt entries. Returns the merged list and the genuinely
|
|
479
|
-
* new snapshots the caller still needs to ingest. */
|
|
480
|
-
function mergeSnapshotsById(existing, incoming, cap) {
|
|
481
|
-
const byIdMap = new Map((existing || []).map((s) => [s.id, s]));
|
|
482
|
-
const added = [];
|
|
483
|
-
for (const snap of incoming || []) {
|
|
484
|
-
if (byIdMap.has(snap.id)) continue;
|
|
485
|
-
byIdMap.set(snap.id, snap);
|
|
486
|
-
added.push(snap);
|
|
487
|
-
}
|
|
488
|
-
let items = [...byIdMap.values()];
|
|
489
|
-
if (items.length > cap) {
|
|
490
|
-
items = items
|
|
491
|
-
.slice()
|
|
492
|
-
.sort((a, b) => (toMs(a.fetchedAt) - toMs(b.fetchedAt)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
493
|
-
.slice(items.length - cap);
|
|
494
|
-
}
|
|
495
|
-
return { items, added };
|
|
496
|
-
}
|
|
497
|
-
|
|
498
476
|
const fetchedAtMs = (snapshot) => {
|
|
499
477
|
const ms = toMs(snapshot?.fetchedAt);
|
|
500
478
|
return Number.isFinite(ms) ? ms : 0;
|
|
501
479
|
};
|
|
502
480
|
|
|
503
|
-
/** True once a snapshot has been through a grounding round.
|
|
504
|
-
*
|
|
505
|
-
*
|
|
481
|
+
/** True once a snapshot has been through a grounding round. A snapshot is
|
|
482
|
+
* filed the moment it arrives, so "known" and "grounded" are two different
|
|
483
|
+
* states and only this one means the facts landed. */
|
|
506
484
|
const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
|
|
507
485
|
|
|
486
|
+
const byIdAscending = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
487
|
+
const byFetchedAtThenId = (a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || byIdAscending(a, b);
|
|
488
|
+
// The item cap's drop order: a snapshot already read is the first to go, since
|
|
489
|
+
// its facts are in the graph and its keys are remembered, while one still
|
|
490
|
+
// waiting to be read would lose its facts for good.
|
|
491
|
+
const alreadyReadFirst = (a, b) => (isGroundedSnapshot(b) - isGroundedSnapshot(a)) || byFetchedAtThenId(a, b);
|
|
492
|
+
|
|
493
|
+
// How many item keys the de-dupe memory carries. Each grounded item files two
|
|
494
|
+
// (its source id and its content key), so this remembers roughly a thousand
|
|
495
|
+
// articles — many times the item window itself, which is the point: the window
|
|
496
|
+
// forgets an article as soon as newer ones crowd it out, and without this the
|
|
497
|
+
// next poll would read that same article as brand new.
|
|
498
|
+
const SEEN_ITEM_KEY_CAP = 2000;
|
|
499
|
+
|
|
500
|
+
const seenEntries = (seen) => (Array.isArray(seen) ? seen : []);
|
|
501
|
+
|
|
502
|
+
/** Every key `snapshots` and `seen` between them name, newest first and capped
|
|
503
|
+
* — an entry's `at` is the snapshot's own fetchedAt, never a fresh clock
|
|
504
|
+
* reading. Ordered off the entries themselves, so the same items produce the
|
|
505
|
+
* same memory whatever order they arrived in. */
|
|
506
|
+
function rememberItemKeys(seen, snapshots) {
|
|
507
|
+
const atByKey = new Map();
|
|
508
|
+
const remember = (key, at) => {
|
|
509
|
+
if (!key) return;
|
|
510
|
+
const previous = atByKey.get(key);
|
|
511
|
+
atByKey.set(key, previous === undefined ? at : Math.max(previous, at));
|
|
512
|
+
};
|
|
513
|
+
for (const entry of seenEntries(seen)) {
|
|
514
|
+
const at = Number(entry?.at);
|
|
515
|
+
remember(String(entry?.key ?? ""), Number.isFinite(at) ? at : 0);
|
|
516
|
+
}
|
|
517
|
+
for (const snap of snapshots || []) {
|
|
518
|
+
for (const key of newsItemKeys(snap)) remember(key, fetchedAtMs(snap));
|
|
519
|
+
}
|
|
520
|
+
return [...atByKey.entries()]
|
|
521
|
+
.map(([key, at]) => ({ key, at }))
|
|
522
|
+
.sort((a, b) => (b.at - a.at) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
523
|
+
.slice(0, SEEN_ITEM_KEY_CAP);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Merges `incoming` snapshots into `existing` by item identity: a snapshot
|
|
527
|
+
* whose id or content key already sits in the window, or in the `seen` memory
|
|
528
|
+
* of what has been grounded, is neither re-added nor re-ingested. Two
|
|
529
|
+
* incoming snapshots naming one item collapse to the lower id, so the same
|
|
530
|
+
* fetch read in two orders admits the same snapshot. The merged list sorts by
|
|
531
|
+
* fetchedAt then id, so the window is a function of which items are in it
|
|
532
|
+
* rather than of when each arrived, and `cap` then drops the snapshots that
|
|
533
|
+
* have already been read before any that still have facts to contribute.
|
|
534
|
+
* Returns the merged list and the genuinely new snapshots the caller still
|
|
535
|
+
* needs to ingest. */
|
|
536
|
+
function mergeSnapshots(existing, incoming, { cap, seen } = {}) {
|
|
537
|
+
const known = new Set();
|
|
538
|
+
for (const snap of existing || []) for (const key of newsItemKeys(snap)) known.add(key);
|
|
539
|
+
for (const entry of seenEntries(seen)) if (entry?.key) known.add(String(entry.key));
|
|
540
|
+
|
|
541
|
+
const claimed = new Set();
|
|
542
|
+
const admitted = new Set();
|
|
543
|
+
for (const snap of [...(incoming || [])].sort(byIdAscending)) {
|
|
544
|
+
const keys = newsItemKeys(snap);
|
|
545
|
+
if (!keys.length) continue;
|
|
546
|
+
if (keys.some((key) => known.has(key) || claimed.has(key))) continue;
|
|
547
|
+
for (const key of keys) claimed.add(key);
|
|
548
|
+
admitted.add(snap);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const added = (incoming || []).filter((snap) => admitted.has(snap));
|
|
552
|
+
const items = [...(existing || []), ...added].sort(byFetchedAtThenId);
|
|
553
|
+
if (items.length <= cap) return { items, added };
|
|
554
|
+
const dropped = new Set(
|
|
555
|
+
items.slice().sort(alreadyReadFirst).slice(0, items.length - cap).map((snap) => snap.id),
|
|
556
|
+
);
|
|
557
|
+
return { items: items.filter((snap) => !dropped.has(snap.id)), added };
|
|
558
|
+
}
|
|
559
|
+
|
|
508
560
|
/** One source's fetched-but-not-yet-grounded snapshots, oldest first. A cycle
|
|
509
561
|
* that ran out of time leaves its backlog here, so the next cycle works
|
|
510
562
|
* through that before anything newer and the same article is never ingested
|
|
@@ -513,7 +565,7 @@ const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
|
|
|
513
565
|
function pendingSnapshotsFor(items, sourceId) {
|
|
514
566
|
return (items || [])
|
|
515
567
|
.filter((snap) => snap?.sourceId === sourceId && !isGroundedSnapshot(snap))
|
|
516
|
-
.sort(
|
|
568
|
+
.sort(byFetchedAtThenId);
|
|
517
569
|
}
|
|
518
570
|
|
|
519
571
|
function emptyCycleAccumulator(at) {
|
|
@@ -575,7 +627,9 @@ export async function pollNewsSources(ctx) {
|
|
|
575
627
|
recordSuccess(health, nowVal, "not-modified");
|
|
576
628
|
} else {
|
|
577
629
|
recordSuccess(health, nowVal, "ok");
|
|
578
|
-
const merged =
|
|
630
|
+
const merged = mergeSnapshots(state.items, result.items, {
|
|
631
|
+
cap: config.itemCap, seen: state.seenItemKeys,
|
|
632
|
+
});
|
|
579
633
|
state.items = merged.items;
|
|
580
634
|
added = merged.added;
|
|
581
635
|
newItemsTotal += added.length;
|
|
@@ -614,6 +668,15 @@ export async function pollNewsSources(ctx) {
|
|
|
614
668
|
if (aborted) break;
|
|
615
669
|
}
|
|
616
670
|
|
|
671
|
+
// Only a GROUNDED snapshot is remembered. One the item cap dropped before it
|
|
672
|
+
// was ever read still has its facts to contribute, so the next poll is meant
|
|
673
|
+
// to pick it up again; one whose facts already landed must never be read a
|
|
674
|
+
// second time, however long ago the window forgot it.
|
|
675
|
+
state.seenItemKeys = rememberItemKeys(
|
|
676
|
+
state.seenItemKeys,
|
|
677
|
+
(state.items || []).filter(isGroundedSnapshot),
|
|
678
|
+
);
|
|
679
|
+
|
|
617
680
|
const memory = await store.loadMemory(memoryDir);
|
|
618
681
|
const rows = store.readFactRows(memory);
|
|
619
682
|
const evictIds = evictNewsFacts(rows, { cap: config.newsFactCap });
|
|
@@ -998,7 +1061,7 @@ export function cycleMetrics(before, after, { source } = {}) {
|
|
|
998
1061
|
|
|
999
1062
|
export function createNewsState() {
|
|
1000
1063
|
return {
|
|
1001
|
-
items: [], ledger: ledgerPayload(createTermLedger()), health: [],
|
|
1002
|
-
lastPollAt: "", lastEnrichAt: "",
|
|
1064
|
+
items: [], seenItemKeys: [], ledger: ledgerPayload(createTermLedger()), health: [],
|
|
1065
|
+
requestLog: [], metrics: [], lastPollAt: "", lastEnrichAt: "",
|
|
1003
1066
|
};
|
|
1004
1067
|
}
|