@polycode-projects/the-mechanical-code-talker 6.0.14 → 6.0.15
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.15",
|
|
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.",
|
|
@@ -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 };
|
package/src/domain/news-feed.mjs
CHANGED
|
@@ -240,6 +240,19 @@ const ENTITY_FRAGMENT_LEAD_WORDS = new Set([
|
|
|
240
240
|
// term; "back" alone is a fine noun.
|
|
241
241
|
const ENTITY_PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
|
|
242
242
|
|
|
243
|
+
// A pronoun points back at whatever the last clause named, so a multi-word
|
|
244
|
+
// term opening with one is a clause the split lost the subject of. A term
|
|
245
|
+
// ending in a bare auxiliary is the front half of one. Both mirror
|
|
246
|
+
// extract-facts.mjs's own lexical rules, for the same reason the sets above do.
|
|
247
|
+
const ENTITY_PRONOUN_LEAD_WORDS = new Set([
|
|
248
|
+
"i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
|
|
249
|
+
"his", "her", "its", "their", "our", "your", "my",
|
|
250
|
+
]);
|
|
251
|
+
const ENTITY_CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
|
|
252
|
+
const ENTITY_TRAILING_AUXILIARY_WORDS = new Set([
|
|
253
|
+
"is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
|
|
254
|
+
]);
|
|
255
|
+
|
|
243
256
|
/** Does `term` read as a thing's name rather than a clause fragment? Bounds
|
|
244
257
|
* the word count and rejects a leading conjunction, auxiliary or
|
|
245
258
|
* preposition (test E's condition 3, PLAN_NEWSWORTHINESS.md section 2), plus
|
|
@@ -251,7 +264,10 @@ function looksLikeEntityTerm(term) {
|
|
|
251
264
|
if (words.length > ENTITY_TERM_MAX_WORDS) return false;
|
|
252
265
|
const first = words[0].toLowerCase().replace(/^[^a-z0-9]+/, "");
|
|
253
266
|
if (!first || ENTITY_FRAGMENT_LEAD_WORDS.has(first)) return false;
|
|
254
|
-
if (words.length
|
|
267
|
+
if (words.length === 1) return true;
|
|
268
|
+
if (ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
|
|
269
|
+
if (ENTITY_PRONOUN_LEAD_WORDS.has(first.replace(ENTITY_CLITIC_SUFFIX_RE, ""))) return false;
|
|
270
|
+
if (ENTITY_TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
|
|
255
271
|
return true;
|
|
256
272
|
}
|
|
257
273
|
|
|
@@ -425,21 +441,29 @@ export function buildTermAdjacency(rows) {
|
|
|
425
441
|
}
|
|
426
442
|
|
|
427
443
|
/** 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
|
-
|
|
444
|
+
* levels deep, then capped: a `priorityIds` row first, then the nearer hop,
|
|
445
|
+
* then content-addressed id. The cap never depends on `rows`' own order, only
|
|
446
|
+
* on which rows the hop-bounded walk actually reaches and how far out each
|
|
447
|
+
* one sits.
|
|
448
|
+
*
|
|
449
|
+
* `priorityIds` is what keeps a card about a term the graph already knows
|
|
450
|
+
* thousands of things about from being built out of an arbitrary slice of
|
|
451
|
+
* them: a hub like "france" reaches far more rows than the cap, and the one
|
|
452
|
+
* report that made it news would otherwise be the row that fell out. */
|
|
453
|
+
export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null } = {}) {
|
|
432
454
|
const adj = adjacency ?? buildTermAdjacency(rows);
|
|
433
455
|
const hubTerm = normFactTerm(hub);
|
|
434
456
|
const visited = new Set([hubTerm]);
|
|
435
457
|
let frontier = [hubTerm];
|
|
436
458
|
const collected = new Map();
|
|
459
|
+
const hopOf = new Map();
|
|
437
460
|
for (let hop = 0; hop < hops; hop += 1) {
|
|
438
461
|
const nextFrontier = new Set();
|
|
439
462
|
for (const term of [...frontier].sort()) {
|
|
440
463
|
for (const idx of adj.byTerm.get(term) ?? []) {
|
|
441
464
|
const row = rows[idx];
|
|
442
465
|
collected.set(row.id, row);
|
|
466
|
+
if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
|
|
443
467
|
const [s, o] = adj.terms[idx];
|
|
444
468
|
if (!visited.has(s)) nextFrontier.add(s);
|
|
445
469
|
if (!visited.has(o)) nextFrontier.add(o);
|
|
@@ -448,7 +472,12 @@ export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adja
|
|
|
448
472
|
for (const term of nextFrontier) visited.add(term);
|
|
449
473
|
frontier = [...nextFrontier].sort();
|
|
450
474
|
}
|
|
451
|
-
|
|
475
|
+
const isPriority = (id) => (priorityIds instanceof Set ? priorityIds.has(id) : Boolean(priorityIds?.includes?.(id)));
|
|
476
|
+
return [...collected.values()]
|
|
477
|
+
.sort((a, b) => (isPriority(b.id) - isPriority(a.id))
|
|
478
|
+
|| (hopOf.get(a.id) - hopOf.get(b.id))
|
|
479
|
+
|| byId(a, b))
|
|
480
|
+
.slice(0, cap);
|
|
452
481
|
}
|
|
453
482
|
|
|
454
483
|
/** The strongest prior kind among `rows`, for the item's trust chip — read
|
|
@@ -487,6 +516,28 @@ function joinWithAnd(items) {
|
|
|
487
516
|
|
|
488
517
|
const IDENTITY_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
|
|
489
518
|
const SENTENCE_CAP = 5;
|
|
519
|
+
// How many objects one sentence names before it counts the rest. A live source
|
|
520
|
+
// reports the same relation over and over inside one window — every quake of
|
|
521
|
+
// the day strikes near somewhere — and an unbounded list turns a card into a
|
|
522
|
+
// wall of text.
|
|
523
|
+
const OBJECTS_PER_SENTENCE = 6;
|
|
524
|
+
|
|
525
|
+
function joinObjects(objects) {
|
|
526
|
+
if (objects.length <= OBJECTS_PER_SENTENCE) return joinWithAnd(objects);
|
|
527
|
+
const shown = objects.slice(0, OBJECTS_PER_SENTENCE);
|
|
528
|
+
return `${shown.join(", ")} and ${objects.length - OBJECTS_PER_SENTENCE} more`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The predicates `rows` carry, curated-table order first and then whatever
|
|
532
|
+
* is left, sorted. A relation minted from a source's own verb ("mgx:hit",
|
|
533
|
+
* "mgx:strike-near") has no curated entry, and reading the table alone left
|
|
534
|
+
* every card built from live headlines with an empty paragraph. */
|
|
535
|
+
function predicatesInRenderOrder(rows) {
|
|
536
|
+
const present = new Set(rows.map((r) => r.predicate));
|
|
537
|
+
const curated = Object.keys(FACT_PREDICATE_PHRASES).filter((predicate) => present.has(predicate));
|
|
538
|
+
const rest = [...present].filter((predicate) => !Object.hasOwn(FACT_PREDICATE_PHRASES, predicate)).sort();
|
|
539
|
+
return [...curated, ...rest];
|
|
540
|
+
}
|
|
490
541
|
|
|
491
542
|
/** The fixed five-sentence paraphrase template (PLAN_NEWS_FEED.md section
|
|
492
543
|
* 8.3): identity first, then the hub's own relations grouped by predicate in
|
|
@@ -519,17 +570,30 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
|
|
|
519
570
|
.sort();
|
|
520
571
|
if (identityObjects.length) {
|
|
521
572
|
const withArticles = identityObjects.map((object) => `${articleFor(object)} ${object}`);
|
|
522
|
-
sentences.push(`${hub} is ${
|
|
573
|
+
sentences.push(`${hub} is ${joinObjects(withArticles)}`);
|
|
523
574
|
}
|
|
524
575
|
|
|
525
|
-
for (const predicate of
|
|
576
|
+
for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
|
|
526
577
|
if (IDENTITY_PREDICATES.has(predicate) || sentences.length >= SENTENCE_CAP) continue;
|
|
527
578
|
const objects = reportedHubRows
|
|
528
579
|
.filter((r) => r.predicate === predicate)
|
|
529
580
|
.map((r) => r.object)
|
|
530
581
|
.sort();
|
|
531
582
|
if (!objects.length) continue;
|
|
532
|
-
sentences.push(`${hub} ${predicatePhrase(predicate)} ${
|
|
583
|
+
sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinObjects(objects)}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// A hub that only ever appears as an OBJECT — the place a quake struck, the
|
|
587
|
+
// story a site discussed — has no subject-side row to build a sentence from,
|
|
588
|
+
// and its card came out blank. What was reported about it still says
|
|
589
|
+
// something, so those rows render whole, subject and all.
|
|
590
|
+
if (!sentences.length) {
|
|
591
|
+
const aboutHub = subgraphRows
|
|
592
|
+
.filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
|
|
593
|
+
.sort(byId)
|
|
594
|
+
.slice(0, OBJECTS_PER_SENTENCE)
|
|
595
|
+
.map((r) => factSentence(r));
|
|
596
|
+
if (aboutHub.length) sentences.push(aboutHub.join("; "));
|
|
533
597
|
}
|
|
534
598
|
|
|
535
599
|
if (sentences.length < SENTENCE_CAP && secondHopRows.length) {
|
|
@@ -564,7 +628,7 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
|
|
|
564
628
|
if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
|
|
565
629
|
const hubs = newsworthyHubs(rows, reported, hubOptions);
|
|
566
630
|
const items = hubs.map(({ term, changed }) => {
|
|
567
|
-
const subgraphRows = subgraphAround(rows, term, { adjacency });
|
|
631
|
+
const subgraphRows = subgraphAround(rows, term, { adjacency, priorityIds: reportedIds });
|
|
568
632
|
const factIds = subgraphRows.map((r) => r.id).sort();
|
|
569
633
|
const { background } = splitCardRows(subgraphRows, reportedIds);
|
|
570
634
|
return {
|
|
@@ -8,6 +8,33 @@ 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
|
+
function isFunctionWordTerm(term) {
|
|
35
|
+
return FUNCTION_WORD_TERMS.has(term);
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
/** Ledger entry field order fixed once here so `ledgerPayload` serializes
|
|
12
39
|
* byte-identically across peers regardless of insertion order elsewhere. */
|
|
13
40
|
function newEntry(term, vocabGrounded, now) {
|
|
@@ -35,7 +62,7 @@ export function createTermLedger() {
|
|
|
35
62
|
export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
|
|
36
63
|
for (const [rawTerm, occurrences] of termCounts) {
|
|
37
64
|
const term = normFactTerm(rawTerm);
|
|
38
|
-
if (!term) continue;
|
|
65
|
+
if (!term || isFunctionWordTerm(term)) continue;
|
|
39
66
|
let entry = ledger.terms.get(term);
|
|
40
67
|
if (!entry) {
|
|
41
68
|
const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
|
|
@@ -112,6 +139,7 @@ export function ledgerPayload(ledger) {
|
|
|
112
139
|
export function ledgerFromPayload(payload) {
|
|
113
140
|
const ledger = createTermLedger();
|
|
114
141
|
for (const entry of payload?.terms ?? []) {
|
|
142
|
+
if (isFunctionWordTerm(entry.term)) continue;
|
|
115
143
|
ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
|
|
116
144
|
}
|
|
117
145
|
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 {
|