@polycode-projects/the-mechanical-code-talker 6.0.16 → 6.0.18
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 +15 -5
- package/src/domain/news-feed.mjs +381 -65
- package/src/services/news-viz.mjs +50 -1
- package/src/services/news.mjs +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
|
|
@@ -260,16 +260,24 @@ async function fetchFeedFormat(record, gate, { format, now }) {
|
|
|
260
260
|
* carries `wikibaseItem` when the article names one, past normalizeFeedItems
|
|
261
261
|
* (which only knows the snapshot's own fixed fields) so the news
|
|
262
262
|
* enrichment loop can short-circuit straight to the Wikidata KB source with
|
|
263
|
-
* the Q-id already in hand, no lookup needed.
|
|
263
|
+
* the Q-id already in hand, no lookup needed.
|
|
264
|
+
*
|
|
265
|
+
* Neither `news` nor `mostread` carries a per-article timestamp in the raw
|
|
266
|
+
* payload — the only date this feed exposes is the UTC calendar day the
|
|
267
|
+
* request itself names (the `/YYYY/MM/DD/` path Wikimedia selected these
|
|
268
|
+
* articles for), so `publishedAt` is stamped from whichever day's page
|
|
269
|
+
* actually answered (the primary day, or the prior day on a 404 retry),
|
|
270
|
+
* never from the fetch's own clock. */
|
|
264
271
|
async function fetchWikimediaFeed(record, gate, { now }) {
|
|
265
|
-
|
|
266
|
-
let body = await pacedFetchJson(gate,
|
|
272
|
+
let feedDay = now;
|
|
273
|
+
let body = await pacedFetchJson(gate, wikimediaFeedUrl(record.url, feedDay));
|
|
267
274
|
if (body === null) {
|
|
268
275
|
// A 404 means the day's page is not yet published; retry once against
|
|
269
276
|
// the previous UTC day before giving up.
|
|
270
277
|
const prevDay = new Date(now);
|
|
271
278
|
prevDay.setUTCDate(prevDay.getUTCDate() - 1);
|
|
272
|
-
|
|
279
|
+
feedDay = prevDay.toISOString();
|
|
280
|
+
body = await pacedFetchJson(gate, wikimediaFeedUrl(record.url, feedDay));
|
|
273
281
|
if (body === null) return null;
|
|
274
282
|
}
|
|
275
283
|
if (isNotModified(body)) return { items: [], bytes: 0, notModified: true };
|
|
@@ -280,12 +288,14 @@ async function fetchWikimediaFeed(record, gate, { now }) {
|
|
|
280
288
|
? body.mostread.articles
|
|
281
289
|
: [];
|
|
282
290
|
|
|
291
|
+
const { yyyy, mm, dd } = utcDateParts(feedDay);
|
|
292
|
+
const feedDayIso = `${yyyy}-${mm}-${dd}T00:00:00.000Z`;
|
|
283
293
|
const raw = articles.map((a) => ({
|
|
284
294
|
guid: a?.wikibase_item || a?.normalizedtitle || a?.title || "",
|
|
285
295
|
title: stripMarkup(a?.normalizedtitle || a?.displaytitle || a?.title || ""),
|
|
286
296
|
url: a?.content_urls?.desktop?.page || "",
|
|
287
297
|
summary: stripMarkup(a?.extract || ""),
|
|
288
|
-
publishedAt:
|
|
298
|
+
publishedAt: feedDayIso,
|
|
289
299
|
wikibaseItem: a?.wikibase_item || "",
|
|
290
300
|
}));
|
|
291
301
|
|
package/src/domain/news-feed.mjs
CHANGED
|
@@ -150,16 +150,24 @@ const hasNonEmptyArray = (value) => Array.isArray(value) && value.length > 0;
|
|
|
150
150
|
// after it.
|
|
151
151
|
const provenanceHead = (provenance) => String(provenance || "").trim().split(/\s+/)[0] || "";
|
|
152
152
|
|
|
153
|
+
/** True when the graph reasoned this row out for itself rather than reading it
|
|
154
|
+
* somewhere — an entailment head, an environment, or a justification chain.
|
|
155
|
+
* A card never speaks from one: the subClassOf closure gives a common noun
|
|
156
|
+
* every parent class of every sense it has ("earthquake is a kind of
|
|
157
|
+
* cognition, a tentacle, a christians"), which is sound as inference and
|
|
158
|
+
* useless as a sentence about today's quake. */
|
|
159
|
+
export function isDerivedRow(row) {
|
|
160
|
+
return provenanceHead(row?.provenance).startsWith("entailed:")
|
|
161
|
+
|| hasNonEmptyArray(row?.environments) || hasNonEmptyArray(row?.justification);
|
|
162
|
+
}
|
|
163
|
+
|
|
153
164
|
/** "derived" | "background" | "reported" for one row, pure over the row plus
|
|
154
165
|
* `now` (PLAN_NEWS_FEED.md section 17.3, step one). Rules apply in order,
|
|
155
166
|
* first hit wins: a syllogised row is derived; an identity, universal or
|
|
156
167
|
* non-news-provenance row is background; a news/news-fixture row with no
|
|
157
168
|
* readable or in-window stamp is background; everything else is reported. */
|
|
158
169
|
export function classifyNewsRow(row, { now, windowMs }) {
|
|
159
|
-
if (
|
|
160
|
-
|| hasNonEmptyArray(row.environments) || hasNonEmptyArray(row.justification)) {
|
|
161
|
-
return "derived";
|
|
162
|
-
}
|
|
170
|
+
if (isDerivedRow(row)) return "derived";
|
|
163
171
|
if (GATE_IDENTITY_PREDICATES.has(row.predicate)) return "background";
|
|
164
172
|
if (UNIVERSAL_QUANTIFIERS.has(String(row.quantifier || "").toLowerCase())) return "background";
|
|
165
173
|
if (!REPORT_PROVENANCE_RE.test(String(row.provenance || ""))) return "background";
|
|
@@ -485,6 +493,26 @@ export function buildTermAdjacency(rows) {
|
|
|
485
493
|
return { byTerm, terms };
|
|
486
494
|
}
|
|
487
495
|
|
|
496
|
+
// A source names a place by settlement and region at once — "mina, nevada",
|
|
497
|
+
// "pedro bay, alaska", "san juan, puerto rico". The region is the trailing
|
|
498
|
+
// part, and it is the half the graph plausibly already holds facts about,
|
|
499
|
+
// while the joined term is new. So the walk seeds from the region as well as
|
|
500
|
+
// the whole name. The leading part stays out: "mina" the town and "mina" the
|
|
501
|
+
// myna bird are one string to a graph keyed on words, and the settlement half
|
|
502
|
+
// is exactly where that collision lands. Comma-separated only — splitting on
|
|
503
|
+
// spaces would turn "public investments fund" into three terms that name
|
|
504
|
+
// nothing.
|
|
505
|
+
export function hubSeedTerms(hub) {
|
|
506
|
+
const whole = normFactTerm(hub);
|
|
507
|
+
const seeds = [whole];
|
|
508
|
+
if (!whole.includes(",")) return seeds;
|
|
509
|
+
const region = normFactTerm(whole.split(",").pop());
|
|
510
|
+
if (!region || region === whole) return seeds;
|
|
511
|
+
if (STOP_SET.has(region) || isQuantityTerm(region) || !looksLikeEntityTerm(region)) return seeds;
|
|
512
|
+
seeds.push(region);
|
|
513
|
+
return seeds;
|
|
514
|
+
}
|
|
515
|
+
|
|
488
516
|
/** Breadth-first over subject/object adjacency from `hub`, exactly `hops`
|
|
489
517
|
* levels deep, then capped: a `priorityIds` row first, then the nearer hop,
|
|
490
518
|
* then content-addressed id. The cap never depends on `rows`' own order, only
|
|
@@ -494,12 +522,20 @@ export function buildTermAdjacency(rows) {
|
|
|
494
522
|
* `priorityIds` is what keeps a card about a term the graph already knows
|
|
495
523
|
* thousands of things about from being built out of an arbitrary slice of
|
|
496
524
|
* them: a hub like "france" reaches far more rows than the cap, and the one
|
|
497
|
-
* report that made it news would otherwise be the row that fell out.
|
|
498
|
-
|
|
525
|
+
* report that made it news would otherwise be the row that fell out.
|
|
526
|
+
*
|
|
527
|
+
* `seedTerms` starts the walk from more than the hub itself (hubSeedTerms).
|
|
528
|
+
* Everything downstream that asks "is this the hub" — the report sentences,
|
|
529
|
+
* the sources, the neighbourhood — still reads the hub term alone, so a seed
|
|
530
|
+
* widens only what the card can draw background from. */
|
|
531
|
+
export function subgraphAround(rows, hub, {
|
|
532
|
+
hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null,
|
|
533
|
+
} = {}) {
|
|
499
534
|
const adj = adjacency ?? buildTermAdjacency(rows);
|
|
500
535
|
const hubTerm = normFactTerm(hub);
|
|
501
|
-
const
|
|
502
|
-
|
|
536
|
+
const seeds = (seedTerms?.length ? seedTerms : [hubTerm]).map((t) => normFactTerm(t)).filter(Boolean);
|
|
537
|
+
const visited = new Set(seeds);
|
|
538
|
+
let frontier = [...new Set(seeds)].sort();
|
|
503
539
|
const collected = new Map();
|
|
504
540
|
const hopOf = new Map();
|
|
505
541
|
for (let hop = 0; hop < hops; hop += 1) {
|
|
@@ -538,17 +574,38 @@ function tierOf(rows) {
|
|
|
538
574
|
return kinds[0] || "";
|
|
539
575
|
}
|
|
540
576
|
|
|
541
|
-
|
|
577
|
+
// How much of an item's own summary a card carries. A wire description runs
|
|
578
|
+
// about a line; an encyclopaedia extract runs several paragraphs, and a feed
|
|
579
|
+
// of fifty cards has a byte budget to keep (MAX_FEED_DOCUMENT_BYTES). Cut at
|
|
580
|
+
// the last word boundary inside the bound so the quote ends on a word.
|
|
581
|
+
const SOURCE_SUMMARY_MAX_CHARS = 400;
|
|
582
|
+
|
|
583
|
+
function clampSummary(summary) {
|
|
584
|
+
const text = String(summary || "").trim();
|
|
585
|
+
if (text.length <= SOURCE_SUMMARY_MAX_CHARS) return text;
|
|
586
|
+
const cut = text.slice(0, SOURCE_SUMMARY_MAX_CHARS);
|
|
587
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
588
|
+
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function collectSources(citedRows, sourcesByFactId) {
|
|
542
592
|
const get = (id) => (sourcesByFactId instanceof Map ? sourcesByFactId.get(id) : sourcesByFactId?.[id]);
|
|
543
593
|
const seen = new Set();
|
|
544
594
|
const sources = [];
|
|
545
|
-
for (const row of
|
|
595
|
+
for (const row of citedRows) {
|
|
546
596
|
const src = get(row.id);
|
|
547
597
|
if (!src) continue;
|
|
548
598
|
const key = src.url || src.title || "";
|
|
549
599
|
if (!key || seen.has(key)) continue;
|
|
550
600
|
seen.add(key);
|
|
551
|
-
|
|
601
|
+
const entry = { title: src.title || "", url: src.url || "", name: src.name || "" };
|
|
602
|
+
// Both of these ride along only when the snapshot actually has one — a
|
|
603
|
+
// card for an undated or summary-less snapshot shows nothing there rather
|
|
604
|
+
// than a blank field.
|
|
605
|
+
if (src.publishedAt) entry.publishedAt = src.publishedAt;
|
|
606
|
+
const summary = clampSummary(src.summary);
|
|
607
|
+
if (summary) entry.summary = summary;
|
|
608
|
+
sources.push(entry);
|
|
552
609
|
}
|
|
553
610
|
return sources;
|
|
554
611
|
}
|
|
@@ -559,14 +616,227 @@ function joinWithAnd(items) {
|
|
|
559
616
|
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
560
617
|
}
|
|
561
618
|
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// What one card reports, and whose neighbourhood it sits in.
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
/** An id membership test over a Set, an array, or null — null meaning "every
|
|
624
|
+
* row counts", which is what a caller with no reported-row set of its own
|
|
625
|
+
* (the background paragraph, a direct render call) needs. */
|
|
626
|
+
function idMembership(ids) {
|
|
627
|
+
if (ids === null || ids === undefined) return () => true;
|
|
628
|
+
if (ids instanceof Set) return (id) => ids.has(id);
|
|
629
|
+
return (id) => ids.includes(id);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const rowObservedRank = (row) => {
|
|
633
|
+
const t = rowObservedMs(row);
|
|
634
|
+
return Number.isFinite(t) ? t : 0;
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
/** The reported rows of `subgraphRows` that touch `hub` directly — what this
|
|
638
|
+
* card actually reports, and so what it may attribute a source to. A row
|
|
639
|
+
* further out in the two-hop walk was reached through a term the hub merely
|
|
640
|
+
* shares; it belongs to some other card's report. */
|
|
641
|
+
export function hubReportRows(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
642
|
+
const hubTerm = normFactTerm(hub);
|
|
643
|
+
const isReported = idMembership(reportedIds);
|
|
644
|
+
return subgraphRows.filter((row) => isReported(row.id)
|
|
645
|
+
&& (normFactTerm(row.subject) === hubTerm || normFactTerm(row.object) === hubTerm));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Every term sitting one edge from `hub` inside this card's own sub-graph. */
|
|
649
|
+
function hubLinkTerms(subgraphRows, hubTerm) {
|
|
650
|
+
const terms = new Set();
|
|
651
|
+
for (const row of subgraphRows) {
|
|
652
|
+
const s = normFactTerm(row.subject);
|
|
653
|
+
const o = normFactTerm(row.object);
|
|
654
|
+
if (s === hubTerm && o) terms.add(o);
|
|
655
|
+
else if (o === hubTerm && s) terms.add(s);
|
|
656
|
+
}
|
|
657
|
+
terms.delete(hubTerm);
|
|
658
|
+
return terms;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const NEIGHBOUR_ROW_LIMIT = 3;
|
|
662
|
+
|
|
663
|
+
/** This hub's own neighbourhood: the reported rows one further edge out,
|
|
664
|
+
* reached only through a link term specific enough to name a neighbourhood.
|
|
665
|
+
* A link term the clause cannot name in full — one reaching more rows than
|
|
666
|
+
* the sentence prints — is a category node, not a neighbour: "earthquake"
|
|
667
|
+
* sits between all 44 quakes of a day, so every quake card walked through it
|
|
668
|
+
* and printed the same arbitrary three. Naming a slice of a category is what
|
|
669
|
+
* made sibling cards identical, so a term over the clause's own capacity
|
|
670
|
+
* contributes nothing and a card with no specific link prints no "Around it"
|
|
671
|
+
* at all.
|
|
672
|
+
*
|
|
673
|
+
* Survivors rank by a predicate the hub's own report also used, then by
|
|
674
|
+
* observation time, then by id — this hub's choice, and a pure function of
|
|
675
|
+
* the fact set either way. */
|
|
676
|
+
export function neighbourRows(hub, subgraphRows, { reportedIds = null, limit = NEIGHBOUR_ROW_LIMIT } = {}) {
|
|
677
|
+
const hubTerm = normFactTerm(hub);
|
|
678
|
+
const isReported = idMembership(reportedIds);
|
|
679
|
+
const linkTerms = hubLinkTerms(subgraphRows, hubTerm);
|
|
680
|
+
|
|
681
|
+
const reachedByLinkTerm = new Map();
|
|
682
|
+
for (const row of subgraphRows) {
|
|
683
|
+
const s = normFactTerm(row.subject);
|
|
684
|
+
const o = normFactTerm(row.object);
|
|
685
|
+
if (s === hubTerm || o === hubTerm || !isReported(row.id)) continue;
|
|
686
|
+
for (const term of new Set([s, o])) {
|
|
687
|
+
if (!term || !linkTerms.has(term)) continue;
|
|
688
|
+
let reached = reachedByLinkTerm.get(term);
|
|
689
|
+
if (!reached) reachedByLinkTerm.set(term, (reached = []));
|
|
690
|
+
reached.push(row);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const candidates = new Map();
|
|
695
|
+
for (const [, reached] of reachedByLinkTerm) {
|
|
696
|
+
if (reached.length > limit) continue;
|
|
697
|
+
for (const row of reached) candidates.set(row.id, row);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const hubPredicates = new Set(hubReportRows(hub, subgraphRows, { reportedIds }).map((r) => r.predicate));
|
|
701
|
+
return [...candidates.values()]
|
|
702
|
+
.sort((a, b) => (Number(hubPredicates.has(b.predicate)) - Number(hubPredicates.has(a.predicate)))
|
|
703
|
+
|| (rowObservedRank(b) - rowObservedRank(a))
|
|
704
|
+
|| byId(a, b))
|
|
705
|
+
.slice(0, limit);
|
|
706
|
+
}
|
|
707
|
+
|
|
562
708
|
const IDENTITY_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
|
|
563
|
-
const SENTENCE_CAP =
|
|
709
|
+
const SENTENCE_CAP = 6;
|
|
710
|
+
// The four blocks a card's paragraph is built from, in the order they read:
|
|
711
|
+
// what was reported, then what the thing is, then what the graph already knew
|
|
712
|
+
// about it, then its neighbourhood. Their caps add up past SENTENCE_CAP on
|
|
713
|
+
// purpose — the paragraph fills from the front, so a news-rich card spends its
|
|
714
|
+
// budget on the news and a quiet one spends it on background.
|
|
715
|
+
const REPORT_SENTENCE_CAP = 4;
|
|
716
|
+
const IDENTITY_SENTENCE_CAP = 1;
|
|
717
|
+
const KNOWN_FACT_SENTENCE_CAP = 2;
|
|
564
718
|
// How many objects one sentence names before it counts the rest. A live source
|
|
565
719
|
// reports the same relation over and over inside one window — every quake of
|
|
566
720
|
// the day strikes near somewhere — and an unbounded list turns a card into a
|
|
567
721
|
// wall of text.
|
|
568
722
|
const OBJECTS_PER_SENTENCE = 6;
|
|
569
723
|
|
|
724
|
+
// A term the graph says is more than this many things is read across senses:
|
|
725
|
+
// "earthquake" is a natural event, a cognition, a social station and nine more,
|
|
726
|
+
// so no single class line about it is trustworthy on a card about one quake.
|
|
727
|
+
// Same discipline as the neighbourhood's own category-node test — a node too
|
|
728
|
+
// wide for one clause to name in full says nothing — applied to senses instead
|
|
729
|
+
// of edges.
|
|
730
|
+
const IDENTITY_MAX_CLASSES = 2;
|
|
731
|
+
// A background line's far side, when this many terms in the sub-graph already
|
|
732
|
+
// fall under it, names a category rather than anything about this card:
|
|
733
|
+
// "france is related to place" is true of most of the graph. The hub's own
|
|
734
|
+
// identity clause is exempt — a crowded class is still this thing's own kind,
|
|
735
|
+
// and "france is a country" is the most useful line a card can carry.
|
|
736
|
+
const CATEGORY_FAN_MAX = 3;
|
|
737
|
+
// How many background rows the "what the graph already knew" disclosure names.
|
|
738
|
+
// The paragraph itself shows the first KNOWN_FACT_SENTENCE_CAP groups of these.
|
|
739
|
+
const KNOWN_FACT_ROW_LIMIT = 6;
|
|
740
|
+
|
|
741
|
+
/** Three fan-out readings of the identity rows inside one card's sub-graph.
|
|
742
|
+
* `senseFan` counts every class a term is said to BE, the entailment closure
|
|
743
|
+
* included — high means the graph reads the term across senses, which is what
|
|
744
|
+
* makes a common noun a poor subject for a card about one event.
|
|
745
|
+
* `sourcedSenseFan` counts only the classes something actually stated, which
|
|
746
|
+
* is what a printed "X is a Y" clause may draw on. `categoryFan` counts the
|
|
747
|
+
* distinct terms said to fall UNDER a term — high means a category node, the
|
|
748
|
+
* same reading the neighbourhood's own link-term test makes. All three are
|
|
749
|
+
* pure counts over the rows handed in, so a card's own sub-graph decides and
|
|
750
|
+
* no global blocklist is involved. */
|
|
751
|
+
function identityFans(subgraphRows) {
|
|
752
|
+
const senseFan = new Map();
|
|
753
|
+
const sourcedSenseFan = new Map();
|
|
754
|
+
const membersByClass = new Map();
|
|
755
|
+
for (const row of subgraphRows) {
|
|
756
|
+
if (!IDENTITY_PREDICATES.has(row.predicate)) continue;
|
|
757
|
+
const s = normFactTerm(row.subject);
|
|
758
|
+
const o = normFactTerm(row.object);
|
|
759
|
+
if (!s || !o) continue;
|
|
760
|
+
senseFan.set(s, (senseFan.get(s) || 0) + 1);
|
|
761
|
+
if (!isDerivedRow(row)) sourcedSenseFan.set(s, (sourcedSenseFan.get(s) || 0) + 1);
|
|
762
|
+
let members = membersByClass.get(o);
|
|
763
|
+
if (!members) membersByClass.set(o, (members = new Set()));
|
|
764
|
+
members.add(s);
|
|
765
|
+
}
|
|
766
|
+
const categoryFan = new Map();
|
|
767
|
+
for (const [term, members] of membersByClass) categoryFan.set(term, members.size);
|
|
768
|
+
return { senseFan, sourcedSenseFan, categoryFan };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** What this card is ABOUT: its hub, the region of a hub the source spelled
|
|
772
|
+
* "settlement, region", and the other terms its own report sentences name —
|
|
773
|
+
* minus any of those that reads across senses. A quake card's report
|
|
774
|
+
* names "earthquake" and a place; the class term is where the graph's
|
|
775
|
+
* knowledge is thinnest and its senses widest, so background drawn through it
|
|
776
|
+
* 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
|
+
function cardSubjectTerms(hub, subgraphRows, { reportedIds = null, senseFan }) {
|
|
779
|
+
const hubTerm = normFactTerm(hub);
|
|
780
|
+
const isReported = idMembership(reportedIds);
|
|
781
|
+
const terms = new Set(hubSeedTerms(hubTerm));
|
|
782
|
+
for (const row of hubReportRows(hubTerm, subgraphRows, { reportedIds })) {
|
|
783
|
+
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
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return terms;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** The background rows worth telling a reader about, ranked: a row touching
|
|
795
|
+
* one of this card's own subject terms, whose other side is not a category
|
|
796
|
+
* node, and — for an identity row — whose subject is not read across senses.
|
|
797
|
+
* Ranks the hub's own rows first, then by how specific the other side is,
|
|
798
|
+
* then by content-addressed id, so the same fact set always yields the same
|
|
799
|
+
* lines in the same order. */
|
|
800
|
+
export function knownFactRows(hub, subgraphRows, { reportedIds = null, limit = KNOWN_FACT_ROW_LIMIT } = {}) {
|
|
801
|
+
const hubTerm = normFactTerm(hub);
|
|
802
|
+
const isReported = idMembership(reportedIds);
|
|
803
|
+
const { senseFan, sourcedSenseFan, categoryFan } = identityFans(subgraphRows);
|
|
804
|
+
const subjects = cardSubjectTerms(hub, subgraphRows, { reportedIds, senseFan });
|
|
805
|
+
const neighbourIds = new Set(neighbourRows(hub, subgraphRows, { reportedIds }).map((r) => r.id));
|
|
806
|
+
|
|
807
|
+
const scored = [];
|
|
808
|
+
for (const row of subgraphRows) {
|
|
809
|
+
if (isReported(row.id) || neighbourIds.has(row.id) || isDerivedRow(row)) continue;
|
|
810
|
+
const s = normFactTerm(row.subject);
|
|
811
|
+
const o = normFactTerm(row.object);
|
|
812
|
+
if (!s || !o || s === o) continue;
|
|
813
|
+
const anchorIsSubject = subjects.has(s);
|
|
814
|
+
if (!anchorIsSubject && !subjects.has(o)) continue;
|
|
815
|
+
const anchor = anchorIsSubject ? s : o;
|
|
816
|
+
const other = anchorIsSubject ? o : s;
|
|
817
|
+
if (IDENTITY_PREDICATES.has(row.predicate)) {
|
|
818
|
+
// What the hub itself IS belongs to the identity clause and is said once.
|
|
819
|
+
if (s === hubTerm) continue;
|
|
820
|
+
if ((sourcedSenseFan.get(s) || 0) > IDENTITY_MAX_CLASSES) continue;
|
|
821
|
+
}
|
|
822
|
+
if ((categoryFan.get(other) || 0) > CATEGORY_FAN_MAX) continue;
|
|
823
|
+
scored.push({
|
|
824
|
+
row,
|
|
825
|
+
anchorIsHub: anchor === hubTerm,
|
|
826
|
+
otherCategoryFan: categoryFan.get(other) || 0,
|
|
827
|
+
otherSenseFan: senseFan.get(other) || 0,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return scored
|
|
832
|
+
.sort((a, b) => (Number(b.anchorIsHub) - Number(a.anchorIsHub))
|
|
833
|
+
|| (a.otherCategoryFan - b.otherCategoryFan)
|
|
834
|
+
|| (a.otherSenseFan - b.otherSenseFan)
|
|
835
|
+
|| byId(a.row, b.row))
|
|
836
|
+
.slice(0, limit)
|
|
837
|
+
.map((entry) => entry.row);
|
|
838
|
+
}
|
|
839
|
+
|
|
570
840
|
function joinObjects(objects) {
|
|
571
841
|
if (objects.length <= OBJECTS_PER_SENTENCE) return joinWithAnd(objects);
|
|
572
842
|
const shown = objects.slice(0, OBJECTS_PER_SENTENCE);
|
|
@@ -584,86 +854,130 @@ function predicatesInRenderOrder(rows) {
|
|
|
584
854
|
return [...curated, ...rest];
|
|
585
855
|
}
|
|
586
856
|
|
|
587
|
-
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
857
|
+
/** One sentence per (subject, predicate) group over `rows`, in the order the
|
|
858
|
+
* 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
|
+
function groupedFactSentences(rows) {
|
|
861
|
+
const groups = new Map();
|
|
862
|
+
for (const row of rows) {
|
|
863
|
+
const key = `${row.subject}${row.predicate}`;
|
|
864
|
+
let group = groups.get(key);
|
|
865
|
+
if (!group) groups.set(key, (group = { subject: row.subject, predicate: row.predicate, objects: [] }));
|
|
866
|
+
group.objects.push(row.object);
|
|
867
|
+
}
|
|
868
|
+
return [...groups.values()].map(({ subject, predicate, objects }) => {
|
|
869
|
+
const sorted = [...objects].sort();
|
|
870
|
+
const rendered = IDENTITY_PREDICATES.has(predicate)
|
|
871
|
+
? sorted.map((object) => `${articleFor(object)} ${object}`)
|
|
872
|
+
: sorted;
|
|
873
|
+
return `${subject} ${predicatePhrase(predicate, subject)} ${joinObjects(rendered)}`;
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** The sentences a card's paragraph is made of, as four ordered blocks: the
|
|
878
|
+
* report (what a source said inside the window), the identity clause, the
|
|
879
|
+
* related facts the graph already held, and the neighbourhood. Callers that
|
|
880
|
+
* render only one block — the "what the graph already knew" disclosure —
|
|
881
|
+
* read the block they want instead of re-deriving it. */
|
|
882
|
+
function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
600
883
|
const hubTerm = normFactTerm(hub);
|
|
601
|
-
const isReported = reportedIds
|
|
602
|
-
? () => true
|
|
603
|
-
: (id) => (reportedIds instanceof Set ? reportedIds.has(id) : reportedIds.includes(id));
|
|
884
|
+
const isReported = idMembership(reportedIds);
|
|
604
885
|
const hubRows = subgraphRows.filter((r) => normFactTerm(r.subject) === hubTerm);
|
|
605
886
|
const reportedHubRows = hubRows.filter((r) => isReported(r.id));
|
|
606
|
-
const
|
|
607
|
-
(r) => normFactTerm(r.subject) !== hubTerm && normFactTerm(r.object) !== hubTerm && isReported(r.id),
|
|
608
|
-
);
|
|
609
|
-
|
|
610
|
-
const sentences = [];
|
|
611
|
-
|
|
612
|
-
const identityObjects = hubRows
|
|
613
|
-
.filter((r) => IDENTITY_PREDICATES.has(r.predicate))
|
|
614
|
-
.map((r) => r.object)
|
|
615
|
-
.sort();
|
|
616
|
-
if (identityObjects.length) {
|
|
617
|
-
const withArticles = identityObjects.map((object) => `${articleFor(object)} ${object}`);
|
|
618
|
-
sentences.push(`${hub} is ${joinObjects(withArticles)}`);
|
|
619
|
-
}
|
|
620
|
-
|
|
887
|
+
const report = [];
|
|
621
888
|
for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
|
|
622
|
-
if (IDENTITY_PREDICATES.has(predicate) ||
|
|
889
|
+
if (IDENTITY_PREDICATES.has(predicate) || report.length >= REPORT_SENTENCE_CAP) continue;
|
|
623
890
|
const objects = reportedHubRows
|
|
624
891
|
.filter((r) => r.predicate === predicate)
|
|
625
892
|
.map((r) => r.object)
|
|
626
893
|
.sort();
|
|
627
894
|
if (!objects.length) continue;
|
|
628
|
-
|
|
895
|
+
report.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
|
|
629
896
|
}
|
|
630
897
|
|
|
631
898
|
// A hub that only ever appears as an OBJECT — the place a quake struck, the
|
|
632
899
|
// story a site discussed — has no subject-side row to build a sentence from,
|
|
633
900
|
// and its card came out blank. What was reported about it still says
|
|
634
901
|
// something, so those rows render whole, subject and all.
|
|
635
|
-
if (!
|
|
902
|
+
if (!report.length) {
|
|
636
903
|
const aboutHub = subgraphRows
|
|
637
904
|
.filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
|
|
638
905
|
.sort(byId)
|
|
639
906
|
.slice(0, OBJECTS_PER_SENTENCE)
|
|
640
907
|
.map((r) => factSentence(r));
|
|
641
|
-
if (aboutHub.length)
|
|
908
|
+
if (aboutHub.length) report.push(aboutHub.join("; "));
|
|
642
909
|
}
|
|
643
910
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
911
|
+
// The identity clause follows the news, never leads it, and only when
|
|
912
|
+
// something actually stated the hub's kind in one sense. The entailment
|
|
913
|
+
// closure names thirteen classes for "france" — a list nobody asked for,
|
|
914
|
+
// most of it the wrong sense — so it opens no card.
|
|
915
|
+
const identity = [];
|
|
916
|
+
const identityObjects = hubRows
|
|
917
|
+
.filter((r) => IDENTITY_PREDICATES.has(r.predicate) && !isDerivedRow(r))
|
|
918
|
+
.map((r) => r.object)
|
|
919
|
+
.sort();
|
|
920
|
+
const identityIsSingleSense = identityObjects.length > 0 && identityObjects.length <= IDENTITY_MAX_CLASSES;
|
|
921
|
+
if (identityIsSingleSense) {
|
|
922
|
+
identity.push(`${hub} is ${joinObjects(identityObjects.map((object) => `${articleFor(object)} ${object}`))}`);
|
|
652
923
|
}
|
|
653
924
|
|
|
654
|
-
const
|
|
655
|
-
|
|
925
|
+
const known = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds }));
|
|
926
|
+
|
|
927
|
+
const neighbours = neighbourRows(hub, subgraphRows, { reportedIds });
|
|
928
|
+
const around = neighbours.length ? [`Around it: ${neighbours.map((r) => factSentence(r)).join("; ")}`] : [];
|
|
929
|
+
|
|
930
|
+
return { report, identity, known, around };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** A card's paragraph: what a source reported, then what the thing is, then
|
|
934
|
+
* the related facts the graph already held about it, then its own
|
|
935
|
+
* neighbourhood (neighbourRows). Every sentence shown is a grounded fact,
|
|
936
|
+
* never a paraphrase of prose the grammar could not read, and the news always
|
|
937
|
+
* leads.
|
|
938
|
+
*
|
|
939
|
+
* `reportedIds` (PLAN_NEWS_FEED.md section 17.4), when given, splits the rows
|
|
940
|
+
* into what was reported (the lead sentences) and what the graph already held
|
|
941
|
+
* (the background ones). Defaults to null, meaning every row counts as
|
|
942
|
+
* reported. */
|
|
943
|
+
export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
|
|
944
|
+
const { report, identity, known, around } = paragraphBlocks(hub, subgraphRows, { reportedIds });
|
|
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);
|
|
951
|
+
return sentences.length ? `${sentences.join(". ")}.` : "";
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** The "what the graph already knew" disclosure: the same related facts the
|
|
955
|
+
* paragraph leads with, at the disclosure's own fuller depth, and nothing the
|
|
956
|
+
* card already reported. Empty when the graph held nothing about this card's
|
|
957
|
+
* own subjects — a card with no background says so rather than filling the
|
|
958
|
+
* 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 }));
|
|
961
|
+
return sentences.length ? `${sentences.join(". ")}.` : "";
|
|
656
962
|
}
|
|
657
963
|
|
|
658
964
|
/** newsworthyHubs -> one item per hub (PLAN_NEWS_FEED.md section 6.6),
|
|
659
965
|
* paragraph included, sorted builtAt desc then id asc. `sourcesByFactId`
|
|
660
|
-
* maps fact ids to snapshot source links ({ title, url, name
|
|
661
|
-
*
|
|
966
|
+
* maps fact ids to snapshot source links ({ title, url, name, publishedAt?
|
|
967
|
+
* }); publishedAt is present only when the source snapshot carried one.
|
|
968
|
+
* The gate (PLAN_NEWS_FEED.md section 17): `reportedRows` replaces `newsWindowRows`
|
|
662
969
|
* and `newsworthyHubs` replaces `scoreHubs` as this function's own inputs —
|
|
663
970
|
* both keep their prior behaviour for every other caller. Each item's
|
|
664
|
-
* two-hop sub-graph then splits into its own `reported`/`background` rows
|
|
665
|
-
*
|
|
666
|
-
* `backgroundParagraph`
|
|
971
|
+
* two-hop sub-graph then splits into its own `reported`/`background` rows:
|
|
972
|
+
* the report leads the paragraph, the related background follows it, and
|
|
973
|
+
* `backgroundParagraph` names that background at its own fuller depth for the
|
|
974
|
+
* card's disclosure.
|
|
975
|
+
*
|
|
976
|
+
* A card attributes a source to the rows it reports (hubReportRows) and to
|
|
977
|
+
* nothing else. The two-hop walk reaches every row a shared class node
|
|
978
|
+
* touches, so attributing the whole sub-graph gave one quake's card all 44 of
|
|
979
|
+
* 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. */
|
|
667
981
|
export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId = new Map(), readsAsEntityTerm } = {}) {
|
|
668
982
|
const reported = reportedRows(rows, { now, windowMs });
|
|
669
983
|
const reportedIds = new Set(reported.map((r) => r.id));
|
|
@@ -673,7 +987,9 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
|
|
|
673
987
|
if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
|
|
674
988
|
const hubs = newsworthyHubs(rows, reported, hubOptions);
|
|
675
989
|
const items = hubs.map(({ term, changed }) => {
|
|
676
|
-
const subgraphRows = subgraphAround(rows, term, {
|
|
990
|
+
const subgraphRows = subgraphAround(rows, term, {
|
|
991
|
+
adjacency, priorityIds: reportedIds, seedTerms: hubSeedTerms(term),
|
|
992
|
+
});
|
|
677
993
|
const factIds = subgraphRows.map((r) => r.id).sort();
|
|
678
994
|
const { background } = splitCardRows(subgraphRows, reportedIds);
|
|
679
995
|
return {
|
|
@@ -684,9 +1000,9 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
|
|
|
684
1000
|
builtAt: now,
|
|
685
1001
|
paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds }),
|
|
686
1002
|
tier: tierOf(subgraphRows),
|
|
687
|
-
sources: collectSources(subgraphRows, sourcesByFactId),
|
|
1003
|
+
sources: collectSources(hubReportRows(term, subgraphRows, { reportedIds }), sourcesByFactId),
|
|
688
1004
|
background: background.map((r) => r.id).sort(),
|
|
689
|
-
backgroundParagraph:
|
|
1005
|
+
backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds }),
|
|
690
1006
|
};
|
|
691
1007
|
});
|
|
692
1008
|
return items.sort((a, b) => (toMs(b.builtAt) - toMs(a.builtAt)) || byId(a, b));
|
|
@@ -150,7 +150,14 @@ ${THEME_TOKENS_CSS}
|
|
|
150
150
|
.item .tier { font-family: ${MONO_STACK}; font-size: .64rem; padding: .05rem .5rem; border-radius: 99px; border: 1px solid var(--line); margin-left: .5rem; }
|
|
151
151
|
.item .newtag { font-family: ${MONO_STACK}; font-size: .64rem; color: var(--taught); margin-left: .5rem; }
|
|
152
152
|
.item .paragraph { margin: .4rem 0; }
|
|
153
|
+
.item .report { margin: .5rem 0; padding: .35rem 0 .35rem .7rem; border-left: 2px solid var(--line); }
|
|
154
|
+
.item .report + .report { margin-top: .35rem; }
|
|
155
|
+
.item .reportheadline { display: block; font-weight: 600; font-size: .88rem; }
|
|
156
|
+
.item .reportsummary { margin: .2rem 0 0; font-size: .84rem; color: var(--ink); }
|
|
157
|
+
.item .reportcite { display: block; margin-top: .2rem; font-family: ${MONO_STACK}; font-size: .64rem; color: var(--muted); font-style: normal; }
|
|
158
|
+
.item .reportmore { font-size: .74rem; color: var(--muted); }
|
|
153
159
|
.item .sources-links { font-size: .74rem; color: var(--muted); }
|
|
160
|
+
.item .sourcedate { font-family: ${MONO_STACK}; }
|
|
154
161
|
.item details.facts summary { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--corpus); cursor: pointer; }
|
|
155
162
|
.item details.background summary { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); cursor: pointer; }
|
|
156
163
|
.item details.background p { margin: .35rem 0 0; color: var(--muted); }
|
|
@@ -482,12 +489,53 @@ ${NEWS_STYLE}
|
|
|
482
489
|
});
|
|
483
490
|
}
|
|
484
491
|
|
|
492
|
+
// The earliest publication date among a card's sources — a lexicographic
|
|
493
|
+
// min over ISO-8601 UTC strings sorts chronologically without parsing, and
|
|
494
|
+
// "earliest" reads as when the reported event actually happened rather
|
|
495
|
+
// than whichever source snapshot the subgraph walk reached last. A source
|
|
496
|
+
// with no publishedAt (its feed never carries one) never contributes here,
|
|
497
|
+
// so a card built entirely from undated sources shows no date at all.
|
|
498
|
+
function earliestSourceDate(sources) {
|
|
499
|
+
let earliest = null;
|
|
500
|
+
for (const s of sources || []) {
|
|
501
|
+
if (!s.publishedAt) continue;
|
|
502
|
+
if (earliest === null || s.publishedAt < earliest) earliest = s.publishedAt;
|
|
503
|
+
}
|
|
504
|
+
return earliest;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// How many backing reports a card sets out in full before it counts the
|
|
508
|
+
// rest. After the per-card attribution cut a card cites one or two items;
|
|
509
|
+
// the shared-subject card ("earthquake") still cites dozens, and quoting
|
|
510
|
+
// every one of them buries its own paragraph.
|
|
511
|
+
var REPORTS_SHOWN_PER_CARD = 3;
|
|
512
|
+
|
|
513
|
+
// The report a card was built from, in the source's own framing: its
|
|
514
|
+
// headline, its description, and who filed it when. Set off from the
|
|
515
|
+
// graph's own sentences so a reader can always tell which is which.
|
|
516
|
+
function reportBlockHtml(sources) {
|
|
517
|
+
const shown = (sources || []).slice(0, REPORTS_SHOWN_PER_CARD);
|
|
518
|
+
var html = shown.map(function (s) {
|
|
519
|
+
const headline = s.title ? '<span class="reportheadline">' + esc(s.title) + "</span>" : "";
|
|
520
|
+
const summary = s.summary ? '<p class="reportsummary">' + esc(s.summary) + "</p>" : "";
|
|
521
|
+
if (!headline && !summary) return "";
|
|
522
|
+
const cite = [s.name, s.publishedAt ? String(s.publishedAt).slice(0, 10) : ""].filter(Boolean).join(", ");
|
|
523
|
+
return '<blockquote class="report">' + headline + summary
|
|
524
|
+
+ (cite ? '<cite class="reportcite">' + esc(cite) + "</cite>" : "") + "</blockquote>";
|
|
525
|
+
}).join("");
|
|
526
|
+
const more = (sources || []).length - shown.length;
|
|
527
|
+
if (more > 0) html += '<p class="reportmore">…and ' + more + ' more report' + (more === 1 ? "" : "s") + "</p>";
|
|
528
|
+
return html;
|
|
529
|
+
}
|
|
530
|
+
|
|
485
531
|
function cardHtml(item) {
|
|
486
532
|
const factLines = item.factLines || [];
|
|
487
533
|
const factsHtml = factLines.map(function (line) { return '<div class="factrow">' + esc(line) + '</div>'; }).join("");
|
|
488
534
|
const moreCount = (item.factCount || 0) - factLines.length;
|
|
489
535
|
const moreHtml = moreCount > 0 ? '<div class="factrow factmore">…and ' + moreCount + ' more</div>' : "";
|
|
490
536
|
const sourcesText = (item.sources || []).map(function (s) { return esc(s.title || s.url || ""); }).filter(Boolean).join(", ");
|
|
537
|
+
const sourceDate = earliestSourceDate(item.sources);
|
|
538
|
+
const dateText = sourceDate ? ' <span class="sourcedate">(' + esc(sourceDate.slice(0, 10)) + ')</span>' : "";
|
|
491
539
|
const background = item.backgroundParagraph
|
|
492
540
|
? '<details class="background"><summary>what the graph already knew</summary><p>' + esc(item.backgroundParagraph) + '</p></details>'
|
|
493
541
|
: "";
|
|
@@ -495,8 +543,9 @@ ${NEWS_STYLE}
|
|
|
495
543
|
return '<div class="item" data-item-id="' + esc(item.id) + '">'
|
|
496
544
|
+ '<span class="hub">' + esc(item.hub) + '</span><span class="tier">' + esc(item.tier || "unranked") + '</span>' + newTag
|
|
497
545
|
+ '<p class="paragraph">' + esc(item.paragraph) + '</p>'
|
|
546
|
+
+ reportBlockHtml(item.sources)
|
|
498
547
|
+ background
|
|
499
|
-
+ (sourcesText ? '<p class="sources-links">sources: ' + sourcesText + '</p>' : "")
|
|
548
|
+
+ (sourcesText ? '<p class="sources-links">sources: ' + sourcesText + dateText + '</p>' : "")
|
|
500
549
|
+ '<details class="facts"><summary>' + (item.factCount || 0) + ' fact' + (item.factCount === 1 ? "" : "s") + '</summary>' + factsHtml + moreHtml + '</details>'
|
|
501
550
|
+ '</div>';
|
|
502
551
|
}
|
package/src/services/news.mjs
CHANGED
|
@@ -287,7 +287,19 @@ function buildSourcesByFactId(items) {
|
|
|
287
287
|
const recordsById = new Map(newsSourceRecords().map((r) => [r.id, r]));
|
|
288
288
|
for (const snap of items || []) {
|
|
289
289
|
const record = recordsById.get(snap.sourceId);
|
|
290
|
-
const src = {
|
|
290
|
+
const src = {
|
|
291
|
+
title: snap.title || "",
|
|
292
|
+
url: snap.url || "",
|
|
293
|
+
name: record?.name || snap.sourceId || "",
|
|
294
|
+
// The item's own description, carried so a card can show the report it
|
|
295
|
+
// was built from beside the sentences the graph read out of it. Bounded
|
|
296
|
+
// downstream (collectSources), never rewritten here.
|
|
297
|
+
summary: snap.summary || "",
|
|
298
|
+
};
|
|
299
|
+
// A snapshot with no publication timestamp (a source whose own feed never
|
|
300
|
+
// carries one) leaves the key off entirely — never an invented or blank
|
|
301
|
+
// date, and never the fetch's own clock standing in for it.
|
|
302
|
+
if (snap.publishedAt) src.publishedAt = snap.publishedAt;
|
|
291
303
|
for (const factId of snap.factIds || []) map.set(factId, src);
|
|
292
304
|
}
|
|
293
305
|
return map;
|
|
@@ -922,7 +934,13 @@ function renderFeedText(feed, focus) {
|
|
|
922
934
|
const sourceNames = it.sources.map((s) => s.title || s.url).filter(Boolean);
|
|
923
935
|
const sourcesText = sourceNames.length ? ` sources: ${sourceNames.join(", ")}` : "";
|
|
924
936
|
const backgroundText = it.backgroundParagraph ? ` what the graph already knew: ${it.backgroundParagraph}` : "";
|
|
925
|
-
|
|
937
|
+
// The report the card was built from, quoted, so a reader can check the
|
|
938
|
+
// graph's own sentences against what the source actually filed.
|
|
939
|
+
const filed = it.sources
|
|
940
|
+
.map((s) => [s.title, s.summary].filter(Boolean).join(" — "))
|
|
941
|
+
.filter(Boolean);
|
|
942
|
+
const filedText = filed.length ? ` as filed: "${filed[0]}"` : "";
|
|
943
|
+
return `${i + 1}. ${it.paragraph} (${it.tier || "unranked"})${filedText}${sourcesText}${seedTag}${backgroundText}`;
|
|
926
944
|
})
|
|
927
945
|
.join("\n");
|
|
928
946
|
}
|