@polycode-projects/the-mechanical-code-talker 6.0.14 → 6.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/adapters/corpus/news-sources.mjs +59 -4
- package/src/domain/fact-phrase.mjs +65 -8
- package/src/domain/news-feed.mjs +119 -10
- package/src/domain/term-ledger.mjs +63 -1
- package/src/services/extract-facts.mjs +29 -0
- package/src/services/news.mjs +98 -35
- package/src/surfaces/web/memory-ask-browser.bundle.js +74 -74
package/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.16",
|
|
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",
|
|
@@ -298,6 +298,30 @@ async function fetchWikimediaFeed(record, gate, { now }) {
|
|
|
298
298
|
return { items, bytes: jsonByteLength(body) };
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// A Hacker News item is a headline and nothing else — the API carries no
|
|
302
|
+
// summary field to fetch — and a headline is rarely a sentence, so nothing in
|
|
303
|
+
// the item grounds on its own. The fixed sentence below states what the site
|
|
304
|
+
// itself did with the story, and it names the headline IN QUOTES: quoted, the
|
|
305
|
+
// headline's own words can never be re-read as a claim of their own, so "Let's
|
|
306
|
+
// take apart your phone" stays a story Hacker News discusses instead of
|
|
307
|
+
// becoming a fact about Hacker News taking a phone apart.
|
|
308
|
+
//
|
|
309
|
+
// "Hackernews" is one word here on purpose. A two-word proper name in subject
|
|
310
|
+
// position reads as noun + verb ("Hacker" doing "News"), and the fact that
|
|
311
|
+
// falls out of that is nonsense.
|
|
312
|
+
const HACKER_NEWS_TITLE_PREFIX_RE = /^(?:show|ask|tell)\s+hn:\s*/i;
|
|
313
|
+
// Past this many words the stored term is a clause, not a name, and the
|
|
314
|
+
// recognizer turns it down — so the sentence is not built at all rather than
|
|
315
|
+
// stored half-read.
|
|
316
|
+
const HACKER_NEWS_HEADLINE_MAX_WORDS = 6;
|
|
317
|
+
|
|
318
|
+
function hackerNewsSummary(title) {
|
|
319
|
+
const headline = String(title || "").replace(HACKER_NEWS_TITLE_PREFIX_RE, "").trim();
|
|
320
|
+
if (!headline || headline.includes(":")) return "";
|
|
321
|
+
if (headline.split(/\s+/).length > HACKER_NEWS_HEADLINE_MAX_WORDS) return "";
|
|
322
|
+
return `Hackernews discusses "${headline}".`;
|
|
323
|
+
}
|
|
324
|
+
|
|
301
325
|
/** topstories.json, then item/<id>.json for the first ten ids, through the
|
|
302
326
|
* same gate — each item fetch takes its own slot, so the ten round trips
|
|
303
327
|
* are genuinely paced by the gate's minIntervalMs rather than firing back
|
|
@@ -311,11 +335,12 @@ async function fetchHackerNews(record, gate, { now }) {
|
|
|
311
335
|
const item = await pacedFetchJson(gate, `${record.url}/item/${id}.json`);
|
|
312
336
|
if (!item) continue;
|
|
313
337
|
bytes += jsonByteLength(item);
|
|
338
|
+
const title = stripMarkup(item.title || "");
|
|
314
339
|
raw.push({
|
|
315
340
|
guid: String(item.id ?? id),
|
|
316
|
-
title
|
|
341
|
+
title,
|
|
317
342
|
url: item.url || `https://news.ycombinator.com/item?id=${item.id ?? id}`,
|
|
318
|
-
summary:
|
|
343
|
+
summary: hackerNewsSummary(title),
|
|
319
344
|
publishedAt: Number.isFinite(item.time) ? new Date(item.time * 1000).toISOString() : "",
|
|
320
345
|
});
|
|
321
346
|
}
|
|
@@ -323,6 +348,32 @@ async function fetchHackerNews(record, gate, { now }) {
|
|
|
323
348
|
return { items, bytes };
|
|
324
349
|
}
|
|
325
350
|
|
|
351
|
+
// A USGS place names a point by its distance and bearing from a settlement
|
|
352
|
+
// ("25 km ENE of Wana, Pakistan"), or by an offshore bearing ("off the west
|
|
353
|
+
// coast of Vancouver Island, Canada"), or outright ("Western Australia"). The
|
|
354
|
+
// distance and the bearing are a measurement, not a name, so they come off
|
|
355
|
+
// before the place reaches the sentence below: a number-led term can never
|
|
356
|
+
// head a feed card, and "near" already covers the few kilometres dropped.
|
|
357
|
+
const USGS_DISTANCE_PREFIX_RE = /^\s*\d+(?:\.\d+)?\s*km\s+[NSEW]{1,3}\s+of\s+/i;
|
|
358
|
+
const USGS_OFFSHORE_PREFIX_RE = /^\s*off\s+(?:the\s+)?[a-z\s]*?coast\s+of\s+/i;
|
|
359
|
+
|
|
360
|
+
function usgsPlaceName(place) {
|
|
361
|
+
return String(place || "")
|
|
362
|
+
.replace(USGS_DISTANCE_PREFIX_RE, "")
|
|
363
|
+
.replace(USGS_OFFSHORE_PREFIX_RE, "")
|
|
364
|
+
.trim();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// One fixed sentence per quake: a bare-noun subject, the verb the event did,
|
|
368
|
+
// and the named place. The magnitude stays where USGS itself put it, in the
|
|
369
|
+
// item's title — a stored fact's subject here is the CLASS "earthquake", so
|
|
370
|
+
// writing the number into this sentence would say something about earthquakes
|
|
371
|
+
// in general rather than about this one.
|
|
372
|
+
function usgsSummary(place) {
|
|
373
|
+
const name = usgsPlaceName(place);
|
|
374
|
+
return name ? `An earthquake struck near ${name}.` : "";
|
|
375
|
+
}
|
|
376
|
+
|
|
326
377
|
async function fetchUsgs(record, gate, { now }) {
|
|
327
378
|
const body = await pacedFetchJson(gate, record.url);
|
|
328
379
|
if (body === null) return null;
|
|
@@ -332,7 +383,7 @@ async function fetchUsgs(record, gate, { now }) {
|
|
|
332
383
|
guid: String(f?.id ?? f?.properties?.detail ?? f?.properties?.url ?? ""),
|
|
333
384
|
title: stripMarkup(f?.properties?.title || ""),
|
|
334
385
|
url: f?.properties?.url || "",
|
|
335
|
-
summary: stripMarkup(
|
|
386
|
+
summary: stripMarkup(usgsSummary(f?.properties?.place)),
|
|
336
387
|
publishedAt: Number.isFinite(f?.properties?.time) ? new Date(f.properties.time).toISOString() : "",
|
|
337
388
|
}));
|
|
338
389
|
const items = normalizeFeedItems(record.id, raw, { now });
|
|
@@ -347,7 +398,11 @@ function wikinewsArticleOrigin(apiUrl) {
|
|
|
347
398
|
* recentchanges list — the smallest query that names what is new without a
|
|
348
399
|
* category to maintain by hand. */
|
|
349
400
|
async function fetchWikinews(record, gate, { now }) {
|
|
350
|
-
|
|
401
|
+
// `rctype=new` is the action API's own name for "page creations only". An
|
|
402
|
+
// earlier `rcnewonly` is not a parameter the API has: it answers with an
|
|
403
|
+
// "Unrecognized parameter" warning and lists every edit, so a re-edited old
|
|
404
|
+
// article read as a new story.
|
|
405
|
+
const url = `${record.url}?action=query&list=recentchanges&rcnamespace=0&rctype=new&rclimit=20&format=json&formatversion=2&origin=*`;
|
|
351
406
|
const body = await pacedFetchJson(gate, url);
|
|
352
407
|
if (body === null) return null;
|
|
353
408
|
if (isNotModified(body)) return { items: [], bytes: 0, notModified: true };
|
|
@@ -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
|
|
@@ -240,6 +285,19 @@ const ENTITY_FRAGMENT_LEAD_WORDS = new Set([
|
|
|
240
285
|
// term; "back" alone is a fine noun.
|
|
241
286
|
const ENTITY_PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
|
|
242
287
|
|
|
288
|
+
// A pronoun points back at whatever the last clause named, so a multi-word
|
|
289
|
+
// term opening with one is a clause the split lost the subject of. A term
|
|
290
|
+
// ending in a bare auxiliary is the front half of one. Both mirror
|
|
291
|
+
// extract-facts.mjs's own lexical rules, for the same reason the sets above do.
|
|
292
|
+
const ENTITY_PRONOUN_LEAD_WORDS = new Set([
|
|
293
|
+
"i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
|
|
294
|
+
"his", "her", "its", "their", "our", "your", "my",
|
|
295
|
+
]);
|
|
296
|
+
const ENTITY_CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
|
|
297
|
+
const ENTITY_TRAILING_AUXILIARY_WORDS = new Set([
|
|
298
|
+
"is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
|
|
299
|
+
]);
|
|
300
|
+
|
|
243
301
|
/** Does `term` read as a thing's name rather than a clause fragment? Bounds
|
|
244
302
|
* the word count and rejects a leading conjunction, auxiliary or
|
|
245
303
|
* preposition (test E's condition 3, PLAN_NEWSWORTHINESS.md section 2), plus
|
|
@@ -251,7 +309,10 @@ function looksLikeEntityTerm(term) {
|
|
|
251
309
|
if (words.length > ENTITY_TERM_MAX_WORDS) return false;
|
|
252
310
|
const first = words[0].toLowerCase().replace(/^[^a-z0-9]+/, "");
|
|
253
311
|
if (!first || ENTITY_FRAGMENT_LEAD_WORDS.has(first)) return false;
|
|
254
|
-
if (words.length
|
|
312
|
+
if (words.length === 1) return true;
|
|
313
|
+
if (ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
|
|
314
|
+
if (ENTITY_PRONOUN_LEAD_WORDS.has(first.replace(ENTITY_CLITIC_SUFFIX_RE, ""))) return false;
|
|
315
|
+
if (ENTITY_TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
|
|
255
316
|
return true;
|
|
256
317
|
}
|
|
257
318
|
|
|
@@ -425,21 +486,29 @@ export function buildTermAdjacency(rows) {
|
|
|
425
486
|
}
|
|
426
487
|
|
|
427
488
|
/** Breadth-first over subject/object adjacency from `hub`, exactly `hops`
|
|
428
|
-
* levels deep, then capped
|
|
429
|
-
* depends on `rows`' own order, only
|
|
430
|
-
* actually reaches
|
|
431
|
-
|
|
489
|
+
* levels deep, then capped: a `priorityIds` row first, then the nearer hop,
|
|
490
|
+
* then content-addressed id. The cap never depends on `rows`' own order, only
|
|
491
|
+
* on which rows the hop-bounded walk actually reaches and how far out each
|
|
492
|
+
* one sits.
|
|
493
|
+
*
|
|
494
|
+
* `priorityIds` is what keeps a card about a term the graph already knows
|
|
495
|
+
* thousands of things about from being built out of an arbitrary slice of
|
|
496
|
+
* 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
|
+
export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null } = {}) {
|
|
432
499
|
const adj = adjacency ?? buildTermAdjacency(rows);
|
|
433
500
|
const hubTerm = normFactTerm(hub);
|
|
434
501
|
const visited = new Set([hubTerm]);
|
|
435
502
|
let frontier = [hubTerm];
|
|
436
503
|
const collected = new Map();
|
|
504
|
+
const hopOf = new Map();
|
|
437
505
|
for (let hop = 0; hop < hops; hop += 1) {
|
|
438
506
|
const nextFrontier = new Set();
|
|
439
507
|
for (const term of [...frontier].sort()) {
|
|
440
508
|
for (const idx of adj.byTerm.get(term) ?? []) {
|
|
441
509
|
const row = rows[idx];
|
|
442
510
|
collected.set(row.id, row);
|
|
511
|
+
if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
|
|
443
512
|
const [s, o] = adj.terms[idx];
|
|
444
513
|
if (!visited.has(s)) nextFrontier.add(s);
|
|
445
514
|
if (!visited.has(o)) nextFrontier.add(o);
|
|
@@ -448,7 +517,12 @@ export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adja
|
|
|
448
517
|
for (const term of nextFrontier) visited.add(term);
|
|
449
518
|
frontier = [...nextFrontier].sort();
|
|
450
519
|
}
|
|
451
|
-
|
|
520
|
+
const isPriority = (id) => (priorityIds instanceof Set ? priorityIds.has(id) : Boolean(priorityIds?.includes?.(id)));
|
|
521
|
+
return [...collected.values()]
|
|
522
|
+
.sort((a, b) => (isPriority(b.id) - isPriority(a.id))
|
|
523
|
+
|| (hopOf.get(a.id) - hopOf.get(b.id))
|
|
524
|
+
|| byId(a, b))
|
|
525
|
+
.slice(0, cap);
|
|
452
526
|
}
|
|
453
527
|
|
|
454
528
|
/** The strongest prior kind among `rows`, for the item's trust chip — read
|
|
@@ -487,6 +561,28 @@ function joinWithAnd(items) {
|
|
|
487
561
|
|
|
488
562
|
const IDENTITY_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
|
|
489
563
|
const SENTENCE_CAP = 5;
|
|
564
|
+
// How many objects one sentence names before it counts the rest. A live source
|
|
565
|
+
// reports the same relation over and over inside one window — every quake of
|
|
566
|
+
// the day strikes near somewhere — and an unbounded list turns a card into a
|
|
567
|
+
// wall of text.
|
|
568
|
+
const OBJECTS_PER_SENTENCE = 6;
|
|
569
|
+
|
|
570
|
+
function joinObjects(objects) {
|
|
571
|
+
if (objects.length <= OBJECTS_PER_SENTENCE) return joinWithAnd(objects);
|
|
572
|
+
const shown = objects.slice(0, OBJECTS_PER_SENTENCE);
|
|
573
|
+
return `${shown.join(", ")} and ${objects.length - OBJECTS_PER_SENTENCE} more`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The predicates `rows` carry, curated-table order first and then whatever
|
|
577
|
+
* is left, sorted. A relation minted from a source's own verb ("mgx:hit",
|
|
578
|
+
* "mgx:strike-near") has no curated entry, and reading the table alone left
|
|
579
|
+
* every card built from live headlines with an empty paragraph. */
|
|
580
|
+
function predicatesInRenderOrder(rows) {
|
|
581
|
+
const present = new Set(rows.map((r) => r.predicate));
|
|
582
|
+
const curated = Object.keys(FACT_PREDICATE_PHRASES).filter((predicate) => present.has(predicate));
|
|
583
|
+
const rest = [...present].filter((predicate) => !Object.hasOwn(FACT_PREDICATE_PHRASES, predicate)).sort();
|
|
584
|
+
return [...curated, ...rest];
|
|
585
|
+
}
|
|
490
586
|
|
|
491
587
|
/** The fixed five-sentence paraphrase template (PLAN_NEWS_FEED.md section
|
|
492
588
|
* 8.3): identity first, then the hub's own relations grouped by predicate in
|
|
@@ -519,17 +615,30 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
|
|
|
519
615
|
.sort();
|
|
520
616
|
if (identityObjects.length) {
|
|
521
617
|
const withArticles = identityObjects.map((object) => `${articleFor(object)} ${object}`);
|
|
522
|
-
sentences.push(`${hub} is ${
|
|
618
|
+
sentences.push(`${hub} is ${joinObjects(withArticles)}`);
|
|
523
619
|
}
|
|
524
620
|
|
|
525
|
-
for (const predicate of
|
|
621
|
+
for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
|
|
526
622
|
if (IDENTITY_PREDICATES.has(predicate) || sentences.length >= SENTENCE_CAP) continue;
|
|
527
623
|
const objects = reportedHubRows
|
|
528
624
|
.filter((r) => r.predicate === predicate)
|
|
529
625
|
.map((r) => r.object)
|
|
530
626
|
.sort();
|
|
531
627
|
if (!objects.length) continue;
|
|
532
|
-
sentences.push(`${hub} ${predicatePhrase(predicate)} ${
|
|
628
|
+
sentences.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// A hub that only ever appears as an OBJECT — the place a quake struck, the
|
|
632
|
+
// story a site discussed — has no subject-side row to build a sentence from,
|
|
633
|
+
// and its card came out blank. What was reported about it still says
|
|
634
|
+
// something, so those rows render whole, subject and all.
|
|
635
|
+
if (!sentences.length) {
|
|
636
|
+
const aboutHub = subgraphRows
|
|
637
|
+
.filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
|
|
638
|
+
.sort(byId)
|
|
639
|
+
.slice(0, OBJECTS_PER_SENTENCE)
|
|
640
|
+
.map((r) => factSentence(r));
|
|
641
|
+
if (aboutHub.length) sentences.push(aboutHub.join("; "));
|
|
533
642
|
}
|
|
534
643
|
|
|
535
644
|
if (sentences.length < SENTENCE_CAP && secondHopRows.length) {
|
|
@@ -564,7 +673,7 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
|
|
|
564
673
|
if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
|
|
565
674
|
const hubs = newsworthyHubs(rows, reported, hubOptions);
|
|
566
675
|
const items = hubs.map(({ term, changed }) => {
|
|
567
|
-
const subgraphRows = subgraphAround(rows, term, { adjacency });
|
|
676
|
+
const subgraphRows = subgraphAround(rows, term, { adjacency, priorityIds: reportedIds });
|
|
568
677
|
const factIds = subgraphRows.map((r) => r.id).sort();
|
|
569
678
|
const { background } = splitCardRows(subgraphRows, reportedIds);
|
|
570
679
|
return {
|
|
@@ -8,6 +8,67 @@ import { normFactTerm } from "./hash.mjs";
|
|
|
8
8
|
|
|
9
9
|
const ITEM_IDS_CAP = 12;
|
|
10
10
|
|
|
11
|
+
// A preposition, conjunction or degree/frequency adverb scaffolds a
|
|
12
|
+
// sentence; it never names the thing the sentence is about, so no
|
|
13
|
+
// occurrence count makes one a useful ledger term. Closed set, checked
|
|
14
|
+
// against the already-normalized (lowercased) term. bumpTerms uses this to
|
|
15
|
+
// keep a function word from ever being admitted; ledgerFromPayload uses it
|
|
16
|
+
// to drop one that reached a persisted payload before this filter existed.
|
|
17
|
+
const FUNCTION_WORD_TERMS = new Set([
|
|
18
|
+
// prepositions
|
|
19
|
+
"about", "above", "across", "after", "against", "along", "among", "around",
|
|
20
|
+
"at", "before", "behind", "below", "beneath", "beside", "between", "beyond",
|
|
21
|
+
"by", "despite", "down", "during", "except", "for", "from", "in", "into",
|
|
22
|
+
"near", "of", "off", "on", "onto", "out", "over", "since", "through",
|
|
23
|
+
"throughout", "to", "toward", "towards", "under", "underneath", "until",
|
|
24
|
+
"up", "upon", "with", "within", "without",
|
|
25
|
+
// conjunctions
|
|
26
|
+
"and", "or", "nor", "but", "so", "yet", "although", "because", "if",
|
|
27
|
+
"though", "unless", "when", "whenever", "whereas", "while", "than",
|
|
28
|
+
// degree and frequency adverbs
|
|
29
|
+
"very", "quite", "rather", "too", "just", "only", "even", "also", "still",
|
|
30
|
+
"already", "almost", "always", "never", "ever", "often", "sometimes",
|
|
31
|
+
"usually", "indeed", "however", "therefore", "thus", "hence", "meanwhile",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// 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));
|
|
70
|
+
}
|
|
71
|
+
|
|
11
72
|
/** Ledger entry field order fixed once here so `ledgerPayload` serializes
|
|
12
73
|
* byte-identically across peers regardless of insertion order elsewhere. */
|
|
13
74
|
function newEntry(term, vocabGrounded, now) {
|
|
@@ -35,7 +96,7 @@ export function createTermLedger() {
|
|
|
35
96
|
export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
|
|
36
97
|
for (const [rawTerm, occurrences] of termCounts) {
|
|
37
98
|
const term = normFactTerm(rawTerm);
|
|
38
|
-
if (!term) continue;
|
|
99
|
+
if (!term || isNoiseTerm(term)) continue;
|
|
39
100
|
let entry = ledger.terms.get(term);
|
|
40
101
|
if (!entry) {
|
|
41
102
|
const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
|
|
@@ -112,6 +173,7 @@ export function ledgerPayload(ledger) {
|
|
|
112
173
|
export function ledgerFromPayload(payload) {
|
|
113
174
|
const ledger = createTermLedger();
|
|
114
175
|
for (const entry of payload?.terms ?? []) {
|
|
176
|
+
if (isNoiseTerm(entry.term)) continue;
|
|
115
177
|
ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
|
|
116
178
|
}
|
|
117
179
|
return ledger;
|
|
@@ -673,6 +673,32 @@ const FRAGMENT_LEAD_TAGS = new Set(["VERB", "AUX", "ADP", "CCONJ", "SCONJ", "PAR
|
|
|
673
673
|
// which names nothing. Read lexically so a checkout with no wink model catches
|
|
674
674
|
// it too, and only for a multi-word term — "back" alone is a fine noun.
|
|
675
675
|
const PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
|
|
676
|
+
// A pronoun names nothing on its own — it points back at whatever the last
|
|
677
|
+
// clause named — so a multi-word term opening with one is a clause the split
|
|
678
|
+
// lost the subject of ("he's also destroyed the city's soul"), never a name. A
|
|
679
|
+
// one-word term is exempt for the same reason the particle rule exempts one:
|
|
680
|
+
// "us" is also how a headline writes the United States.
|
|
681
|
+
const PRONOUN_LEAD_WORDS = new Set([
|
|
682
|
+
"i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
|
|
683
|
+
"his", "her", "its", "their", "our", "your", "my",
|
|
684
|
+
]);
|
|
685
|
+
const CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
|
|
686
|
+
// A term ending in a bare auxiliary is the front half of a clause the split cut
|
|
687
|
+
// ("rooms were"), never the whole of a name.
|
|
688
|
+
const TRAILING_AUXILIARY_WORDS = new Set([
|
|
689
|
+
"is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
|
|
690
|
+
]);
|
|
691
|
+
// A compass word opening a place name is a modifier, not a clause lead —
|
|
692
|
+
// "north korea", "south sandwich islands". A tagger reading the LOWERCASED
|
|
693
|
+
// term has no capital left to tell the place from the direction and tags
|
|
694
|
+
// "north"/"south" as an adverb, so the POS rule below would turn every one of
|
|
695
|
+
// them down. Followed by "of" the word really is heading a prepositional
|
|
696
|
+
// phrase ("north of the border"), and that stays declined.
|
|
697
|
+
const COMPASS_LEAD_WORDS = new Set([
|
|
698
|
+
"north", "south", "east", "west",
|
|
699
|
+
"northeast", "northwest", "southeast", "southwest",
|
|
700
|
+
"northern", "southern", "eastern", "western",
|
|
701
|
+
]);
|
|
676
702
|
|
|
677
703
|
/** Does `term` read as a thing's name rather than a clause fragment? Bounds
|
|
678
704
|
* the word count and rejects a leading conjunction, auxiliary, preposition,
|
|
@@ -689,6 +715,9 @@ export function readsAsEntityTerm(term, nlp) {
|
|
|
689
715
|
if (FRAGMENT_LEAD_WORDS.has(first)) return false;
|
|
690
716
|
if (words.length === 1) return true;
|
|
691
717
|
if (PARTICLE_LEAD_WORDS.has(first)) return false;
|
|
718
|
+
if (PRONOUN_LEAD_WORDS.has(first.replace(CLITIC_SUFFIX_RE, ""))) return false;
|
|
719
|
+
if (TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
|
|
720
|
+
if (COMPASS_LEAD_WORDS.has(first) && words[1].toLowerCase() !== "of") return true;
|
|
692
721
|
const engine = nlp === undefined ? winkInstance() : nlp;
|
|
693
722
|
if (!engine) return true;
|
|
694
723
|
try {
|