@polycode-projects/the-mechanical-code-talker 6.0.15 → 6.0.17
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 +15 -5
- package/src/domain/fact-phrase.mjs +65 -8
- package/src/domain/news-feed.mjs +54 -4
- package/src/domain/term-ledger.mjs +38 -4
- package/src/services/news-viz.mjs +19 -1
- package/src/services/news.mjs +102 -35
- package/src/surfaces/web/memory-ask-browser.bundle.js +63 -63
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.17",
|
|
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.",
|
|
@@ -118,6 +118,8 @@
|
|
|
118
118
|
"gen:screenshots": "node scripts/gen-screenshots.mjs",
|
|
119
119
|
"gen:og-images": "node scripts/gen-og-images.mjs",
|
|
120
120
|
"probe:news-sources": "node scripts/probe-news-sources.mjs",
|
|
121
|
+
"bench:news": "node scripts/news-bench/run.mjs --seed=xl",
|
|
122
|
+
"bench:news:fast": "node scripts/news-bench/run.mjs --seed=fixture",
|
|
121
123
|
"check:links": "node scripts/check-links.mjs",
|
|
122
124
|
"check:pii": "node scripts/pii-lint.mjs",
|
|
123
125
|
"check:pack": "node scripts/check-pack-manifest.mjs",
|
|
@@ -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
|
|
|
@@ -117,6 +117,41 @@ export function gerundVerbSurface(verb) {
|
|
|
117
117
|
return /[^aeiou]e$/i.test(base) && base.length > 2 ? `${base.slice(0, -1)}ing` : `${base}ing`;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** Irregular plural nouns whose surface carries no "-s" at all, so the suffix
|
|
121
|
+
* check in isSubjectPlural below would read them as singular. A small closed
|
|
122
|
+
* table, not this file's own pluralizer — it answers one question only
|
|
123
|
+
* ("is this head noun plural"), never generates a form. */
|
|
124
|
+
const IRREGULAR_PLURAL_NOUNS = new Set([
|
|
125
|
+
"people", "men", "women", "children", "mice", "geese", "feet", "teeth", "oxen",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/** Nouns that end in "s" but stay grammatically SINGULAR ("the news
|
|
129
|
+
* spreads", not "the news spread") — the suffix check below would otherwise
|
|
130
|
+
* misread them as plural. */
|
|
131
|
+
const SINGULAR_NOUNS_ENDING_S = new Set([
|
|
132
|
+
"news", "physics", "species", "series", "means", "measles", "mathematics", "politics", "economics",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Is a stored fact's SUBJECT text grammatically plural, for the one thing
|
|
137
|
+
* this file needs it for: choosing a minted verb's surface form. Reads the
|
|
138
|
+
* HEAD noun only — the word right after any leading article, before a
|
|
139
|
+
* trailing "of ..." phrase, so "the group of scientists" agrees on "group"
|
|
140
|
+
* ("the group of scientists reports"), not "scientists". From there: the
|
|
141
|
+
* closed irregular table above, then the regular "-s" suffix, same
|
|
142
|
+
* naive-morphology trade thirdPersonSingularSurface already takes above. A
|
|
143
|
+
* subject this can't read (empty, or a plural-invariant noun like "sheep")
|
|
144
|
+
* defaults to singular — English's own unmarked form, and also this file's
|
|
145
|
+
* pre-existing default for every caller that passes no subject at all.
|
|
146
|
+
*/
|
|
147
|
+
export function isSubjectPlural(subject) {
|
|
148
|
+
const head = String(subject || "").trim().replace(/^(?:the|a|an)\s+/i, "").split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
149
|
+
if (!head) return false;
|
|
150
|
+
if (IRREGULAR_PLURAL_NOUNS.has(head)) return true;
|
|
151
|
+
if (SINGULAR_NOUNS_ENDING_S.has(head)) return false;
|
|
152
|
+
return /[a-z]s$/.test(head) && !/ss$/.test(head);
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
/**
|
|
121
156
|
* How a stored predicate reads in English: a curated table hit, else the
|
|
122
157
|
* mechanical surface form of a minted predicate, else the predicate's local
|
|
@@ -124,11 +159,21 @@ export function gerundVerbSurface(verb) {
|
|
|
124
159
|
* none). The local-name tail is what keeps a namespace tmct itself mints —
|
|
125
160
|
* `tmct:needs`, stamped by the lexicon — out of a public-facing sentence.
|
|
126
161
|
*
|
|
162
|
+
* `subject` is optional and used ONLY by the minted "mgx:<lemma>" verb fold
|
|
163
|
+
* (and the do-support it derives under negation): a plural subject reads the
|
|
164
|
+
* lemma's bare form ("scientists report"), anything else reads its
|
|
165
|
+
* third-person-singular fold ("an earthquake strikes"). A CURATED phrase is
|
|
166
|
+
* returned exactly as written regardless of subject — the table is fixed
|
|
167
|
+
* English on purpose, no morphology applied to it here or anywhere else.
|
|
168
|
+
* Callers with no subject to offer (most of chat.mjs's call sites, which
|
|
169
|
+
* render a bare predicate with no sentence around it) get today's singular
|
|
170
|
+
* default, unchanged.
|
|
171
|
+
*
|
|
127
172
|
* Self-contained on purpose: the news page runs this exact function in the
|
|
128
|
-
* browser (phraseRendererSource below stringifies it and
|
|
129
|
-
* it reaches for nothing outside this module.
|
|
173
|
+
* browser (phraseRendererSource below stringifies it and everything it calls
|
|
174
|
+
* into), so it reaches for nothing outside this module.
|
|
130
175
|
*/
|
|
131
|
-
export function predicatePhrase(predicate) {
|
|
176
|
+
export function predicatePhrase(predicate, subject) {
|
|
132
177
|
if (FACT_PREDICATE_PHRASES[predicate]) return FACT_PREDICATE_PHRASES[predicate];
|
|
133
178
|
const p = String(predicate ?? "");
|
|
134
179
|
// NEGATIVE polarity renders as its own positive phrase, negated — ONE branch
|
|
@@ -144,12 +189,13 @@ export function predicatePhrase(predicate) {
|
|
|
144
189
|
// for the same reason: a modal, a copula and a plain verb take different
|
|
145
190
|
// negations, and nothing else does.
|
|
146
191
|
if (p.startsWith("mgxneg:")) {
|
|
147
|
-
const phrase = predicatePhrase(`mgx:${p.slice("mgxneg:".length)}
|
|
192
|
+
const phrase = predicatePhrase(`mgx:${p.slice("mgxneg:".length)}`, subject);
|
|
148
193
|
if (phrase === "can") return "cannot";
|
|
149
194
|
if (phrase === "can be") return "cannot be";
|
|
150
195
|
if (phrase === "is" || phrase.startsWith("is ")) return `is not${phrase.slice(2)}`;
|
|
151
196
|
const [head, ...tail] = phrase.split(" ");
|
|
152
|
-
|
|
197
|
+
const doSupport = isSubjectPlural(subject) ? "do not" : "does not";
|
|
198
|
+
return [doSupport, baseVerbSurface(head), ...tail].join(" ");
|
|
153
199
|
}
|
|
154
200
|
// a comparative renders as its copula surface: mgx:smaller-than ->
|
|
155
201
|
// "is smaller than" (never a 3sg fold — "smallers" isn't a word)
|
|
@@ -164,15 +210,23 @@ export function predicatePhrase(predicate) {
|
|
|
164
210
|
const same = /^mgx:same-([a-z]+)-as$/i.exec(p);
|
|
165
211
|
if (same) return `has the same ${same[1].toLowerCase()} as`;
|
|
166
212
|
// a folded preposition renders back naturally: mgx:rest-on -> "rests on"
|
|
213
|
+
// (subject-verb agreement lives here alone: mgx:<lemma> is minted from the
|
|
214
|
+
// verb's OWN base form, so a plural subject reads it bare — "scientists
|
|
215
|
+
// report" — and anything else takes the 3sg fold — "an earthquake strikes")
|
|
167
216
|
const minted = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
|
|
168
|
-
if (minted)
|
|
217
|
+
if (minted) {
|
|
218
|
+
const verb = isSubjectPlural(subject) ? minted[1] : thirdPersonSingularSurface(minted[1]);
|
|
219
|
+
return `${verb}${minted[2] ? ` ${minted[2]}` : ""}`;
|
|
220
|
+
}
|
|
169
221
|
const colon = p.indexOf(":");
|
|
170
222
|
return colon === -1 ? p : p.slice(colon + 1);
|
|
171
223
|
}
|
|
172
224
|
|
|
173
|
-
/** "a heart has a valve" from one { subject, predicate, object } fact row
|
|
225
|
+
/** "a heart has a valve" from one { subject, predicate, object } fact row —
|
|
226
|
+
* "scientists report a finding" for a plural row.subject, off the same
|
|
227
|
+
* agreement rule in predicatePhrase above. */
|
|
174
228
|
export function factSentence(row) {
|
|
175
|
-
return `${row.subject} ${predicatePhrase(row.predicate)} ${row.object}`;
|
|
229
|
+
return `${row.subject} ${predicatePhrase(row.predicate, row.subject)} ${row.object}`;
|
|
176
230
|
}
|
|
177
231
|
|
|
178
232
|
/**
|
|
@@ -224,6 +278,9 @@ export function phraseRendererSource() {
|
|
|
224
278
|
`const TEACH_PARTICIPLE_SRC = ${JSON.stringify(TEACH_PARTICIPLE_SRC)};`,
|
|
225
279
|
`const thirdPersonSingularSurface = ${thirdPersonSingularSurface};`,
|
|
226
280
|
`const baseVerbSurface = ${baseVerbSurface};`,
|
|
281
|
+
`const IRREGULAR_PLURAL_NOUNS = new Set(${JSON.stringify([...IRREGULAR_PLURAL_NOUNS])});`,
|
|
282
|
+
`const SINGULAR_NOUNS_ENDING_S = new Set(${JSON.stringify([...SINGULAR_NOUNS_ENDING_S])});`,
|
|
283
|
+
`const isSubjectPlural = ${isSubjectPlural};`,
|
|
227
284
|
`const predicatePhrase = ${predicatePhrase};`,
|
|
228
285
|
`const factSentence = ${factSentence};`,
|
|
229
286
|
].join("\n ");
|
package/src/domain/news-feed.mjs
CHANGED
|
@@ -71,6 +71,51 @@ export function newsWindowRows(rows, { now, windowMs }) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Item identity: what makes two fetched snapshots the same newsworthy item.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
// Everything a source can respell between two readings of one article —
|
|
79
|
+
// punctuation, capitalisation, entity escapes already stripped upstream, run
|
|
80
|
+
// of spaces — folds away, so the key answers to the item's words alone.
|
|
81
|
+
const CONTENT_KEY_NOISE_RE = /[^a-z0-9]+/g;
|
|
82
|
+
|
|
83
|
+
function itemContentText(snapshot) {
|
|
84
|
+
return `${snapshot?.title ?? ""} ${snapshot?.summary ?? ""}`
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(CONTENT_KEY_NOISE_RE, " ")
|
|
87
|
+
.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The content key one snapshot answers to, or "" when it carries no words:
|
|
91
|
+
* its source, its own publication stamp and its normalized text. The key a
|
|
92
|
+
* source with no stable id of its own de-dupes on, and the second chance at
|
|
93
|
+
* recognising an article a source re-issued under a fresh id. The publication
|
|
94
|
+
* stamp stays in so two genuinely different events that happen to share a
|
|
95
|
+
* headline — two quakes of the same size near the same town — keep separate
|
|
96
|
+
* keys. Pure. */
|
|
97
|
+
export function newsItemContentKey(snapshot) {
|
|
98
|
+
const text = itemContentText(snapshot);
|
|
99
|
+
if (!text) return "";
|
|
100
|
+
const sourceId = String(snapshot?.sourceId ?? "");
|
|
101
|
+
const publishedAt = String(snapshot?.publishedAt ?? "");
|
|
102
|
+
return `news-text:${sha256HexPrefix(`${sourceId}\0${publishedAt}\0${text}`, 8)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every key a fetched snapshot is the same item under: the id the fetcher
|
|
106
|
+
* minted from the source's own identifier (Hacker News story id, USGS event
|
|
107
|
+
* id, an RSS guid, a Wikinews page id, a Wikimedia article title) and its
|
|
108
|
+
* content key. Two snapshots sharing any key name one item. Pure — a function
|
|
109
|
+
* of the snapshot's own fields, never of when or in what order it arrived. */
|
|
110
|
+
export function newsItemKeys(snapshot) {
|
|
111
|
+
const keys = [];
|
|
112
|
+
const id = String(snapshot?.id ?? "");
|
|
113
|
+
if (id) keys.push(id);
|
|
114
|
+
const contentKey = newsItemContentKey(snapshot);
|
|
115
|
+
if (contentKey) keys.push(contentKey);
|
|
116
|
+
return keys;
|
|
117
|
+
}
|
|
118
|
+
|
|
74
119
|
// ---------------------------------------------------------------------------
|
|
75
120
|
// The newsworthiness gate (PLAN_NEWS_FEED.md section 17). A card reports what
|
|
76
121
|
// a contemporary source said inside the window; everything the graph looked
|
|
@@ -503,7 +548,11 @@ function collectSources(subgraphRows, sourcesByFactId) {
|
|
|
503
548
|
const key = src.url || src.title || "";
|
|
504
549
|
if (!key || seen.has(key)) continue;
|
|
505
550
|
seen.add(key);
|
|
506
|
-
|
|
551
|
+
const entry = { title: src.title || "", url: src.url || "", name: src.name || "" };
|
|
552
|
+
// Carried through only when the source snapshot actually has one — a
|
|
553
|
+
// card for an undated snapshot shows no date rather than a blank field.
|
|
554
|
+
if (src.publishedAt) entry.publishedAt = src.publishedAt;
|
|
555
|
+
sources.push(entry);
|
|
507
556
|
}
|
|
508
557
|
return sources;
|
|
509
558
|
}
|
|
@@ -580,7 +629,7 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
|
|
|
580
629
|
.map((r) => r.object)
|
|
581
630
|
.sort();
|
|
582
631
|
if (!objects.length) continue;
|
|
583
|
-
sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinObjects(objects)}`);
|
|
632
|
+
sentences.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
|
|
584
633
|
}
|
|
585
634
|
|
|
586
635
|
// A hub that only ever appears as an OBJECT — the place a quake struck, the
|
|
@@ -612,8 +661,9 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
|
|
|
612
661
|
|
|
613
662
|
/** newsworthyHubs -> one item per hub (PLAN_NEWS_FEED.md section 6.6),
|
|
614
663
|
* paragraph included, sorted builtAt desc then id asc. `sourcesByFactId`
|
|
615
|
-
* maps fact ids to snapshot source links ({ title, url, name
|
|
616
|
-
*
|
|
664
|
+
* maps fact ids to snapshot source links ({ title, url, name, publishedAt?
|
|
665
|
+
* }); publishedAt is present only when the source snapshot carried one.
|
|
666
|
+
* The gate (PLAN_NEWS_FEED.md section 17): `reportedRows` replaces `newsWindowRows`
|
|
617
667
|
* and `newsworthyHubs` replaces `scoreHubs` as this function's own inputs —
|
|
618
668
|
* both keep their prior behaviour for every other caller. Each item's
|
|
619
669
|
* two-hop sub-graph then splits into its own `reported`/`background` rows,
|
|
@@ -31,8 +31,42 @@ const FUNCTION_WORD_TERMS = new Set([
|
|
|
31
31
|
"usually", "indeed", "however", "therefore", "thus", "hence", "meanwhile",
|
|
32
32
|
]);
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// A bare measurement-unit abbreviation names a quantity, not a subject —
|
|
35
|
+
// "10 km SSW of Ridgecrest" leaks "km" as a standalone term. Closed set of
|
|
36
|
+
// the abbreviations USGS/wire-service distance, weight and speed reports
|
|
37
|
+
// actually use standalone.
|
|
38
|
+
const MEASUREMENT_UNIT_TERMS = new Set([
|
|
39
|
+
"km", "kms", "m", "mi", "mis", "ft", "kg", "kgs", "mph", "kmh", "kph",
|
|
40
|
+
"cm", "mm", "nm", "yd", "yds", "lb", "lbs", "oz",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// A compass abbreviation locates something relative to a place; it never
|
|
44
|
+
// names the place itself — "10 km SSW of Ridgecrest" leaks "ssw" the same
|
|
45
|
+
// way it leaks "km". Closed set: the 16 standard compass-rose points.
|
|
46
|
+
const COMPASS_ABBREVIATION_TERMS = new Set([
|
|
47
|
+
"n", "s", "e", "w",
|
|
48
|
+
"ne", "nw", "se", "sw",
|
|
49
|
+
"nne", "ene", "ese", "sse", "ssw", "wsw", "wnw", "nnw",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// A bare foreign article/preposition arrives only as a fragment of a title
|
|
53
|
+
// that never got tokenized as a whole name ("Tour de France Femmes" leaking
|
|
54
|
+
// "de"). Closed set, standalone only — this never matches "de" glued inside
|
|
55
|
+
// a multi-word term like "tour de france", since bumpTerms and
|
|
56
|
+
// ledgerFromPayload both key on the full normalized term, not its words.
|
|
57
|
+
const FOREIGN_PARTICLE_TERMS = new Set([
|
|
58
|
+
"de", "la", "le", "du", "der", "von", "van", "di", "el",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const NOISE_TERM_SETS = [
|
|
62
|
+
FUNCTION_WORD_TERMS,
|
|
63
|
+
MEASUREMENT_UNIT_TERMS,
|
|
64
|
+
COMPASS_ABBREVIATION_TERMS,
|
|
65
|
+
FOREIGN_PARTICLE_TERMS,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function isNoiseTerm(term) {
|
|
69
|
+
return NOISE_TERM_SETS.some((set) => set.has(term));
|
|
36
70
|
}
|
|
37
71
|
|
|
38
72
|
/** Ledger entry field order fixed once here so `ledgerPayload` serializes
|
|
@@ -62,7 +96,7 @@ export function createTermLedger() {
|
|
|
62
96
|
export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
|
|
63
97
|
for (const [rawTerm, occurrences] of termCounts) {
|
|
64
98
|
const term = normFactTerm(rawTerm);
|
|
65
|
-
if (!term ||
|
|
99
|
+
if (!term || isNoiseTerm(term)) continue;
|
|
66
100
|
let entry = ledger.terms.get(term);
|
|
67
101
|
if (!entry) {
|
|
68
102
|
const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
|
|
@@ -139,7 +173,7 @@ export function ledgerPayload(ledger) {
|
|
|
139
173
|
export function ledgerFromPayload(payload) {
|
|
140
174
|
const ledger = createTermLedger();
|
|
141
175
|
for (const entry of payload?.terms ?? []) {
|
|
142
|
-
if (
|
|
176
|
+
if (isNoiseTerm(entry.term)) continue;
|
|
143
177
|
ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
|
|
144
178
|
}
|
|
145
179
|
return ledger;
|
|
@@ -151,6 +151,7 @@ ${THEME_TOKENS_CSS}
|
|
|
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
153
|
.item .sources-links { font-size: .74rem; color: var(--muted); }
|
|
154
|
+
.item .sourcedate { font-family: ${MONO_STACK}; }
|
|
154
155
|
.item details.facts summary { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--corpus); cursor: pointer; }
|
|
155
156
|
.item details.background summary { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); cursor: pointer; }
|
|
156
157
|
.item details.background p { margin: .35rem 0 0; color: var(--muted); }
|
|
@@ -482,12 +483,29 @@ ${NEWS_STYLE}
|
|
|
482
483
|
});
|
|
483
484
|
}
|
|
484
485
|
|
|
486
|
+
// The earliest publication date among a card's sources — a lexicographic
|
|
487
|
+
// min over ISO-8601 UTC strings sorts chronologically without parsing, and
|
|
488
|
+
// "earliest" reads as when the reported event actually happened rather
|
|
489
|
+
// than whichever source snapshot the subgraph walk reached last. A source
|
|
490
|
+
// with no publishedAt (its feed never carries one) never contributes here,
|
|
491
|
+
// so a card built entirely from undated sources shows no date at all.
|
|
492
|
+
function earliestSourceDate(sources) {
|
|
493
|
+
let earliest = null;
|
|
494
|
+
for (const s of sources || []) {
|
|
495
|
+
if (!s.publishedAt) continue;
|
|
496
|
+
if (earliest === null || s.publishedAt < earliest) earliest = s.publishedAt;
|
|
497
|
+
}
|
|
498
|
+
return earliest;
|
|
499
|
+
}
|
|
500
|
+
|
|
485
501
|
function cardHtml(item) {
|
|
486
502
|
const factLines = item.factLines || [];
|
|
487
503
|
const factsHtml = factLines.map(function (line) { return '<div class="factrow">' + esc(line) + '</div>'; }).join("");
|
|
488
504
|
const moreCount = (item.factCount || 0) - factLines.length;
|
|
489
505
|
const moreHtml = moreCount > 0 ? '<div class="factrow factmore">…and ' + moreCount + ' more</div>' : "";
|
|
490
506
|
const sourcesText = (item.sources || []).map(function (s) { return esc(s.title || s.url || ""); }).filter(Boolean).join(", ");
|
|
507
|
+
const sourceDate = earliestSourceDate(item.sources);
|
|
508
|
+
const dateText = sourceDate ? ' <span class="sourcedate">(' + esc(sourceDate.slice(0, 10)) + ')</span>' : "";
|
|
491
509
|
const background = item.backgroundParagraph
|
|
492
510
|
? '<details class="background"><summary>what the graph already knew</summary><p>' + esc(item.backgroundParagraph) + '</p></details>'
|
|
493
511
|
: "";
|
|
@@ -496,7 +514,7 @@ ${NEWS_STYLE}
|
|
|
496
514
|
+ '<span class="hub">' + esc(item.hub) + '</span><span class="tier">' + esc(item.tier || "unranked") + '</span>' + newTag
|
|
497
515
|
+ '<p class="paragraph">' + esc(item.paragraph) + '</p>'
|
|
498
516
|
+ background
|
|
499
|
-
+ (sourcesText ? '<p class="sources-links">sources: ' + sourcesText + '</p>' : "")
|
|
517
|
+
+ (sourcesText ? '<p class="sources-links">sources: ' + sourcesText + dateText + '</p>' : "")
|
|
500
518
|
+ '<details class="facts"><summary>' + (item.factCount || 0) + ' fact' + (item.factCount === 1 ? "" : "s") + '</summary>' + factsHtml + moreHtml + '</details>'
|
|
501
519
|
+ '</div>';
|
|
502
520
|
}
|
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,
|
|
@@ -288,6 +288,10 @@ function buildSourcesByFactId(items) {
|
|
|
288
288
|
for (const snap of items || []) {
|
|
289
289
|
const record = recordsById.get(snap.sourceId);
|
|
290
290
|
const src = { title: snap.title || "", url: snap.url || "", name: record?.name || snap.sourceId || "" };
|
|
291
|
+
// A snapshot with no publication timestamp (a source whose own feed never
|
|
292
|
+
// carries one) leaves the key off entirely — never an invented or blank
|
|
293
|
+
// date, and never the fetch's own clock standing in for it.
|
|
294
|
+
if (snap.publishedAt) src.publishedAt = snap.publishedAt;
|
|
291
295
|
for (const factId of snap.factIds || []) map.set(factId, src);
|
|
292
296
|
}
|
|
293
297
|
return map;
|
|
@@ -473,38 +477,90 @@ function recordSuccess(health, nowVal, status) {
|
|
|
473
477
|
health.autoDisabled = false;
|
|
474
478
|
}
|
|
475
479
|
|
|
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
480
|
const fetchedAtMs = (snapshot) => {
|
|
499
481
|
const ms = toMs(snapshot?.fetchedAt);
|
|
500
482
|
return Number.isFinite(ms) ? ms : 0;
|
|
501
483
|
};
|
|
502
484
|
|
|
503
|
-
/** True once a snapshot has been through a grounding round.
|
|
504
|
-
*
|
|
505
|
-
*
|
|
485
|
+
/** True once a snapshot has been through a grounding round. A snapshot is
|
|
486
|
+
* filed the moment it arrives, so "known" and "grounded" are two different
|
|
487
|
+
* states and only this one means the facts landed. */
|
|
506
488
|
const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
|
|
507
489
|
|
|
490
|
+
const byIdAscending = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
491
|
+
const byFetchedAtThenId = (a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || byIdAscending(a, b);
|
|
492
|
+
// The item cap's drop order: a snapshot already read is the first to go, since
|
|
493
|
+
// its facts are in the graph and its keys are remembered, while one still
|
|
494
|
+
// waiting to be read would lose its facts for good.
|
|
495
|
+
const alreadyReadFirst = (a, b) => (isGroundedSnapshot(b) - isGroundedSnapshot(a)) || byFetchedAtThenId(a, b);
|
|
496
|
+
|
|
497
|
+
// How many item keys the de-dupe memory carries. Each grounded item files two
|
|
498
|
+
// (its source id and its content key), so this remembers roughly a thousand
|
|
499
|
+
// articles — many times the item window itself, which is the point: the window
|
|
500
|
+
// forgets an article as soon as newer ones crowd it out, and without this the
|
|
501
|
+
// next poll would read that same article as brand new.
|
|
502
|
+
const SEEN_ITEM_KEY_CAP = 2000;
|
|
503
|
+
|
|
504
|
+
const seenEntries = (seen) => (Array.isArray(seen) ? seen : []);
|
|
505
|
+
|
|
506
|
+
/** Every key `snapshots` and `seen` between them name, newest first and capped
|
|
507
|
+
* — an entry's `at` is the snapshot's own fetchedAt, never a fresh clock
|
|
508
|
+
* reading. Ordered off the entries themselves, so the same items produce the
|
|
509
|
+
* same memory whatever order they arrived in. */
|
|
510
|
+
function rememberItemKeys(seen, snapshots) {
|
|
511
|
+
const atByKey = new Map();
|
|
512
|
+
const remember = (key, at) => {
|
|
513
|
+
if (!key) return;
|
|
514
|
+
const previous = atByKey.get(key);
|
|
515
|
+
atByKey.set(key, previous === undefined ? at : Math.max(previous, at));
|
|
516
|
+
};
|
|
517
|
+
for (const entry of seenEntries(seen)) {
|
|
518
|
+
const at = Number(entry?.at);
|
|
519
|
+
remember(String(entry?.key ?? ""), Number.isFinite(at) ? at : 0);
|
|
520
|
+
}
|
|
521
|
+
for (const snap of snapshots || []) {
|
|
522
|
+
for (const key of newsItemKeys(snap)) remember(key, fetchedAtMs(snap));
|
|
523
|
+
}
|
|
524
|
+
return [...atByKey.entries()]
|
|
525
|
+
.map(([key, at]) => ({ key, at }))
|
|
526
|
+
.sort((a, b) => (b.at - a.at) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
527
|
+
.slice(0, SEEN_ITEM_KEY_CAP);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Merges `incoming` snapshots into `existing` by item identity: a snapshot
|
|
531
|
+
* whose id or content key already sits in the window, or in the `seen` memory
|
|
532
|
+
* of what has been grounded, is neither re-added nor re-ingested. Two
|
|
533
|
+
* incoming snapshots naming one item collapse to the lower id, so the same
|
|
534
|
+
* fetch read in two orders admits the same snapshot. The merged list sorts by
|
|
535
|
+
* fetchedAt then id, so the window is a function of which items are in it
|
|
536
|
+
* rather than of when each arrived, and `cap` then drops the snapshots that
|
|
537
|
+
* have already been read before any that still have facts to contribute.
|
|
538
|
+
* Returns the merged list and the genuinely new snapshots the caller still
|
|
539
|
+
* needs to ingest. */
|
|
540
|
+
function mergeSnapshots(existing, incoming, { cap, seen } = {}) {
|
|
541
|
+
const known = new Set();
|
|
542
|
+
for (const snap of existing || []) for (const key of newsItemKeys(snap)) known.add(key);
|
|
543
|
+
for (const entry of seenEntries(seen)) if (entry?.key) known.add(String(entry.key));
|
|
544
|
+
|
|
545
|
+
const claimed = new Set();
|
|
546
|
+
const admitted = new Set();
|
|
547
|
+
for (const snap of [...(incoming || [])].sort(byIdAscending)) {
|
|
548
|
+
const keys = newsItemKeys(snap);
|
|
549
|
+
if (!keys.length) continue;
|
|
550
|
+
if (keys.some((key) => known.has(key) || claimed.has(key))) continue;
|
|
551
|
+
for (const key of keys) claimed.add(key);
|
|
552
|
+
admitted.add(snap);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const added = (incoming || []).filter((snap) => admitted.has(snap));
|
|
556
|
+
const items = [...(existing || []), ...added].sort(byFetchedAtThenId);
|
|
557
|
+
if (items.length <= cap) return { items, added };
|
|
558
|
+
const dropped = new Set(
|
|
559
|
+
items.slice().sort(alreadyReadFirst).slice(0, items.length - cap).map((snap) => snap.id),
|
|
560
|
+
);
|
|
561
|
+
return { items: items.filter((snap) => !dropped.has(snap.id)), added };
|
|
562
|
+
}
|
|
563
|
+
|
|
508
564
|
/** One source's fetched-but-not-yet-grounded snapshots, oldest first. A cycle
|
|
509
565
|
* that ran out of time leaves its backlog here, so the next cycle works
|
|
510
566
|
* through that before anything newer and the same article is never ingested
|
|
@@ -513,7 +569,7 @@ const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
|
|
|
513
569
|
function pendingSnapshotsFor(items, sourceId) {
|
|
514
570
|
return (items || [])
|
|
515
571
|
.filter((snap) => snap?.sourceId === sourceId && !isGroundedSnapshot(snap))
|
|
516
|
-
.sort(
|
|
572
|
+
.sort(byFetchedAtThenId);
|
|
517
573
|
}
|
|
518
574
|
|
|
519
575
|
function emptyCycleAccumulator(at) {
|
|
@@ -575,7 +631,9 @@ export async function pollNewsSources(ctx) {
|
|
|
575
631
|
recordSuccess(health, nowVal, "not-modified");
|
|
576
632
|
} else {
|
|
577
633
|
recordSuccess(health, nowVal, "ok");
|
|
578
|
-
const merged =
|
|
634
|
+
const merged = mergeSnapshots(state.items, result.items, {
|
|
635
|
+
cap: config.itemCap, seen: state.seenItemKeys,
|
|
636
|
+
});
|
|
579
637
|
state.items = merged.items;
|
|
580
638
|
added = merged.added;
|
|
581
639
|
newItemsTotal += added.length;
|
|
@@ -614,6 +672,15 @@ export async function pollNewsSources(ctx) {
|
|
|
614
672
|
if (aborted) break;
|
|
615
673
|
}
|
|
616
674
|
|
|
675
|
+
// Only a GROUNDED snapshot is remembered. One the item cap dropped before it
|
|
676
|
+
// was ever read still has its facts to contribute, so the next poll is meant
|
|
677
|
+
// to pick it up again; one whose facts already landed must never be read a
|
|
678
|
+
// second time, however long ago the window forgot it.
|
|
679
|
+
state.seenItemKeys = rememberItemKeys(
|
|
680
|
+
state.seenItemKeys,
|
|
681
|
+
(state.items || []).filter(isGroundedSnapshot),
|
|
682
|
+
);
|
|
683
|
+
|
|
617
684
|
const memory = await store.loadMemory(memoryDir);
|
|
618
685
|
const rows = store.readFactRows(memory);
|
|
619
686
|
const evictIds = evictNewsFacts(rows, { cap: config.newsFactCap });
|
|
@@ -998,7 +1065,7 @@ export function cycleMetrics(before, after, { source } = {}) {
|
|
|
998
1065
|
|
|
999
1066
|
export function createNewsState() {
|
|
1000
1067
|
return {
|
|
1001
|
-
items: [], ledger: ledgerPayload(createTermLedger()), health: [],
|
|
1002
|
-
lastPollAt: "", lastEnrichAt: "",
|
|
1068
|
+
items: [], seenItemKeys: [], ledger: ledgerPayload(createTermLedger()), health: [],
|
|
1069
|
+
requestLog: [], metrics: [], lastPollAt: "", lastEnrichAt: "",
|
|
1003
1070
|
};
|
|
1004
1071
|
}
|