@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
package/src/domain/news-feed.mjs
CHANGED
|
@@ -527,9 +527,13 @@ export function hubSeedTerms(hub) {
|
|
|
527
527
|
* `seedTerms` starts the walk from more than the hub itself (hubSeedTerms).
|
|
528
528
|
* Everything downstream that asks "is this the hub" — the report sentences,
|
|
529
529
|
* the sources, the neighbourhood — still reads the hub term alone, so a seed
|
|
530
|
-
* widens only what the card can draw background from.
|
|
530
|
+
* widens only what the card can draw background from.
|
|
531
|
+
*
|
|
532
|
+
* `excludeIds` drops rows before the cap rather than after it, so a card that
|
|
533
|
+
* gives a report away to another card (storyCoverage) spends the freed budget
|
|
534
|
+
* on rows it will actually show. */
|
|
531
535
|
export function subgraphAround(rows, hub, {
|
|
532
|
-
hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null,
|
|
536
|
+
hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null, excludeIds = null,
|
|
533
537
|
} = {}) {
|
|
534
538
|
const adj = adjacency ?? buildTermAdjacency(rows);
|
|
535
539
|
const hubTerm = normFactTerm(hub);
|
|
@@ -538,11 +542,13 @@ export function subgraphAround(rows, hub, {
|
|
|
538
542
|
let frontier = [...new Set(seeds)].sort();
|
|
539
543
|
const collected = new Map();
|
|
540
544
|
const hopOf = new Map();
|
|
545
|
+
const isExcluded = excludeIds instanceof Set ? (id) => excludeIds.has(id) : () => false;
|
|
541
546
|
for (let hop = 0; hop < hops; hop += 1) {
|
|
542
547
|
const nextFrontier = new Set();
|
|
543
548
|
for (const term of [...frontier].sort()) {
|
|
544
549
|
for (const idx of adj.byTerm.get(term) ?? []) {
|
|
545
550
|
const row = rows[idx];
|
|
551
|
+
if (isExcluded(row.id)) continue;
|
|
546
552
|
collected.set(row.id, row);
|
|
547
553
|
if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
|
|
548
554
|
const [s, o] = adj.terms[idx];
|
|
@@ -561,6 +567,32 @@ export function subgraphAround(rows, hub, {
|
|
|
561
567
|
.slice(0, cap);
|
|
562
568
|
}
|
|
563
569
|
|
|
570
|
+
// How far the walk goes from an entity the ARTICLE names rather than a fact,
|
|
571
|
+
// and how many rows it may bring back. One hop: what the graph says about that
|
|
572
|
+
// entity itself, never what it says about everything that entity touches. The
|
|
573
|
+
// hub's own two-hop walk is unchanged; this one runs beside it.
|
|
574
|
+
const ARTICLE_ENTITY_HOPS = 1;
|
|
575
|
+
const ARTICLE_ENTITY_ROW_CAP = 24;
|
|
576
|
+
|
|
577
|
+
/** The rows sitting one hop from an entity the card's article names. Seeded
|
|
578
|
+
* from `terms` rather than the hub, so a definition the graph holds about a
|
|
579
|
+
* name inside the headline reaches the card even when no fact of the card's
|
|
580
|
+
* own touches that name — "amigados is a disk operating system" beside a
|
|
581
|
+
* report whose only fact is that a site discussed the headline.
|
|
582
|
+
*
|
|
583
|
+
* `excludeIds` carries the card's reported rows as well as the ones another
|
|
584
|
+
* card claimed, so this walk returns background and nothing else: what a
|
|
585
|
+
* source reported is the hub walk's business. */
|
|
586
|
+
export function articleEntityRows(rows, terms, {
|
|
587
|
+
adjacency = null, excludeIds = null, cap = ARTICLE_ENTITY_ROW_CAP,
|
|
588
|
+
} = {}) {
|
|
589
|
+
const seedTerms = (terms || []).map((term) => normFactTerm(term)).filter(Boolean);
|
|
590
|
+
if (!seedTerms.length) return [];
|
|
591
|
+
return subgraphAround(rows, seedTerms[0], {
|
|
592
|
+
hops: ARTICLE_ENTITY_HOPS, cap, adjacency, seedTerms, excludeIds,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
564
596
|
/** The strongest prior kind among `rows`, for the item's trust chip — read
|
|
565
597
|
* off each row's own `trust` (a number) and `sourceTypes` (the kind array
|
|
566
598
|
* readFactRows already computes), never a re-derivation of SOURCE_PRIOR. */
|
|
@@ -768,40 +800,58 @@ function identityFans(subgraphRows) {
|
|
|
768
800
|
return { senseFan, sourcedSenseFan, categoryFan };
|
|
769
801
|
}
|
|
770
802
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
803
|
+
// Where a background row's anchor term came from, and so which rows the
|
|
804
|
+
// disclosure names first: the card's own hub, then a term its report names,
|
|
805
|
+
// then an entity its article names that no fact of the card's own touches.
|
|
806
|
+
const ANCHOR_RANK_HUB = 0;
|
|
807
|
+
const ANCHOR_RANK_REPORT = 1;
|
|
808
|
+
const ANCHOR_RANK_ARTICLE = 2;
|
|
809
|
+
|
|
810
|
+
/** What this card is ABOUT, each term mapped to its anchor rank: its hub, the
|
|
811
|
+
* region of a hub the source spelled "settlement, region", the other terms its
|
|
812
|
+
* own report sentences name, and the entities its article names (`articleTerms`)
|
|
813
|
+
* — minus any of those that reads across senses. A quake card's report
|
|
774
814
|
* names "earthquake" and a place; the class term is where the graph's
|
|
775
815
|
* knowledge is thinnest and its senses widest, so background drawn through it
|
|
776
816
|
* is background about earthquakes in general, never about this quake. The hub
|
|
777
|
-
* itself is always in, whatever its sense count — the card is about it.
|
|
778
|
-
|
|
817
|
+
* itself is always in, whatever its sense count — the card is about it.
|
|
818
|
+
*
|
|
819
|
+
* An article entity earns the same reading a report term gets, one rank
|
|
820
|
+
* behind it: the story's own words name it, but no fact the card reports
|
|
821
|
+
* does. */
|
|
822
|
+
function cardSubjectTerms(hub, subgraphRows, { reportedIds = null, senseFan, articleTerms = [] }) {
|
|
779
823
|
const hubTerm = normFactTerm(hub);
|
|
780
824
|
const isReported = idMembership(reportedIds);
|
|
781
|
-
const terms = new
|
|
825
|
+
const terms = new Map();
|
|
826
|
+
for (const seed of hubSeedTerms(hubTerm)) terms.set(seed, ANCHOR_RANK_HUB);
|
|
827
|
+
const admit = (raw, rank) => {
|
|
828
|
+
const term = normFactTerm(raw);
|
|
829
|
+
if (!term || terms.has(term) || STOP_SET.has(term)) return;
|
|
830
|
+
if ((senseFan.get(term) || 0) > IDENTITY_MAX_CLASSES) return;
|
|
831
|
+
terms.set(term, rank);
|
|
832
|
+
};
|
|
782
833
|
for (const row of hubReportRows(hubTerm, subgraphRows, { reportedIds })) {
|
|
783
834
|
if (!isReported(row.id)) continue;
|
|
784
|
-
for (const raw of [row.subject, row.object])
|
|
785
|
-
const term = normFactTerm(raw);
|
|
786
|
-
if (!term || terms.has(term) || STOP_SET.has(term)) continue;
|
|
787
|
-
if ((senseFan.get(term) || 0) > IDENTITY_MAX_CLASSES) continue;
|
|
788
|
-
terms.add(term);
|
|
789
|
-
}
|
|
835
|
+
for (const raw of [row.subject, row.object]) admit(raw, ANCHOR_RANK_REPORT);
|
|
790
836
|
}
|
|
837
|
+
for (const raw of articleTerms) admit(raw, ANCHOR_RANK_ARTICLE);
|
|
791
838
|
return terms;
|
|
792
839
|
}
|
|
793
840
|
|
|
794
841
|
/** The background rows worth telling a reader about, ranked: a row touching
|
|
795
842
|
* one of this card's own subject terms, whose other side is not a category
|
|
796
843
|
* node, and — for an identity row — whose subject is not read across senses.
|
|
797
|
-
* Ranks the hub's own rows first, then
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
|
|
844
|
+
* Ranks the hub's own rows first, then the ones its report names, then the
|
|
845
|
+
* ones an entity in its article names, then by how specific the other side
|
|
846
|
+
* is, then by content-addressed id, so the same fact set always yields the
|
|
847
|
+
* same lines in the same order. */
|
|
848
|
+
export function knownFactRows(hub, subgraphRows, {
|
|
849
|
+
reportedIds = null, limit = KNOWN_FACT_ROW_LIMIT, articleTerms = [],
|
|
850
|
+
} = {}) {
|
|
801
851
|
const hubTerm = normFactTerm(hub);
|
|
802
852
|
const isReported = idMembership(reportedIds);
|
|
803
853
|
const { senseFan, sourcedSenseFan, categoryFan } = identityFans(subgraphRows);
|
|
804
|
-
const subjects = cardSubjectTerms(hub, subgraphRows, { reportedIds, senseFan });
|
|
854
|
+
const subjects = cardSubjectTerms(hub, subgraphRows, { reportedIds, senseFan, articleTerms });
|
|
805
855
|
const neighbourIds = new Set(neighbourRows(hub, subgraphRows, { reportedIds }).map((r) => r.id));
|
|
806
856
|
|
|
807
857
|
const scored = [];
|
|
@@ -822,14 +872,14 @@ export function knownFactRows(hub, subgraphRows, { reportedIds = null, limit = K
|
|
|
822
872
|
if ((categoryFan.get(other) || 0) > CATEGORY_FAN_MAX) continue;
|
|
823
873
|
scored.push({
|
|
824
874
|
row,
|
|
825
|
-
|
|
875
|
+
anchorRank: subjects.get(anchor),
|
|
826
876
|
otherCategoryFan: categoryFan.get(other) || 0,
|
|
827
877
|
otherSenseFan: senseFan.get(other) || 0,
|
|
828
878
|
});
|
|
829
879
|
}
|
|
830
880
|
|
|
831
881
|
return scored
|
|
832
|
-
.sort((a, b) => (
|
|
882
|
+
.sort((a, b) => (a.anchorRank - b.anchorRank)
|
|
833
883
|
|| (a.otherCategoryFan - b.otherCategoryFan)
|
|
834
884
|
|| (a.otherSenseFan - b.otherSenseFan)
|
|
835
885
|
|| byId(a.row, b.row))
|
|
@@ -856,30 +906,49 @@ function predicatesInRenderOrder(rows) {
|
|
|
856
906
|
|
|
857
907
|
/** One sentence per (subject, predicate) group over `rows`, in the order the
|
|
858
908
|
* rows arrive — the same shape the hub's own relation sentences take, so a
|
|
859
|
-
* background line reads like the rest of the paragraph rather than a dump.
|
|
860
|
-
|
|
909
|
+
* background line reads like the rest of the paragraph rather than a dump.
|
|
910
|
+
* Each entry carries the group's own rows alongside its text, so a caller
|
|
911
|
+
* that needs to know which facts a sentence came from (the bench's noisy-
|
|
912
|
+
* line scoring) reads them off the same grouping the sentence itself used,
|
|
913
|
+
* never a second derivation of it. */
|
|
914
|
+
function groupedFactSentenceEntries(rows) {
|
|
861
915
|
const groups = new Map();
|
|
862
916
|
for (const row of rows) {
|
|
863
917
|
const key = `${row.subject}${row.predicate}`;
|
|
864
918
|
let group = groups.get(key);
|
|
865
|
-
if (!group) groups.set(key, (group = { subject: row.subject, predicate: row.predicate, objects: [] }));
|
|
919
|
+
if (!group) groups.set(key, (group = { subject: row.subject, predicate: row.predicate, objects: [], rows: [] }));
|
|
866
920
|
group.objects.push(row.object);
|
|
921
|
+
group.rows.push(row);
|
|
867
922
|
}
|
|
868
|
-
return [...groups.values()].map(({ subject, predicate, objects }) => {
|
|
923
|
+
return [...groups.values()].map(({ subject, predicate, objects, rows: groupRows }) => {
|
|
869
924
|
const sorted = [...objects].sort();
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
925
|
+
// "rdf:type" reads "is a" from the curated phrase table, which is the wrong
|
|
926
|
+
// article before a vowel and a second one in front of an object articleFor
|
|
927
|
+
// has already spelled. The identity clause the paragraph opens with says a
|
|
928
|
+
// bare "is" and lets articleFor choose; a background line says it the same
|
|
929
|
+
// way. Every other predicate, "is a kind of" included, carries whatever
|
|
930
|
+
// article it needs inside its own phrase and takes the object bare.
|
|
931
|
+
const text = predicate === "rdf:type"
|
|
932
|
+
? `${subject} is ${joinObjects(sorted.map((object) => `${articleFor(object)} ${object}`))}`
|
|
933
|
+
: `${subject} ${predicatePhrase(predicate, subject)} ${joinObjects(sorted)}`;
|
|
934
|
+
return { text, rows: groupRows };
|
|
874
935
|
});
|
|
875
936
|
}
|
|
876
937
|
|
|
938
|
+
function groupedFactSentences(rows) {
|
|
939
|
+
return groupedFactSentenceEntries(rows).map((entry) => entry.text);
|
|
940
|
+
}
|
|
941
|
+
|
|
877
942
|
/** The sentences a card's paragraph is made of, as four ordered blocks: the
|
|
878
943
|
* report (what a source said inside the window), the identity clause, the
|
|
879
|
-
* related facts the graph already held, and the neighbourhood.
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
|
|
944
|
+
* related facts the graph already held, and the neighbourhood. Each entry is
|
|
945
|
+
* `{ text, rows }` — the rendered sentence and the fact row(s) it came from
|
|
946
|
+
* — so a caller that needs to know which facts actually reached the printed
|
|
947
|
+
* text (the bench's noisy-line scoring) reads them off the same blocks
|
|
948
|
+
* `renderNewsParagraph` itself slices, never a second derivation of it.
|
|
949
|
+
* Callers that render only one block — the "what the graph already knew"
|
|
950
|
+
* disclosure — read the block they want instead of re-deriving it. */
|
|
951
|
+
function paragraphBlocks(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
|
|
883
952
|
const hubTerm = normFactTerm(hub);
|
|
884
953
|
const isReported = idMembership(reportedIds);
|
|
885
954
|
const hubRows = subgraphRows.filter((r) => normFactTerm(r.subject) === hubTerm);
|
|
@@ -887,12 +956,10 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
|
887
956
|
const report = [];
|
|
888
957
|
for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
|
|
889
958
|
if (IDENTITY_PREDICATES.has(predicate) || report.length >= REPORT_SENTENCE_CAP) continue;
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
.map((r) => r.object)
|
|
893
|
-
.sort();
|
|
959
|
+
const groupRows = reportedHubRows.filter((r) => r.predicate === predicate);
|
|
960
|
+
const objects = groupRows.map((r) => r.object).sort();
|
|
894
961
|
if (!objects.length) continue;
|
|
895
|
-
report.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}
|
|
962
|
+
report.push({ text: `${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`, rows: groupRows });
|
|
896
963
|
}
|
|
897
964
|
|
|
898
965
|
// A hub that only ever appears as an OBJECT — the place a quake struck, the
|
|
@@ -900,12 +967,13 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
|
900
967
|
// and its card came out blank. What was reported about it still says
|
|
901
968
|
// something, so those rows render whole, subject and all.
|
|
902
969
|
if (!report.length) {
|
|
903
|
-
const
|
|
970
|
+
const aboutHubRows = subgraphRows
|
|
904
971
|
.filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
|
|
905
972
|
.sort(byId)
|
|
906
|
-
.slice(0, OBJECTS_PER_SENTENCE)
|
|
907
|
-
|
|
908
|
-
|
|
973
|
+
.slice(0, OBJECTS_PER_SENTENCE);
|
|
974
|
+
if (aboutHubRows.length) {
|
|
975
|
+
report.push({ text: aboutHubRows.map((r) => factSentence(r)).join("; "), rows: aboutHubRows });
|
|
976
|
+
}
|
|
909
977
|
}
|
|
910
978
|
|
|
911
979
|
// The identity clause follows the news, never leads it, and only when
|
|
@@ -913,23 +981,41 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
|
913
981
|
// closure names thirteen classes for "france" — a list nobody asked for,
|
|
914
982
|
// most of it the wrong sense — so it opens no card.
|
|
915
983
|
const identity = [];
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
.map((r) => r.object)
|
|
919
|
-
.sort();
|
|
984
|
+
const identityRows = hubRows.filter((r) => IDENTITY_PREDICATES.has(r.predicate) && !isDerivedRow(r));
|
|
985
|
+
const identityObjects = identityRows.map((r) => r.object).sort();
|
|
920
986
|
const identityIsSingleSense = identityObjects.length > 0 && identityObjects.length <= IDENTITY_MAX_CLASSES;
|
|
921
987
|
if (identityIsSingleSense) {
|
|
922
|
-
identity.push(
|
|
988
|
+
identity.push({
|
|
989
|
+
text: `${hub} is ${joinObjects(identityObjects.map((object) => `${articleFor(object)} ${object}`))}`,
|
|
990
|
+
rows: identityRows,
|
|
991
|
+
});
|
|
923
992
|
}
|
|
924
993
|
|
|
925
|
-
const known =
|
|
994
|
+
const known = groupedFactSentenceEntries(knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }));
|
|
926
995
|
|
|
927
996
|
const neighbours = neighbourRows(hub, subgraphRows, { reportedIds });
|
|
928
|
-
const around = neighbours.length
|
|
997
|
+
const around = neighbours.length
|
|
998
|
+
? [{ text: `Around it: ${neighbours.map((r) => factSentence(r)).join("; ")}`, rows: neighbours }]
|
|
999
|
+
: [];
|
|
929
1000
|
|
|
930
1001
|
return { report, identity, known, around };
|
|
931
1002
|
}
|
|
932
1003
|
|
|
1004
|
+
/** The paragraph's own sentence entries (`{ text, rows }`), in print order and
|
|
1005
|
+
* sliced to exactly what `renderNewsParagraph` shows — the per-block caps
|
|
1006
|
+
* (identity, known) and the paragraph-wide `SENTENCE_CAP` both applied.
|
|
1007
|
+
* Shared by `renderNewsParagraph` and `printedParagraphRows` so the two can
|
|
1008
|
+
* never drift: one reads `.text`, the other reads `.rows`. */
|
|
1009
|
+
function paragraphSentenceEntries(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
|
|
1010
|
+
const { report, identity, known, around } = paragraphBlocks(hub, subgraphRows, { reportedIds, articleTerms });
|
|
1011
|
+
return [
|
|
1012
|
+
...report,
|
|
1013
|
+
...identity.slice(0, IDENTITY_SENTENCE_CAP),
|
|
1014
|
+
...known.slice(0, KNOWN_FACT_SENTENCE_CAP),
|
|
1015
|
+
...around,
|
|
1016
|
+
].slice(0, SENTENCE_CAP);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
933
1019
|
/** A card's paragraph: what a source reported, then what the thing is, then
|
|
934
1020
|
* the related facts the graph already held about it, then its own
|
|
935
1021
|
* neighbourhood (neighbourRows). Every sentence shown is a grounded fact,
|
|
@@ -940,27 +1026,307 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
|
940
1026
|
* into what was reported (the lead sentences) and what the graph already held
|
|
941
1027
|
* (the background ones). Defaults to null, meaning every row counts as
|
|
942
1028
|
* reported. */
|
|
943
|
-
export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
944
|
-
const
|
|
945
|
-
const sentences = [
|
|
946
|
-
...report,
|
|
947
|
-
...identity.slice(0, IDENTITY_SENTENCE_CAP),
|
|
948
|
-
...known.slice(0, KNOWN_FACT_SENTENCE_CAP),
|
|
949
|
-
...around,
|
|
950
|
-
].slice(0, SENTENCE_CAP);
|
|
1029
|
+
export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
|
|
1030
|
+
const sentences = paragraphSentenceEntries(hub, subgraphRows, { reportedIds, articleTerms }).map((entry) => entry.text);
|
|
951
1031
|
return sentences.length ? `${sentences.join(". ")}.` : "";
|
|
952
1032
|
}
|
|
953
1033
|
|
|
1034
|
+
/** Every fact row that survives into the card's rendered main paragraph —
|
|
1035
|
+
* the same rows `renderNewsParagraph` drew its sentences from, after every
|
|
1036
|
+
* cap it applies (per-block and the paragraph-wide `SENTENCE_CAP`). A row
|
|
1037
|
+
* computed but sliced away before render (an "Around it" clause cut by the
|
|
1038
|
+
* overall cap, an identity class beyond `IDENTITY_MAX_CLASSES`) never
|
|
1039
|
+
* appears here, because it never appears on the card either. */
|
|
1040
|
+
export function printedParagraphRows(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
|
|
1041
|
+
return paragraphSentenceEntries(hub, subgraphRows, { reportedIds, articleTerms }).flatMap((entry) => entry.rows);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
954
1044
|
/** The "what the graph already knew" disclosure: the same related facts the
|
|
955
1045
|
* paragraph leads with, at the disclosure's own fuller depth, and nothing the
|
|
956
1046
|
* card already reported. Empty when the graph held nothing about this card's
|
|
957
1047
|
* own subjects — a card with no background says so rather than filling the
|
|
958
1048
|
* space with whatever the two-hop walk happened to reach. */
|
|
959
|
-
export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
960
|
-
const sentences = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds }));
|
|
1049
|
+
export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
|
|
1050
|
+
const sentences = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }));
|
|
961
1051
|
return sentences.length ? `${sentences.join(". ")}.` : "";
|
|
962
1052
|
}
|
|
963
1053
|
|
|
1054
|
+
// ---------------------------------------------------------------------------
|
|
1055
|
+
// Which story a card tells, and what it is called.
|
|
1056
|
+
// ---------------------------------------------------------------------------
|
|
1057
|
+
|
|
1058
|
+
// The item tag a news row carries, at whatever depth the ingest wrapper
|
|
1059
|
+
// nested it ("news:<sourceId>@<itemId>", "optimistic-extract:news:…"), plus
|
|
1060
|
+
// the fixture replay's own twin. Every sentence a card prints comes from a
|
|
1061
|
+
// row, so which story a card is telling is readable from the fact set alone —
|
|
1062
|
+
// no source map, no arrival order, no clock.
|
|
1063
|
+
const NEWS_STORY_TAG_RE = /(?:^|:)news(?:-fixture)?:([^\s|]+)/;
|
|
1064
|
+
|
|
1065
|
+
/** The newsworthy item one row reported, or "" when its provenance names
|
|
1066
|
+
* none. Pure. */
|
|
1067
|
+
export function newsStoryKey(row) {
|
|
1068
|
+
return NEWS_STORY_TAG_RE.exec(String(row?.provenance || ""))?.[1] || "";
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Every reported row touching each term, subject side or object side. Built
|
|
1072
|
+
* once per feed assembly and shared, for the same reason buildTermAdjacency
|
|
1073
|
+
* is. */
|
|
1074
|
+
function reportedRowsByTerm(reported) {
|
|
1075
|
+
const byTerm = new Map();
|
|
1076
|
+
for (const row of reported) {
|
|
1077
|
+
for (const term of new Set([normFactTerm(row.subject), normFactTerm(row.object)])) {
|
|
1078
|
+
if (!term) continue;
|
|
1079
|
+
let rowsFor = byTerm.get(term);
|
|
1080
|
+
if (!rowsFor) byTerm.set(term, (rowsFor = []));
|
|
1081
|
+
rowsFor.push(row);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return byTerm;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// How many stories a term's own reports must span before it stops being one
|
|
1088
|
+
// story's subject. Two is enough: a term the day's second story also names is
|
|
1089
|
+
// a publication, a wire desk or a class every item shares, and a card headed
|
|
1090
|
+
// by one repeats whatever the per-story cards already said.
|
|
1091
|
+
const PUBLICATION_STORY_MIN = 2;
|
|
1092
|
+
|
|
1093
|
+
/** How many distinct newsworthy items each term's own reports came from. */
|
|
1094
|
+
function storyCountsByTerm(rowsByTerm) {
|
|
1095
|
+
const counts = new Map();
|
|
1096
|
+
for (const [term, rowsFor] of rowsByTerm) {
|
|
1097
|
+
const keys = new Set();
|
|
1098
|
+
for (const row of rowsFor) {
|
|
1099
|
+
const key = newsStoryKey(row);
|
|
1100
|
+
if (key) keys.add(key);
|
|
1101
|
+
}
|
|
1102
|
+
counts.set(term, keys.size);
|
|
1103
|
+
}
|
|
1104
|
+
return counts;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// The classes the graph's own identity rows use to say a thing is a place or
|
|
1108
|
+
// a person. The same closed set the bench's entity-preservation metric reads
|
|
1109
|
+
// off the seed, duplicated here (not imported) because the domain layer never
|
|
1110
|
+
// imports a script.
|
|
1111
|
+
const PLACE_OR_PERSON_CLASS_TERMS = new Set([
|
|
1112
|
+
"place", "person", "city", "country", "location", "nation", "continent",
|
|
1113
|
+
"capital", "state", "province", "town", "region",
|
|
1114
|
+
]);
|
|
1115
|
+
|
|
1116
|
+
/** Every term the graph itself types as a place or a person, whoever stated
|
|
1117
|
+
* the identity. A card prefers one of these for its own title: it is the
|
|
1118
|
+
* thing the story is about, where a clause the same report threw off is only
|
|
1119
|
+
* something that happened to it. Pure over `rows`. */
|
|
1120
|
+
export function placeAndPersonTerms(rows) {
|
|
1121
|
+
const grounded = new Set();
|
|
1122
|
+
for (const row of rows) {
|
|
1123
|
+
if (row.predicate !== "rdf:type" && row.predicate !== "rdfs:subClassOf") continue;
|
|
1124
|
+
if (!PLACE_OR_PERSON_CLASS_TERMS.has(normFactTerm(row.object))) continue;
|
|
1125
|
+
const subject = normFactTerm(row.subject);
|
|
1126
|
+
if (subject) grounded.add(subject);
|
|
1127
|
+
}
|
|
1128
|
+
return grounded;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// A predicate minted from a source's own verb, lemma only: "mgx:hit",
|
|
1132
|
+
// "mgx:discuss". The curated table's entries carry a capital ("mgx:partOf")
|
|
1133
|
+
// and a folded preposition carries a hyphen ("mgx:strike-near"), and neither
|
|
1134
|
+
// can be a single word inside a term, so neither belongs in the verb set.
|
|
1135
|
+
const MINTED_VERB_PREDICATE_RE = /^mgx:([a-z]+)$/;
|
|
1136
|
+
|
|
1137
|
+
/** The verbs this window's own reports minted a predicate from. A term
|
|
1138
|
+
* carrying one of them is a clause the extraction cut in half, and the graph
|
|
1139
|
+
* says so itself rather than a hand-written verb list saying it. */
|
|
1140
|
+
export function reportedVerbWords(reported) {
|
|
1141
|
+
const verbs = new Set();
|
|
1142
|
+
for (const row of reported) {
|
|
1143
|
+
const lemma = MINTED_VERB_PREDICATE_RE.exec(String(row.predicate || ""))?.[1];
|
|
1144
|
+
if (lemma) verbs.add(lemma);
|
|
1145
|
+
}
|
|
1146
|
+
return verbs;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// How many words a name runs to before it reads as a sentence about a thing
|
|
1150
|
+
// rather than the thing's name. "south sandwich islands region" is a place;
|
|
1151
|
+
// "boats hit by mystery attackers" is what happened to some.
|
|
1152
|
+
const CLAUSE_TITLE_MAX_WORDS = 4;
|
|
1153
|
+
const CLAUSE_WORD_EDGE_RE = /^[^a-z0-9]+|[^a-z0-9]+$/g;
|
|
1154
|
+
|
|
1155
|
+
/** Does `term` read as a clause rather than a name — longer than a name runs,
|
|
1156
|
+
* or carrying one of the verbs the window's own reports minted? A clause
|
|
1157
|
+
* never takes a card's title from a grounded entity term. */
|
|
1158
|
+
export function readsAsClauseTerm(term, verbWords) {
|
|
1159
|
+
const words = String(term ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1160
|
+
if (!words.length) return false;
|
|
1161
|
+
if (words.length > CLAUSE_TITLE_MAX_WORDS) return true;
|
|
1162
|
+
const verbs = verbWords instanceof Set ? verbWords : new Set(verbWords || []);
|
|
1163
|
+
return words.some((word) => verbs.has(word.replace(CLAUSE_WORD_EDGE_RE, "")));
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** The term a card is titled and keyed by, given the hub the gate chose and
|
|
1167
|
+
* the reported rows that hub sits in. In order:
|
|
1168
|
+
*
|
|
1169
|
+
* 1. the hub itself, when the graph already types it as a place or a person;
|
|
1170
|
+
* 2. a place/person term the hub's own reports name — "bali" over "sacred
|
|
1171
|
+
* glow", "clacton" over "election";
|
|
1172
|
+
* 3. the subject those reports share, but only when the hub reads as a clause
|
|
1173
|
+
* and only when that subject tells one story of its own — the publication
|
|
1174
|
+
* every headline hangs off is never a card's name;
|
|
1175
|
+
* 4. the hub, unchanged.
|
|
1176
|
+
*
|
|
1177
|
+
* Ties inside a step go to the term the reports name most, then alphabetical,
|
|
1178
|
+
* so the same fact set always titles a card the same way. Nothing here invents
|
|
1179
|
+
* a term: every candidate is already on one of the card's own rows. */
|
|
1180
|
+
export function hubTitleTerm(hubTerm, hubRows, { placeOrPerson, verbWords, storyCountByTerm }) {
|
|
1181
|
+
if (placeOrPerson.has(hubTerm)) return hubTerm;
|
|
1182
|
+
|
|
1183
|
+
const namesAThing = (term) => Boolean(term) && term !== hubTerm && !STOP_SET.has(term)
|
|
1184
|
+
&& !isQuantityTerm(term) && looksLikeEntityTerm(term) && !readsAsClauseTerm(term, verbWords);
|
|
1185
|
+
|
|
1186
|
+
const groundedCounts = new Map();
|
|
1187
|
+
const subjectCounts = new Map();
|
|
1188
|
+
for (const row of hubRows) {
|
|
1189
|
+
const subject = normFactTerm(row.subject);
|
|
1190
|
+
const object = normFactTerm(row.object);
|
|
1191
|
+
for (const term of new Set([subject, object])) {
|
|
1192
|
+
if (namesAThing(term) && placeOrPerson.has(term)) groundedCounts.set(term, (groundedCounts.get(term) || 0) + 1);
|
|
1193
|
+
}
|
|
1194
|
+
if (object !== hubTerm || !namesAThing(subject)) continue;
|
|
1195
|
+
if ((storyCountByTerm.get(subject) || 0) >= PUBLICATION_STORY_MIN) continue;
|
|
1196
|
+
subjectCounts.set(subject, (subjectCounts.get(subject) || 0) + 1);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const mostNamed = (counts) => [...counts.entries()]
|
|
1200
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))[0]?.[0] || "";
|
|
1201
|
+
|
|
1202
|
+
return mostNamed(groundedCounts)
|
|
1203
|
+
|| (readsAsClauseTerm(hubTerm, verbWords) ? mostNamed(subjectCounts) : "")
|
|
1204
|
+
|| hubTerm;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** True when a source spelled this term as a settlement and its region at once
|
|
1208
|
+
* — "wana, pakistan", "mina, nevada". The source has already said which of a
|
|
1209
|
+
* report's terms is the thing that happened somewhere, so the term reads as a
|
|
1210
|
+
* place even where no identity row types it as one. hubSeedTerms holds the
|
|
1211
|
+
* same reading for the walk. */
|
|
1212
|
+
const namesASettlementAndRegion = (term) => hubSeedTerms(term).length > 1;
|
|
1213
|
+
|
|
1214
|
+
/** The gate's hubs retitled by hubTitleTerm, with two hubs that answer to the
|
|
1215
|
+
* same name merged into one. Each keeps the two readings storyCoverage ranks
|
|
1216
|
+
* cards by, taken on the title the card will actually wear. Keeps the gate's
|
|
1217
|
+
* own sort — changed count desc, then term asc. */
|
|
1218
|
+
function titledHubs(hubs, rows, reported, rowsByTerm) {
|
|
1219
|
+
const placeOrPerson = placeAndPersonTerms(rows);
|
|
1220
|
+
const verbWords = reportedVerbWords(reported);
|
|
1221
|
+
const storyCountByTerm = storyCountsByTerm(rowsByTerm);
|
|
1222
|
+
const changedByTitle = new Map();
|
|
1223
|
+
for (const { term, changed } of hubs) {
|
|
1224
|
+
const title = hubTitleTerm(term, rowsByTerm.get(term) || [], { placeOrPerson, verbWords, storyCountByTerm });
|
|
1225
|
+
const held = changedByTitle.get(title);
|
|
1226
|
+
if (held === undefined || changed > held) changedByTitle.set(title, changed);
|
|
1227
|
+
}
|
|
1228
|
+
return [...changedByTitle.entries()]
|
|
1229
|
+
.map(([term, changed]) => ({
|
|
1230
|
+
term,
|
|
1231
|
+
changed,
|
|
1232
|
+
namesAnEntity: placeOrPerson.has(term) || namesASettlementAndRegion(term),
|
|
1233
|
+
clauseShaped: readsAsClauseTerm(term, verbWords),
|
|
1234
|
+
reportSubject: (rowsByTerm.get(term) || []).some((row) => normFactTerm(row.subject) === term),
|
|
1235
|
+
}))
|
|
1236
|
+
.sort((a, b) => b.changed - a.changed || (a.term < b.term ? -1 : a.term > b.term ? 1 : 0));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** How a story picks between the cards that want to tell it. In order: the
|
|
1240
|
+
* hub whose own reports span fewest stories, since a card about one story
|
|
1241
|
+
* beats a publication's roundup of it; then the hub that names a place or a
|
|
1242
|
+
* person, since that is what the story is about; then the hub that
|
|
1243
|
+
* reads as a name rather than a clause; then the one the report puts on the
|
|
1244
|
+
* subject side, the story's actor where the object is what happened to it;
|
|
1245
|
+
* then the gate's own changed count; then the term itself, so the answer never
|
|
1246
|
+
* comes down to arrival order. */
|
|
1247
|
+
function byCardClaim(a, b) {
|
|
1248
|
+
return a.storyCount - b.storyCount
|
|
1249
|
+
|| (Number(b.namesAnEntity) - Number(a.namesAnEntity))
|
|
1250
|
+
|| (Number(a.clauseShaped) - Number(b.clauseShaped))
|
|
1251
|
+
|| (Number(b.reportSubject) - Number(a.reportSubject))
|
|
1252
|
+
|| (b.changed - a.changed)
|
|
1253
|
+
|| (a.term < b.term ? -1 : a.term > b.term ? 1 : 0);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Which stories each hub gets to tell, and which of its reports belong to
|
|
1257
|
+
* another card. One story mints one card: the hubs bid for it in byCardClaim
|
|
1258
|
+
* order and the first takes it, so a report that threw off both a subject hub
|
|
1259
|
+
* and an object hub ("ukraine" beside "air war", "london" beside "glass")
|
|
1260
|
+
* stops minting a card each. A hub left with no story of its own does not
|
|
1261
|
+
* mint, and one that keeps some carries only those — the "hackernews" card
|
|
1262
|
+
* that repeated both of the day's Hacker News cards wholesale is gone, and one
|
|
1263
|
+
* that repeated three of four carries the fourth alone.
|
|
1264
|
+
*
|
|
1265
|
+
* A row whose provenance names no story is never claimed and never dropped, so
|
|
1266
|
+
* a hub built only from those still mints.
|
|
1267
|
+
*
|
|
1268
|
+
* Returns hub term -> `{ mints, coveredRowIds }`. */
|
|
1269
|
+
export function storyCoverage(hubs, rowsByTerm) {
|
|
1270
|
+
const rowIdsByStory = new Map();
|
|
1271
|
+
for (const { term } of hubs) {
|
|
1272
|
+
const byStory = new Map();
|
|
1273
|
+
for (const row of rowsByTerm.get(term) || []) {
|
|
1274
|
+
const key = newsStoryKey(row);
|
|
1275
|
+
if (!key) continue;
|
|
1276
|
+
let ids = byStory.get(key);
|
|
1277
|
+
if (!ids) byStory.set(key, (ids = []));
|
|
1278
|
+
ids.push(row.id);
|
|
1279
|
+
}
|
|
1280
|
+
rowIdsByStory.set(term, byStory);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const bidders = hubs
|
|
1284
|
+
.map((hub) => ({ ...hub, storyCount: rowIdsByStory.get(hub.term).size }))
|
|
1285
|
+
.sort(byCardClaim);
|
|
1286
|
+
|
|
1287
|
+
const claimed = new Set();
|
|
1288
|
+
const coverage = new Map();
|
|
1289
|
+
for (const { term, storyCount } of bidders) {
|
|
1290
|
+
const stories = rowIdsByStory.get(term);
|
|
1291
|
+
const coveredRowIds = new Set();
|
|
1292
|
+
let ownStories = 0;
|
|
1293
|
+
for (const key of [...stories.keys()].sort()) {
|
|
1294
|
+
if (claimed.has(key)) {
|
|
1295
|
+
for (const id of stories.get(key)) coveredRowIds.add(id);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
claimed.add(key);
|
|
1299
|
+
ownStories += 1;
|
|
1300
|
+
}
|
|
1301
|
+
coverage.set(term, { mints: storyCount === 0 || ownStories > 0, coveredRowIds });
|
|
1302
|
+
}
|
|
1303
|
+
return coverage;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** The entities this card's own article names, as terms the graph could hold
|
|
1307
|
+
* facts about. `articleEntityNames` reads them out of the text the card
|
|
1308
|
+
* already shows — each source's headline and the description beneath it — and
|
|
1309
|
+
* the same discipline the hub gate applies then filters them: no stop word, no
|
|
1310
|
+
* quantity, no class the graph's own identity rows name, nothing that reads as
|
|
1311
|
+
* a clause rather than a name. Sorted, so a card's background never depends on
|
|
1312
|
+
* the order the names came back in. */
|
|
1313
|
+
function cardArticleTerms(sources, articleEntityNames, { concepts, readsAsEntityTerm }) {
|
|
1314
|
+
const texts = [];
|
|
1315
|
+
for (const source of sources) {
|
|
1316
|
+
if (source.title) texts.push(source.title);
|
|
1317
|
+
if (source.summary) texts.push(source.summary);
|
|
1318
|
+
}
|
|
1319
|
+
if (!texts.length) return [];
|
|
1320
|
+
const terms = new Set();
|
|
1321
|
+
for (const name of articleEntityNames(texts) || []) {
|
|
1322
|
+
const term = normFactTerm(name);
|
|
1323
|
+
if (!term || STOP_SET.has(term) || isQuantityTerm(term) || concepts.has(term)) continue;
|
|
1324
|
+
if (!readsAsEntityTerm(term)) continue;
|
|
1325
|
+
terms.add(term);
|
|
1326
|
+
}
|
|
1327
|
+
return [...terms].sort();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
964
1330
|
/** newsworthyHubs -> one item per hub (PLAN_NEWS_FEED.md section 6.6),
|
|
965
1331
|
* paragraph included, sorted builtAt desc then id asc. `sourcesByFactId`
|
|
966
1332
|
* maps fact ids to snapshot source links ({ title, url, name, publishedAt?
|
|
@@ -977,19 +1343,57 @@ export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = nul
|
|
|
977
1343
|
* nothing else. The two-hop walk reaches every row a shared class node
|
|
978
1344
|
* touches, so attributing the whole sub-graph gave one quake's card all 44 of
|
|
979
1345
|
* the day's quake headlines. An "Around it" neighbour is context the card
|
|
980
|
-
* borrows, and it carries its own citation on its own card.
|
|
981
|
-
|
|
1346
|
+
* borrows, and it carries its own citation on its own card.
|
|
1347
|
+
*
|
|
1348
|
+
* Between the gate and the render sit two more reads over the same reported
|
|
1349
|
+
* rows: `titledHubs` names each card after the entity its own report grounds
|
|
1350
|
+
* rather than a clause the report threw off, and `storyCoverage` leaves a
|
|
1351
|
+
* publication with only the stories no other card tells. Both are pure over
|
|
1352
|
+
* the fact set, so a feed built from the same rows in two orders still comes
|
|
1353
|
+
* back byte for byte.
|
|
1354
|
+
*
|
|
1355
|
+
* `articleEntityNames`, when the caller supplies one, reads the entity names
|
|
1356
|
+
* out of the text of the sources a card shows (news.mjs wires the services
|
|
1357
|
+
* layer's own capture, the same one the enrichment ledger admits terms by).
|
|
1358
|
+
* Those entities widen the card's background: what the graph holds about a
|
|
1359
|
+
* name inside the headline now reaches the card, where before only the
|
|
1360
|
+
* endpoints of its own facts did. They never widen its report — the walk they
|
|
1361
|
+
* seed excludes every reported row — so what a card claims a source said is
|
|
1362
|
+
* untouched. */
|
|
1363
|
+
export function buildNewsItems(rows, {
|
|
1364
|
+
now, windowMs, limit = 6, sourcesByFactId = new Map(), readsAsEntityTerm, articleEntityNames = null,
|
|
1365
|
+
} = {}) {
|
|
982
1366
|
const reported = reportedRows(rows, { now, windowMs });
|
|
983
1367
|
const reportedIds = new Set(reported.map((r) => r.id));
|
|
984
1368
|
const adjacency = buildTermAdjacency(rows);
|
|
985
1369
|
const prior = priorTerms(rows);
|
|
986
1370
|
const hubOptions = { now, windowMs, limit, adjacency, prior };
|
|
987
1371
|
if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
|
|
988
|
-
const
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
1372
|
+
const rowsByTerm = reportedRowsByTerm(reported);
|
|
1373
|
+
const hubs = titledHubs(newsworthyHubs(rows, reported, hubOptions), rows, reported, rowsByTerm);
|
|
1374
|
+
const coverage = storyCoverage(hubs, rowsByTerm);
|
|
1375
|
+
const concepts = conceptTerms(rows);
|
|
1376
|
+
const namesEntities = readsAsEntityTerm || looksLikeEntityTerm;
|
|
1377
|
+
const items = hubs.filter(({ term }) => coverage.get(term).mints).map(({ term, changed }) => {
|
|
1378
|
+
const coveredRowIds = coverage.get(term).coveredRowIds;
|
|
1379
|
+
const hubRows = subgraphAround(rows, term, {
|
|
1380
|
+
adjacency,
|
|
1381
|
+
priorityIds: reportedIds,
|
|
1382
|
+
seedTerms: hubSeedTerms(term),
|
|
1383
|
+
excludeIds: coveredRowIds,
|
|
992
1384
|
});
|
|
1385
|
+
const sources = collectSources(hubReportRows(term, hubRows, { reportedIds }), sourcesByFactId);
|
|
1386
|
+
const articleTerms = articleEntityNames
|
|
1387
|
+
? cardArticleTerms(sources, articleEntityNames, { concepts, readsAsEntityTerm: namesEntities })
|
|
1388
|
+
: [];
|
|
1389
|
+
const heldIds = new Set(hubRows.map((r) => r.id));
|
|
1390
|
+
const articleRows = articleTerms.length
|
|
1391
|
+
? articleEntityRows(rows, articleTerms, {
|
|
1392
|
+
adjacency,
|
|
1393
|
+
excludeIds: new Set([...coveredRowIds, ...reportedIds]),
|
|
1394
|
+
}).filter((r) => !heldIds.has(r.id))
|
|
1395
|
+
: [];
|
|
1396
|
+
const subgraphRows = articleRows.length ? [...hubRows, ...articleRows] : hubRows;
|
|
993
1397
|
const factIds = subgraphRows.map((r) => r.id).sort();
|
|
994
1398
|
const { background } = splitCardRows(subgraphRows, reportedIds);
|
|
995
1399
|
return {
|
|
@@ -998,11 +1402,11 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
|
|
|
998
1402
|
factIds,
|
|
999
1403
|
changedCount: changed,
|
|
1000
1404
|
builtAt: now,
|
|
1001
|
-
paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds }),
|
|
1405
|
+
paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds, articleTerms }),
|
|
1002
1406
|
tier: tierOf(subgraphRows),
|
|
1003
|
-
sources
|
|
1407
|
+
sources,
|
|
1004
1408
|
background: background.map((r) => r.id).sort(),
|
|
1005
|
-
backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds }),
|
|
1409
|
+
backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds, articleTerms }),
|
|
1006
1410
|
};
|
|
1007
1411
|
});
|
|
1008
1412
|
return items.sort((a, b) => (toMs(b.builtAt) - toMs(a.builtAt)) || byId(a, b));
|